Skip to main content

perl_lsp/execute_command/
provider.rs

1//! Backend command implementations for run/debug/test and analyzer actions.
2
3use crate::perl_critic::{BuiltInAnalyzer, CriticAnalyzer, CriticConfig};
4#[cfg(not(target_arch = "wasm32"))]
5use perl_lsp_rs_core::config::PerlOracleEnv;
6use perl_lsp_rs_core::config::WorkspaceConfig;
7use perl_lsp_rs_core::providers::{
8    ProviderDecisionConfidence, ProviderDecisionCopyablePayload, ProviderDecisionExplanation,
9    ProviderDecisionFactSource, ProviderDecisionFallback, ProviderDecisionFreshness,
10    ProviderDecisionOutcome, ProviderDecisionProvider, ProviderDecisionReason,
11    ProviderDecisionRequestPosition, format_provider_decision_explanation,
12};
13use serde::Deserialize;
14use serde_json::{Value, json};
15use std::borrow::Cow;
16#[cfg(windows)]
17use std::ffi::OsString;
18#[cfg(windows)]
19use std::os::windows::ffi::{OsStrExt, OsStringExt};
20#[cfg(windows)]
21use std::path::{Component, Prefix};
22use std::path::{Path, PathBuf};
23use std::process::Command;
24use url::Url;
25
26/// Strip the Windows extended-length path prefix (`\\?\`) before passing a path
27/// to an external command such as `perl`, `prove`, or `yath`.
28///
29/// On Windows, `Path::canonicalize` returns paths prefixed with `\\?\`, which is
30/// understood by Win32 APIs but not by external programs (e.g. `perl.exe`).  This
31/// helper strips that prefix so the resulting path is usable as a command-line
32/// argument.  On non-Windows platforms the function is a no-op identity.
33///
34/// Two prefix forms are handled:
35/// - `\\?\C:\...`         (local drive) → `C:\...`
36/// - `\\?\UNC\server\...` (network UNC) → `\\server\...`
37///
38/// The UNC form requires special treatment: stripping `\\?\` alone would leave
39/// `UNC\server\...` which is not a valid path.  Instead we replace `\\?\UNC\`
40/// with `\\` so the result is a conventional UNC path (`\\server\share\...`).
41#[cfg(windows)]
42pub(crate) fn normalize_path_for_external_command(path: &Path) -> PathBuf {
43    let mut components = path.components();
44    let Some(Component::Prefix(prefix_component)) = components.next() else {
45        return path.to_path_buf();
46    };
47
48    match prefix_component.kind() {
49        Prefix::VerbatimDisk(drive) => {
50            let mut normalized = PathBuf::from(format!("{}:", char::from(drive)));
51            normalized.push(components.as_path());
52            normalized
53        }
54        Prefix::VerbatimUNC(server, share) => {
55            let mut wide = vec![b'\\' as u16, b'\\' as u16];
56            wide.extend(server.encode_wide());
57            wide.push(b'\\' as u16);
58            wide.extend(share.encode_wide());
59
60            let mut normalized = PathBuf::from(OsString::from_wide(&wide));
61            normalized.push(components.as_path());
62            normalized
63        }
64        _ => path.to_path_buf(),
65    }
66}
67
68#[cfg(not(windows))]
69pub(crate) fn normalize_path_for_external_command(path: &Path) -> PathBuf {
70    path.to_path_buf()
71}
72
73/// Execute command provider implementing the LSP executeCommand method.
74pub struct ExecuteCommandProvider {
75    workspace_roots: Vec<PathBuf>,
76    workspace_config: Option<WorkspaceConfig>,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub(crate) enum TestRunner {
81    Yath,
82    Prove,
83    Perl,
84}
85
86#[derive(Debug, Deserialize)]
87struct ExplainProviderDecisionRequest {
88    provider: ProviderDecisionProvider,
89    #[serde(default)]
90    receipt_id: Option<String>,
91    #[serde(default)]
92    scenario: Option<String>,
93    #[serde(default)]
94    request_receipt: Option<Value>,
95    #[serde(default)]
96    request_position: Option<ProviderDecisionRequestPosition>,
97}
98
99impl Default for ExecuteCommandProvider {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105// Private helpers for PerlOracleEnv subprocess isolation.
106impl ExecuteCommandProvider {
107    /// Build a `Command` for a Perl subprocess using `PerlOracleEnv`.
108    ///
109    /// The `file_path` is used only to derive a `cwd` (its parent directory).
110    /// Callers must append the actual Perl arguments after this call.
111    #[cfg(not(target_arch = "wasm32"))]
112    fn perl_command_for(&self, file_path: &Path) -> Result<Command, String> {
113        let cwd = file_path
114            .parent()
115            .map(Path::to_path_buf)
116            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
117        let Some(config) = self.workspace_config.as_ref() else {
118            return Err(Self::unresolved_execute_command_perl_error(file_path));
119        };
120        let Some(oracle) = PerlOracleEnv::for_execute_command(config, cwd) else {
121            return Err(Self::unresolved_execute_command_perl_error(file_path));
122        };
123        Ok(oracle.into_command())
124    }
125
126    #[cfg(target_arch = "wasm32")]
127    fn perl_command_for(&self, file_path: &Path) -> Result<Command, String> {
128        Err(Self::unresolved_execute_command_perl_error(file_path))
129    }
130
131    fn unresolved_execute_command_perl_error(file_path: &Path) -> String {
132        format!(
133            "Cannot run Perl command for '{}': Perl binary could not be resolved from `perl.path` or PATH. Configure `perl.path` to an explicit Perl executable; refusing ambient fallback.",
134            file_path.display()
135        )
136    }
137}
138
139impl ExecuteCommandProvider {
140    /// Create a new execute command provider.
141    pub fn new() -> Self {
142        Self { workspace_roots: Vec::new(), workspace_config: None }
143    }
144
145    /// Create a provider with workspace root enforcement.
146    pub fn with_workspace_roots(workspace_roots: Vec<PathBuf>) -> Self {
147        Self { workspace_roots, workspace_config: None }
148    }
149
150    /// Attach a workspace configuration to enable PerlOracleEnv isolation for
151    /// Perl subprocess calls (`perl.runFile`, `perl.runTestSub`).
152    ///
153    /// `run_file` and `run_test_sub` use `PerlOracleEnv::for_execute_command`
154    /// instead of a bare `Command::new("perl")`, applying the
155    /// deny-all-ambient env policy.
156    pub fn with_workspace_config(mut self, config: WorkspaceConfig) -> Self {
157        self.workspace_config = Some(config);
158        self
159    }
160
161    /// Execute a supported command with validated JSON arguments.
162    pub fn execute_command(&self, command: &str, arguments: Vec<Value>) -> Result<Value, String> {
163        match command {
164            "perl.runTests" => {
165                let file_path = self.resolve_path_from_args(&arguments)?;
166                self.run_tests(&file_path)
167            }
168            "perl.runFile" => {
169                let file_path = self.resolve_path_from_args(&arguments)?;
170                self.run_file(&file_path)
171            }
172            "perl.runTestSub" => {
173                let file_path = self.resolve_path_from_args(&arguments)?;
174                let sub_name = arguments
175                    .get(1)
176                    .and_then(|v| v.as_str())
177                    .ok_or_else(|| "Missing subroutine name argument".to_string())?;
178                self.run_test_sub(&file_path, sub_name)
179            }
180            "perl.debugTests" | "perl.debugFile" | "perl.debugTest" => {
181                let file_path = self.resolve_path_from_args(&arguments)?;
182                self.debug_tests(&file_path)
183            }
184            "perl.runTest" | "perl.runTestFile" => {
185                let file_path = self.resolve_path_from_args(&arguments)?;
186                self.run_tests(&file_path)
187            }
188            "perl.runSubtest" => {
189                let file_path = self.resolve_path_from_args(&arguments)?;
190                let sub_name = arguments
191                    .get(1)
192                    .and_then(|v| v.as_str())
193                    .ok_or_else(|| "Missing subroutine name argument".to_string())?;
194                self.run_test_sub(&file_path, sub_name)
195            }
196            "perl.runCritic" => self.run_critic_secure(&arguments),
197            "perl.goToTest" => {
198                let file_path = self.resolve_path_from_args(&arguments)?;
199                Ok(self.go_to_test(&file_path))
200            }
201            "perl.goToImplementation" => {
202                let file_path = self.resolve_path_from_args(&arguments)?;
203                Ok(self.go_to_implementation(&file_path))
204            }
205            "perl.workspaceTrustReport" => {
206                Err("perl.workspaceTrustReport requires the live LSP runtime state".to_string())
207            }
208            "perl.previewSafeDelete" => {
209                Err("perl.previewSafeDelete requires the live LSP runtime workspace index"
210                    .to_string())
211            }
212            "perl.safeDeleteSymbol" => {
213                Err("perl.safeDeleteSymbol requires the live LSP runtime workspace index"
214                    .to_string())
215            }
216            "perl.previewPackageRename" => {
217                Err("perl.previewPackageRename requires the live LSP runtime workspace index"
218                    .to_string())
219            }
220            "perl.explainMissingModuleLookup" => {
221                Err("perl.explainMissingModuleLookup requires the live LSP runtime module-resolution state"
222                    .to_string())
223            }
224            "perl.explainProviderDecision" => self.explain_provider_decision(&arguments),
225            _ => Err(format!("Unknown command: {}", command)),
226        }
227    }
228
229    fn explain_provider_decision(&self, arguments: &[Value]) -> Result<Value, String> {
230        let request_value = arguments
231            .first()
232            .ok_or_else(|| "Missing explain-provider-decision argument".to_string())?;
233        let request: ExplainProviderDecisionRequest = serde_json::from_value(request_value.clone())
234            .map_err(|error| format!("Invalid explain-provider-decision argument: {error}"))?;
235
236        let mut explanation = default_provider_decision_explanation(request.provider);
237
238        if let Some(receipt_id) = request.receipt_id {
239            explanation = explanation.with_receipt_id(receipt_id);
240        }
241        if let Some(scenario) = request.scenario {
242            explanation = explanation.with_scenario(scenario);
243        }
244        if let Some(request_receipt) = request.request_receipt {
245            if !request_receipt.is_object() {
246                return Err(
247                    "Invalid explain-provider-decision argument: request_receipt must be an object"
248                        .to_string(),
249                );
250            }
251            explanation = explanation.with_request_receipt(request_receipt);
252        }
253
254        let request_position = request.request_position;
255        let user_message = format_provider_decision_explanation(&explanation);
256        explanation = explanation.with_user_message(user_message);
257        let copyable_payload = ProviderDecisionCopyablePayload::from_explanation(
258            &explanation,
259            env!("CARGO_PKG_VERSION"),
260            workspace_root_class(&self.workspace_roots),
261            workspace_root_hash(&self.workspace_roots),
262            request_position,
263            provider_support_tier_link(explanation.provider),
264        );
265        explanation = explanation.with_copyable_payload(copyable_payload);
266
267        serde_json::to_value(explanation)
268            .map_err(|error| format!("Failed to serialize provider decision explanation: {error}"))
269    }
270
271    pub(crate) fn run_tests(&self, file_path: &Path) -> Result<Value, String> {
272        let file_path_str = file_path.to_string_lossy();
273        let is_test_file = self.is_test_file(&file_path_str);
274        let runner = select_test_runner(
275            is_test_file,
276            self.command_exists("yath"),
277            self.command_exists("prove"),
278        );
279        let ext_path = normalize_path_for_external_command(file_path);
280
281        match runner {
282            TestRunner::Yath => self.run_tests_with_yath_fallback(&ext_path),
283            TestRunner::Prove => self.run_tests_with_prove_fallback(&ext_path),
284            TestRunner::Perl => self.run_tests_with_perl(&ext_path, "perl"),
285        }
286    }
287
288    fn run_tests_with_yath_fallback(&self, ext_path: &Path) -> Result<Value, String> {
289        match self.run_test_command("yath", ext_path) {
290            Ok(value) => Ok(value),
291            Err(yath_error) => {
292                if self.command_exists("prove") {
293                    match self.run_test_command("prove", ext_path) {
294                        Ok(value) => Ok(value),
295                        Err(prove_error) => match self.run_test_command("perl", ext_path) {
296                            Ok(value) => Ok(value),
297                            Err(perl_error) => Ok(self.format_command_launch_failure(
298                                "yath",
299                                format!(
300                                    "Failed to run yath: {yath_error}; prove fallback also failed: {prove_error}; perl fallback also failed: {perl_error}"
301                                ),
302                            )),
303                        },
304                    }
305                } else {
306                    match self.run_test_command("perl", ext_path) {
307                        Ok(value) => Ok(value),
308                        Err(perl_error) => Ok(self.format_command_launch_failure(
309                            "yath",
310                            format!(
311                                "Failed to run yath: {yath_error}; perl fallback also failed: {perl_error}"
312                            ),
313                        )),
314                    }
315                }
316            }
317        }
318    }
319
320    fn run_tests_with_prove_fallback(&self, ext_path: &Path) -> Result<Value, String> {
321        match self.run_test_command("prove", ext_path) {
322            Ok(value) => Ok(value),
323            Err(prove_error) => match self.run_test_command("perl", ext_path) {
324                Ok(value) => Ok(value),
325                Err(perl_error) => Ok(self.format_command_launch_failure(
326                    "prove",
327                    format!(
328                        "Failed to run prove: {prove_error}; perl fallback also failed: {perl_error}"
329                    ),
330                )),
331            },
332        }
333    }
334
335    fn run_tests_with_perl(&self, ext_path: &Path, command_name: &str) -> Result<Value, String> {
336        match self.run_test_command("perl", ext_path) {
337            Ok(value) => Ok(value),
338            Err(error) => Ok(self.format_command_launch_failure(
339                command_name,
340                format!("Failed to run {command_name}: {error}"),
341            )),
342        }
343    }
344
345    pub(crate) fn run_test_command(&self, command: &str, ext_path: &Path) -> Result<Value, String> {
346        // Resolve the bare program name to an absolute PATH-searched path before
347        // passing it to Command::new.  On Windows, Command::new(bare_name)
348        // triggers CreateProcess's CWD-first search — a planted perl.exe/yath.exe/
349        // prove.exe in the LSP workspace root would execute instead of the real
350        // tool (binary-planting RCE, #2764/#3028).  resolve_program fails closed
351        // when the tool is not found on any absolute PATH directory.
352        #[cfg(not(target_arch = "wasm32"))]
353        let resolved_command =
354            perl_subprocess_runtime::resolve_program(command).map_err(|e| e.to_string())?;
355        #[cfg(target_arch = "wasm32")]
356        let resolved_command = command.to_string();
357
358        let mut cmd = Command::new(&resolved_command);
359        if command == "perl" {
360            cmd.arg("--").arg(ext_path.as_os_str());
361        } else {
362            cmd.arg("-v").arg("--").arg(ext_path.as_os_str());
363        }
364
365        crate::util::run_command_with_timeout(cmd, 30)
366            .map(|result| self.format_command_result(result, Some(("command", command.into()))))
367            .map_err(|error| error.to_string())
368    }
369
370    pub(crate) fn run_test_sub(&self, file_path: &Path, sub_name: &str) -> Result<Value, String> {
371        let perl_code = r#"
372            my ($file, $sub) = @ARGV;
373            do $file;
374            if (defined &$sub) {
375                no strict 'refs';
376                &$sub();
377            } else {
378                die "Subroutine $sub not found";
379            }
380        "#;
381        let ext_path = normalize_path_for_external_command(file_path);
382
383        let mut perl_cmd = self.perl_command_for(file_path)?;
384        perl_cmd.arg("-e").arg(perl_code).arg("--").arg(ext_path.as_os_str()).arg(sub_name);
385        match crate::util::run_command_with_timeout(perl_cmd, 30) {
386            Ok(result) => {
387                Ok(self.format_command_result(result, Some(("subroutine", sub_name.into()))))
388            }
389            Err(error) => Ok(self.format_command_launch_failure(
390                "perl",
391                format!("Failed to run test subroutine: {error}"),
392            )),
393        }
394    }
395
396    pub(crate) fn run_file(&self, file_path: &Path) -> Result<Value, String> {
397        let ext_path = normalize_path_for_external_command(file_path);
398        let mut perl_cmd = self.perl_command_for(file_path)?;
399        perl_cmd.arg("--").arg(ext_path.as_os_str());
400        match crate::util::run_command_with_timeout(perl_cmd, 30) {
401            Ok(result) => Ok(self.format_command_result(result, None)),
402            Err(error) => {
403                Ok(self
404                    .format_command_launch_failure("perl", format!("Failed to run file: {error}")))
405            }
406        }
407    }
408
409    fn debug_tests(&self, file_path: &Path) -> Result<Value, String> {
410        let file_path_str = file_path.to_string_lossy();
411        Ok(json!({
412            "success": false,
413            "output": format!("Debug mode not yet implemented for {}", file_path_str),
414            "error": Some("Debugging support coming soon".to_string())
415        }))
416    }
417
418    fn run_critic_secure(&self, arguments: &[Value]) -> Result<Value, String> {
419        let canonical_path = match self.resolve_path_from_args(arguments) {
420            Ok(path) => path,
421            Err(e) => {
422                if e.contains("Missing file path argument") {
423                    return Err(e);
424                }
425
426                if e.contains("File not found")
427                    || e.contains("does not exist")
428                    || e.contains("No such file or directory")
429                    || e.contains("Failed to canonicalize")
430                {
431                    let error_message = if e.contains("Failed to canonicalize") {
432                        if let Some(start) = e.find('\'') {
433                            if let Some(end) = e[start + 1..].find('\'') {
434                                let path = &e[start + 1..start + 1 + end];
435                                format!("File not found: {}", path)
436                            } else {
437                                "File not found".to_string()
438                            }
439                        } else {
440                            "File not found".to_string()
441                        }
442                    } else {
443                        e.clone()
444                    };
445                    return Ok(self.format_critic_error(error_message, "none"));
446                }
447
448                if e.contains("Path traversal")
449                    || e.contains("outside workspace")
450                    || e.contains("Argument too long")
451                {
452                    return Err(format!("Path resolution failed: {}", e));
453                }
454
455                return Ok(self.format_critic_error(e, "none"));
456            }
457        };
458
459        if command_exists("perlcritic") {
460            if let Ok(result) = self.run_external_critic(&canonical_path) {
461                return Ok(result);
462            }
463        }
464
465        self.run_builtin_critic(&canonical_path)
466    }
467
468    #[deprecated(since = "0.8.9", note = "Use run_critic_secure for secure path resolution")]
469    #[allow(dead_code)]
470    #[allow(deprecated)]
471    pub(crate) fn run_critic(&self, file_path: &str) -> Result<Value, String> {
472        let normalized_path = self.normalize_file_path(file_path);
473        let path = Path::new(normalized_path.as_ref());
474
475        if !path.exists() {
476            return Ok(self.format_critic_error(
477                format!("File not found: {}", normalized_path.as_ref()),
478                "none",
479            ));
480        }
481
482        if command_exists("perlcritic") {
483            if let Ok(result) = self.run_external_critic(path) {
484                return Ok(result);
485            }
486        }
487
488        self.run_builtin_critic(path)
489    }
490
491    fn run_external_critic(&self, file_path: &Path) -> Result<Value, String> {
492        let config = CriticConfig { severity: 3, verbose: true, ..Default::default() };
493        let mut analyzer = CriticAnalyzer::with_os_runtime(config);
494
495        match analyzer.analyze_file(file_path) {
496            Ok(violations) => {
497                let formatted_violations: Vec<_> = violations
498                    .iter()
499                    .map(|v| {
500                        self.format_violation(
501                            &v.policy,
502                            &v.description,
503                            &v.explanation,
504                            v.severity as u8,
505                            (v.range.start.line + 1) as usize,
506                            (v.range.start.column + 1) as usize,
507                            &v.file,
508                        )
509                    })
510                    .collect();
511
512                Ok(json!({
513                    "status": "success",
514                    "violations": formatted_violations,
515                    "violationCount": formatted_violations.len(),
516                    "analyzerUsed": "external"
517                }))
518            }
519            Err(e) => Err(format!("External perlcritic failed: {}", e)),
520        }
521    }
522
523    pub(crate) fn run_builtin_critic(&self, file_path: &Path) -> Result<Value, String> {
524        use crate::Parser;
525
526        let content = std::fs::read_to_string(file_path)
527            .map_err(|e| format!("Failed to read file: {}", e))?;
528
529        let code_text = perl_parser::util::code_slice(&content);
530        let mut parser = Parser::new(code_text);
531
532        let (ast, parse_error) = match parser.parse() {
533            Ok(ast) => (ast, None),
534            Err(error) => {
535                let message = error.to_string();
536                (
537                    crate::ast::Node::new(
538                        crate::ast::NodeKind::Error {
539                            message,
540                            expected: vec![],
541                            found: None,
542                            partial: None,
543                        },
544                        crate::ast::SourceLocation { start: 0, end: code_text.len() },
545                    ),
546                    Some(error),
547                )
548            }
549        };
550
551        let analyzer = BuiltInAnalyzer::new();
552        let mut all_violations = analyzer.analyze(&ast, code_text);
553        if let Some(error) = parse_error {
554            all_violations.push(self.create_syntax_error_violation(&error, code_text, file_path));
555        }
556
557        let formatted_violations: Vec<_> = all_violations
558            .iter()
559            .map(|v| {
560                self.format_violation(
561                    &v.policy,
562                    &v.description,
563                    &v.explanation,
564                    v.severity as u8,
565                    (v.range.start.line + 1) as usize,
566                    (v.range.start.column + 1) as usize,
567                    &file_path.to_string_lossy(),
568                )
569            })
570            .collect();
571
572        Ok(json!({
573            "status": "success",
574            "violations": formatted_violations,
575            "violationCount": formatted_violations.len(),
576            "analyzerUsed": "builtin"
577        }))
578    }
579
580    pub(crate) fn is_test_file(&self, file_path: &str) -> bool {
581        file_path.ends_with(".t") || file_path.contains("/t/") || file_path.contains("test")
582    }
583
584    /// Convert a Perl module name to a test file stem.
585    ///
586    /// `Foo::Bar` -> `foo-bar` (canonical hyphen form used by many CPAN distributions)
587    pub fn module_to_test_stem(&self, module_name: &str) -> String {
588        module_name.replace("::", "-").to_lowercase()
589    }
590
591    /// Infer a module name from a `lib/` path component.
592    ///
593    /// `/path/to/lib/Foo/Bar.pm` -> `Foo::Bar`
594    fn pm_path_to_module(&self, pm_path: &std::path::Path) -> Option<String> {
595        // Walk up from the file to find the `lib` directory anchor.
596        let components: Vec<_> = pm_path.components().collect();
597        let lib_pos = components.iter().rposition(|c| c.as_os_str() == "lib")?;
598        let after_lib: Vec<_> = components[lib_pos + 1..].to_vec();
599        if after_lib.is_empty() {
600            return None;
601        }
602        let mut parts = Vec::new();
603        for c in &after_lib {
604            let s = c.as_os_str().to_string_lossy();
605            let part = if s.ends_with(".pm") {
606                s.trim_end_matches(".pm").to_string()
607            } else {
608                s.to_string()
609            };
610            parts.push(part);
611        }
612        Some(parts.join("::"))
613    }
614
615    /// Navigate from a `.pm` implementation file to its companion test file.
616    ///
617    /// Probes (in order):
618    ///   1. `t/<stem>.t`  where stem is the hyphen-lowercased module name
619    ///   2. `t/<stem>.t`  where stem uses underscores instead of hyphens
620    ///   3. `t/<leaf>.t`  where leaf is just the last module component lowercased
621    ///   4. `t/lib/<Foo/Bar>.t`  where the relative path mirrors the module hierarchy
622    pub(crate) fn go_to_test(&self, pm_path: &std::path::Path) -> Value {
623        let module_name = match self.pm_path_to_module(pm_path) {
624            Some(m) => m,
625            None => {
626                return json!({ "found": false, "candidates": [] });
627            }
628        };
629
630        // Find workspace root: walk up until we find a `lib` or `t` sibling.
631        let workspace_root = match self.find_workspace_root(pm_path) {
632            Some(r) => r,
633            None => {
634                return json!({ "found": false, "candidates": [] });
635            }
636        };
637
638        let t_dir = workspace_root.join("t");
639        let stem_hyphen = self.module_to_test_stem(&module_name);
640        let stem_underscore = stem_hyphen.replace('-', "_");
641        // Leaf component without unwrap: split always produces at least one element.
642        let leaf = match module_name.rsplit_once("::") {
643            Some((_, last)) => last.to_lowercase(),
644            None => module_name.to_lowercase(),
645        };
646        // Mirror path under t/lib/ (e.g. Foo::Bar::Baz -> t/lib/Foo/Bar/Baz.t)
647        let mirror_rel = module_name.replace("::", std::path::MAIN_SEPARATOR_STR) + ".t";
648
649        let candidates = [
650            t_dir.join(format!("{stem_hyphen}.t")),
651            t_dir.join(format!("{stem_underscore}.t")),
652            t_dir.join(format!("{leaf}.t")),
653            t_dir.join("lib").join(&mirror_rel),
654        ];
655
656        for candidate in &candidates {
657            if candidate.exists() {
658                return json!({
659                    "found": true,
660                    "path": candidate.to_string_lossy(),
661                    "module": module_name,
662                });
663            }
664        }
665
666        let candidate_strings: Vec<_> =
667            candidates.iter().map(|p| p.to_string_lossy().to_string()).collect();
668        json!({ "found": false, "candidates": candidate_strings })
669    }
670
671    /// Navigate from a test file to the first local module it uses.
672    ///
673    /// Scans the test file for `use Foo::Bar;` statements (skipping well-known
674    /// CPAN pragmas and test modules), then maps the first match to
675    /// `lib/Foo/Bar.pm` relative to the workspace root.
676    pub(crate) fn go_to_implementation(&self, test_path: &std::path::Path) -> Value {
677        let content = match std::fs::read_to_string(test_path) {
678            Ok(c) => c,
679            Err(_) => return json!({ "found": false }),
680        };
681
682        let workspace_root = match self.find_workspace_root(test_path) {
683            Some(r) => r,
684            None => return json!({ "found": false }),
685        };
686
687        // Well-known modules that are NOT local implementations (exact matches).
688        const SKIP_MODULES: &[&str] = &[
689            // Core pragmas
690            "strict",
691            "warnings",
692            "utf8",
693            "feature",
694            "parent",
695            "base",
696            "vars",
697            "constant",
698            "overload",
699            "ok",
700            // Core modules
701            "Carp",
702            "Exporter",
703            "Scalar::Util",
704            "List::Util",
705            "Hash::Util",
706            "POSIX",
707            "Data::Dumper",
708            "Storable",
709            "Encode",
710            "Cwd",
711            "FindBin",
712            "File::Basename",
713            "File::Path",
714            "File::Spec",
715            "File::Find",
716            "File::Temp",
717            "File::Copy",
718            "IO::File",
719            "IO::Handle",
720            "IO::Select",
721            "Getopt::Long",
722            "Getopt::Std",
723            // OO frameworks
724            "Moo",
725            "Moose",
726            "Mouse",
727            // Test modules (exact)
728            "Test::More",
729            "Test::Simple",
730            "Test::Builder",
731            "Test::Deep",
732            "Test::Exception",
733            "Test::Warn",
734            "Test::Fatal",
735            "Test::MockObject",
736            "Test::MockModule",
737            "Test::Output",
738            "Test::Differences",
739            "Test::Class",
740            "Test::Pod",
741            "Test::Pod::Coverage",
742            "Try::Tiny",
743        ];
744
745        // Module name-space prefixes whose entire hierarchy is non-local.
746        // Any `use` statement whose module starts with one of these prefixes
747        // will be skipped without needing every sub-module listed.
748        const SKIP_PREFIXES: &[&str] = &[
749            "Test2::",  // Test2::V0, Test2::Bundle::*, Test2::Tools::*
750            "MooseX::", // MooseX::Types, MooseX::Declare, etc.
751            "MouseX::",
752            "Moo::Role",
753            "Moose::Role",
754            "Types::",     // Types::Standard, Types::Path::Tiny, etc.
755            "namespace::", // namespace::autoclean, namespace::clean
756            "Sub::",       // Sub::Exporter, Sub::Quote, etc.
757            "Class::MOP",
758            "DBIx::", // DBIx::Class, DBIx::Connector
759            "DBI",
760            "LWP::",
761            "HTTP::",
762            "URI::",
763            "JSON::",
764            "YAML::",
765            "XML::",
766            "DateTime::",
767            "Path::Tiny",
768            "Path::Class",
769        ];
770
771        for line in content.lines() {
772            let trimmed = line.trim();
773            // Match `use Module::Name;` or `use Module::Name qw(...);`
774            if !trimmed.starts_with("use ") {
775                continue;
776            }
777            let after_use = trimmed.trim_start_matches("use ").trim();
778            // Extract the module name (stop at first whitespace or semicolon)
779            let module_name: String =
780                after_use.chars().take_while(|c| c.is_alphanumeric() || *c == ':').collect();
781
782            if module_name.is_empty() {
783                continue;
784            }
785            // Skip version-only pragmas like `use 5.010;` or `use v5.10;`
786            if module_name.chars().next().is_some_and(|c| c.is_ascii_digit() || c == 'v') {
787                continue;
788            }
789            if SKIP_MODULES.contains(&module_name.as_str()) {
790                continue;
791            }
792            if SKIP_PREFIXES
793                .iter()
794                .any(|p| module_name.starts_with(p) || module_name == p.trim_end_matches("::"))
795            {
796                continue;
797            }
798
799            // Map Foo::Bar -> lib/Foo/Bar.pm
800            let rel_path = module_name.replace("::", std::path::MAIN_SEPARATOR_STR) + ".pm";
801            let candidate = workspace_root.join("lib").join(&rel_path);
802            if candidate.exists() {
803                return json!({
804                    "found": true,
805                    "path": candidate.to_string_lossy(),
806                    "module": module_name,
807                });
808            }
809        }
810
811        json!({ "found": false })
812    }
813
814    /// Find the workspace root by walking up from `path`.
815    ///
816    /// Preference order:
817    ///   1. Explicit workspace roots registered with the provider (normal LSP runtime path).
818    ///   2. Nearest ancestor that contains a Perl project marker (`Makefile.PL`,
819    ///      `Build.PL`, `cpanfile`, `dist.ini`, `META.json`, `META.yml`, `.git`).
820    ///   3. Nearest ancestor that contains either a `lib/` or `t/` child directory.
821    ///
822    /// This multi-tier strategy avoids accidentally picking up a distant ancestor
823    /// that happens to have a `lib/` or `t/` directory unrelated to the current project.
824    fn find_workspace_root(&self, path: &std::path::Path) -> Option<std::path::PathBuf> {
825        // Tier 1: explicit workspace roots registered with the provider.
826        if !self.workspace_roots.is_empty() {
827            let canonical_path = path.canonicalize().map_err(|e| {
828                tracing::debug!(path = %path.display(), error = %e, "workspace root: failed to canonicalize path");
829            }).ok();
830            for root in &self.workspace_roots {
831                let Ok(canonical_root) = root.canonicalize() else { continue };
832                if canonical_path.as_ref().is_some_and(|p| p.starts_with(&canonical_root)) {
833                    return Some(root.clone());
834                }
835            }
836        }
837
838        // Perl distribution marker files that indicate a project root.
839        const PROJECT_MARKERS: &[&str] =
840            &["Makefile.PL", "Build.PL", "cpanfile", "dist.ini", "META.json", "META.yml", ".git"];
841
842        let mut current = path.parent()?;
843
844        // Tier 2: walk up looking for a Perl project marker first.
845        let mut tier3_candidate: Option<std::path::PathBuf> = None;
846        loop {
847            // Check for definitive project markers.
848            if PROJECT_MARKERS.iter().any(|m| current.join(m).exists()) {
849                return Some(current.to_path_buf());
850            }
851            // Remember the first ancestor with lib/ or t/ for tier-3 fallback.
852            if tier3_candidate.is_none()
853                && (current.join("lib").is_dir() || current.join("t").is_dir())
854            {
855                tier3_candidate = Some(current.to_path_buf());
856            }
857            current = match current.parent() {
858                Some(p) => p,
859                None => break,
860            };
861        }
862
863        // Tier 3: fall back to the nearest lib/t ancestor found above.
864        tier3_candidate
865    }
866
867    pub(crate) fn format_command_result(
868        &self,
869        result: std::process::Output,
870        extra_field: Option<(&str, Value)>,
871    ) -> Value {
872        let output = String::from_utf8_lossy(&result.stdout);
873        let error = if !result.status.success() {
874            Some(String::from_utf8_lossy(&result.stderr).to_string())
875        } else {
876            None
877        };
878
879        let mut response = json!({
880            "success": result.status.success(),
881            "output": output.to_string(),
882            "error": error
883        });
884
885        if let Some((key, value)) = extra_field {
886            response[key] = value;
887        }
888
889        response
890    }
891
892    fn format_command_launch_failure(&self, command: &str, error: String) -> Value {
893        json!({
894            "success": false,
895            "output": String::new(),
896            "error": error,
897            "command": command
898        })
899    }
900
901    fn resolve_path_from_args(&self, arguments: &[Value]) -> Result<PathBuf, String> {
902        let raw_path = arguments
903            .first()
904            .and_then(|v| v.as_str())
905            .ok_or_else(|| "Missing file path argument".to_string())?;
906
907        const MAX_ARG_LENGTH: usize = 4096;
908        if raw_path.len() > MAX_ARG_LENGTH {
909            return Err(format!(
910                "Argument too long ({} bytes, max {})",
911                raw_path.len(),
912                MAX_ARG_LENGTH
913            ));
914        }
915
916        let path = if raw_path.starts_with("file://") {
917            crate::workspace_index::uri_to_fs_path(raw_path)
918                .ok_or_else(|| format!("Failed to parse file URI: {raw_path}"))?
919        } else {
920            PathBuf::from(raw_path)
921        };
922        let normalized_path = path.to_string_lossy();
923        if normalized_path.contains("..") {
924            return Err("Path traversal attempt detected: path contains '..' component".to_string());
925        }
926
927        let canonical_path = path
928            .canonicalize()
929            .map_err(|e| format!("Failed to canonicalize path '{}': {}", normalized_path, e))?;
930
931        let effective_roots: Vec<PathBuf> = if self.workspace_roots.is_empty() {
932            match std::env::current_dir() {
933                Ok(cwd) => vec![cwd],
934                Err(_) => {
935                    return Err(
936                        "No workspace roots configured and cannot determine working directory"
937                            .to_string(),
938                    );
939                }
940            }
941        } else {
942            self.workspace_roots.clone()
943        };
944
945        let allowed = effective_roots.iter().any(|workspace_root| {
946            workspace_root
947                .canonicalize()
948                .map(|canonical_root| canonical_path.starts_with(&canonical_root))
949                .unwrap_or(false)
950        });
951
952        if !allowed {
953            return Err(format!(
954                "Path traversal detected: {} is outside workspace boundaries",
955                canonical_path.display()
956            ));
957        }
958
959        if !canonical_path.exists() {
960            return Err(format!("File not found: {}", canonical_path.display()));
961        }
962
963        if !canonical_path.is_file() {
964            return Err(format!("Path is not a file: {}", canonical_path.display()));
965        }
966
967        std::fs::metadata(&canonical_path).map_err(|e| {
968            format!("Cannot read file metadata '{}': {}", canonical_path.display(), e)
969        })?;
970
971        Ok(canonical_path)
972    }
973
974    /// Resolve a debug file path using the same workspace security checks.
975    pub fn resolve_debug_file_path(&self, file_path: &str) -> Result<PathBuf, String> {
976        self.resolve_path_from_args(&[Value::String(file_path.to_string())])
977    }
978
979    #[deprecated(since = "0.8.9", note = "Use resolve_path_from_args for secure path resolution")]
980    #[allow(dead_code)]
981    pub(crate) fn normalize_file_path<'a>(&self, file_path: &'a str) -> Cow<'a, str> {
982        if !file_path.starts_with("file://") {
983            return Cow::Borrowed(file_path);
984        }
985
986        if let Ok(url) = Url::parse(file_path)
987            && let Ok(path) = url.to_file_path()
988        {
989            return Cow::Owned(path.to_string_lossy().into_owned());
990        }
991
992        Cow::Borrowed(file_path.strip_prefix("file://").unwrap_or(file_path))
993    }
994
995    pub(crate) fn format_violation(
996        &self,
997        policy: &str,
998        description: &str,
999        explanation: &str,
1000        severity: u8,
1001        line: usize,
1002        column: usize,
1003        file: &str,
1004    ) -> Value {
1005        json!({
1006            "policy": policy,
1007            "description": description,
1008            "explanation": explanation,
1009            "severity": severity,
1010            "line": line,
1011            "column": column,
1012            "file": file
1013        })
1014    }
1015
1016    pub(crate) fn format_critic_error(&self, error_message: String, analyzer_used: &str) -> Value {
1017        json!({
1018            "status": "error",
1019            "error": error_message,
1020            "violations": [],
1021            "violationCount": 0,
1022            "analyzerUsed": analyzer_used
1023        })
1024    }
1025
1026    fn create_syntax_error_violation(
1027        &self,
1028        error: &perl_parser::ParseError,
1029        _content: &str,
1030        file_path: &Path,
1031    ) -> crate::perl_critic::Violation {
1032        let error_msg = format!("{}", error);
1033        let (line, column) = (0, 0);
1034
1035        crate::perl_critic::Violation {
1036            policy: "Syntax::ParseError".to_string(),
1037            description: format!("Syntax error: {}", error_msg),
1038            explanation: "This code contains a syntax error that prevents parsing. Fix the syntax error before running additional checks.".to_string(),
1039            severity: crate::perl_critic::Severity::Brutal,
1040            range: crate::position::Range {
1041                start: crate::position::Position { byte: 0, line: line as u32, column: column as u32 },
1042                end: crate::position::Position { byte: 1, line: line as u32, column: (column + 1) as u32 },
1043            },
1044            file: file_path.to_string_lossy().to_string(),
1045        }
1046    }
1047
1048    pub(crate) fn command_exists(&self, command: &str) -> bool {
1049        // On Windows, spawning `Command::new("where")` is itself a bare-name call
1050        // subject to the same CWD-first CreateProcess RCE (#3028).  Use the
1051        // hardened PATH-only resolver instead — it already answers "is this tool
1052        // findable on an absolute PATH directory" without touching the CWD.
1053        //
1054        // On non-Windows, the resolver is a pass-through (returns Ok unchanged),
1055        // so we fall back to the `which` crate for the actual PATH search there.
1056        #[cfg(all(windows, not(target_arch = "wasm32")))]
1057        {
1058            perl_subprocess_runtime::resolve_program(command).is_ok()
1059        }
1060        #[cfg(all(not(windows), not(target_arch = "wasm32")))]
1061        {
1062            let mut cmd = Command::new("which");
1063            cmd.arg(command);
1064            crate::util::run_command_with_timeout(cmd, 2)
1065                .map(|output| output.status.success())
1066                .unwrap_or(false)
1067        }
1068        #[cfg(target_arch = "wasm32")]
1069        {
1070            false
1071        }
1072    }
1073}
1074
1075fn default_provider_decision_explanation(
1076    provider: ProviderDecisionProvider,
1077) -> ProviderDecisionExplanation {
1078    let (
1079        decision,
1080        reason,
1081        fact_source,
1082        confidence,
1083        freshness,
1084        dynamic_boundary,
1085        fallback,
1086        receipt_id,
1087        scenario,
1088    ) = match provider {
1089        ProviderDecisionProvider::Completion => (
1090            ProviderDecisionOutcome::Fallback,
1091            ProviderDecisionReason::FallbackPolicy,
1092            ProviderDecisionFactSource::CompilerFact,
1093            ProviderDecisionConfidence::High,
1094            ProviderDecisionFreshness::Fresh,
1095            false,
1096            ProviderDecisionFallback::LegacyProvider,
1097            Some("docs/project/status/provider_confidence_matrix.md#completion"),
1098            Some("ux_scenario_28_mojolicious_completion_ranking"),
1099        ),
1100        ProviderDecisionProvider::GotoDefinition => (
1101            ProviderDecisionOutcome::Acted,
1102            ProviderDecisionReason::SourceBackedHighConfidence,
1103            ProviderDecisionFactSource::CompilerFact,
1104            ProviderDecisionConfidence::High,
1105            ProviderDecisionFreshness::Fresh,
1106            false,
1107            ProviderDecisionFallback::None,
1108            Some("docs/project/status/provider_cutover.md#navigation-live-quality-dashboard"),
1109            Some("ux_scenario_30_mojolicious_navigation_quality"),
1110        ),
1111        ProviderDecisionProvider::TypeDefinition => (
1112            ProviderDecisionOutcome::Acted,
1113            ProviderDecisionReason::SourceBackedHighConfidence,
1114            ProviderDecisionFactSource::ParserSyntax,
1115            ProviderDecisionConfidence::High,
1116            ProviderDecisionFreshness::Fresh,
1117            false,
1118            ProviderDecisionFallback::None,
1119            Some("docs/project/status/provider_confidence_matrix.md#type-definition"),
1120            Some("type-definition-provider-decision-receipt"),
1121        ),
1122        ProviderDecisionProvider::References => (
1123            ProviderDecisionOutcome::Acted,
1124            ProviderDecisionReason::SourceBackedHighConfidence,
1125            ProviderDecisionFactSource::CompilerFact,
1126            ProviderDecisionConfidence::High,
1127            ProviderDecisionFreshness::Fresh,
1128            false,
1129            ProviderDecisionFallback::None,
1130            Some("docs/project/status/provider_cutover.md#navigation-live-quality-dashboard"),
1131            Some("ux_scenario_30_mojolicious_navigation_quality"),
1132        ),
1133        ProviderDecisionProvider::Hover => (
1134            ProviderDecisionOutcome::Fallback,
1135            ProviderDecisionReason::FallbackPolicy,
1136            ProviderDecisionFactSource::CompilerFact,
1137            ProviderDecisionConfidence::High,
1138            ProviderDecisionFreshness::Fresh,
1139            false,
1140            ProviderDecisionFallback::LegacyProvider,
1141            Some("docs/project/status/provider_confidence_matrix.md#hover"),
1142            Some("ux_scenario_29_mojolicious_hover_provenance"),
1143        ),
1144        ProviderDecisionProvider::Diagnostics => (
1145            ProviderDecisionOutcome::Fallback,
1146            ProviderDecisionReason::FallbackPolicy,
1147            ProviderDecisionFactSource::CompilerFact,
1148            ProviderDecisionConfidence::High,
1149            ProviderDecisionFreshness::Fresh,
1150            false,
1151            ProviderDecisionFallback::LegacyProvider,
1152            Some("docs/project/status/provider_confidence_matrix.md#diagnostics"),
1153            Some("ux_scenario_31_mojolicious_diagnostics_quality"),
1154        ),
1155        ProviderDecisionProvider::Rename => (
1156            ProviderDecisionOutcome::Fallback,
1157            ProviderDecisionReason::FallbackPolicy,
1158            ProviderDecisionFactSource::ParserSyntax,
1159            ProviderDecisionConfidence::High,
1160            ProviderDecisionFreshness::Fresh,
1161            false,
1162            ProviderDecisionFallback::LegacyProvider,
1163            Some("docs/project/status/provider_confidence_matrix.md#rename"),
1164            Some("ux_scenario_35_mojolicious_rename_unsafe_edit"),
1165        ),
1166        ProviderDecisionProvider::SafeDelete => (
1167            ProviderDecisionOutcome::Blocked,
1168            ProviderDecisionReason::UnsafeEditBlocked,
1169            ProviderDecisionFactSource::CompilerFact,
1170            ProviderDecisionConfidence::High,
1171            ProviderDecisionFreshness::Fresh,
1172            false,
1173            ProviderDecisionFallback::NoEdit,
1174            Some("docs/project/status/provider_confidence_matrix.md#safe-delete"),
1175            Some("realbaseline-safe-delete-imported-symbol"),
1176        ),
1177        ProviderDecisionProvider::WorkspaceSymbols => (
1178            ProviderDecisionOutcome::Acted,
1179            ProviderDecisionReason::SourceBackedHighConfidence,
1180            ProviderDecisionFactSource::CompilerFact,
1181            ProviderDecisionConfidence::High,
1182            ProviderDecisionFreshness::Fresh,
1183            false,
1184            ProviderDecisionFallback::None,
1185            Some("docs/project/status/provider_confidence_matrix.md#workspace-symbols"),
1186            Some("ux_scenario_33_mojolicious_workspace_symbol_noise"),
1187        ),
1188        ProviderDecisionProvider::DocumentSymbols => (
1189            ProviderDecisionOutcome::Acted,
1190            ProviderDecisionReason::SourceBackedHighConfidence,
1191            ProviderDecisionFactSource::ParserSyntax,
1192            ProviderDecisionConfidence::High,
1193            ProviderDecisionFreshness::Fresh,
1194            false,
1195            ProviderDecisionFallback::None,
1196            Some("docs/project/status/provider_confidence_matrix.md#document-symbols"),
1197            Some("ux_scenario_32_mojolicious_document_symbols_quality"),
1198        ),
1199        ProviderDecisionProvider::SemanticTokens => (
1200            ProviderDecisionOutcome::Shadowed,
1201            ProviderDecisionReason::ShadowOnly,
1202            ProviderDecisionFactSource::SemanticFact,
1203            ProviderDecisionConfidence::High,
1204            ProviderDecisionFreshness::Fresh,
1205            false,
1206            ProviderDecisionFallback::ShadowReceiptOnly,
1207            Some("docs/project/status/provider_confidence_matrix.md#semantic-tokens"),
1208            Some("ux_scenario_34_mojolicious_semantic_tokens_quality"),
1209        ),
1210        ProviderDecisionProvider::ModuleResolution => (
1211            ProviderDecisionOutcome::Acted,
1212            ProviderDecisionReason::SourceBackedHighConfidence,
1213            ProviderDecisionFactSource::SemanticFact,
1214            ProviderDecisionConfidence::High,
1215            ProviderDecisionFreshness::Fresh,
1216            false,
1217            ProviderDecisionFallback::None,
1218            Some("docs/project/status/module_resolution.md"),
1219            Some("ux_scenario_14_inc_conformance"),
1220        ),
1221        ProviderDecisionProvider::DapModulePaths => (
1222            ProviderDecisionOutcome::Acted,
1223            ProviderDecisionReason::SourceBackedHighConfidence,
1224            ProviderDecisionFactSource::SemanticFact,
1225            ProviderDecisionConfidence::High,
1226            ProviderDecisionFreshness::Fresh,
1227            false,
1228            ProviderDecisionFallback::None,
1229            Some("docs/development/PERL_ORACLE_RAIL.md"),
1230            Some("dap-module-resolution-smoke"),
1231        ),
1232        ProviderDecisionProvider::PerlSubprocess => (
1233            ProviderDecisionOutcome::Acted,
1234            ProviderDecisionReason::SourceBackedHighConfidence,
1235            ProviderDecisionFactSource::LegacyWorkspace,
1236            ProviderDecisionConfidence::High,
1237            ProviderDecisionFreshness::Fresh,
1238            false,
1239            ProviderDecisionFallback::None,
1240            Some("docs/architecture/perl-subprocess-seams.md"),
1241            Some("perl-oracle-env"),
1242        ),
1243        ProviderDecisionProvider::WorkspaceTrustReport => (
1244            // Report-only boundary (PLSP-SPEC-0016): the trust report aggregates
1245            // existing runtime state without driving live provider behavior, so it
1246            // is shadowed rather than acted, and must not promote support tiers.
1247            ProviderDecisionOutcome::Shadowed,
1248            ProviderDecisionReason::ShadowOnly,
1249            ProviderDecisionFactSource::LegacyWorkspace,
1250            ProviderDecisionConfidence::Low,
1251            ProviderDecisionFreshness::NotApplicable,
1252            false,
1253            ProviderDecisionFallback::ShadowReceiptOnly,
1254            Some("docs/project/status/SUPPORT_TIERS.md"),
1255            Some("workspace-trust-report-boundary"),
1256        ),
1257        ProviderDecisionProvider::Unknown => (
1258            ProviderDecisionOutcome::Fallback,
1259            ProviderDecisionReason::MissingFact,
1260            ProviderDecisionFactSource::Unknown,
1261            ProviderDecisionConfidence::Low,
1262            ProviderDecisionFreshness::Unknown,
1263            false,
1264            ProviderDecisionFallback::NoResult,
1265            None,
1266            None,
1267        ),
1268        _ => (
1269            ProviderDecisionOutcome::Fallback,
1270            ProviderDecisionReason::Unknown,
1271            ProviderDecisionFactSource::Unknown,
1272            ProviderDecisionConfidence::Low,
1273            ProviderDecisionFreshness::Unknown,
1274            false,
1275            ProviderDecisionFallback::NoResult,
1276            None,
1277            None,
1278        ),
1279    };
1280
1281    let mut explanation = ProviderDecisionExplanation::new(
1282        provider,
1283        decision,
1284        reason,
1285        fact_source,
1286        confidence,
1287        freshness,
1288        dynamic_boundary,
1289        fallback,
1290    );
1291    if let Some(receipt_id) = receipt_id {
1292        explanation = explanation.with_receipt_id(receipt_id);
1293    }
1294    if let Some(scenario) = scenario {
1295        explanation = explanation.with_scenario(scenario);
1296    }
1297    explanation
1298}
1299
1300fn workspace_root_class(workspace_roots: &[PathBuf]) -> &'static str {
1301    match workspace_roots.len() {
1302        0 => "none",
1303        1 => "single_root",
1304        _ => "multi_root",
1305    }
1306}
1307
1308fn workspace_root_hash(workspace_roots: &[PathBuf]) -> Option<String> {
1309    if workspace_roots.is_empty() {
1310        return None;
1311    }
1312
1313    let mut roots = workspace_roots
1314        .iter()
1315        .map(|root| root.to_string_lossy().replace('\\', "/"))
1316        .collect::<Vec<_>>();
1317    roots.sort();
1318    Some(format!("{:x}", md5::compute(roots.join("\n"))))
1319}
1320
1321fn provider_support_tier_link(_provider: ProviderDecisionProvider) -> &'static str {
1322    "docs/project/status/SUPPORT_TIERS.md#claim-rows"
1323}
1324
1325pub(crate) fn select_test_runner(
1326    is_test_file: bool,
1327    yath_available: bool,
1328    prove_available: bool,
1329) -> TestRunner {
1330    if !is_test_file {
1331        TestRunner::Perl
1332    } else if yath_available {
1333        TestRunner::Yath
1334    } else if prove_available {
1335        TestRunner::Prove
1336    } else {
1337        TestRunner::Perl
1338    }
1339}
1340
1341/// Check whether a command exists in the current PATH.
1342pub fn command_exists(command: &str) -> bool {
1343    which::which(command).is_ok()
1344}
1345
1346/// Return the supported executeCommand identifiers.
1347pub fn get_supported_commands() -> Vec<String> {
1348    // Keep in sync with perl_lsp_rs_core::protocol::capabilities::get_supported_commands
1349    vec![
1350        "perl.runTests".to_string(),
1351        "perl.runFile".to_string(),
1352        "perl.runTestSub".to_string(),
1353        "perl.runCritic".to_string(),
1354        "perl.runTest".to_string(),
1355        "perl.runTestFile".to_string(),
1356        "perl.runSubtest".to_string(),
1357        "perl.debugFile".to_string(),
1358        "perl.debugTest".to_string(),
1359        "perl.goToTest".to_string(),
1360        "perl.goToImplementation".to_string(),
1361        "perl.explainProviderDecision".to_string(),
1362        "perl.workspaceTrustReport".to_string(),
1363        "perl.previewSafeDelete".to_string(),
1364        "perl.safeDeleteSymbol".to_string(),
1365        "perl.previewPackageRename".to_string(),
1366        "perl.explainMissingModuleLookup".to_string(),
1367    ]
1368}
1369
1370#[cfg(test)]
1371mod normalize_path_tests;