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 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 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 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 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 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 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 extract_imports(&mut edges, root, source_bytes, &fp, file);
146
147 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(&mut edges, root, source_bytes, file);
182
183 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 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 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 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 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 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 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 let complexity = compute_complexity(fn_capture.node, source_bytes);
322
323 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 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 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(); 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
496fn 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 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 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 fn_stack.push(String::new());
551 }
552 }
553
554 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 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 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 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 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 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
688fn 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 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
741fn 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 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
778fn 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 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 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}