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}