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}