Skip to main content

ryo_mutations/clippy/
diagnostic.rs

1//! Clippy diagnostic types and parsing
2
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5
6/// Applicability level of a Clippy suggestion
7///
8/// Determines whether a fix can be automatically applied.
9/// See: <https://doc.rust-lang.org/nightly/nightly-rustc/rustc_errors/enum.Applicability.html>
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12#[derive(Default)]
13pub enum Applicability {
14    /// The suggestion is definitely correct and can be applied automatically.
15    MachineApplicable,
16    /// The suggestion may be correct but needs human verification.
17    MaybeIncorrect,
18    /// The suggestion contains placeholders that need manual editing.
19    HasPlaceholders,
20    /// The applicability is unknown.
21    #[default]
22    Unspecified,
23}
24
25impl Applicability {
26    /// Returns true if this suggestion can be safely auto-applied
27    pub fn is_auto_applicable(&self) -> bool {
28        matches!(self, Applicability::MachineApplicable)
29    }
30}
31
32/// Category of Clippy lint
33///
34/// See: <https://doc.rust-lang.org/stable/clippy/lints.html>
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum LintCategory {
38    /// Lints that detect outright wrong or useless code (deny by default)
39    Correctness,
40    /// Lints that detect suspicious code patterns
41    Suspicious,
42    /// Lints suggesting simpler code
43    Complexity,
44    /// Lints suggesting performance improvements
45    Perf,
46    /// Lints about idiomatic Rust style
47    Style,
48    /// Opinionated lints that may have false positives
49    Pedantic,
50    /// Lints that may lint against reasonable code
51    Restriction,
52    /// Lints for cargo manifest issues
53    Cargo,
54    /// Nursery lints (experimental)
55    Nursery,
56}
57
58impl LintCategory {
59    /// Get the clippy lint group name
60    pub fn as_str(&self) -> &'static str {
61        match self {
62            LintCategory::Correctness => "correctness",
63            LintCategory::Suspicious => "suspicious",
64            LintCategory::Complexity => "complexity",
65            LintCategory::Perf => "perf",
66            LintCategory::Style => "style",
67            LintCategory::Pedantic => "pedantic",
68            LintCategory::Restriction => "restriction",
69            LintCategory::Cargo => "cargo",
70            LintCategory::Nursery => "nursery",
71        }
72    }
73
74    /// Parse from lint name prefix (e.g., "clippy::complexity")
75    pub fn from_lint_name(_name: &str) -> Option<Self> {
76        // Extract category from lint documentation or use heuristics
77        // In practice, this would require a lookup table
78        None // Placeholder - TODO: implement lookup table
79    }
80}
81
82/// Span within a source file
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84pub struct Span {
85    /// File path
86    pub file_name: PathBuf,
87    /// Start byte offset
88    pub byte_start: usize,
89    /// End byte offset
90    pub byte_end: usize,
91    /// Start line (1-indexed)
92    pub line_start: usize,
93    /// End line (1-indexed)
94    pub line_end: usize,
95    /// Start column (1-indexed)
96    pub column_start: usize,
97    /// End column (1-indexed)
98    pub column_end: usize,
99}
100
101impl Span {
102    /// Create a span from byte offsets
103    pub fn from_bytes(file_name: impl Into<PathBuf>, start: usize, end: usize) -> Self {
104        Self {
105            file_name: file_name.into(),
106            byte_start: start,
107            byte_end: end,
108            line_start: 0,
109            line_end: 0,
110            column_start: 0,
111            column_end: 0,
112        }
113    }
114
115    /// Byte range for slicing
116    pub fn byte_range(&self) -> std::ops::Range<usize> {
117        self.byte_start..self.byte_end
118    }
119}
120
121/// A suggestion from Clippy for fixing a lint
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct Suggestion {
124    /// The span to replace
125    pub span: Span,
126    /// The replacement text
127    pub replacement: String,
128    /// Applicability level
129    pub applicability: Applicability,
130    /// Human-readable message
131    pub message: String,
132}
133
134impl Suggestion {
135    /// Create a new suggestion
136    pub fn new(span: Span, replacement: impl Into<String>) -> Self {
137        Self {
138            span,
139            replacement: replacement.into(),
140            applicability: Applicability::Unspecified,
141            message: String::new(),
142        }
143    }
144
145    /// Set applicability level
146    pub fn with_applicability(mut self, applicability: Applicability) -> Self {
147        self.applicability = applicability;
148        self
149    }
150
151    /// Set message
152    pub fn with_message(mut self, message: impl Into<String>) -> Self {
153        self.message = message.into();
154        self
155    }
156}
157
158/// A diagnostic message from Clippy
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct ClippyDiagnostic {
161    /// The lint name (e.g., "clippy::bool_comparison")
162    pub lint_name: String,
163    /// Severity level
164    pub level: DiagnosticLevel,
165    /// Primary message
166    pub message: String,
167    /// Primary span (where the issue was detected)
168    pub span: Option<Span>,
169    /// Suggested fixes
170    pub suggestions: Vec<Suggestion>,
171    /// Additional notes
172    pub notes: Vec<String>,
173}
174
175/// Diagnostic severity level
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "lowercase")]
178pub enum DiagnosticLevel {
179    Error,
180    Warning,
181    Note,
182    Help,
183}
184
185impl ClippyDiagnostic {
186    /// Create a new diagnostic
187    pub fn new(lint_name: impl Into<String>, message: impl Into<String>) -> Self {
188        Self {
189            lint_name: lint_name.into(),
190            level: DiagnosticLevel::Warning,
191            message: message.into(),
192            span: None,
193            suggestions: Vec::new(),
194            notes: Vec::new(),
195        }
196    }
197
198    /// Get the short lint name (without "clippy::" prefix)
199    pub fn short_lint_name(&self) -> &str {
200        self.lint_name
201            .strip_prefix("clippy::")
202            .unwrap_or(&self.lint_name)
203    }
204
205    /// Check if any suggestion is machine-applicable
206    pub fn has_auto_fix(&self) -> bool {
207        self.suggestions
208            .iter()
209            .any(|s| s.applicability.is_auto_applicable())
210    }
211
212    /// Get the first machine-applicable suggestion
213    pub fn auto_fix(&self) -> Option<&Suggestion> {
214        self.suggestions
215            .iter()
216            .find(|s| s.applicability.is_auto_applicable())
217    }
218
219    /// Convert to a Mutation if possible
220    ///
221    /// This attempts to create an appropriate Mutation based on the lint type.
222    /// Returns None if the lint doesn't have a corresponding Mutation implementation.
223    pub fn to_mutation(&self) -> Option<Box<dyn crate::Mutation>> {
224        use super::lints;
225        use crate::idiom::*;
226
227        match self.lint_name.as_str() {
228            lints::BOOL_COMPARISON => Some(Box::new(BoolSimplifyMutation::new())),
229            lints::COLLAPSIBLE_IF => Some(Box::new(CollapsibleIfMutation::new())),
230            lints::COMPARISON_TO_EMPTY => Some(Box::new(ComparisonToMethodMutation::new())),
231            lints::ASSIGN_OP_PATTERN => Some(Box::new(AssignOpMutation::new())),
232            lints::CLONE_ON_COPY => Some(Box::new(CloneOnCopyMutation::new())),
233            lints::REDUNDANT_CLOSURE => Some(Box::new(RedundantClosureMutation::new())),
234            // Add more mappings as mutations are implemented
235            _ => None,
236        }
237    }
238}
239
240/// Parse Clippy's JSON output format
241///
242/// Clippy outputs diagnostics in the cargo JSON message format when run with
243/// `--message-format=json`.
244///
245/// # Example
246///
247/// ```rust,ignore
248/// use ryo_mutations::clippy::parse_clippy_output;
249///
250/// let output = r#"{"reason":"compiler-message","message":{"code":{"code":"clippy::bool_comparison"},...}}"#;
251/// let diagnostics = parse_clippy_output(output)?;
252/// ```
253pub fn parse_clippy_output(json_lines: &str) -> Result<Vec<ClippyDiagnostic>, serde_json::Error> {
254    let mut diagnostics = Vec::new();
255
256    for line in json_lines.lines() {
257        if line.trim().is_empty() {
258            continue;
259        }
260
261        // Parse the cargo message envelope
262        if let Ok(CargoMessage::CompilerMessage { message }) =
263            serde_json::from_str::<CargoMessage>(line)
264        {
265            if let Some(diag) = convert_compiler_message(message) {
266                diagnostics.push(diag);
267            }
268        }
269    }
270
271    Ok(diagnostics)
272}
273
274/// Cargo's JSON message envelope
275#[derive(Debug, Deserialize)]
276#[serde(tag = "reason")]
277enum CargoMessage {
278    #[serde(rename = "compiler-message")]
279    CompilerMessage { message: CompilerMessage },
280    #[serde(other)]
281    Other,
282}
283
284/// Compiler message from cargo
285#[derive(Debug, Deserialize)]
286struct CompilerMessage {
287    code: Option<DiagnosticCode>,
288    level: String,
289    message: String,
290    spans: Vec<CompilerSpan>,
291    children: Vec<CompilerMessage>,
292}
293
294#[derive(Debug, Deserialize)]
295struct DiagnosticCode {
296    code: String,
297}
298
299#[derive(Debug, Deserialize)]
300struct CompilerSpan {
301    file_name: String,
302    byte_start: usize,
303    byte_end: usize,
304    line_start: usize,
305    line_end: usize,
306    column_start: usize,
307    column_end: usize,
308    is_primary: bool,
309    suggested_replacement: Option<String>,
310    suggestion_applicability: Option<String>,
311}
312
313fn convert_compiler_message(msg: CompilerMessage) -> Option<ClippyDiagnostic> {
314    // Only process clippy lints
315    let code = msg.code.as_ref()?;
316    if !code.code.starts_with("clippy::") {
317        return None;
318    }
319
320    let level = match msg.level.as_str() {
321        "error" => DiagnosticLevel::Error,
322        "warning" => DiagnosticLevel::Warning,
323        "note" => DiagnosticLevel::Note,
324        "help" => DiagnosticLevel::Help,
325        _ => DiagnosticLevel::Warning,
326    };
327
328    let primary_span = msg.spans.iter().find(|s| s.is_primary).map(|s| Span {
329        file_name: PathBuf::from(&s.file_name),
330        byte_start: s.byte_start,
331        byte_end: s.byte_end,
332        line_start: s.line_start,
333        line_end: s.line_end,
334        column_start: s.column_start,
335        column_end: s.column_end,
336    });
337
338    // Extract suggestions from spans and children
339    let mut suggestions = Vec::new();
340    for span in &msg.spans {
341        if let Some(ref replacement) = span.suggested_replacement {
342            let applicability = span
343                .suggestion_applicability
344                .as_ref()
345                .map(|s| match s.as_str() {
346                    "MachineApplicable" => Applicability::MachineApplicable,
347                    "MaybeIncorrect" => Applicability::MaybeIncorrect,
348                    "HasPlaceholders" => Applicability::HasPlaceholders,
349                    _ => Applicability::Unspecified,
350                })
351                .unwrap_or(Applicability::Unspecified);
352
353            suggestions.push(Suggestion {
354                span: Span {
355                    file_name: PathBuf::from(&span.file_name),
356                    byte_start: span.byte_start,
357                    byte_end: span.byte_end,
358                    line_start: span.line_start,
359                    line_end: span.line_end,
360                    column_start: span.column_start,
361                    column_end: span.column_end,
362                },
363                replacement: replacement.clone(),
364                applicability,
365                message: String::new(),
366            });
367        }
368    }
369
370    // Extract notes from children
371    let notes: Vec<String> = msg
372        .children
373        .iter()
374        .filter(|c| c.level == "note" || c.level == "help")
375        .map(|c| c.message.clone())
376        .collect();
377
378    Some(ClippyDiagnostic {
379        lint_name: code.code.clone(),
380        level,
381        message: msg.message,
382        span: primary_span,
383        suggestions,
384        notes,
385    })
386}
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391
392    #[test]
393    fn test_applicability_auto_applicable() {
394        assert!(Applicability::MachineApplicable.is_auto_applicable());
395        assert!(!Applicability::MaybeIncorrect.is_auto_applicable());
396        assert!(!Applicability::HasPlaceholders.is_auto_applicable());
397        assert!(!Applicability::Unspecified.is_auto_applicable());
398    }
399
400    #[test]
401    fn test_diagnostic_short_lint_name() {
402        let diag = ClippyDiagnostic::new("clippy::bool_comparison", "test");
403        assert_eq!(diag.short_lint_name(), "bool_comparison");
404
405        let diag2 = ClippyDiagnostic::new("other_lint", "test");
406        assert_eq!(diag2.short_lint_name(), "other_lint");
407    }
408
409    #[test]
410    fn test_parse_clippy_output_empty() {
411        let result = parse_clippy_output("");
412        assert!(result.is_ok());
413        assert!(result.unwrap().is_empty());
414    }
415}