1use tree_sitter::{Language, Parser, Tree};
2
3use crate::core::code_graph::CodeGraph;
4use crate::core::moniker::Moniker;
5use crate::core::shape::Shape;
6
7use crate::lang::KindSpec;
8
9pub mod build;
10mod kinds;
11mod sdk_pipeline;
12
13pub fn parse(source: &str) -> Tree {
14 parse_with_uri(source, "")
15}
16
17pub fn parse_with_uri(source: &str, uri: &str) -> Tree {
18 let mut parser = Parser::new();
19 let language: Language = if uri_uses_jsx(uri) {
20 tree_sitter_typescript::LANGUAGE_TSX.into()
21 } else {
22 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
23 };
24 parser.set_language(&language).unwrap_or_else(|err| {
25 panic!("failed to load tree-sitter TypeScript grammar: {err}");
26 });
27 parser.parse(source, None).unwrap_or_else(|| {
28 panic!("tree-sitter parse returned None on a non-cancelled call");
29 })
30}
31
32fn uri_uses_jsx(uri: &str) -> bool {
33 uri.ends_with(".tsx") || uri.ends_with(".jsx")
34}
35
36#[derive(Clone, Debug, Default)]
37pub struct Presets {
38 pub di_register_callees: Vec<String>,
39 pub path_aliases: Vec<PathAlias>,
40}
41
42#[derive(Clone, Debug)]
43pub struct PathAlias {
44 pub pattern: String,
45 pub substitution: String,
46}
47
48pub fn extract(
49 uri: &str,
50 source: &str,
51 anchor: &Moniker,
52 deep: bool,
53 presets: &Presets,
54) -> CodeGraph {
55 sdk_pipeline::extract(uri, source, anchor, deep, presets)
56}
57
58pub struct Lang;
59
60const DEF_KINDS: &[&str] = &[
61 "class",
62 "interface",
63 "type",
64 "function",
65 "method",
66 "const",
67 "enum",
68 "constructor",
69 "field",
70 "enum_constant",
71 "namespace",
72];
73
74const DEF_KIND_SPECS: &[KindSpec] = &[
75 KindSpec::new("namespace", Shape::Namespace, 10, "namespace"),
76 KindSpec::new("class", Shape::Type, 20, "class"),
77 KindSpec::new("interface", Shape::Type, 21, "interface"),
78 KindSpec::new("enum", Shape::Type, 22, "enum"),
79 KindSpec::new("type", Shape::Type, 23, "type"),
80 KindSpec::new("constructor", Shape::Callable, 40, "constructor"),
81 KindSpec::new("method", Shape::Callable, 41, "method"),
82 KindSpec::new("function", Shape::Callable, 42, "function"),
83 KindSpec::new("field", Shape::Value, 60, "field"),
84 KindSpec::new("enum_constant", Shape::Value, 61, "enum_constant"),
85 KindSpec::new("const", Shape::Value, 62, "const"),
86];
87
88impl crate::lang::LangExtractor for Lang {
89 type Presets = Presets;
90 const LANG_TAG: &'static str = "ts";
91 const ALLOWED_KINDS: &'static [&'static str] = DEF_KINDS;
92 const KIND_SPECS: &'static [KindSpec] = DEF_KIND_SPECS;
93 const ALLOWED_VISIBILITIES: &'static [&'static str] =
94 &["public", "private", "protected", "module"];
95
96 fn extract(
97 uri: &str,
98 source: &str,
99 anchor: &Moniker,
100 deep: bool,
101 presets: &Self::Presets,
102 ) -> CodeGraph {
103 extract(uri, source, anchor, deep, presets)
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110 use crate::core::moniker::MonikerBuilder;
111 use crate::lang::assert_conformance;
112
113 fn extract(uri: &str, source: &str, anchor: &Moniker, deep: bool) -> CodeGraph {
114 let g = super::extract(uri, source, anchor, deep, &Presets::default());
115 assert_conformance::<super::Lang>(&g, anchor);
116 g
117 }
118
119 fn make_anchor() -> Moniker {
120 MonikerBuilder::new()
121 .project(b"my-app")
122 .segment(b"path", b"main")
123 .build()
124 }
125
126 #[test]
127 fn parse_empty_source_returns_program() {
128 let tree = parse("");
129 assert_eq!(tree.root_node().kind(), "program");
130 assert_eq!(tree.root_node().child_count(), 0);
131 }
132
133 #[test]
134 fn parse_simple_class_has_class_declaration() {
135 let tree = parse("class Foo {}");
136 assert_eq!(
137 tree.root_node().child(0).unwrap().kind(),
138 "class_declaration"
139 );
140 }
141
142 #[test]
143 fn parse_invalid_syntax_marks_errors() {
144 assert!(parse("class { ").root_node().has_error());
145 }
146
147 #[test]
148 fn extract_strips_each_known_extension() {
149 let anchor = make_anchor();
150 for uri in [
151 "foo.ts", "foo.tsx", "foo.js", "foo.jsx", "foo.mjs", "foo.cjs",
152 ] {
153 let g = extract(uri, "", &anchor, false);
154 let last = g.root().as_view().segments().last().unwrap();
155 assert_eq!(last.name, b"foo", "extension not stripped on {uri}");
156 }
157 }
158
159 #[test]
160 fn extract_dot_only_specifier_resolves_relative_not_external() {
161 let g = extract(
162 "src/__tests__/foo.test.ts",
163 "import { z } from \"..\";",
164 &make_anchor(),
165 false,
166 );
167 let r = g.refs().next().unwrap();
168 let target = MonikerBuilder::new()
169 .project(b"my-app")
170 .segment(b"path", b"main")
171 .segment(b"lang", b"ts")
172 .segment(b"dir", b"src")
173 .segment(b"path", b"z")
174 .build();
175 assert_eq!(r.target, target);
176 }
177
178 #[test]
179 fn extract_dotdot_import_walks_up_then_down() {
180 let g = extract(
181 "src/lib/foo.ts",
182 "import { X } from '../other';",
183 &make_anchor(),
184 false,
185 );
186 let r = g.refs().next().unwrap();
187 let target = MonikerBuilder::new()
188 .project(b"my-app")
189 .segment(b"path", b"main")
190 .segment(b"lang", b"ts")
191 .segment(b"dir", b"src")
192 .segment(b"module", b"other")
193 .segment(b"path", b"X")
194 .build();
195 assert_eq!(r.target, target);
196 }
197
198 #[test]
199 fn extract_call_to_nested_function_is_resolved() {
200 let src = r#"
201function outer() {
202 function inner() {}
203 inner();
204}
205"#;
206 let g = extract("util.ts", src, &make_anchor(), false);
207 let r = g
208 .refs()
209 .find(|r| {
210 r.kind == b"calls"
211 && r.target
212 .as_view()
213 .segments()
214 .last()
215 .unwrap()
216 .name
217 .starts_with(b"inner")
218 })
219 .expect("calls ref for inner");
220 assert_eq!(
221 r.confidence,
222 b"resolved",
223 "call to nested fn must be resolved; got {:?}",
224 std::str::from_utf8(&r.confidence)
225 );
226 let segs: Vec<_> = r.target.as_view().segments().collect();
227 assert!(
228 segs.iter()
229 .any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
230 "target must be scoped under outer; got {:?}",
231 segs.iter()
232 .map(|s| (
233 std::str::from_utf8(s.kind).unwrap_or("?"),
234 std::str::from_utf8(s.name).unwrap_or("?")
235 ))
236 .collect::<Vec<_>>()
237 );
238 }
239
240 #[test]
241 fn extract_call_hoists_nested_fn_used_before_decl() {
242 let src = r#"
243function outer() {
244 inner();
245 function inner() {}
246}
247"#;
248 let g = extract("util.ts", src, &make_anchor(), false);
249 let r = g
250 .refs()
251 .find(|r| {
252 r.kind == b"calls"
253 && r.target
254 .as_view()
255 .segments()
256 .last()
257 .unwrap()
258 .name
259 .starts_with(b"inner")
260 })
261 .expect("calls ref for inner");
262 assert_eq!(
263 r.confidence,
264 b"resolved",
265 "hoisted nested fn call must be resolved; got {:?}",
266 std::str::from_utf8(&r.confidence)
267 );
268 }
269
270 #[test]
271 fn extract_reads_param_marks_confidence_local() {
272 let g = extract(
273 "util.ts",
274 "function f(x) { return x; }",
275 &make_anchor(),
276 true,
277 );
278 let r = g.refs().find(|r| r.kind == b"reads").expect("reads ref");
279 assert_eq!(r.confidence, b"local".to_vec(), "ref to a param is local");
280 }
281
282 #[test]
283 fn extract_calls_local_function_marks_confidence_local() {
284 let g = extract(
285 "util.ts",
286 "function f() { const helper = () => 1; helper(); }",
287 &make_anchor(),
288 true,
289 );
290 let r = g.refs().find(|r| r.kind == b"calls").expect("calls ref");
291 assert_eq!(
292 r.confidence,
293 b"local".to_vec(),
294 "call into a locally-bound name is local"
295 );
296 }
297
298 #[test]
299 fn extract_local_def_has_no_visibility() {
300 let g = extract(
301 "util.ts",
302 "function f() { let x = 1; }",
303 &make_anchor(),
304 true,
305 );
306 let local = g.defs().find(|d| d.kind == b"local").expect("local def");
307 assert!(
308 local.visibility.is_empty(),
309 "locals must not carry a synthetic visibility, got {:?}",
310 String::from_utf8_lossy(&local.visibility)
311 );
312 }
313
314 #[test]
315 fn extract_param_def_has_no_visibility() {
316 let g = extract("util.ts", "function f(x) {}", &make_anchor(), true);
317 let p = g.defs().find(|d| d.kind == b"param").expect("param def");
318 assert!(p.visibility.is_empty());
319 }
320
321 #[test]
322 fn extract_di_register_fires_only_when_callee_in_preset() {
323 let presets = Presets {
324 di_register_callees: vec!["register".into(), "bind".into()],
325 ..Presets::default()
326 };
327 let g = super::extract(
328 "util.ts",
329 "register(UserService);",
330 &make_anchor(),
331 false,
332 &presets,
333 );
334 assert!(g.refs().any(|r| r.kind == b"di_register"));
335 }
336
337 #[test]
338 fn extract_di_register_silent_without_preset() {
339 let g = extract("util.ts", "register(UserService);", &make_anchor(), false);
340 assert!(
341 g.refs().all(|r| r.kind != b"di_register"),
342 "di_register must stay silent without a preset",
343 );
344 }
345
346 #[test]
347 fn extract_di_register_skips_non_matching_callee() {
348 let presets = Presets {
349 di_register_callees: vec!["register".into()],
350 ..Presets::default()
351 };
352 let g = super::extract("util.ts", "expect(value);", &make_anchor(), false, &presets);
353 assert!(g.refs().all(|r| r.kind != b"di_register"));
354 }
355
356 #[test]
357 fn extract_di_register_register_with_name_and_factory() {
358 let presets = Presets {
359 di_register_callees: vec!["register".into()],
360 ..Presets::default()
361 };
362 let g = super::extract(
363 "util.ts",
364 "register('repoStore', makeRepoStore);",
365 &make_anchor(),
366 false,
367 &presets,
368 );
369 assert!(
370 g.refs().any(|r| r.kind == b"di_register"),
371 "register('name', factory) must emit di_register on the factory identifier",
372 );
373 }
374
375 #[test]
376 fn extract_di_register_member_callee_register() {
377 let presets = Presets {
378 di_register_callees: vec!["register".into()],
379 ..Presets::default()
380 };
381 let g = super::extract(
382 "util.ts",
383 "container.register('repoStore', makeRepoStore);",
384 &make_anchor(),
385 false,
386 &presets,
387 );
388 assert!(
389 g.refs().any(|r| r.kind == b"di_register"),
390 "container.register(...) must emit di_register when 'register' is in the preset",
391 );
392 }
393
394 #[test]
395 fn extract_di_register_recurses_into_factory_call_argument() {
396 let presets = Presets {
397 di_register_callees: vec!["register".into()],
398 ..Presets::default()
399 };
400 let g = super::extract(
401 "util.ts",
402 "register('repoStore', asFunction(makeRepoStore));",
403 &make_anchor(),
404 false,
405 &presets,
406 );
407 assert!(
408 g.refs().any(|r| r.kind == b"di_register"),
409 "register('name', asFunction(make)) must recurse to find 'make'",
410 );
411 }
412
413 #[test]
414 fn extract_di_register_recurses_through_chained_call_postfix() {
415 let presets = Presets {
416 di_register_callees: vec!["asFunction".into()],
417 ..Presets::default()
418 };
419 let g = super::extract(
420 "util.ts",
421 "asFunction(makeRepoStore).singleton();",
422 &make_anchor(),
423 false,
424 &presets,
425 );
426 assert!(
427 g.refs().any(|r| r.kind == b"di_register"),
428 "asFunction(make).singleton() chain must still register the inner 'make'",
429 );
430 }
431
432 #[test]
433 fn extract_di_register_full_awilix_pattern() {
434 let presets = Presets {
435 di_register_callees: vec!["register".into()],
436 ..Presets::default()
437 };
438 let g = super::extract(
439 "util.ts",
440 "container.register('readResource', asFunction(makeReadResource).singleton());",
441 &make_anchor(),
442 false,
443 &presets,
444 );
445 assert!(
446 g.refs().any(|r| r.kind == b"di_register"),
447 "container.register('name', asFunction(make).singleton()) must emit di_register",
448 );
449 }
450 #[test]
451 fn extract_shallow_skips_param_and_local() {
452 let g = extract(
453 "util.ts",
454 "function f(a: number) { let x = 1; }",
455 &make_anchor(),
456 false,
457 );
458 assert!(
459 g.defs().all(|d| d.kind != b"param" && d.kind != b"local"),
460 "shallow extraction must not produce param/local defs"
461 );
462 }
463
464 #[test]
465 fn extract_deep_emits_params_and_locals() {
466 let g = extract(
467 "util.ts",
468 "function f(a: number, b: number) { let sum = a + b; }",
469 &make_anchor(),
470 true,
471 );
472 let pa = MonikerBuilder::new()
473 .project(b"my-app")
474 .segment(b"path", b"main")
475 .segment(b"lang", b"ts")
476 .segment(b"module", b"util")
477 .segment(b"function", b"f(a:number,b:number)")
478 .segment(b"param", b"a")
479 .build();
480 let pb = MonikerBuilder::new()
481 .project(b"my-app")
482 .segment(b"path", b"main")
483 .segment(b"lang", b"ts")
484 .segment(b"module", b"util")
485 .segment(b"function", b"f(a:number,b:number)")
486 .segment(b"param", b"b")
487 .build();
488 let sum = MonikerBuilder::new()
489 .project(b"my-app")
490 .segment(b"path", b"main")
491 .segment(b"lang", b"ts")
492 .segment(b"module", b"util")
493 .segment(b"function", b"f(a:number,b:number)")
494 .segment(b"local", b"sum")
495 .build();
496 assert!(
497 g.contains(&pa),
498 "missing param a; defs: {:?}",
499 g.def_monikers()
500 );
501 assert!(g.contains(&pb));
502 assert!(g.contains(&sum));
503 }
504
505 #[test]
506 fn extract_deep_anonymous_callback_uses_position_name() {
507 let g = extract(
508 "util.ts",
509 "function f() { [1].map(x => x); }",
510 &make_anchor(),
511 true,
512 );
513 let monikers = g.def_monikers();
514 let cb = monikers
515 .iter()
516 .find(|m| {
517 let last = m.as_view().segments().last().unwrap();
518 last.kind == b"function" && last.name.starts_with(b"__cb_")
519 })
520 .expect("anonymous callback def with __cb_ prefix")
521 .clone();
522 let view = cb.as_view();
523 let last = view.segments().last().unwrap();
524 assert_eq!(last.kind, b"function");
525 assert!(g.defs().any(|d| {
526 let dv = d.moniker.as_view();
527 dv.segment_count() == view.segment_count() + 1
528 && dv.segments().last().unwrap().kind == b"param"
529 }));
530 }
531
532 #[test]
533 fn extract_alias_import_routes_to_project_rooted_module() {
534 let presets = Presets {
535 path_aliases: vec![PathAlias {
536 pattern: "@/*".into(),
537 substitution: "./src/*".into(),
538 }],
539 ..Presets::default()
540 };
541 let g = super::extract(
542 "src/router.tsx",
543 "import { AppShell } from '@/components/layout/app-shell';",
544 &make_anchor(),
545 false,
546 &presets,
547 );
548 let r = g.refs().next().expect("one ref");
549 let target = MonikerBuilder::new()
550 .project(b"my-app")
551 .segment(b"path", b"main")
552 .segment(b"lang", b"ts")
553 .segment(b"dir", b"src")
554 .segment(b"dir", b"components")
555 .segment(b"dir", b"layout")
556 .segment(b"module", b"app-shell")
557 .segment(b"path", b"AppShell")
558 .build();
559 assert_eq!(
560 r.target, target,
561 "alias-resolved import must point at the project-rooted module, not external_pkg",
562 );
563 }
564
565 #[test]
566 fn extract_alias_import_keeps_external_when_no_alias_matches() {
567 let presets = Presets {
568 path_aliases: vec![PathAlias {
569 pattern: "@/*".into(),
570 substitution: "./src/*".into(),
571 }],
572 ..Presets::default()
573 };
574 let g = super::extract(
575 "util.ts",
576 "import { join } from '@scope/pkg/sub';",
577 &make_anchor(),
578 false,
579 &presets,
580 );
581 let r = g.refs().next().unwrap();
582 let head = r.target.as_view().segments().next().unwrap();
583 assert_eq!(head.kind, b"external_pkg");
584 }
585
586 #[test]
587 fn extract_jsx_expression_identifier_still_emits_read() {
588 let g = extract(
589 "app.tsx",
590 "function App(label: string) { return <div>{label}</div>; }",
591 &make_anchor(),
592 true,
593 );
594 assert!(
595 g.refs().any(|r| r.kind == b"reads"
596 && r.target.as_view().segments().last().unwrap().name == b"label"),
597 "identifier inside jsx_expression must still surface as a read",
598 );
599 }
600
601 #[test]
602 fn extract_closure_read_targets_outer_param_def() {
603 let src = "function outer({ x }: { x: string }) { return function inner() { return x; }; }";
604 let g = extract("util.ts", src, &make_anchor(), true);
605 let read = g
606 .refs()
607 .find(|r| {
608 r.kind == b"reads" && r.target.as_view().segments().last().unwrap().name == b"x"
609 })
610 .expect("reads ref for x");
611 let segs: Vec<_> = read.target.as_view().segments().collect();
612 assert!(
613 segs.iter().any(|s| s.kind == b"param" && s.name == b"x"),
614 "target must terminate with param:x of the defining frame, got: {segs:?}"
615 );
616 assert!(
617 !segs
618 .iter()
619 .any(|s| s.kind == b"function" && s.name == b"inner()"),
620 "target must NOT carry the inner frame segment, got: {segs:?}"
621 );
622 }
623
624 #[test]
625 fn extract_closure_uses_type_targets_outer_type_alias_def() {
626 let src = "function outer() { type Local = string; function inner(x: Local): Local { return x; } return inner; }";
627 let g = extract("util.ts", src, &make_anchor(), true);
628 let r = g
629 .refs()
630 .find(|r| {
631 r.kind == b"uses_type"
632 && r.target.as_view().segments().last().unwrap().name == b"Local"
633 })
634 .expect("uses_type ref for Local");
635 let segs: Vec<_> = r.target.as_view().segments().collect();
636 assert!(
637 segs.iter().any(|s| s.kind == b"type" && s.name == b"Local"),
638 "target must terminate with type:Local of the defining frame, got: {segs:?}"
639 );
640 assert!(
641 segs.iter()
642 .any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
643 "target must be parented under outer (the defining frame), got: {segs:?}"
644 );
645 }
646
647 #[test]
648 fn extract_closure_uses_type_targets_outer_interface_def() {
649 let src = "function outer() { interface Local { v: string; } function inner(x: Local): Local { return x; } return inner; }";
650 let g = extract("util.ts", src, &make_anchor(), true);
651 let r = g
652 .refs()
653 .find(|r| {
654 r.kind == b"uses_type"
655 && r.target.as_view().segments().last().unwrap().name == b"Local"
656 })
657 .expect("uses_type ref for Local");
658 let segs: Vec<_> = r.target.as_view().segments().collect();
659 assert!(
660 segs.iter()
661 .any(|s| s.kind == b"interface" && s.name == b"Local"),
662 "target must terminate with interface:Local of the defining frame, got: {segs:?}"
663 );
664 assert!(
665 segs.iter()
666 .any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
667 "target must be parented under outer, got: {segs:?}"
668 );
669 }
670
671 #[test]
672 fn extract_closure_instantiates_targets_outer_class_def() {
673 let src = "function outer() { class Local { ok = true; } function inner() { return new Local(); } return inner; }";
674 let g = extract("util.ts", src, &make_anchor(), true);
675 let r = g
676 .refs()
677 .find(|r| {
678 r.kind == b"instantiates"
679 && r.target.as_view().segments().last().unwrap().name == b"Local"
680 })
681 .expect("instantiates ref for Local");
682 let segs: Vec<_> = r.target.as_view().segments().collect();
683 assert!(
684 segs.iter()
685 .any(|s| s.kind == b"class" && s.name == b"Local"),
686 "target must terminate with class:Local of the defining frame, got: {segs:?}"
687 );
688 assert!(
689 segs.iter()
690 .any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
691 "target must be parented under outer, got: {segs:?}"
692 );
693 }
694
695 #[test]
696 fn extract_closure_uses_type_targets_outer_enum_def() {
697 let src = "function outer() { enum Mode { A, B } function inner(m: Mode): Mode { return m; } return inner; }";
698 let g = extract("util.ts", src, &make_anchor(), true);
699 let r = g
700 .refs()
701 .find(|r| {
702 r.kind == b"uses_type"
703 && r.target.as_view().segments().last().unwrap().name == b"Mode"
704 })
705 .expect("uses_type ref for Mode");
706 let segs: Vec<_> = r.target.as_view().segments().collect();
707 assert!(
708 segs.iter().any(|s| s.kind == b"enum" && s.name == b"Mode"),
709 "target must terminate with enum:Mode of the defining frame, got: {segs:?}"
710 );
711 assert!(
712 segs.iter()
713 .any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
714 "target must be parented under outer, got: {segs:?}"
715 );
716 }
717
718 #[test]
719 fn extract_closure_call_targets_outer_local_def() {
720 let src = "function outer() { const helper = () => 1; return function inner() { return helper(); }; }";
721 let g = extract("util.ts", src, &make_anchor(), true);
722 let call = g
723 .refs()
724 .find(|r| {
725 r.kind == b"calls"
726 && r.target.as_view().segments().last().unwrap().name == b"helper"
727 })
728 .expect("calls ref for helper");
729 let segs: Vec<_> = call.target.as_view().segments().collect();
730 assert!(
731 segs.iter()
732 .any(|s| s.kind == b"local" && s.name == b"helper"),
733 "target must terminate with local:helper of the defining frame, got: {segs:?}"
734 );
735 assert!(
736 !segs
737 .iter()
738 .any(|s| s.kind == b"function" && s.name == b"inner()"),
739 "target must NOT carry the inner frame segment, got: {segs:?}"
740 );
741 }
742}