Skip to main content

codesynapse_core/
import_extension.rs

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
16/// Resolve a JS/TS/Svelte import candidate path to an existing local file.
17///
18/// Resolution order:
19/// 1. The path itself (if it's already a file)
20/// 2. `.js` → `.ts` and `.jsx` → `.tsx` (TypeScript ESM convention)
21/// 3. Append known JS/TS extensions to the filename
22/// 4. Directory index files (only if the candidate is a directory)
23pub 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    // TS ESM convention: .js → .ts, .jsx → .tsx
30    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    // Try appending extensions to the full name
49    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    // Directory index fallback
60    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
72/// Resolve a JS/TS module specifier to a local file.
73///
74/// - Relative paths (starting with `.`) are resolved against `start_dir`.
75/// - tsconfig.json `paths` aliases are applied.
76/// - pnpm workspace packages are resolved via package.json.
77/// - Non-relative bare specifiers that don't match any alias/workspace return None.
78pub 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    // Simple normalization: collapse . and ..
97    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
111/// Read tsconfig.json compilerOptions.paths aliases from a directory hierarchy.
112///
113/// Walks up from `start_dir` until a `tsconfig.json` is found.
114/// Handles simple `extends` chains (one level deep) and JSONC comments.
115pub 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    // Handle `extends` — may be a string or an array of strings
151    if let Some(extends_field) = data.get("extends") {
152        // Collect all extends paths into a list
153        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
193/// Strip JSONC comments and trailing commas so serde_json can parse it.
194pub 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: &regex::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    // Remove trailing commas before } or ]
207    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    // Check exports field
316    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
347// Internal glob helper on PathBuf so we don't need an extra trait
348trait 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    // --- resolve_js_import_path: direct file ---
383
384    #[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    // --- .js → .ts rewrite ---
398
399    #[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    // --- extension appending ---
416
417    #[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    // --- directory index fallback ---
458
459    #[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    // Extension appending takes priority over directory index
476    #[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    // --- resolve_js_module_path: relative path ---
486
487    #[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    // --- tsconfig alias resolution ---
504
505    #[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    // --- strip_jsonc ---
534
535    #[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    // --- load_tsconfig_aliases ---
570
571    #[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    // --- workspace_globs ---
599
600    #[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}