11use std:: collections:: HashMap ;
22
33use swc_common:: { sync:: Lrc , FileName , SourceMap , Spanned } ;
4- use swc_ecma_ast:: { CallExpr , Expr , Lit , MemberExpr , MemberProp , Str } ;
4+ use swc_ecma_ast:: { CallExpr , Expr , Lit , MemberExpr , MemberProp , ObjectLit , Prop , PropName , Str } ;
55use swc_ecma_parser:: { lexer:: Lexer , Parser , StringInput , Syntax , TsSyntax } ;
66use swc_ecma_visit:: { Visit , VisitWith } ;
77use windmill_parser:: asset_parser:: {
@@ -309,6 +309,56 @@ impl Visit for AssetsFinder {
309309 }
310310}
311311
312+ /// Extract a string-literal property value from an object literal.
313+ /// Returns `Some(value)` for `{ name: "value" }`, ignoring computed,
314+ /// shorthand, spread, and non-string-literal properties.
315+ fn object_str_prop ( obj : & ObjectLit , name : & str ) -> Option < String > {
316+ for prop in & obj. props {
317+ let swc_ecma_ast:: PropOrSpread :: Prop ( p) = prop else {
318+ continue ;
319+ } ;
320+ let Prop :: KeyValue ( kv) = p. as_ref ( ) else {
321+ continue ;
322+ } ;
323+ let key = match & kv. key {
324+ PropName :: Ident ( i) => i. sym . as_str ( ) ,
325+ PropName :: Str ( s) => s. value . as_str ( ) ,
326+ _ => continue ,
327+ } ;
328+ if key != name {
329+ continue ;
330+ }
331+ if let Expr :: Lit ( Lit :: Str ( s) ) = kv. value . as_ref ( ) {
332+ return Some ( s. value . to_string ( ) ) ;
333+ }
334+ }
335+ None
336+ }
337+
338+ /// Resolve the SDK `S3Object` argument of `loadS3File`/`loadS3FileStream`/
339+ /// `writeS3File` to a canonical asset path, mirroring the runtime
340+ /// `parseS3Object`: an object `{ s3: "<key>", storage?: "<bucket>" }` maps to
341+ /// the URI `s3://<bucket>/<key>` (empty bucket for default storage, i.e.
342+ /// `s3:///<key>`), and a bare `"s3://bucket/key"` string is passed through.
343+ /// The resulting URI is fed through `parse_asset_syntax` so the stored path
344+ /// matches the `// on s3:///…` trigger form exactly.
345+ fn s3_object_arg_path ( arg : & Expr ) -> Option < String > {
346+ let uri = match arg {
347+ Expr :: Lit ( Lit :: Str ( s) ) => s. value . to_string ( ) ,
348+ Expr :: Object ( obj) => {
349+ let key = object_str_prop ( obj, "s3" ) ?;
350+ let storage = object_str_prop ( obj, "storage" ) . unwrap_or_default ( ) ;
351+ format ! ( "s3://{storage}/{key}" )
352+ }
353+ _ => return None ,
354+ } ;
355+ Some (
356+ parse_asset_syntax ( & uri, false )
357+ . map ( |( _, p) | p. to_string ( ) )
358+ . unwrap_or ( uri) ,
359+ )
360+ }
361+
312362impl AssetsFinder {
313363 fn visit_call_expr_inner ( & mut self , node : & swc_ecma_ast:: CallExpr ) -> Result < ( ) , ( ) > {
314364 let ident = match node. callee . as_expr ( ) . map ( AsRef :: as_ref) {
@@ -331,20 +381,20 @@ impl AssetsFinder {
331381
332382 let arg_value = node. args . get ( arg_pos) ;
333383
334- match arg_value. map ( |e| e. expr . as_ref ( ) ) {
335- Some ( Expr :: Lit ( Lit :: Str ( Str { value, .. } ) ) ) => {
336- let path = parse_asset_syntax ( & value, false )
337- . map ( |( _, p) | p)
338- . unwrap_or ( & value) ;
339- self . assets . push ( ParseAssetsResult {
340- kind,
341- path : path. to_string ( ) ,
342- access_type,
343- columns : None ,
344- } ) ;
345- }
384+ // S3 helpers take an `S3Object` (`{ s3, storage? }`) or an
385+ // `s3://bucket/key` string — the form every real script uses. Other
386+ // helpers take a bare resource-path string literal.
387+ let is_s3_helper = matches ! ( kind, AssetKind :: S3Object ) ;
388+
389+ let path = match arg_value. map ( |e| e. expr . as_ref ( ) ) {
390+ Some ( arg) if is_s3_helper => s3_object_arg_path ( arg) . ok_or ( ( ) ) ?,
391+ Some ( Expr :: Lit ( Lit :: Str ( Str { value, .. } ) ) ) => parse_asset_syntax ( & value, false )
392+ . map ( |( _, p) | p. to_string ( ) )
393+ . unwrap_or_else ( || value. to_string ( ) ) ,
346394 _ => return Err ( ( ) ) ,
347- }
395+ } ;
396+ self . assets
397+ . push ( ParseAssetsResult { kind, path, access_type, columns : None } ) ;
348398 Ok ( ( ) )
349399 }
350400}
@@ -375,6 +425,136 @@ mod tests {
375425 ) ;
376426 }
377427
428+ #[ test]
429+ fn test_ts_asset_parser_write_s3_object_arg ( ) {
430+ // The SDK signature is `writeS3File(s3object: S3Object, ...)` and every
431+ // real script passes the object form with a bare key. It must resolve
432+ // to the same canonical path as a `// on s3:///<key>` trigger.
433+ let input = r#"
434+ import * as wmill from "windmill-client"
435+ export async function main() {
436+ await wmill.writeS3File(
437+ { s3: "pipelines/km_real/raw_events.json" },
438+ JSON.stringify([]),
439+ undefined,
440+ "application/json"
441+ )
442+ }
443+ "# ;
444+ let s = parse_assets ( input) ;
445+ assert_eq ! (
446+ s. map( |r| r. assets) . map_err( |e| e. to_string( ) ) ,
447+ Ok ( vec![ ParseAssetsResult {
448+ kind: AssetKind :: S3Object ,
449+ path: "/pipelines/km_real/raw_events.json" . to_string( ) ,
450+ access_type: Some ( W ) ,
451+ columns: None ,
452+ } , ] )
453+ ) ;
454+ }
455+
456+ #[ test]
457+ fn test_ts_asset_parser_s3_object_with_storage ( ) {
458+ // `{ s3, storage }` maps to `s3://<storage>/<key>`, matching the
459+ // `s3://bucket/key` string form and `parseS3Object`.
460+ let input = r#"
461+ import * as wmill from "windmill-client"
462+ export async function main() {
463+ await wmill.loadS3File({ s3: "dir/in.csv", storage: "mybucket" })
464+ }
465+ "# ;
466+ let s = parse_assets ( input) ;
467+ assert_eq ! (
468+ s. map( |r| r. assets) . map_err( |e| e. to_string( ) ) ,
469+ Ok ( vec![ ParseAssetsResult {
470+ kind: AssetKind :: S3Object ,
471+ path: "mybucket/dir/in.csv" . to_string( ) ,
472+ access_type: Some ( R ) ,
473+ columns: None ,
474+ } , ] )
475+ ) ;
476+ }
477+
478+ #[ test]
479+ fn test_ts_asset_parser_multiple_s3_object_writes ( ) {
480+ // Mirrors the f/km/r_seed shape: several direct object-form writes in
481+ // main() — all four outputs must be detected.
482+ let input = r#"
483+ import * as wmill from "windmill-client"
484+ export async function main() {
485+ await wmill.writeS3File({ s3: "pipelines/km_real/raw_events.json" }, "[]")
486+ await wmill.writeS3File({ s3: "pipelines/km_real/enriched.json" }, "[]")
487+ await wmill.writeS3File({ s3: "pipelines/km_real/summary.json" }, "[]")
488+ await wmill.writeS3File({ s3: "pipelines/km_real/report.json" }, "{}")
489+ }
490+ "# ;
491+ // merge_assets returns a deterministic (path-sorted) order.
492+ let s = parse_assets ( input) ;
493+ assert_eq ! (
494+ s. map( |r| r. assets) . map_err( |e| e. to_string( ) ) ,
495+ Ok ( vec![
496+ ParseAssetsResult {
497+ kind: AssetKind :: S3Object ,
498+ path: "/pipelines/km_real/enriched.json" . to_string( ) ,
499+ access_type: Some ( W ) ,
500+ columns: None ,
501+ } ,
502+ ParseAssetsResult {
503+ kind: AssetKind :: S3Object ,
504+ path: "/pipelines/km_real/raw_events.json" . to_string( ) ,
505+ access_type: Some ( W ) ,
506+ columns: None ,
507+ } ,
508+ ParseAssetsResult {
509+ kind: AssetKind :: S3Object ,
510+ path: "/pipelines/km_real/report.json" . to_string( ) ,
511+ access_type: Some ( W ) ,
512+ columns: None ,
513+ } ,
514+ ParseAssetsResult {
515+ kind: AssetKind :: S3Object ,
516+ path: "/pipelines/km_real/summary.json" . to_string( ) ,
517+ access_type: Some ( W ) ,
518+ columns: None ,
519+ } ,
520+ ] )
521+ ) ;
522+ }
523+
524+ #[ test]
525+ fn test_ts_asset_parser_s3_object_quoted_key ( ) {
526+ let input = r#"
527+ import * as wmill from "windmill-client"
528+ export async function main() {
529+ await wmill.writeS3File({ "s3": "out.json" }, "{}")
530+ }
531+ "# ;
532+ let s = parse_assets ( input) ;
533+ assert_eq ! (
534+ s. map( |r| r. assets) . map_err( |e| e. to_string( ) ) ,
535+ Ok ( vec![ ParseAssetsResult {
536+ kind: AssetKind :: S3Object ,
537+ path: "/out.json" . to_string( ) ,
538+ access_type: Some ( W ) ,
539+ columns: None ,
540+ } , ] )
541+ ) ;
542+ }
543+
544+ #[ test]
545+ fn test_ts_asset_parser_s3_object_dynamic_key_no_false_positive ( ) {
546+ // A computed key can't be resolved statically — must yield nothing
547+ // rather than a bogus path.
548+ let input = r#"
549+ import * as wmill from "windmill-client"
550+ export async function main(name: string) {
551+ await wmill.writeS3File({ s3: `pipelines/${name}.json` }, "{}")
552+ }
553+ "# ;
554+ let s = parse_assets ( input) ;
555+ assert_eq ! ( s. map( |r| r. assets) . map_err( |e| e. to_string( ) ) , Ok ( vec![ ] ) ) ;
556+ }
557+
378558 #[ test]
379559 fn test_ts_asset_parser_unused_sql ( ) {
380560 let input = r#"
0 commit comments