Skip to main content

perl_module/resolution/
uri.rs

1//! Deterministic Perl module URI resolution helpers.
2//!
3//! Extracts the URI-first, timeout-bounded resolution policy.
4
5use crate::path::module_name_to_path;
6use perl_parser_core::path_security::validate_workspace_path;
7use perl_workspace::folder::workspace_folder_to_path;
8use std::collections::HashSet;
9use std::path::{Component, Path, PathBuf};
10use std::time::{Duration, Instant};
11use url::Url;
12
13/// Source/category of an effective include root.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IncRootKind {
16    /// File-local lexical include roots (for example `use lib` overlays).
17    FileLocalLexical,
18    /// Workspace-relative include roots, resolved against each owning workspace.
19    WorkspaceRelative,
20    /// External absolute include roots.
21    ExternalAbsolute,
22    /// Paths sourced from the `PERL5LIB` environment variable.
23    ///
24    /// Treated like `ExternalAbsolute` for resolution (no workspace-boundary
25    /// validation) but carries a distinct source label so diagnostics and
26    /// tooling can tell environment-supplied roots apart from project-configured ones.
27    Perl5LibEnv,
28    /// Startup `@INC` entries from the selected Perl interpreter.
29    InterpreterStartup,
30    /// Runtime-derived include roots (reserved for future trusted runtime mode).
31    RuntimeDerived,
32}
33
34/// A single ordered include root entry used to resolve modules.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct IncRoot {
37    /// Root kind/category.
38    pub kind: IncRootKind,
39    /// Path value for this root.
40    pub path: PathBuf,
41    /// Search precedence: lower values are searched first.
42    pub precedence: usize,
43    /// Human-readable source label.
44    pub source: String,
45}
46
47/// Outcome of a module name to URI resolution attempt.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum ModuleUriResolution {
50    /// A matching module URI was found.
51    Resolved(String),
52    /// No matching module was found.
53    NotFound,
54    /// Resolution stopped because the timeout budget was exhausted.
55    TimedOut,
56}
57
58/// Resolve a module name to a `file://` URI using deterministic precedence.
59///
60/// Search order:
61/// 1. Open document URIs (`ends_with` match on relative module path)
62/// 2. Workspace folders + `include_paths` (path-safe filesystem checks)
63/// 3. System `@INC` paths (when `use_system_inc` is true)
64#[must_use]
65pub fn resolve_module_uri(
66    module_name: &str,
67    open_document_uris: &[String],
68    workspace_folders: &[String],
69    include_paths: &[String],
70    use_system_inc: bool,
71    system_inc: &[PathBuf],
72    timeout: Duration,
73) -> ModuleUriResolution {
74    let mut effective_inc_roots = Vec::new();
75    let mut seen_include_paths = HashSet::new();
76
77    for include_path in include_paths {
78        let Some(path) = normalize_inc_path_string(include_path) else {
79            continue;
80        };
81        if !seen_include_paths.insert(path.clone()) {
82            continue;
83        }
84
85        let kind = if path.is_absolute() {
86            IncRootKind::ExternalAbsolute
87        } else {
88            IncRootKind::WorkspaceRelative
89        };
90        effective_inc_roots.push(IncRoot {
91            kind,
92            path,
93            precedence: effective_inc_roots.len(),
94            source: "includePaths".to_string(),
95        });
96    }
97
98    if use_system_inc {
99        let mut seen_system_paths = HashSet::new();
100
101        for path in system_inc {
102            let Some(path) = normalize_system_inc_path(path) else {
103                continue;
104            };
105            if !seen_system_paths.insert(path.clone()) {
106                continue;
107            }
108
109            effective_inc_roots.push(IncRoot {
110                kind: IncRootKind::InterpreterStartup,
111                path,
112                precedence: effective_inc_roots.len(),
113                source: "interpreter-startup-inc".to_string(),
114            });
115        }
116    }
117
118    resolve_module_uri_with_effective_inc(
119        module_name,
120        open_document_uris,
121        workspace_folders,
122        &effective_inc_roots,
123        timeout,
124    )
125}
126
127/// Resolve a module name to a `file://` URI using an ordered effective `@INC` model.
128#[must_use]
129pub fn resolve_module_uri_with_effective_inc(
130    module_name: &str,
131    open_document_uris: &[String],
132    workspace_folders: &[String],
133    effective_inc_roots: &[IncRoot],
134    timeout: Duration,
135) -> ModuleUriResolution {
136    let start_time = Instant::now();
137    let relative_path = module_name_to_path(module_name);
138
139    for uri in open_document_uris {
140        if uri.ends_with(&relative_path) {
141            return ModuleUriResolution::Resolved(uri.clone());
142        }
143    }
144
145    let mut ordered_roots = effective_inc_roots.to_vec();
146    ordered_roots.sort_by_key(|r| r.precedence);
147
148    for workspace_folder in workspace_folders {
149        if start_time.elapsed() > timeout {
150            return ModuleUriResolution::TimedOut;
151        }
152
153        let workspace_path = workspace_folder_to_path(workspace_folder);
154
155        for inc_root in &ordered_roots {
156            if !matches!(
157                inc_root.kind,
158                IncRootKind::FileLocalLexical | IncRootKind::WorkspaceRelative
159            ) {
160                continue;
161            }
162            if start_time.elapsed() > timeout {
163                return ModuleUriResolution::TimedOut;
164            }
165
166            let full_path = full_path_for_root(inc_root, &workspace_path, &relative_path);
167            let Some(full_path) = full_path else { continue };
168
169            if full_path.is_file()
170                && let Ok(url) = Url::from_file_path(&full_path)
171            {
172                return ModuleUriResolution::Resolved(url.to_string());
173            }
174        }
175    }
176
177    for inc_root in &ordered_roots {
178        if !matches!(
179            inc_root.kind,
180            IncRootKind::ExternalAbsolute
181                | IncRootKind::Perl5LibEnv
182                | IncRootKind::InterpreterStartup
183                | IncRootKind::RuntimeDerived
184        ) {
185            continue;
186        }
187        if start_time.elapsed() > timeout {
188            return ModuleUriResolution::TimedOut;
189        }
190
191        let full_path = inc_root.path.join(&relative_path);
192        if full_path.is_file()
193            && let Ok(url) = Url::from_file_path(&full_path)
194        {
195            return ModuleUriResolution::Resolved(url.to_string());
196        }
197    }
198
199    ModuleUriResolution::NotFound
200}
201
202fn normalize_inc_path_string(input: &str) -> Option<PathBuf> {
203    let trimmed = input.trim();
204    if trimmed.is_empty() {
205        return None;
206    }
207
208    Some(normalize_path_for_dedupe(Path::new(trimmed)))
209}
210
211fn normalize_system_inc_path(input: &Path) -> Option<PathBuf> {
212    let trimmed = input.to_string_lossy().trim().to_string();
213    if trimmed.is_empty() {
214        return None;
215    }
216
217    let normalized = normalize_path_for_dedupe(Path::new(&trimmed));
218    if normalized == Path::new(".") {
219        return None;
220    }
221
222    Some(normalized)
223}
224
225fn normalize_path_for_dedupe(path: &Path) -> PathBuf {
226    let mut normalized = PathBuf::new();
227    for component in path.components() {
228        if component == Component::CurDir {
229            continue;
230        }
231        normalized.push(component.as_os_str());
232    }
233
234    if normalized.as_os_str().is_empty() { PathBuf::from(".") } else { normalized }
235}
236
237fn full_path_for_root(
238    inc_root: &IncRoot,
239    workspace_path: &Path,
240    relative_path: &str,
241) -> Option<PathBuf> {
242    match inc_root.kind {
243        IncRootKind::FileLocalLexical | IncRootKind::WorkspaceRelative => {
244            if inc_root.path == Path::new(".") {
245                let full_path = workspace_path.join(relative_path);
246                validate_workspace_path(&full_path, workspace_path).ok()
247            } else if inc_root.path.is_absolute() {
248                Some(inc_root.path.join(relative_path))
249            } else {
250                let full_path = workspace_path.join(&inc_root.path).join(relative_path);
251                validate_workspace_path(&full_path, workspace_path).ok()
252            }
253        }
254        IncRootKind::ExternalAbsolute
255        | IncRootKind::Perl5LibEnv
256        | IncRootKind::InterpreterStartup
257        | IncRootKind::RuntimeDerived => Some(inc_root.path.join(relative_path)),
258    }
259}