perl_lsp_tooling/perl_critic/
analyzer.rs1#[cfg(not(feature = "lsp-compat"))]
2use crate::perl_critic::ViolationSummary;
3#[cfg(feature = "lsp-compat")]
4use crate::perl_critic::perlcritic_quick_fix;
5use crate::perl_critic::{CriticConfig, QuickFix, Severity, Violation};
6use perl_lsp_critic_parser::parse_perlcritic_output;
7use perl_parser_core::position::{Position, Range};
8use perl_subprocess_runtime::SubprocessRuntime;
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::Arc;
12
13#[cfg(feature = "lsp-compat")]
14use lsp_types;
15
16pub struct CriticAnalyzer {
18 config: CriticConfig,
20 cache: HashMap<String, Vec<Violation>>,
22 runtime: Arc<dyn SubprocessRuntime>,
24}
25
26impl CriticAnalyzer {
27 pub fn new(config: CriticConfig, runtime: Arc<dyn SubprocessRuntime>) -> Self {
29 Self { config, cache: HashMap::new(), runtime }
30 }
31
32 #[cfg(not(target_arch = "wasm32"))]
34 pub fn with_os_runtime(config: CriticConfig) -> Self {
35 use perl_subprocess_runtime::OsSubprocessRuntime;
36 let timeout = config.timeout_secs;
37 Self::new(config, Arc::new(OsSubprocessRuntime::with_timeout(timeout)))
38 }
39
40 pub fn analyze_file(&mut self, file_path: &Path) -> Result<Vec<Violation>, String> {
42 let path_str = file_path.to_string_lossy().to_string();
43 if let Some(cached) = self.cache.get(&path_str) {
44 return Ok(cached.clone());
45 }
46
47 let args = build_perlcritic_args(&self.config, &path_str);
48 let args_refs: Vec<&str> = args.iter().map(String::as_str).collect();
49 let output =
50 self.runtime.run_command("perlcritic", &args_refs, None).map_err(|e| e.message)?;
51 let violations = self.parse_output(&output.stdout, &path_str)?;
52 self.cache.insert(path_str, violations.clone());
53 Ok(violations)
54 }
55
56 fn parse_output(&self, output: &[u8], file_path: &str) -> Result<Vec<Violation>, String> {
58 let output_str = String::from_utf8_lossy(output);
59 Ok(parse_perlcritic_output(&output_str)
60 .into_iter()
61 .map(|parsed| Violation {
62 policy: parsed.policy.clone(),
63 description: parsed.message,
64 explanation: self.get_policy_explanation(&parsed.policy),
65 severity: Severity::from_number(parsed.severity),
66 range: Range {
67 start: Position { byte: 0, line: parsed.line - 1, column: parsed.column - 1 },
68 end: Position { byte: 0, line: parsed.line - 1, column: parsed.column },
69 },
70 file: file_path.to_string(),
71 })
72 .collect())
73 }
74
75 fn get_policy_explanation(&self, policy: &str) -> String {
77 format!("See perldoc Perl::Critic::Policy::{policy}")
78 }
79
80 pub fn invalidate_cache(&mut self, file_path: &str) {
82 self.cache.remove(file_path);
83 }
84
85 #[cfg(feature = "lsp-compat")]
87 pub fn to_diagnostics(&self, violations: &[Violation]) -> Vec<lsp_types::Diagnostic> {
88 violations
89 .iter()
90 .map(|v| {
91 let lsp_range = lsp_types::Range::new(
92 lsp_types::Position::new(v.range.start.line, v.range.start.column),
93 lsp_types::Position::new(v.range.end.line, v.range.end.column),
94 );
95 lsp_types::Diagnostic {
96 range: lsp_range,
97 severity: Some(v.severity.to_diagnostic_severity()),
98 code: Some(lsp_types::NumberOrString::String(v.policy.clone())),
99 source: Some("perlcritic".to_string()),
100 message: v.description.clone(),
101 related_information: None,
102 tags: None,
103 code_description: None,
104 data: None,
105 }
106 })
107 .collect()
108 }
109
110 #[cfg(not(feature = "lsp-compat"))]
112 pub fn to_violation_summaries(&self, violations: &[Violation]) -> Vec<ViolationSummary> {
113 violations
114 .iter()
115 .map(|v| ViolationSummary {
116 policy: v.policy.clone(),
117 description: v.description.clone(),
118 severity: v.severity.to_severity_level(),
119 line: v.range.start.line as usize,
120 })
121 .collect()
122 }
123
124 #[cfg(feature = "lsp-compat")]
126 pub fn get_quick_fix(&self, violation: &Violation, _content: &str) -> Option<QuickFix> {
127 perlcritic_quick_fix(violation)
128 }
129}
130
131fn build_perlcritic_args(config: &CriticConfig, path_str: &str) -> Vec<String> {
132 let mut args = vec![format!("--severity={}", config.severity)];
133
134 if let Some(profile) = &config.profile {
135 args.push(format!("--profile={profile}"));
136 }
137 if let Some(theme) = &config.theme {
138 args.push(format!("--theme={theme}"));
139 }
140 for policy in &config.include {
141 args.push(format!("--include={policy}"));
142 }
143 for policy in &config.exclude {
144 args.push(format!("--exclude={policy}"));
145 }
146
147 args.push("--verbose=%f:%l:%c:%s:%p:%m\\n".to_string());
148 args.push("--".to_string());
149 args.push(path_str.to_string());
150 args
151}