exspec_lang_typescript/
tsconfig.rs1use std::path::{Path, PathBuf};
2
3#[derive(Debug, Clone)]
10pub struct PathAlias {
11 pub prefix: String,
12 pub suffix: String,
13 pub targets: Vec<(String, String)>,
14}
15
16#[derive(Debug, Clone)]
18pub struct TsconfigPaths {
19 pub base_url: PathBuf,
20 pub aliases: Vec<PathAlias>,
21}
22
23impl TsconfigPaths {
24 pub fn from_str(json: &str, tsconfig_dir: &Path) -> Option<Self> {
31 Self::from_str_depth(json, tsconfig_dir, 0)
32 }
33
34 fn from_str_depth(json: &str, tsconfig_dir: &Path, depth: usize) -> Option<Self> {
35 let value: serde_json::Value = serde_json::from_str(json).ok()?;
36
37 let parent_paths: Vec<PathAlias> = if depth < 3 {
39 if let Some(extends_val) = value.get("extends").and_then(|v| v.as_str()) {
40 if extends_val.starts_with("./") || extends_val.starts_with("../") {
42 let extends_path = tsconfig_dir.join(extends_val);
43 if let Ok(content) = std::fs::read_to_string(&extends_path) {
44 let parent_dir = extends_path.parent().unwrap_or(tsconfig_dir);
45 if let Some(parent) = Self::from_str_depth(&content, parent_dir, depth + 1)
46 {
47 parent.aliases
48 } else {
49 Vec::new()
50 }
51 } else {
52 Vec::new()
53 }
54 } else {
55 Vec::new()
56 }
57 } else {
58 Vec::new()
59 }
60 } else {
61 Vec::new()
62 };
63
64 let compiler_options = value.get("compilerOptions")?;
65
66 let base_url =
68 if let Some(base_url_str) = compiler_options.get("baseUrl").and_then(|v| v.as_str()) {
69 tsconfig_dir.join(base_url_str)
70 } else {
71 tsconfig_dir.to_path_buf()
72 };
73
74 let paths_obj = compiler_options.get("paths").and_then(|v| v.as_object());
76
77 let mut child_aliases: Vec<PathAlias> = if let Some(paths) = paths_obj {
78 paths
79 .iter()
80 .map(|(pattern, targets_val)| {
81 let (prefix, suffix) = split_wildcard(pattern);
82 let targets = targets_val
83 .as_array()
84 .map(|arr| {
85 arr.iter()
86 .filter_map(|t| t.as_str())
87 .map(split_wildcard)
88 .collect()
89 })
90 .unwrap_or_default();
91 PathAlias {
92 prefix,
93 suffix,
94 targets,
95 }
96 })
97 .collect()
98 } else {
99 Vec::new()
100 };
101
102 let mut merged: Vec<PathAlias> = Vec::new();
105 let child_prefixes: std::collections::HashSet<String> =
106 child_aliases.iter().map(|a| a.prefix.clone()).collect();
107 for parent_alias in parent_paths {
108 if !child_prefixes.contains(&parent_alias.prefix) {
109 merged.push(parent_alias);
110 }
111 }
112 merged.append(&mut child_aliases);
113
114 if merged.is_empty() {
116 return None;
117 }
118
119 Some(TsconfigPaths {
120 base_url,
121 aliases: merged,
122 })
123 }
124
125 pub fn resolve_alias(&self, specifier: &str) -> Option<PathBuf> {
129 for alias in &self.aliases {
130 if alias.suffix.is_empty() && alias.prefix == specifier {
131 if let Some((target_prefix, target_suffix)) = alias.targets.first() {
134 if target_suffix.is_empty() {
135 return Some(self.base_url.join(target_prefix));
136 } else {
137 continue;
139 }
140 }
141 } else if !alias.prefix.is_empty()
142 && specifier.starts_with(&alias.prefix)
143 && specifier.ends_with(&alias.suffix)
144 && specifier.len() >= alias.prefix.len() + alias.suffix.len()
145 {
146 let wildcard_start = alias.prefix.len();
148 let wildcard_end = if alias.suffix.is_empty() {
149 specifier.len()
150 } else {
151 specifier.len() - alias.suffix.len()
152 };
153 if wildcard_start > wildcard_end {
154 continue;
155 }
156 let wildcard = &specifier[wildcard_start..wildcard_end];
157
158 if let Some((target_prefix, target_suffix)) = alias.targets.first() {
160 let resolved = format!("{target_prefix}{wildcard}{target_suffix}");
161 return Some(self.base_url.join(&resolved));
162 }
163 }
164 }
165 None
166 }
167}
168
169fn split_wildcard(pattern: &str) -> (String, String) {
172 if let Some(idx) = pattern.find('*') {
173 let prefix = pattern[..idx].to_string();
174 let suffix = pattern[idx + 1..].to_string();
175 (prefix, suffix)
176 } else {
177 (pattern.to_string(), String::new())
178 }
179}
180
181pub fn discover_tsconfig(start_dir: &Path) -> Option<PathBuf> {
186 let mut current = start_dir.to_path_buf();
187 for _ in 0..10 {
188 let candidate = current.join("tsconfig.json");
189 if candidate.exists() {
190 return Some(candidate);
191 }
192 match current.parent() {
193 Some(parent) => current = parent.to_path_buf(),
194 None => break,
195 }
196 }
197 None
198}
199
200#[cfg(test)]
205mod tests {
206 use super::*;
207 use std::fs;
208 use tempfile::TempDir;
209
210 #[test]
212 fn test_parse_basic_alias() {
213 let json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#;
215 let dir = TempDir::new().unwrap();
216
217 let result = TsconfigPaths::from_str(json, dir.path());
219
220 let tc = result.expect("expected Some(TsconfigPaths)");
222 assert_eq!(tc.aliases.len(), 1, "expected 1 alias");
223 let alias = &tc.aliases[0];
224 assert_eq!(alias.prefix, "@app/");
225 assert_eq!(
226 alias.targets,
227 vec![("src/".to_string(), "".to_string())],
228 "expected targets=[('src/', '')]"
229 );
230 }
231
232 #[test]
234 fn test_parse_multiple_targets() {
235 let json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*","lib/*"]}}}"#;
237 let dir = TempDir::new().unwrap();
238
239 let result = TsconfigPaths::from_str(json, dir.path());
241
242 let tc = result.expect("expected Some(TsconfigPaths)");
244 let alias = &tc.aliases[0];
245 assert_eq!(alias.targets.len(), 2, "expected 2 targets");
246 }
247
248 #[test]
250 fn test_base_url_defaults_to_tsconfig_dir() {
251 let json = r#"{"compilerOptions":{"paths":{"@app/*":["src/*"]}}}"#;
253 let dir = TempDir::new().unwrap();
254 let tsconfig_dir = dir.path();
255
256 let result = TsconfigPaths::from_str(json, tsconfig_dir);
258
259 let tc = result.expect("expected Some(TsconfigPaths)");
261 assert_eq!(
262 tc.base_url, tsconfig_dir,
263 "expected base_url to equal tsconfig_dir"
264 );
265 }
266
267 #[test]
269 fn test_exact_match_no_wildcard() {
270 let dir = TempDir::new().unwrap();
272 let json =
273 r#"{"compilerOptions":{"baseUrl":".","paths":{"@config":["src/config/index"]}}}"#;
274 let tc = TsconfigPaths::from_str(json, dir.path()).expect("expected Some");
275
276 let result = tc.resolve_alias("@config");
278
279 let expected = dir.path().join("src/config/index");
281 assert_eq!(result, Some(expected), "expected exact match resolution");
282 }
283
284 #[test]
286 fn test_extends_chain_inherits_paths() {
287 let dir = TempDir::new().unwrap();
290
291 let base_json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@base/*":["base_src/*"]}}}"#;
292 let base_path = dir.path().join("tsconfig.base.json");
293 fs::write(&base_path, base_json).unwrap();
294
295 let child_json = r#"{"extends":"./tsconfig.base.json","compilerOptions":{"baseUrl":"."}}"#;
296 let child_path = dir.path().join("tsconfig.json");
297 fs::write(&child_path, child_json).unwrap();
298
299 let child_source = fs::read_to_string(&child_path).unwrap();
301 let result = TsconfigPaths::from_str(&child_source, dir.path());
302
303 let tc = result.expect("expected Some(TsconfigPaths) with inherited paths");
305 assert!(
306 tc.aliases.iter().any(|a| a.prefix == "@base/"),
307 "expected @base/ alias inherited from base, got {:?}",
308 tc.aliases
309 );
310 }
311
312 #[test]
314 fn test_extends_child_overrides() {
315 let dir = TempDir::new().unwrap();
318
319 let base_json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["lib/*"]}}}"#;
320 let base_path = dir.path().join("tsconfig.base.json");
321 fs::write(&base_path, base_json).unwrap();
322
323 let child_json = r#"{"extends":"./tsconfig.base.json","compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#;
324 let child_path = dir.path().join("tsconfig.json");
325 fs::write(&child_path, child_json).unwrap();
326
327 let child_source = fs::read_to_string(&child_path).unwrap();
329 let result = TsconfigPaths::from_str(&child_source, dir.path());
330
331 let tc = result.expect("expected Some(TsconfigPaths)");
333 let app_alias = tc.aliases.iter().find(|a| a.prefix == "@app/");
334 assert!(app_alias.is_some(), "expected @app/ alias");
335 let targets = &app_alias.unwrap().targets;
336 assert_eq!(
337 targets,
338 &[("src/".to_string(), "".to_string())],
339 "expected child override src/, got {:?}",
340 targets
341 );
342 }
343
344 #[test]
346 fn test_discover_tsconfig_in_parent() {
347 let dir = TempDir::new().unwrap();
349 let parent = dir.path();
350 let sub = parent.join("sub");
351 fs::create_dir_all(&sub).unwrap();
352 let tsconfig = parent.join("tsconfig.json");
353 fs::write(&tsconfig, "{}").unwrap();
354
355 let result = discover_tsconfig(&sub);
357
358 assert_eq!(
360 result,
361 Some(tsconfig),
362 "expected to find tsconfig.json in parent"
363 );
364 }
365
366 #[test]
368 fn test_discover_tsconfig_none() {
369 let dir = TempDir::new().unwrap();
371
372 let result = discover_tsconfig(dir.path());
374
375 assert!(
377 result.is_none(),
378 "expected None when no tsconfig.json exists"
379 );
380 }
381
382 #[test]
384 fn test_resolve_alias_no_match() {
385 let dir = TempDir::new().unwrap();
387 let json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#;
388 let tc = TsconfigPaths::from_str(json, dir.path()).expect("expected Some");
389
390 let result = tc.resolve_alias("lodash");
392
393 assert!(
395 result.is_none(),
396 "expected None for non-alias specifier 'lodash'"
397 );
398 }
399
400 #[test]
402 fn test_resolve_alias_with_wildcard() {
403 let dir = TempDir::new().unwrap();
405 let json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#;
406 let tc = TsconfigPaths::from_str(json, dir.path()).expect("expected Some");
407
408 let result = tc.resolve_alias("@app/services/foo");
410
411 let expected = dir.path().join("src/services/foo");
413 assert_eq!(
414 result,
415 Some(expected),
416 "expected wildcard resolution to src/services/foo"
417 );
418 }
419}