1use regex::Regex;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::OnceLock;
5
6static JS_RESOLVE_EXTS: &[&str] = &[".ts", ".tsx", ".svelte", ".js", ".jsx", ".mjs"];
7static JS_INDEX_FILES: &[&str] = &[
8 "index.ts",
9 "index.tsx",
10 "index.svelte",
11 "index.js",
12 "index.jsx",
13 "index.mjs",
14];
15
16pub fn resolve_js_import_path(candidate: &Path) -> Option<PathBuf> {
24 let candidate = normalize_path(candidate);
25 if candidate.is_file() {
26 return Some(candidate);
27 }
28
29 if let Some(ext) = candidate.extension().and_then(|e| e.to_str()) {
31 match ext {
32 "js" => {
33 let ts = candidate.with_extension("ts");
34 if ts.is_file() {
35 return Some(ts);
36 }
37 }
38 "jsx" => {
39 let tsx = candidate.with_extension("tsx");
40 if tsx.is_file() {
41 return Some(tsx);
42 }
43 }
44 _ => {}
45 }
46 }
47
48 if let Some(name) = candidate.file_name().and_then(|n| n.to_str()) {
50 let parent = candidate.parent().unwrap_or(Path::new("."));
51 for &ext in JS_RESOLVE_EXTS {
52 let with_ext = parent.join(format!("{}{}", name, ext));
53 if with_ext.is_file() {
54 return Some(with_ext);
55 }
56 }
57 }
58
59 if candidate.is_dir() {
61 for &index_name in JS_INDEX_FILES {
62 let index = candidate.join(index_name);
63 if index.is_file() {
64 return Some(index);
65 }
66 }
67 }
68
69 None
70}
71
72pub fn resolve_js_module_path(raw: &str, start_dir: &Path) -> Option<PathBuf> {
79 if raw.starts_with('.') {
80 return resolve_js_import_path(&start_dir.join(raw));
81 }
82
83 let aliases = load_tsconfig_aliases(start_dir);
84 for (alias_prefix, alias_base) in &aliases {
85 if raw == alias_prefix || raw.starts_with(&format!("{}/", alias_prefix)) {
86 let rest = raw[alias_prefix.len()..].trim_start_matches('/');
87 let resolved = normalize_path(&Path::new(alias_base).join(rest));
88 return resolve_js_import_path(&resolved);
89 }
90 }
91
92 resolve_workspace_import(raw, start_dir)
93}
94
95fn normalize_path(path: &Path) -> PathBuf {
96 let mut components = Vec::new();
98 for component in path.components() {
99 use std::path::Component;
100 match component {
101 Component::CurDir => {}
102 Component::ParentDir => {
103 components.pop();
104 }
105 other => components.push(other),
106 }
107 }
108 components.iter().collect()
109}
110
111pub fn load_tsconfig_aliases(start_dir: &Path) -> HashMap<String, String> {
116 let start = match std::fs::canonicalize(start_dir) {
117 Ok(p) => p,
118 Err(_) => start_dir.to_path_buf(),
119 };
120 let mut current = Some(start.as_path());
121 while let Some(dir) = current {
122 let tsconfig = dir.join("tsconfig.json");
123 if tsconfig.is_file() {
124 return read_tsconfig_aliases(&tsconfig, dir, &mut std::collections::HashSet::new());
125 }
126 current = dir.parent();
127 }
128 HashMap::new()
129}
130
131fn read_tsconfig_aliases(
132 tsconfig_path: &Path,
133 base_dir: &Path,
134 seen: &mut std::collections::HashSet<PathBuf>,
135) -> HashMap<String, String> {
136 if !seen.insert(tsconfig_path.to_path_buf()) {
137 return HashMap::new();
138 }
139 let text = match std::fs::read_to_string(tsconfig_path) {
140 Ok(t) => t,
141 Err(_) => return HashMap::new(),
142 };
143 let stripped = strip_jsonc(&text);
144 let data: serde_json::Value = match serde_json::from_str(&stripped) {
145 Ok(v) => v,
146 Err(_) => return HashMap::new(),
147 };
148 let mut aliases = HashMap::new();
149
150 if let Some(extends_field) = data.get("extends") {
152 let extend_strs: Vec<&str> = if let Some(s) = extends_field.as_str() {
154 vec![s]
155 } else if let Some(arr) = extends_field.as_array() {
156 arr.iter().filter_map(|v| v.as_str()).collect()
157 } else {
158 vec![]
159 };
160 for extends_val in extend_strs {
161 let extended_path = base_dir.join(extends_val);
162 let extended_path = normalize_path(&extended_path);
163 if extended_path.is_file() {
164 let parent = extended_path.parent().unwrap_or(Path::new("."));
165 aliases.extend(read_tsconfig_aliases(&extended_path, parent, seen));
166 }
167 }
168 }
169
170 if let Some(paths) = data
171 .get("compilerOptions")
172 .and_then(|co| co.get("paths"))
173 .and_then(|p| p.as_object())
174 {
175 for (alias, targets) in paths {
176 let targets = match targets.as_array() {
177 Some(t) if !t.is_empty() => t,
178 _ => continue,
179 };
180 let target_base = match targets[0].as_str() {
181 Some(t) => t.trim_end_matches("/*"),
182 None => continue,
183 };
184 let alias_prefix = alias.trim_end_matches("/*").to_string();
185 let resolved_base = normalize_path(&base_dir.join(target_base));
186 aliases.insert(alias_prefix, resolved_base.to_string_lossy().into_owned());
187 }
188 }
189
190 aliases
191}
192
193pub fn strip_jsonc(text: &str) -> String {
195 static PATTERN: OnceLock<Regex> = OnceLock::new();
196 let re =
197 PATTERN.get_or_init(|| Regex::new(r#""(?:\\.|[^"\\])*"|/\*[\s\S]*?\*/|//[^\n]*"#).unwrap());
198 let result = re.replace_all(text, |caps: ®ex::Captures| {
199 let m = caps.get(0).unwrap().as_str();
200 if m.starts_with('"') {
201 m.to_string()
202 } else {
203 String::new()
204 }
205 });
206 static TRAILING_COMMA: OnceLock<Regex> = OnceLock::new();
208 let re2 = TRAILING_COMMA.get_or_init(|| Regex::new(r",(\s*[}\]])").unwrap());
209 re2.replace_all(&result, "$1").into_owned()
210}
211
212fn resolve_workspace_import(raw: &str, start_dir: &Path) -> Option<PathBuf> {
213 let packages = load_workspace_packages(start_dir);
214 for (package_name, package_dir) in &packages {
215 let subpath = if raw == package_name {
216 ""
217 } else if raw.starts_with(&format!("{}/", package_name)) {
218 &raw[package_name.len() + 1..]
219 } else {
220 continue;
221 };
222 for candidate in package_entry_candidates(package_dir, subpath) {
223 if let Some(resolved) = resolve_js_import_path(&candidate) {
224 return Some(resolved);
225 }
226 }
227 }
228 None
229}
230
231fn load_workspace_packages(start_dir: &Path) -> HashMap<String, PathBuf> {
232 let root = match find_workspace_root(start_dir) {
233 Some(r) => r,
234 None => return HashMap::new(),
235 };
236 let workspace_file = root.join("pnpm-workspace.yaml");
237 let mut packages = HashMap::new();
238 for pattern in workspace_globs(&workspace_file) {
239 for entry in root.glob_iter(&pattern) {
240 let package_dir = match entry {
241 Ok(p) => p,
242 Err(_) => continue,
243 };
244 let manifest = package_dir.join("package.json");
245 if !manifest.is_file() {
246 continue;
247 }
248 let data: serde_json::Value = match std::fs::read_to_string(&manifest)
249 .ok()
250 .and_then(|t| serde_json::from_str(&t).ok())
251 {
252 Some(v) => v,
253 None => continue,
254 };
255 if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
256 if !name.is_empty() {
257 packages.insert(name.to_string(), package_dir);
258 }
259 }
260 }
261 }
262 packages
263}
264
265fn find_workspace_root(start_dir: &Path) -> Option<PathBuf> {
266 let start = std::fs::canonicalize(start_dir).unwrap_or_else(|_| start_dir.to_path_buf());
267 let mut current = Some(start.as_path());
268 while let Some(dir) = current {
269 if dir.join("pnpm-workspace.yaml").is_file() {
270 return Some(dir.to_path_buf());
271 }
272 current = dir.parent();
273 }
274 None
275}
276
277fn workspace_globs(workspace_file: &Path) -> Vec<String> {
278 let text = match std::fs::read_to_string(workspace_file) {
279 Ok(t) => t,
280 Err(_) => return Vec::new(),
281 };
282 let mut globs = Vec::new();
283 let mut in_packages = false;
284 for line in text.lines() {
285 let stripped = line.trim();
286 if stripped.is_empty() || stripped.starts_with('#') {
287 continue;
288 }
289 if stripped.starts_with("packages:") {
290 in_packages = true;
291 continue;
292 }
293 if in_packages && stripped.starts_with('-') {
294 let value = stripped[1..].trim().trim_matches(|c| c == '\'' || c == '"');
295 if !value.is_empty() && !value.starts_with('!') {
296 globs.push(value.to_string());
297 }
298 } else if in_packages && !line.starts_with(' ') && !line.starts_with('\t') {
299 break;
300 }
301 }
302 globs
303}
304
305fn package_entry_candidates(package_dir: &Path, subpath: &str) -> Vec<PathBuf> {
306 if !subpath.is_empty() {
307 return vec![package_dir.join(subpath)];
308 }
309 let manifest = package_dir.join("package.json");
310 let data: serde_json::Value = std::fs::read_to_string(&manifest)
311 .ok()
312 .and_then(|t| serde_json::from_str(&t).ok())
313 .unwrap_or(serde_json::Value::Null);
314
315 if let Some(exports) = data.get("exports") {
317 if let Some(s) = exports.as_str() {
318 return vec![package_dir.join(s)];
319 }
320 if let Some(obj) = exports.as_object() {
321 if let Some(dot) = obj.get(".") {
322 if let Some(s) = dot.as_str() {
323 return vec![package_dir.join(s)];
324 }
325 if let Some(dot_obj) = dot.as_object() {
326 for key in &["types", "import", "default", "svelte"] {
327 if let Some(s) = dot_obj.get(*key).and_then(|v| v.as_str()) {
328 return vec![package_dir.join(s)];
329 }
330 }
331 }
332 }
333 }
334 }
335
336 let mut candidates = Vec::new();
337 for key in &["svelte", "module", "main", "types"] {
338 if let Some(s) = data.get(*key).and_then(|v| v.as_str()) {
339 candidates.push(package_dir.join(s));
340 }
341 }
342 candidates.push(package_dir.join("src/index"));
343 candidates.push(package_dir.join("index"));
344 candidates
345}
346
347trait GlobIter {
349 fn glob_iter(&self, pattern: &str) -> Vec<std::result::Result<PathBuf, glob::GlobError>>;
350}
351
352impl GlobIter for PathBuf {
353 fn glob_iter(&self, pattern: &str) -> Vec<std::result::Result<PathBuf, glob::GlobError>> {
354 let full_pattern = self.join(pattern);
355 let pattern_str = full_pattern.to_string_lossy();
356 match glob::glob(&pattern_str) {
357 Ok(paths) => paths.collect(),
358 Err(_) => Vec::new(),
359 }
360 }
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366 use std::fs;
367 use tempfile::TempDir;
368
369 fn tmp() -> TempDir {
370 tempfile::TempDir::new().unwrap()
371 }
372
373 fn write(dir: &Path, name: &str, content: &str) -> PathBuf {
374 let path = dir.join(name);
375 if let Some(parent) = path.parent() {
376 fs::create_dir_all(parent).unwrap();
377 }
378 fs::write(&path, content).unwrap();
379 path
380 }
381
382 #[test]
385 fn test_exact_file_returned() {
386 let dir = tmp();
387 let p = write(dir.path(), "foo.ts", "");
388 assert_eq!(resolve_js_import_path(&p), Some(p));
389 }
390
391 #[test]
392 fn test_nonexistent_returns_none() {
393 let dir = tmp();
394 assert_eq!(resolve_js_import_path(&dir.path().join("nope.ts")), None);
395 }
396
397 #[test]
400 fn test_js_to_ts_rewrite() {
401 let dir = tmp();
402 let ts = write(dir.path(), "helper.ts", "");
403 let js_path = dir.path().join("helper.js");
404 assert_eq!(resolve_js_import_path(&js_path), Some(ts));
405 }
406
407 #[test]
408 fn test_jsx_to_tsx_rewrite() {
409 let dir = tmp();
410 let tsx = write(dir.path(), "component.tsx", "");
411 let jsx_path = dir.path().join("component.jsx");
412 assert_eq!(resolve_js_import_path(&jsx_path), Some(tsx));
413 }
414
415 #[test]
418 fn test_extensionless_resolves_to_ts() {
419 let dir = tmp();
420 let ts = write(dir.path(), "utils.ts", "");
421 let bare = dir.path().join("utils");
422 assert_eq!(resolve_js_import_path(&bare), Some(ts));
423 }
424
425 #[test]
426 fn test_extensionless_resolves_to_tsx() {
427 let dir = tmp();
428 let tsx = write(dir.path(), "comp.tsx", "");
429 let bare = dir.path().join("comp");
430 assert_eq!(resolve_js_import_path(&bare), Some(tsx));
431 }
432
433 #[test]
434 fn test_extensionless_resolves_to_js_when_no_ts() {
435 let dir = tmp();
436 let js = write(dir.path(), "legacy.js", "");
437 let bare = dir.path().join("legacy");
438 assert_eq!(resolve_js_import_path(&bare), Some(js));
439 }
440
441 #[test]
442 fn test_extensionless_resolves_to_svelte() {
443 let dir = tmp();
444 let svelte = write(dir.path(), "Button.svelte", "");
445 let bare = dir.path().join("Button");
446 assert_eq!(resolve_js_import_path(&bare), Some(svelte));
447 }
448
449 #[test]
450 fn test_extensionless_resolves_to_mjs() {
451 let dir = tmp();
452 let mjs = write(dir.path(), "esm.mjs", "");
453 let bare = dir.path().join("esm");
454 assert_eq!(resolve_js_import_path(&bare), Some(mjs));
455 }
456
457 #[test]
460 fn test_dir_index_ts() {
461 let dir = tmp();
462 let idx = write(dir.path(), "mymod/index.ts", "");
463 let bare = dir.path().join("mymod");
464 assert_eq!(resolve_js_import_path(&bare), Some(idx));
465 }
466
467 #[test]
468 fn test_dir_index_js_when_no_ts() {
469 let dir = tmp();
470 let idx = write(dir.path(), "mymod/index.js", "");
471 let bare = dir.path().join("mymod");
472 assert_eq!(resolve_js_import_path(&bare), Some(idx));
473 }
474
475 #[test]
477 fn test_extension_appended_before_directory_index() {
478 let dir = tmp();
479 let _ = write(dir.path(), "mod/index.ts", "");
480 let ts_file = write(dir.path(), "mod.ts", "");
481 let bare = dir.path().join("mod");
482 assert_eq!(resolve_js_import_path(&bare), Some(ts_file));
483 }
484
485 #[test]
488 fn test_relative_path_resolution() {
489 let dir = tmp();
490 let ts = write(dir.path(), "lib/helper.ts", "");
491 let result = resolve_js_module_path("./helper", &dir.path().join("lib"));
492 assert_eq!(result, Some(ts));
493 }
494
495 #[test]
496 fn test_relative_path_parent_dir() {
497 let dir = tmp();
498 let ts = write(dir.path(), "shared.ts", "");
499 let result = resolve_js_module_path("../shared", &dir.path().join("subdir"));
500 assert_eq!(result, Some(ts));
501 }
502
503 #[test]
506 fn test_tsconfig_alias_resolution() {
507 let dir = tmp();
508 let ts = write(dir.path(), "src/utils/helper.ts", "");
509 let tsconfig =
510 r#"{"compilerOptions": {"paths": {"@utils/*": ["src/utils/*"]}}}"#.to_string();
511 write(dir.path(), "tsconfig.json", &tsconfig);
512 let result = resolve_js_module_path("@utils/helper", dir.path());
513 assert_eq!(result, Some(ts));
514 }
515
516 #[test]
517 fn test_tsconfig_alias_exact_match() {
518 let dir = tmp();
519 let ts = write(dir.path(), "src/lib.ts", "");
520 let tsconfig = r#"{"compilerOptions": {"paths": {"@lib": ["src/lib"]}}}"#.to_string();
521 write(dir.path(), "tsconfig.json", &tsconfig);
522 let result = resolve_js_module_path("@lib", dir.path());
523 assert_eq!(result, Some(ts));
524 }
525
526 #[test]
527 fn test_no_tsconfig_bare_import_returns_none() {
528 let dir = tmp();
529 let result = resolve_js_module_path("some-external-package", dir.path());
530 assert!(result.is_none());
531 }
532
533 #[test]
536 fn test_strip_jsonc_line_comment() {
537 let input = r#"{"key": "value" // comment
538}"#;
539 let stripped = strip_jsonc(input);
540 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
541 assert_eq!(parsed["key"], "value");
542 }
543
544 #[test]
545 fn test_strip_jsonc_block_comment() {
546 let input = r#"{"key": /* block comment */ "value"}"#;
547 let stripped = strip_jsonc(input);
548 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
549 assert_eq!(parsed["key"], "value");
550 }
551
552 #[test]
553 fn test_strip_jsonc_trailing_comma() {
554 let input = r#"{"a": 1, "b": 2,}"#;
555 let stripped = strip_jsonc(input);
556 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
557 assert_eq!(parsed["a"], 1);
558 assert_eq!(parsed["b"], 2);
559 }
560
561 #[test]
562 fn test_strip_jsonc_preserves_url_in_string() {
563 let input = r#"{"url": "https://example.com/path"}"#;
564 let stripped = strip_jsonc(input);
565 let parsed: serde_json::Value = serde_json::from_str(&stripped).unwrap();
566 assert_eq!(parsed["url"], "https://example.com/path");
567 }
568
569 #[test]
572 fn test_load_tsconfig_from_parent_dir() {
573 let dir = tmp();
574 let tsconfig = r#"{"compilerOptions": {"paths": {"@app/*": ["app/*"]}}}"#;
575 write(dir.path(), "tsconfig.json", tsconfig);
576 let subdir = dir.path().join("src");
577 fs::create_dir_all(&subdir).unwrap();
578 let aliases = load_tsconfig_aliases(&subdir);
579 assert!(aliases.contains_key("@app"));
580 }
581
582 #[test]
583 fn test_load_tsconfig_no_paths_empty() {
584 let dir = tmp();
585 let tsconfig = r#"{"compilerOptions": {}}"#;
586 write(dir.path(), "tsconfig.json", tsconfig);
587 let aliases = load_tsconfig_aliases(dir.path());
588 assert!(aliases.is_empty());
589 }
590
591 #[test]
592 fn test_no_tsconfig_returns_empty() {
593 let dir = tmp();
594 let aliases = load_tsconfig_aliases(dir.path());
595 assert!(aliases.is_empty());
596 }
597
598 #[test]
601 fn test_workspace_globs_parsed() {
602 let dir = tmp();
603 let yaml = "packages:\n - 'packages/*'\n - 'apps/*'\n";
604 write(dir.path(), "pnpm-workspace.yaml", yaml);
605 let globs = workspace_globs(&dir.path().join("pnpm-workspace.yaml"));
606 assert_eq!(globs, vec!["packages/*", "apps/*"]);
607 }
608
609 #[test]
610 fn test_workspace_globs_ignores_negations() {
611 let dir = tmp();
612 let yaml = "packages:\n - 'packages/*'\n - '!packages/internal'\n";
613 write(dir.path(), "pnpm-workspace.yaml", yaml);
614 let globs = workspace_globs(&dir.path().join("pnpm-workspace.yaml"));
615 assert_eq!(globs, vec!["packages/*"]);
616 }
617}