chainsaw/lang/typescript/
mod.rs1mod 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 if let Some(result) = self.workspace_cache.get(dir).map(|e| e.value().clone()) {
68 return result;
69 }
70
71 let mut uncached: Vec<PathBuf> = Vec::new();
73
74 let result = loop {
75 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 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 fs::write(root.join("package.json"), r#"{"name": "root"}"#).unwrap();
204 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 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 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 assert_eq!(
248 support.workspace_package_name(&lib_a.join("index.ts"), &root),
249 Some("@org/lib-a".to_string())
250 );
251 assert_eq!(
253 support.workspace_package_name(&lib_b.join("file.ts"), &root),
254 Some("@org/lib-b".to_string())
255 );
256 assert_eq!(
258 support.workspace_package_name(&lib_b.join("other.ts"), &root),
259 Some("@org/lib-b".to_string())
260 );
261 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 assert_eq!(
268 support.workspace_package_name(&root.join("src/app.ts"), &root),
269 None
270 );
271 }
272}