acp/ast/languages/
javascript.rs

1//! @acp:module "JavaScript Extractor"
2//! @acp:summary "Symbol extraction for JavaScript source files"
3//! @acp:domain cli
4//! @acp:layer parsing
5
6use super::{node_text, LanguageExtractor};
7use crate::ast::{
8    ExtractedSymbol, FunctionCall, Import, ImportedName, Parameter, SymbolKind, Visibility,
9};
10use crate::error::Result;
11use tree_sitter::{Language, Node, Tree};
12
13/// JavaScript language extractor
14pub struct JavaScriptExtractor;
15
16impl LanguageExtractor for JavaScriptExtractor {
17    fn language(&self) -> Language {
18        tree_sitter_javascript::LANGUAGE.into()
19    }
20
21    fn name(&self) -> &'static str {
22        "javascript"
23    }
24
25    fn extensions(&self) -> &'static [&'static str] {
26        &["js", "jsx", "mjs", "cjs"]
27    }
28
29    fn extract_symbols(&self, tree: &Tree, source: &str) -> Result<Vec<ExtractedSymbol>> {
30        let mut symbols = Vec::new();
31        let root = tree.root_node();
32        self.extract_symbols_recursive(&root, source, &mut symbols, None);
33        Ok(symbols)
34    }
35
36    fn extract_imports(&self, tree: &Tree, source: &str) -> Result<Vec<Import>> {
37        let mut imports = Vec::new();
38        let root = tree.root_node();
39        self.extract_imports_recursive(&root, source, &mut imports);
40        Ok(imports)
41    }
42
43    fn extract_calls(
44        &self,
45        tree: &Tree,
46        source: &str,
47        current_function: Option<&str>,
48    ) -> Result<Vec<FunctionCall>> {
49        let mut calls = Vec::new();
50        let root = tree.root_node();
51        self.extract_calls_recursive(&root, source, &mut calls, current_function);
52        Ok(calls)
53    }
54
55    fn extract_doc_comment(&self, node: &Node, source: &str) -> Option<String> {
56        if let Some(prev) = node.prev_sibling() {
57            if prev.kind() == "comment" {
58                let comment = node_text(&prev, source);
59                if comment.starts_with("/**") {
60                    return Some(Self::clean_jsdoc(comment));
61                }
62                if let Some(rest) = comment.strip_prefix("//") {
63                    return Some(rest.trim().to_string());
64                }
65            }
66        }
67        None
68    }
69}
70
71impl JavaScriptExtractor {
72    fn extract_symbols_recursive(
73        &self,
74        node: &Node,
75        source: &str,
76        symbols: &mut Vec<ExtractedSymbol>,
77        parent: Option<&str>,
78    ) {
79        match node.kind() {
80            "function_declaration" => {
81                if let Some(sym) = self.extract_function(node, source, parent) {
82                    symbols.push(sym);
83                }
84            }
85
86            "lexical_declaration" | "variable_declaration" => {
87                self.extract_variable_symbols(node, source, symbols, parent);
88            }
89
90            "class_declaration" => {
91                if let Some(sym) = self.extract_class(node, source, parent) {
92                    let class_name = sym.name.clone();
93                    symbols.push(sym);
94
95                    if let Some(body) = node.child_by_field_name("body") {
96                        self.extract_class_members(&body, source, symbols, Some(&class_name));
97                    }
98                }
99            }
100
101            "export_statement" => {
102                self.extract_export_symbols(node, source, symbols, parent);
103            }
104
105            _ => {}
106        }
107
108        let mut cursor = node.walk();
109        for child in node.children(&mut cursor) {
110            self.extract_symbols_recursive(&child, source, symbols, parent);
111        }
112    }
113
114    fn extract_function(
115        &self,
116        node: &Node,
117        source: &str,
118        parent: Option<&str>,
119    ) -> Option<ExtractedSymbol> {
120        let name_node = node.child_by_field_name("name")?;
121        let name = node_text(&name_node, source).to_string();
122
123        let mut sym = ExtractedSymbol::new(
124            name,
125            SymbolKind::Function,
126            node.start_position().row + 1,
127            node.end_position().row + 1,
128        )
129        .with_columns(node.start_position().column, node.end_position().column);
130
131        let text = node_text(node, source);
132        if text.starts_with("async") {
133            sym = sym.async_fn();
134        }
135
136        if let Some(params) = node.child_by_field_name("parameters") {
137            self.extract_parameters(&params, source, &mut sym);
138        }
139
140        sym.doc_comment = self.extract_doc_comment(node, source);
141
142        if let Some(p) = parent {
143            sym = sym.with_parent(p);
144        }
145
146        sym.signature = Some(self.build_function_signature(node, source));
147
148        Some(sym)
149    }
150
151    fn extract_variable_symbols(
152        &self,
153        node: &Node,
154        source: &str,
155        symbols: &mut Vec<ExtractedSymbol>,
156        parent: Option<&str>,
157    ) {
158        let mut cursor = node.walk();
159        for child in node.children(&mut cursor) {
160            if child.kind() == "variable_declarator" {
161                if let (Some(name_node), Some(value)) = (
162                    child.child_by_field_name("name"),
163                    child.child_by_field_name("value"),
164                ) {
165                    let name = node_text(&name_node, source);
166                    let value_kind = value.kind();
167
168                    if value_kind == "arrow_function" || value_kind == "function_expression" {
169                        let mut sym = ExtractedSymbol::new(
170                            name.to_string(),
171                            SymbolKind::Function,
172                            node.start_position().row + 1,
173                            node.end_position().row + 1,
174                        );
175
176                        let text = node_text(&value, source);
177                        if text.starts_with("async") {
178                            sym = sym.async_fn();
179                        }
180
181                        if let Some(params) = value.child_by_field_name("parameters") {
182                            self.extract_parameters(&params, source, &mut sym);
183                        }
184
185                        sym.doc_comment = self.extract_doc_comment(node, source);
186
187                        if let Some(p) = parent {
188                            sym = sym.with_parent(p);
189                        }
190
191                        if let Some(parent_node) = node.parent() {
192                            if parent_node.kind() == "export_statement" {
193                                sym = sym.exported();
194                            }
195                        }
196
197                        symbols.push(sym);
198                    } else {
199                        let kind = if node_text(node, source).starts_with("const") {
200                            SymbolKind::Constant
201                        } else {
202                            SymbolKind::Variable
203                        };
204
205                        let mut sym = ExtractedSymbol::new(
206                            name.to_string(),
207                            kind,
208                            node.start_position().row + 1,
209                            node.end_position().row + 1,
210                        );
211
212                        if let Some(p) = parent {
213                            sym = sym.with_parent(p);
214                        }
215
216                        symbols.push(sym);
217                    }
218                }
219            }
220        }
221    }
222
223    fn extract_class(
224        &self,
225        node: &Node,
226        source: &str,
227        parent: Option<&str>,
228    ) -> Option<ExtractedSymbol> {
229        let name_node = node.child_by_field_name("name")?;
230        let name = node_text(&name_node, source).to_string();
231
232        let mut sym = ExtractedSymbol::new(
233            name,
234            SymbolKind::Class,
235            node.start_position().row + 1,
236            node.end_position().row + 1,
237        )
238        .with_columns(node.start_position().column, node.end_position().column);
239
240        sym.doc_comment = self.extract_doc_comment(node, source);
241
242        if let Some(p) = parent {
243            sym = sym.with_parent(p);
244        }
245
246        Some(sym)
247    }
248
249    fn extract_class_members(
250        &self,
251        body: &Node,
252        source: &str,
253        symbols: &mut Vec<ExtractedSymbol>,
254        class_name: Option<&str>,
255    ) {
256        let mut cursor = body.walk();
257        for child in body.children(&mut cursor) {
258            if child.kind() == "method_definition" {
259                if let Some(sym) = self.extract_method(&child, source, class_name) {
260                    symbols.push(sym);
261                }
262            }
263        }
264    }
265
266    fn extract_method(
267        &self,
268        node: &Node,
269        source: &str,
270        class_name: Option<&str>,
271    ) -> Option<ExtractedSymbol> {
272        let name_node = node.child_by_field_name("name")?;
273        let name = node_text(&name_node, source).to_string();
274
275        // Check for private (# prefix) before passing name to new
276        let is_private = name.starts_with('#');
277
278        let mut sym = ExtractedSymbol::new(
279            name,
280            SymbolKind::Method,
281            node.start_position().row + 1,
282            node.end_position().row + 1,
283        );
284
285        let text = node_text(node, source);
286        if text.contains("static") {
287            sym = sym.static_fn();
288        }
289        if text.contains("async") {
290            sym = sym.async_fn();
291        }
292
293        // JavaScript doesn't have visibility modifiers by default
294        // Private fields start with #
295        if is_private {
296            sym.visibility = Visibility::Private;
297        }
298
299        if let Some(params) = node.child_by_field_name("parameters") {
300            self.extract_parameters(&params, source, &mut sym);
301        }
302
303        sym.doc_comment = self.extract_doc_comment(node, source);
304
305        if let Some(p) = class_name {
306            sym = sym.with_parent(p);
307        }
308
309        Some(sym)
310    }
311
312    fn extract_export_symbols(
313        &self,
314        node: &Node,
315        source: &str,
316        symbols: &mut Vec<ExtractedSymbol>,
317        parent: Option<&str>,
318    ) {
319        let mut cursor = node.walk();
320        for child in node.children(&mut cursor) {
321            match child.kind() {
322                "function_declaration" => {
323                    if let Some(mut sym) = self.extract_function(&child, source, parent) {
324                        sym = sym.exported();
325                        symbols.push(sym);
326                    }
327                }
328                "class_declaration" => {
329                    if let Some(mut sym) = self.extract_class(&child, source, parent) {
330                        sym = sym.exported();
331                        let class_name = sym.name.clone();
332                        symbols.push(sym);
333
334                        if let Some(body) = child.child_by_field_name("body") {
335                            self.extract_class_members(&body, source, symbols, Some(&class_name));
336                        }
337                    }
338                }
339                "lexical_declaration" | "variable_declaration" => {
340                    self.extract_variable_symbols(&child, source, symbols, parent);
341                    if let Some(last) = symbols.last_mut() {
342                        last.exported = true;
343                    }
344                }
345                _ => {}
346            }
347        }
348    }
349
350    fn extract_parameters(&self, params: &Node, source: &str, sym: &mut ExtractedSymbol) {
351        let mut cursor = params.walk();
352        for child in params.children(&mut cursor) {
353            match child.kind() {
354                "identifier" => {
355                    sym.add_parameter(Parameter {
356                        name: node_text(&child, source).to_string(),
357                        type_info: None,
358                        default_value: None,
359                        is_rest: false,
360                        is_optional: false,
361                    });
362                }
363                "assignment_pattern" => {
364                    let name = child
365                        .child_by_field_name("left")
366                        .map(|n| node_text(&n, source).to_string())
367                        .unwrap_or_default();
368                    let default_value = child
369                        .child_by_field_name("right")
370                        .map(|n| node_text(&n, source).to_string());
371
372                    sym.add_parameter(Parameter {
373                        name,
374                        type_info: None,
375                        default_value,
376                        is_rest: false,
377                        is_optional: true,
378                    });
379                }
380                "rest_pattern" => {
381                    let name = node_text(&child, source)
382                        .trim_start_matches("...")
383                        .to_string();
384                    sym.add_parameter(Parameter {
385                        name,
386                        type_info: None,
387                        default_value: None,
388                        is_rest: true,
389                        is_optional: false,
390                    });
391                }
392                _ => {}
393            }
394        }
395    }
396
397    fn extract_imports_recursive(&self, node: &Node, source: &str, imports: &mut Vec<Import>) {
398        if node.kind() == "import_statement" {
399            if let Some(import) = self.parse_import(node, source) {
400                imports.push(import);
401            }
402        }
403
404        let mut cursor = node.walk();
405        for child in node.children(&mut cursor) {
406            self.extract_imports_recursive(&child, source, imports);
407        }
408    }
409
410    fn parse_import(&self, node: &Node, source: &str) -> Option<Import> {
411        let source_node = node.child_by_field_name("source")?;
412        let source_path = node_text(&source_node, source)
413            .trim_matches(|c| c == '"' || c == '\'')
414            .to_string();
415
416        let mut import = Import {
417            source: source_path,
418            names: Vec::new(),
419            is_default: false,
420            is_namespace: false,
421            line: node.start_position().row + 1,
422        };
423
424        let mut cursor = node.walk();
425        for child in node.children(&mut cursor) {
426            if child.kind() == "import_clause" {
427                self.parse_import_clause(&child, source, &mut import);
428            }
429        }
430
431        Some(import)
432    }
433
434    fn parse_import_clause(&self, clause: &Node, source: &str, import: &mut Import) {
435        let mut cursor = clause.walk();
436        for child in clause.children(&mut cursor) {
437            match child.kind() {
438                "identifier" => {
439                    import.is_default = true;
440                    import.names.push(ImportedName {
441                        name: "default".to_string(),
442                        alias: Some(node_text(&child, source).to_string()),
443                    });
444                }
445                "namespace_import" => {
446                    import.is_namespace = true;
447                    if let Some(name_node) = child.child_by_field_name("name") {
448                        import.names.push(ImportedName {
449                            name: "*".to_string(),
450                            alias: Some(node_text(&name_node, source).to_string()),
451                        });
452                    }
453                }
454                "named_imports" => {
455                    self.parse_named_imports(&child, source, import);
456                }
457                _ => {}
458            }
459        }
460    }
461
462    fn parse_named_imports(&self, node: &Node, source: &str, import: &mut Import) {
463        let mut cursor = node.walk();
464        for child in node.children(&mut cursor) {
465            if child.kind() == "import_specifier" {
466                let name = child
467                    .child_by_field_name("name")
468                    .map(|n| node_text(&n, source).to_string())
469                    .unwrap_or_default();
470
471                let alias = child
472                    .child_by_field_name("alias")
473                    .map(|n| node_text(&n, source).to_string());
474
475                import.names.push(ImportedName { name, alias });
476            }
477        }
478    }
479
480    fn extract_calls_recursive(
481        &self,
482        node: &Node,
483        source: &str,
484        calls: &mut Vec<FunctionCall>,
485        current_function: Option<&str>,
486    ) {
487        if node.kind() == "call_expression" {
488            if let Some(call) = self.parse_call(node, source, current_function) {
489                calls.push(call);
490            }
491        }
492
493        let func_name = match node.kind() {
494            "function_declaration" | "method_definition" => node
495                .child_by_field_name("name")
496                .map(|n| node_text(&n, source)),
497            _ => None,
498        };
499
500        let current = func_name
501            .map(String::from)
502            .or_else(|| current_function.map(String::from));
503
504        let mut cursor = node.walk();
505        for child in node.children(&mut cursor) {
506            self.extract_calls_recursive(&child, source, calls, current.as_deref());
507        }
508    }
509
510    fn parse_call(
511        &self,
512        node: &Node,
513        source: &str,
514        current_function: Option<&str>,
515    ) -> Option<FunctionCall> {
516        let function = node.child_by_field_name("function")?;
517
518        let (callee, is_method, receiver) = match function.kind() {
519            "member_expression" => {
520                let object = function
521                    .child_by_field_name("object")
522                    .map(|n| node_text(&n, source).to_string());
523                let property = function
524                    .child_by_field_name("property")
525                    .map(|n| node_text(&n, source).to_string())?;
526                (property, true, object)
527            }
528            "identifier" => (node_text(&function, source).to_string(), false, None),
529            _ => return None,
530        };
531
532        Some(FunctionCall {
533            caller: current_function.unwrap_or("<module>").to_string(),
534            callee,
535            line: node.start_position().row + 1,
536            is_method,
537            receiver,
538        })
539    }
540
541    fn build_function_signature(&self, node: &Node, source: &str) -> String {
542        let name = node
543            .child_by_field_name("name")
544            .map(|n| node_text(&n, source))
545            .unwrap_or("anonymous");
546
547        let params = node
548            .child_by_field_name("parameters")
549            .map(|n| node_text(&n, source))
550            .unwrap_or("()");
551
552        format!("function {}{}", name, params)
553    }
554
555    fn clean_jsdoc(comment: &str) -> String {
556        comment
557            .trim_start_matches("/**")
558            .trim_end_matches("*/")
559            .lines()
560            .map(|line| line.trim().trim_start_matches('*').trim())
561            .filter(|line| !line.is_empty())
562            .collect::<Vec<_>>()
563            .join("\n")
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    fn parse_js(source: &str) -> (Tree, String) {
572        let mut parser = tree_sitter::Parser::new();
573        parser
574            .set_language(&tree_sitter_javascript::LANGUAGE.into())
575            .unwrap();
576        let tree = parser.parse(source, None).unwrap();
577        (tree, source.to_string())
578    }
579
580    #[test]
581    fn test_extract_function() {
582        let source = r#"
583function greet(name) {
584    return `Hello, ${name}!`;
585}
586"#;
587        let (tree, src) = parse_js(source);
588        let extractor = JavaScriptExtractor;
589        let symbols = extractor.extract_symbols(&tree, &src).unwrap();
590
591        assert_eq!(symbols.len(), 1);
592        assert_eq!(symbols[0].name, "greet");
593        assert_eq!(symbols[0].kind, SymbolKind::Function);
594    }
595
596    #[test]
597    fn test_extract_class() {
598        let source = r#"
599class UserService {
600    constructor(name) {
601        this.name = name;
602    }
603
604    greet() {
605        return `Hello, ${this.name}!`;
606    }
607}
608"#;
609        let (tree, src) = parse_js(source);
610        let extractor = JavaScriptExtractor;
611        let symbols = extractor.extract_symbols(&tree, &src).unwrap();
612
613        assert!(symbols
614            .iter()
615            .any(|s| s.name == "UserService" && s.kind == SymbolKind::Class));
616        assert!(symbols
617            .iter()
618            .any(|s| s.name == "greet" && s.kind == SymbolKind::Method));
619    }
620
621    #[test]
622    fn test_extract_arrow_function() {
623        let source = r#"
624const add = (a, b) => a + b;
625"#;
626        let (tree, src) = parse_js(source);
627        let extractor = JavaScriptExtractor;
628        let symbols = extractor.extract_symbols(&tree, &src).unwrap();
629
630        assert_eq!(symbols.len(), 1);
631        assert_eq!(symbols[0].name, "add");
632        assert_eq!(symbols[0].kind, SymbolKind::Function);
633    }
634}