1pub use crate::command_args::format_command_args;
10
11pub 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#[derive(Debug, Clone, PartialEq)]
36pub enum PerlInterpreterResult {
37 ConfiguredPath(PathBuf),
39 FoundOnPath(PathBuf),
41 FoundViaFallback {
43 path: PathBuf,
45 label: String,
47 },
48 NotFound {
50 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 let home = env::var("HOME").unwrap_or_default();
67 let userprofile = env::var("USERPROFILE").unwrap_or_default();
68 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#[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
95fn 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
114fn 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
124fn 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 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 vec![
163 (PathBuf::from("/usr/bin/perl"), "system Perl"),
164 (PathBuf::from("/usr/local/bin/perl"), "local Perl"),
165 ]
166 }
167}
168
169pub fn find_perl_interpreter(configured_path: Option<&str>) -> PerlInterpreterResult {
180 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 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 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 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
221pub 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)]
266fn 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
272pub 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
316pub 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("ed_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 #[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 #[test]
580 fn discovery_cache_key_contains_userprofile_and_prefix() {
581 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}