Skip to main content

codesynapse_core/ts_extract/
javascript.rs

1use super::{
2    add_contains_edge, add_node_if_missing, make_file_node, run_query_matches_ranged,
3    run_query_named, run_tree_walk_docstrings,
4};
5use crate::error::Result;
6use crate::extract::{make_id, ImportNode, LanguageExtractor};
7use crate::types::{Edge, ExtractionFragment, Node};
8use std::collections::HashMap;
9use std::path::Path;
10
11pub struct TsJavaScriptExtractor;
12
13impl TsJavaScriptExtractor {
14    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
15        let (file_id, _, file_node) = make_file_node(path);
16        let mut fragment = ExtractionFragment {
17            nodes: vec![file_node],
18            edges: vec![],
19        };
20
21        let lang = tree_sitter_javascript::LANGUAGE.into();
22        let content = std::str::from_utf8(source).unwrap_or("");
23
24        let docstrings = run_tree_walk_docstrings(source, &lang, &["class_declaration"]);
25
26        // Class declarations with extends
27        let class_query = r#"
28            (class_declaration
29                name: (identifier) @class.name
30                (class_heritage
31                    (identifier) @class.base
32                )?
33            ) @class.node
34        "#;
35        if let Ok(matches) = run_query_matches_ranged(source, &lang, class_query) {
36            for (m, ranges) in &matches {
37                let name = match m.get("class.name") {
38                    Some(n) => n.clone(),
39                    None => continue,
40                };
41                let class_id = make_id(&[&file_id, &name]);
42                let source_location = ranges
43                    .get("class.node")
44                    .map(|(start, end)| format!("{}:{}", start, end));
45                let class_node = Node {
46                    id: class_id.clone(),
47                    label: name.to_string(),
48                    file_type: "class".to_string(),
49                    source_file: path.to_string_lossy().to_string(),
50                    source_location,
51                    community: None,
52                    rationale: None,
53                    docstring: docstrings.get(name.as_str()).cloned(),
54                    metadata: HashMap::new(),
55                };
56                add_node_if_missing(&mut fragment, class_node);
57                add_contains_edge(&mut fragment, &file_id, class_id.clone(), path);
58
59                if let Some(base_name) = m.get("class.base") {
60                    let base_id = make_id(&[base_name]);
61                    let base_node_ = Node {
62                        id: base_id.clone(),
63                        label: base_name.to_string(),
64                        file_type: "code".to_string(),
65                        source_file: path.to_string_lossy().to_string(),
66                        source_location: None,
67                        community: None,
68                        rationale: None,
69                        docstring: None,
70                        metadata: HashMap::new(),
71                    };
72                    add_node_if_missing(&mut fragment, base_node_);
73                    fragment.edges.push(Edge {
74                        source: class_id.clone(),
75                        target: base_id,
76                        relation: "inherits".to_string(),
77                        confidence: "EXTRACTED".to_string(),
78                        source_file: Some(path.to_string_lossy().to_string()),
79                        weight: 1.0,
80                        context: None,
81                    });
82                }
83            }
84        }
85
86        // Named function declarations
87        let fn_query = r#"
88            (function_declaration
89                name: (identifier) @func.name
90            ) @func.node
91        "#;
92        if let Ok(matches) = run_query_matches_ranged(source, &lang, fn_query) {
93            for (m, ranges) in &matches {
94                let name = match m.get("func.name") {
95                    Some(n) => n.clone(),
96                    None => continue,
97                };
98                let fn_id = make_id(&[&file_id, &name, "()"]);
99                let source_location = ranges
100                    .get("func.node")
101                    .map(|(start, end)| format!("{}:{}", start, end));
102                let fn_node = Node {
103                    id: fn_id.clone(),
104                    label: format!("{}()", name),
105                    file_type: "function".to_string(),
106                    source_file: path.to_string_lossy().to_string(),
107                    source_location,
108                    community: None,
109                    rationale: None,
110                    docstring: None,
111                    metadata: HashMap::new(),
112                };
113                add_node_if_missing(&mut fragment, fn_node);
114                add_contains_edge(&mut fragment, &file_id, fn_id, path);
115            }
116        }
117
118        // Class method definitions
119        let method_query = r#"
120            (method_definition
121                name: (property_identifier) @method.name
122            ) @method.node
123        "#;
124        if let Ok(matches) = run_query_matches_ranged(source, &lang, method_query) {
125            for (m, ranges) in &matches {
126                let name = match m.get("method.name") {
127                    Some(n) => n.clone(),
128                    None => continue,
129                };
130                let method_id = make_id(&[&file_id, &name, "()"]);
131                let source_location = ranges
132                    .get("method.node")
133                    .map(|(start, end)| format!("{}:{}", start, end));
134                let method_node = Node {
135                    id: method_id.clone(),
136                    label: format!("{}()", name),
137                    file_type: "method".to_string(),
138                    source_file: path.to_string_lossy().to_string(),
139                    source_location,
140                    community: None,
141                    rationale: None,
142                    docstring: None,
143                    metadata: HashMap::new(),
144                };
145                add_node_if_missing(&mut fragment, method_node);
146                add_contains_edge(&mut fragment, &file_id, method_id, path);
147            }
148        }
149
150        // Arrow functions and function expressions assigned to variables
151        let arrow_query = r#"
152            (variable_declarator
153                name: (identifier) @func.name
154                value: [
155                    (arrow_function)
156                    (function_expression)
157                ] @func.node
158            )
159        "#;
160        if let Ok(matches) = run_query_matches_ranged(source, &lang, arrow_query) {
161            for (m, ranges) in &matches {
162                let name = match m.get("func.name") {
163                    Some(n) => n.clone(),
164                    None => continue,
165                };
166                let fn_id = make_id(&[&file_id, &name, "()"]);
167                let source_location = ranges
168                    .get("func.node")
169                    .map(|(start, end)| format!("{}:{}", start, end));
170                let fn_node = Node {
171                    id: fn_id.clone(),
172                    label: format!("{}()", name),
173                    file_type: "function".to_string(),
174                    source_file: path.to_string_lossy().to_string(),
175                    source_location,
176                    community: None,
177                    rationale: None,
178                    docstring: None,
179                    metadata: HashMap::new(),
180                };
181                add_node_if_missing(&mut fragment, fn_node);
182                add_contains_edge(&mut fragment, &file_id, fn_id, path);
183            }
184        }
185
186        // ESM imports
187        // IMPORTANT: query children must be in tree-sibling order.
188        // `import_clause` (child 1) comes BEFORE `string` (child 3),
189        // so `(import_clause ...)` must precede `source: (string)`.
190        let import_query = r#"
191            (import_statement
192                (import_clause
193                    (named_imports
194                        (import_specifier
195                            (identifier) @import.name
196                        )
197                    )?
198                )?
199                source: (string) @import.source
200            )
201        "#;
202        if let Ok(mut captures) = run_query_named(source, &lang, import_query) {
203            let sources = captures.remove("import.source").unwrap_or_default();
204            let import_names = captures.remove("import.name").unwrap_or_default();
205
206            let mut source_modules: Vec<String> = Vec::new();
207            for s in &sources {
208                let s = s.trim().trim_matches('"').trim_matches('\'');
209                source_modules.push(s.to_string());
210            }
211
212            let mut all_import_names: Vec<String> = Vec::new();
213            for name in &import_names {
214                all_import_names.push(name.to_string());
215            }
216
217            for mod_name in source_modules.iter() {
218                let mod_id = make_id(&[&file_id, mod_name]);
219                let mod_node = Node {
220                    id: mod_id.clone(),
221                    label: mod_name.to_string(),
222                    file_type: "code".to_string(),
223                    source_file: path.to_string_lossy().to_string(),
224                    source_location: None,
225                    community: None,
226                    rationale: None,
227                    docstring: None,
228                    metadata: HashMap::new(),
229                };
230                add_node_if_missing(&mut fragment, mod_node);
231                fragment.edges.push(Edge {
232                    source: file_id.clone(),
233                    target: mod_id,
234                    relation: "imports_from".to_string(),
235                    confidence: "EXTRACTED".to_string(),
236                    source_file: Some(path.to_string_lossy().to_string()),
237                    weight: 1.0,
238                    context: None,
239                });
240            }
241
242            for name in &all_import_names {
243                let spec_id = make_id(&[&file_id, name]);
244                let spec_node = Node {
245                    id: spec_id.clone(),
246                    label: name.to_string(),
247                    file_type: "code".to_string(),
248                    source_file: path.to_string_lossy().to_string(),
249                    source_location: None,
250                    community: None,
251                    rationale: None,
252                    docstring: None,
253                    metadata: HashMap::new(),
254                };
255                add_node_if_missing(&mut fragment, spec_node);
256                fragment.edges.push(Edge {
257                    source: file_id.clone(),
258                    target: spec_id,
259                    relation: "imports".to_string(),
260                    confidence: "EXTRACTED".to_string(),
261                    source_file: Some(path.to_string_lossy().to_string()),
262                    weight: 1.0,
263                    context: None,
264                });
265            }
266        }
267
268        // Dynamic imports and require - fallback to line-based for CJS patterns
269        for line in content.lines() {
270            let trimmed = line.trim();
271            if trimmed.contains("import(") {
272                let start = trimmed.find("import(");
273                if let Some(start) = start {
274                    let rest = &trimmed[start + 7..];
275                    let source_str = rest
276                        .split(')')
277                        .next()
278                        .unwrap_or("")
279                        .trim()
280                        .trim_matches('\'')
281                        .trim_matches('"');
282                    if !source_str.is_empty() {
283                        let mod_id = make_id(&[&file_id, source_str]);
284                        let mod_node = Node {
285                            id: mod_id.clone(),
286                            label: source_str.to_string(),
287                            file_type: "code".to_string(),
288                            source_file: path.to_string_lossy().to_string(),
289                            source_location: None,
290                            community: None,
291                            rationale: None,
292                            docstring: None,
293                            metadata: HashMap::new(),
294                        };
295                        add_node_if_missing(&mut fragment, mod_node);
296                        fragment.edges.push(Edge {
297                            source: file_id.clone(),
298                            target: mod_id,
299                            relation: "imports_from".to_string(),
300                            confidence: "EXTRACTED".to_string(),
301                            source_file: Some(path.to_string_lossy().to_string()),
302                            weight: 1.0,
303                            context: None,
304                        });
305                    }
306                }
307            }
308            if trimmed.contains("require(") {
309                let start = trimmed.find("require(");
310                if let Some(start) = start {
311                    let rest = &trimmed[start + 8..];
312                    let source_str = rest
313                        .split(')')
314                        .next()
315                        .unwrap_or("")
316                        .trim()
317                        .trim_matches('\'')
318                        .trim_matches('"');
319                    if !source_str.is_empty() {
320                        let mod_id = make_id(&[&file_id, source_str]);
321                        let mod_node = Node {
322                            id: mod_id.clone(),
323                            label: source_str.to_string(),
324                            file_type: "code".to_string(),
325                            source_file: path.to_string_lossy().to_string(),
326                            source_location: None,
327                            community: None,
328                            rationale: None,
329                            docstring: None,
330                            metadata: HashMap::new(),
331                        };
332                        add_node_if_missing(&mut fragment, mod_node);
333                        fragment.edges.push(Edge {
334                            source: file_id.clone(),
335                            target: mod_id,
336                            relation: "imports_from".to_string(),
337                            confidence: "EXTRACTED".to_string(),
338                            source_file: Some(path.to_string_lossy().to_string()),
339                            weight: 1.0,
340                            context: None,
341                        });
342                    }
343                }
344            }
345        }
346
347        Ok(fragment)
348    }
349}
350
351impl LanguageExtractor for TsJavaScriptExtractor {
352    fn file_extensions(&self) -> Vec<&'static str> {
353        vec!["js", "jsx", "mjs", "cjs"]
354    }
355    fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
356        Self::extract(source, path)
357    }
358    fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
359        vec![]
360    }
361    fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
362}