Skip to main content

sdivi_graph/
tsconfig.rs

1//! tsconfig.json / jsconfig.json path-alias parsing for TS/JS resolution.
2//!
3//! Reads `compilerOptions.baseUrl` and `compilerOptions.paths` from the config
4//! at the repository root only (`extends` chains and per-package monorepo
5//! tsconfigs are deferred to a follow-up milestone).
6//!
7//! JSONC tolerance: `//` line comments, `/* */` block comments, and trailing
8//! commas are stripped by a state-machine pre-pass before `serde_json`.
9
10use std::collections::BTreeMap;
11use std::path::{Component, Path, PathBuf};
12
13use petgraph::graph::NodeIndex;
14use tracing::warn;
15
16/// Parsed alias map from `compilerOptions.paths` in a tsconfig / jsconfig file.
17///
18/// `base` is the effective resolution root (tsconfig dir + `baseUrl` if set)
19/// expressed **relative to the repository root**. `mappings` is in BTreeMap key
20/// order from serde_json (alphabetical); insertion-order semantics are a
21/// known limitation for projects where key order is significant.
22#[derive(Debug, Clone)]
23pub struct TsConfigPaths {
24    /// Effective base for alias resolution, relative to repo root.
25    pub base: PathBuf,
26    /// Pattern → target list pairs, in JSON key order.
27    pub mappings: Vec<(String, Vec<String>)>,
28}
29
30// Re-exported publicly via sdivi_graph::TsConfigPaths and sdivi_graph::parse_tsconfig_content.
31
32pub(crate) fn strip_jsonc(input: &str) -> String {
33    let without_comments = {
34        let mut out = String::with_capacity(input.len());
35        let mut chars = input.chars().peekable();
36        let (mut in_str, mut in_block, mut in_line) = (false, false, false);
37        while let Some(c) = chars.next() {
38            if in_line {
39                if c == '\n' {
40                    out.push('\n');
41                    in_line = false;
42                }
43                continue;
44            }
45            if in_block {
46                if c == '*' && chars.peek() == Some(&'/') {
47                    chars.next();
48                    in_block = false;
49                }
50                continue;
51            }
52            if in_str {
53                out.push(c);
54                if c == '\\' {
55                    if let Some(e) = chars.next() {
56                        out.push(e);
57                    }
58                } else if c == '"' {
59                    in_str = false;
60                }
61                continue;
62            }
63            match c {
64                '"' => {
65                    in_str = true;
66                    out.push(c);
67                }
68                '/' => match chars.peek() {
69                    Some('/') => {
70                        chars.next();
71                        in_line = true;
72                    }
73                    Some('*') => {
74                        chars.next();
75                        in_block = true;
76                    }
77                    _ => out.push(c),
78                },
79                _ => out.push(c),
80            }
81        }
82        out
83    };
84    // Strip trailing commas before } or ]
85    let chars: Vec<char> = without_comments.chars().collect();
86    let mut out = String::with_capacity(without_comments.len());
87    let mut i = 0;
88    while i < chars.len() {
89        if chars[i] == ',' {
90            let mut j = i + 1;
91            while j < chars.len() && chars[j].is_whitespace() {
92                j += 1;
93            }
94            if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
95                i += 1;
96                continue;
97            }
98        }
99        out.push(chars[i]);
100        i += 1;
101    }
102    out
103}
104
105fn normalize_rel(p: PathBuf) -> PathBuf {
106    let mut out = PathBuf::new();
107    for comp in p.components() {
108        if let Component::Normal(n) = comp {
109            out.push(n);
110        }
111    }
112    out
113}
114
115/// Parses tsconfig.json / jsconfig.json content and returns alias info.
116///
117/// `tsconfig_rel_dir` is the tsconfig directory relative to the repository root
118/// (pass `""` for a root-level config). Returns `None` on parse failure (logs a
119/// `WARN`) or when neither `baseUrl` nor `paths` is present. Patterns with two
120/// or more `*` chars are skipped with a `WARN`.
121pub fn parse_tsconfig_content(content: &str, tsconfig_rel_dir: &Path) -> Option<TsConfigPaths> {
122    let value: serde_json::Value = match serde_json::from_str(&strip_jsonc(content)) {
123        Ok(v) => v,
124        Err(e) => {
125            warn!(
126                "tsconfig.json unparseable after comment-strip: {}; alias resolution disabled",
127                e
128            );
129            return None;
130        }
131    };
132    let opts = value.get("compilerOptions")?;
133    let base_url = opts.get("baseUrl").and_then(|v| v.as_str());
134    let base = if let Some(url) = base_url {
135        normalize_rel(tsconfig_rel_dir.join(url))
136    } else {
137        normalize_rel(tsconfig_rel_dir.to_path_buf())
138    };
139    let paths_obj = match opts.get("paths").and_then(|v| v.as_object()) {
140        Some(obj) => obj,
141        None => {
142            return Some(TsConfigPaths {
143                base,
144                mappings: vec![],
145            })
146        }
147    };
148    let mut mappings = Vec::new();
149    for (key, val) in paths_obj {
150        if key.matches('*').count() > 1 {
151            warn!("tsconfig.json: pattern '{}' has >1 '*'; skipping", key);
152            continue;
153        }
154        let targets: Vec<String> = val
155            .as_array()
156            .map(|a| {
157                a.iter()
158                    .filter_map(|v| v.as_str().map(str::to_string))
159                    .collect()
160            })
161            .unwrap_or_default();
162        mappings.push((key.clone(), targets));
163    }
164    Some(TsConfigPaths { base, mappings })
165}
166
167/// Returns the wildcard capture if `specifier` matches `pattern`, else `None`.
168fn match_alias(pattern: &str, specifier: &str) -> Option<String> {
169    match pattern.find('*') {
170        None => (specifier == pattern).then(String::new),
171        Some(star) => {
172            let (pre, suf) = (&pattern[..star], &pattern[star + 1..]);
173            if specifier.starts_with(pre)
174                && specifier.ends_with(suf)
175                && specifier.len() >= pre.len() + suf.len()
176            {
177                Some(specifier[pre.len()..specifier.len() - suf.len()].to_string())
178            } else {
179                None
180            }
181        }
182    }
183}
184
185fn apply_capture(target: &str, capture: &str) -> String {
186    match target.find('*') {
187        None => target.to_string(),
188        Some(s) => format!("{}{}{}", &target[..s], capture, &target[s + 1..]),
189    }
190}
191
192/// Resolves a TS/JS import specifier against tsconfig path aliases.
193///
194/// Iterates `paths.mappings` in declaration order; first pattern match wins.
195/// Within a pattern, first resolving target wins. Returns empty `Vec` if no
196/// alias matched or no target resolved to a known node.
197pub(crate) fn resolve_tsconfig_alias(
198    specifier: &str,
199    paths: &TsConfigPaths,
200    path_to_node: &BTreeMap<PathBuf, NodeIndex>,
201) -> Vec<NodeIndex> {
202    for (pattern, targets) in &paths.mappings {
203        let Some(capture) = match_alias(pattern, specifier) else {
204            continue;
205        };
206        for target in targets {
207            let sub = apply_capture(target, &capture);
208            let rem = sub.strip_prefix("./").unwrap_or(&sub);
209            let node = crate::resolve::try_path(&paths.base, rem, "typescript", path_to_node)
210                .or_else(|| crate::resolve::try_path(&paths.base, rem, "javascript", path_to_node));
211            if let Some(ni) = node {
212                return vec![ni];
213            }
214        }
215    }
216    vec![]
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn strip_line_comment() {
225        let v: serde_json::Value =
226            serde_json::from_str(&strip_jsonc("{ \"a\": 1 // c\n}")).unwrap();
227        assert_eq!(v["a"], 1);
228    }
229    #[test]
230    fn strip_block_comment() {
231        let v: serde_json::Value =
232            serde_json::from_str(&strip_jsonc(r#"{"a": /* c */ 2}"#)).unwrap();
233        assert_eq!(v["a"], 2);
234    }
235    #[test]
236    fn preserve_url_in_string() {
237        let v: serde_json::Value =
238            serde_json::from_str(&strip_jsonc(r#"{"u":"http://x.com"}"#)).unwrap();
239        assert_eq!(v["u"], "http://x.com");
240    }
241    #[test]
242    fn trailing_comma_object() {
243        let v: serde_json::Value = serde_json::from_str(&strip_jsonc(r#"{"a":1,}"#)).unwrap();
244        assert_eq!(v["a"], 1);
245    }
246    #[test]
247    fn trailing_comma_array() {
248        let v: serde_json::Value = serde_json::from_str(&strip_jsonc(r#"[1,2,]"#)).unwrap();
249        assert_eq!(v, serde_json::json!([1, 2]));
250    }
251    #[test]
252    fn match_exact() {
253        assert_eq!(match_alias("~lib", "~lib"), Some(String::new()));
254        assert_eq!(match_alias("~lib", "~other"), None);
255    }
256    #[test]
257    fn match_wildcard_prefix() {
258        assert_eq!(match_alias("@/*", "@/lib/foo"), Some("lib/foo".to_string()));
259        assert_eq!(match_alias("@/*", "other"), None);
260    }
261    #[test]
262    fn match_prefix_suffix() {
263        assert_eq!(
264            match_alias("#int/*.t", "#int/foo.t"),
265            Some("foo".to_string())
266        );
267    }
268    #[test]
269    fn apply_no_star() {
270        assert_eq!(apply_capture("./index.ts", ""), "./index.ts");
271    }
272    #[test]
273    fn apply_star() {
274        assert_eq!(apply_capture("./*", "lib/foo"), "./lib/foo");
275    }
276}