Skip to main content

perl_dap/platform/
mod.rs

1//! Cross-platform utilities for Perl path resolution and environment setup.
2//!
3//! Shared toolchain detection functions (`resolve_perl_path_with_toolchain`,
4//! `detect_perlbrew_perl`, `detect_plenv_perl`, `resolve_perl_path`) are
5//! canonical in `perl_lsp_rs_core::platform` and re-exported here for
6//! backward compatibility.
7
8// Re-export format_command_args for backward compatibility (was in old platform re-export module)
9pub use crate::command_args::format_command_args;
10
11// Re-export canonical toolchain detection from perl-lsp-rs-core (#4545)
12pub use perl_lsp_rs_core::platform::{
13    detect_perlbrew_perl, detect_plenv_perl, resolve_perl_path, resolve_perl_path_with_toolchain,
14};
15
16use std::collections::HashMap;
17use std::env;
18use std::path::PathBuf;
19use std::sync::{LazyLock, Mutex};
20
21#[cfg(windows)]
22const PATH_SEPARATOR: char = ';';
23#[cfg(not(windows))]
24const PATH_SEPARATOR: char = ':';
25
26#[cfg(windows)]
27const PERL_EXECUTABLE: &str = "perl.exe";
28#[cfg(not(windows))]
29const PERL_EXECUTABLE: &str = "perl";
30
31/// The result of Perl interpreter discovery.
32///
33/// Separates the "found on PATH" case from the "found via OS fallback" case
34/// so callers can log or surface different messages to users.
35#[derive(Debug, Clone, PartialEq)]
36pub enum PerlInterpreterResult {
37    /// Perl was found via the configured `perl-lsp.perl.path` setting.
38    ConfiguredPath(PathBuf),
39    /// Perl was found on PATH (the normal case).
40    FoundOnPath(PathBuf),
41    /// Perl was NOT on PATH but was found at a well-known OS install location.
42    FoundViaFallback {
43        /// Absolute path to the Perl interpreter found at the fallback location.
44        path: PathBuf,
45        /// Human-readable label for the fallback source (e.g. `"Strawberry Perl"`).
46        label: String,
47    },
48    /// No Perl interpreter found anywhere.
49    NotFound {
50        /// Ordered list of locations that were searched, for use in error messages.
51        searched: Vec<String>,
52    },
53}
54
55static PERL_INTERPRETER_CACHE: LazyLock<Mutex<Option<(String, PerlInterpreterResult)>>> =
56    LazyLock::new(|| Mutex::new(None));
57
58fn perl_discovery_cache_key(configured_path: Option<&str>) -> String {
59    let path_env = env::var("PATH").unwrap_or_default();
60    let perlbrew_perl = env::var("PERLBREW_PERL").unwrap_or_default();
61    let perlbrew_root = env::var("PERLBREW_ROOT").unwrap_or_default();
62    let plenv_root = env::var("PLENV_ROOT").unwrap_or_default();
63    let plenv_version = env::var("PLENV_VERSION").unwrap_or_default();
64    // HOME (Unix) and USERPROFILE (Windows) are both checked by home_dir() in
65    // perl-lsp-rs-core::platform when PERLBREW_ROOT/PLENV_ROOT are absent.
66    let home = env::var("HOME").unwrap_or_default();
67    let userprofile = env::var("USERPROFILE").unwrap_or_default();
68    // PREFIX is used by resolve_perl_path() for Termux detection.
69    let prefix = env::var("PREFIX").unwrap_or_default();
70
71    format!(
72        "cfg={};path={path_env};perlbrew_perl={perlbrew_perl};perlbrew_root={perlbrew_root};plenv_root={plenv_root};plenv_version={plenv_version};home={home};userprofile={userprofile};prefix={prefix}",
73        configured_path.map(str::trim).unwrap_or_default()
74    )
75}
76
77/// Rank a Perl binary path for preference on Windows.
78///
79/// Returns a lower score for higher-priority interpreters.
80/// Strawberry Perl ranks best (1), then ActiveState (2), then msys/Git Bash (100).
81#[cfg(windows)]
82fn windows_perl_rank(path: &std::path::Path) -> u8 {
83    let s = path.to_string_lossy().to_ascii_lowercase();
84    if s.contains("strawberry") {
85        1
86    } else if s.contains("perl64") || s.contains("activestate") || s.contains("activeperl") {
87        2
88    } else if s.contains(r"\git\usr\bin") || s.contains("/git/usr/bin") || s.contains("msys") {
89        100
90    } else {
91        50
92    }
93}
94
95/// Collect all Perl executables found on PATH, ranked for Windows preference.
96///
97/// On Windows, returns all matches sorted by [`windows_perl_rank`] so the caller
98/// can pick the best one. On non-Windows, returns candidates in PATH order (no ranking).
99fn find_all_perl_on_path(path_env: &str) -> Vec<PathBuf> {
100    #[allow(unused_mut)]
101    let mut found: Vec<PathBuf> = path_env
102        .split(PATH_SEPARATOR)
103        .filter_map(normalize_path_entry)
104        .map(|dir| dir.join(PERL_EXECUTABLE))
105        .filter(|p| p.exists() && p.is_file())
106        .collect();
107
108    #[cfg(windows)]
109    found.sort_by_key(|p| windows_perl_rank(p));
110
111    found
112}
113
114/// Normalize a single PATH segment by trimming whitespace/quotes and dropping empties.
115fn normalize_path_entry(entry: &str) -> Option<PathBuf> {
116    let trimmed = entry.trim().trim_matches('"');
117    if trimmed.is_empty() {
118        return None;
119    }
120
121    Some(PathBuf::from(trimmed))
122}
123
124/// Canonical OS-specific fallback paths to probe when Perl is not on PATH.
125///
126/// Returns `(path, label)` pairs. Probed in order; first existing file wins.
127fn fallback_perl_paths() -> Vec<(PathBuf, &'static str)> {
128    #[cfg(windows)]
129    {
130        vec![
131            (PathBuf::from(r"C:\Strawberry\perl\bin\perl.exe"), "Strawberry Perl"),
132            (PathBuf::from(r"C:\Perl64\bin\perl.exe"), "ActiveState Perl (64-bit)"),
133            (
134                {
135                    let pf = env::var("ProgramFiles")
136                        .unwrap_or_else(|_| r"C:\Program Files".to_string());
137                    PathBuf::from(pf).join(r"Strawberry\perl\bin\perl.exe")
138                },
139                "Strawberry Perl (Program Files)",
140            ),
141        ]
142    }
143    #[cfg(target_os = "macos")]
144    {
145        vec![
146            (PathBuf::from("/opt/homebrew/bin/perl"), "Homebrew Perl (Apple Silicon)"),
147            (PathBuf::from("/usr/local/bin/perl"), "Homebrew Perl (Intel)"),
148            (PathBuf::from("/usr/bin/perl"), "macOS system Perl"),
149        ]
150    }
151    #[cfg(target_os = "android")]
152    {
153        // Android (Termux)
154        vec![
155            (PathBuf::from("/data/data/com.termux/files/usr/bin/perl"), "Termux Perl"),
156            (PathBuf::from("/data/user/0/com.termux/files/usr/bin/perl"), "Termux Perl"),
157        ]
158    }
159    #[cfg(all(not(windows), not(target_os = "macos"), not(target_os = "android")))]
160    {
161        // Linux and others
162        vec![
163            (PathBuf::from("/usr/bin/perl"), "system Perl"),
164            (PathBuf::from("/usr/local/bin/perl"), "local Perl"),
165        ]
166    }
167}
168
169/// Find the best available Perl interpreter with full cross-platform detection.
170///
171/// Detection order:
172/// 1. If `configured_path` is `Some` and non-empty, validate and return
173///    [`PerlInterpreterResult::ConfiguredPath`] (or [`PerlInterpreterResult::NotFound`]
174///    if the configured path is broken). Does **not** fall back silently.
175/// 2. Check toolchain managers (perlbrew, plenv) before PATH.
176/// 3. Walk PATH; on Windows, prefer Strawberry/ActiveState over msys/Git Bash perls.
177/// 4. If not on PATH, probe canonical OS install locations as a last resort.
178/// 5. If still not found, return [`PerlInterpreterResult::NotFound`] with searched paths.
179pub fn find_perl_interpreter(configured_path: Option<&str>) -> PerlInterpreterResult {
180    // 1. Honour explicit config path — validate it exists, never silently fall back.
181    if let Some(cfg) = configured_path.map(str::trim).filter(|s| !s.is_empty()) {
182        let p = PathBuf::from(cfg);
183        if p.exists() && p.is_file() {
184            return PerlInterpreterResult::ConfiguredPath(p);
185        } else {
186            return PerlInterpreterResult::NotFound {
187                searched: vec![format!("configured path: {cfg}")],
188            };
189        }
190    }
191
192    let mut searched: Vec<String> = vec!["PATH".to_string()];
193
194    // 2. Check toolchain managers (perlbrew, plenv) first.
195    if let Some(path) = detect_perlbrew_perl() {
196        return PerlInterpreterResult::FoundOnPath(path);
197    }
198    if let Some(path) = detect_plenv_perl() {
199        return PerlInterpreterResult::FoundOnPath(path);
200    }
201
202    // 3. Walk PATH, ranking results on Windows.
203    if let Ok(path_env) = env::var("PATH") {
204        let ranked = find_all_perl_on_path(&path_env);
205        if let Some(best) = ranked.into_iter().next() {
206            return PerlInterpreterResult::FoundOnPath(best);
207        }
208    }
209
210    // 4. Probe OS-specific fallback paths.
211    for (path, label) in fallback_perl_paths() {
212        searched.push(path.to_string_lossy().to_string());
213        if path.exists() && path.is_file() {
214            return PerlInterpreterResult::FoundViaFallback { path, label: label.to_string() };
215        }
216    }
217
218    PerlInterpreterResult::NotFound { searched }
219}
220
221/// Cached variant of [`find_perl_interpreter`].
222///
223/// This avoids repeated PATH/toolchain scans during repeated launch failures in
224/// the same environment while still invalidating when key discovery inputs
225/// (PATH, toolchain environment, configured path) change.
226///
227/// # Concurrency
228///
229/// The cache uses a simple check-then-compute-then-store pattern with two
230/// separate lock acquisitions. Two concurrent callers racing on a cold cache
231/// will both run [`find_perl_interpreter`] and the second write wins. This is
232/// intentional: the correctness invariant is that callers always receive a
233/// valid result for the current environment, not that exactly one subprocess
234/// is spawned per cache entry. A condvar/OnceLock approach would complicate the
235/// API without measurable benefit for the low-frequency DAP launch path.
236pub fn find_perl_interpreter_cached(configured_path: Option<&str>) -> PerlInterpreterResult {
237    let cache_key = perl_discovery_cache_key(configured_path);
238
239    if let Ok(cache) = PERL_INTERPRETER_CACHE.lock()
240        && let Some((cached_key, cached_result)) = cache.as_ref()
241        && *cached_key == cache_key
242    {
243        return cached_result.clone();
244    }
245
246    let resolved = find_perl_interpreter(configured_path);
247    if let Ok(mut cache) = PERL_INTERPRETER_CACHE.lock() {
248        *cache = Some((cache_key, resolved.clone()));
249    }
250    resolved
251}
252
253#[cfg(test)]
254fn resolve_perl_path_from_path_env(path_env: &str) -> anyhow::Result<PathBuf> {
255    for path_dir in path_env.split(PATH_SEPARATOR).filter_map(normalize_path_entry) {
256        let perl_path = path_dir.join(PERL_EXECUTABLE);
257        if perl_path.exists() && perl_path.is_file() {
258            return Ok(perl_path);
259        }
260    }
261
262    anyhow::bail!(perl_not_found_install_message())
263}
264
265#[cfg(test)]
266/// End-user remediation guidance shown when no Perl interpreter is available.
267fn perl_not_found_install_message() -> &'static str {
268    "perl binary not found on PATH. Install Perl via https://strawberryperl.com (Windows), \
269`brew install perl` (macOS), `pkg install perl` in Termux (Android), or your distro package manager, then add it to PATH."
270}
271
272/// Normalize a file path for cross-platform compatibility.
273pub fn normalize_path(path: &std::path::Path) -> PathBuf {
274    #[cfg(target_os = "linux")]
275    {
276        if let Some(path_str) = path.to_str()
277            && path_str.starts_with("/mnt/")
278            && path_str.len() > 6
279        {
280            let drive_letter = &path_str[5..6];
281            let rest = &path_str[6..];
282            let windows_path =
283                format!("{}:{}", drive_letter.to_uppercase(), rest.replace('/', "\\"));
284            return PathBuf::from(windows_path);
285        }
286    }
287
288    #[cfg(windows)]
289    {
290        if let Some(path_str) = path.to_str() {
291            if path_str.len() >= 2
292                && path_str.chars().nth(1) == Some(':')
293                && let Some(first_char) = path_str.chars().next()
294            {
295                let drive_letter = first_char.to_uppercase();
296                let rest = &path_str[1..];
297                return PathBuf::from(format!("{}{}", drive_letter, rest));
298            }
299
300            if path_str.starts_with("\\\\") {
301                return path.to_path_buf();
302            }
303        }
304    }
305
306    #[cfg(not(windows))]
307    {
308        if let Ok(canonical) = path.canonicalize() {
309            return canonical;
310        }
311    }
312
313    path.to_path_buf()
314}
315
316/// Setup environment variables for Perl execution.
317pub fn setup_environment(include_paths: &[PathBuf]) -> HashMap<String, String> {
318    let mut env = HashMap::new();
319
320    if !include_paths.is_empty() {
321        let perl5lib = include_paths
322            .iter()
323            .map(|p| p.to_string_lossy().to_string())
324            .collect::<Vec<_>>()
325            .join(&PATH_SEPARATOR.to_string());
326
327        env.insert("PERL5LIB".to_string(), perl5lib);
328    }
329
330    env
331}
332
333#[cfg(test)]
334#[allow(clippy::panic)]
335mod tests {
336    use super::*;
337    use perl_tdd_support::{must, must_err};
338
339    #[test]
340    fn test_resolve_perl_path() {
341        if let Ok(path) = resolve_perl_path() {
342            assert!(path.exists());
343            assert!(path.is_file());
344        }
345    }
346
347    #[test]
348    fn test_normalize_path_basic() {
349        let normalized = normalize_path(&PathBuf::from("script.pl"));
350        assert!(!normalized.as_os_str().is_empty());
351    }
352
353    #[test]
354    fn test_setup_environment_empty() {
355        let env = setup_environment(&[]);
356        assert!(!env.contains_key("PERL5LIB"));
357    }
358
359    #[test]
360    fn test_setup_environment_with_paths() {
361        let env =
362            setup_environment(&[PathBuf::from("/workspace/lib"), PathBuf::from("/custom/lib")]);
363        assert!(env.contains_key("PERL5LIB"));
364    }
365
366    #[test]
367    fn resolve_from_path_env_finds_perl_in_first_dir() {
368        use std::fs;
369        let tempdir = must(tempfile::tempdir());
370        let bin = tempdir.path().join(PERL_EXECUTABLE);
371        must(fs::write(&bin, ""));
372        #[cfg(unix)]
373        {
374            use std::os::unix::fs::PermissionsExt;
375            let mut perms = must(fs::metadata(&bin)).permissions();
376            perms.set_mode(0o755);
377            must(fs::set_permissions(&bin, perms));
378        }
379        let path_str = tempdir.path().to_string_lossy().to_string();
380        let result = resolve_perl_path_from_path_env(&path_str);
381        assert_eq!(must(result), bin);
382    }
383
384    #[test]
385    fn resolve_from_path_env_empty_path_returns_error() {
386        let result = resolve_perl_path_from_path_env("");
387        assert!(result.is_err());
388        let msg = format!("{}", must_err(result));
389        assert!(
390            msg.contains("perl") || msg.contains("PATH"),
391            "error should mention perl/PATH: {msg}"
392        );
393        assert!(msg.contains("strawberryperl.com"), "error should include install guidance: {msg}");
394    }
395
396    #[test]
397    fn resolve_from_path_env_no_perl_on_path_returns_error() {
398        let tempdir = must(tempfile::tempdir());
399        let path_str = tempdir.path().to_string_lossy().to_string();
400        let result = resolve_perl_path_from_path_env(&path_str);
401        assert!(result.is_err());
402    }
403
404    #[test]
405    fn resolve_from_path_env_handles_quoted_path_segment() {
406        use std::fs;
407        let tempdir = must(tempfile::tempdir());
408        let bin = tempdir.path().join(PERL_EXECUTABLE);
409        must(fs::write(&bin, ""));
410        #[cfg(unix)]
411        {
412            use std::os::unix::fs::PermissionsExt;
413            let mut perms = must(fs::metadata(&bin)).permissions();
414            perms.set_mode(0o755);
415            must(fs::set_permissions(&bin, perms));
416        }
417        let quoted_path = format!("\"{}\"", tempdir.path().to_string_lossy());
418        let result = resolve_perl_path_from_path_env(&quoted_path);
419        assert_eq!(must(result), bin);
420    }
421
422    #[test]
423    fn normalize_path_entry_trims_whitespace_and_quotes() {
424        let raw = "  \"/tmp/perl path\"  ";
425        let normalized = normalize_path_entry(raw);
426        assert_eq!(normalized, Some(PathBuf::from("/tmp/perl path")));
427    }
428
429    #[test]
430    fn normalize_path_entry_rejects_empty_segments() {
431        assert_eq!(normalize_path_entry(""), None);
432        assert_eq!(normalize_path_entry("   "), None);
433        assert_eq!(normalize_path_entry("\"\""), None);
434    }
435
436    #[test]
437    #[cfg(target_os = "linux")]
438    fn normalize_path_wsl_mnt_translated_to_windows_style() {
439        let wsl_path = std::path::Path::new("/mnt/c/Users/user/script.pl");
440        let normalized = normalize_path(wsl_path);
441        let s = normalized.to_string_lossy();
442        assert!(
443            s.starts_with("C:\\") || s.starts_with("C:/"),
444            "expected Windows-style path, got: {s}"
445        );
446        assert!(s.contains("Users"), "path content preserved: {s}");
447    }
448
449    #[test]
450    fn normalize_path_non_wsl_unix_path_unchanged_on_linux() {
451        let path = std::path::Path::new("/usr/local/bin/perl");
452        let normalized = normalize_path(path);
453        assert!(
454            !normalized.to_string_lossy().contains('\\'),
455            "non-WSL path should not be Windows-escaped"
456        );
457    }
458
459    // ── find_perl_interpreter tests ────────────────────────────────────────
460
461    #[test]
462    fn find_perl_interpreter_configured_path_valid_returns_configured() {
463        use std::fs;
464        let tempdir = must(tempfile::tempdir());
465        let fake_perl = tempdir.path().join(PERL_EXECUTABLE);
466        must(fs::write(&fake_perl, ""));
467        #[cfg(unix)]
468        {
469            use std::os::unix::fs::PermissionsExt;
470            let mut perms = must(fs::metadata(&fake_perl)).permissions();
471            perms.set_mode(0o755);
472            must(fs::set_permissions(&fake_perl, perms));
473        }
474        let path_str = fake_perl.to_string_lossy().to_string();
475        let result = find_perl_interpreter(Some(&path_str));
476        assert!(
477            matches!(result, PerlInterpreterResult::ConfiguredPath(_)),
478            "expected ConfiguredPath, got: {result:?}"
479        );
480    }
481
482    #[test]
483    fn find_perl_interpreter_configured_path_missing_returns_not_found() {
484        let result = find_perl_interpreter(Some("/nonexistent/path/to/perl"));
485        assert!(
486            matches!(result, PerlInterpreterResult::NotFound { .. }),
487            "expected NotFound, got: {result:?}"
488        );
489        let PerlInterpreterResult::NotFound { searched } = result else {
490            return;
491        };
492        assert!(
493            searched.iter().any(|s| s.contains("configured")),
494            "searched list should mention configured path: {searched:?}"
495        );
496    }
497
498    #[test]
499    fn find_perl_interpreter_empty_config_falls_back_to_path_detection() {
500        let result = find_perl_interpreter(Some(""));
501        assert!(
502            !matches!(result, PerlInterpreterResult::ConfiguredPath(_)),
503            "empty config should fall back to path detection"
504        );
505    }
506
507    #[test]
508    fn find_perl_interpreter_whitespace_config_falls_back_to_path_detection() {
509        let result = find_perl_interpreter(Some("   	  "));
510        assert!(
511            !matches!(result, PerlInterpreterResult::ConfiguredPath(_)),
512            "whitespace-only config should fall back to path detection"
513        );
514    }
515
516    #[test]
517    fn find_perl_interpreter_none_config_performs_detection() {
518        let result = find_perl_interpreter(None);
519        assert!(
520            !matches!(result, PerlInterpreterResult::ConfiguredPath(_)),
521            "None config should not return ConfiguredPath"
522        );
523    }
524
525    #[test]
526    fn find_perl_interpreter_not_found_includes_searched_paths() {
527        let result = find_perl_interpreter(Some("/absolutely/not/a/real/path/perl"));
528        if let PerlInterpreterResult::NotFound { searched } = result {
529            assert!(!searched.is_empty(), "searched list should not be empty");
530        }
531    }
532
533    #[test]
534    #[cfg(target_os = "android")]
535    fn fallback_perl_paths_include_termux_locations() {
536        let paths = fallback_perl_paths();
537        let path_strings: Vec<String> =
538            paths.into_iter().map(|(path, _)| path.to_string_lossy().to_string()).collect();
539
540        assert!(
541            path_strings.iter().any(|p| p == "/data/data/com.termux/files/usr/bin/perl"),
542            "expected /data/data/com.termux/files/usr/bin/perl in fallback paths"
543        );
544        assert!(
545            path_strings.iter().any(|p| p == "/data/user/0/com.termux/files/usr/bin/perl"),
546            "expected /data/user/0/com.termux/files/usr/bin/perl in fallback paths"
547        );
548    }
549
550    #[test]
551    #[cfg(windows)]
552    fn windows_perl_rank_strawberry_is_best() {
553        let strawberry = std::path::Path::new(r"C:\Strawberry\perl\bin\perl.exe");
554        let msys = std::path::Path::new(r"C:\Program Files\Git\usr\bin\perl.exe");
555        assert!(
556            windows_perl_rank(strawberry) < windows_perl_rank(msys),
557            "Strawberry should rank better than msys perl"
558        );
559    }
560
561    #[test]
562    #[cfg(windows)]
563    fn windows_perl_rank_activestate_beats_msys() {
564        let active = std::path::Path::new(r"C:\Perl64\bin\perl.exe");
565        let msys = std::path::Path::new(r"C:\Program Files\Git\usr\bin\perl.exe");
566        assert!(
567            windows_perl_rank(active) < windows_perl_rank(msys),
568            "ActiveState should rank better than msys perl"
569        );
570    }
571
572    /// Verify that USERPROFILE and PREFIX changes produce different cache keys.
573    ///
574    /// On Windows, HOME is typically absent and home_dir() falls back to
575    /// USERPROFILE to resolve the perlbrew/plenv default root.  The PREFIX
576    /// variable is used by the Termux path resolver.  Both must appear in the
577    /// cache key so that a changed user profile or Termux prefix invalidates
578    /// a stale cached result.
579    #[test]
580    fn discovery_cache_key_contains_userprofile_and_prefix() {
581        // Synthetic keys that differ only in userprofile or prefix must differ.
582        let base = "cfg=;path=;perlbrew_perl=;perlbrew_root=;plenv_root=;plenv_version=;home=;userprofile=;prefix=";
583        let with_profile = "cfg=;path=;perlbrew_perl=;perlbrew_root=;plenv_root=;plenv_version=;home=;userprofile=C_Users_alice;prefix=";
584        let with_prefix = "cfg=;path=;perlbrew_perl=;perlbrew_root=;plenv_root=;plenv_version=;home=;userprofile=;prefix=/data/data/com.termux/files/usr";
585        assert_ne!(base, with_profile, "key must differ when userprofile changes");
586        assert_ne!(base, with_prefix, "key must differ when prefix changes");
587        assert!(base.contains("userprofile="), "key must contain userprofile= field");
588        assert!(base.contains("prefix="), "key must contain prefix= field");
589    }
590}