Skip to main content

reflex/parsers/
tsconfig.rs

1//! TypeScript configuration file parser
2//!
3//! Parses tsconfig.json files to extract path alias mappings from compilerOptions.paths.
4//! These mappings are used to resolve non-relative imports in TypeScript/JavaScript/Vue files.
5//!
6//! Example tsconfig.json:
7//! ```json
8//! {
9//!   "compilerOptions": {
10//!     "baseUrl": ".",
11//!     "paths": {
12//!       "~/*": ["./src/*"],
13//!       "@packages/*": ["../../packages/*"]
14//!     }
15//!   }
16//! }
17//! ```
18
19use anyhow::{Context, Result};
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23
24/// Path alias mapping from tsconfig.json
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct PathAliasMap {
27    /// Map of alias pattern to target paths
28    /// Example: "@packages/*" => ["../../packages/*"]
29    pub aliases: HashMap<String, Vec<String>>,
30    /// Base URL for resolving relative paths
31    pub base_url: Option<String>,
32    /// Directory containing the tsconfig.json file
33    pub config_dir: PathBuf,
34}
35
36/// TypeScript compiler options (subset)
37#[derive(Debug, Deserialize)]
38struct CompilerOptions {
39    #[serde(rename = "baseUrl")]
40    base_url: Option<String>,
41    paths: Option<HashMap<String, Vec<String>>>,
42}
43
44/// TypeScript configuration file structure (subset)
45#[derive(Debug, Deserialize)]
46struct TsConfig {
47    #[serde(rename = "compilerOptions")]
48    compiler_options: Option<CompilerOptions>,
49}
50
51impl PathAliasMap {
52    /// Parse a tsconfig.json file and extract path aliases
53    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).with_context(|| {
56            format!("Failed to read tsconfig.json: {}", tsconfig_path.display())
57        })?;
58
59        // Parse JSON5 (tsconfig.json supports comments and trailing commas)
60        let config: TsConfig = json5::from_str(&content).with_context(|| {
61            format!("Failed to parse tsconfig.json: {}", tsconfig_path.display())
62        })?;
63
64        let config_dir = tsconfig_path
65            .parent()
66            .ok_or_else(|| anyhow::anyhow!("Invalid tsconfig.json path"))?
67            .to_path_buf();
68
69        let compiler_options = config.compiler_options.unwrap_or_else(|| CompilerOptions {
70            base_url: None,
71            paths: None,
72        });
73
74        Ok(Self {
75            aliases: compiler_options.paths.unwrap_or_default(),
76            base_url: compiler_options.base_url,
77            config_dir,
78        })
79    }
80
81    /// Find the nearest tsconfig.json for a given source file
82    ///
83    /// Walks up the directory tree from the source file until it finds a tsconfig.json.
84    /// Returns None if no tsconfig.json is found.
85    pub fn find_nearest_tsconfig(source_file: &Path) -> Option<PathBuf> {
86        let mut current_dir = source_file.parent()?;
87
88        loop {
89            let tsconfig_path = current_dir.join("tsconfig.json");
90            if tsconfig_path.exists() {
91                return Some(tsconfig_path);
92            }
93
94            // Also check for .nuxt/tsconfig.json (Nuxt-generated)
95            let nuxt_tsconfig = current_dir.join(".nuxt/tsconfig.json");
96            if nuxt_tsconfig.exists() {
97                return Some(nuxt_tsconfig);
98            }
99
100            // Move up one directory
101            current_dir = current_dir.parent()?;
102        }
103    }
104
105    /// Resolve an import path using the path alias mappings
106    ///
107    /// Returns the resolved path if the import matches an alias, None otherwise.
108    ///
109    /// Example:
110    /// - Alias: `@packages/*` => `../../packages/*`
111    /// - Import: `@packages/ui/stores/auth`
112    /// - Resolves to: `../../packages/ui/stores/auth`
113    pub fn resolve_alias(&self, import_path: &str) -> Option<String> {
114        log::debug!(
115            "  resolve_alias: trying to match '{}' against {} aliases",
116            import_path,
117            self.aliases.len()
118        );
119
120        // Try to match against each alias pattern
121        for (alias_pattern, target_paths) in &self.aliases {
122            log::trace!(
123                "    Checking alias pattern: {} => {:?}",
124                alias_pattern,
125                target_paths
126            );
127            // Check if pattern has a wildcard
128            if alias_pattern.ends_with("/*") {
129                let alias_prefix = alias_pattern.trim_end_matches("/*");
130
131                // Check if import starts with the alias prefix
132                if import_path.starts_with(alias_prefix) {
133                    // Extract the suffix after the alias prefix
134                    // Example: "@packages/ui/stores/auth" with alias "@packages"
135                    //          => suffix = "/ui/stores/auth"
136                    let suffix = import_path.strip_prefix(alias_prefix).unwrap_or("");
137
138                    // Use the first target path (tsconfig allows multiple, but we'll use the first)
139                    if let Some(target_pattern) = target_paths.first() {
140                        // Replace wildcard in target with the suffix
141                        let resolved = if target_pattern.ends_with("/*") {
142                            let target_prefix = target_pattern.trim_end_matches("/*");
143                            format!("{}{}", target_prefix, suffix)
144                        } else {
145                            // No wildcard in target - append suffix with proper path joining
146                            // Strip leading '/' from suffix to avoid double slashes
147                            // Example: ".." + "/ui/stores/auth" => "../ui/stores/auth"
148                            let clean_suffix = suffix.trim_start_matches('/');
149                            if clean_suffix.is_empty() {
150                                target_pattern.to_string()
151                            } else {
152                                format!("{}/{}", target_pattern, clean_suffix)
153                            }
154                        };
155
156                        log::trace!(
157                            "Resolved alias {} + {} => {}",
158                            alias_pattern,
159                            import_path,
160                            resolved
161                        );
162                        return Some(resolved);
163                    }
164                }
165            } else {
166                // Exact match (no wildcard)
167                if import_path == alias_pattern {
168                    if let Some(target) = target_paths.first() {
169                        log::trace!("Resolved exact alias {} => {}", alias_pattern, target);
170                        return Some(target.clone());
171                    }
172                }
173            }
174        }
175
176        None
177    }
178
179    /// Resolve a path relative to the tsconfig directory and baseUrl
180    pub fn resolve_relative_to_config(&self, path: &str) -> PathBuf {
181        let base = if let Some(ref base_url) = self.base_url {
182            self.config_dir.join(base_url)
183        } else {
184            self.config_dir.clone()
185        };
186
187        let joined = base.join(path);
188
189        // Normalize the path to resolve .. components without requiring file to exist
190        // Example: /home/user/packages/ui/./ui => /home/user/packages/ui
191        let normalized = joined
192            .components()
193            .fold(PathBuf::new(), |mut acc, component| {
194                match component {
195                    std::path::Component::CurDir => acc, // Skip .
196                    std::path::Component::ParentDir => {
197                        acc.pop(); // Go up one level for ..
198                        acc
199                    }
200                    _ => {
201                        acc.push(component);
202                        acc
203                    }
204                }
205            });
206
207        normalized
208    }
209}
210
211/// Find and parse all tsconfig.json files in a project directory
212///
213/// Walks the directory tree to discover all tsconfig.json files (including .nuxt/tsconfig.json)
214/// and returns a HashMap mapping each tsconfig directory to its PathAliasMap.
215///
216/// This supports monorepos with multiple tsconfig.json files in different directories.
217/// Respects .gitignore rules to skip node_modules and other ignored directories.
218pub fn parse_all_tsconfigs(
219    root: &Path,
220) -> Result<std::collections::HashMap<PathBuf, PathAliasMap>> {
221    use ignore::WalkBuilder;
222    use std::collections::HashMap;
223
224    log::debug!("Starting tsconfig discovery in {}", root.display());
225    let mut tsconfigs = HashMap::new();
226    let mut file_count = 0;
227
228    // Walk directory tree respecting .gitignore
229    for entry in WalkBuilder::new(root)
230        .follow_links(false)
231        .build()
232        .filter_map(|e| e.ok())
233    {
234        let path = entry.path();
235
236        // Check if this is a tsconfig.json file
237        if path.file_name().and_then(|n| n.to_str()) == Some("tsconfig.json") {
238            file_count += 1;
239            log::debug!(
240                "Found tsconfig.json file #{}: {}",
241                file_count,
242                path.display()
243            );
244
245            // Parse the tsconfig file
246            match PathAliasMap::from_file(path) {
247                Ok(alias_map) => {
248                    // Store using the directory containing the tsconfig as key
249                    let config_dir = alias_map.config_dir.clone();
250                    log::debug!(
251                        "  Parsed successfully: base_url={:?}, {} aliases",
252                        alias_map.base_url,
253                        alias_map.aliases.len()
254                    );
255                    tsconfigs.insert(config_dir, alias_map);
256                }
257                Err(e) => {
258                    log::warn!("Failed to parse tsconfig.json at {}: {}", path.display(), e);
259                }
260            }
261        }
262    }
263
264    log::debug!(
265        "Tsconfig discovery complete: found {} files, parsed {} successfully",
266        file_count,
267        tsconfigs.len()
268    );
269    Ok(tsconfigs)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275    use std::fs;
276    use tempfile::TempDir;
277
278    #[test]
279    fn test_parse_tsconfig_with_paths() {
280        let temp = TempDir::new().unwrap();
281        let tsconfig_path = temp.path().join("tsconfig.json");
282
283        let tsconfig_content = r#"{
284            "compilerOptions": {
285                "baseUrl": ".",
286                "paths": {
287                    "~/*": ["./src/*"],
288                    "@packages/*": ["../../packages/*"]
289                }
290            }
291        }"#;
292
293        fs::write(&tsconfig_path, tsconfig_content).unwrap();
294
295        let alias_map = PathAliasMap::from_file(&tsconfig_path).unwrap();
296
297        assert_eq!(alias_map.base_url, Some(".".to_string()));
298        assert_eq!(alias_map.aliases.len(), 2);
299        assert!(alias_map.aliases.contains_key("~/*"));
300        assert!(alias_map.aliases.contains_key("@packages/*"));
301    }
302
303    #[test]
304    fn test_resolve_wildcard_alias() {
305        let temp = TempDir::new().unwrap();
306        let alias_map = PathAliasMap {
307            aliases: HashMap::from([(
308                "@packages/*".to_string(),
309                vec!["../../packages/*".to_string()],
310            )]),
311            base_url: Some(".".to_string()),
312            config_dir: temp.path().to_path_buf(),
313        };
314
315        // Test wildcard alias resolution
316        let resolved = alias_map.resolve_alias("@packages/ui/stores/auth");
317        assert_eq!(resolved, Some("../../packages/ui/stores/auth".to_string()));
318    }
319
320    #[test]
321    fn test_resolve_exact_alias() {
322        let temp = TempDir::new().unwrap();
323        let alias_map = PathAliasMap {
324            aliases: HashMap::from([("~".to_string(), vec!["./src".to_string()])]),
325            base_url: None,
326            config_dir: temp.path().to_path_buf(),
327        };
328
329        // Test exact alias resolution
330        let resolved = alias_map.resolve_alias("~");
331        assert_eq!(resolved, Some("./src".to_string()));
332    }
333
334    #[test]
335    fn test_no_match() {
336        let temp = TempDir::new().unwrap();
337        let alias_map = PathAliasMap {
338            aliases: HashMap::from([(
339                "@packages/*".to_string(),
340                vec!["../../packages/*".to_string()],
341            )]),
342            base_url: None,
343            config_dir: temp.path().to_path_buf(),
344        };
345
346        // Import doesn't match any alias
347        let resolved = alias_map.resolve_alias("./relative/path");
348        assert_eq!(resolved, None);
349    }
350
351    #[test]
352    fn test_find_nearest_tsconfig() {
353        let temp = TempDir::new().unwrap();
354
355        // Create directory structure: temp/src/components/
356        let src_dir = temp.path().join("src");
357        let components_dir = src_dir.join("components");
358        fs::create_dir_all(&components_dir).unwrap();
359
360        // Create tsconfig.json in temp/
361        let tsconfig_path = temp.path().join("tsconfig.json");
362        fs::write(&tsconfig_path, "{}").unwrap();
363
364        // Create a source file in components/
365        let source_file = components_dir.join("Button.tsx");
366        fs::write(&source_file, "export const Button = () => {}").unwrap();
367
368        // Should find the tsconfig.json in temp/
369        let found = PathAliasMap::find_nearest_tsconfig(&source_file);
370        assert_eq!(found, Some(tsconfig_path));
371    }
372
373    #[test]
374    fn test_resolve_relative_to_config() {
375        let temp = TempDir::new().unwrap();
376        let alias_map = PathAliasMap {
377            aliases: HashMap::new(),
378            base_url: Some("src".to_string()),
379            config_dir: temp.path().to_path_buf(),
380        };
381
382        let resolved = alias_map.resolve_relative_to_config("utils/helper.ts");
383        assert_eq!(resolved, temp.path().join("src/utils/helper.ts"));
384    }
385}