Skip to main content

normalize_languages/
ecmascript.rs

1//! Shared ECMAScript (JavaScript/TypeScript) support functions.
2//!
3//! This module contains common logic shared between JavaScript, TypeScript, and TSX.
4//! Each language struct delegates to these functions for DRY implementation.
5
6use crate::{ImplementsInfo, Import, Visibility};
7use tree_sitter::Node;
8
9// ============================================================================
10// Visibility
11// ============================================================================
12
13/// Get visibility for a JS/TS class member.
14///
15/// TypeScript: looks for `accessibility_modifier` child (`public`, `private`, `protected`).
16/// JavaScript: checks if the member name starts with `#` (private field syntax).
17/// Default is Public (JS/TS have no module-level visibility modifiers).
18pub fn get_visibility(node: &Node, content: &str) -> Visibility {
19    let mut cursor = node.walk();
20    for child in node.children(&mut cursor) {
21        if child.kind() == "accessibility_modifier" {
22            let mod_text = &content[child.byte_range()];
23            return match mod_text {
24                "private" => Visibility::Private,
25                "protected" => Visibility::Protected,
26                "public" => Visibility::Public,
27                _ => Visibility::Public,
28            };
29        }
30    }
31    // JS private fields/methods use # prefix on the name
32    if let Some(name_node) = node.child_by_field_name("name") {
33        let name = &content[name_node.byte_range()];
34        if name.starts_with('#') {
35            return Visibility::Private;
36        }
37    }
38    Visibility::Public
39}
40
41// ============================================================================
42// Semantic hook helpers (for Language trait build_signature / extract_implements)
43// ============================================================================
44
45/// Build signature for a JS/TS function, method, or class node.
46pub fn build_signature(node: &Node, content: &str, name: &str) -> String {
47    match node.kind() {
48        "method_definition" | "method_signature" => {
49            let params = node
50                .child_by_field_name("parameters")
51                .map(|p| content[p.byte_range()].to_string())
52                .unwrap_or_else(|| "()".to_string());
53            format!("{}{}", name, params)
54        }
55        "function_declaration" | "generator_function_declaration" => {
56            let params = node
57                .child_by_field_name("parameters")
58                .map(|p| content[p.byte_range()].to_string())
59                .unwrap_or_else(|| "()".to_string());
60            format!("function {}{}", name, params)
61        }
62        "class_declaration" | "class" => format!("class {}", name),
63        "interface_declaration" => format!("interface {}", name),
64        "type_alias_declaration" => format!("type {}", name),
65        "enum_declaration" => format!("enum {}", name),
66        _ => {
67            let text = &content[node.byte_range()];
68            text.lines().next().unwrap_or(text).trim().to_string()
69        }
70    }
71}
72
73/// Extract implements/extends list for a JS/TS class or interface node.
74pub fn extract_implements(node: &Node, content: &str) -> ImplementsInfo {
75    let mut implements = Vec::new();
76    for i in 0..node.child_count() as u32 {
77        if let Some(heritage) = node.child(i)
78            && heritage.kind() == "class_heritage"
79        {
80            for j in 0..heritage.child_count() as u32 {
81                if let Some(clause) = heritage.child(j) {
82                    if clause.kind() == "extends_clause" || clause.kind() == "implements_clause" {
83                        for k in 0..clause.child_count() as u32 {
84                            if let Some(type_node) = clause.child(k)
85                                && (type_node.kind() == "type_identifier"
86                                    || type_node.kind() == "identifier")
87                            {
88                                implements.push(content[type_node.byte_range()].to_string());
89                            }
90                        }
91                    } else if clause.kind() == "type_identifier" || clause.kind() == "identifier" {
92                        implements.push(content[clause.byte_range()].to_string());
93                    }
94                }
95            }
96        }
97    }
98    ImplementsInfo {
99        is_interface: false,
100        implements,
101    }
102}
103
104// ============================================================================
105// Docstring extraction
106// ============================================================================
107
108/// Extract a JSDoc comment (`/** ... */`) preceding a node.
109///
110/// Walks backwards through siblings looking for a `comment` starting with `/**`.
111pub fn extract_jsdoc(node: &Node, content: &str) -> Option<String> {
112    let mut prev = node.prev_sibling();
113    while let Some(sibling) = prev {
114        match sibling.kind() {
115            "comment" => {
116                let text = &content[sibling.byte_range()];
117                if text.starts_with("/**") {
118                    return Some(clean_block_doc_comment(text));
119                }
120                return None;
121            }
122            "decorator" | "export_statement" => {}
123            _ => return None,
124        }
125        prev = sibling.prev_sibling();
126    }
127    None
128}
129
130/// Clean a `/** ... */` block doc comment into plain text.
131fn clean_block_doc_comment(text: &str) -> String {
132    let lines: Vec<&str> = text
133        .strip_prefix("/**")
134        .unwrap_or(text)
135        .strip_suffix("*/")
136        .unwrap_or(text)
137        .lines()
138        .map(|l| l.trim().strip_prefix('*').unwrap_or(l).trim())
139        .filter(|l| !l.is_empty())
140        .collect();
141    lines.join(" ")
142}
143
144/// Extract decorator attributes (`@decorator`) preceding a node.
145///
146/// Walks backwards through siblings looking for `decorator` nodes.
147pub fn extract_decorators(node: &Node, content: &str) -> Vec<String> {
148    let mut attrs = Vec::new();
149    let mut prev = node.prev_sibling();
150    while let Some(sibling) = prev {
151        if sibling.kind() == "decorator" {
152            attrs.insert(0, content[sibling.byte_range()].to_string());
153        } else if sibling.kind() == "comment" {
154            // Skip comments between decorators and declaration
155        } else {
156            break;
157        }
158        prev = sibling.prev_sibling();
159    }
160    attrs
161}
162
163// ============================================================================
164// Node kind constants
165// ============================================================================
166
167pub const JS_CONTAINER_KINDS: &[&str] = &["class_declaration", "class"];
168pub const TS_CONTAINER_KINDS: &[&str] = &["class_declaration", "class", "interface_declaration"];
169
170pub const JS_FUNCTION_KINDS: &[&str] = &[
171    "function_declaration",
172    "method_definition",
173    "generator_function_declaration",
174];
175pub const TS_FUNCTION_KINDS: &[&str] = &[
176    "function_declaration",
177    "method_definition",
178    "method_signature", // Interface methods
179];
180
181pub const JS_TYPE_KINDS: &[&str] = &["class_declaration"];
182pub const TS_TYPE_KINDS: &[&str] = &[
183    "class_declaration",
184    "interface_declaration",
185    "type_alias_declaration",
186    "enum_declaration",
187];
188
189pub const IMPORT_KINDS: &[&str] = &["import_statement"];
190pub const PUBLIC_SYMBOL_KINDS: &[&str] = &["export_statement"];
191
192pub const SCOPE_CREATING_KINDS: &[&str] = &[
193    "for_statement",
194    "for_in_statement",
195    "while_statement",
196    "do_statement",
197    "try_statement",
198    "catch_clause",
199    "switch_statement",
200    "arrow_function",
201];
202
203pub const CONTROL_FLOW_KINDS: &[&str] = &[
204    "if_statement",
205    "for_statement",
206    "for_in_statement",
207    "while_statement",
208    "do_statement",
209    "switch_statement",
210    "try_statement",
211    "return_statement",
212    "break_statement",
213    "continue_statement",
214    "throw_statement",
215];
216
217pub const COMPLEXITY_NODES: &[&str] = &[
218    "if_statement",
219    "for_statement",
220    "for_in_statement",
221    "while_statement",
222    "do_statement",
223    "switch_case",
224    "catch_clause",
225    "ternary_expression",
226    "binary_expression",
227];
228
229pub const NESTING_NODES: &[&str] = &[
230    "if_statement",
231    "for_statement",
232    "for_in_statement",
233    "while_statement",
234    "do_statement",
235    "switch_statement",
236    "try_statement",
237    "function_declaration",
238    "method_definition",
239    "class_declaration",
240];
241
242// ============================================================================
243// Import/export extraction
244// ============================================================================
245
246/// Extract imports from an import_statement node.
247pub fn extract_imports(node: &Node, content: &str) -> Vec<Import> {
248    if node.kind() != "import_statement" {
249        return Vec::new();
250    }
251
252    let line = node.start_position().row + 1;
253    let mut module = String::new();
254    let mut names = Vec::new();
255
256    let mut cursor = node.walk();
257    for child in node.children(&mut cursor) {
258        match child.kind() {
259            "string" | "string_fragment" => {
260                let text = &content[child.byte_range()];
261                module = text.trim_matches(|c| c == '"' || c == '\'').to_string();
262            }
263            "import_clause" => {
264                collect_import_names(&child, content, &mut names);
265            }
266            _ => {}
267        }
268    }
269
270    if module.is_empty() {
271        return Vec::new();
272    }
273
274    // Check for namespace import sentinel set by collect_import_names.
275    // `import * as ns` → is_wildcard=true, alias=Some("ns"), names=[].
276    if names.len() == 1
277        && let Some(ns_alias) = names[0].strip_prefix("__namespace__:")
278    {
279        return vec![Import {
280            module: module.clone(),
281            names: Vec::new(),
282            alias: Some(ns_alias.to_string()),
283            is_wildcard: true,
284            is_relative: module.starts_with('.'),
285            line,
286        }];
287    }
288
289    vec![Import {
290        module: module.clone(),
291        names,
292        alias: None,
293        is_wildcard: false,
294        is_relative: module.starts_with('.'),
295        line,
296    }]
297}
298
299/// Format an import as JavaScript/TypeScript source code.
300pub fn format_import(import: &Import, names: Option<&[&str]>) -> String {
301    let names_to_use: Vec<&str> = names
302        .map(|n| n.to_vec())
303        .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
304
305    if import.is_wildcard {
306        format!("import * from '{}';", import.module)
307    } else if names_to_use.is_empty() {
308        format!("import '{}';", import.module)
309    } else if names_to_use.len() == 1 {
310        format!("import {{ {} }} from '{}';", names_to_use[0], import.module)
311    } else {
312        format!(
313            "import {{ {} }} from '{}';",
314            names_to_use.join(", "),
315            import.module
316        )
317    }
318}
319
320fn collect_import_names(import_clause: &Node, content: &str, names: &mut Vec<String>) {
321    let mut cursor = import_clause.walk();
322    for child in import_clause.children(&mut cursor) {
323        match child.kind() {
324            "identifier" => {
325                // Default import: import foo from './module'
326                names.push(content[child.byte_range()].to_string());
327            }
328            "named_imports" => {
329                // { foo, bar }
330                let mut inner_cursor = child.walk();
331                for inner in child.children(&mut inner_cursor) {
332                    if inner.kind() == "import_specifier"
333                        && let Some(name_node) = inner.child_by_field_name("name")
334                    {
335                        names.push(content[name_node.byte_range()].to_string());
336                    }
337                }
338            }
339            "namespace_import" => {
340                // import * as foo — this is a wildcard import; the alias is the namespace name.
341                // We signal this by setting is_wildcard=true and alias on the Import, not by
342                // pushing to names. collect_import_names can only push names, so we push a
343                // sentinel "__namespace__:<alias>" that extract_imports rewrites after this loop.
344                if let Some(name_node) = child.child_by_field_name("name") {
345                    names.push(format!(
346                        "__namespace__:{}",
347                        &content[name_node.byte_range()]
348                    ));
349                }
350            }
351            _ => {}
352        }
353    }
354}
355
356/// Extract the file-level JSDoc comment from JavaScript/TypeScript source.
357///
358/// Looks for a `/** ... */` block comment at the very top of the file
359/// (after optional shebang or blank lines). The comment must start with `/**`
360/// to qualify as JSDoc. Regular `/* ... */` comments without the double `*`
361/// are not extracted.
362pub fn extract_js_module_doc(src: &str) -> Option<String> {
363    // Skip shebang line if present
364    let trimmed = if src.starts_with("#!") {
365        src[src.find('\n').map(|i| i + 1).unwrap_or(src.len())..].trim_start()
366    } else {
367        src.trim_start()
368    };
369
370    // Must start with `/**` (JSDoc), not just `/*`
371    if !trimmed.starts_with("/**") {
372        return None;
373    }
374
375    let end = trimmed.find("*/")?;
376    let block = &trimmed[..end + 2];
377    let doc = clean_block_doc_comment(block);
378    if doc.is_empty() { None } else { Some(doc) }
379}