Skip to main content

normalize_languages/
ecmascript.rs

1//! Shared ECMAScript (JavaScript/TypeScript) support functions.
2//!
3//! This module contains common logic shared between JavaScript, TypeScript, and TSX.
4//! Each language struct delegates to these functions for DRY implementation.
5
6use crate::external_packages::ResolvedPackage;
7use crate::{Export, Import, Symbol, SymbolKind, Visibility};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use tree_sitter::Node;
11
12// ============================================================================
13// Node kind constants
14// ============================================================================
15
16pub const JS_CONTAINER_KINDS: &[&str] = &["class_declaration", "class"];
17pub const TS_CONTAINER_KINDS: &[&str] = &["class_declaration", "class", "interface_declaration"];
18
19pub const JS_FUNCTION_KINDS: &[&str] = &[
20    "function_declaration",
21    "method_definition",
22    "generator_function_declaration",
23];
24pub const TS_FUNCTION_KINDS: &[&str] = &[
25    "function_declaration",
26    "method_definition",
27    "method_signature", // Interface methods
28];
29
30pub const JS_TYPE_KINDS: &[&str] = &["class_declaration"];
31pub const TS_TYPE_KINDS: &[&str] = &[
32    "class_declaration",
33    "interface_declaration",
34    "type_alias_declaration",
35    "enum_declaration",
36];
37
38pub const IMPORT_KINDS: &[&str] = &["import_statement"];
39pub const PUBLIC_SYMBOL_KINDS: &[&str] = &["export_statement"];
40
41pub const SCOPE_CREATING_KINDS: &[&str] = &[
42    "for_statement",
43    "for_in_statement",
44    "while_statement",
45    "do_statement",
46    "try_statement",
47    "catch_clause",
48    "switch_statement",
49    "arrow_function",
50];
51
52pub const CONTROL_FLOW_KINDS: &[&str] = &[
53    "if_statement",
54    "for_statement",
55    "for_in_statement",
56    "while_statement",
57    "do_statement",
58    "switch_statement",
59    "try_statement",
60    "return_statement",
61    "break_statement",
62    "continue_statement",
63    "throw_statement",
64];
65
66pub const COMPLEXITY_NODES: &[&str] = &[
67    "if_statement",
68    "for_statement",
69    "for_in_statement",
70    "while_statement",
71    "do_statement",
72    "switch_case",
73    "catch_clause",
74    "ternary_expression",
75    "binary_expression",
76];
77
78pub const NESTING_NODES: &[&str] = &[
79    "if_statement",
80    "for_statement",
81    "for_in_statement",
82    "while_statement",
83    "do_statement",
84    "switch_statement",
85    "try_statement",
86    "function_declaration",
87    "method_definition",
88    "class_declaration",
89];
90
91// ============================================================================
92// Symbol extraction
93// ============================================================================
94
95/// Extract a function/method symbol from a node.
96pub fn extract_function(node: &Node, content: &str, in_container: bool, name: &str) -> Symbol {
97    let params = node
98        .child_by_field_name("parameters")
99        .map(|p| content[p.byte_range()].to_string())
100        .unwrap_or_else(|| "()".to_string());
101
102    let signature = if node.kind() == "method_definition" {
103        format!("{}{}", name, params)
104    } else {
105        format!("function {}{}", name, params)
106    };
107
108    // Check for explicit override modifier (TypeScript)
109    let is_override = {
110        let mut cursor = node.walk();
111        let children: Vec<_> = node.children(&mut cursor).collect();
112        children
113            .iter()
114            .any(|child| child.kind() == "override_modifier")
115    };
116
117    Symbol {
118        name: name.to_string(),
119        kind: if in_container {
120            SymbolKind::Method
121        } else {
122            SymbolKind::Function
123        },
124        signature,
125        docstring: None,
126        attributes: Vec::new(),
127        start_line: node.start_position().row + 1,
128        end_line: node.end_position().row + 1,
129        visibility: Visibility::Public,
130        children: Vec::new(),
131        is_interface_impl: is_override,
132        implements: Vec::new(),
133    }
134}
135
136/// Extract a class or interface container symbol from a node.
137pub fn extract_container(node: &Node, content: &str, name: &str) -> Symbol {
138    let (kind, keyword) = if node.kind() == "interface_declaration" {
139        (SymbolKind::Interface, "interface")
140    } else {
141        (SymbolKind::Class, "class")
142    };
143
144    // Extract implements/extends clauses for semantic interface detection
145    let mut implements = Vec::new();
146    // Find class_heritage child node (not a field)
147    for i in 0..node.child_count() as u32 {
148        if let Some(heritage) = node.child(i) {
149            if heritage.kind() == "class_heritage" {
150                // heritage can contain extends_clause and/or implements_clause
151                for j in 0..heritage.child_count() as u32 {
152                    if let Some(clause) = heritage.child(j) {
153                        if clause.kind() == "extends_clause" || clause.kind() == "implements_clause"
154                        {
155                            // Each clause contains type identifiers
156                            for k in 0..clause.child_count() as u32 {
157                                if let Some(type_node) = clause.child(k) {
158                                    if type_node.kind() == "type_identifier"
159                                        || type_node.kind() == "identifier"
160                                    {
161                                        implements
162                                            .push(content[type_node.byte_range()].to_string());
163                                    }
164                                }
165                            }
166                        }
167                    }
168                }
169            }
170        }
171    }
172
173    Symbol {
174        name: name.to_string(),
175        kind,
176        signature: format!("{} {}", keyword, name),
177        docstring: None,
178        attributes: Vec::new(),
179        start_line: node.start_position().row + 1,
180        end_line: node.end_position().row + 1,
181        visibility: Visibility::Public,
182        children: Vec::new(),
183        is_interface_impl: false,
184        implements,
185    }
186}
187
188/// Extract a TypeScript type symbol (interface, type alias, enum).
189pub fn extract_type(node: &Node, name: &str) -> Option<Symbol> {
190    let (kind, keyword) = match node.kind() {
191        "interface_declaration" => (SymbolKind::Interface, "interface"),
192        "type_alias_declaration" => (SymbolKind::Type, "type"),
193        "enum_declaration" => (SymbolKind::Enum, "enum"),
194        "class_declaration" => (SymbolKind::Class, "class"),
195        _ => return None,
196    };
197
198    Some(Symbol {
199        name: name.to_string(),
200        kind,
201        signature: format!("{} {}", keyword, name),
202        docstring: None,
203        attributes: Vec::new(),
204        start_line: node.start_position().row + 1,
205        end_line: node.end_position().row + 1,
206        visibility: Visibility::Public,
207        children: Vec::new(),
208        is_interface_impl: false,
209        implements: Vec::new(),
210    })
211}
212
213// ============================================================================
214// Import/Export extraction
215// ============================================================================
216
217/// Extract imports from an import_statement node.
218pub fn extract_imports(node: &Node, content: &str) -> Vec<Import> {
219    if node.kind() != "import_statement" {
220        return Vec::new();
221    }
222
223    let line = node.start_position().row + 1;
224    let mut module = String::new();
225    let mut names = Vec::new();
226
227    let mut cursor = node.walk();
228    for child in node.children(&mut cursor) {
229        match child.kind() {
230            "string" | "string_fragment" => {
231                let text = &content[child.byte_range()];
232                module = text.trim_matches(|c| c == '"' || c == '\'').to_string();
233            }
234            "import_clause" => {
235                collect_import_names(&child, content, &mut names);
236            }
237            _ => {}
238        }
239    }
240
241    if module.is_empty() {
242        return Vec::new();
243    }
244
245    vec![Import {
246        module: module.clone(),
247        names,
248        alias: None,
249        is_wildcard: false,
250        is_relative: module.starts_with('.'),
251        line,
252    }]
253}
254
255/// Format an import as JavaScript/TypeScript source code.
256pub fn format_import(import: &Import, names: Option<&[&str]>) -> String {
257    let names_to_use: Vec<&str> = names
258        .map(|n| n.to_vec())
259        .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
260
261    if import.is_wildcard {
262        format!("import * from '{}';", import.module)
263    } else if names_to_use.is_empty() {
264        format!("import '{}';", import.module)
265    } else if names_to_use.len() == 1 {
266        format!("import {{ {} }} from '{}';", names_to_use[0], import.module)
267    } else {
268        format!(
269            "import {{ {} }} from '{}';",
270            names_to_use.join(", "),
271            import.module
272        )
273    }
274}
275
276fn collect_import_names(import_clause: &Node, content: &str, names: &mut Vec<String>) {
277    let mut cursor = import_clause.walk();
278    for child in import_clause.children(&mut cursor) {
279        match child.kind() {
280            "identifier" => {
281                // Default import: import foo from './module'
282                names.push(content[child.byte_range()].to_string());
283            }
284            "named_imports" => {
285                // { foo, bar }
286                let mut inner_cursor = child.walk();
287                for inner in child.children(&mut inner_cursor) {
288                    if inner.kind() == "import_specifier" {
289                        if let Some(name_node) = inner.child_by_field_name("name") {
290                            names.push(content[name_node.byte_range()].to_string());
291                        }
292                    }
293                }
294            }
295            "namespace_import" => {
296                // import * as foo
297                if let Some(name_node) = child.child_by_field_name("name") {
298                    names.push(format!("* as {}", &content[name_node.byte_range()]));
299                }
300            }
301            _ => {}
302        }
303    }
304}
305
306/// Extract exports from an export_statement node.
307pub fn extract_public_symbols(node: &Node, content: &str) -> Vec<Export> {
308    if node.kind() != "export_statement" {
309        return Vec::new();
310    }
311
312    let line = node.start_position().row + 1;
313    let mut exports = Vec::new();
314
315    let mut cursor = node.walk();
316    for child in node.children(&mut cursor) {
317        match child.kind() {
318            "function_declaration" | "generator_function_declaration" => {
319                if let Some(name_node) = child.child_by_field_name("name") {
320                    exports.push(Export {
321                        name: content[name_node.byte_range()].to_string(),
322                        kind: SymbolKind::Function,
323                        line,
324                    });
325                }
326            }
327            "class_declaration" => {
328                if let Some(name_node) = child.child_by_field_name("name") {
329                    exports.push(Export {
330                        name: content[name_node.byte_range()].to_string(),
331                        kind: SymbolKind::Class,
332                        line,
333                    });
334                }
335            }
336            "lexical_declaration" => {
337                // export const foo = ...
338                let mut decl_cursor = child.walk();
339                for decl_child in child.children(&mut decl_cursor) {
340                    if decl_child.kind() == "variable_declarator" {
341                        if let Some(name_node) = decl_child.child_by_field_name("name") {
342                            exports.push(Export {
343                                name: content[name_node.byte_range()].to_string(),
344                                kind: SymbolKind::Variable,
345                                line,
346                            });
347                        }
348                    }
349                }
350            }
351            _ => {}
352        }
353    }
354
355    exports
356}
357
358// ============================================================================
359// Import resolution
360// ============================================================================
361
362/// Resolve a relative import to a local file path.
363pub fn resolve_local_import(
364    module: &str,
365    current_file: &Path,
366    extensions: &[&str],
367) -> Option<PathBuf> {
368    // Only handle relative imports
369    if !module.starts_with('.') {
370        return None;
371    }
372
373    let current_dir = current_file.parent()?;
374
375    // Normalize the path
376    let target = if module.starts_with("./") {
377        current_dir.join(&module[2..])
378    } else if module.starts_with("../") {
379        current_dir.join(module)
380    } else {
381        return None;
382    };
383
384    // First try exact path (might already have extension)
385    if target.exists() && target.is_file() {
386        return Some(target);
387    }
388
389    // Try adding extensions
390    for ext in extensions {
391        let with_ext = target.with_extension(ext);
392        if with_ext.exists() && with_ext.is_file() {
393            return Some(with_ext);
394        }
395    }
396
397    // Try index files in directory
398    if target.is_dir() {
399        for ext in extensions {
400            let index = target.join(format!("index.{}", ext));
401            if index.exists() && index.is_file() {
402                return Some(index);
403            }
404        }
405    }
406
407    None
408}
409
410// ============================================================================
411// Node.js external package resolution
412// ============================================================================
413
414/// Find node_modules directory by walking up from a file.
415pub fn find_node_modules(start: &Path) -> Option<PathBuf> {
416    let mut current = if start.is_file() {
417        start.parent()?.to_path_buf()
418    } else {
419        start.to_path_buf()
420    };
421
422    loop {
423        let node_modules = current.join("node_modules");
424        if node_modules.is_dir() {
425            return Some(node_modules);
426        }
427
428        if !current.pop() {
429            break;
430        }
431    }
432
433    None
434}
435
436/// Get Node.js version (for index versioning).
437pub fn get_node_version() -> Option<String> {
438    let output = Command::new("node").args(["--version"]).output().ok()?;
439
440    if output.status.success() {
441        let version_str = String::from_utf8_lossy(&output.stdout);
442        // "v20.10.0" -> "20.10"
443        let ver = version_str.trim().trim_start_matches('v');
444        let parts: Vec<&str> = ver.split('.').collect();
445        if parts.len() >= 2 {
446            return Some(format!("{}.{}", parts[0], parts[1]));
447        }
448    }
449
450    None
451}
452
453/// Resolve a bare import (non-relative) to node_modules.
454///
455/// Handles:
456/// - `lodash` -> `node_modules/lodash`
457/// - `@scope/pkg` -> `node_modules/@scope/pkg`
458/// - `lodash/fp` -> `node_modules/lodash/fp`
459fn resolve_node_import(import_path: &str, node_modules: &Path) -> Option<ResolvedPackage> {
460    // Parse package name (handle scoped packages)
461    let parsed = parse_node_package_name(import_path);
462
463    let pkg_dir = node_modules.join(&parsed.name);
464    if !pkg_dir.is_dir() {
465        return None;
466    }
467
468    // If there's a subpath, resolve it directly
469    if let Some(subpath) = parsed.subpath {
470        let target = pkg_dir.join(subpath);
471        if let Some(resolved) = resolve_node_file_or_dir(&target) {
472            return Some(ResolvedPackage {
473                path: resolved,
474                name: import_path.to_string(),
475                is_namespace: false,
476            });
477        }
478        return None;
479    }
480
481    // No subpath - use package.json to find entry point
482    let pkg_json = pkg_dir.join("package.json");
483    if pkg_json.is_file() {
484        if let Some(entry) = get_package_entry_point(&pkg_dir, &pkg_json) {
485            return Some(ResolvedPackage {
486                path: entry,
487                name: import_path.to_string(),
488                is_namespace: false,
489            });
490        }
491    }
492
493    // Fall back to index.js
494    if let Some(resolved) = resolve_node_file_or_dir(&pkg_dir) {
495        return Some(ResolvedPackage {
496            path: resolved,
497            name: import_path.to_string(),
498            is_namespace: false,
499        });
500    }
501
502    None
503}
504
505/// Parsed node package reference
506struct ParsedPackage<'a> {
507    name: String,
508    subpath: Option<&'a str>,
509}
510
511/// Parse a package name into name and optional subpath
512fn parse_node_package_name(import_path: &str) -> ParsedPackage<'_> {
513    if import_path.starts_with('@') {
514        // Scoped package: @scope/name or @scope/name/subpath
515        let parts: Vec<&str> = import_path.splitn(3, '/').collect();
516        if parts.len() >= 2 {
517            let name = format!("{}/{}", parts[0], parts[1]);
518            let subpath = if parts.len() > 2 {
519                Some(parts[2])
520            } else {
521                None
522            };
523            return ParsedPackage { name, subpath };
524        }
525        ParsedPackage {
526            name: import_path.to_string(),
527            subpath: None,
528        }
529    } else {
530        // Regular package: name or name/subpath
531        if let Some(idx) = import_path.find('/') {
532            let name = import_path[..idx].to_string();
533            let subpath = Some(&import_path[idx + 1..]);
534            ParsedPackage { name, subpath }
535        } else {
536            ParsedPackage {
537                name: import_path.to_string(),
538                subpath: None,
539            }
540        }
541    }
542}
543
544/// Get the entry point from package.json.
545fn get_package_entry_point(pkg_dir: &Path, pkg_json: &Path) -> Option<PathBuf> {
546    let content = std::fs::read_to_string(pkg_json).ok()?;
547    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
548
549    // Try "exports" field (simplified - just handle string or { ".": ... })
550    if let Some(exports) = json.get("exports")
551        && let Some(entry) = exports.as_str()
552    {
553        let path = pkg_dir.join(entry.trim_start_matches("./"));
554        if path.is_file() {
555            return Some(path);
556        }
557    } else if let Some(exports) = json.get("exports")
558        && let Some(obj) = exports.as_object()
559        && let Some(dot) = obj.get(".")
560        && let Some(entry) = extract_export_entry(dot)
561    {
562        let path = pkg_dir.join(entry.trim_start_matches("./"));
563        if path.is_file() {
564            return Some(path);
565        }
566    }
567
568    // Try "module" field (ESM entry point)
569    if let Some(module) = json.get("module").and_then(|v| v.as_str()) {
570        let path = pkg_dir.join(module.trim_start_matches("./"));
571        if path.is_file() {
572            return Some(path);
573        }
574    }
575
576    // Try "main" field
577    if let Some(main) = json.get("main").and_then(|v| v.as_str()) {
578        let path = pkg_dir.join(main.trim_start_matches("./"));
579        if let Some(resolved) = resolve_node_file_or_dir(&path) {
580            return Some(resolved);
581        }
582    }
583
584    None
585}
586
587/// Extract entry point from an exports value.
588fn extract_export_entry(value: &serde_json::Value) -> Option<&str> {
589    if let Some(s) = value.as_str() {
590        return Some(s);
591    }
592    if let Some(obj) = value.as_object() {
593        // Prefer: import > require > default
594        for key in &["import", "require", "default"] {
595            if let Some(entry) = obj.get(*key) {
596                if let Some(s) = entry.as_str() {
597                    return Some(s);
598                }
599                // Recursive for nested conditions
600                if let Some(s) = extract_export_entry(entry) {
601                    return Some(s);
602                }
603            }
604        }
605    }
606    None
607}
608
609/// Resolve a path to a file, trying extensions and index files.
610fn resolve_node_file_or_dir(target: &Path) -> Option<PathBuf> {
611    let extensions = ["js", "mjs", "cjs", "ts", "tsx", "jsx"];
612
613    // Exact file
614    if target.is_file() {
615        return Some(target.to_path_buf());
616    }
617
618    // Try with extensions
619    for ext in &extensions {
620        let with_ext = target.with_extension(ext);
621        if with_ext.is_file() {
622            return Some(with_ext);
623        }
624    }
625
626    // Try as directory with index
627    if target.is_dir() {
628        for ext in &extensions {
629            let index = target.join(format!("index.{}", ext));
630            if index.is_file() {
631                return Some(index);
632            }
633        }
634    }
635
636    None
637}
638
639/// Resolve an external (node_modules) import.
640pub fn resolve_external_import(import_name: &str, project_root: &Path) -> Option<ResolvedPackage> {
641    if import_name.starts_with('.') || import_name.starts_with('/') {
642        return None;
643    }
644
645    let node_modules = find_node_modules(project_root)?;
646    resolve_node_import(import_name, &node_modules)
647}
648
649/// Get the Node.js version.
650pub fn get_version() -> Option<String> {
651    get_node_version()
652}
653
654/// Find the node_modules directory.
655pub fn find_package_cache(project_root: &Path) -> Option<PathBuf> {
656    find_node_modules(project_root)
657}
658
659// Extension preferences for each language variant
660pub const JS_EXTENSIONS: &[&str] = &["js", "jsx", "mjs", "cjs"];
661pub const TS_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mts", "mjs"];
662
663// ============================================================================
664// Deno external package resolution
665// ============================================================================
666
667/// Get Deno version.
668pub fn get_deno_version() -> Option<String> {
669    let output = Command::new("deno").args(["--version"]).output().ok()?;
670
671    if output.status.success() {
672        let version_str = String::from_utf8_lossy(&output.stdout);
673        for line in version_str.lines() {
674            if line.starts_with("deno ") {
675                let version_part = line.strip_prefix("deno ")?;
676                let parts: Vec<&str> = version_part.split('.').collect();
677                if parts.len() >= 2 {
678                    let major = parts[0].trim();
679                    let minor = parts[1]
680                        .chars()
681                        .take_while(|c| c.is_ascii_digit())
682                        .collect::<String>();
683                    return Some(format!("{}.{}", major, minor));
684                }
685            }
686        }
687    }
688
689    None
690}
691
692/// Find Deno cache directory.
693pub fn find_deno_cache() -> Option<PathBuf> {
694    if let Ok(deno_dir) = std::env::var("DENO_DIR") {
695        let cache = PathBuf::from(deno_dir);
696        if cache.is_dir() {
697            return Some(cache);
698        }
699    }
700
701    #[cfg(target_os = "macos")]
702    {
703        if let Ok(home) = std::env::var("HOME") {
704            let cache = PathBuf::from(home).join("Library/Caches/deno");
705            if cache.is_dir() {
706                return Some(cache);
707            }
708        }
709    }
710
711    #[cfg(target_os = "linux")]
712    {
713        if let Ok(xdg_cache) = std::env::var("XDG_CACHE_HOME") {
714            let cache = PathBuf::from(xdg_cache).join("deno");
715            if cache.is_dir() {
716                return Some(cache);
717            }
718        }
719        if let Ok(home) = std::env::var("HOME") {
720            let cache = PathBuf::from(home).join(".cache/deno");
721            if cache.is_dir() {
722                return Some(cache);
723            }
724        }
725    }
726
727    #[cfg(target_os = "windows")]
728    {
729        if let Ok(local_app_data) = std::env::var("LOCALAPPDATA") {
730            let cache = PathBuf::from(local_app_data).join("deno");
731            if cache.is_dir() {
732                return Some(cache);
733            }
734        }
735    }
736
737    if let Ok(home) = std::env::var("HOME") {
738        for path in &[".cache/deno", "Library/Caches/deno"] {
739            let cache = PathBuf::from(&home).join(path);
740            if cache.is_dir() {
741                return Some(cache);
742            }
743        }
744    }
745
746    None
747}
748
749/// Resolve a Deno URL import to its cached location.
750pub fn resolve_deno_import(import_url: &str, cache: &Path) -> Option<ResolvedPackage> {
751    if let Some(npm_spec) = import_url.strip_prefix("npm:") {
752        return resolve_deno_npm_import(npm_spec, cache);
753    }
754
755    if import_url.starts_with("https://") || import_url.starts_with("http://") {
756        return resolve_deno_url_import(import_url, cache);
757    }
758
759    None
760}
761
762fn resolve_deno_npm_import(npm_spec: &str, cache: &Path) -> Option<ResolvedPackage> {
763    let npm_cache = cache.join("npm").join("registry.npmjs.org");
764    if !npm_cache.is_dir() {
765        return None;
766    }
767
768    let (pkg_name, version_spec) = if npm_spec.starts_with('@') {
769        let parts: Vec<&str> = npm_spec.splitn(3, '/').collect();
770        if parts.len() < 2 {
771            return None;
772        }
773        let scope = parts[0];
774        let name_ver = parts[1];
775        let (name, ver) = if let Some(idx) = name_ver.rfind('@') {
776            (&name_ver[..idx], Some(&name_ver[idx + 1..]))
777        } else {
778            (name_ver, None)
779        };
780        (format!("{}/{}", scope, name), ver)
781    } else if let Some(idx) = npm_spec.rfind('@') {
782        (npm_spec[..idx].to_string(), Some(&npm_spec[idx + 1..]))
783    } else {
784        (npm_spec.to_string(), None)
785    };
786
787    let pkg_path = if pkg_name.starts_with('@') {
788        let parts: Vec<&str> = pkg_name.splitn(2, '/').collect();
789        npm_cache.join(parts[0]).join(parts[1])
790    } else {
791        npm_cache.join(&pkg_name)
792    };
793
794    if !pkg_path.is_dir() {
795        return None;
796    }
797
798    let version_dir = find_best_version_dir(&pkg_path, version_spec)?;
799    let entry = find_package_entry(&version_dir)?;
800
801    Some(ResolvedPackage {
802        path: entry,
803        name: pkg_name,
804        is_namespace: false,
805    })
806}
807
808fn resolve_deno_url_import(url: &str, cache: &Path) -> Option<ResolvedPackage> {
809    let deps_dir = cache.join("deps");
810    let url_parsed = url
811        .strip_prefix("https://")
812        .or_else(|| url.strip_prefix("http://"))?;
813    let scheme = if url.starts_with("https://") {
814        "https"
815    } else {
816        "http"
817    };
818
819    let scheme_dir = deps_dir.join(scheme);
820    if !scheme_dir.is_dir() {
821        return None;
822    }
823
824    let (host, path) = url_parsed.split_once('/')?;
825    let host_dir = scheme_dir.join(host);
826    if !host_dir.is_dir() {
827        return None;
828    }
829
830    if let Ok(entries) = std::fs::read_dir(&host_dir) {
831        for entry in entries.flatten() {
832            let entry_path = entry.path();
833            let name = entry.file_name().to_string_lossy().to_string();
834
835            if name.ends_with(".metadata.json") {
836                continue;
837            }
838
839            let meta_path = host_dir.join(format!("{}.metadata.json", name));
840            if meta_path.is_file() {
841                if let Ok(meta_content) = std::fs::read_to_string(&meta_path) {
842                    if meta_content.contains(url) {
843                        return Some(ResolvedPackage {
844                            path: entry_path,
845                            name: format!("{}/{}", host, path),
846                            is_namespace: false,
847                        });
848                    }
849                }
850            }
851        }
852    }
853
854    None
855}
856
857fn find_best_version_dir(pkg_path: &Path, version_spec: Option<&str>) -> Option<PathBuf> {
858    let entries: Vec<_> = std::fs::read_dir(pkg_path).ok()?.flatten().collect();
859
860    if let Some(spec) = version_spec {
861        let exact = pkg_path.join(spec);
862        if exact.is_dir() {
863            return Some(exact);
864        }
865
866        for entry in &entries {
867            let name = entry.file_name().to_string_lossy().to_string();
868            if name.starts_with(spec) && entry.path().is_dir() {
869                return Some(entry.path());
870            }
871        }
872    }
873
874    let mut versions: Vec<_> = entries.into_iter().filter(|e| e.path().is_dir()).collect();
875    versions.sort_by(|a, b| {
876        let a_name = a.file_name().to_string_lossy().to_string();
877        let b_name = b.file_name().to_string_lossy().to_string();
878        deno_version_cmp(&a_name, &b_name)
879    });
880    versions.last().map(|e| e.path())
881}
882
883fn deno_version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
884    let a_parts: Vec<u32> = a.split('.').filter_map(|p| p.parse().ok()).collect();
885    let b_parts: Vec<u32> = b.split('.').filter_map(|p| p.parse().ok()).collect();
886
887    for (ap, bp) in a_parts.iter().zip(b_parts.iter()) {
888        match ap.cmp(bp) {
889            std::cmp::Ordering::Equal => continue,
890            other => return other,
891        }
892    }
893    a_parts.len().cmp(&b_parts.len())
894}
895
896/// Find entry point for a JavaScript/TypeScript package.
897/// Checks package.json for module/main fields, falls back to index.{js,mjs,cjs,ts}.
898pub fn find_package_entry(dir: &Path) -> Option<PathBuf> {
899    let pkg_json = dir.join("package.json");
900    if pkg_json.is_file()
901        && let Ok(content) = std::fs::read_to_string(&pkg_json)
902        && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
903    {
904        for field in &["module", "main"] {
905            if let Some(entry) = json.get(field).and_then(|v| v.as_str()) {
906                let path = dir.join(entry.trim_start_matches("./"));
907                if path.is_file() {
908                    return Some(path);
909                }
910                let with_ext = path.with_extension("js");
911                if with_ext.is_file() {
912                    return Some(with_ext);
913                }
914            }
915        }
916    }
917
918    for ext in &["js", "mjs", "cjs", "ts"] {
919        let index = dir.join(format!("index.{}", ext));
920        if index.is_file() {
921            return Some(index);
922        }
923    }
924
925    None
926}