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 single fixable change: one source edit plus any import it requires.
45///
46/// Both the `fix` command and the diff/interactive flow are built from
47/// suggestions, so applying a change is identical everywhere: the [`edit`] is
48/// spliced into the source and the [`import`], if any, is inserted once
49/// (imports are deduplicated across the applied suggestions).
50///
51/// [`edit`]: Suggestion::edit
52/// [`import`]: Suggestion::import
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Suggestion {
55    /// The byte-range edit that performs the rewrite
56    pub edit:   TextEdit,
57    /// A `use` statement the rewrite depends on, if any
58    pub import: Option<String>
59}
60
61/// Type of fix that can be applied to resolve an issue.
62///
63/// Represents different kinds of automatic fixes that analyzers can provide.
64///
65/// # Examples
66///
67/// ```
68/// use cargo_quality::analyzer::Fix;
69///
70/// let simple_fix = Fix::Simple("let x = 42;".to_string());
71/// assert!(simple_fix.is_available());
72/// assert_eq!(simple_fix.as_simple(), Some("let x = 42;"));
73///
74/// let import_fix = Fix::WithImport {
75///     import:      "use std::fs::read;".to_string(),
76///     pattern:     "std::fs::read".to_string(),
77///     replacement: "read".to_string()
78/// };
79/// assert!(import_fix.is_available());
80/// assert_eq!(
81///     import_fix.as_import(),
82///     Some(("use std::fs::read;", "std::fs::read", "read"))
83/// );
84/// ```
85#[derive(Debug, Clone, PartialEq)]
86pub enum Fix {
87    /// No automatic fix available
88    None,
89
90    /// Simple line replacement
91    ///
92    /// Replace the entire line with the provided string.
93    ///
94    /// Note: Reserved for future analyzers that need simple line replacements.
95    #[allow(dead_code)]
96    Simple(String),
97
98    /// Fix requiring import addition
99    ///
100    /// Adds an import statement and replaces the line.
101    WithImport {
102        /// Import statement to add (e.g., "use std::fs::read_to_string;")
103        import:      String,
104        /// Pattern to find in original line (e.g., "std::fs::read_to_string")
105        pattern:     String,
106        /// Replacement for the pattern (e.g., "read_to_string")
107        replacement: String
108    }
109}
110
111impl Fix {
112    /// Checks if fix is available.
113    ///
114    /// # Returns
115    ///
116    /// `true` if fix can be applied automatically
117    #[inline]
118    pub fn is_available(&self) -> bool {
119        !matches!(self, Fix::None)
120    }
121
122    /// Returns simple replacement string if available.
123    ///
124    /// # Returns
125    ///
126    /// Option<&str> - Replacement string for simple fixes
127    #[inline]
128    pub fn as_simple(&self) -> Option<&str> {
129        match self {
130            Fix::Simple(s) => Some(s.as_str()),
131            _ => None
132        }
133    }
134
135    /// Returns import, pattern, and replacement for import-based fixes.
136    ///
137    /// # Returns
138    ///
139    /// Option<(&str, &str, &str)> - (import, pattern, replacement) tuple
140    #[inline]
141    pub fn as_import(&self) -> Option<(&str, &str, &str)> {
142        match self {
143            Fix::WithImport {
144                import,
145                pattern,
146                replacement
147            } => Some((import.as_str(), pattern.as_str(), replacement.as_str())),
148            _ => None
149        }
150    }
151}
152
153/// Analysis issue found in code.
154///
155/// Represents a single quality issue detected by an analyzer, including
156/// its location, description, and optional fix.
157///
158/// # Examples
159///
160/// ```
161/// # use cargo_quality::analyzer::{Issue, Fix};
162/// let issue = Issue {
163///     line:    42,
164///     column:  15,
165///     message: "Use import instead of path".to_string(),
166///     fix:     Fix::WithImport {
167///         import:      "use std::fs::read_to_string;".to_string(),
168///         pattern:     "std::fs::read_to_string".to_string(),
169///         replacement: "read_to_string".to_string()
170///     }
171/// };
172/// assert_eq!(issue.line, 42);
173/// assert!(issue.fix.is_available());
174/// ```
175#[derive(Debug, Clone, PartialEq)]
176pub struct Issue {
177    /// Line number where issue was found
178    pub line:    usize,
179    /// Column number
180    pub column:  usize,
181    /// Issue description
182    pub message: String,
183    /// Automatic fix
184    pub fix:     Fix
185}
186
187/// Result of code analysis.
188///
189/// Contains all issues found during analysis and count of fixable issues.
190///
191/// # Examples
192///
193/// ```
194/// use cargo_quality::analyzer::AnalysisResult;
195///
196/// let result = AnalysisResult {
197///     issues:        vec![],
198///     fixable_count: 0
199/// };
200/// assert_eq!(result.issues.len(), 0);
201/// ```
202#[derive(Debug, Default)]
203pub struct AnalysisResult {
204    /// Issues found
205    pub issues:        Vec<Issue>,
206    /// Number of fixable issues
207    pub fixable_count: usize
208}
209
210/// Trait for code analyzers.
211///
212/// Implement this trait to create custom quality analyzers. Each analyzer
213/// must provide a unique name, analysis logic, and optional fix capability.
214///
215/// # Examples
216///
217/// ```
218/// use cargo_quality::analyzer::{AnalysisResult, Analyzer};
219/// use masterror::AppResult;
220/// use syn::File;
221///
222/// struct MyAnalyzer;
223///
224/// impl Analyzer for MyAnalyzer {
225///     fn name(&self) -> &'static str {
226///         "my_analyzer"
227///     }
228///
229///     fn analyze(&self, ast: &File, content: &str) -> AppResult<AnalysisResult> {
230///         Ok(AnalysisResult::default())
231///     }
232/// }
233/// ```
234pub trait Analyzer {
235    /// Returns unique analyzer identifier.
236    ///
237    /// Used for reporting and configuration. Must be lowercase snake_case.
238    fn name(&self) -> &'static str;
239
240    /// Analyze Rust syntax tree for quality issues.
241    ///
242    /// # Arguments
243    ///
244    /// * `ast` - Parsed Rust syntax tree to analyze
245    /// * `content` - Source code content for analyzers that need raw text
246    ///
247    /// # Returns
248    ///
249    /// `AppResult<AnalysisResult>` - Analysis results or error
250    fn analyze(&self, ast: &File, content: &str) -> AppResult<AnalysisResult>;
251
252    /// Produce fixable suggestions for the detected issues.
253    ///
254    /// Each suggestion is a byte-range edit (plus an optional import) applied
255    /// against the original source, preserving everything outside the edited
256    /// ranges (comments, blank lines, formatting). The default implementation
257    /// returns none, for analyzers that are advisory only.
258    ///
259    /// # Arguments
260    ///
261    /// * `ast` - Parsed Rust syntax tree to fix
262    /// * `content` - Original source code the edits apply to
263    ///
264    /// # Returns
265    ///
266    /// `AppResult<Vec<Suggestion>>` - Non-overlapping suggestions, or error
267    fn suggestions(&self, _ast: &File, _content: &str) -> AppResult<Vec<Suggestion>> {
268        Ok(Vec::new())
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_fix_none() {
278        let fix = Fix::None;
279        assert!(!fix.is_available());
280        assert!(fix.as_simple().is_none());
281        assert!(fix.as_import().is_none());
282    }
283
284    #[test]
285    fn test_fix_simple() {
286        let fix = Fix::Simple("replacement".to_string());
287        assert!(fix.is_available());
288        assert_eq!(fix.as_simple(), Some("replacement"));
289        assert!(fix.as_import().is_none());
290    }
291
292    #[test]
293    fn test_fix_with_import() {
294        let fix = Fix::WithImport {
295            import:      "use std::fs::read;".to_string(),
296            pattern:     "std::fs::read".to_string(),
297            replacement: "read".to_string()
298        };
299        assert!(fix.is_available());
300        assert!(fix.as_simple().is_none());
301        assert_eq!(
302            fix.as_import(),
303            Some(("use std::fs::read;", "std::fs::read", "read"))
304        );
305    }
306
307    #[test]
308    fn test_issue_creation() {
309        let issue = Issue {
310            line:    42,
311            column:  10,
312            message: "Test issue".to_string(),
313            fix:     Fix::Simple("Fix suggestion".to_string())
314        };
315
316        assert_eq!(issue.line, 42);
317        assert_eq!(issue.column, 10);
318        assert!(issue.fix.is_available());
319    }
320
321    #[test]
322    fn test_analysis_result_default() {
323        let result = AnalysisResult::default();
324        assert_eq!(result.issues.len(), 0);
325        assert_eq!(result.fixable_count, 0);
326    }
327}