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}