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