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/// Build ordered effective include roots from lexical, configured, environment,
48/// and interpreter startup sources.
49///
50/// This centralizes the source-labeling and precedence model used by URI
51/// resolution. Callers are still responsible for computing configured include
52/// paths and deciding whether `PERL5LIB` / system `@INC` should participate.
53#[must_use]
54pub fn build_effective_inc_roots(
55    include_paths: &[String],
56    perl5lib_paths: &[String],
57    use_perl5lib: bool,
58    lexical_paths: &[String],
59    system_paths: &[PathBuf],
60) -> Vec<IncRoot> {
61    let perl5lib_set: HashSet<String> =
62        if use_perl5lib { perl5lib_paths.iter().cloned().collect() } else { HashSet::new() };
63
64    let mut roots = Vec::new();
65    let mut seen = HashSet::new();
66    let mut precedence = 0usize;
67
68    for path in lexical_paths {
69        let path_buf = PathBuf::from(path);
70        let kind = if path_buf.is_absolute() {
71            IncRootKind::ExternalAbsolute
72        } else {
73            IncRootKind::FileLocalLexical
74        };
75        if !seen.insert(normalized_inc_key(&path_buf)) {
76            continue;
77        }
78        roots.push(IncRoot {
79            kind,
80            path: path_buf,
81            precedence,
82            source: "use-lib-lexical".to_string(),
83        });
84        precedence += 1;
85    }
86
87    for path in include_paths {
88        let path_buf = PathBuf::from(path);
89        if !seen.insert(normalized_inc_key(&path_buf)) {
90            continue;
91        }
92        let (kind, source) = if perl5lib_set.contains(path) {
93            (IncRootKind::Perl5LibEnv, "perl5lib-env")
94        } else if path_buf.is_absolute() {
95            (IncRootKind::ExternalAbsolute, "workspace-include-paths")
96        } else {
97            (IncRootKind::WorkspaceRelative, "workspace-include-paths")
98        };
99        roots.push(IncRoot { kind, path: path_buf, precedence, source: source.to_string() });
100        precedence += 1;
101    }
102
103    for path in system_paths {
104        if !seen.insert(normalized_inc_key(path)) {
105            continue;
106        }
107        roots.push(IncRoot {
108            kind: IncRootKind::InterpreterStartup,
109            path: path.clone(),
110            precedence,
111            source: "interpreter-startup-inc".to_string(),
112        });
113        precedence += 1;
114    }
115
116    roots
117}
118
119/// Outcome of a module name to URI resolution attempt.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ModuleUriResolution {
122    /// A matching module URI was found.
123    Resolved(String),
124    /// No matching module was found.
125    NotFound,
126    /// Resolution stopped because the timeout budget was exhausted.
127    TimedOut,
128}
129
130/// Resolve a module name to a `file://` URI using deterministic precedence.
131///
132/// Search order:
133/// 1. Open document URIs (`ends_with` match on relative module path)
134/// 2. Workspace folders + `include_paths` (path-safe filesystem checks)
135/// 3. System `@INC` paths (when `use_system_inc` is true)
136#[must_use]
137pub fn resolve_module_uri(
138    module_name: &str,
139    open_document_uris: &[String],
140    workspace_folders: &[String],
141    include_paths: &[String],
142    use_system_inc: bool,
143    system_inc: &[PathBuf],
144    timeout: Duration,
145) -> ModuleUriResolution {
146    let mut effective_inc_roots = Vec::new();
147    let mut seen_include_paths = HashSet::new();
148
149    for include_path in include_paths {
150        let Some(path) = normalize_inc_path_string(include_path) else {
151            continue;
152        };
153        if !seen_include_paths.insert(path.clone()) {
154            continue;
155        }
156
157        let kind = if path.is_absolute() {
158            IncRootKind::ExternalAbsolute
159        } else {
160            IncRootKind::WorkspaceRelative
161        };
162        effective_inc_roots.push(IncRoot {
163            kind,
164            path,
165            precedence: effective_inc_roots.len(),
166            source: "includePaths".to_string(),
167        });
168    }
169
170    if use_system_inc {
171        let mut seen_system_paths = HashSet::new();
172
173        for path in system_inc {
174            let Some(path) = normalize_system_inc_path(path) else {
175                continue;
176            };
177            if !seen_system_paths.insert(path.clone()) {
178                continue;
179            }
180
181            effective_inc_roots.push(IncRoot {
182                kind: IncRootKind::InterpreterStartup,
183                path,
184                precedence: effective_inc_roots.len(),
185                source: "interpreter-startup-inc".to_string(),
186            });
187        }
188    }
189
190    resolve_module_uri_with_effective_inc(
191        module_name,
192        open_document_uris,
193        workspace_folders,
194        &effective_inc_roots,
195        timeout,
196    )
197}
198
199/// Resolve a module name to a `file://` URI using an ordered effective `@INC` model.
200#[must_use]
201pub fn resolve_module_uri_with_effective_inc(
202    module_name: &str,
203    open_document_uris: &[String],
204    workspace_folders: &[String],
205    effective_inc_roots: &[IncRoot],
206    timeout: Duration,
207) -> ModuleUriResolution {
208    let start_time = Instant::now();
209    let relative_path = module_name_to_path(module_name);
210
211    for uri in open_document_uris {
212        if uri.ends_with(&relative_path) {
213            return ModuleUriResolution::Resolved(uri.clone());
214        }
215    }
216
217    let mut ordered_roots = effective_inc_roots.to_vec();
218    ordered_roots.sort_by_key(|r| r.precedence);
219
220    for workspace_folder in workspace_folders {
221        if start_time.elapsed() > timeout {
222            return ModuleUriResolution::TimedOut;
223        }
224
225        let workspace_path = workspace_folder_to_path(workspace_folder);
226
227        for inc_root in &ordered_roots {
228            if !matches!(
229                inc_root.kind,
230                IncRootKind::FileLocalLexical | IncRootKind::WorkspaceRelative
231            ) {
232                continue;
233            }
234            if start_time.elapsed() > timeout {
235                return ModuleUriResolution::TimedOut;
236            }
237
238            let full_path = full_path_for_root(inc_root, &workspace_path, &relative_path);
239            let Some(full_path) = full_path else { continue };
240
241            if full_path.is_file()
242                && let Ok(url) = Url::from_file_path(&full_path)
243            {
244                return ModuleUriResolution::Resolved(url.to_string());
245            }
246        }
247    }
248
249    for inc_root in &ordered_roots {
250        if !matches!(
251            inc_root.kind,
252            IncRootKind::ExternalAbsolute
253                | IncRootKind::Perl5LibEnv
254                | IncRootKind::InterpreterStartup
255                | IncRootKind::RuntimeDerived
256        ) {
257            continue;
258        }
259        if start_time.elapsed() > timeout {
260            return ModuleUriResolution::TimedOut;
261        }
262
263        let full_path = inc_root.path.join(&relative_path);
264        if full_path.is_file()
265            && let Ok(url) = Url::from_file_path(&full_path)
266        {
267            return ModuleUriResolution::Resolved(url.to_string());
268        }
269    }
270
271    ModuleUriResolution::NotFound
272}
273
274fn normalize_inc_path_string(input: &str) -> Option<PathBuf> {
275    let trimmed = input.trim();
276    if trimmed.is_empty() {
277        return None;
278    }
279
280    Some(normalize_path_for_dedupe(Path::new(trimmed)))
281}
282
283fn normalize_system_inc_path(input: &Path) -> Option<PathBuf> {
284    let trimmed = input.to_string_lossy().trim().to_string();
285    if trimmed.is_empty() {
286        return None;
287    }
288
289    let normalized = normalize_path_for_dedupe(Path::new(&trimmed));
290    if normalized == Path::new(".") {
291        return None;
292    }
293
294    Some(normalized)
295}
296
297fn normalize_path_for_dedupe(path: &Path) -> PathBuf {
298    let mut normalized = PathBuf::new();
299    for component in path.components() {
300        if component == Component::CurDir {
301            continue;
302        }
303        normalized.push(component.as_os_str());
304    }
305
306    if normalized.as_os_str().is_empty() { PathBuf::from(".") } else { normalized }
307}
308
309fn normalized_inc_key(path: &Path) -> String {
310    let normalized = path.to_string_lossy().replace('\\', "/");
311    if normalized == "/" { normalized } else { normalized.trim_end_matches('/').to_string() }
312}
313
314fn full_path_for_root(
315    inc_root: &IncRoot,
316    workspace_path: &Path,
317    relative_path: &str,
318) -> Option<PathBuf> {
319    match inc_root.kind {
320        IncRootKind::FileLocalLexical | IncRootKind::WorkspaceRelative => {
321            if inc_root.path == Path::new(".") {
322                let full_path = workspace_path.join(relative_path);
323                validate_workspace_path(&full_path, workspace_path).ok()
324            } else if inc_root.path.is_absolute() {
325                Some(inc_root.path.join(relative_path))
326            } else {
327                let full_path = workspace_path.join(&inc_root.path).join(relative_path);
328                validate_workspace_path(&full_path, workspace_path).ok()
329            }
330        }
331        IncRootKind::ExternalAbsolute
332        | IncRootKind::Perl5LibEnv
333        | IncRootKind::InterpreterStartup
334        | IncRootKind::RuntimeDerived => Some(inc_root.path.join(relative_path)),
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::{IncRootKind, build_effective_inc_roots};
341    use std::path::PathBuf;
342
343    #[test]
344    fn effective_inc_roots_dedupes_normalized_sources() {
345        let include_paths = vec!["lib".to_string(), "lib/".to_string(), "other".to_string()];
346        let lexical_paths = vec!["lib\\".to_string()];
347        let system_paths = vec![PathBuf::from("other/"), PathBuf::from("syslib")];
348
349        let roots =
350            build_effective_inc_roots(&include_paths, &[], false, &lexical_paths, &system_paths);
351        let root_paths: Vec<String> =
352            roots.iter().map(|root| root.path.to_string_lossy().replace('\\', "/")).collect();
353
354        assert_eq!(root_paths, vec!["lib/".to_string(), "other".to_string(), "syslib".to_string()]);
355        assert_eq!(roots[0].source, "use-lib-lexical");
356        assert_eq!(roots[1].source, "workspace-include-paths");
357        assert_eq!(roots[2].source, "interpreter-startup-inc");
358    }
359
360    #[test]
361    fn effective_inc_roots_preserves_first_source_precedence() {
362        let include_paths = vec!["dup".to_string(), "late".to_string()];
363        let lexical_paths = vec!["dup".to_string()];
364        let system_paths = vec![PathBuf::from("late"), PathBuf::from("sys")];
365
366        let roots =
367            build_effective_inc_roots(&include_paths, &[], false, &lexical_paths, &system_paths);
368
369        assert_eq!(roots.len(), 3);
370        assert_eq!(roots[0].path, PathBuf::from("dup"));
371        assert_eq!(roots[0].kind, IncRootKind::FileLocalLexical);
372        assert_eq!(roots[1].path, PathBuf::from("late"));
373        assert_eq!(roots[1].kind, IncRootKind::WorkspaceRelative);
374        assert_eq!(roots[2].path, PathBuf::from("sys"));
375        assert_eq!(roots[2].kind, IncRootKind::InterpreterStartup);
376        assert_eq!(roots[0].precedence, 0);
377        assert_eq!(roots[1].precedence, 1);
378        assert_eq!(roots[2].precedence, 2);
379    }
380
381    #[test]
382    fn effective_inc_roots_labels_perl5lib_only_when_enabled() {
383        let perl5lib_path = "perl5lib".to_string();
384        let include_paths = vec![perl5lib_path.clone(), "lib".to_string()];
385
386        let enabled = build_effective_inc_roots(
387            &include_paths,
388            std::slice::from_ref(&perl5lib_path),
389            true,
390            &[],
391            &[],
392        );
393        assert_eq!(enabled[0].kind, IncRootKind::Perl5LibEnv);
394        assert_eq!(enabled[0].source, "perl5lib-env");
395        assert_eq!(enabled[1].kind, IncRootKind::WorkspaceRelative);
396
397        let disabled = build_effective_inc_roots(&include_paths, &[perl5lib_path], false, &[], &[]);
398        assert_eq!(disabled[0].kind, IncRootKind::WorkspaceRelative);
399        assert_eq!(disabled[0].source, "workspace-include-paths");
400        assert_eq!(disabled[1].kind, IncRootKind::WorkspaceRelative);
401    }
402}