Skip to main content

code_moniker_core/lang/ts/
mod.rs

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}