reflex/parsers/
tsconfig.rs1use anyhow::{Context, Result};
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PathAliasMap {
27 pub aliases: HashMap<String, Vec<String>>,
30 pub base_url: Option<String>,
32 pub config_dir: PathBuf,
34}
35
36#[derive(Debug, Deserialize)]
38struct CompilerOptions {
39 #[serde(rename = "baseUrl")]
40 base_url: Option<String>,
41 paths: Option<HashMap<String, Vec<String>>>,
42}
43
44#[derive(Debug, Deserialize)]
46struct TsConfig {
47 #[serde(rename = "compilerOptions")]
48 compiler_options: Option<CompilerOptions>,
49}
50
51impl PathAliasMap {
52 pub fn from_file(tsconfig_path: impl AsRef<Path>) -> Result<Self> {
54 let tsconfig_path = tsconfig_path.as_ref();
55 let content = std::fs::read_to_string(tsconfig_path)
56 .with_context(|| format!("Failed to read tsconfig.json: {}", tsconfig_path.display()))?;
57
58 let config: TsConfig = json5::from_str(&content)
60 .with_context(|| format!("Failed to parse tsconfig.json: {}", tsconfig_path.display()))?;
61
62 let config_dir = tsconfig_path.parent()
63 .ok_or_else(|| anyhow::anyhow!("Invalid tsconfig.json path"))?
64 .to_path_buf();
65
66 let compiler_options = config.compiler_options.unwrap_or_else(|| {
67 CompilerOptions {
68 base_url: None,
69 paths: None,
70 }
71 });
72
73 Ok(Self {
74 aliases: compiler_options.paths.unwrap_or_default(),
75 base_url: compiler_options.base_url,
76 config_dir,
77 })
78 }
79
80 pub fn find_nearest_tsconfig(source_file: &Path) -> Option<PathBuf> {
85 let mut current_dir = source_file.parent()?;
86
87 loop {
88 let tsconfig_path = current_dir.join("tsconfig.json");
89 if tsconfig_path.exists() {
90 return Some(tsconfig_path);
91 }
92
93 let nuxt_tsconfig = current_dir.join(".nuxt/tsconfig.json");
95 if nuxt_tsconfig.exists() {
96 return Some(nuxt_tsconfig);
97 }
98
99 current_dir = current_dir.parent()?;
101 }
102 }
103
104 pub fn resolve_alias(&self, import_path: &str) -> Option<String> {
113 log::debug!(" resolve_alias: trying to match '{}' against {} aliases", import_path, self.aliases.len());
114
115 for (alias_pattern, target_paths) in &self.aliases {
117 log::trace!(" Checking alias pattern: {} => {:?}", alias_pattern, target_paths);
118 if alias_pattern.ends_with("/*") {
120 let alias_prefix = alias_pattern.trim_end_matches("/*");
121
122 if import_path.starts_with(alias_prefix) {
124 let suffix = import_path.strip_prefix(alias_prefix).unwrap_or("");
128
129 if let Some(target_pattern) = target_paths.first() {
131 let resolved = if target_pattern.ends_with("/*") {
133 let target_prefix = target_pattern.trim_end_matches("/*");
134 format!("{}{}", target_prefix, suffix)
135 } else {
136 let clean_suffix = suffix.trim_start_matches('/');
140 if clean_suffix.is_empty() {
141 target_pattern.to_string()
142 } else {
143 format!("{}/{}", target_pattern, clean_suffix)
144 }
145 };
146
147 log::trace!("Resolved alias {} + {} => {}", alias_pattern, import_path, resolved);
148 return Some(resolved);
149 }
150 }
151 } else {
152 if import_path == alias_pattern {
154 if let Some(target) = target_paths.first() {
155 log::trace!("Resolved exact alias {} => {}", alias_pattern, target);
156 return Some(target.clone());
157 }
158 }
159 }
160 }
161
162 None
163 }
164
165 pub fn resolve_relative_to_config(&self, path: &str) -> PathBuf {
167 let base = if let Some(ref base_url) = self.base_url {
168 self.config_dir.join(base_url)
169 } else {
170 self.config_dir.clone()
171 };
172
173 let joined = base.join(path);
174
175 let normalized = joined.components()
178 .fold(PathBuf::new(), |mut acc, component| {
179 match component {
180 std::path::Component::CurDir => acc, std::path::Component::ParentDir => {
182 acc.pop(); acc
184 }
185 _ => {
186 acc.push(component);
187 acc
188 }
189 }
190 });
191
192 normalized
193 }
194}
195
196pub fn parse_all_tsconfigs(root: &Path) -> Result<std::collections::HashMap<PathBuf, PathAliasMap>> {
204 use std::collections::HashMap;
205 use ignore::WalkBuilder;
206
207 log::debug!("Starting tsconfig discovery in {}", root.display());
208 let mut tsconfigs = HashMap::new();
209 let mut file_count = 0;
210
211 for entry in WalkBuilder::new(root)
213 .follow_links(false)
214 .build()
215 .filter_map(|e| e.ok())
216 {
217 let path = entry.path();
218
219 if path.file_name().and_then(|n| n.to_str()) == Some("tsconfig.json") {
221 file_count += 1;
222 log::debug!("Found tsconfig.json file #{}: {}", file_count, path.display());
223
224 match PathAliasMap::from_file(path) {
226 Ok(alias_map) => {
227 let config_dir = alias_map.config_dir.clone();
229 log::debug!(" Parsed successfully: base_url={:?}, {} aliases",
230 alias_map.base_url,
231 alias_map.aliases.len());
232 tsconfigs.insert(config_dir, alias_map);
233 }
234 Err(e) => {
235 log::warn!("Failed to parse tsconfig.json at {}: {}", path.display(), e);
236 }
237 }
238 }
239 }
240
241 log::debug!("Tsconfig discovery complete: found {} files, parsed {} successfully", file_count, tsconfigs.len());
242 Ok(tsconfigs)
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use std::fs;
249 use tempfile::TempDir;
250
251 #[test]
252 fn test_parse_tsconfig_with_paths() {
253 let temp = TempDir::new().unwrap();
254 let tsconfig_path = temp.path().join("tsconfig.json");
255
256 let tsconfig_content = r#"{
257 "compilerOptions": {
258 "baseUrl": ".",
259 "paths": {
260 "~/*": ["./src/*"],
261 "@packages/*": ["../../packages/*"]
262 }
263 }
264 }"#;
265
266 fs::write(&tsconfig_path, tsconfig_content).unwrap();
267
268 let alias_map = PathAliasMap::from_file(&tsconfig_path).unwrap();
269
270 assert_eq!(alias_map.base_url, Some(".".to_string()));
271 assert_eq!(alias_map.aliases.len(), 2);
272 assert!(alias_map.aliases.contains_key("~/*"));
273 assert!(alias_map.aliases.contains_key("@packages/*"));
274 }
275
276 #[test]
277 fn test_resolve_wildcard_alias() {
278 let temp = TempDir::new().unwrap();
279 let alias_map = PathAliasMap {
280 aliases: HashMap::from([
281 ("@packages/*".to_string(), vec!["../../packages/*".to_string()]),
282 ]),
283 base_url: Some(".".to_string()),
284 config_dir: temp.path().to_path_buf(),
285 };
286
287 let resolved = alias_map.resolve_alias("@packages/ui/stores/auth");
289 assert_eq!(resolved, Some("../../packages/ui/stores/auth".to_string()));
290 }
291
292 #[test]
293 fn test_resolve_exact_alias() {
294 let temp = TempDir::new().unwrap();
295 let alias_map = PathAliasMap {
296 aliases: HashMap::from([
297 ("~".to_string(), vec!["./src".to_string()]),
298 ]),
299 base_url: None,
300 config_dir: temp.path().to_path_buf(),
301 };
302
303 let resolved = alias_map.resolve_alias("~");
305 assert_eq!(resolved, Some("./src".to_string()));
306 }
307
308 #[test]
309 fn test_no_match() {
310 let temp = TempDir::new().unwrap();
311 let alias_map = PathAliasMap {
312 aliases: HashMap::from([
313 ("@packages/*".to_string(), vec!["../../packages/*".to_string()]),
314 ]),
315 base_url: None,
316 config_dir: temp.path().to_path_buf(),
317 };
318
319 let resolved = alias_map.resolve_alias("./relative/path");
321 assert_eq!(resolved, None);
322 }
323
324 #[test]
325 fn test_find_nearest_tsconfig() {
326 let temp = TempDir::new().unwrap();
327
328 let src_dir = temp.path().join("src");
330 let components_dir = src_dir.join("components");
331 fs::create_dir_all(&components_dir).unwrap();
332
333 let tsconfig_path = temp.path().join("tsconfig.json");
335 fs::write(&tsconfig_path, "{}").unwrap();
336
337 let source_file = components_dir.join("Button.tsx");
339 fs::write(&source_file, "export const Button = () => {}").unwrap();
340
341 let found = PathAliasMap::find_nearest_tsconfig(&source_file);
343 assert_eq!(found, Some(tsconfig_path));
344 }
345
346 #[test]
347 fn test_resolve_relative_to_config() {
348 let temp = TempDir::new().unwrap();
349 let alias_map = PathAliasMap {
350 aliases: HashMap::new(),
351 base_url: Some("src".to_string()),
352 config_dir: temp.path().to_path_buf(),
353 };
354
355 let resolved = alias_map.resolve_relative_to_config("utils/helper.ts");
356 assert_eq!(resolved, temp.path().join("src/utils/helper.ts"));
357 }
358}