Skip to main content

balise_cli/check/
mod.rs

1//! `balise check`: compiles the project with clippy, which also applies the project's lints,
2//! and shows each diagnostic as Balise shows its errors, rewritten when Balise recognizes
3//! it. Errors come first, then warnings, then one summary line.
4//!
5//! It compiles in its own `target/check` folder, so that it never waits for the build of
6//! `balise dev`.
7
8mod diagnostic;
9mod enrich;
10pub mod render;
11mod source;
12
13use std::path::Path;
14use std::process::Command;
15
16use balise_core::error_codes::CMP0027;
17use balise_core::json::{self, Number, Value};
18
19use crate::{Failure, project};
20
21pub use diagnostic::{Diagnostic, Severity, is_rustc_code};
22
23/// What a check found.
24pub struct Checked {
25    pub diagnostics: Vec<Diagnostic>,
26}
27
28impl Checked {
29    pub fn errors(&self) -> usize {
30        self.count(Severity::Error)
31    }
32
33    pub fn warnings(&self) -> usize {
34        self.count(Severity::Warning)
35    }
36
37    fn count(&self, severity: Severity) -> usize {
38        self.diagnostics
39            .iter()
40            .filter(|d| d.severity == severity)
41            .count()
42    }
43
44    /// Whether the check fails: an error, or a warning with `--ci`.
45    pub fn failed(&self, ci: bool) -> bool {
46        self.errors() > 0 || (ci && self.warnings() > 0)
47    }
48
49    pub fn to_value(&self) -> Value {
50        let count = |n: usize| Value::Number(Number::Unsigned(n as u64));
51        Value::Object(vec![
52            (
53                "diagnostics".into(),
54                Value::Array(self.diagnostics.iter().map(Diagnostic::to_value).collect()),
55            ),
56            ("errors".into(), count(self.errors())),
57            ("warnings".into(), count(self.warnings())),
58        ])
59    }
60
61    /// Reads [`to_value`](Self::to_value)'s data back.
62    pub fn from_value(value: &Value) -> Option<Checked> {
63        let diagnostics = diagnostic::array(value, "diagnostics")
64            .iter()
65            .map(Diagnostic::from_value)
66            .collect::<Option<_>>()?;
67        Some(Checked { diagnostics })
68    }
69}
70
71/// Checks the project at `root` with the cargo of this machine. With `ci`, also its
72/// formatting and the release build.
73pub fn run(root: &Path, ci: bool) -> Result<Checked, Failure> {
74    let target = project::target_dir(root).join("check");
75    let mut diagnostics = cargo(
76        root,
77        &target,
78        &["clippy", "--all-targets", "--features", "dev"],
79    )?;
80    diagnostics.extend(source::undeclared_modules(root));
81    diagnostics.extend(source::static_muts(root));
82    if ci {
83        diagnostics.extend(formatting(root)?);
84        // Only what the release build adds: the rest was just reported.
85        if diagnostics.iter().all(|d| d.severity != Severity::Error) {
86            diagnostics.extend(cargo(root, &target, &["build", "--release"])?);
87        }
88    }
89    // Each target (the binary, its tests) reports the same diagnostic; one of Balise's is
90    // said once per line (`Mutex<u32> = Mutex::new(0)` holds two `Mutex`).
91    let mut unique: Vec<Diagnostic> = Vec::new();
92    for d in diagnostics {
93        let same = |other: &Diagnostic| {
94            other.code == d.code
95                && other.message == d.message
96                && other.file == d.file
97                && other.line == d.line
98                && (other.column == d.column || d.code.starts_with("CMP"))
99        };
100        if !unique.iter().any(same) {
101            unique.push(d);
102        }
103    }
104    // Errors first, each group in the order of the files.
105    unique.sort_by(|a, b| {
106        (a.severity != Severity::Error, &a.file, a.line).cmp(&(
107            b.severity != Severity::Error,
108            &b.file,
109            b.line,
110        ))
111    });
112    Ok(Checked {
113        diagnostics: unique,
114    })
115}
116
117/// Runs a cargo command with diagnostics as JSON, and reads them.
118fn cargo(root: &Path, target: &Path, args: &[&str]) -> Result<Vec<Diagnostic>, Failure> {
119    let output = Command::new("cargo")
120        .args(args)
121        .args(["--quiet", "--message-format", "json"])
122        .env("CARGO_TARGET_DIR", target)
123        .current_dir(root)
124        .output()
125        .map_err(|error| cargo_failed(&error.to_string()))?;
126    let diagnostics: Vec<Diagnostic> = String::from_utf8_lossy(&output.stdout)
127        .lines()
128        .filter_map(|line| json::parse(line.as_bytes()).ok())
129        .filter(|message| diagnostic::text(message, "reason") == Some("compiler-message"))
130        .filter_map(|message| {
131            let message = diagnostic::get(&message, "message")?.clone();
132            Diagnostic::from_rustc(root, &message)
133        })
134        .collect();
135    let compiled = diagnostics.iter().any(|d| d.severity == Severity::Error);
136    if !output.status.success() && !compiled {
137        let stderr = String::from_utf8_lossy(&output.stderr);
138        let cause: Vec<&str> = stderr
139            .lines()
140            .map(str::trim)
141            .filter(|line| !line.is_empty())
142            .take(6)
143            .collect();
144        return Err(cargo_failed(&cause.join(" ")));
145    }
146    Ok(diagnostics)
147}
148
149fn cargo_failed(cause: &str) -> Failure {
150    Failure::problem(CMP0027, &[("cause", cause)], &[])
151}
152
153/// Files `cargo fmt` would change.
154fn formatting(root: &Path) -> Result<Vec<Diagnostic>, Failure> {
155    let output = Command::new("cargo")
156        .args(["fmt", "--check", "--", "-l"])
157        .current_dir(root)
158        .output()
159        .map_err(|error| cargo_failed(&error.to_string()))?;
160    let prefix = format!("{}/", root.display());
161    Ok(String::from_utf8_lossy(&output.stdout)
162        .lines()
163        .filter(|line| !line.trim().is_empty())
164        .map(|line| {
165            let file = line
166                .strip_prefix(&prefix)
167                .unwrap_or(line)
168                .replace('\\', "/");
169            Diagnostic {
170                severity: Severity::Error,
171                code: "rustfmt".into(),
172                message: format!("`{file}` is not formatted"),
173                file: Some(file),
174                line: 1,
175                column: 1,
176                function: None,
177                snippet: Vec::new(),
178                label: None,
179                fix: Some("Run `cargo fmt`.".into()),
180                more: None,
181            }
182        })
183        .collect())
184}