1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum IncRootKind {
16 FileLocalLexical,
18 WorkspaceRelative,
20 ExternalAbsolute,
22 Perl5LibEnv,
28 InterpreterStartup,
30 RuntimeDerived,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct IncRoot {
37 pub kind: IncRootKind,
39 pub path: PathBuf,
41 pub precedence: usize,
43 pub source: String,
45}
46
47#[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#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ModuleUriResolution {
122 Resolved(String),
124 NotFound,
126 TimedOut,
128}
129
130#[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#[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}