Skip to main content

vtcode_core/code/code_quality/linting/
mod.rs

1use crate::code::code_quality::config::{LintConfig, LintSeverity};
2use serde_json::Value;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6/// Shared utilities for lint output parsing
7mod parser_utils {
8    use crate::code::code_quality::config::LintSeverity;
9    use serde_json::Value;
10
11    /// Extract string value from JSON, return as owned String or default
12    pub fn get_str(value: &Value, key: &str, default: &str) -> String {
13        value.get(key).and_then(Value::as_str).unwrap_or(default).to_string()
14    }
15
16    /// Extract u64 and convert to usize
17    pub fn get_u64(value: &Value, key: &str) -> usize {
18        value.get(key).and_then(Value::as_u64).unwrap_or(0) as usize
19    }
20
21    /// Parse severity level from string
22    pub fn parse_severity_error(level: &str) -> LintSeverity {
23        match level {
24            "error" | "fatal" => LintSeverity::Error,
25            "warning" => LintSeverity::Warning,
26            _ => LintSeverity::Info,
27        }
28    }
29
30    /// Parse severity level from numeric code (1=warning, 2=error)
31    pub fn parse_severity_numeric(code: u64) -> LintSeverity {
32        match code {
33            2 => LintSeverity::Error,
34            1 => LintSeverity::Warning,
35            _ => LintSeverity::Info,
36        }
37    }
38}
39
40/// Individual lint finding
41#[derive(Debug, Clone)]
42pub struct LintFinding {
43    pub file_path: PathBuf,
44    pub line: usize,
45    pub column: usize,
46    pub severity: LintSeverity,
47    pub rule: String,
48    pub message: String,
49    pub suggestion: Option<String>,
50}
51
52/// Result of linting operation
53#[derive(Debug, Clone)]
54pub struct LintResult {
55    pub success: bool,
56    pub findings: Vec<LintFinding>,
57    pub error_message: Option<String>,
58    pub tool_used: String,
59}
60
61/// Linting orchestrator that manages multiple linters
62pub struct LintingOrchestrator {
63    configs: Vec<LintConfig>,
64}
65
66impl Default for LintingOrchestrator {
67    fn default() -> Self {
68        Self::new()
69    }
70}
71
72impl LintingOrchestrator {
73    pub fn new() -> Self {
74        let mut orchestrator = Self { configs: Vec::new() };
75
76        // Register default linters
77        orchestrator.register(LintConfig::clippy());
78        orchestrator.register(LintConfig::eslint());
79        orchestrator.register(LintConfig::pylint());
80
81        orchestrator
82    }
83
84    /// Register a linting configuration
85    pub fn register(&mut self, config: LintConfig) {
86        self.configs.push(config);
87    }
88
89    /// Lint a file or directory
90    pub async fn lint_path(&self, path: &Path) -> Vec<LintResult> {
91        let mut results = Vec::new();
92
93        for config in &self.configs {
94            if config.enabled
95                && let Some(result) = self.run_linter(config, path).await
96            {
97                results.push(result);
98            }
99        }
100
101        results
102    }
103
104    async fn run_linter(&self, config: &LintConfig, path: &Path) -> Option<LintResult> {
105        // Execute the linting tool off the async executor —
106        // `Command::output()` blocks until the subprocess exits. See the
107        // `# Blocking` docs in `src/agent/runloop/git.rs` for the pattern.
108        let config = config.clone();
109        let config_for_parse = config.clone();
110        let path_for_cmd = path.to_path_buf();
111        match tokio::task::spawn_blocking(move || {
112            let mut cmd = Command::new(&config.command[0]);
113            for arg in &config.args {
114                cmd.arg(arg);
115            }
116            cmd.arg(&path_for_cmd);
117            cmd.output()
118        })
119        .await
120        {
121            Ok(Ok(output)) => {
122                if output.status.success() {
123                    // Parse the lint output based on the tool
124                    let findings = self.parse_lint_output(&config_for_parse, &output.stdout, path);
125                    Some(LintResult {
126                        success: true,
127                        findings,
128                        error_message: None,
129                        tool_used: config.tool_name.clone(),
130                    })
131                } else {
132                    let error_msg = String::from_utf8_lossy(&output.stderr).into_owned();
133                    Some(LintResult {
134                        success: false,
135                        findings: Vec::new(),
136                        error_message: Some(error_msg),
137                        tool_used: config.tool_name.clone(),
138                    })
139                }
140            }
141            Ok(Err(e)) => Some(LintResult {
142                success: false,
143                findings: Vec::new(),
144                error_message: Some(format!("Failed to execute {}: {}", config.tool_name, e)),
145                tool_used: config.tool_name.clone(),
146            }),
147            Err(e) => Some(LintResult {
148                success: false,
149                findings: Vec::new(),
150                error_message: Some(format!("Linter task failed: {e}")),
151                tool_used: config.tool_name.clone(),
152            }),
153        }
154    }
155
156    fn parse_lint_output(&self, config: &LintConfig, output: &[u8], base_path: &Path) -> Vec<LintFinding> {
157        let output_str = String::from_utf8_lossy(output);
158
159        // Parse based on the tool used
160        match config.tool_name.as_str() {
161            "clippy" => self.parse_clippy_output(&output_str, base_path),
162            "eslint" => self.parse_eslint_output(&output_str, base_path),
163            "pylint" => self.parse_pylint_output(&output_str, base_path),
164            _ => Vec::new(), // Unknown tool, return empty findings
165        }
166    }
167
168    fn parse_clippy_output(&self, output: &str, base_path: &Path) -> Vec<LintFinding> {
169        let mut findings = Vec::new();
170        for line in output.lines() {
171            if let Ok(json) = serde_json::from_str::<Value>(line)
172                && json.get("reason").and_then(Value::as_str) == Some("compiler-message")
173                && let Some(message) = json.get("message")
174                && let Some(spans) = message.get("spans").and_then(Value::as_array)
175            {
176                for span in spans {
177                    if span.get("is_primary").and_then(Value::as_bool) == Some(true) {
178                        let file = span.get("file_name").and_then(Value::as_str).unwrap_or("");
179                        let line_num = span.get("line_start").and_then(Value::as_u64).unwrap_or(0) as usize;
180                        let column = span.get("column_start").and_then(Value::as_u64).unwrap_or(0) as usize;
181                        let rule = parser_utils::get_str(
182                            message.get("code").and_then(|c| c.get("code")).unwrap_or(&Value::Null),
183                            "code",
184                            "",
185                        );
186                        let severity = parser_utils::parse_severity_error(&parser_utils::get_str(message, "level", ""));
187                        let msg = parser_utils::get_str(message, "message", "");
188                        findings.push(LintFinding {
189                            file_path: base_path.join(file),
190                            line: line_num,
191                            column,
192                            severity,
193                            rule,
194                            message: msg,
195                            suggestion: None,
196                        });
197                    }
198                }
199            }
200        }
201        findings
202    }
203
204    fn parse_eslint_output(&self, output: &str, base_path: &Path) -> Vec<LintFinding> {
205        let mut findings = Vec::new();
206        if let Ok(json) = serde_json::from_str::<Value>(output)
207            && let Some(arr) = json.as_array()
208        {
209            for file in arr {
210                let path = file.get("filePath").and_then(Value::as_str).unwrap_or("");
211                if let Some(messages) = file.get("messages").and_then(Value::as_array) {
212                    for m in messages {
213                        let line = parser_utils::get_u64(m, "line");
214                        let column = parser_utils::get_u64(m, "column");
215                        let rule = parser_utils::get_str(m, "ruleId", "");
216                        let severity = parser_utils::parse_severity_numeric(
217                            m.get("severity").and_then(Value::as_u64).unwrap_or(0),
218                        );
219                        let msg = parser_utils::get_str(m, "message", "");
220                        findings.push(LintFinding {
221                            file_path: base_path.join(path),
222                            line,
223                            column,
224                            severity,
225                            rule,
226                            message: msg,
227                            suggestion: m.get("fix").map(|_| "fix available".to_owned()),
228                        });
229                    }
230                }
231            }
232        }
233        findings
234    }
235
236    fn parse_pylint_output(&self, output: &str, base_path: &Path) -> Vec<LintFinding> {
237        let mut findings = Vec::new();
238        if let Ok(json) = serde_json::from_str::<Value>(output)
239            && let Some(arr) = json.as_array()
240        {
241            for item in arr {
242                let path = parser_utils::get_str(item, "path", "");
243                let line = parser_utils::get_u64(item, "line");
244                let column = parser_utils::get_u64(item, "column");
245                let rule = parser_utils::get_str(item, "symbol", "");
246                let msg = parser_utils::get_str(item, "message", "");
247                let severity = parser_utils::parse_severity_error(&parser_utils::get_str(item, "type", ""));
248                findings.push(LintFinding {
249                    file_path: base_path.join(path),
250                    line,
251                    column,
252                    severity,
253                    rule,
254                    message: msg,
255                    suggestion: None,
256                });
257            }
258        }
259        findings
260    }
261}