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