Skip to main content

perl_module_resolution/
use_lib.rs

1//! Extract include paths from `use lib` and `FindBin` statements.
2//!
3//! Scans Perl source text for `use lib` pragmas and recognizes common
4//! `FindBin` patterns to discover additional module include directories.
5
6use std::path::Path;
7
8/// A discovered include path from a `use lib` statement.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct UseLibPath {
11    /// The resolved directory path (relative or absolute).
12    pub path: String,
13    /// Whether this path was derived from a `FindBin` variable.
14    pub from_findbin: bool,
15}
16
17/// Extract include paths from `use lib` statements in Perl source text.
18///
19/// Handles the following patterns:
20/// - `use lib 'path';`
21/// - `use lib "path";`
22/// - `use lib qw(path1 path2);`
23/// - `use lib qw/path1 path2/;`
24/// - `use lib ("path1", "path2");`
25/// - `use lib '$FindBin::Bin/path'` and `"$FindBin::Bin/path"`
26///
27/// Returns extracted paths in order of appearance.
28///
29/// # Examples
30///
31/// ```
32/// use perl_module_resolution::use_lib::extract_use_lib_paths;
33///
34/// let paths = extract_use_lib_paths("use lib 'lib';");
35/// assert_eq!(paths.len(), 1);
36/// assert_eq!(paths[0].path, "lib");
37/// ```
38pub fn extract_use_lib_paths(source: &str) -> Vec<UseLibPath> {
39    let mut paths = Vec::new();
40
41    for line in source.lines() {
42        let trimmed = line.trim();
43        if let Some(rest) = strip_use_lib_prefix(trimmed) {
44            extract_paths_from_args(rest, &mut paths);
45        }
46    }
47
48    paths
49}
50
51/// Resolve `use lib` paths against a workspace root and optional file directory.
52///
53/// - Absolute paths are returned as-is (if they exist).
54/// - `$FindBin::Bin`-relative paths are resolved against `file_dir` (or `workspace_root` if absent).
55/// - Other relative paths are resolved against `workspace_root`.
56///
57/// # Examples
58///
59/// ```
60/// use std::path::Path;
61/// use perl_module_resolution::use_lib::{extract_use_lib_paths, resolve_use_lib_paths};
62///
63/// let source = "use lib 'lib';";
64/// let extracted = extract_use_lib_paths(source);
65/// let resolved = resolve_use_lib_paths(&extracted, Path::new("/project"), None);
66/// assert_eq!(resolved, vec![String::from("lib")]);
67/// ```
68pub fn resolve_use_lib_paths(
69    use_lib_paths: &[UseLibPath],
70    workspace_root: &Path,
71    file_dir: Option<&Path>,
72) -> Vec<String> {
73    let mut result = Vec::new();
74
75    for ulp in use_lib_paths {
76        let path_str = &ulp.path;
77
78        if ulp.from_findbin {
79            let base = file_dir.unwrap_or(workspace_root);
80            let resolved = base.join(path_str);
81            if let Some(s) = path_to_relative_string(&resolved, workspace_root) {
82                if !result.contains(&s) {
83                    result.push(s);
84                }
85            }
86        } else {
87            let p = Path::new(path_str);
88            if p.is_absolute() {
89                if let Ok(rel) = p.strip_prefix(workspace_root) {
90                    let s = rel.to_string_lossy().to_string();
91                    if !result.contains(&s) {
92                        result.push(s);
93                    }
94                }
95            } else {
96                let s = path_str.to_string();
97                if !result.contains(&s) {
98                    result.push(s);
99                }
100            }
101        }
102    }
103
104    result
105}
106
107fn strip_use_lib_prefix(trimmed: &str) -> Option<&str> {
108    let rest = trimmed.strip_prefix("use")?;
109    if !rest.starts_with(|c: char| c.is_whitespace()) {
110        return None;
111    }
112    let rest = rest.trim_start();
113    let rest = rest.strip_prefix("lib")?;
114    if !rest.starts_with(|c: char| c.is_whitespace() || c == '(' || c == ';') {
115        return None;
116    }
117    Some(rest.trim_start())
118}
119
120fn extract_paths_from_args(args: &str, out: &mut Vec<UseLibPath>) {
121    let args = args.trim_end_matches(';').trim();
122
123    if let Some(rest) = args.strip_prefix("qw") {
124        extract_qw_paths(rest.trim_start(), out);
125        return;
126    }
127
128    if let Some(inner) = strip_parens(args) {
129        extract_quoted_list(inner, out);
130        return;
131    }
132
133    extract_quoted_list(args, out);
134}
135
136fn extract_qw_paths(rest: &str, out: &mut Vec<UseLibPath>) {
137    let (open, close) = match rest.chars().next() {
138        Some('(') => ('(', ')'),
139        Some('/') => ('/', '/'),
140        Some('{') => ('{', '}'),
141        Some('[') => ('[', ']'),
142        Some('<') => ('<', '>'),
143        Some('!') => ('!', '!'),
144        _ => return,
145    };
146
147    let inner = &rest[open.len_utf8()..];
148    let end = inner.find(close).unwrap_or(inner.len());
149    let content = &inner[..end];
150
151    for word in content.split_whitespace() {
152        out.push(UseLibPath { path: word.to_string(), from_findbin: false });
153    }
154}
155
156fn strip_parens(s: &str) -> Option<&str> {
157    let s = s.trim();
158    let inner = s.strip_prefix('(')?;
159    let inner = inner.trim_end().strip_suffix(')')?;
160    Some(inner)
161}
162
163fn extract_quoted_list(s: &str, out: &mut Vec<UseLibPath>) {
164    let mut remaining = s.trim();
165
166    while !remaining.is_empty() {
167        remaining = remaining.trim_start_matches(|c: char| c == ',' || c.is_whitespace());
168        if remaining.is_empty() {
169            break;
170        }
171
172        if let Some((path, from_findbin, rest)) = extract_one_quoted(remaining) {
173            out.push(UseLibPath { path, from_findbin });
174            remaining = rest.trim_start_matches(|c: char| c == ',' || c.is_whitespace());
175        } else {
176            break;
177        }
178    }
179}
180
181fn extract_one_quoted(s: &str) -> Option<(String, bool, &str)> {
182    let s = s.trim();
183    let quote = match s.chars().next()? {
184        '\'' => '\'',
185        '"' => '"',
186        _ => return None,
187    };
188
189    let inner = &s[1..];
190    let end = inner.find(quote)?;
191    let content = &inner[..end];
192    let rest = &inner[end + 1..];
193
194    let (path, from_findbin) = resolve_findbin_in_string(content);
195    Some((path, from_findbin, rest))
196}
197
198fn resolve_findbin_in_string(s: &str) -> (String, bool) {
199    let findbin_vars =
200        ["$FindBin::Bin", "$FindBin::RealBin", "${FindBin::Bin}", "${FindBin::RealBin}"];
201
202    for var in &findbin_vars {
203        if let Some(rest) = s.strip_prefix(var) {
204            let path = rest.strip_prefix('/').unwrap_or(rest);
205            if path.is_empty() {
206                return (".".to_string(), true);
207            }
208            return (path.to_string(), true);
209        }
210    }
211
212    (s.to_string(), false)
213}
214
215fn path_to_relative_string(path: &Path, workspace_root: &Path) -> Option<String> {
216    if let Ok(rel) = path.strip_prefix(workspace_root) {
217        let s = normalize_relative_path_string(rel.to_string_lossy().as_ref());
218        if s.is_empty() { Some(".".to_string()) } else { Some(s) }
219    } else {
220        let s = normalize_relative_path_string(path.to_string_lossy().as_ref());
221        Some(s)
222    }
223}
224
225fn normalize_relative_path_string(path: &str) -> String {
226    path.replace('\\', "/")
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn single_quoted_lib() {
235        let paths = extract_use_lib_paths("use lib 'lib';");
236        assert_eq!(paths, vec![UseLibPath { path: "lib".into(), from_findbin: false }]);
237    }
238
239    #[test]
240    fn double_quoted_lib() {
241        let paths = extract_use_lib_paths("use lib \"lib\";");
242        assert_eq!(paths, vec![UseLibPath { path: "lib".into(), from_findbin: false }]);
243    }
244
245    #[test]
246    fn single_quoted_with_subdir() {
247        let paths = extract_use_lib_paths("use lib 'local/lib/perl5';");
248        assert_eq!(paths, vec![UseLibPath { path: "local/lib/perl5".into(), from_findbin: false }]);
249    }
250
251    #[test]
252    fn qw_parens_multiple_paths() {
253        let paths = extract_use_lib_paths("use lib qw(lib t/lib);");
254        assert_eq!(paths.len(), 2);
255        assert_eq!(paths[0].path, "lib");
256        assert_eq!(paths[1].path, "t/lib");
257    }
258
259    #[test]
260    fn qw_slash_delimiter() {
261        let paths = extract_use_lib_paths("use lib qw/lib t-lib/;");
262        assert_eq!(paths.len(), 2);
263        assert_eq!(paths[0].path, "lib");
264        assert_eq!(paths[1].path, "t-lib");
265    }
266
267    #[test]
268    fn qw_curly_delimiter() {
269        let paths = extract_use_lib_paths("use lib qw{lib};");
270        assert_eq!(paths.len(), 1);
271        assert_eq!(paths[0].path, "lib");
272    }
273
274    #[test]
275    fn qw_bracket_delimiter() {
276        let paths = extract_use_lib_paths("use lib qw[lib t/lib];");
277        assert_eq!(paths.len(), 2);
278        assert_eq!(paths[0].path, "lib");
279        assert_eq!(paths[1].path, "t/lib");
280    }
281
282    #[test]
283    fn paren_list_single() {
284        let paths = extract_use_lib_paths("use lib ('lib');");
285        assert_eq!(paths, vec![UseLibPath { path: "lib".into(), from_findbin: false }]);
286    }
287
288    #[test]
289    fn paren_list_multiple() {
290        let paths = extract_use_lib_paths("use lib ('lib', 't/lib');");
291        assert_eq!(paths.len(), 2);
292        assert_eq!(paths[0].path, "lib");
293        assert_eq!(paths[1].path, "t/lib");
294    }
295
296    #[test]
297    fn findbin_bin_with_lib() {
298        let paths = extract_use_lib_paths("use lib \"$FindBin::Bin/lib\";");
299        assert_eq!(paths.len(), 1);
300        assert_eq!(paths[0].path, "lib");
301        assert!(paths[0].from_findbin);
302    }
303
304    #[test]
305    fn findbin_bin_with_parent_lib() {
306        let paths = extract_use_lib_paths("use lib \"$FindBin::Bin/../lib\";");
307        assert_eq!(paths.len(), 1);
308        assert_eq!(paths[0].path, "../lib");
309        assert!(paths[0].from_findbin);
310    }
311
312    #[test]
313    fn findbin_realbin() {
314        let paths = extract_use_lib_paths("use lib \"$FindBin::RealBin/lib\";");
315        assert_eq!(paths.len(), 1);
316        assert_eq!(paths[0].path, "lib");
317        assert!(paths[0].from_findbin);
318    }
319
320    #[test]
321    fn findbin_braced_form() {
322        let paths = extract_use_lib_paths("use lib \"${FindBin::Bin}/lib\";");
323        assert_eq!(paths.len(), 1);
324        assert_eq!(paths[0].path, "lib");
325        assert!(paths[0].from_findbin);
326    }
327
328    #[test]
329    fn findbin_bare_bin() {
330        let paths = extract_use_lib_paths("use lib \"$FindBin::Bin\";");
331        assert_eq!(paths.len(), 1);
332        assert_eq!(paths[0].path, ".");
333        assert!(paths[0].from_findbin);
334    }
335
336    #[test]
337    fn leading_whitespace() {
338        let paths = extract_use_lib_paths("  use lib 'lib';");
339        assert_eq!(paths.len(), 1);
340        assert_eq!(paths[0].path, "lib");
341    }
342
343    #[test]
344    fn multiple_use_lib_statements() {
345        let source = "use lib 'lib';\nuse lib 't/lib';\n";
346        let paths = extract_use_lib_paths(source);
347        assert_eq!(paths.len(), 2);
348        assert_eq!(paths[0].path, "lib");
349        assert_eq!(paths[1].path, "t/lib");
350    }
351
352    #[test]
353    fn non_use_lib_lines_ignored() {
354        let source = "use strict;\nuse warnings;\nuse lib 'lib';\nuse Foo::Bar;\n";
355        let paths = extract_use_lib_paths(source);
356        assert_eq!(paths.len(), 1);
357        assert_eq!(paths[0].path, "lib");
358    }
359
360    #[test]
361    fn use_library_not_confused_with_use_lib() {
362        let paths = extract_use_lib_paths("use library 'foo';");
363        assert!(paths.is_empty());
364    }
365
366    #[test]
367    fn empty_source() {
368        let paths = extract_use_lib_paths("");
369        assert!(paths.is_empty());
370    }
371
372    #[test]
373    fn no_use_lib() {
374        let paths = extract_use_lib_paths("use strict;\nuse warnings;\n");
375        assert!(paths.is_empty());
376    }
377
378    #[test]
379    fn resolve_relative_path() {
380        let paths = vec![UseLibPath { path: "lib".into(), from_findbin: false }];
381        let resolved = resolve_use_lib_paths(&paths, Path::new("/project"), None);
382        assert_eq!(resolved, vec!["lib"]);
383    }
384
385    #[test]
386    fn resolve_findbin_path_with_file_dir() {
387        let paths = vec![UseLibPath { path: "lib".into(), from_findbin: true }];
388        let resolved =
389            resolve_use_lib_paths(&paths, Path::new("/project"), Some(Path::new("/project/bin")));
390        assert_eq!(resolved, vec!["bin/lib"]);
391    }
392
393    #[test]
394    fn resolve_findbin_path_without_file_dir() {
395        let paths = vec![UseLibPath { path: "lib".into(), from_findbin: true }];
396        let resolved = resolve_use_lib_paths(&paths, Path::new("/project"), None);
397        assert_eq!(resolved, vec!["lib"]);
398    }
399
400    #[test]
401    fn resolve_absolute_path_inside_workspace() -> Result<(), Box<dyn std::error::Error>> {
402        let workspace = tempfile::tempdir()?;
403        let inside = workspace.path().join("lib");
404        let paths =
405            vec![UseLibPath { path: inside.to_string_lossy().to_string(), from_findbin: false }];
406        let resolved = resolve_use_lib_paths(&paths, workspace.path(), None);
407        assert_eq!(resolved, vec!["lib"]);
408        Ok(())
409    }
410
411    #[test]
412    fn resolve_absolute_path_outside_workspace_ignored() -> Result<(), Box<dyn std::error::Error>> {
413        let workspace = tempfile::tempdir()?;
414        let outside = tempfile::tempdir()?;
415        let paths = vec![UseLibPath {
416            path: outside.path().join("lib").to_string_lossy().to_string(),
417            from_findbin: false,
418        }];
419        let resolved = resolve_use_lib_paths(&paths, workspace.path(), None);
420        assert!(resolved.is_empty());
421        Ok(())
422    }
423
424    #[test]
425    fn resolve_deduplicates_paths() {
426        let paths = vec![
427            UseLibPath { path: "lib".into(), from_findbin: false },
428            UseLibPath { path: "lib".into(), from_findbin: false },
429        ];
430        let resolved = resolve_use_lib_paths(&paths, Path::new("/project"), None);
431        assert_eq!(resolved, vec!["lib"]);
432    }
433}