Skip to main content

xtask/
check.rs

1//! `cargo xtask check`: the typo and TOML checks, clippy, the doc build, then a
2//! formatting check.
3
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use crate::step;
8
9/// Run the typo and TOML checks, clippy twice, the doc build, then the
10/// formatting check.
11///
12/// The two checks that need no compiler go first, so a misspelling or an
13/// unsorted feature table costs a second rather than the minutes clippy takes.
14///
15/// Clippy next: its warnings and the diagnostics it raises may leave the tree
16/// in a state `rustfmt` would rewrite, so formatting is the last word. The doc
17/// build sits between the two because a rustdoc warning is a lint like clippy's
18/// — it should fail here rather than only in CI — while a doc comment it would
19/// rewrite is still rustfmt's to settle.
20///
21/// Clippy runs twice, because each compiles a different set of code: the
22/// library crates with no default features at all, and the whole workspace
23/// with every feature, which is what the doc build and the full test run
24/// compile.
25///
26/// The no-features pass is the one the test run cannot stand in for. Those
27/// crates' built-in backends are optional, so this is the combination that
28/// proves the general facilities stand on their own — and the one a false
29/// optional feature, whose dependants never really compiled without it, fails
30/// to compile. The crates are named one by one rather than picked with
31/// `--workspace`, because `--no-default-features` reaches only the packages
32/// the command names: for every other package the features come from the
33/// dependency edges that reach it, and feature unification applies all of
34/// them. Every application crate asks `unlit3d` for `ui` and `winit`, so a
35/// workspace-wide run would compile the libraries with exactly the backends
36/// this pass exists to leave out.
37///
38/// Every run denies warnings, as CI does. Without it a warning here is a
39/// warning CI turns into an error, which is the same gap in a different guise.
40pub fn run(release: bool) -> Result<(), String> {
41    let root = workspace_root()?;
42
43    // Both tools take the directory to work on as their only argument, and both
44    // are run from here rather than from the working directory: a task may be
45    // started from anywhere inside the workspace, and these two read their
46    // configuration — `typos.toml`, `tombi.toml` — from where they are pointed.
47    require("typos")?;
48    require("tombi")?;
49
50    let mut typos = Command::new("typos");
51    typos.arg(&root);
52    step::run(&mut typos, "typos")?;
53
54    // `lint` and `format --check` are separate invocations because they report
55    // different things: a schema violation and a file the formatter would
56    // rewrite. The lint denies warnings, as CI does, so the two runs agree.
57    let mut tombi_lint = Command::new("tombi");
58    tombi_lint.args(["lint", "--error-on-warnings"]).arg(&root);
59    step::run(&mut tombi_lint, "tombi lint")?;
60
61    let mut tombi_format = Command::new("tombi");
62    tombi_format
63        .args(["format", "--check", "--diff"])
64        .arg(&root);
65    step::run(&mut tombi_format, "tombi format")?;
66
67    let profile: &[&str] = if release { &["--release"] } else { &[] };
68    let deny: &[&str] = &["--", "-D", "warnings"];
69
70    let mut bare = Command::new("cargo");
71    bare.args(["clippy"]);
72    for package in BARE_PACKAGES {
73        bare.args(["-p", package]);
74    }
75    bare.args(["--no-default-features", "--all-targets"])
76        .args(profile)
77        .args(deny);
78    step::run(&mut bare, "clippy (no features)")?;
79
80    let mut all = Command::new("cargo");
81    all.args(["clippy", "--workspace", "--all-targets", "--all-features"])
82        .args(profile)
83        .args(deny);
84    step::run(&mut all, "clippy (all features)")?;
85
86    // The doc build, so a broken intra-doc link or an undocumented private item
87    // fails here rather than only in CI. `cargo doc` has no `--` for handing
88    // flags to rustdoc — unlike clippy — so denying warnings goes through the
89    // environment, as CI does it. Every feature, because that is the build the
90    // published documentation is generated from: an item behind an optional
91    // feature is one whose docs only that combination compiles.
92    let mut doc = Command::new("cargo");
93    doc.args([
94        "doc",
95        "--workspace",
96        "--no-deps",
97        "--document-private-items",
98        "--keep-going",
99        "--all-features",
100    ])
101    .args(profile)
102    .env("RUSTDOCFLAGS", "-D warnings");
103    step::run(&mut doc, "doc")?;
104
105    let mut fmt = Command::new("cargo");
106    fmt.args(["fmt", "--all", "--", "--check"]);
107    step::run(&mut fmt, "fmt")?;
108
109    Ok(())
110}
111
112/// The crates the no-features clippy run names, one by one.
113///
114/// These are the library crates with optional backends: their general
115/// facilities have to compile and lint on their own, without the backends a
116/// caller may leave out. Naming them keeps the feature graph to the edges
117/// between the four, which ask for nothing, because the application crates
118/// are left out — each of them asks `unlit3d` for `ui` and `winit`, and
119/// feature unification would hand those to the libraries too. The same reason
120/// rules out `--workspace`: `--no-default-features` does not follow a
121/// dependency edge.
122const BARE_PACKAGES: [&str; 4] = ["unlit_ecs", "unlit_wgpu", "unlit3d", "unlit3dx"];
123
124/// Fail, naming the tool, when `tool` is not usable.
125///
126/// Neither tool ships with cargo, so a missing one reaches the step as a bare
127/// "No such file or directory" under a name that says what the step wanted;
128/// this reports which tool is absent instead. How to install it is left to
129/// the contributor: both are distributed outside cargo, so a command named
130/// here would be a guess.
131fn require(tool: &str) -> Result<(), String> {
132    let output = Command::new(tool)
133        .arg("--version")
134        .output()
135        .map_err(|error| format!("`{tool}` is not on PATH: {error}"))?;
136    if !output.status.success() {
137        return Err(format!(
138            "`{tool} --version` failed:\n{}",
139            String::from_utf8_lossy(&output.stderr)
140        ));
141    }
142    Ok(())
143}
144
145/// The directory the task's tools work on, which is the workspace root.
146///
147/// Asked of cargo rather than read from the working directory, so that a task
148/// started from a crate's own directory covers the whole workspace: these
149/// tools read their configuration from the directory they are pointed at, and
150/// `typos.toml` and `tombi.toml` live at the root.
151fn workspace_root() -> Result<PathBuf, String> {
152    let mut metadata = Command::new("cargo");
153    metadata.args(["locate-project", "--workspace", "--message-format", "plain"]);
154    let output = metadata
155        .output()
156        .map_err(|error| format!("running `cargo locate-project`: {error}"))?;
157    if !output.status.success() {
158        return Err(format!(
159            "`cargo locate-project` failed:\n{}",
160            String::from_utf8_lossy(&output.stderr)
161        ));
162    }
163    let manifest = String::from_utf8(output.stdout)
164        .map_err(|error| format!("reading `cargo locate-project`'s output: {error}"))?;
165    Path::new(manifest.trim())
166        .parent()
167        .map(Path::to_path_buf)
168        .ok_or_else(|| "`cargo locate-project` named a manifest with no directory".to_owned())
169}