1use 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#[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
73pub 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
105impl ExecuteCommandProvider {
107 #[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 pub fn new() -> Self {
142 Self { workspace_roots: Vec::new(), workspace_config: None }
143 }
144
145 pub fn with_workspace_roots(workspace_roots: Vec<PathBuf>) -> Self {
147 Self { workspace_roots, workspace_config: None }
148 }
149
150 pub fn with_workspace_config(mut self, config: WorkspaceConfig) -> Self {
157 self.workspace_config = Some(config);
158 self
159 }
160
161 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 #[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 pub fn module_to_test_stem(&self, module_name: &str) -> String {
588 module_name.replace("::", "-").to_lowercase()
589 }
590
591 fn pm_path_to_module(&self, pm_path: &std::path::Path) -> Option<String> {
595 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 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 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 let leaf = match module_name.rsplit_once("::") {
643 Some((_, last)) => last.to_lowercase(),
644 None => module_name.to_lowercase(),
645 };
646 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 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 const SKIP_MODULES: &[&str] = &[
689 "strict",
691 "warnings",
692 "utf8",
693 "feature",
694 "parent",
695 "base",
696 "vars",
697 "constant",
698 "overload",
699 "ok",
700 "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 "Moo",
725 "Moose",
726 "Mouse",
727 "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 const SKIP_PREFIXES: &[&str] = &[
749 "Test2::", "MooseX::", "MouseX::",
752 "Moo::Role",
753 "Moose::Role",
754 "Types::", "namespace::", "Sub::", "Class::MOP",
758 "DBIx::", "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 if !trimmed.starts_with("use ") {
775 continue;
776 }
777 let after_use = trimmed.trim_start_matches("use ").trim();
778 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 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 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 fn find_workspace_root(&self, path: &std::path::Path) -> Option<std::path::PathBuf> {
825 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 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 let mut tier3_candidate: Option<std::path::PathBuf> = None;
846 loop {
847 if PROJECT_MARKERS.iter().any(|m| current.join(m).exists()) {
849 return Some(current.to_path_buf());
850 }
851 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 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 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 #[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 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
1341pub fn command_exists(command: &str) -> bool {
1343 which::which(command).is_ok()
1344}
1345
1346pub fn get_supported_commands() -> Vec<String> {
1348 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;