Skip to main content

chainsaw/lang/typescript/
mod.rs

1//! TypeScript/JavaScript support: OXC parser with `node_modules` resolution.
2
3mod parser;
4mod resolver;
5
6use std::path::{Path, PathBuf};
7
8use dashmap::DashMap;
9
10use crate::lang::{LanguageSupport, ParseError, ParseResult};
11
12use self::resolver::{package_name_from_path, ImportResolver};
13
14const EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs", "cjs", "mts", "cts"];
15#[derive(Debug)]
16pub struct TypeScriptSupport {
17    resolver: ImportResolver,
18    workspace_cache: DashMap<PathBuf, Option<String>>,
19}
20
21impl TypeScriptSupport {
22    pub fn new(root: &Path) -> Self {
23        Self {
24            resolver: ImportResolver::new(root),
25            workspace_cache: DashMap::new(),
26        }
27    }
28}
29
30impl LanguageSupport for TypeScriptSupport {
31    fn extensions(&self) -> &'static [&'static str] {
32        EXTENSIONS
33    }
34
35    fn parse(&self, path: &Path, source: &str) -> Result<ParseResult, ParseError> {
36        parser::parse_file(path, source)
37    }
38
39    fn resolve(&self, from_dir: &Path, specifier: &str) -> Option<PathBuf> {
40        self.resolver.resolve(from_dir, specifier)
41    }
42
43    fn package_name(&self, resolved_path: &Path) -> Option<String> {
44        package_name_from_path(resolved_path)
45    }
46
47    fn workspace_package_name(&self, file_path: &Path, project_root: &Path) -> Option<String> {
48        let mut dir = file_path.parent()?;
49
50        // Fast path: starting directory already cached.
51        // Clone and drop the Ref immediately to avoid holding a read lock
52        // across insert() calls (DashMap shard deadlock).
53        if let Some(result) = self.workspace_cache.get(dir).map(|e| e.value().clone()) {
54            return result;
55        }
56
57        // Walk up from the file's directory, collecting uncached directories
58        let mut uncached: Vec<PathBuf> = Vec::new();
59
60        let result = loop {
61            // Check cache (clone + drop Ref before any insert)
62            if let Some(result) = self.workspace_cache.get(dir).map(|e| e.value().clone()) {
63                break result;
64            }
65
66            let pkg_json = dir.join("package.json");
67            if pkg_json.exists() {
68                let result = if dir == project_root {
69                    None
70                } else {
71                    resolver::read_package_name(&pkg_json)
72                };
73                uncached.push(dir.to_path_buf());
74                break result;
75            }
76
77            uncached.push(dir.to_path_buf());
78
79            match dir.parent() {
80                Some(parent) if parent != dir => dir = parent,
81                _ => break None,
82            }
83        };
84
85        // Cache all visited directories with the result
86        for d in uncached {
87            self.workspace_cache.insert(d, result.clone());
88        }
89        result
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use std::fs;
97
98    fn setup_workspace(tmp: &Path) {
99        let app = tmp.join("packages/app");
100        let lib = tmp.join("packages/lib/src");
101        fs::create_dir_all(&app).unwrap();
102        fs::create_dir_all(&lib).unwrap();
103        fs::write(app.join("package.json"), r#"{"name": "my-app"}"#).unwrap();
104        fs::write(
105            tmp.join("packages/lib/package.json"),
106            r#"{"name": "@my/lib"}"#,
107        )
108        .unwrap();
109        fs::write(lib.join("index.ts"), "export const x = 1;").unwrap();
110    }
111
112    #[test]
113    fn read_package_name_valid() {
114        let tmp = tempfile::tempdir().unwrap();
115        let nested = tmp.path().join("packages/pkg");
116        fs::create_dir_all(&nested).unwrap();
117        fs::write(nested.join("package.json"), r#"{"name": "@scope/pkg"}"#).unwrap();
118        let support = TypeScriptSupport::new(tmp.path());
119        assert_eq!(
120            support.workspace_package_name(&nested.join("index.ts"), tmp.path()),
121            Some("@scope/pkg".to_string())
122        );
123    }
124
125    #[test]
126    fn read_package_name_missing_name_field() {
127        let tmp = tempfile::tempdir().unwrap();
128        let nested = tmp.path().join("packages/pkg");
129        fs::create_dir_all(&nested).unwrap();
130        fs::write(nested.join("package.json"), r#"{"version": "1.0.0"}"#).unwrap();
131        let support = TypeScriptSupport::new(tmp.path());
132        assert_eq!(
133            support.workspace_package_name(&nested.join("index.ts"), tmp.path()),
134            None
135        );
136    }
137
138    #[test]
139    fn workspace_detects_sibling_package() {
140        let tmp = tempfile::tempdir().unwrap();
141        setup_workspace(tmp.path());
142        let project_root = tmp.path().join("packages/app");
143        let support = TypeScriptSupport::new(&project_root);
144        let sibling_file = tmp.path().join("packages/lib/src/index.ts");
145        assert_eq!(
146            support.workspace_package_name(&sibling_file, &project_root),
147            Some("@my/lib".to_string())
148        );
149    }
150
151    #[test]
152    fn workspace_skips_project_root() {
153        let tmp = tempfile::tempdir().unwrap();
154        setup_workspace(tmp.path());
155        let project_root = tmp.path().join("packages/app");
156        let support = TypeScriptSupport::new(&project_root);
157        let own_file = project_root.join("src/cli.ts");
158        assert_eq!(
159            support.workspace_package_name(&own_file, &project_root),
160            None
161        );
162    }
163
164    #[test]
165    fn workspace_caches_lookups() {
166        let tmp = tempfile::tempdir().unwrap();
167        setup_workspace(tmp.path());
168        let project_root = tmp.path().join("packages/app");
169        let support = TypeScriptSupport::new(&project_root);
170
171        let file1 = tmp.path().join("packages/lib/src/index.ts");
172        let file2 = tmp.path().join("packages/lib/src/utils.ts");
173
174        assert_eq!(
175            support.workspace_package_name(&file1, &project_root),
176            Some("@my/lib".to_string())
177        );
178        assert_eq!(
179            support.workspace_package_name(&file2, &project_root),
180            Some("@my/lib".to_string())
181        );
182    }
183
184    #[test]
185    fn workspace_deep_tree_caches_negatives() {
186        let tmp = tempfile::tempdir().unwrap();
187        let root = tmp.path().canonicalize().unwrap();
188        // Root package.json exists but should be skipped (project root)
189        fs::write(root.join("package.json"), r#"{"name": "root"}"#).unwrap();
190        // Deep directories with no intermediate package.json
191        let deep = root.join("src/features/auth/components/forms");
192        fs::create_dir_all(&deep).unwrap();
193
194        let support = TypeScriptSupport::new(&root);
195
196        // Multiple files at different depths should all return None
197        let file1 = deep.join("LoginForm.ts");
198        let file2 = deep.join("SignupForm.ts");
199        let file3 = root.join("src/features/auth/index.ts");
200        let file4 = root.join("src/features/auth/components/Button.ts");
201
202        assert_eq!(support.workspace_package_name(&file1, &root), None);
203        assert_eq!(support.workspace_package_name(&file2, &root), None);
204        assert_eq!(support.workspace_package_name(&file3, &root), None);
205        assert_eq!(support.workspace_package_name(&file4, &root), None);
206    }
207
208    #[test]
209    fn workspace_mixed_depths_correct_package() {
210        let tmp = tempfile::tempdir().unwrap();
211        let root = tmp.path().canonicalize().unwrap();
212        fs::write(root.join("package.json"), r#"{"name": "root"}"#).unwrap();
213
214        // Two sibling packages at different depths
215        let lib_a = root.join("packages/lib-a");
216        let lib_b = root.join("packages/lib-b/src/deep/nested");
217        fs::create_dir_all(&lib_a).unwrap();
218        fs::create_dir_all(&lib_b).unwrap();
219        fs::write(
220            root.join("packages/lib-a/package.json"),
221            r#"{"name": "@org/lib-a"}"#,
222        )
223        .unwrap();
224        fs::write(
225            root.join("packages/lib-b/package.json"),
226            r#"{"name": "@org/lib-b"}"#,
227        )
228        .unwrap();
229
230        let support = TypeScriptSupport::new(&root);
231
232        // Files in lib-a
233        assert_eq!(
234            support.workspace_package_name(&lib_a.join("index.ts"), &root),
235            Some("@org/lib-a".to_string())
236        );
237        // Files deep in lib-b (tests intermediate directory caching)
238        assert_eq!(
239            support.workspace_package_name(&lib_b.join("file.ts"), &root),
240            Some("@org/lib-b".to_string())
241        );
242        // Another file in same deep dir (should hit cache)
243        assert_eq!(
244            support.workspace_package_name(&lib_b.join("other.ts"), &root),
245            Some("@org/lib-b".to_string())
246        );
247        // File at intermediate depth in lib-b (should also hit cache)
248        assert_eq!(
249            support.workspace_package_name(
250                &root.join("packages/lib-b/src/shallow.ts"),
251                &root
252            ),
253            Some("@org/lib-b".to_string())
254        );
255        // First-party file at root level — should be None
256        assert_eq!(
257            support.workspace_package_name(&root.join("src/app.ts"), &root),
258            None
259        );
260    }
261}