Skip to main content

codelens_engine/
oxc_analysis.rs

1//! Precise JS/TS scope analysis using oxc_semantic.
2//! Provides compiler-grade reference resolution without LSP.
3
4use anyhow::Result;
5use oxc_allocator::Allocator;
6use oxc_parser::Parser;
7use oxc_semantic::SemanticBuilder;
8use oxc_span::{GetSpan, SourceType};
9use serde::Serialize;
10use std::path::Path;
11
12#[derive(Debug, Clone, Serialize)]
13pub struct ResolvedReference {
14    pub symbol_name: String,
15    pub kind: RefKind,
16    pub line: usize,
17    pub column: usize,
18}
19
20#[derive(Debug, Clone, Serialize)]
21#[serde(rename_all = "snake_case")]
22pub enum RefKind {
23    Definition,
24    Read,
25    Write,
26}
27
28#[derive(Debug, Clone, Serialize)]
29pub struct ScopeSymbol {
30    pub name: String,
31    pub line: usize,
32    pub column: usize,
33    pub is_exported: bool,
34    pub reference_count: usize,
35    pub is_mutated: bool,
36}
37
38/// Check if a file is JS/TS (supported by oxc).
39pub fn is_js_ts(path: &Path) -> bool {
40    matches!(
41        path.extension().and_then(|e| e.to_str()),
42        Some("js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "mts" | "cts")
43    )
44}
45
46/// Get all symbols in a JS/TS file with scope-aware metadata.
47pub fn get_scope_symbols(source: &str, file_path: &str) -> Result<Vec<ScopeSymbol>> {
48    let alloc = Allocator::default();
49    let source_type = SourceType::from_path(file_path)
50        .map_err(|_| anyhow::anyhow!("unsupported file type: {}", file_path))?;
51    let parsed = Parser::new(&alloc, source, source_type).parse();
52
53    if parsed.panicked {
54        anyhow::bail!("oxc parser panicked on {}", file_path);
55    }
56
57    let built = SemanticBuilder::new().build(&parsed.program);
58    let semantic = &built.semantic;
59    let scoping = semantic.scoping();
60
61    let source_bytes = source.as_bytes();
62    let mut symbols = Vec::new();
63
64    for symbol_id in scoping.symbol_ids() {
65        let name = scoping.symbol_name(symbol_id).to_string();
66        let node_id = scoping.symbol_declaration(symbol_id);
67        let node = semantic.nodes().get_node(node_id);
68        let span = node.span();
69        let (line, column) = offset_to_line_col(source_bytes, span.start as usize);
70
71        let ref_count = scoping.get_resolved_references(symbol_id).count();
72        let is_mutated = scoping.symbol_is_mutated(symbol_id);
73        let flags = scoping.symbol_flags(symbol_id);
74        let is_exported = format!("{:?}", flags).contains("Export");
75
76        symbols.push(ScopeSymbol {
77            name,
78            line,
79            column,
80            is_exported,
81            reference_count: ref_count,
82            is_mutated,
83        });
84    }
85
86    Ok(symbols)
87}
88
89/// Find all references to a symbol by name in a JS/TS file.
90/// Scope-aware: distinguishes definitions, reads, and writes.
91pub fn find_references_precise(
92    source: &str,
93    file_path: &str,
94    symbol_name: &str,
95) -> Result<Vec<ResolvedReference>> {
96    let alloc = Allocator::default();
97    let source_type = SourceType::from_path(file_path)
98        .map_err(|_| anyhow::anyhow!("unsupported file type: {}", file_path))?;
99    let parsed = Parser::new(&alloc, source, source_type).parse();
100
101    if parsed.panicked {
102        anyhow::bail!("oxc parser panicked on {}", file_path);
103    }
104
105    let built = SemanticBuilder::new().build(&parsed.program);
106    let semantic = &built.semantic;
107    let scoping = semantic.scoping();
108
109    let source_bytes = source.as_bytes();
110    let mut refs = Vec::new();
111
112    for symbol_id in scoping.symbol_ids() {
113        let name = scoping.symbol_name(symbol_id);
114        if name != symbol_name {
115            continue;
116        }
117
118        // Declaration
119        let node_id = scoping.symbol_declaration(symbol_id);
120        let decl_span = semantic.nodes().get_node(node_id).span();
121        let (line, col) = offset_to_line_col(source_bytes, decl_span.start as usize);
122        refs.push(ResolvedReference {
123            symbol_name: symbol_name.to_string(),
124            kind: RefKind::Definition,
125            line,
126            column: col,
127        });
128
129        // All resolved references
130        for reference in scoping.get_resolved_references(symbol_id) {
131            let span = semantic.reference_span(reference);
132            let (line, col) = offset_to_line_col(source_bytes, span.start as usize);
133            let kind = if reference.is_write() {
134                RefKind::Write
135            } else {
136                RefKind::Read
137            };
138            refs.push(ResolvedReference {
139                symbol_name: symbol_name.to_string(),
140                kind,
141                line,
142                column: col,
143            });
144        }
145    }
146
147    Ok(refs)
148}
149
150/// Find unresolved references (potential missing imports or globals).
151pub fn find_unresolved(source: &str, file_path: &str) -> Result<Vec<String>> {
152    let alloc = Allocator::default();
153    let source_type = SourceType::from_path(file_path)
154        .map_err(|_| anyhow::anyhow!("unsupported file type: {}", file_path))?;
155    let parsed = Parser::new(&alloc, source, source_type).parse();
156
157    if parsed.panicked {
158        anyhow::bail!("oxc parser panicked on {}", file_path);
159    }
160
161    let built = SemanticBuilder::new().build(&parsed.program);
162    let scoping = built.semantic.scoping();
163
164    let mut unresolved: Vec<String> = scoping
165        .root_unresolved_references()
166        .keys()
167        .map(|name| name.to_string())
168        .collect();
169
170    unresolved.sort();
171    unresolved.dedup();
172    Ok(unresolved)
173}
174
175fn offset_to_line_col(source: &[u8], offset: usize) -> (usize, usize) {
176    let offset = offset.min(source.len());
177    let mut line = 1;
178    let mut col = 1;
179    for &b in &source[..offset] {
180        if b == b'\n' {
181            line += 1;
182            col = 1;
183        } else {
184            col += 1;
185        }
186    }
187    (line, col)
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn test_get_scope_symbols() {
196        let source = r#"
197const name = "hello";
198function greet(msg) {
199    console.log(msg);
200    return msg.toUpperCase();
201}
202let x = greet(name);
203"#;
204        let symbols = get_scope_symbols(source, "test.js").unwrap();
205        let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
206        assert!(names.contains(&"name"));
207        assert!(names.contains(&"greet"));
208        assert!(names.contains(&"x"));
209    }
210
211    #[test]
212    fn test_find_references() {
213        let source = r#"
214function add(a, b) { return a + b; }
215const result = add(1, 2);
216console.log(add(3, 4));
217"#;
218        let refs = find_references_precise(source, "test.js", "add").unwrap();
219        assert!(refs.len() >= 3);
220        assert!(refs.iter().any(|r| matches!(r.kind, RefKind::Definition)));
221    }
222
223    #[test]
224    fn test_typescript_support() {
225        let source = r#"
226interface User { name: string; }
227function getUser(id: number): User {
228    return { name: "test" };
229}
230const user: User = getUser(1);
231"#;
232        let refs = find_references_precise(source, "test.ts", "getUser").unwrap();
233        assert!(refs.len() >= 2);
234    }
235
236    #[test]
237    fn test_mutation_detection() {
238        let source = "let counter = 0;\ncounter++;\ncounter = counter + 1;\n";
239        let symbols = get_scope_symbols(source, "test.js").unwrap();
240        let counter = symbols.iter().find(|s| s.name == "counter").unwrap();
241        assert!(counter.is_mutated);
242    }
243
244    #[test]
245    fn test_unresolved() {
246        let source = "console.log(unknownVar);\nfetch('/api');\n";
247        let unresolved = find_unresolved(source, "test.js").unwrap();
248        assert!(unresolved.contains(&"console".to_string()));
249        assert!(unresolved.contains(&"unknownVar".to_string()));
250    }
251
252    #[test]
253    fn test_is_js_ts() {
254        assert!(is_js_ts(Path::new("app.ts")));
255        assert!(is_js_ts(Path::new("index.jsx")));
256        assert!(!is_js_ts(Path::new("main.py")));
257    }
258}