Skip to main content

cha_parser/
typescript.rs

1// cha:ignore large_file
2use std::collections::hash_map::DefaultHasher;
3use std::hash::{Hash, Hasher};
4
5use cha_core::{ClassInfo, FunctionInfo, ImportInfo, SourceFile, SourceModel};
6use tree_sitter::{Node, Parser};
7
8use crate::LanguageParser;
9use crate::type_aliases::typescript as extract_type_alias;
10
11pub struct TypeScriptParser;
12pub struct TsxParser;
13
14impl LanguageParser for TypeScriptParser {
15    fn language_name(&self) -> &str {
16        "typescript"
17    }
18
19    fn ts_language(&self) -> tree_sitter::Language {
20        tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
21    }
22
23    fn parse(&self, file: &SourceFile) -> Option<SourceModel> {
24        parse_with_grammar(file, tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into())
25    }
26}
27
28impl LanguageParser for TsxParser {
29    fn language_name(&self) -> &str {
30        "typescript"
31    }
32
33    fn ts_language(&self) -> tree_sitter::Language {
34        tree_sitter_typescript::LANGUAGE_TSX.into()
35    }
36
37    fn parse(&self, file: &SourceFile) -> Option<SourceModel> {
38        parse_with_grammar(file, tree_sitter_typescript::LANGUAGE_TSX.into())
39    }
40}
41
42/// Shared parse pipeline — TypeScript and TSX use the same tree-shape
43/// extraction; only the grammar differs (TSX additionally produces
44/// `jsx_element` / `jsx_attribute` etc. nodes that downstream WASM
45/// plugins can query via `tree_query`).
46fn parse_with_grammar(file: &SourceFile, language: tree_sitter::Language) -> Option<SourceModel> {
47    let mut parser = Parser::new();
48    parser.set_language(&language).ok()?;
49    let tree = parser.parse(&file.content, None)?;
50    let root = tree.root_node();
51    let src = file.content.as_bytes();
52
53    let imports_map = crate::typescript_imports::build(root, src);
54    let mut ctx = ParseContext::new(src, imports_map);
55    ctx.collect_nodes(root, false);
56
57    Some(SourceModel {
58        language: "typescript".into(),
59        total_lines: file.line_count(),
60        functions: ctx.col.functions,
61        classes: ctx.col.classes,
62        imports: ctx.col.imports,
63        comments: collect_comments(root, src),
64        type_aliases: ctx.col.type_aliases,
65    })
66}
67
68/// Accumulator for collected AST items.
69#[derive(Default)]
70struct Collector {
71    functions: Vec<FunctionInfo>,
72    classes: Vec<ClassInfo>,
73    imports: Vec<ImportInfo>,
74    type_aliases: Vec<(String, String)>,
75}
76
77/// Bundles source bytes and collector to eliminate repeated parameter passing.
78struct ParseContext<'a> {
79    src: &'a [u8],
80    col: Collector,
81    imports_map: crate::type_ref::ImportsMap,
82}
83
84impl<'a> ParseContext<'a> {
85    fn new(src: &'a [u8], imports_map: crate::type_ref::ImportsMap) -> Self {
86        Self {
87            src,
88            imports_map,
89            col: Collector::default(),
90        }
91    }
92
93    fn collect_nodes(&mut self, node: Node, exported: bool) {
94        let mut cursor = node.walk();
95        for child in node.children(&mut cursor) {
96            self.collect_single_node(child, exported);
97        }
98    }
99
100    fn collect_single_node(&mut self, child: Node, exported: bool) {
101        match child.kind() {
102            "export_statement" => self.collect_nodes(child, true),
103            "function_declaration" | "method_definition" => self.push_function(child, exported),
104            "lexical_declaration" | "variable_declaration" => {
105                extract_arrow_functions(
106                    child,
107                    self.src,
108                    exported,
109                    &self.imports_map,
110                    &mut self.col.functions,
111                );
112                self.collect_nodes(child, exported);
113            }
114            "class_declaration" => self.push_class(child, exported),
115            "type_alias_declaration" => self
116                .col
117                .type_aliases
118                .extend(extract_type_alias(child, self.src)),
119            "import_statement" => self.push_import(child),
120            _ => self.collect_nodes(child, false),
121        }
122    }
123
124    fn push_function(&mut self, node: Node, exported: bool) {
125        if let Some(mut f) = extract_function(node, self.src, &self.imports_map) {
126            f.is_exported = exported;
127            self.col.functions.push(f);
128        }
129    }
130
131    fn push_class(&mut self, node: Node, exported: bool) {
132        if let Some(mut c) = extract_class(node, self.src) {
133            c.is_exported = exported;
134            self.col.classes.push(c);
135        }
136    }
137
138    fn push_import(&mut self, node: Node) {
139        if let Some(i) = extract_import(node, self.src) {
140            self.col.imports.push(i);
141        }
142    }
143}
144
145fn node_text<'a>(node: Node, src: &'a [u8]) -> &'a str {
146    node.utf8_text(src).unwrap_or("")
147}
148
149/// Hash the AST structure of a node (kind + children structure, ignoring names).
150fn hash_ast_structure(node: Node) -> u64 {
151    let mut hasher = DefaultHasher::new();
152    walk_hash(node, &mut hasher);
153    hasher.finish()
154}
155
156fn walk_hash(node: Node, hasher: &mut DefaultHasher) {
157    node.kind().hash(hasher);
158    let mut cursor = node.walk();
159    for child in node.children(&mut cursor) {
160        walk_hash(child, hasher);
161    }
162}
163
164fn extract_function(
165    node: Node,
166    src: &[u8],
167    imports_map: &crate::type_ref::ImportsMap,
168) -> Option<FunctionInfo> {
169    let name_node = node.child_by_field_name("name")?;
170    let name = node_text(name_node, src).to_string();
171    let name_col = name_node.start_position().column;
172    let name_end_col = name_node.end_position().column;
173    let start_line = node.start_position().row + 1;
174    let end_line = node.end_position().row + 1;
175    let body = node.child_by_field_name("body");
176    let body_hash = body.map(hash_ast_structure);
177    let parameter_count = count_parameters(node);
178    let parameter_types = extract_param_types(node, src, imports_map);
179    let parameter_names = ts_param_names(node, src);
180    let chain_depth = body.map(max_chain_depth).unwrap_or(0);
181    let switch_arms = body.map(count_switch_arms).unwrap_or(0);
182    let switch_arm_values = body
183        .map(|b| collect_ts_arm_values(b, src))
184        .unwrap_or_default();
185    let external_refs = body
186        .map(|b| collect_external_refs(b, src))
187        .unwrap_or_default();
188    let is_delegating = body.map(|b| check_delegating(b, src)).unwrap_or(false);
189    let return_type = ts_return_type(node, src, imports_map);
190    Some(FunctionInfo {
191        name,
192        start_line,
193        end_line,
194        name_col,
195        name_end_col,
196        line_count: end_line - start_line + 1,
197        complexity: count_complexity(node),
198        body_hash,
199        is_exported: false,
200        parameter_count,
201        parameter_types,
202        parameter_names,
203        chain_depth,
204        switch_arms,
205        switch_arm_values,
206        external_refs,
207        is_delegating,
208        comment_lines: count_comment_lines(node),
209        referenced_fields: collect_this_fields(body, src),
210        null_check_fields: collect_null_checks_ts(body, src),
211        switch_dispatch_target: extract_switch_target_ts(body, src),
212        optional_param_count: count_optional_params_ts(node, src),
213        called_functions: collect_calls_ts(body, src),
214        cognitive_complexity: body.map(cognitive_complexity_ts).unwrap_or(0),
215        return_type,
216    })
217}
218
219fn ts_return_type(
220    node: Node,
221    src: &[u8],
222    imports_map: &crate::type_ref::ImportsMap,
223) -> Option<cha_core::TypeRef> {
224    let ann = node.child_by_field_name("return_type")?;
225    let raw = node_text(ann, src).trim_start_matches(':').trim();
226    Some(crate::type_ref::resolve(raw, imports_map))
227}
228
229fn extract_arrow_functions(
230    node: Node,
231    src: &[u8],
232    exported: bool,
233    imports_map: &crate::type_ref::ImportsMap,
234    functions: &mut Vec<FunctionInfo>,
235) {
236    let mut cursor = node.walk();
237    for child in node.children(&mut cursor) {
238        if child.kind() == "variable_declarator"
239            && let Some(f) = try_extract_arrow(child, node, src, exported, imports_map)
240        {
241            functions.push(f);
242        }
243    }
244}
245
246// Try to extract an arrow function from a variable declarator.
247fn try_extract_arrow(
248    child: Node,
249    decl: Node,
250    src: &[u8],
251    exported: bool,
252    imports_map: &crate::type_ref::ImportsMap,
253) -> Option<FunctionInfo> {
254    let name_node = child.child_by_field_name("name")?;
255    let name = node_text(name_node, src).to_string();
256    let value = child.child_by_field_name("value")?;
257    if value.kind() != "arrow_function" {
258        return None;
259    }
260    let name_col = name_node.start_position().column;
261    let name_end_col = name_node.end_position().column;
262    let start_line = decl.start_position().row + 1;
263    let end_line = decl.end_position().row + 1;
264    let body = value.child_by_field_name("body");
265    let body_hash = body.map(hash_ast_structure);
266    Some(FunctionInfo {
267        name,
268        start_line,
269        end_line,
270        name_col,
271        name_end_col,
272        line_count: end_line - start_line + 1,
273        complexity: count_complexity(value),
274        body_hash,
275        is_exported: exported,
276        parameter_count: count_parameters(value),
277        parameter_types: extract_param_types(value, src, imports_map),
278        parameter_names: ts_param_names(value, src),
279        chain_depth: body.map(max_chain_depth).unwrap_or(0),
280        switch_arms: body.map(count_switch_arms).unwrap_or(0),
281        switch_arm_values: body
282            .map(|b| collect_ts_arm_values(b, src))
283            .unwrap_or_default(),
284        external_refs: body
285            .map(|b| collect_external_refs(b, src))
286            .unwrap_or_default(),
287        is_delegating: body.map(|b| check_delegating(b, src)).unwrap_or(false),
288        comment_lines: count_comment_lines(value),
289        referenced_fields: collect_this_fields(body, src),
290        null_check_fields: collect_null_checks_ts(body, src),
291        switch_dispatch_target: extract_switch_target_ts(body, src),
292        optional_param_count: count_optional_params_ts(value, src),
293        called_functions: collect_calls_ts(Some(value), src),
294        cognitive_complexity: cognitive_complexity_ts(value),
295        return_type: ts_return_type(value, src, imports_map),
296    })
297}
298
299fn extract_class(node: Node, src: &[u8]) -> Option<ClassInfo> {
300    let name_node = node.child_by_field_name("name")?;
301    let name = node_text(name_node, src).to_string();
302    let name_col = name_node.start_position().column;
303    let name_end_col = name_node.end_position().column;
304    let start_line = node.start_position().row + 1;
305    let end_line = node.end_position().row + 1;
306    let body = node.child_by_field_name("body")?;
307    let (methods, delegating, fields, has_behavior, cb_fields) = scan_class_body(body, src);
308    let is_interface =
309        node.kind() == "interface_declaration" || node.kind() == "abstract_class_declaration";
310    let has_listener_field = !cb_fields.is_empty();
311    let has_notify_method = has_iterate_and_call_ts(body, src, &cb_fields);
312
313    Some(ClassInfo {
314        name,
315        start_line,
316        end_line,
317        name_col,
318        name_end_col,
319        method_count: methods,
320        line_count: end_line - start_line + 1,
321        is_exported: false,
322        delegating_method_count: delegating,
323        field_count: fields.len(),
324        field_names: fields,
325        field_types: Vec::new(),
326        has_behavior,
327        is_interface,
328        parent_name: extract_parent_name(node, src),
329        override_count: 0,
330        self_call_count: 0,
331        has_listener_field,
332        has_notify_method,
333    })
334}
335
336/// Scan class body and return (method_count, delegating_count, field_names, has_behavior, callback_fields).
337fn scan_class_body(body: Node, src: &[u8]) -> (usize, usize, Vec<String>, bool, Vec<String>) {
338    let mut methods = 0;
339    let mut delegating = 0;
340    let mut fields = Vec::new();
341    let mut callback_fields = Vec::new();
342    let mut has_behavior = false;
343    let mut cursor = body.walk();
344    for child in body.children(&mut cursor) {
345        match child.kind() {
346            "method_definition" => {
347                let (is_behavior, is_delegating) = classify_method(child, src);
348                methods += 1;
349                has_behavior |= is_behavior;
350                delegating += usize::from(is_delegating);
351            }
352            "public_field_definition" | "property_definition" => {
353                if let Some(n) = child.child_by_field_name("name") {
354                    let name = node_text(n, src).to_string();
355                    if is_callback_collection_type_ts(child, src) {
356                        callback_fields.push(name.clone());
357                    }
358                    fields.push(name);
359                }
360            }
361            _ => {}
362        }
363    }
364    (methods, delegating, fields, has_behavior, callback_fields)
365}
366
367/// Classify a method: returns (is_behavior, is_delegating).
368fn classify_method(node: Node, src: &[u8]) -> (bool, bool) {
369    let mname = node
370        .child_by_field_name("name")
371        .map(|n| node_text(n, src))
372        .unwrap_or("");
373    let is_behavior = !is_accessor_name(mname) && mname != "constructor";
374    let is_delegating = node
375        .child_by_field_name("body")
376        .is_some_and(|b| check_delegating(b, src));
377    (is_behavior, is_delegating)
378}
379
380fn count_parameters(node: Node) -> usize {
381    let params = match node.child_by_field_name("parameters") {
382        Some(p) => p,
383        None => return 0,
384    };
385    let mut cursor = params.walk();
386    params
387        .children(&mut cursor)
388        .filter(|c| {
389            matches!(
390                c.kind(),
391                "required_parameter" | "optional_parameter" | "rest_parameter"
392            )
393        })
394        .count()
395}
396
397fn extract_param_types(
398    node: Node,
399    src: &[u8],
400    imports_map: &crate::type_ref::ImportsMap,
401) -> Vec<cha_core::TypeRef> {
402    let params = match node.child_by_field_name("parameters") {
403        Some(p) => p,
404        None => return vec![],
405    };
406    let mut types = Vec::new();
407    let mut cursor = params.walk();
408    for child in params.children(&mut cursor) {
409        if let Some(ann) = child.child_by_field_name("type") {
410            // TS type_annotation nodes include the leading ':'; strip it.
411            let raw = node_text(ann, src)
412                .trim_start_matches(':')
413                .trim()
414                .to_string();
415            types.push(crate::type_ref::resolve(raw, imports_map));
416        }
417    }
418    types
419}
420
421/// Parallel to `extract_param_types`. Only emits a name when the
422/// parameter also has a type annotation — keeps the two vectors
423/// length-aligned for positional analyses.
424fn ts_param_names(node: Node, src: &[u8]) -> Vec<String> {
425    let params = match node.child_by_field_name("parameters") {
426        Some(p) => p,
427        None => return vec![],
428    };
429    let mut names = Vec::new();
430    let mut cursor = params.walk();
431    for child in params.children(&mut cursor) {
432        if child.child_by_field_name("type").is_some() {
433            let pat = child.child_by_field_name("pattern").or_else(|| {
434                let mut c = child.walk();
435                child.children(&mut c).find(|n| {
436                    matches!(
437                        n.kind(),
438                        "identifier" | "shorthand_property_identifier_pattern"
439                    )
440                })
441            });
442            names.push(
443                pat.map(|p| node_text(p, src).to_string())
444                    .unwrap_or_default(),
445            );
446        }
447    }
448    names
449}
450
451fn max_chain_depth(node: Node) -> usize {
452    let mut max = 0;
453    walk_chain_depth(node, &mut max);
454    max
455}
456
457fn walk_chain_depth(node: Node, max: &mut usize) {
458    if node.kind() == "member_expression" {
459        let depth = measure_chain(node);
460        if depth > *max {
461            *max = depth;
462        }
463    }
464    let mut cursor = node.walk();
465    for child in node.children(&mut cursor) {
466        walk_chain_depth(child, max);
467    }
468}
469
470/// Count consecutive property accesses (a.b.c.d), skipping method calls.
471fn measure_chain(node: Node) -> usize {
472    let mut depth = 0;
473    let mut current = node;
474    while current.kind() == "member_expression" {
475        depth += 1;
476        if let Some(obj) = current.child_by_field_name("object") {
477            current = obj;
478        } else {
479            break;
480        }
481    }
482    depth
483}
484
485fn collect_ts_arm_values(body: Node, src: &[u8]) -> Vec<cha_core::ArmValue> {
486    let mut out = Vec::new();
487    crate::switch_arms::walk_arms(body, src, &mut out, &|n| n.kind() == "switch_case");
488    out
489}
490
491fn count_switch_arms(node: Node) -> usize {
492    let mut count = 0;
493    walk_switch_arms(node, &mut count);
494    count
495}
496
497fn walk_switch_arms(node: Node, count: &mut usize) {
498    if node.kind() == "switch_case" || node.kind() == "switch_default" {
499        *count += 1;
500    }
501    let mut cursor = node.walk();
502    for child in node.children(&mut cursor) {
503        walk_switch_arms(child, count);
504    }
505}
506
507fn collect_external_refs(node: Node, src: &[u8]) -> Vec<String> {
508    let mut refs = Vec::new();
509    walk_external_refs(node, src, &mut refs);
510    refs.sort();
511    refs.dedup();
512    refs
513}
514
515fn member_chain_root(node: Node) -> Node {
516    let mut current = node;
517    while current.kind() == "member_expression" {
518        match current.child_by_field_name("object") {
519            Some(child) => current = child,
520            None => break,
521        }
522    }
523    current
524}
525
526fn walk_external_refs(node: Node, src: &[u8], refs: &mut Vec<String>) {
527    if node.kind() == "member_expression" {
528        let root = member_chain_root(node);
529        let text = node_text(root, src);
530        if text != "this" && text != "self" && !text.is_empty() {
531            refs.push(text.to_string());
532        }
533    }
534    let mut cursor = node.walk();
535    for child in node.children(&mut cursor) {
536        walk_external_refs(child, src, refs);
537    }
538}
539
540fn single_stmt(body: Node) -> Option<Node> {
541    let mut cursor = body.walk();
542    let stmts: Vec<_> = body
543        .children(&mut cursor)
544        .filter(|c| c.kind() != "{" && c.kind() != "}")
545        .collect();
546    (stmts.len() == 1).then(|| stmts[0])
547}
548
549fn is_external_call(node: Node, src: &[u8]) -> bool {
550    node.kind() == "call_expression"
551        && node.child_by_field_name("function").is_some_and(|func| {
552            func.kind() == "member_expression"
553                && func
554                    .child_by_field_name("object")
555                    .is_some_and(|obj| node_text(obj, src) != "this")
556        })
557}
558
559fn check_delegating(body: Node, src: &[u8]) -> bool {
560    let Some(stmt) = single_stmt(body) else {
561        return false;
562    };
563    let expr = match stmt.kind() {
564        "return_statement" => stmt.child(1).unwrap_or(stmt),
565        "expression_statement" => stmt.child(0).unwrap_or(stmt),
566        _ => stmt,
567    };
568    is_external_call(expr, src)
569}
570
571fn count_complexity(node: Node) -> usize {
572    let mut complexity = 1;
573    walk_complexity(node, &mut complexity);
574    complexity
575}
576
577fn walk_complexity(node: Node, count: &mut usize) {
578    match node.kind() {
579        "if_statement" | "else_clause" | "for_statement" | "for_in_statement"
580        | "while_statement" | "do_statement" | "switch_case" | "catch_clause"
581        | "ternary_expression" => {
582            *count += 1;
583        }
584        "binary_expression" => {
585            let mut cursor = node.walk();
586            for child in node.children(&mut cursor) {
587                if child.kind() == "&&" || child.kind() == "||" {
588                    *count += 1;
589                }
590            }
591        }
592        _ => {}
593    }
594    let mut cursor = node.walk();
595    for child in node.children(&mut cursor) {
596        walk_complexity(child, count);
597    }
598}
599
600fn extract_import(node: Node, src: &[u8]) -> Option<ImportInfo> {
601    let mut cursor = node.walk();
602    for child in node.children(&mut cursor) {
603        if child.kind() == "string" {
604            let raw = node_text(child, src);
605            let source = raw.trim_matches(|c| c == '\'' || c == '"').to_string();
606            return Some(ImportInfo {
607                source,
608                line: node.start_position().row + 1,
609                col: node.start_position().column,
610                ..Default::default()
611            });
612        }
613    }
614    None
615}
616
617/// Count comment lines inside a node (recursive).
618fn count_comment_lines(node: Node) -> usize {
619    let mut count = 0;
620    let mut cursor = node.walk();
621    for child in node.children(&mut cursor) {
622        if child.kind() == "comment" {
623            count += child.end_position().row - child.start_position().row + 1;
624        } else if child.child_count() > 0 {
625            count += count_comment_lines(child);
626        }
627    }
628    count
629}
630
631// cha:ignore todo_comment
632/// Collect `this.xxx` field references from a function body.
633fn collect_this_fields(body: Option<Node>, src: &[u8]) -> Vec<String> {
634    let Some(body) = body else { return vec![] };
635    let mut refs = Vec::new();
636    collect_this_refs(body, src, &mut refs);
637    refs.sort();
638    refs.dedup();
639    refs
640}
641
642fn collect_this_refs(node: Node, src: &[u8], refs: &mut Vec<String>) {
643    if node.kind() == "member_expression"
644        && let Some(obj) = node.child_by_field_name("object")
645        && node_text(obj, src) == "this"
646        && let Some(prop) = node.child_by_field_name("property")
647    {
648        refs.push(node_text(prop, src).to_string());
649    }
650    let mut cursor = node.walk();
651    for child in node.children(&mut cursor) {
652        collect_this_refs(child, src, refs);
653    }
654}
655
656/// Check if a method name looks like a getter/setter.
657fn is_accessor_name(name: &str) -> bool {
658    let lower = name.to_lowercase();
659    lower.starts_with("get") || lower.starts_with("set") || lower.starts_with("is")
660}
661
662/// Extract parent class name from `extends` clause.
663// cha:ignore cognitive_complexity
664fn extract_parent_name(node: Node, src: &[u8]) -> Option<String> {
665    let mut cursor = node.walk();
666    for child in node.children(&mut cursor) {
667        if child.kind() == "class_heritage" {
668            let mut inner = child.walk();
669            for c in child.children(&mut inner) {
670                if c.kind() == "extends_clause" {
671                    // First identifier child is the parent name
672                    let mut ec = c.walk();
673                    for e in c.children(&mut ec) {
674                        if e.kind() == "identifier" || e.kind() == "type_identifier" {
675                            return Some(node_text(e, src).to_string());
676                        }
677                    }
678                }
679            }
680        }
681    }
682    None
683}
684
685/// Collect field names checked for null/undefined in TS.
686fn collect_null_checks_ts(body: Option<Node>, src: &[u8]) -> Vec<String> {
687    let Some(body) = body else { return vec![] };
688    let mut fields = Vec::new();
689    walk_null_checks_ts(body, src, &mut fields);
690    fields.sort();
691    fields.dedup();
692    fields
693}
694
695fn walk_null_checks_ts(node: Node, src: &[u8], fields: &mut Vec<String>) {
696    if node.kind() == "binary_expression"
697        && let text = node_text(node, src)
698        && (text.contains("null") || text.contains("undefined"))
699        && let Some(left) = node.child_by_field_name("left")
700        && let ltext = node_text(left, src)
701        && let Some(f) = ltext.strip_prefix("this.")
702    {
703        fields.push(f.to_string());
704    }
705    let mut cursor = node.walk();
706    for child in node.children(&mut cursor) {
707        walk_null_checks_ts(child, src, fields);
708    }
709}
710
711/// Extract switch dispatch target in TS.
712fn extract_switch_target_ts(body: Option<Node>, src: &[u8]) -> Option<String> {
713    let body = body?;
714    find_switch_target_ts(body, src)
715}
716
717fn find_switch_target_ts(node: Node, src: &[u8]) -> Option<String> {
718    if node.kind() == "switch_statement"
719        && let Some(value) = node.child_by_field_name("value")
720    {
721        return Some(node_text(value, src).to_string());
722    }
723    let mut cursor = node.walk();
724    for child in node.children(&mut cursor) {
725        if let Some(t) = find_switch_target_ts(child, src) {
726            return Some(t);
727        }
728    }
729    None
730}
731
732/// Count optional parameters in TS (those with ? or default value).
733fn count_optional_params_ts(node: Node, src: &[u8]) -> usize {
734    let Some(params) = node.child_by_field_name("parameters") else {
735        return 0;
736    };
737    let mut count = 0;
738    let mut cursor = params.walk();
739    for child in params.children(&mut cursor) {
740        let text = node_text(child, src);
741        if text.contains('?') || child.child_by_field_name("value").is_some() {
742            count += 1;
743        }
744    }
745    count
746}
747
748/// Check if a field's type annotation is a callback collection.
749/// Matches: `Function[]`, `Array<Function>`, `(() => void)[]`, `((x: T) => R)[]`
750fn is_callback_collection_type_ts(field_node: Node, src: &[u8]) -> bool {
751    let Some(ty) = field_node.child_by_field_name("type") else {
752        // No type annotation — check initializer for array literal
753        if let Some(init) = field_node.child_by_field_name("value") {
754            let text = node_text(init, src);
755            return text == "[]" || text.contains("new Array");
756        }
757        return false;
758    };
759    let text = node_text(ty, src);
760    // Function[] or Array<Function> or (() => void)[] or ((...) => ...)[]
761    (text.contains("Function") && (text.contains("[]") || text.contains("Array<")))
762        || (text.contains("=>") && text.contains("[]"))
763        || text.contains("Array<(")
764}
765
766/// Structural Observer detection for TS: method iterates a callback field and calls elements.
767/// Pattern: `this.field.forEach(cb => cb(...))` or `for (const cb of this.field) { cb(...) }`
768fn has_iterate_and_call_ts(body: Node, src: &[u8], cb_fields: &[String]) -> bool {
769    if cb_fields.is_empty() {
770        return false;
771    }
772    let mut cursor = body.walk();
773    for child in body.children(&mut cursor) {
774        if child.kind() == "method_definition"
775            && let Some(fn_body) = child.child_by_field_name("body")
776        {
777            for field in cb_fields {
778                let this_field = format!("this.{field}");
779                if walk_for_iterate_call_ts(fn_body, src, &this_field) {
780                    return true;
781                }
782            }
783        }
784    }
785    false
786}
787
788fn walk_for_iterate_call_ts(node: Node, src: &[u8], this_field: &str) -> bool {
789    // for (const x of this.field) { x(...) }
790    if node.kind() == "for_in_statement"
791        && node_text(node, src).contains(this_field)
792        && let Some(loop_body) = node.child_by_field_name("body")
793        && has_call_expression_ts(loop_body)
794    {
795        return true;
796    }
797    // this.field.forEach(cb => cb(...))
798    if node.kind() == "call_expression" || node.kind() == "expression_statement" {
799        let text = node_text(node, src);
800        if text.contains(this_field) && text.contains("forEach") {
801            return true;
802        }
803    }
804    let mut cursor = node.walk();
805    for child in node.children(&mut cursor) {
806        if walk_for_iterate_call_ts(child, src, this_field) {
807            return true;
808        }
809    }
810    false
811}
812
813fn collect_comments(root: Node, src: &[u8]) -> Vec<cha_core::CommentInfo> {
814    let mut comments = Vec::new();
815    let mut cursor = root.walk();
816    visit_all(root, &mut cursor, &mut |n| {
817        if n.kind().contains("comment") {
818            comments.push(cha_core::CommentInfo {
819                text: node_text(n, src).to_string(),
820                line: n.start_position().row + 1,
821            });
822        }
823    });
824    comments
825}
826
827fn has_call_expression_ts(node: Node) -> bool {
828    if node.kind() == "call_expression" {
829        return true;
830    }
831    let mut cursor = node.walk();
832    for child in node.children(&mut cursor) {
833        if has_call_expression_ts(child) {
834            return true;
835        }
836    }
837    false
838}
839
840fn cognitive_complexity_ts(node: tree_sitter::Node) -> usize {
841    let mut score = 0;
842    cc_walk_ts(node, 0, &mut score);
843    score
844}
845
846fn cc_walk_ts(node: tree_sitter::Node, nesting: usize, score: &mut usize) {
847    match node.kind() {
848        "if_statement" => {
849            *score += 1 + nesting;
850            cc_children_ts(node, nesting + 1, score);
851            return;
852        }
853        "for_statement" | "for_in_statement" | "while_statement" | "do_statement" => {
854            *score += 1 + nesting;
855            cc_children_ts(node, nesting + 1, score);
856            return;
857        }
858        "switch_statement" => {
859            *score += 1 + nesting;
860            cc_children_ts(node, nesting + 1, score);
861            return;
862        }
863        "else_clause" => {
864            *score += 1;
865        }
866        "binary_expression" => {
867            if let Some(op) = node.child_by_field_name("operator")
868                && (op.kind() == "&&" || op.kind() == "||")
869            {
870                *score += 1;
871            }
872        }
873        "catch_clause" => {
874            *score += 1 + nesting;
875            cc_children_ts(node, nesting + 1, score);
876            return;
877        }
878        "arrow_function" | "function_expression" => {
879            cc_children_ts(node, nesting + 1, score);
880            return;
881        }
882        _ => {}
883    }
884    cc_children_ts(node, nesting, score);
885}
886
887fn cc_children_ts(node: tree_sitter::Node, nesting: usize, score: &mut usize) {
888    let mut cursor = node.walk();
889    for child in node.children(&mut cursor) {
890        cc_walk_ts(child, nesting, score);
891    }
892}
893
894fn collect_calls_ts(body: Option<tree_sitter::Node>, src: &[u8]) -> Vec<String> {
895    let Some(body) = body else { return Vec::new() };
896    let mut calls = Vec::new();
897    let mut cursor = body.walk();
898    visit_all(body, &mut cursor, &mut |n| {
899        if n.kind() == "call_expression"
900            && let Some(func) = n.child(0)
901        {
902            let name = node_text(func, src).to_string();
903            if !calls.contains(&name) {
904                calls.push(name);
905            }
906        }
907    });
908    calls
909}
910
911fn visit_all<F: FnMut(Node)>(node: Node, cursor: &mut tree_sitter::TreeCursor, f: &mut F) {
912    f(node);
913    if cursor.goto_first_child() {
914        loop {
915            let child_node = cursor.node();
916            let mut child_cursor = child_node.walk();
917            visit_all(child_node, &mut child_cursor, f);
918            if !cursor.goto_next_sibling() {
919                break;
920            }
921        }
922        cursor.goto_parent();
923    }
924}