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)
56            .with_context(|| format!("Failed to read tsconfig.json: {}", tsconfig_path.display()))?;
57
58        // Parse JSON5 (tsconfig.json supports comments and trailing commas)
59        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    /// Find the nearest tsconfig.json for a given source file
81    ///
82    /// Walks up the directory tree from the source file until it finds a tsconfig.json.
83    /// Returns None if no tsconfig.json is found.
84    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            // Also check for .nuxt/tsconfig.json (Nuxt-generated)
94            let nuxt_tsconfig = current_dir.join(".nuxt/tsconfig.json");
95            if nuxt_tsconfig.exists() {
96                return Some(nuxt_tsconfig);
97            }
98
99            // Move up one directory
100            current_dir = current_dir.parent()?;
101        }
102    }
103
104    /// Resolve an import path using the path alias mappings
105    ///
106    /// Returns the resolved path if the import matches an alias, None otherwise.
107    ///
108    /// Example:
109    /// - Alias: `@packages/*` => `../../packages/*`
110    /// - Import: `@packages/ui/stores/auth`
111    /// - Resolves to: `../../packages/ui/stores/auth`
112    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        // Try to match against each alias pattern
116        for (alias_pattern, target_paths) in &self.aliases {
117            log::trace!("    Checking alias pattern: {} => {:?}", alias_pattern, target_paths);
118            // Check if pattern has a wildcard
119            if alias_pattern.ends_with("/*") {
120                let alias_prefix = alias_pattern.trim_end_matches("/*");
121
122                // Check if import starts with the alias prefix
123                if import_path.starts_with(alias_prefix) {
124                    // Extract the suffix after the alias prefix
125                    // Example: "@packages/ui/stores/auth" with alias "@packages"
126                    //          => suffix = "/ui/stores/auth"
127                    let suffix = import_path.strip_prefix(alias_prefix).unwrap_or("");
128
129                    // Use the first target path (tsconfig allows multiple, but we'll use the first)
130                    if let Some(target_pattern) = target_paths.first() {
131                        // Replace wildcard in target with the suffix
132                        let resolved = if target_pattern.ends_with("/*") {
133                            let target_prefix = target_pattern.trim_end_matches("/*");
134                            format!("{}{}", target_prefix, suffix)
135                        } else {
136                            // No wildcard in target - append suffix with proper path joining
137                            // Strip leading '/' from suffix to avoid double slashes
138                            // Example: ".." + "/ui/stores/auth" => "../ui/stores/auth"
139                            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                // Exact match (no wildcard)
153                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    /// Resolve a path relative to the tsconfig directory and baseUrl
166    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        // Normalize the path to resolve .. components without requiring file to exist
176        // Example: /home/user/packages/ui/./ui => /home/user/packages/ui
177        let normalized = joined.components()
178            .fold(PathBuf::new(), |mut acc, component| {
179                match component {
180                    std::path::Component::CurDir => acc, // Skip .
181                    std::path::Component::ParentDir => {
182                        acc.pop(); // Go up one level for ..
183                        acc
184                    }
185                    _ => {
186                        acc.push(component);
187                        acc
188                    }
189                }
190            });
191
192        normalized
193    }
194}
195
196/// Find and parse all tsconfig.json files in a project directory
197///
198/// Walks the directory tree to discover all tsconfig.json files (including .nuxt/tsconfig.json)
199/// and returns a HashMap mapping each tsconfig directory to its PathAliasMap.
200///
201/// This supports monorepos with multiple tsconfig.json files in different directories.
202/// Respects .gitignore rules to skip node_modules and other ignored directories.
203pub 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    // Walk directory tree respecting .gitignore
212    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        // Check if this is a tsconfig.json file
220        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            // Parse the tsconfig file
225            match PathAliasMap::from_file(path) {
226                Ok(alias_map) => {
227                    // Store using the directory containing the tsconfig as key
228                    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        // Test wildcard alias resolution
288        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        // Test exact alias resolution
304        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        // Import doesn't match any alias
320        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        // Create directory structure: temp/src/components/
329        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        // Create tsconfig.json in temp/
334        let tsconfig_path = temp.path().join("tsconfig.json");
335        fs::write(&tsconfig_path, "{}").unwrap();
336
337        // Create a source file in components/
338        let source_file = components_dir.join("Button.tsx");
339        fs::write(&source_file, "export const Button = () => {}").unwrap();
340
341        // Should find the tsconfig.json in temp/
342        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}