chainsaw/lang/typescript/
mod.rs1mod parser;
4mod resolver;
5
6use std::path::{Path, PathBuf};
7
8use dashmap::DashMap;
9
10use crate::lang::{LanguageSupport, ParseError, ParseResult};
11
12use self::resolver::{ImportResolver, package_name_from_path};
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 if let Some(result) = self.workspace_cache.get(dir).map(|e| e.value().clone()) {
54 return result;
55 }
56
57 let mut uncached: Vec<PathBuf> = Vec::new();
59
60 let result = loop {
61 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 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 fs::write(root.join("package.json"), r#"{"name": "root"}"#).unwrap();
190 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 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 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 assert_eq!(
234 support.workspace_package_name(&lib_a.join("index.ts"), &root),
235 Some("@org/lib-a".to_string())
236 );
237 assert_eq!(
239 support.workspace_package_name(&lib_b.join("file.ts"), &root),
240 Some("@org/lib-b".to_string())
241 );
242 assert_eq!(
244 support.workspace_package_name(&lib_b.join("other.ts"), &root),
245 Some("@org/lib-b".to_string())
246 );
247 assert_eq!(
249 support.workspace_package_name(&root.join("packages/lib-b/src/shallow.ts"), &root),
250 Some("@org/lib-b".to_string())
251 );
252 assert_eq!(
254 support.workspace_package_name(&root.join("src/app.ts"), &root),
255 None
256 );
257 }
258}