code_moniker_cli/
tsconfig.rs1use 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}