Skip to main content

cargo_quality/
analyzer.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4//! Core analyzer trait and types for code quality analysis.
5//!
6//! This module defines the fundamental abstractions for building code
7//! analyzers:
8//! - `Analyzer` trait that all analyzers must implement
9//! - `Issue` struct representing detected problems
10//! - `AnalysisResult` struct containing analysis outcomes
11
12use std::ops::Range;
13
14use masterror::AppResult;
15use syn::File;
16
17/// A single text replacement over the original source.
18///
19/// Fixes are expressed as byte-range edits against the untouched source text so
20/// that everything outside the edited range — comments, blank lines, and the
21/// author's formatting — is preserved. This mirrors how `rustfmt` and
22/// `rust-analyzer` apply changes, rather than reprinting the AST (which loses
23/// comments and reformats the whole file).
24///
25/// # Examples
26///
27/// ```
28/// use cargo_quality::analyzer::TextEdit;
29///
30/// let edit = TextEdit {
31///     range:       0..9,
32///     replacement: String::new()
33/// };
34/// assert_eq!(edit.range.len(), 9);
35/// ```
36#[derive(Debug, Clone, Default, PartialEq, Eq)]
37pub struct TextEdit {
38    /// Byte range in the original source to replace
39    pub range:       Range<usize>,
40    /// Text to substitute for the range (empty to delete)
41    pub replacement: String
42}
43
44/// A `use` statement insertion anchored to a specific byte offset.
45///
46/// The offset addresses the module that must receive the import — the top of
47/// the file for top-level rewrites, or the first item of an inline module for
48/// rewrites inside it — so the inserted name is always in scope at the rewrite
49/// site.
50///
51/// # Examples
52///
53/// ```
54/// use cargo_quality::analyzer::ImportEdit;
55///
56/// let import = ImportEdit {
57///     offset:    0,
58///     statement: "use std::fs::read;".to_string()
59/// };
60/// assert!(import.statement.starts_with("use "));
61/// ```
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct ImportEdit {
64    /// Byte offset in the original source at which to insert the statement
65    pub offset:    usize,
66    /// The `use` statement to insert, without a trailing newline
67    pub statement: String
68}
69
70/// A single fixable change: one source edit plus any import it requires.
71///
72/// Both the `fix` command and the diff/interactive flow are built from
73/// suggestions, so applying a change is identical everywhere: the [`edit`] is
74/// spliced into the source and the [`import`], if any, is inserted once per
75/// target offset (imports are deduplicated across the applied suggestions).
76///
77/// [`edit`]: Suggestion::edit
78/// [`import`]: Suggestion::import
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Suggestion {
81    /// The byte-range edit that performs the rewrite
82    pub edit:   TextEdit,
83    /// A `use` statement the rewrite depends on, if any
84    pub import: Option<ImportEdit>
85}
86
87/// Type of fix that can be applied to resolve an issue.
88///
89/// Represents different kinds of automatic fixes that analyzers can provide.
90///
91/// # Examples
92///
93/// ```
94/// use cargo_quality::analyzer::Fix;
95///
96/// let simple_fix = Fix::Simple("let x = 42;".to_string());
97/// assert!(simple_fix.is_available());
98/// assert_eq!(simple_fix.as_simple(), Some("let x = 42;"));
99///
100/// let import_fix = Fix::WithImport {
101///     import:      "use std::fs::read;".to_string(),
102///     pattern:     "std::fs::read".to_string(),
103///     replacement: "read".to_string()
104/// };
105/// assert!(import_fix.is_available());
106/// assert_eq!(
107///     import_fix.as_import(),
108///     Some(("use std::fs::read;", "std::fs::read", "read"))
109/// );
110/// ```
111#[derive(Debug, Clone, PartialEq)]
112pub enum Fix {
113    /// No automatic fix available
114    None,
115
116    /// Simple line replacement
117    ///
118    /// Replace the entire line with the provided string.
119    ///
120    /// Note: Reserved for future analyzers that need simple line replacements.
121    #[allow(dead_code)]
122    Simple(String),
123
124    /// Fix requiring import addition
125    ///
126    /// Adds an import statement and replaces the line.
127    WithImport {
128        /// Import statement to add (e.g., "use std::fs::read_to_string;")
129        import:      String,
130        /// Pattern to find in original line (e.g., "std::fs::read_to_string")
131        pattern:     String,
132        /// Replacement for the pattern (e.g., "read_to_string")
133        replacement: String
134    }
135}
136
137impl Fix {
138    /// Checks if fix is available.
139    ///
140    /// # Returns
141    ///
142    /// `true` if fix can be applied automatically
143    #[inline]
144    pub fn is_available(&self) -> bool {
145        !matches!(self, Fix::None)
146    }
147
148    /// Returns simple replacement string if available.
149    ///
150    /// # Returns
151    ///
152    /// Option<&str> - Replacement string for simple fixes
153    #[inline]
154    pub fn as_simple(&self) -> Option<&str> {
155        match self {
156            Fix::Simple(s) => Some(s.as_str()),
157            _ => None
158        }
159    }
160
161    /// Returns import, pattern, and replacement for import-based fixes.
162    ///
163    /// # Returns
164    ///
165    /// Option<(&str, &str, &str)> - (import, pattern, replacement) tuple
166    #[inline]
167    pub fn as_import(&self) -> Option<(&str, &str, &str)> {
168        match self {
169            Fix::WithImport {
170                import,
171                pattern,
172                replacement
173            } => Some((import.as_str(), pattern.as_str(), replacement.as_str())),
174            _ => None
175        }
176    }
177}
178
179/// Location and description of a single finding in a source file.
180///
181/// Shared by every issue type so that reporting code works with one shape.
182///
183/// # Examples
184///
185/// ```
186/// use cargo_quality::analyzer::Diagnostic;
187///
188/// let diagnostic = Diagnostic {
189///     line:    42,
190///     column:  15,
191///     message: "Use import instead of path".to_string()
192/// };
193/// assert_eq!(diagnostic.line, 42);
194/// ```
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct Diagnostic {
197    /// Line number where issue was found
198    pub line:    usize,
199    /// Column number
200    pub column:  usize,
201    /// Issue description
202    pub message: String
203}
204
205/// Analysis issue found in code.
206///
207/// Represents a single quality issue detected by an analyzer, including
208/// its location, description, and optional fix.
209///
210/// # Examples
211///
212/// ```
213/// # use cargo_quality::analyzer::{Fix, Issue};
214/// let issue = Issue::new(
215///     42,
216///     15,
217///     "Use import instead of path".to_string(),
218///     Fix::WithImport {
219///         import:      "use std::fs::read_to_string;".to_string(),
220///         pattern:     "std::fs::read_to_string".to_string(),
221///         replacement: "read_to_string".to_string()
222///     }
223/// );
224/// assert_eq!(issue.diagnostic.line, 42);
225/// assert!(issue.fix.is_available());
226/// ```
227#[derive(Debug, Clone, PartialEq)]
228pub struct Issue {
229    /// Where the issue was found and what it says
230    pub diagnostic: Diagnostic,
231    /// Automatic fix
232    pub fix:        Fix
233}
234
235impl Issue {
236    /// Creates an issue from its location, message, and fix.
237    ///
238    /// # Arguments
239    ///
240    /// * `line` - Line number where the issue was found
241    /// * `column` - Column number
242    /// * `message` - Issue description
243    /// * `fix` - Automatic fix, or [`Fix::None`]
244    ///
245    /// # Returns
246    ///
247    /// The assembled issue
248    #[inline]
249    pub fn new(line: usize, column: usize, message: String, fix: Fix) -> Self {
250        Self {
251            diagnostic: Diagnostic {
252                line,
253                column,
254                message
255            },
256            fix
257        }
258    }
259}
260
261/// Result of code analysis.
262///
263/// Contains all issues found during analysis and count of fixable issues.
264///
265/// # Examples
266///
267/// ```
268/// use cargo_quality::analyzer::AnalysisResult;
269///
270/// let result = AnalysisResult {
271///     issues:        vec![],
272///     fixable_count: 0
273/// };
274/// assert_eq!(result.issues.len(), 0);
275/// ```
276#[derive(Debug, Default)]
277pub struct AnalysisResult {
278    /// Issues found
279    pub issues:        Vec<Issue>,
280    /// Number of fixable issues
281    pub fixable_count: usize
282}
283
284/// Trait for code analyzers.
285///
286/// Implement this trait to create custom quality analyzers. Each analyzer
287/// must provide a unique name, analysis logic, and optional fix capability.
288///
289/// # Examples
290///
291/// ```
292/// use cargo_quality::analyzer::{AnalysisResult, Analyzer};
293/// use masterror::AppResult;
294/// use syn::File;
295///
296/// struct MyAnalyzer;
297///
298/// impl Analyzer for MyAnalyzer {
299///     fn name(&self) -> &'static str {
300///         "my_analyzer"
301///     }
302///
303///     fn analyze(&self, ast: &File, content: &str) -> AppResult<AnalysisResult> {
304///         Ok(AnalysisResult::default())
305///     }
306/// }
307/// ```
308pub trait Analyzer {
309    /// Returns unique analyzer identifier.
310    ///
311    /// Used for reporting and configuration. Must be lowercase snake_case.
312    fn name(&self) -> &'static str;
313
314    /// Analyze Rust syntax tree for quality issues.
315    ///
316    /// # Arguments
317    ///
318    /// * `ast` - Parsed Rust syntax tree to analyze
319    /// * `content` - Source code content for analyzers that need raw text
320    ///
321    /// # Returns
322    ///
323    /// `AppResult<AnalysisResult>` - Analysis results or error
324    fn analyze(&self, ast: &File, content: &str) -> AppResult<AnalysisResult>;
325
326    /// Produce fixable suggestions for the detected issues.
327    ///
328    /// Each suggestion is a byte-range edit (plus an optional import) applied
329    /// against the original source, preserving everything outside the edited
330    /// ranges (comments, blank lines, formatting). The default implementation
331    /// returns none, for analyzers that are advisory only.
332    ///
333    /// # Arguments
334    ///
335    /// * `ast` - Parsed Rust syntax tree to fix
336    /// * `content` - Original source code the edits apply to
337    ///
338    /// # Returns
339    ///
340    /// `AppResult<Vec<Suggestion>>` - Non-overlapping suggestions, or error
341    fn suggestions(&self, _ast: &File, _content: &str) -> AppResult<Vec<Suggestion>> {
342        Ok(Vec::new())
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn test_fix_none() {
352        let fix = Fix::None;
353        assert!(!fix.is_available());
354        assert!(fix.as_simple().is_none());
355        assert!(fix.as_import().is_none());
356    }
357
358    #[test]
359    fn test_fix_simple() {
360        let fix = Fix::Simple("replacement".to_string());
361        assert!(fix.is_available());
362        assert_eq!(fix.as_simple(), Some("replacement"));
363        assert!(fix.as_import().is_none());
364    }
365
366    #[test]
367    fn test_fix_with_import() {
368        let fix = Fix::WithImport {
369            import:      "use std::fs::read;".to_string(),
370            pattern:     "std::fs::read".to_string(),
371            replacement: "read".to_string()
372        };
373        assert!(fix.is_available());
374        assert!(fix.as_simple().is_none());
375        assert_eq!(
376            fix.as_import(),
377            Some(("use std::fs::read;", "std::fs::read", "read"))
378        );
379    }
380
381    #[test]
382    fn test_issue_creation() {
383        let issue = Issue::new(
384            42,
385            10,
386            "Test issue".to_string(),
387            Fix::Simple("Fix suggestion".to_string())
388        );
389
390        assert_eq!(issue.diagnostic.line, 42);
391        assert_eq!(issue.diagnostic.column, 10);
392        assert!(issue.fix.is_available());
393    }
394
395    #[test]
396    fn test_analysis_result_default() {
397        let result = AnalysisResult::default();
398        assert_eq!(result.issues.len(), 0);
399        assert_eq!(result.fixable_count, 0);
400    }
401}