1use std::collections::BTreeMap;
11use std::path::{Component, Path, PathBuf};
12
13use petgraph::graph::NodeIndex;
14use tracing::warn;
15
16#[derive(Debug, Clone)]
23pub struct TsConfigPaths {
24 pub base: PathBuf,
26 pub mappings: Vec<(String, Vec<String>)>,
28}
29
30pub(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 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
115pub 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
167fn 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
192pub(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}