codelens_engine/
oxc_analysis.rs1use 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
38pub 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
46pub 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
89pub 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 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 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
150pub 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}