Skip to main content

source_map_tauri/frontend/
swc.rs

1use std::{collections::BTreeSet, path::Path};
2
3use anyhow::{anyhow, Result};
4use swc_common::{sync::Lrc, FileName, SourceMap, Span, Spanned};
5use swc_ecma_ast::{
6    ArrowExpr, CallExpr, Callee, Decl, EsVersion, Expr, FnDecl, Function, Lit, MemberExpr,
7    MemberProp, Module, ModuleDecl, ModuleItem, NewExpr, Pat, VarDecl, VarDeclarator,
8};
9use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax};
10use swc_ecma_visit::{Visit, VisitWith};
11
12use crate::{
13    config::{normalize_path, ResolvedConfig},
14    discovery::RepoDiscovery,
15    frontend::language_for_path,
16    ids::document_id,
17    model::{ArtifactDoc, WarningDoc},
18    security::apply_artifact_security,
19};
20
21struct ExportedSymbol {
22    name: String,
23    span: Span,
24    is_async: bool,
25    invoke_key: Option<String>,
26}
27
28#[derive(Clone)]
29struct FunctionContext {
30    name: String,
31}
32
33pub fn extract_file(
34    config: &ResolvedConfig,
35    path: &Path,
36    text: &str,
37    known_hooks: &BTreeSet<String>,
38    guest_binding: bool,
39) -> Result<(Vec<ArtifactDoc>, Vec<WarningDoc>)> {
40    let cm: Lrc<SourceMap> = Default::default();
41    let file_name = FileName::Real(path.to_path_buf());
42    let fm = cm.new_source_file(file_name.into(), text.to_owned());
43    let syntax = syntax_for_path(path)?;
44    let lexer = Lexer::new(syntax, EsVersion::Es2022, StringInput::from(&*fm), None);
45    let mut parser = Parser::new_from(lexer);
46    let module = parser
47        .parse_module()
48        .map_err(|error| anyhow!("swc parse failed for {}: {error:?}", path.display()))?;
49
50    let exported = collect_exported_symbols(&module);
51    let mut artifacts = Vec::new();
52    let mut warnings = Vec::new();
53
54    for item in &exported {
55        if item.name.starts_with("use") {
56            let mut doc = base_artifact(
57                config,
58                path,
59                &cm,
60                "frontend_hook_def",
61                &item.name,
62                item.span,
63            );
64            doc.display_name = Some(format!("{} hook", item.name));
65            doc.tags = vec!["custom hook".to_owned()];
66            doc.data.insert(
67                "hook_kind".to_owned(),
68                serde_json::Value::String(classify_hook_kind(text).to_owned()),
69            );
70            doc.data.insert(
71                "requires_cleanup".to_owned(),
72                serde_json::Value::Bool(text.contains("listen(") || text.contains("once(")),
73            );
74            doc.data.insert(
75                "cleanup_present".to_owned(),
76                serde_json::Value::Bool(text.contains("return () =>") || text.contains("unlisten")),
77            );
78            apply_artifact_security(&mut doc);
79            artifacts.push(doc);
80        } else if item
81            .name
82            .chars()
83            .next()
84            .map(|ch| ch.is_uppercase())
85            .unwrap_or(false)
86        {
87            let mut doc = base_artifact(
88                config,
89                path,
90                &cm,
91                "frontend_component",
92                &item.name,
93                item.span,
94            );
95            doc.display_name = Some(format!("{} component", item.name));
96            doc.tags = vec!["component".to_owned()];
97            doc.data.insert(
98                "component".to_owned(),
99                serde_json::Value::String(item.name.clone()),
100            );
101            apply_artifact_security(&mut doc);
102            artifacts.push(doc);
103        }
104
105        if guest_binding && item.is_async {
106            if let Some(invoke_key) = item.invoke_key.as_ref() {
107                let mut doc = base_artifact(
108                    config,
109                    path,
110                    &cm,
111                    "tauri_plugin_binding",
112                    &item.name,
113                    item.span,
114                );
115                doc.display_name = Some(format!("{} plugin binding", item.name));
116                doc.data.insert(
117                    "plugin_export".to_owned(),
118                    serde_json::Value::String(item.name.clone()),
119                );
120                doc.data.insert(
121                    "invoke_key".to_owned(),
122                    serde_json::Value::String(invoke_key.to_owned()),
123                );
124                if let Some(plugin_name) = invoke_key
125                    .strip_prefix("plugin:")
126                    .and_then(|value| value.split('|').next())
127                {
128                    doc.data.insert(
129                        "plugin_name".to_owned(),
130                        serde_json::Value::String(plugin_name.to_owned()),
131                    );
132                }
133                apply_artifact_security(&mut doc);
134                artifacts.push(doc);
135            }
136        }
137    }
138
139    let mut visitor = SwcCollector {
140        config,
141        path,
142        cm,
143        known_hooks,
144        artifacts: Vec::new(),
145        warnings: Vec::new(),
146        function_stack: Vec::new(),
147    };
148    module.visit_with(&mut visitor);
149    artifacts.extend(visitor.artifacts);
150    warnings.extend(visitor.warnings);
151
152    Ok((artifacts, warnings))
153}
154
155pub fn discover_hook_names(discovery: &RepoDiscovery) -> Result<BTreeSet<String>> {
156    let mut names = BTreeSet::new();
157    for path in &discovery.frontend_files {
158        let text = std::fs::read_to_string(path)?;
159        let cm: Lrc<SourceMap> = Default::default();
160        let file_name = FileName::Real(path.to_path_buf());
161        let fm = cm.new_source_file(file_name.into(), text);
162        let syntax = syntax_for_path(path)?;
163        let lexer = Lexer::new(syntax, EsVersion::Es2022, StringInput::from(&*fm), None);
164        let mut parser = Parser::new_from(lexer);
165        if let Ok(module) = parser.parse_module() {
166            for export in collect_exported_symbols(&module) {
167                if export.name.starts_with("use") {
168                    names.insert(export.name);
169                }
170            }
171        }
172    }
173    Ok(names)
174}
175
176fn syntax_for_path(path: &Path) -> Result<Syntax> {
177    let extension = path
178        .extension()
179        .and_then(|item| item.to_str())
180        .unwrap_or("");
181    Ok(match extension {
182        "ts" => Syntax::Typescript(TsSyntax {
183            tsx: false,
184            decorators: true,
185            ..Default::default()
186        }),
187        "tsx" => Syntax::Typescript(TsSyntax {
188            tsx: true,
189            decorators: true,
190            ..Default::default()
191        }),
192        "jsx" => Syntax::Es(EsSyntax {
193            jsx: true,
194            ..Default::default()
195        }),
196        "js" | "mjs" | "cjs" => Syntax::Es(EsSyntax {
197            jsx: false,
198            ..Default::default()
199        }),
200        other => return Err(anyhow!("unsupported frontend extension {other}")),
201    })
202}
203
204fn base_artifact(
205    config: &ResolvedConfig,
206    path: &Path,
207    cm: &Lrc<SourceMap>,
208    kind: &str,
209    name: &str,
210    span: Span,
211) -> ArtifactDoc {
212    let source_path = normalize_path(&config.root, path);
213    let start = cm.lookup_char_pos(span.lo());
214    let end = cm.lookup_char_pos(span.hi());
215    ArtifactDoc {
216        id: document_id(
217            &config.repo,
218            kind,
219            Some(&source_path),
220            Some(start.line as u32),
221            Some(name),
222        ),
223        repo: config.repo.clone(),
224        kind: kind.to_owned(),
225        side: Some("frontend".to_owned()),
226        language: language_for_path(path),
227        name: Some(name.to_owned()),
228        display_name: Some(name.to_owned()),
229        source_path: Some(source_path),
230        line_start: Some(start.line as u32),
231        line_end: Some(end.line as u32),
232        column_start: Some(start.col_display as u32),
233        column_end: Some(end.col_display as u32),
234        package_name: None,
235        comments: Vec::new(),
236        tags: Vec::new(),
237        related_symbols: Vec::new(),
238        related_tests: Vec::new(),
239        risk_level: "low".to_owned(),
240        risk_reasons: Vec::new(),
241        contains_phi: false,
242        has_related_tests: false,
243        updated_at: chrono::Utc::now().to_rfc3339(),
244        data: {
245            let mut data = serde_json::Map::new();
246            data.insert(
247                "source_map_backend".to_owned(),
248                serde_json::Value::String("swc".to_owned()),
249            );
250            data
251        },
252    }
253}
254
255fn collect_exported_symbols(module: &Module) -> Vec<ExportedSymbol> {
256    let mut exports = Vec::new();
257    for item in &module.body {
258        if let ModuleItem::ModuleDecl(module_decl) = item {
259            match module_decl {
260                ModuleDecl::ExportDecl(export_decl) => match &export_decl.decl {
261                    Decl::Fn(fn_decl) => exports.push(exported_fn_decl(fn_decl)),
262                    Decl::Var(var_decl) => exports.extend(exported_var_decl(var_decl)),
263                    _ => {}
264                },
265                ModuleDecl::ExportDefaultDecl(default_decl) => {
266                    if let swc_ecma_ast::DefaultDecl::Fn(fn_expr) = &default_decl.decl {
267                        if let Some(ident) = &fn_expr.ident {
268                            exports.push(ExportedSymbol {
269                                name: ident.sym.to_string(),
270                                span: fn_expr.function.span,
271                                is_async: fn_expr.function.is_async,
272                                invoke_key: fn_expr
273                                    .function
274                                    .body
275                                    .as_ref()
276                                    .and_then(find_invoke_key_in_block_stmt),
277                            });
278                        }
279                    }
280                }
281                _ => {}
282            }
283        }
284    }
285    exports
286}
287
288fn exported_fn_decl(fn_decl: &FnDecl) -> ExportedSymbol {
289    ExportedSymbol {
290        name: fn_decl.ident.sym.to_string(),
291        span: fn_decl.function.span,
292        is_async: fn_decl.function.is_async,
293        invoke_key: fn_decl
294            .function
295            .body
296            .as_ref()
297            .and_then(find_invoke_key_in_block_stmt),
298    }
299}
300
301fn exported_var_decl(var_decl: &VarDecl) -> Vec<ExportedSymbol> {
302    var_decl
303        .decls
304        .iter()
305        .filter_map(exported_var_symbol)
306        .collect()
307}
308
309fn exported_var_symbol(decl: &VarDeclarator) -> Option<ExportedSymbol> {
310    let Pat::Ident(ident) = &decl.name else {
311        return None;
312    };
313    let name = ident.id.sym.to_string();
314    match decl.init.as_deref() {
315        Some(Expr::Arrow(arrow)) => Some(ExportedSymbol {
316            name,
317            span: arrow.span,
318            is_async: arrow.is_async,
319            invoke_key: find_invoke_key_in_arrow(arrow),
320        }),
321        Some(Expr::Fn(fn_expr)) => Some(ExportedSymbol {
322            name,
323            span: fn_expr.function.span,
324            is_async: fn_expr.function.is_async,
325            invoke_key: fn_expr
326                .function
327                .body
328                .as_ref()
329                .and_then(find_invoke_key_in_block_stmt),
330        }),
331        _ => None,
332    }
333}
334
335fn find_invoke_key_in_expr(expr: &Expr) -> Option<String> {
336    match expr {
337        Expr::Call(call) => literal_arg(&call.args).or_else(|| {
338            call.args
339                .iter()
340                .find_map(|arg| find_invoke_key_in_expr(arg.expr.as_ref()))
341        }),
342        Expr::Arrow(ArrowExpr { body, .. }) => match body.as_ref() {
343            swc_ecma_ast::BlockStmtOrExpr::Expr(inner) => find_invoke_key_in_expr(inner.as_ref()),
344            swc_ecma_ast::BlockStmtOrExpr::BlockStmt(block) => {
345                block.stmts.iter().find_map(find_invoke_key_in_stmt)
346            }
347        },
348        Expr::Await(await_expr) => find_invoke_key_in_expr(await_expr.arg.as_ref()),
349        Expr::Paren(paren) => find_invoke_key_in_expr(paren.expr.as_ref()),
350        _ => None,
351    }
352}
353
354fn find_invoke_key_in_arrow(arrow: &ArrowExpr) -> Option<String> {
355    match arrow.body.as_ref() {
356        swc_ecma_ast::BlockStmtOrExpr::Expr(expr) => find_invoke_key_in_expr(expr.as_ref()),
357        swc_ecma_ast::BlockStmtOrExpr::BlockStmt(block) => find_invoke_key_in_block_stmt(block),
358    }
359}
360
361fn find_invoke_key_in_block_stmt(block: &swc_ecma_ast::BlockStmt) -> Option<String> {
362    block.stmts.iter().find_map(find_invoke_key_in_stmt)
363}
364
365fn find_invoke_key_in_stmt(stmt: &swc_ecma_ast::Stmt) -> Option<String> {
366    match stmt {
367        swc_ecma_ast::Stmt::Expr(expr_stmt) => find_invoke_key_in_expr(expr_stmt.expr.as_ref()),
368        swc_ecma_ast::Stmt::Return(return_stmt) => return_stmt
369            .arg
370            .as_ref()
371            .and_then(|expr| find_invoke_key_in_expr(expr.as_ref())),
372        swc_ecma_ast::Stmt::Decl(swc_ecma_ast::Decl::Var(var_decl)) => {
373            var_decl.decls.iter().find_map(|decl| {
374                decl.init
375                    .as_ref()
376                    .and_then(|expr| find_invoke_key_in_expr(expr))
377            })
378        }
379        _ => None,
380    }
381}
382
383fn classify_hook_kind(text: &str) -> &'static str {
384    if text.contains("new Channel") || text.contains("Channel<") {
385        "channel_stream"
386    } else if text.contains("listen(") || text.contains("once(") {
387        "event_subscription"
388    } else if text.contains("invoke(") || text.contains("__TAURI__.invoke(") {
389        "invoke_once"
390    } else {
391        "unknown"
392    }
393}
394
395struct SwcCollector<'a> {
396    config: &'a ResolvedConfig,
397    path: &'a Path,
398    cm: Lrc<SourceMap>,
399    known_hooks: &'a BTreeSet<String>,
400    artifacts: Vec<ArtifactDoc>,
401    warnings: Vec<WarningDoc>,
402    function_stack: Vec<FunctionContext>,
403}
404
405impl SwcCollector<'_> {
406    fn current_context(&self) -> Option<&FunctionContext> {
407        self.function_stack.last()
408    }
409
410    fn push_named_function(&mut self, name: String) {
411        self.function_stack.push(FunctionContext { name });
412    }
413
414    fn pop_named_function(&mut self) {
415        let _ = self.function_stack.pop();
416    }
417
418    fn add_hook_use(&mut self, name: &str, span: Span) {
419        let mut doc = base_artifact(
420            self.config,
421            self.path,
422            &self.cm,
423            "frontend_hook_use",
424            name,
425            span,
426        );
427        if let Some(context) = self.current_context() {
428            doc.data.insert(
429                "component".to_owned(),
430                serde_json::Value::String(context.name.clone()),
431            );
432            doc.display_name = Some(format!("{} uses {}", context.name, name));
433        }
434        doc.data.insert(
435            "hook_kind".to_owned(),
436            serde_json::Value::String("unknown".to_owned()),
437        );
438        doc.data.insert(
439            "hook_def_name".to_owned(),
440            serde_json::Value::String(name.to_owned()),
441        );
442        apply_artifact_security(&mut doc);
443        self.artifacts.push(doc);
444    }
445
446    fn add_invoke(&mut self, invoke_key: &str, span: Span) {
447        let name = invoke_key
448            .split('|')
449            .next_back()
450            .unwrap_or(invoke_key)
451            .split(':')
452            .next_back()
453            .unwrap_or(invoke_key);
454        let mut doc = base_artifact(self.config, self.path, &self.cm, "tauri_invoke", name, span);
455        doc.display_name = Some(format!("invoke {}", invoke_key));
456        doc.tags = vec!["tauri invoke".to_owned()];
457        doc.data.insert(
458            "invoke_key".to_owned(),
459            serde_json::Value::String(invoke_key.to_owned()),
460        );
461        doc.data.insert(
462            "command_name".to_owned(),
463            serde_json::Value::String(name.to_owned()),
464        );
465        if let Some(context) = self.current_context() {
466            doc.data.insert(
467                "nearest_symbol".to_owned(),
468                serde_json::Value::String(context.name.clone()),
469            );
470        }
471        if let Some(plugin_name) = invoke_key
472            .strip_prefix("plugin:")
473            .and_then(|value| value.split('|').next())
474        {
475            doc.data.insert(
476                "plugin_name".to_owned(),
477                serde_json::Value::String(plugin_name.to_owned()),
478            );
479        }
480        apply_artifact_security(&mut doc);
481        self.artifacts.push(doc);
482    }
483
484    fn add_event(&mut self, kind: &str, event_name: &str, span: Span) {
485        let mut doc = base_artifact(self.config, self.path, &self.cm, kind, event_name, span);
486        doc.data.insert(
487            "event_name".to_owned(),
488            serde_json::Value::String(event_name.to_owned()),
489        );
490        doc.tags = vec!["event".to_owned()];
491        apply_artifact_security(&mut doc);
492        self.artifacts.push(doc);
493    }
494
495    fn add_channel(&mut self, channel_name: &str, span: Span) {
496        let mut doc = base_artifact(
497            self.config,
498            self.path,
499            &self.cm,
500            "tauri_channel",
501            channel_name,
502            span,
503        );
504        doc.display_name = Some(format!("Channel {}", channel_name));
505        doc.data.insert(
506            "channel_name".to_owned(),
507            serde_json::Value::String(channel_name.to_owned()),
508        );
509        apply_artifact_security(&mut doc);
510        self.artifacts.push(doc);
511    }
512
513    fn add_dynamic_invoke_warning(&mut self, variable_name: &str, span: Span) {
514        let source_path = normalize_path(&self.config.root, self.path);
515        let start = self.cm.lookup_char_pos(span.lo());
516        self.warnings.push(WarningDoc {
517            id: document_id(
518                &self.config.repo,
519                "warning",
520                Some(&source_path),
521                Some(start.line as u32),
522                Some("dynamic_invoke"),
523            ),
524            repo: self.config.repo.clone(),
525            kind: "warning".to_owned(),
526            warning_type: "dynamic_invoke".to_owned(),
527            severity: "warning".to_owned(),
528            message: format!(
529                "Cannot statically resolve Tauri command name from {}",
530                variable_name
531            ),
532            source_path: Some(source_path),
533            line_start: Some(start.line as u32),
534            related_id: None,
535            risk_level: "medium".to_owned(),
536            remediation: None,
537            updated_at: chrono::Utc::now().to_rfc3339(),
538        });
539    }
540}
541
542impl Visit for SwcCollector<'_> {
543    fn visit_fn_decl(&mut self, fn_decl: &FnDecl) {
544        self.push_named_function(fn_decl.ident.sym.to_string());
545        fn_decl.function.visit_with(self);
546        self.pop_named_function();
547    }
548
549    fn visit_function(&mut self, function: &Function) {
550        function.visit_children_with(self);
551    }
552
553    fn visit_var_declarator(&mut self, declarator: &VarDeclarator) {
554        if let Pat::Ident(ident) = &declarator.name {
555            if let Some(init) = &declarator.init {
556                match init.as_ref() {
557                    Expr::Arrow(arrow) => {
558                        self.push_named_function(ident.id.sym.to_string());
559                        arrow.visit_children_with(self);
560                        self.pop_named_function();
561                        return;
562                    }
563                    Expr::Fn(fn_expr) => {
564                        self.push_named_function(ident.id.sym.to_string());
565                        fn_expr.function.visit_children_with(self);
566                        self.pop_named_function();
567                        return;
568                    }
569                    Expr::New(new_expr) => {
570                        if is_channel_constructor(new_expr) {
571                            self.add_channel(ident.id.sym.as_ref(), declarator.span());
572                        }
573                        return;
574                    }
575                    _ => {}
576                }
577            }
578        }
579        declarator.visit_children_with(self);
580    }
581
582    fn visit_call_expr(&mut self, call: &CallExpr) {
583        if let Some(name) = hook_call_name(call) {
584            if self.known_hooks.contains(name) {
585                self.add_hook_use(name, call.span);
586            }
587        }
588
589        if let Some(invoke_key) = invoke_key_from_call(call) {
590            self.add_invoke(&invoke_key, call.span);
591        } else if let Some(dynamic_name) = dynamic_invoke_name(call) {
592            self.add_dynamic_invoke_warning(&dynamic_name, call.span);
593        }
594
595        if let Some((kind, event_name)) = event_from_call(call) {
596            self.add_event(kind, &event_name, call.span);
597        }
598
599        call.visit_children_with(self);
600    }
601}
602
603fn hook_call_name(call: &CallExpr) -> Option<&str> {
604    match &call.callee {
605        Callee::Expr(expr) => match expr.as_ref() {
606            Expr::Ident(ident) if ident.sym.starts_with("use") => Some(ident.sym.as_ref()),
607            _ => None,
608        },
609        _ => None,
610    }
611}
612
613fn invoke_key_from_call(call: &CallExpr) -> Option<String> {
614    if !matches_invoke_callee(&call.callee) {
615        return None;
616    }
617    literal_arg(&call.args)
618}
619
620fn dynamic_invoke_name(call: &CallExpr) -> Option<String> {
621    if !matches_invoke_callee(&call.callee) {
622        return None;
623    }
624    let arg = call.args.first()?.expr.as_ref();
625    match arg {
626        Expr::Ident(ident) => Some(ident.sym.to_string()),
627        _ => None,
628    }
629}
630
631fn event_from_call(call: &CallExpr) -> Option<(&'static str, String)> {
632    let method = match &call.callee {
633        Callee::Expr(expr) => match expr.as_ref() {
634            Expr::Ident(ident) => ident.sym.to_string(),
635            Expr::Member(member) => member_property_name(member)?,
636            _ => return None,
637        },
638        _ => return None,
639    };
640    let kind = match method.as_str() {
641        "emit" => "tauri_event_emit",
642        "listen" | "once" => "tauri_event_listener",
643        _ => return None,
644    };
645    literal_arg(&call.args).map(|value| (kind, value))
646}
647
648fn literal_arg(args: &[swc_ecma_ast::ExprOrSpread]) -> Option<String> {
649    let arg = args.first()?.expr.as_ref();
650    match arg {
651        Expr::Lit(Lit::Str(str_lit)) => Some(str_lit.value.to_string_lossy().to_string()),
652        _ => None,
653    }
654}
655
656fn matches_invoke_callee(callee: &Callee) -> bool {
657    match callee {
658        Callee::Expr(expr) => match expr.as_ref() {
659            Expr::Ident(ident) => ident.sym == *"invoke",
660            Expr::Member(member) => member_chain_ends_with_invoke(member),
661            _ => false,
662        },
663        _ => false,
664    }
665}
666
667fn member_chain_ends_with_invoke(member: &MemberExpr) -> bool {
668    if member_property_name(member).as_deref() != Some("invoke") {
669        return false;
670    }
671    true
672}
673
674fn member_property_name(member: &MemberExpr) -> Option<String> {
675    match &member.prop {
676        MemberProp::Ident(ident) => Some(ident.sym.to_string()),
677        MemberProp::PrivateName(private) => Some(private.name.to_string()),
678        MemberProp::Computed(computed) => match computed.expr.as_ref() {
679            Expr::Lit(Lit::Str(str_lit)) => Some(str_lit.value.to_string_lossy().to_string()),
680            _ => None,
681        },
682    }
683}
684
685fn is_channel_constructor(new_expr: &NewExpr) -> bool {
686    match new_expr.callee.as_ref() {
687        Expr::Ident(ident) => ident.sym == *"Channel",
688        _ => false,
689    }
690}