mod calls;
mod config;
mod exports;
mod imports;
mod sfc;
mod template;
mod visitor;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use oxc_allocator::Allocator;
use oxc_ast::{AstKind, ast::*};
use oxc_ast_visit::{Visit, walk::walk_expression};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::GetSpan;
use oxc_span::SourceType;
use crate::types::{FileAnalysis, LocalSymbol, SymbolUsage};
use super::resolvers::TsPathResolver;
pub use config::CommandDetectionConfig;
use calls::is_flow_file;
use sfc::{
extract_svelte_script, extract_svelte_template, extract_vue_script, extract_vue_template,
};
use template::{parse_svelte_template_usages, parse_vue_template_usages};
use visitor::JsVisitor;
pub(crate) fn analyze_js_file_ast(
content: &str,
path: &Path,
root: &Path,
extensions: Option<&HashSet<String>>,
ts_resolver: Option<&TsPathResolver>,
relative: String,
command_cfg: &CommandDetectionConfig,
) -> FileAnalysis {
let allocator = Allocator::default();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let is_jsx_file = ext == "tsx" || ext == "jsx";
let is_svelte_file = ext == "svelte";
let is_vue_file = ext == "vue";
let is_sfc_file = is_svelte_file || is_vue_file;
let is_flow = is_flow_file(content);
let parsed_content: String;
let content_to_parse = if is_svelte_file {
parsed_content = extract_svelte_script(content);
parsed_content.as_str()
} else if is_vue_file {
parsed_content = extract_vue_script(content);
parsed_content.as_str()
} else {
content
};
let source_type = if is_sfc_file {
SourceType::tsx().with_typescript(true)
} else {
SourceType::from_path(path)
.unwrap_or_default()
.with_typescript(true)
.with_jsx(is_jsx_file)
};
let ret = Parser::new(&allocator, content_to_parse, source_type).parse();
if !ret.errors.is_empty() && std::env::var("LOCTREE_VERBOSE").is_ok() {
eprintln!(
"[loctree][debug] Parser errors in {}: {} errors",
path.display(),
ret.errors.len()
);
for (i, err) in ret.errors.iter().take(5).enumerate() {
let line_info = err
.labels
.as_ref()
.and_then(|labels| labels.first())
.map(|label| {
let offset = label.offset();
let line = content[..offset].bytes().filter(|b| *b == b'\n').count() + 1;
format!(" (line {}, col {})", line, label.offset())
})
.unwrap_or_default();
eprintln!(" [{}]{} {}", i + 1, line_info, err);
}
}
let mut visitor = JsVisitor {
analysis: FileAnalysis::new(relative),
path,
root,
extensions,
ts_resolver,
source_text: content_to_parse,
source_lines: content_to_parse.lines().collect(),
command_cfg,
namespace_imports: HashMap::new(),
};
visitor.visit_program(&ret.program);
visitor.analysis.is_flow_file = is_flow;
let semantic_ret = SemanticBuilder::new().build(&ret.program);
if semantic_ret.errors.is_empty() {
let semantic = semantic_ret.semantic;
let exported_names: HashSet<&str> = visitor
.analysis
.exports
.iter()
.map(|e| e.name.as_str())
.collect();
let mut local_symbols = Vec::new();
let mut symbol_usages = Vec::new();
let mut seen_defs: HashSet<(String, usize)> = HashSet::new();
let mut seen_uses: HashSet<(String, usize)> = HashSet::new();
const MAX_USAGES_PER_FILE: usize = 1500;
for symbol_id in semantic.scoping().symbol_ids() {
let name = semantic.scoping().symbol_name(symbol_id);
if name.is_empty() {
continue;
}
let decl = semantic.symbol_declaration(symbol_id);
let kind = match decl.kind() {
AstKind::Function(_) => "function",
AstKind::Class(_) => "class",
AstKind::VariableDeclarator(_) => "variable",
AstKind::TSTypeAliasDeclaration(_) => "type",
AstKind::TSInterfaceDeclaration(_) => "interface",
AstKind::TSEnumDeclaration(_) => "enum",
AstKind::ImportSpecifier(_)
| AstKind::ImportDefaultSpecifier(_)
| AstKind::ImportNamespaceSpecifier(_) => "import",
AstKind::FormalParameter(_) | AstKind::BindingIdentifier(_) => "binding",
_ => "symbol",
};
let span = decl.kind().span();
let line = visitor.get_line(span);
let is_exported = exported_names.contains(name);
let context = visitor.line_context(line);
if !is_exported && kind != "import" && seen_defs.insert((name.to_string(), line)) {
local_symbols.push(LocalSymbol {
name: name.to_string(),
kind: kind.to_string(),
line: Some(line),
context,
is_exported,
});
}
let ref_ids = semantic.scoping().get_resolved_reference_ids(symbol_id);
if is_exported && !ref_ids.is_empty() {
visitor.analysis.local_uses.push(name.to_string());
}
if symbol_usages.len() < MAX_USAGES_PER_FILE {
for reference in semantic.symbol_references(symbol_id) {
if symbol_usages.len() >= MAX_USAGES_PER_FILE {
break;
}
let ref_span = semantic.reference_span(reference);
let ref_line = visitor.get_line(ref_span);
if ref_line == 0 {
continue;
}
if seen_uses.insert((name.to_string(), ref_line)) {
let ref_context = visitor.line_context(ref_line);
symbol_usages.push(SymbolUsage {
name: name.to_string(),
line: ref_line,
context: ref_context,
});
}
}
}
}
if !local_symbols.is_empty() {
visitor.analysis.local_symbols = local_symbols;
}
if !symbol_usages.is_empty() {
visitor.analysis.symbol_usages = symbol_usages;
}
}
if is_svelte_file {
let template = extract_svelte_template(content);
let template_usages = parse_svelte_template_usages(&template);
for usage in template_usages {
if !visitor.analysis.local_uses.contains(&usage) {
visitor.analysis.local_uses.push(usage);
}
}
}
if is_vue_file {
let template = extract_vue_template(content);
let template_usages = parse_vue_template_usages(&template);
for usage in template_usages {
if !visitor.analysis.local_uses.contains(&usage) {
visitor.analysis.local_uses.push(usage);
}
}
}
visitor.analysis
}
impl<'a> Visit<'a> for JsVisitor<'a> {
fn visit_expression(&mut self, expr: &Expression<'a>) {
match expr {
Expression::StringLiteral(lit) => {
self.push_string_literal(&lit.value, lit.span);
}
Expression::TemplateLiteral(tpl) => {
if tpl.expressions.is_empty()
&& tpl.quasis.len() == 1
&& let Some(cooked) = &tpl.quasis[0].value.cooked
{
self.push_string_literal(cooked, tpl.span);
} else if tpl.expressions.is_empty() && tpl.quasis.len() == 1 {
self.push_string_literal(&tpl.quasis[0].value.raw, tpl.span);
}
}
Expression::NewExpression(new_expr) => {
if let Expression::Identifier(ident) = &new_expr.callee {
let name = ident.name.to_string();
if name == "WeakMap" || name == "WeakSet" {
self.analysis.has_weak_collections = true;
}
}
}
_ => {}
}
walk_expression(self, expr);
}
fn visit_import_declaration(&mut self, decl: &ImportDeclaration<'a>) {
self.handle_import_declaration(decl);
}
fn visit_member_expression(&mut self, member: &MemberExpression<'a>) {
self.handle_member_expression(member);
oxc_ast_visit::walk::walk_member_expression(self, member);
}
fn visit_export_named_declaration(&mut self, decl: &ExportNamedDeclaration<'a>) {
self.handle_export_named_declaration(decl);
}
fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'a>) {
self.handle_export_default_declaration(decl);
}
fn visit_export_all_declaration(&mut self, decl: &ExportAllDeclaration<'a>) {
self.handle_export_all_declaration(decl);
}
fn visit_import_expression(&mut self, expr: &ImportExpression<'a>) {
self.handle_import_expression(expr);
self.visit_expression(&expr.source);
if let Some(opts) = &expr.options {
self.visit_expression(opts);
}
}
fn visit_call_expression(&mut self, call: &CallExpression<'a>) {
self.visit_arguments(&call.arguments);
self.visit_expression(&call.callee);
self.handle_call_expression(call);
}
fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
self.handle_variable_declarator(decl);
self.visit_binding_pattern(&decl.id);
if let Some(init) = &decl.init {
self.visit_expression(init);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn test_ast_parsing_basic() {
let content = r#"
import { Foo } from "./bar";
import Default, { Named } from "./baz";
import * as NS from "./ns";
export const myVar = 1;
export function myFunc() {}
export default class MyClass {}
export { reexported } from "./other";
invoke("my_command");
safeInvoke("another_command");
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/test.ts"),
Path::new("src"),
None,
None,
"test.ts".to_string(),
&CommandDetectionConfig::default(),
);
assert_eq!(analysis.imports.len(), 3);
let bar = analysis
.imports
.iter()
.find(|i| i.source == "./bar")
.unwrap();
assert_eq!(bar.symbols[0].name, "Foo");
assert!(!bar.symbols[0].is_default);
let baz = analysis
.imports
.iter()
.find(|i| i.source == "./baz")
.unwrap();
assert_eq!(baz.symbols.len(), 2);
assert!(
baz.symbols
.iter()
.any(|s| s.name == "Default" && s.is_default)
);
assert!(
baz.symbols
.iter()
.any(|s| s.name == "Named" && !s.is_default)
);
let ns = analysis
.imports
.iter()
.find(|i| i.source == "./ns")
.unwrap();
assert_eq!(ns.symbols[0].name, "*");
assert_eq!(ns.symbols[0].alias.as_deref(), Some("NS"));
let exports: Vec<_> = analysis.exports.iter().map(|e| e.name.as_str()).collect();
assert!(exports.contains(&"myVar"));
assert!(exports.contains(&"myFunc"));
assert!(exports.contains(&"default"));
assert!(
analysis
.exports
.iter()
.any(|e| e.name == "default" && e.export_type == "MyClass")
);
assert!(exports.contains(&"reexported"));
let commands: Vec<_> = analysis
.command_calls
.iter()
.map(|c| c.name.as_str())
.collect();
assert!(commands.contains(&"my_command"));
assert!(commands.contains(&"another_command"));
}
#[test]
fn test_register_command_not_tauri_invoke() {
let content = r#"
import * as vscode from "vscode";
vscode.commands.registerCommand("loctree.analyzeImpact", () => {});
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("editors/vscode/src/commands.ts"),
Path::new("editors/vscode/src"),
None,
None,
"commands.ts".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
analysis.command_calls.is_empty(),
"VSCode registerCommand should not be treated as a Tauri invoke"
);
}
#[test]
fn test_vue_sfc_script_extraction() {
let content = r#"
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
export function increment() {
count.value++
}
</script>
<template>
<div>{{ count }}</div>
</template>
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/Counter.vue"),
Path::new("src"),
None,
None,
"Counter.vue".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
analysis.imports.iter().any(|i| i.source == "vue"),
"Should detect vue import"
);
assert!(
analysis
.exports
.iter()
.any(|e| e.name == "increment" && e.kind == "function"),
"Should detect increment export"
);
}
#[test]
fn test_vue_sfc_options_api() {
let content = r#"
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
data() {
return { count: 0 }
}
})
</script>
<template>
<div>{{ count }}</div>
</template>
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/Counter.vue"),
Path::new("src"),
None,
None,
"Counter.vue".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
analysis.imports.iter().any(|i| i.source == "vue"),
"Should detect vue import"
);
assert!(
analysis.exports.iter().any(|e| e.export_type == "default"),
"Should detect default export"
);
}
#[test]
fn test_svelte_file_full_analysis() {
let content = r#"
<script lang="ts">
import type { Account } from './types';
export function badgeText(account: Account): string {
return account.name;
}
export let account: Account;
</script>
<div class="badge">
<span>{badgeText(account)}</span>
</div>
<style>
.badge { color: blue; }
</style>
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/GitHubAccountBadge.svelte"),
Path::new("src"),
None,
None,
"GitHubAccountBadge.svelte".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
analysis.local_uses.contains(&"badgeText".to_string()),
"badgeText should be in local_uses, found: {:?}",
analysis.local_uses
);
assert!(
analysis.local_uses.contains(&"account".to_string()),
"account should be in local_uses, found: {:?}",
analysis.local_uses
);
}
#[test]
fn test_vue_file_full_analysis() {
let content = r#"
<script setup lang="ts">
import type { Product } from './types';
export function formatPrice(price: number): string {
return `$${price.toFixed(2)}`;
}
export const product: Product = { name: 'Widget', price: 29.99 };
</script>
<template>
<div class="product">
<h3>{{ product.name }}</h3>
<p>{{ formatPrice(product.price) }}</p>
</div>
</template>
<style scoped>
.product { border: 1px solid #ccc; }
</style>
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/ProductCard.vue"),
Path::new("src"),
None,
None,
"ProductCard.vue".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
analysis.local_uses.contains(&"formatPrice".to_string()),
"formatPrice should be in local_uses, found: {:?}",
analysis.local_uses
);
assert!(
analysis.local_uses.contains(&"product".to_string()),
"product should be in local_uses, found: {:?}",
analysis.local_uses
);
}
#[test]
fn test_weakmap_detection() {
let content = r#"
// React DevTools pattern: store component metadata in WeakMap
const componentMap = new WeakMap();
const stateMap = new WeakSet();
export function registerComponent(component) {
componentMap.set(component, { name: component.name });
}
export const MyComponent = () => <div>Hello</div>;
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/devtools.tsx"), Path::new("src"),
None,
None,
"devtools.tsx".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
analysis.has_weak_collections,
"Should detect WeakMap/WeakSet usage"
);
assert_eq!(analysis.exports.len(), 2);
assert!(
analysis
.exports
.iter()
.any(|e| e.name == "registerComponent")
);
assert!(analysis.exports.iter().any(|e| e.name == "MyComponent"));
}
#[test]
fn test_no_weakmap_detection() {
let content = r#"
const cache = new Map();
export function getCached(key) {
return cache.get(key);
}
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/cache.ts"),
Path::new("src"),
None,
None,
"cache.ts".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
!analysis.has_weak_collections,
"Should NOT flag regular Map as WeakMap"
);
}
#[test]
fn test_local_symbols_and_usages() {
let content = r#"
import { Component as MyComponent } from 'react';
const taskFilter = 'all';
function applyFilter() {
return taskFilter;
}
const onClick = () => MyComponent;
MyComponent();
"#;
let analysis = analyze_js_file_ast(
content,
Path::new("src/test.tsx"),
Path::new("src"),
None,
None,
"test.tsx".to_string(),
&CommandDetectionConfig::default(),
);
assert!(
analysis
.local_symbols
.iter()
.any(|s| s.name == "taskFilter"),
"taskFilter should be in local_symbols"
);
assert!(
analysis
.local_symbols
.iter()
.any(|s| s.name == "applyFilter"),
"applyFilter should be in local_symbols"
);
assert!(
analysis
.symbol_usages
.iter()
.any(|u| u.name == "taskFilter"),
"taskFilter should be in symbol_usages"
);
assert!(
analysis
.symbol_usages
.iter()
.any(|u| u.name == "MyComponent"),
"MyComponent usage should be tracked"
);
}
}