Skip to main content

shuyi_cli/contract/
lint.rs

1//! `lint`: everything wrong with the shape of the game's code, in one
2//! answer, each finding with its file, its line and what to do (ADR-0005
3//! §1):
4//!
5//! - `check-keys`' findings: a key named in the game's code or text
6//!   ([`crate::check_keys`], [`codes::HARD_CODED_KEY`]);
7//! - `check-text`'s: screen text written as literals, when there are more
8//!   than the project allows, and a data name read by a screen
9//!   ([`crate::check_text`], [`codes::SCREEN_TEXT`]);
10//! - the rules crate's fence (ADR-0032 §4): `cargo clippy` on the package
11//!   `shuyi.ron`'s `rules` names, read for the lints its `clippy.toml` and
12//!   its `lib.rs` set, a hash map, a float, a clock, file I/O or outside
13//!   luck ([`FENCE_LINTS`], [`codes::RULES_FENCE`]);
14//! - its layers: `cargo tree` of that package, as the template's
15//!   `tests/layers.rs` reads it, each crate the rules never depend on
16//!   ([`NEVER`], [`codes::RULES_DEPENDENCY`]) with the `cargo tree -i`
17//!   that shows the way in.
18//!
19//! The two checks run in this process; clippy and `cargo tree` run as
20//! child processes from the project's root, through a [`Cargo`], so the
21//! tests can give any output. What isn't checked (a part `shuyi.ron`
22//! lacks, a cargo that fails) is in the answer's limits. It passes when
23//! nothing is found.
24
25use std::fmt::Write as _;
26use std::path::Path;
27use std::process::Command;
28
29use schemars::JsonSchema;
30use serde::{Deserialize, Serialize};
31use serde_json::Value;
32
33use super::{Answer, Op, codes};
34use crate::check_keys::relative;
35use crate::project::{FILE, Project};
36use crate::{check_keys, check_text};
37
38/// The clippy lints of the rules crate's fence.
39pub const FENCE_LINTS: [&str; 3] = [
40    "clippy::disallowed_types",
41    "clippy::disallowed_methods",
42    "clippy::float_arithmetic",
43];
44
45/// The crates the rules never depend on, even through another crate, as
46/// the template's grid stage's `crates/rules/tests/layers.rs` lists them
47/// (a test keeps the two the same). A trailing `*` is a prefix.
48pub const NEVER: &[&str] = &[
49    "shuyi",
50    "shuyi-ui",
51    "shuyi-platform",
52    "shuyi-app",
53    "shuyi-cli",
54    "macroquad",
55    "miniquad",
56    "quad-*",
57    "gilrs",
58    "gilrs-core",
59    "cpal",
60    "rodio",
61    "kira",
62    "alsa",
63    "alsa-sys",
64    "coreaudio-rs",
65    "coreaudio-sys",
66    "oboe",
67    "symphonia",
68    "lewton",
69    "hound",
70    "rand",
71    "getrandom",
72];
73
74/// What `lint` checks: everything (nothing to choose yet).
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
76#[serde(deny_unknown_fields)]
77pub struct LintInput {}
78
79/// Which check found a finding.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
81#[serde(rename_all = "kebab-case")]
82pub enum LintCheck {
83    /// `check-keys`: a key named in code or text.
84    CheckKeys,
85    /// `check-text`: screen text as a literal, or a data name read.
86    CheckText,
87    /// The rules crate's clippy fence.
88    RulesFence,
89    /// The rules crate's dependencies.
90    RulesLayers,
91}
92
93/// One thing wrong, with where.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
95#[serde(deny_unknown_fields)]
96pub struct Finding {
97    /// The check that found it.
98    pub check: LintCheck,
99    /// The file, from the project's root (`crates/rules/src/lib.rs`).
100    pub file: String,
101    /// Its 1-based line, if known.
102    pub line: Option<u32>,
103    /// Its 1-based column, if known.
104    pub column: Option<u32>,
105    /// What is wrong, and for the fence, what to use instead.
106    pub message: String,
107    /// Its problem code (`SH2101`).
108    pub code: String,
109    /// How to fix it, with the code.
110    pub help: Option<String>,
111    /// A command that shows more: for a dependency, the `cargo tree -i`
112    /// that shows the way in.
113    pub command: Option<String>,
114}
115
116impl Finding {
117    /// A finding of `check`, in `file`, with code `code` and its row's
118    /// help.
119    fn new(check: LintCheck, file: impl Into<String>, code: codes::Code, message: String) -> Self {
120        Self {
121            check,
122            file: file.into(),
123            line: None,
124            column: None,
125            message,
126            code: code.0.to_owned(),
127            help: codes::help_of(code.0).map(str::to_owned),
128            command: None,
129        }
130    }
131}
132
133/// What `lint` found.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
135#[serde(deny_unknown_fields)]
136pub struct LintOutput {
137    /// Every finding: `check-keys`', `check-text`'s, the fence's, then the
138    /// layers'.
139    pub findings: Vec<Finding>,
140}
141
142/// What cargo printed and whether it succeeded.
143#[derive(Debug, Clone, Default, PartialEq, Eq)]
144pub struct Ran {
145    /// Whether it exited with 0.
146    pub success: bool,
147    /// Its stdout.
148    pub stdout: String,
149    /// Its stderr.
150    pub stderr: String,
151}
152
153/// How `lint` runs cargo.
154pub trait Cargo {
155    /// Cargo with `args`, run in `root`; an error if it doesn't start.
156    fn run(&self, root: &Path, args: &[&str]) -> Result<Ran, String>;
157}
158
159/// The machine's cargo: the one running this program if cargo started it
160/// (`CARGO`), else `cargo` on the `PATH`.
161#[derive(Debug, Clone, Copy, Default)]
162pub struct ThisMachine;
163
164impl Cargo for ThisMachine {
165    fn run(&self, root: &Path, args: &[&str]) -> Result<Ran, String> {
166        let cargo = crate::cargo();
167        let out = Command::new(&cargo)
168            .args(args)
169            .current_dir(root)
170            .output()
171            .map_err(|e| format!("cannot run {}: {e}", cargo.to_string_lossy()))?;
172        Ok(Ran {
173            success: out.status.success(),
174            stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
175            stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
176        })
177    }
178}
179
180/// `lint` on `project`, running cargo with `cargo`.
181pub fn lint(cargo: &dyn Cargo, project: &Project, _input: &LintInput) -> Answer<LintOutput> {
182    let config = &project.config;
183    let root = &project.root;
184    let mut findings = Vec::new();
185    let mut limits = Vec::new();
186    match &config.check_keys {
187        Some(rules) => findings.extend(
188            check_keys::run(rules, root)
189                .iter()
190                .map(|e| reported(LintCheck::CheckKeys, codes::HARD_CODED_KEY, e)),
191        ),
192        None => limits.push(format!("check-keys wasn't run: {FILE} has no `check_keys`")),
193    }
194    match &config.check_text {
195        Some(rules) => findings.extend(text_findings(rules, root)),
196        None => limits.push(format!("check-text wasn't run: {FILE} has no `check_text`")),
197    }
198    match &config.rules {
199        Some(package) => match fence(cargo, root, package, &mut limits) {
200            Ok(found) => findings.extend(found),
201            Err(e) => return Answer::failed(Op::Lint, codes::TOOLCHAIN, e),
202        },
203        None => limits.push(format!(
204            "the rules crate's fence wasn't checked: {FILE} names no rules package (`rules`)"
205        )),
206    }
207    let ok = findings.is_empty();
208    Answer::new(Op::Lint, ok, LintOutput { findings }, limits)
209}
210
211/// `check-text`'s findings: every literal if there are more than the
212/// rules allow, and every data name read.
213fn text_findings(rules: &crate::project::CheckText, root: &Path) -> Vec<Finding> {
214    let (hits, errors) = check_text::run(rules, root);
215    let mut found: Vec<Finding> = errors
216        .iter()
217        .map(|e| reported(LintCheck::CheckText, codes::SCREEN_TEXT, e))
218        .collect();
219    if hits.len() > rules.max_literals {
220        found.extend(hits.iter().map(|hit| {
221            let message = format!(
222                "screen text written as a literal, {:?} ({} in {}, at most {} allowed) — {}",
223                hit.text,
224                hits.len(),
225                rules.dir,
226                rules.max_literals,
227                rules.hint
228            );
229            Finding {
230                line: u32::try_from(hit.line).ok(),
231                ..Finding::new(LintCheck::CheckText, &hit.file, codes::SCREEN_TEXT, message)
232            }
233        }));
234    }
235    found.extend(
236        check_text::run_names(rules, root)
237            .iter()
238            .map(|e| reported(LintCheck::CheckText, codes::SCREEN_TEXT, e)),
239    );
240    found
241}
242
243/// A finding of `check` from a line a check reports (`file:line: what`,
244/// or `file: what`), with `code`; one that couldn't read a file has
245/// [`codes::FILE`].
246pub fn reported(check: LintCheck, code: codes::Code, error: &str) -> Finding {
247    let (place, message) = error.split_once(": ").unwrap_or(("", error));
248    let (file, line) = match place.rsplit_once(':') {
249        Some((file, line)) if line.parse::<u32>().is_ok() => (file, line.parse().ok()),
250        _ => (place, None),
251    };
252    let code = if message.starts_with("cannot read") {
253        codes::FILE
254    } else {
255        code
256    };
257    Finding {
258        line,
259        ..Finding::new(check, file, code, message.to_owned())
260    }
261}
262
263/// The rules crate's fence: clippy's findings, then `cargo tree`'s, for
264/// `package`, run in `root`. What couldn't be checked goes in `limits`;
265/// an error if cargo doesn't start.
266fn fence(
267    cargo: &dyn Cargo,
268    root: &Path,
269    package: &str,
270    limits: &mut Vec<String>,
271) -> Result<Vec<Finding>, String> {
272    let clippy = cargo.run(
273        root,
274        &[
275            "clippy",
276            "--quiet",
277            "-p",
278            package,
279            "--message-format=json",
280            "--",
281            "-D",
282            "warnings",
283        ],
284    )?;
285    let mut found = clippy_findings(root, &clippy.stdout);
286    if let Some(error) = compile_error(root, &clippy.stdout) {
287        limits.push(format!(
288            "the rules crate's fence was checked only in part: {package} doesn't compile ({error})"
289        ));
290    } else if !clippy.success && found.is_empty() && !has_messages(&clippy.stdout) {
291        limits.push(format!(
292            "the rules crate's fence wasn't checked: cargo clippy -p {package} failed: {}",
293            last_line(&clippy.stderr)
294        ));
295    }
296    let tree = cargo.run(
297        root,
298        &[
299            "tree",
300            "-p",
301            package,
302            "--edges",
303            "normal,build",
304            "--target",
305            "all",
306            "--prefix",
307            "none",
308            "--format",
309            "{p}",
310        ],
311    )?;
312    if tree.success {
313        found.extend(tree_findings(root, package, &tree.stdout));
314    } else {
315        limits.push(format!(
316            "the rules crate's layers weren't checked: cargo tree -p {package} failed: {}",
317            last_line(&tree.stderr)
318        ));
319    }
320    Ok(found)
321}
322
323/// The last line of `text` that says something.
324fn last_line(text: &str) -> &str {
325    text.lines()
326        .rev()
327        .map(str::trim)
328        .find(|line| !line.is_empty())
329        .unwrap_or("no output")
330}
331
332/// The compiler messages of cargo's JSON output, `stdout`.
333fn messages(stdout: &str) -> impl Iterator<Item = Value> + '_ {
334    stdout
335        .lines()
336        .filter_map(|line| serde_json::from_str::<Value>(line).ok())
337        .filter(|line| line["reason"] == "compiler-message")
338        .map(|mut line| line["message"].take())
339}
340
341/// Whether cargo's JSON output has any compiler message.
342fn has_messages(stdout: &str) -> bool {
343    messages(stdout).next().is_some()
344}
345
346/// `path` (from `root`, or a path in it) as the project shows it: from
347/// `root`, with `/`.
348fn shown(root: &Path, path: &str) -> String {
349    relative(root, Path::new(path))
350}
351
352/// The file, line and column of `message`'s primary span, if it has one.
353fn place(root: &Path, message: &Value) -> (String, Option<u32>, Option<u32>) {
354    let spans = message["spans"].as_array().map_or(&[][..], Vec::as_slice);
355    let Some(span) = spans
356        .iter()
357        .find(|span| span["is_primary"] == true)
358        .or_else(|| spans.first())
359    else {
360        return (String::new(), None, None);
361    };
362    let number = |key: &str| span[key].as_u64().and_then(|n| u32::try_from(n).ok());
363    (
364        shown(root, span["file_name"].as_str().unwrap_or_default()),
365        number("line_start"),
366        number("column_start"),
367    )
368}
369
370/// The reason a disallowed type's or method's message gives: the note
371/// with no span that isn't clippy's own (a link, the `-D` it came from).
372fn reason(message: &Value) -> Option<&str> {
373    const CLIPPYS: [&str; 4] = [
374        "for further information",
375        "`-D ",
376        "to override",
377        "the lint level",
378    ];
379    message["children"]
380        .as_array()?
381        .iter()
382        .filter(|child| child["level"] == "note")
383        .filter(|child| child["spans"].as_array().is_none_or(Vec::is_empty))
384        .filter_map(|child| child["message"].as_str())
385        .find(|text| !CLIPPYS.iter().any(|clippy| text.starts_with(clippy)))
386}
387
388/// The fence's findings in clippy's JSON output, `stdout` (cargo's
389/// `--message-format=json`), files shown from `root`: each message of a
390/// lint of [`FENCE_LINTS`], once, its reason after it. Any other lint is
391/// left out.
392pub fn clippy_findings(root: &Path, stdout: &str) -> Vec<Finding> {
393    let mut found: Vec<Finding> = Vec::new();
394    for message in messages(stdout) {
395        let name = message["code"]["code"].as_str().unwrap_or_default();
396        if !FENCE_LINTS.contains(&name) {
397            continue;
398        }
399        let (file, line, column) = place(root, &message);
400        let text = message["message"].as_str().unwrap_or_default();
401        let text = match reason(&message) {
402            Some(reason) => format!("{text}: {reason}"),
403            None => text.to_owned(),
404        };
405        let finding = Finding {
406            line,
407            column,
408            ..Finding::new(LintCheck::RulesFence, file, codes::RULES_FENCE, text)
409        };
410        if !found.contains(&finding) {
411            found.push(finding);
412        }
413    }
414    found
415}
416
417/// The first error in clippy's JSON output that is the compiler's, not a
418/// lint's (`error[E0425]`, or one with no code), with where, if any.
419fn compile_error(root: &Path, stdout: &str) -> Option<String> {
420    messages(stdout).find_map(|message| {
421        let code = message["code"]["code"].as_str();
422        let compiler = code.is_none_or(|code| {
423            code.strip_prefix('E')
424                .is_some_and(|n| n.bytes().all(|b| b.is_ascii_digit()))
425        });
426        (message["level"] == "error" && compiler).then(|| {
427            let text = message["message"].as_str().unwrap_or_default();
428            match place(root, &message) {
429                (file, Some(line), _) => format!("{file}:{line}: {text}"),
430                _ => text.to_owned(),
431            }
432        })
433    })
434}
435
436/// Whether crate `name` is one of [`NEVER`].
437fn is_never(name: &str) -> bool {
438    NEVER.iter().any(|never| match never.strip_suffix('*') {
439        Some(prefix) => name.starts_with(prefix),
440        None => name == *never,
441    })
442}
443
444/// The layers' findings in `cargo tree -p package --prefix none --format
445/// {p}`'s output, `stdout`: each crate of [`NEVER`] in it, once, in the
446/// package's `Cargo.toml` (from `root`, read off the tree's first line),
447/// with the `cargo tree -i` that shows the way in.
448pub fn tree_findings(root: &Path, package: &str, stdout: &str) -> Vec<Finding> {
449    let manifest = stdout
450        .lines()
451        .next()
452        .and_then(|first| first.rsplit_once(" ("))
453        .and_then(|(_, path)| path.strip_suffix(')'))
454        .map_or_else(
455            || "Cargo.toml".to_owned(),
456            |dir| {
457                let dir = shown(root, dir);
458                if dir.is_empty() {
459                    "Cargo.toml".to_owned()
460                } else {
461                    format!("{dir}/Cargo.toml")
462                }
463            },
464        );
465    let mut names: Vec<&str> = stdout
466        .lines()
467        .filter_map(|line| line.split_whitespace().next())
468        .filter(|name| is_never(name))
469        .collect();
470    names.sort_unstable();
471    names.dedup();
472    names
473        .into_iter()
474        .map(|name| {
475            let message = format!(
476                "{package} depends on {name}: the rules stay pure, so screens, devices and tools \
477                 go in crates/game, and the only luck is the SimRng kept in the state"
478            );
479            Finding {
480                command: Some(format!("cargo tree -p {package} -i {name}")),
481                ..Finding::new(
482                    LintCheck::RulesLayers,
483                    manifest.clone(),
484                    codes::RULES_DEPENDENCY,
485                    message,
486                )
487            }
488        })
489        .collect()
490}
491
492/// `lint`'s usage, with `command` as the tool's name.
493pub fn usage(command: &str) -> String {
494    format!("usage: {command} lint [--json]")
495}
496
497/// What `lint` found, for people.
498pub fn text(output: &LintOutput) -> String {
499    if output.findings.is_empty() {
500        return "lint: OK\n".to_owned();
501    }
502    let mut out = format!("lint: {} finding(s)\n", output.findings.len());
503    for finding in &output.findings {
504        // Writing to a `String` can't fail.
505        let _ = write!(out, "  {}", finding.file);
506        if let Some(line) = finding.line {
507            let _ = write!(out, ":{line}");
508            if let Some(column) = finding.column {
509                let _ = write!(out, ":{column}");
510            }
511        }
512        let _ = writeln!(out, ": error[{}]: {}", finding.code, finding.message);
513        if let Some(command) = &finding.command {
514            let _ = writeln!(out, "    see the way in: {command}");
515        }
516    }
517    let mut seen: Vec<&str> = Vec::new();
518    for finding in &output.findings {
519        if let Some(help) = &finding.help
520            && !seen.contains(&finding.code.as_str())
521        {
522            seen.push(&finding.code);
523            let _ = writeln!(out, "help[{}]: {help}", finding.code);
524        }
525    }
526    out
527}
528
529#[cfg(test)]
530mod tests;