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