Skip to main content

code_moniker_cli/
tsconfig.rs

1use std::path::{Path, PathBuf};
2
3use code_moniker_core::lang::ts::PathAlias;
4use serde::Deserialize;
5
6#[derive(Debug, Clone, Default)]
7pub struct TsResolution {
8	pub aliases: Vec<PathAlias>,
9}
10
11#[derive(Deserialize)]
12struct RawTsConfig {
13	#[serde(rename = "compilerOptions", default)]
14	compiler_options: Option<RawCompilerOptions>,
15	#[serde(default)]
16	references: Vec<RawReference>,
17}
18
19#[derive(Deserialize)]
20struct RawCompilerOptions {
21	#[serde(rename = "baseUrl", default)]
22	base_url: Option<String>,
23	#[serde(default)]
24	paths: std::collections::BTreeMap<String, Vec<String>>,
25}
26
27#[derive(Deserialize)]
28struct RawReference {
29	path: String,
30}
31
32const TSCONFIG_CANDIDATES: &[&str] = &[
33	"tsconfig.json",
34	"tsconfig.app.json",
35	"tsconfig.base.json",
36	"tsconfig.web.json",
37];
38
39const SKIP_DIR_NAMES: &[&str] = &["node_modules", "target", "dist", "build", "out"];
40
41const MAX_REFERENCES_DEPTH: usize = 3;
42
43pub fn load(root: &Path) -> TsResolution {
44	let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
45	let mut aliases: Vec<PathAlias> = Vec::new();
46	for entry in discover_tsconfigs(root) {
47		merge_from_file(&entry, &canonical_root, &mut aliases, 0);
48	}
49	TsResolution { aliases }
50}
51
52fn discover_tsconfigs(root: &Path) -> Vec<PathBuf> {
53	let mut out = Vec::new();
54	push_tsconfigs_in(root, &mut out);
55	if let Ok(entries) = std::fs::read_dir(root) {
56		for entry in entries.flatten() {
57			let path = entry.path();
58			if path.is_dir() && !is_ignored_dir(&path) {
59				push_tsconfigs_in(&path, &mut out);
60			}
61		}
62	}
63	out
64}
65
66fn push_tsconfigs_in(dir: &Path, out: &mut Vec<PathBuf>) {
67	for name in TSCONFIG_CANDIDATES {
68		let p = dir.join(name);
69		if p.is_file() {
70			out.push(p);
71		}
72	}
73}
74
75fn is_ignored_dir(path: &Path) -> bool {
76	let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
77		return false;
78	};
79	name.starts_with('.') || SKIP_DIR_NAMES.contains(&name)
80}
81
82fn merge_from_file(file: &Path, root: &Path, aliases: &mut Vec<PathAlias>, depth: usize) {
83	if depth > MAX_REFERENCES_DEPTH {
84		return;
85	}
86	let Ok(raw) = std::fs::read_to_string(file) else {
87		return;
88	};
89	let stripped = strip_jsonc(&raw);
90	let Ok(parsed) = serde_json::from_str::<RawTsConfig>(&stripped) else {
91		return;
92	};
93	let file_dir = file.parent().unwrap_or(root);
94
95	if let Some(opts) = parsed.compiler_options.as_ref() {
96		let base_dir = match opts.base_url.as_deref() {
97			Some(s) => file_dir.join(s),
98			None => file_dir.to_path_buf(),
99		};
100		for (pattern, substitutions) in &opts.paths {
101			let Some(first) = substitutions.first() else {
102				continue;
103			};
104			let Some(substitution) = rebase_substitution(&base_dir, first, root) else {
105				continue;
106			};
107			if !aliases.iter().any(|a| a.pattern == *pattern) {
108				aliases.push(PathAlias {
109					pattern: pattern.clone(),
110					substitution,
111				});
112			}
113		}
114	}
115
116	for r in parsed.references {
117		let p = file_dir.join(&r.path);
118		let resolved = if p.is_file() {
119			p
120		} else if p.is_dir() {
121			p.join("tsconfig.json")
122		} else if p.extension().is_none() {
123			let with_ext = p.with_extension("json");
124			if with_ext.is_file() {
125				with_ext
126			} else {
127				continue;
128			}
129		} else {
130			continue;
131		};
132		merge_from_file(&resolved, root, aliases, depth + 1);
133	}
134}
135
136fn rebase_substitution(base_dir: &Path, sub: &str, root: &Path) -> Option<String> {
137	let (prefix, star, suffix) = match sub.find('*') {
138		Some(i) => (&sub[..i], true, &sub[i + 1..]),
139		None => (sub, false, ""),
140	};
141	let abs_prefix = base_dir.join(prefix);
142	let canonical = abs_prefix.canonicalize().unwrap_or_else(|_| {
143		base_dir
144			.canonicalize()
145			.unwrap_or_else(|_| base_dir.to_path_buf())
146			.join(prefix)
147	});
148	let rel = canonical.strip_prefix(root).ok()?;
149	let rel_str = rel.to_string_lossy();
150	let mut out = String::from("./");
151	out.push_str(&rel_str);
152	if star {
153		if !out.ends_with('/') && !rel_str.is_empty() {
154			out.push('/');
155		}
156		out.push('*');
157		out.push_str(suffix);
158	}
159	Some(out)
160}
161
162fn strip_jsonc(src: &str) -> String {
163	let bytes = src.as_bytes();
164	let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
165	let mut i = 0;
166	while i < bytes.len() {
167		let b = bytes[i];
168		if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
169			while i < bytes.len() && bytes[i] != b'\n' {
170				i += 1;
171			}
172		} else if b == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
173			i += 2;
174			while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
175				i += 1;
176			}
177			i = (i + 2).min(bytes.len());
178		} else if b == b'"' {
179			out.push(b);
180			i += 1;
181			while i < bytes.len() && bytes[i] != b'"' {
182				if bytes[i] == b'\\' && i + 1 < bytes.len() {
183					out.push(bytes[i]);
184					out.push(bytes[i + 1]);
185					i += 2;
186				} else {
187					out.push(bytes[i]);
188					i += 1;
189				}
190			}
191			if i < bytes.len() {
192				out.push(bytes[i]);
193				i += 1;
194			}
195		} else {
196			out.push(b);
197			i += 1;
198		}
199	}
200	String::from_utf8(out).unwrap_or_else(|_| src.to_string())
201}
202
203#[cfg(test)]
204mod tests {
205	use super::*;
206	use std::fs;
207	use tempfile::tempdir;
208
209	#[test]
210	fn load_picks_aliases_from_root_tsconfig() {
211		let tmp = tempdir().unwrap();
212		fs::write(
213			tmp.path().join("tsconfig.json"),
214			r#"{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}"#,
215		)
216		.unwrap();
217		let r = load(tmp.path());
218		assert_eq!(r.aliases.len(), 1);
219		assert_eq!(r.aliases[0].pattern, "@/*");
220		assert_eq!(r.aliases[0].substitution, "./src/*");
221	}
222
223	#[test]
224	fn load_picks_aliases_from_nested_tsconfig() {
225		let tmp = tempdir().unwrap();
226		fs::create_dir_all(tmp.path().join("web/src")).unwrap();
227		fs::write(
228			tmp.path().join("web/tsconfig.app.json"),
229			r#"{"compilerOptions": {"paths": {"@/*": ["./src/*"]}}}"#,
230		)
231		.unwrap();
232		let r = load(tmp.path());
233		let pattern_hit = r
234			.aliases
235			.iter()
236			.any(|a| a.pattern == "@/*" && a.substitution.ends_with("web/src/*"));
237		assert!(
238			pattern_hit,
239			"alias from nested tsconfig must be rebased to project root: {:?}",
240			r.aliases
241		);
242	}
243
244	#[test]
245	fn load_strips_jsonc_comments() {
246		let tmp = tempdir().unwrap();
247		fs::write(
248			tmp.path().join("tsconfig.json"),
249			"{\n  // a comment\n  \"compilerOptions\": { \"paths\": { \"@/*\": [\"./src/*\"] } } /* trailing */\n}",
250		)
251		.unwrap();
252		let r = load(tmp.path());
253		assert_eq!(r.aliases.len(), 1);
254	}
255
256	#[test]
257	fn load_empty_when_no_tsconfig() {
258		let tmp = tempdir().unwrap();
259		let r = load(tmp.path());
260		assert!(r.aliases.is_empty());
261	}
262
263	#[test]
264	fn load_ignores_node_modules() {
265		let tmp = tempdir().unwrap();
266		fs::create_dir_all(tmp.path().join("node_modules/foo")).unwrap();
267		fs::write(
268			tmp.path().join("node_modules/foo/tsconfig.json"),
269			r#"{"compilerOptions": {"paths": {"!polluted/*": ["./*"]}}}"#,
270		)
271		.unwrap();
272		let r = load(tmp.path());
273		assert!(
274			r.aliases.iter().all(|a| a.pattern != "!polluted/*"),
275			"node_modules tsconfigs must not pollute aliases: {:?}",
276			r.aliases
277		);
278	}
279
280	#[test]
281	fn strip_jsonc_preserves_utf8_multibyte() {
282		let src = "{ \"k\": \"é à\" } // 中文";
283		let out = strip_jsonc(src);
284		assert!(out.contains("é à"), "UTF-8 multibyte preserved: {out:?}");
285	}
286}