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 (path-boundary 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 open_document_uri_matches_relative_path(uri, &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 open_document_uri_matches_relative_path(uri: &str, relative_path: &str) -> bool {
275    if relative_path.is_empty() {
276        return false;
277    }
278
279    let normalized_uri = uri.replace('\\', "/");
280    let normalized_relative_path = relative_path.replace('\\', "/");
281    normalized_uri
282        .strip_suffix(&normalized_relative_path)
283        .is_some_and(|prefix| prefix.is_empty() || prefix.ends_with('/'))
284}
285
286fn normalize_inc_path_string(input: &str) -> Option<PathBuf> {
287    let trimmed = input.trim();
288    if trimmed.is_empty() {
289        return None;
290    }
291
292    Some(normalize_path_for_dedupe(Path::new(trimmed)))
293}
294
295fn normalize_system_inc_path(input: &Path) -> Option<PathBuf> {
296    let trimmed = input.to_string_lossy().trim().to_string();
297    if trimmed.is_empty() {
298        return None;
299    }
300
301    let normalized = normalize_path_for_dedupe(Path::new(&trimmed));
302    if normalized == Path::new(".") {
303        return None;
304    }
305
306    Some(normalized)
307}
308
309fn normalize_path_for_dedupe(path: &Path) -> PathBuf {
310    let mut normalized = PathBuf::new();
311    for component in path.components() {
312        if component == Component::CurDir {
313            continue;
314        }
315        normalized.push(component.as_os_str());
316    }
317
318    if normalized.as_os_str().is_empty() { PathBuf::from(".") } else { normalized }
319}
320
321fn normalized_inc_key(path: &Path) -> String {
322    let normalized = path.to_string_lossy().replace('\\', "/");
323    if normalized == "/" { normalized } else { normalized.trim_end_matches('/').to_string() }
324}
325
326fn full_path_for_root(
327    inc_root: &IncRoot,
328    workspace_path: &Path,
329    relative_path: &str,
330) -> Option<PathBuf> {
331    match inc_root.kind {
332        IncRootKind::FileLocalLexical | IncRootKind::WorkspaceRelative => {
333            if inc_root.path == Path::new(".") {
334                let full_path = workspace_path.join(relative_path);
335                validate_workspace_path(&full_path, workspace_path).ok()
336            } else if inc_root.path.is_absolute() {
337                Some(inc_root.path.join(relative_path))
338            } else {
339                let full_path = workspace_path.join(&inc_root.path).join(relative_path);
340                validate_workspace_path(&full_path, workspace_path).ok()
341            }
342        }
343        IncRootKind::ExternalAbsolute
344        | IncRootKind::Perl5LibEnv
345        | IncRootKind::InterpreterStartup
346        | IncRootKind::RuntimeDerived => Some(inc_root.path.join(relative_path)),
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::{IncRootKind, build_effective_inc_roots, open_document_uri_matches_relative_path};
353    use std::path::PathBuf;
354
355    #[test]
356    fn effective_inc_roots_dedupes_normalized_sources() {
357        let include_paths = vec!["lib".to_string(), "lib/".to_string(), "other".to_string()];
358        let lexical_paths = vec!["lib\\".to_string()];
359        let system_paths = vec![PathBuf::from("other/"), PathBuf::from("syslib")];
360
361        let roots =
362            build_effective_inc_roots(&include_paths, &[], false, &lexical_paths, &system_paths);
363        let root_paths: Vec<String> =
364            roots.iter().map(|root| root.path.to_string_lossy().replace('\\', "/")).collect();
365
366        assert_eq!(root_paths, vec!["lib/".to_string(), "other".to_string(), "syslib".to_string()]);
367        assert_eq!(roots[0].source, "use-lib-lexical");
368        assert_eq!(roots[1].source, "workspace-include-paths");
369        assert_eq!(roots[2].source, "interpreter-startup-inc");
370    }
371
372    #[test]
373    fn effective_inc_roots_preserves_first_source_precedence() {
374        let include_paths = vec!["dup".to_string(), "late".to_string()];
375        let lexical_paths = vec!["dup".to_string()];
376        let system_paths = vec![PathBuf::from("late"), PathBuf::from("sys")];
377
378        let roots =
379            build_effective_inc_roots(&include_paths, &[], false, &lexical_paths, &system_paths);
380
381        assert_eq!(roots.len(), 3);
382        assert_eq!(roots[0].path, PathBuf::from("dup"));
383        assert_eq!(roots[0].kind, IncRootKind::FileLocalLexical);
384        assert_eq!(roots[1].path, PathBuf::from("late"));
385        assert_eq!(roots[1].kind, IncRootKind::WorkspaceRelative);
386        assert_eq!(roots[2].path, PathBuf::from("sys"));
387        assert_eq!(roots[2].kind, IncRootKind::InterpreterStartup);
388        assert_eq!(roots[0].precedence, 0);
389        assert_eq!(roots[1].precedence, 1);
390        assert_eq!(roots[2].precedence, 2);
391    }
392
393    #[test]
394    fn effective_inc_roots_labels_perl5lib_only_when_enabled() {
395        let perl5lib_path = "perl5lib".to_string();
396        let include_paths = vec![perl5lib_path.clone(), "lib".to_string()];
397
398        let enabled = build_effective_inc_roots(
399            &include_paths,
400            std::slice::from_ref(&perl5lib_path),
401            true,
402            &[],
403            &[],
404        );
405        assert_eq!(enabled[0].kind, IncRootKind::Perl5LibEnv);
406        assert_eq!(enabled[0].source, "perl5lib-env");
407        assert_eq!(enabled[1].kind, IncRootKind::WorkspaceRelative);
408
409        let disabled = build_effective_inc_roots(&include_paths, &[perl5lib_path], false, &[], &[]);
410        assert_eq!(disabled[0].kind, IncRootKind::WorkspaceRelative);
411        assert_eq!(disabled[0].source, "workspace-include-paths");
412        assert_eq!(disabled[1].kind, IncRootKind::WorkspaceRelative);
413    }
414
415    #[test]
416    fn open_document_uri_match_rejects_empty_relative_path() {
417        assert!(
418            !open_document_uri_matches_relative_path("file:///workspace/lib/Foo.pm", ""),
419            "empty relative paths must never match an open document"
420        );
421    }
422
423    #[test]
424    fn open_document_uri_match_accepts_exact_relative_path() {
425        let cases = [("Foo/Bar.pm", "Foo/Bar.pm", true), ("Other/Bar.pm", "Foo/Bar.pm", false)];
426
427        for (normalized_uri, normalized_relative_path, expected) in cases {
428            assert_eq!(
429                open_document_uri_matches_relative_path(normalized_uri, normalized_relative_path),
430                expected,
431                "exact relative path equality should decide raw relative inputs"
432            );
433        }
434    }
435
436    #[test]
437    fn open_document_uri_match_accepts_path_bounded_suffix() {
438        assert!(
439            open_document_uri_matches_relative_path(
440                "file:///workspace/local/lib/Foo/Bar.pm",
441                "Foo/Bar.pm"
442            ),
443            "open document URIs may contain editor or workspace prefixes before the module path"
444        );
445    }
446
447    #[test]
448    fn open_document_uri_match_rejects_unbounded_suffix() {
449        assert!(
450            !open_document_uri_matches_relative_path(
451                "file:///workspace/local/lib/MyFoo/Bar.pm",
452                "Foo/Bar.pm"
453            ),
454            "the preceding URI segment must end before the module path starts"
455        );
456    }
457
458    #[test]
459    fn open_document_uri_match_normalizes_windows_separators() {
460        assert!(
461            open_document_uri_matches_relative_path(
462                "file:///workspace\\local\\lib\\Foo\\Bar.pm",
463                "Foo\\Bar.pm"
464            ),
465            "path-boundary matching should not depend on slash direction"
466        );
467    }
468}