Skip to main content

cgx_engine/parsers/
ts.rs

1use tree_sitter::{Node, Parser, Query, QueryCursor};
2
3use crate::parser::{
4    CommentKind, CommentTag, EdgeDef, EdgeKind, LanguageParser, NodeDef, NodeKind, ParseResult,
5};
6use crate::walker::SourceFile;
7
8pub struct TypeScriptParser;
9
10impl TypeScriptParser {
11    pub fn new() -> Self {
12        Self
13    }
14}
15
16impl Default for TypeScriptParser {
17    fn default() -> Self {
18        Self::new()
19    }
20}
21
22fn is_jsx_extension(path: &str) -> bool {
23    path.ends_with(".tsx") || path.ends_with(".jsx")
24}
25
26impl LanguageParser for TypeScriptParser {
27    fn extensions(&self) -> &[&str] {
28        &["ts", "tsx", "js", "jsx", "mjs", "cjs"]
29    }
30
31    fn extract(&self, file: &SourceFile) -> anyhow::Result<ParseResult> {
32        // TSX/JSX files must use the TSX grammar; TypeScript grammar rejects JSX syntax
33        // and produces error nodes with wrong line positions for every JSX element.
34        let language = if is_jsx_extension(&file.relative_path) {
35            tree_sitter_typescript::language_tsx()
36        } else {
37            tree_sitter_typescript::language_typescript()
38        };
39
40        let mut parser = Parser::new();
41        parser.set_language(&language)?;
42
43        let tree = parser
44            .parse(&file.content, None)
45            .ok_or_else(|| anyhow::anyhow!("failed to parse {}", file.relative_path))?;
46
47        let source_bytes = file.content.as_bytes();
48        let root = tree.root_node();
49        let mut nodes = Vec::new();
50        let mut edges = Vec::new();
51
52        let fp = file_node_id(&file.relative_path);
53
54        // Parse function declarations
55        if let Ok(query) = Query::new(
56            &language,
57            "(function_declaration name: (identifier) @name) @fn",
58        ) {
59            extract_nodes(
60                &mut nodes,
61                &mut edges,
62                file,
63                &query,
64                root,
65                source_bytes,
66                NodeKind::Function,
67                "fn",
68                &fp,
69            );
70        }
71
72        // Parse arrow functions / variable declarations with arrow
73        if let Ok(query) = Query::new(
74            &language,
75            "(variable_declarator name: (identifier) @name value: (arrow_function) @fn)",
76        ) {
77            extract_nodes(
78                &mut nodes,
79                &mut edges,
80                file,
81                &query,
82                root,
83                source_bytes,
84                NodeKind::Function,
85                "fn",
86                &fp,
87            );
88        }
89
90        // Parse variable declarations with function expressions
91        if let Ok(query) = Query::new(
92            &language,
93            "(variable_declarator name: (identifier) @name value: (function_expression) @fn)",
94        ) {
95            extract_nodes(
96                &mut nodes,
97                &mut edges,
98                file,
99                &query,
100                root,
101                source_bytes,
102                NodeKind::Function,
103                "fn",
104                &fp,
105            );
106        }
107
108        // Parse class declarations
109        if let Ok(query) = Query::new(
110            &language,
111            "(class_declaration name: (type_identifier) @name) @cls",
112        ) {
113            extract_nodes(
114                &mut nodes,
115                &mut edges,
116                file,
117                &query,
118                root,
119                source_bytes,
120                NodeKind::Class,
121                "cls",
122                &fp,
123            );
124        }
125
126        // Parse method definitions
127        if let Ok(query) = Query::new(
128            &language,
129            "(method_definition name: (property_identifier) @name) @m",
130        ) {
131            extract_nodes(
132                &mut nodes,
133                &mut edges,
134                file,
135                &query,
136                root,
137                source_bytes,
138                NodeKind::Function,
139                "fn",
140                &fp,
141            );
142        }
143
144        // Parse imports — walk the tree directly to find import statements
145        extract_imports(&mut edges, root, source_bytes, &fp, file);
146
147        // Parse exports
148        if let Ok(query) = Query::new(
149            &language,
150            "(export_statement (function_declaration name: (identifier) @name) @expr)",
151        ) {
152            process_exports(
153                &mut nodes,
154                &mut edges,
155                file,
156                &query,
157                root,
158                source_bytes,
159                &fp,
160                "fn",
161            );
162        }
163
164        if let Ok(query) = Query::new(
165            &language,
166            "(export_statement (class_declaration name: (type_identifier) @name) @expr)",
167        ) {
168            process_exports(
169                &mut nodes,
170                &mut edges,
171                file,
172                &query,
173                root,
174                source_bytes,
175                &fp,
176                "cls",
177            );
178        }
179
180        // Extract CALLS edges by walking the AST with function context tracking
181        extract_calls(&mut edges, root, source_bytes, file);
182
183        // Mark exported nodes based on export statements
184        // Merge exported=true into existing metadata (preserving complexity, doc_comment, etc.)
185        let exported_names = collect_exported_names(root, source_bytes);
186        for node in &mut nodes {
187            if exported_names.contains(&node.name) {
188                if let Some(obj) = node.metadata.as_object_mut() {
189                    obj.insert("exported".to_string(), serde_json::Value::Bool(true));
190                } else {
191                    node.metadata = serde_json::json!({"exported": true});
192                }
193            }
194        }
195
196        // Extract JSX expression comments and annotation tags (TODO/FIXME/etc.)
197        let mut comment_tags = Vec::new();
198        extract_jsx_comments(&mut comment_tags, root, source_bytes, false);
199
200        Ok(ParseResult {
201            nodes,
202            edges,
203            comment_tags,
204        })
205    }
206}
207
208fn collect_exported_names(
209    root: tree_sitter::Node,
210    source_bytes: &[u8],
211) -> std::collections::HashSet<String> {
212    let mut exported = std::collections::HashSet::new();
213    collect_exported_names_walk(root, source_bytes, &mut exported);
214    exported
215}
216
217fn collect_exported_names_walk(
218    node: tree_sitter::Node,
219    source_bytes: &[u8],
220    exported: &mut std::collections::HashSet<String>,
221) {
222    if node.kind() == "export_statement" {
223        // Walk children to find identifiers/function names
224        for i in 0..node.child_count() {
225            if let Some(child) = node.child(i) {
226                match child.kind() {
227                    "function_declaration" | "class_declaration" => {
228                        if let Some(name_node) = child.child_by_field_name("name") {
229                            exported.insert(node_text(name_node, source_bytes));
230                        }
231                    }
232                    "variable_declaration" => {
233                        // export const foo = ...
234                        for j in 0..child.child_count() {
235                            if let Some(decl) = child.child(j) {
236                                if decl.kind() == "variable_declarator" {
237                                    if let Some(name_node) = decl.child_by_field_name("name") {
238                                        exported.insert(node_text(name_node, source_bytes));
239                                    }
240                                }
241                            }
242                        }
243                    }
244                    "export_clause" => {
245                        // export { foo, bar }
246                        for j in 0..child.child_count() {
247                            if let Some(spec) = child.child(j) {
248                                if spec.kind() == "export_specifier" {
249                                    if let Some(name_node) = spec.child_by_field_name("name") {
250                                        exported.insert(node_text(name_node, source_bytes));
251                                    }
252                                }
253                            }
254                        }
255                    }
256                    _ => {}
257                }
258            }
259        }
260    }
261    // recurse
262    for i in 0..node.child_count() {
263        if let Some(child) = node.child(i) {
264            collect_exported_names_walk(child, source_bytes, exported);
265        }
266    }
267}
268
269fn file_node_id(rel_path: &str) -> String {
270    format!("file:{}", rel_path)
271}
272
273#[allow(clippy::too_many_arguments)]
274fn extract_nodes(
275    nodes: &mut Vec<NodeDef>,
276    edges: &mut Vec<EdgeDef>,
277    file: &SourceFile,
278    query: &Query,
279    root: tree_sitter::Node,
280    source_bytes: &[u8],
281    kind: NodeKind,
282    prefix: &str,
283    file_id: &str,
284) {
285    let mut cursor = QueryCursor::new();
286    for m in cursor.matches(query, root, source_bytes) {
287        let Some(name_capture) = m
288            .captures
289            .iter()
290            .find(|c| query.capture_names()[c.index as usize] == "name")
291        else {
292            continue;
293        };
294
295        let name = unquote_str(&source_bytes[name_capture.node.byte_range()]);
296        let node_start = name_capture.node.start_position();
297
298        // Use the body node's end position so the snippet covers the full function/class body
299        let body_end = m
300            .captures
301            .iter()
302            .find(|c| {
303                let cap_name = &query.capture_names()[c.index as usize];
304                *cap_name == "fn" || *cap_name == "cls" || *cap_name == "m"
305            })
306            .map(|c| c.node.end_position())
307            .unwrap_or_else(|| name_capture.node.end_position());
308
309        let fn_capture_node = m.captures.iter().find(|c| {
310            let cap_name = &query.capture_names()[c.index as usize];
311            *cap_name == "fn" || *cap_name == "cls" || *cap_name == "m"
312        });
313
314        let Some(fn_capture) = fn_capture_node else {
315            continue;
316        };
317
318        let id = format!("{}:{}:{}", prefix, file.relative_path, name);
319
320        // Compute cyclomatic-style complexity
321        let complexity = compute_complexity(fn_capture.node, source_bytes);
322
323        // Extract doc comment (/** */ or /// just before the function)
324        let fn_line = node_start.row as u32 + 1;
325        let doc_comment = extract_doc_comment(root, source_bytes, fn_line);
326
327        let metadata = serde_json::json!({
328            "complexity": complexity,
329            "doc_comment": doc_comment,
330        });
331
332        nodes.push(NodeDef {
333            id,
334            kind: kind.clone(),
335            name,
336            path: file.relative_path.clone(),
337            line_start: fn_line,
338            line_end: body_end.row as u32 + 1,
339            metadata,
340        });
341
342        edges.push(EdgeDef {
343            src: file_id.to_string(),
344            dst: format!(
345                "{}:{}:{}",
346                prefix,
347                file.relative_path,
348                unquote_str(&source_bytes[name_capture.node.byte_range()])
349            ),
350            kind: EdgeKind::Exports,
351            ..Default::default()
352        });
353    }
354}
355
356#[allow(clippy::too_many_arguments)]
357fn process_exports(
358    _nodes: &mut Vec<NodeDef>,
359    edges: &mut Vec<EdgeDef>,
360    file: &SourceFile,
361    query: &Query,
362    root: tree_sitter::Node,
363    source_bytes: &[u8],
364    file_id: &str,
365    prefix: &str,
366) {
367    let mut cursor = QueryCursor::new();
368    for m in cursor.matches(query, root, source_bytes) {
369        let Some(name_capture) = m
370            .captures
371            .iter()
372            .find(|c| query.capture_names()[c.index as usize] == "name")
373        else {
374            continue;
375        };
376
377        let name = node_text(name_capture.node, source_bytes);
378
379        edges.push(EdgeDef {
380            src: file_id.to_string(),
381            dst: format!("{}:{}:{}", prefix, file.relative_path, name),
382            kind: EdgeKind::Exports,
383            ..Default::default()
384        });
385    }
386}
387
388fn node_text(node: tree_sitter::Node, source: &[u8]) -> String {
389    node.utf8_text(source).unwrap_or("").to_string()
390}
391
392fn extract_imports(
393    edges: &mut Vec<EdgeDef>,
394    root: tree_sitter::Node,
395    source_bytes: &[u8],
396    file_id: &str,
397    file: &SourceFile,
398) {
399    // Walk the entire tree (not just root children) to find imports and requires
400    let mut cursor = root.walk();
401    traverse_imports(edges, root, source_bytes, file_id, file, &mut cursor);
402}
403
404fn traverse_imports(
405    edges: &mut Vec<EdgeDef>,
406    node: tree_sitter::Node,
407    source_bytes: &[u8],
408    file_id: &str,
409    file: &SourceFile,
410    cursor: &mut tree_sitter::TreeCursor,
411) {
412    if node.kind() == "import_statement" {
413        for j in 0..node.child_count() {
414            let Some(import_child) = node.child(j) else {
415                continue;
416            };
417            if import_child.kind() == "string" {
418                let import_path = unquote_str(&source_bytes[import_child.byte_range()]);
419                if import_path.starts_with('.') {
420                    let resolved = resolve_import_path(&file.relative_path, &import_path);
421                    if !resolved.is_empty() {
422                        edges.push(EdgeDef {
423                            src: file_id.to_string(),
424                            dst: file_node_id(&resolved),
425                            kind: EdgeKind::Imports,
426                            ..Default::default()
427                        });
428                    }
429                }
430                break;
431            }
432        }
433    } else if node.kind() == "call_expression" {
434        // Check for require('...')
435        if let Some(func) = node.child_by_field_name("function") {
436            if func.kind() == "identifier" && node_text(func, source_bytes) == "require" {
437                if let Some(args) = node.child_by_field_name("arguments") {
438                    for k in 0..args.child_count() {
439                        let Some(arg) = args.child(k) else { continue };
440                        if arg.kind() == "string" {
441                            let import_path = unquote_str(&source_bytes[arg.byte_range()]);
442                            if import_path.starts_with('.') {
443                                let resolved =
444                                    resolve_import_path(&file.relative_path, &import_path);
445                                if !resolved.is_empty() {
446                                    edges.push(EdgeDef {
447                                        src: file_id.to_string(),
448                                        dst: file_node_id(&resolved),
449                                        kind: EdgeKind::Imports,
450                                        ..Default::default()
451                                    });
452                                }
453                            }
454                            break;
455                        }
456                    }
457                }
458            }
459        }
460    }
461
462    if cursor.goto_first_child() {
463        loop {
464            let child = cursor.node();
465            traverse_imports(edges, child, source_bytes, file_id, file, cursor);
466            if !cursor.goto_next_sibling() {
467                break;
468            }
469        }
470        cursor.goto_parent();
471    }
472}
473
474fn unquote_str(s: &[u8]) -> String {
475    let s = std::str::from_utf8(s).unwrap_or("");
476    s.trim().trim_matches('\'').trim_matches('"').to_string()
477}
478
479fn resolve_import_path(current: &str, import: &str) -> String {
480    let mut parts: Vec<&str> = current.split('/').collect();
481    parts.pop(); // remove filename
482
483    for segment in import.split('/') {
484        match segment {
485            "." => {}
486            ".." => {
487                parts.pop();
488            }
489            _ => parts.push(segment),
490        }
491    }
492
493    parts.join("/")
494}
495
496/// Walk the AST tracking function context, emitting CALLS edges for each call_expression.
497fn extract_calls(edges: &mut Vec<EdgeDef>, root: Node, source: &[u8], file: &SourceFile) {
498    let mut fn_stack: Vec<String> = Vec::new();
499    walk_for_calls(edges, root, source, file, &mut fn_stack);
500}
501
502fn is_fn_node(kind: &str) -> bool {
503    matches!(
504        kind,
505        "function_declaration"
506            | "function"
507            | "arrow_function"
508            | "method_definition"
509            | "generator_function_declaration"
510            | "generator_function"
511    )
512}
513
514fn fn_name_from_node<'a>(node: Node<'a>, source: &[u8], file: &SourceFile) -> Option<String> {
515    // function_declaration / generator_function_declaration: has `name` field
516    if let Some(name_node) = node.child_by_field_name("name") {
517        let name = name_node.utf8_text(source).unwrap_or("").to_string();
518        if !name.is_empty() {
519            return Some(format!("fn:{}:{}", file.relative_path, name));
520        }
521    }
522    // Arrow/anonymous assigned to variable: look at parent variable_declarator
523    let parent = node.parent()?;
524    if parent.kind() == "variable_declarator" {
525        if let Some(name_node) = parent.child_by_field_name("name") {
526            let name = name_node.utf8_text(source).unwrap_or("").to_string();
527            if !name.is_empty() {
528                return Some(format!("fn:{}:{}", file.relative_path, name));
529            }
530        }
531    }
532    None
533}
534
535fn walk_for_calls(
536    edges: &mut Vec<EdgeDef>,
537    node: Node,
538    source: &[u8],
539    file: &SourceFile,
540    fn_stack: &mut Vec<String>,
541) {
542    let kind = node.kind();
543    let pushed = is_fn_node(kind);
544
545    if pushed {
546        if let Some(id) = fn_name_from_node(node, source, file) {
547            fn_stack.push(id);
548        } else {
549            // anonymous — push a sentinel so pop is balanced
550            fn_stack.push(String::new());
551        }
552    }
553
554    // Effective caller: innermost named function, or the file node for module-level code
555    let caller_id: Option<String> = fn_stack
556        .iter()
557        .rev()
558        .find(|s| !s.is_empty())
559        .cloned()
560        .or_else(|| Some(format!("file:{}", file.relative_path)));
561
562    if kind == "call_expression" {
563        if let Some(ref caller) = caller_id {
564            let func_node = node.child_by_field_name("function");
565            let callee_name = func_node
566                .as_ref()
567                .and_then(|func| match func.kind() {
568                    "identifier" => Some(func.utf8_text(source).unwrap_or("").to_string()),
569                    "member_expression" => func
570                        .child_by_field_name("property")
571                        .map(|p| p.utf8_text(source).unwrap_or("").to_string()),
572                    _ => None,
573                })
574                .unwrap_or_default();
575
576            if !callee_name.is_empty() && callee_name != "require" {
577                edges.push(EdgeDef {
578                    src: caller.clone(),
579                    dst: callee_name,
580                    kind: EdgeKind::Calls,
581                    confidence: 0.7,
582                    ..Default::default()
583                });
584            }
585
586            // For `Obj.method()` also emit a CALLS edge to the object identifier so
587            // classes used only via static methods aren't flagged as dead code.
588            if let Some(func) = func_node {
589                if func.kind() == "member_expression" {
590                    if let Some(obj) = func.child_by_field_name("object") {
591                        if obj.kind() == "identifier" {
592                            let obj_name = obj.utf8_text(source).unwrap_or("").to_string();
593                            if !obj_name.is_empty() {
594                                edges.push(EdgeDef {
595                                    src: caller.clone(),
596                                    dst: obj_name,
597                                    kind: EdgeKind::Calls,
598                                    confidence: 0.6,
599                                    ..Default::default()
600                                });
601                            }
602                        }
603                    }
604                }
605            }
606        }
607    }
608
609    // new_expression: `new ClassName(...)` — emit CALLS edge to the constructor
610    if kind == "new_expression" {
611        if let Some(ref caller) = caller_id {
612            let constructor_name = node
613                .child_by_field_name("constructor")
614                .and_then(|c| match c.kind() {
615                    "identifier" => Some(c.utf8_text(source).unwrap_or("").to_string()),
616                    "member_expression" => c
617                        .child_by_field_name("property")
618                        .map(|p| p.utf8_text(source).unwrap_or("").to_string()),
619                    _ => None,
620                })
621                .unwrap_or_default();
622
623            if !constructor_name.is_empty() {
624                edges.push(EdgeDef {
625                    src: caller.clone(),
626                    dst: constructor_name,
627                    kind: EdgeKind::Calls,
628                    confidence: 0.7,
629                    ..Default::default()
630                });
631            }
632        }
633    }
634
635    // JSX component usage: <ComponentName ... /> and <ComponentName ...>
636    // Treat JSX elements as calls from the enclosing function to the component.
637    if kind == "jsx_opening_element" || kind == "jsx_self_closing_element" {
638        if let Some(ref caller_id) = caller_id {
639            let tag_name = node
640                .child_by_field_name("name")
641                .map(|n| n.utf8_text(source).unwrap_or("").to_string())
642                .unwrap_or_default();
643
644            // Only emit edges for PascalCase or camelCase user-defined components.
645            // Lowercase tags like <div>, <span> are HTML intrinsics — skip them.
646            let is_component = tag_name
647                .chars()
648                .next()
649                .map(|c| {
650                    c.is_uppercase()
651                        || (c.is_lowercase() && tag_name.len() > 3 && tag_name.contains('.'))
652                })
653                .unwrap_or(false);
654
655            if is_component {
656                // Strip member access for <Namespace.Component /> — use only the last segment
657                let callee = tag_name
658                    .split('.')
659                    .next_back()
660                    .unwrap_or(&tag_name)
661                    .to_string();
662                edges.push(EdgeDef {
663                    src: caller_id.clone(),
664                    dst: callee,
665                    kind: EdgeKind::Calls,
666                    confidence: 0.6,
667                    ..Default::default()
668                });
669            }
670        }
671    }
672
673    let mut cursor = node.walk();
674    if cursor.goto_first_child() {
675        loop {
676            walk_for_calls(edges, cursor.node(), source, file, fn_stack);
677            if !cursor.goto_next_sibling() {
678                break;
679            }
680        }
681    }
682
683    if pushed {
684        fn_stack.pop();
685    }
686}
687
688/// Compute a normalized cyclomatic-style complexity score for a function node.
689/// Returns a value in 0.0..=1.0 (raw score capped at 100, then divided by 100).
690fn compute_complexity(node: tree_sitter::Node, source: &[u8]) -> f64 {
691    let raw = count_complexity(node, source, 0);
692    let capped = raw.min(100.0);
693    capped / 100.0
694}
695
696fn count_complexity(node: tree_sitter::Node, source: &[u8], nesting: u32) -> f64 {
697    let mut score: f64 = 0.0;
698    let kind = node.kind();
699
700    let is_branching = matches!(
701        kind,
702        "if_statement"
703            | "for_statement"
704            | "for_in_statement"
705            | "while_statement"
706            | "do_statement"
707            | "switch_statement"
708            | "catch_clause"
709            | "ternary_expression"
710    );
711
712    if is_branching {
713        score += 1.0 + (nesting as f64 * 0.5);
714    }
715
716    // logical expressions (&&, ||)
717    if kind == "binary_expression" || kind == "logical_expression" {
718        if let Some(op) = node.child_by_field_name("operator") {
719            let op_text = op.utf8_text(source).unwrap_or("");
720            if op_text == "&&" || op_text == "||" {
721                score += 0.5;
722            }
723        }
724    }
725
726    let new_nesting = if is_branching { nesting + 1 } else { nesting };
727
728    let mut cursor = node.walk();
729    if cursor.goto_first_child() {
730        loop {
731            score += count_complexity(cursor.node(), source, new_nesting);
732            if !cursor.goto_next_sibling() {
733                break;
734            }
735        }
736    }
737
738    score
739}
740
741/// Look for a doc comment (/** */ or ///) just before the function line.
742/// Searches up to 3 lines before fn_line for a comment node ending near that line.
743fn extract_doc_comment(root: tree_sitter::Node, source: &[u8], fn_line: u32) -> Option<String> {
744    find_doc_comment(root, source, fn_line)
745}
746
747fn find_doc_comment(node: tree_sitter::Node, source: &[u8], fn_line: u32) -> Option<String> {
748    if node.kind() == "comment" {
749        let end_line = node.end_position().row as u32 + 1;
750        // Comment should end at or just before the function line (within 3 lines)
751        if end_line >= fn_line.saturating_sub(3) && end_line < fn_line {
752            let text = node.utf8_text(source).unwrap_or("").trim().to_string();
753            if text.starts_with("/**") || text.starts_with("///") {
754                return Some(text);
755            }
756        }
757    }
758
759    let mut cursor = node.walk();
760    if cursor.goto_first_child() {
761        loop {
762            if let Some(result) = find_doc_comment(cursor.node(), source, fn_line) {
763                return Some(result);
764            }
765            if !cursor.goto_next_sibling() {
766                break;
767            }
768        }
769    }
770
771    None
772}
773
774const ANNOTATION_TAGS: &[&str] = &[
775    "TODO", "FIXME", "HACK", "NOTE", "BUG", "OPTIMIZE", "WARN", "XXX",
776];
777
778/// Recursively walk the AST extracting annotation comments (TODO/FIXME/etc.).
779/// `in_jsx_expression` tracks whether we are inside a `jsx_expression` node,
780/// which is how `{/* ... */}` comments appear in the TSX grammar.
781fn extract_jsx_comments(
782    tags: &mut Vec<CommentTag>,
783    node: Node,
784    source: &[u8],
785    in_jsx_expression: bool,
786) {
787    let kind = node.kind();
788
789    // Track whether we're entering a jsx_expression wrapper
790    let now_in_jsx = in_jsx_expression || kind == "jsx_expression";
791
792    if kind == "comment" {
793        let raw = node.utf8_text(source).unwrap_or("").trim();
794
795        let comment_kind = if in_jsx_expression {
796            // Strip `/*` / `*/` delimiters and check for commented-out JSX code
797            let inner = raw.trim_start_matches("/*").trim_end_matches("*/").trim();
798            if inner.starts_with('<') || inner.contains("</") || inner.contains("/>") {
799                CommentKind::JsxCommentedCode
800            } else {
801                CommentKind::JsxExpression
802            }
803        } else {
804            CommentKind::Standard
805        };
806
807        let upper = raw.to_uppercase();
808        for &tag in ANNOTATION_TAGS {
809            if upper.contains(tag) {
810                tags.push(CommentTag {
811                    tag_type: tag.to_string(),
812                    text: raw.to_string(),
813                    line: node.start_position().row as u32 + 1,
814                    comment_kind: comment_kind.clone(),
815                });
816                break;
817            }
818        }
819    }
820
821    let mut cursor = node.walk();
822    if cursor.goto_first_child() {
823        loop {
824            extract_jsx_comments(tags, cursor.node(), source, now_in_jsx);
825            if !cursor.goto_next_sibling() {
826                break;
827            }
828        }
829    }
830}