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}