Skip to main content

ravenrs_lint/
lib.rs

1//! Lints for applications built with [RavenRS](https://alexkads.github.io/RavenRS/).
2//!
3//! The compiler and Clippy know Rust, not the rules an interface must follow to stay right in every
4//! theme, for screen readers and across rebuilds. `ravenrs-lint` reads the syntax tree of an
5//! application's sources and reports those rules the way the compiler does:
6//!
7//! ```text
8//! error[ravenrs::raw_color]: literal color in the interface
9//!   --> src/ui.rs:12:21
10//!    |
11//! 12 |         .background(Color::rgb(0.1, 0.2, 0.3))
12//!    |                     ^^^^^^^^^^
13//!    |
14//!    = help: use a `Semantic` role, such as `.background(Semantic::Control)`, so it stays right in every theme
15//! ```
16//!
17//! # Running it
18//!
19//! From the command line, after `cargo install ravenrs-lint`:
20//!
21//! ```text
22//! cargo ravenrs-lint            # every package of the workspace that depends on RavenRS
23//! cargo ravenrs-lint --init     # recommended rustc and Clippy lints, the test below and AGENTS.md
24//! ```
25//!
26//! From `cargo test`, so nothing passes while a rule is broken, with `ravenrs-lint` as a
27//! dev-dependency and a file such as `tests/ravenrs_lint.rs`:
28//!
29//! ```no_run
30//! #[test]
31//! fn follows_the_ravenrs_rules() {
32//!     ravenrs_lint::assert_clean();
33//! }
34//! ```
35//!
36//! # Configuring it
37//!
38//! Levels follow Cargo's `[lints]`: `allow`, `warn` or `deny`. They go in the package's or the
39//! workspace's metadata, and the package wins:
40//!
41//! ```toml
42//! [package.metadata.ravenrs-lint]
43//! language = "pt-BR"            # how button captions are checked: "en" (default) or "pt-BR"
44//! extra-verbs = ["Ok"]          # first words accepted as verbs besides the built-in ones
45//!
46//! [package.metadata.ravenrs-lint.lints]
47//! off_grid_spacing = "deny"
48//! ```
49//!
50//! A single place is excused with a comment that says why, on the same line or on its own line
51//! right above:
52//!
53//! ```text
54//! // ravenrs-lint: allow(raw_color) the church's logo keeps its brand color in every theme
55//! ```
56//!
57//! `allow-file(...)` excuses the whole file. Test code (`#[test]`, `#[cfg(test)]`) is not checked.
58//!
59//! The rules are listed in [`Lint`].
60
61mod config;
62mod init;
63mod report;
64mod rules;
65mod workspace;
66
67use std::fmt;
68use std::path::{Path, PathBuf};
69
70pub use config::{Config, Language, Level};
71pub use init::{InitReport, init, recommended_clippy, recommended_lints};
72pub use report::{render, render_json};
73pub use rules::Lint;
74pub use workspace::{Package, packages};
75
76/// One broken rule at one place in a source file.
77#[derive(Clone, Debug, PartialEq)]
78pub struct Diagnostic {
79    /// The rule.
80    pub lint: Lint,
81    /// How it is reported, after the configuration.
82    pub level: Level,
83    /// The file, as given to the check.
84    pub file: PathBuf,
85    /// Line, from 1.
86    pub line: usize,
87    /// Column of the first character, from 1.
88    pub column: usize,
89    /// Column after the last character on the same line, from 1.
90    pub end_column: usize,
91    /// What is wrong.
92    pub message: String,
93    /// How to fix it.
94    pub help: String,
95    /// The text of the line, for showing it.
96    pub snippet: String,
97}
98
99/// Why a check could not run.
100#[derive(Debug)]
101#[non_exhaustive]
102pub enum Error {
103    /// A file or directory could not be read.
104    Io(PathBuf, std::io::Error),
105    /// A source file is not valid Rust.
106    Parse(PathBuf, syn::Error),
107    /// The configuration names something that does not exist.
108    Config(String),
109    /// `cargo metadata` failed or answered something unexpected.
110    Cargo(String),
111}
112
113impl fmt::Display for Error {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Error::Io(path, error) => write!(f, "could not read {}: {error}", path.display()),
117            Error::Parse(path, error) => {
118                let at = error.span().start();
119                write!(f, "{}:{}:{}: {error}", path.display(), at.line, at.column + 1)
120            }
121            Error::Config(message) => write!(f, "invalid ravenrs-lint configuration: {message}"),
122            Error::Cargo(message) => write!(f, "cargo metadata: {message}"),
123        }
124    }
125}
126
127impl std::error::Error for Error {}
128
129/// Checks one source file. `file` is only used in the diagnostics.
130pub fn check_source(file: &Path, source: &str, config: &Config) -> Result<Vec<Diagnostic>, Error> {
131    let syntax = syn::parse_file(source).map_err(|error| Error::Parse(file.to_owned(), error))?;
132    let mut found = rules::check(&syntax, config, source.lines().count());
133    found.extend(rules::check_allow_comments(source));
134    let allowed = rules::Allowances::read(source);
135    let lines: Vec<&str> = source.lines().collect();
136    let mut diagnostics: Vec<Diagnostic> = found
137        .into_iter()
138        .filter(|finding| !allowed.covers(finding.lint, finding.line))
139        .filter_map(|finding| {
140            let level = config.level(finding.lint);
141            (level != Level::Allow).then(|| Diagnostic {
142                lint: finding.lint,
143                level,
144                file: file.to_owned(),
145                line: finding.line,
146                column: finding.column,
147                end_column: finding.end_column,
148                message: finding.message,
149                help: finding.help,
150                snippet: lines.get(finding.line.saturating_sub(1)).copied().unwrap_or_default().to_owned(),
151            })
152        })
153        .collect();
154    diagnostics.sort_by_key(|d| (d.line, d.column));
155    Ok(diagnostics)
156}
157
158/// Checks every `.rs` file under `dir`, in a stable order. Paths in the diagnostics are relative
159/// to `base` when they are inside it.
160pub fn check_dir(dir: &Path, base: &Path, config: &Config) -> Result<Vec<Diagnostic>, Error> {
161    let mut files = Vec::new();
162    collect_rust_files(dir, &mut files)?;
163    files.sort();
164    let mut diagnostics = Vec::new();
165    for path in files {
166        let source = std::fs::read_to_string(&path).map_err(|error| Error::Io(path.clone(), error))?;
167        let shown = path.strip_prefix(base).unwrap_or(&path);
168        diagnostics.extend(check_source(shown, &source, config)?);
169    }
170    Ok(diagnostics)
171}
172
173fn collect_rust_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<(), Error> {
174    let entries = std::fs::read_dir(dir).map_err(|error| Error::Io(dir.to_owned(), error))?;
175    for entry in entries {
176        let path = entry.map_err(|error| Error::Io(dir.to_owned(), error))?.path();
177        if path.is_dir() {
178            collect_rust_files(&path, files)?;
179        } else if path.extension().is_some_and(|ext| ext == "rs") {
180            files.push(path);
181        }
182    }
183    Ok(())
184}
185
186/// Checks the sources of one package: the directories of its library and binaries.
187pub fn check_package(package: &Package) -> Result<Vec<Diagnostic>, Error> {
188    let mut diagnostics = Vec::new();
189    for dir in &package.source_dirs {
190        diagnostics.extend(check_dir(dir, &package.root, &package.config)?);
191    }
192    Ok(diagnostics)
193}
194
195/// Checks the package whose tests are running and panics with every diagnostic, warnings
196/// included, the way `cargo clippy -- -D warnings` fails. Call it from a `#[test]`.
197///
198/// # Panics
199///
200/// When a rule is broken, and when the package cannot be read.
201#[allow(clippy::panic)]
202pub fn assert_clean() {
203    let Some(dir) = std::env::var_os("CARGO_MANIFEST_DIR") else {
204        panic!("ravenrs_lint::assert_clean runs from `cargo test`, which sets CARGO_MANIFEST_DIR");
205    };
206    let manifest = PathBuf::from(dir).join("Cargo.toml");
207    let result = packages(&manifest).and_then(|found| {
208        let same = |a: &Path, b: &Path| a == b || a.canonicalize().ok() == b.canonicalize().ok();
209        let package = found
210            .into_iter()
211            .find(|p| same(&p.manifest, &manifest))
212            .ok_or_else(|| Error::Cargo(format!("{} is not in its workspace", manifest.display())))?;
213        check_package(&package)
214    });
215    match result {
216        Ok(diagnostics) if diagnostics.is_empty() => {}
217        Ok(diagnostics) => panic!("\n{}", render(&diagnostics, true)),
218        Err(error) => panic!("{error}"),
219    }
220}