Skip to main content

exspec_lang_typescript/
tsconfig.rs

1use std::path::{Path, PathBuf};
2
3/// A single path alias entry from tsconfig paths.
4///
5/// For `"@app/*": ["src/*"]`, the split is:
6///   prefix = "@app/"  (everything before the `*`)
7///   suffix = ""       (everything after the `*`, often empty)
8///   targets = [("src/", "")]  (prefix / suffix pairs for each target)
9#[derive(Debug, Clone)]
10pub struct PathAlias {
11    pub prefix: String,
12    pub suffix: String,
13    pub targets: Vec<(String, String)>,
14}
15
16/// Parsed tsconfig paths configuration.
17#[derive(Debug, Clone)]
18pub struct TsconfigPaths {
19    pub base_url: PathBuf,
20    pub aliases: Vec<PathAlias>,
21}
22
23impl TsconfigPaths {
24    /// Parse tsconfig from JSON string with the directory containing the tsconfig.
25    ///
26    /// Returns `None` when:
27    /// - JSON is invalid (includes JSON5 / comments)
28    /// - `compilerOptions` is absent
29    /// - `paths` is absent or empty
30    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        // Parse base from extends (max 3 levels)
38        let parent_paths: Vec<PathAlias> = if depth < 3 {
39            if let Some(extends_val) = value.get("extends").and_then(|v| v.as_str()) {
40                // Only handle relative paths; skip npm packages
41                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        // Determine base_url
67        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        // Parse paths
75        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        // Merge: start from parent, then child overrides by prefix key
103        // Child paths override parent paths with the same pattern key
104        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        // Return None only if there are no aliases at all and no parent aliases
115        if merged.is_empty() {
116            return None;
117        }
118
119        Some(TsconfigPaths {
120            base_url,
121            aliases: merged,
122        })
123    }
124
125    /// Resolve an import specifier against the path aliases.
126    ///
127    /// Returns the first matching resolved `PathBuf`, or `None` if no alias matches.
128    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                // Exact match (no wildcard in pattern)
132                // Use first target
133                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                        // target has wildcard but pattern doesn't — unusual, skip
138                        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                // Wildcard match
147                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                // Return first matching target
159                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
169/// Split a pattern on the first `*` into (prefix, suffix).
170/// If no `*`, returns (pattern, "").
171fn 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
181/// Discover `tsconfig.json` by walking up from the given directory.
182///
183/// Returns the path of the first `tsconfig.json` found, or `None` if the
184/// filesystem root is reached without finding one.
185pub 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// ---------------------------------------------------------------------------
201// Tests
202// ---------------------------------------------------------------------------
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use std::fs;
208    use tempfile::TempDir;
209
210    // TP-01: basic alias parse
211    #[test]
212    fn test_parse_basic_alias() {
213        // Given: tsconfig JSON with one alias @app/* -> src/*
214        let json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*"]}}}"#;
215        let dir = TempDir::new().unwrap();
216
217        // When: parse
218        let result = TsconfigPaths::from_str(json, dir.path());
219
220        // Then: 1 alias, prefix="@app/", targets=[("src/","")]
221        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    // TP-02: multiple targets
233    #[test]
234    fn test_parse_multiple_targets() {
235        // Given: paths with two targets for the same alias
236        let json = r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/*","lib/*"]}}}"#;
237        let dir = TempDir::new().unwrap();
238
239        // When: parse
240        let result = TsconfigPaths::from_str(json, dir.path());
241
242        // Then: targets.len() == 2
243        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    // TP-03: baseUrl defaults to tsconfig_dir when omitted
249    #[test]
250    fn test_base_url_defaults_to_tsconfig_dir() {
251        // Given: JSON with no baseUrl
252        let json = r#"{"compilerOptions":{"paths":{"@app/*":["src/*"]}}}"#;
253        let dir = TempDir::new().unwrap();
254        let tsconfig_dir = dir.path();
255
256        // When: parse
257        let result = TsconfigPaths::from_str(json, tsconfig_dir);
258
259        // Then: base_url == tsconfig_dir (canonicalized)
260        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    // TP-04: exact match (no wildcard) resolves correctly
268    #[test]
269    fn test_exact_match_no_wildcard() {
270        // Given: alias without wildcard @config -> src/config/index
271        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        // When: resolve exact specifier
277        let result = tc.resolve_alias("@config");
278
279        // Then: resolves to base_url/src/config/index
280        let expected = dir.path().join("src/config/index");
281        assert_eq!(result, Some(expected), "expected exact match resolution");
282    }
283
284    // TP-05: extends chain inherits paths from base
285    #[test]
286    fn test_extends_chain_inherits_paths() {
287        // Given: base tsconfig with @base/* -> base_src/*
288        //        child tsconfig extends base, has no paths of its own
289        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        // When: parse child
300        let child_source = fs::read_to_string(&child_path).unwrap();
301        let result = TsconfigPaths::from_str(&child_source, dir.path());
302
303        // Then: child inherits @base/* alias from base
304        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    // TP-06: extends child overrides base paths
313    #[test]
314    fn test_extends_child_overrides() {
315        // Given: base has @app/* -> lib/*
316        //        child extends base and overrides @app/* -> src/*
317        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        // When: parse child
328        let child_source = fs::read_to_string(&child_path).unwrap();
329        let result = TsconfigPaths::from_str(&child_source, dir.path());
330
331        // Then: @app/* resolves to src/*, not lib/*
332        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    // TP-07: discover_tsconfig finds tsconfig.json in parent directory
345    #[test]
346    fn test_discover_tsconfig_in_parent() {
347        // Given: parent/tsconfig.json exists, start from parent/sub/
348        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        // When: discover from sub/
356        let result = discover_tsconfig(&sub);
357
358        // Then: finds parent/tsconfig.json
359        assert_eq!(
360            result,
361            Some(tsconfig),
362            "expected to find tsconfig.json in parent"
363        );
364    }
365
366    // TP-08: discover_tsconfig returns None when no tsconfig exists
367    #[test]
368    fn test_discover_tsconfig_none() {
369        // Given: temp dir with no tsconfig.json anywhere
370        let dir = TempDir::new().unwrap();
371
372        // When: discover
373        let result = discover_tsconfig(dir.path());
374
375        // Then: None
376        assert!(
377            result.is_none(),
378            "expected None when no tsconfig.json exists"
379        );
380    }
381
382    // TP-09: resolve_alias returns None for non-matching specifier
383    #[test]
384    fn test_resolve_alias_no_match() {
385        // Given: only @app/* alias configured
386        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        // When: resolve a non-matching specifier
391        let result = tc.resolve_alias("lodash");
392
393        // Then: None
394        assert!(
395            result.is_none(),
396            "expected None for non-alias specifier 'lodash'"
397        );
398    }
399
400    // TP-10: resolve_alias with wildcard maps correctly
401    #[test]
402    fn test_resolve_alias_with_wildcard() {
403        // Given: @app/* -> src/*
404        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        // When: resolve @app/services/foo
409        let result = tc.resolve_alias("@app/services/foo");
410
411        // Then: base_url/src/services/foo
412        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}