From 6581a5da255f05a09476c4265d76594481b6a3bf Mon Sep 17 00:00:00 2001 From: Scott Schafer Date: Wed, 15 Oct 2025 11:39:33 -0600 Subject: [PATCH] feat: Add a typos CI job --- .cargo/config.toml | 1 + .github/workflows/main.yml | 10 + Cargo.lock | 11 + crates/xtask-spellcheck/Cargo.toml | 15 ++ crates/xtask-spellcheck/src/main.rs | 202 ++++++++++++++++++ .../contrib/src/process/working-on-cargo.md | 5 + typos.toml | 45 ++++ 7 files changed, 289 insertions(+) create mode 100644 crates/xtask-spellcheck/Cargo.toml create mode 100644 crates/xtask-spellcheck/src/main.rs create mode 100755 typos.toml diff --git a/.cargo/config.toml b/.cargo/config.toml index f1a267084..938113135 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,6 +3,7 @@ build-man = "run --package xtask-build-man --" stale-label = "run --package xtask-stale-label --" bump-check = "run --package xtask-bump-check --" lint-docs = "run --package xtask-lint-docs --" +spellcheck = "run --package xtask-spellcheck --" [env] # HACK: Until this is stabilized, `snapbox`s polyfill could get confused diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a000e573c..77eb9cd39 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,6 +28,7 @@ jobs: - resolver - rustfmt - schema + - spellcheck - test - test_gitoxide permissions: @@ -304,3 +305,12 @@ jobs: - uses: actions/checkout@v5 - uses: taiki-e/install-action@cargo-hack - run: cargo hack check --all-targets --rust-version --workspace --ignore-private --locked + + spellcheck: + name: Spell Check with Typos + runs-on: ubuntu-latest + steps: + - name: Checkout Actions Repository + uses: actions/checkout@v5 + - name: Spell Check Repo + uses: crate-ci/typos@v1.38.1 diff --git a/Cargo.lock b/Cargo.lock index 2bf8cd106..9ab1742bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5211,6 +5211,17 @@ dependencies = [ "itertools 0.14.0", ] +[[package]] +name = "xtask-spellcheck" +version = "0.0.0" +dependencies = [ + "anyhow", + "cargo-util", + "cargo_metadata", + "clap", + "semver", +] + [[package]] name = "xtask-stale-label" version = "0.0.0" diff --git a/crates/xtask-spellcheck/Cargo.toml b/crates/xtask-spellcheck/Cargo.toml new file mode 100644 index 000000000..d082b358a --- /dev/null +++ b/crates/xtask-spellcheck/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "xtask-spellcheck" +version = "0.0.0" +edition.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +cargo_metadata.workspace = true +cargo-util.workspace = true +clap.workspace = true +semver.workspace = true + +[lints] +workspace = true diff --git a/crates/xtask-spellcheck/src/main.rs b/crates/xtask-spellcheck/src/main.rs new file mode 100644 index 000000000..4afb97ca0 --- /dev/null +++ b/crates/xtask-spellcheck/src/main.rs @@ -0,0 +1,202 @@ +#![allow(clippy::disallowed_methods)] +#![allow(clippy::print_stderr)] +#![allow(clippy::print_stdout)] + +use anyhow::Result; +use cargo_metadata::{Metadata, MetadataCommand}; +use clap::{Arg, ArgAction}; +use semver::Version; +use std::{ + env, io, + path::{Path, PathBuf}, + process::Command, +}; + +const BIN_NAME: &str = "typos"; +const PKG_NAME: &str = "typos-cli"; +const TYPOS_STEP_PREFIX: &str = " uses: crate-ci/typos@v"; + +fn main() -> anyhow::Result<()> { + let cli = cli(); + exec(&cli.get_matches())?; + Ok(()) +} + +pub fn cli() -> clap::Command { + clap::Command::new("xtask-spellcheck") + .arg( + Arg::new("color") + .long("color") + .help("Coloring: auto, always, never") + .action(ArgAction::Set) + .value_name("WHEN") + .global(true), + ) + .arg( + Arg::new("quiet") + .long("quiet") + .short('q') + .help("Do not print cargo log messages") + .action(ArgAction::SetTrue) + .global(true), + ) + .arg( + Arg::new("verbose") + .long("verbose") + .short('v') + .help("Use verbose output (-vv very verbose/build.rs output)") + .action(ArgAction::Count) + .global(true), + ) + .arg( + Arg::new("write-changes") + .long("write-changes") + .short('w') + .help("Write fixes out") + .action(ArgAction::SetTrue) + .global(true), + ) +} + +pub fn exec(matches: &clap::ArgMatches) -> Result<()> { + let mut args = vec![]; + + match matches.get_one::("color") { + Some(c) if matches!(c.as_str(), "auto" | "always" | "never") => { + args.push("--color"); + args.push(c); + } + Some(c) => { + anyhow::bail!( + "argument for --color must be auto, always, or \ + never, but found `{}`", + c + ); + } + _ => {} + } + + if matches.get_flag("quiet") { + args.push("--quiet"); + } + + let verbose_count = matches.get_count("verbose"); + + for _ in 0..verbose_count { + args.push("--verbose"); + } + if matches.get_flag("write-changes") { + args.push("--write-changes"); + } + + let metadata = MetadataCommand::new() + .exec() + .expect("cargo_metadata failed"); + + let required_version = extract_workflow_typos_version(&metadata)?; + + let outdir = metadata + .build_directory + .unwrap_or_else(|| metadata.target_directory) + .as_std_path() + .join("tmp"); + let workspace_root = metadata.workspace_root.as_path().as_std_path(); + let bin_path = crate::ensure_version_or_cargo_install(&outdir, required_version)?; + + eprintln!("running {BIN_NAME}"); + Command::new(bin_path) + .current_dir(workspace_root) + .args(args) + .status()?; + + Ok(()) +} + +fn extract_workflow_typos_version(metadata: &Metadata) -> anyhow::Result { + let ws_root = metadata.workspace_root.as_path().as_std_path(); + let workflow_path = ws_root.join(".github").join("workflows").join("main.yml"); + let file_content = std::fs::read_to_string(workflow_path)?; + + if let Some(line) = file_content + .lines() + .find(|line| line.contains(TYPOS_STEP_PREFIX)) + && let Some(stripped) = line.strip_prefix(TYPOS_STEP_PREFIX) + && let Ok(v) = Version::parse(stripped) + { + Ok(v) + } else { + Err(anyhow::anyhow!("Could not find typos version in workflow")) + } +} + +/// If the given executable is installed with the given version, use that, +/// otherwise install via cargo. +pub fn ensure_version_or_cargo_install( + build_dir: &Path, + required_version: Version, +) -> io::Result { + // Check if the user has a sufficient version already installed + let bin_path = PathBuf::from(BIN_NAME).with_extension(env::consts::EXE_EXTENSION); + if let Some(user_version) = get_typos_version(&bin_path) { + if user_version >= required_version { + return Ok(bin_path); + } + } + + let tool_root_dir = build_dir.join("misc-tools"); + let tool_bin_dir = tool_root_dir.join("bin"); + let bin_path = tool_bin_dir + .join(BIN_NAME) + .with_extension(env::consts::EXE_EXTENSION); + + // Check if we have already installed sufficient version + if let Some(misc_tools_version) = get_typos_version(&bin_path) { + if misc_tools_version >= required_version { + return Ok(bin_path); + } + } + + eprintln!("required `typos` version ({required_version}) not found, building from source"); + + let mut cmd = Command::new("cargo"); + // use --force to ensure that if the required version is bumped, we update it. + cmd.args(["install", "--locked", "--force", "--quiet"]) + .arg("--root") + .arg(&tool_root_dir) + // use --target-dir to ensure we have a build cache so repeated invocations aren't slow. + .arg("--target-dir") + .arg(tool_root_dir.join("target")) + .arg(format!("{PKG_NAME}@{required_version}")) + // modify PATH so that cargo doesn't print a warning telling the user to modify the path. + .env( + "PATH", + env::join_paths( + env::split_paths(&env::var("PATH").unwrap()) + .chain(std::iter::once(tool_bin_dir.clone())), + ) + .expect("build dir contains invalid char"), + ); + + let cargo_exit_code = cmd.spawn()?.wait()?; + if !cargo_exit_code.success() { + return Err(io::Error::other("cargo install failed")); + } + assert!( + matches!(bin_path.try_exists(), Ok(true)), + "cargo install did not produce the expected binary" + ); + eprintln!("finished {BIN_NAME}"); + Ok(bin_path) +} + +fn get_typos_version(bin: &PathBuf) -> Option { + // ignore the process exit code here and instead just let the version number check fail + if let Ok(output) = Command::new(&bin).arg("--version").output() + && let Ok(s) = String::from_utf8(output.stdout) + && let Some(version_str) = s.trim().split_whitespace().last() + { + Version::parse(version_str).ok() + } else { + None + } +} diff --git a/src/doc/contrib/src/process/working-on-cargo.md b/src/doc/contrib/src/process/working-on-cargo.md index d3069702e..f5e50f884 100644 --- a/src/doc/contrib/src/process/working-on-cargo.md +++ b/src/doc/contrib/src/process/working-on-cargo.md @@ -99,6 +99,11 @@ Some guidelines on working on a change: * Include tests that cover all non-trivial code. See the [Testing chapter] for more about writing and running tests. * All code should be warning-free. This is checked during tests. +* All changes should be free of typos. Cargo's CI has a job that runs [`typos`] + to enforce this. You can use `cargo spellcheck` to run this check locally, + and `cargo spellcheck --write-changes` to fix most typos automatically. + +[`typos`]: https://github.com/crate-ci/typos ## Submitting a Pull Request diff --git a/typos.toml b/typos.toml new file mode 100755 index 000000000..17120b92f --- /dev/null +++ b/typos.toml @@ -0,0 +1,45 @@ +[files] +extend-exclude = [ + "crates/resolver-tests/*", + "LICENSE-THIRD-PARTY", + "tests/testsuite/script/rustc_fixtures", +] + +[default] +extend-ignore-re = [ + # Handles ssh keys + "AAAA[0-9A-Za-z+/]+[=]{0,3}", + + # Handles paseto from login tests + "k3[.](secret|public)[.][a-zA-Z0-9_-]+", +] +extend-ignore-identifiers-re = [ + # Handles git short SHA-1 hashes + "[a-f0-9]{8,9}", +] +extend-ignore-words-re = [ + # words with length <= 4 chars is likely noise + "^[a-zA-Z]{1,4}$", +] + +[default.extend-identifiers] +# This comes from `windows_sys` +ERROR_FILENAME_EXCED_RANGE = "ERROR_FILENAME_EXCED_RANGE" + +# Name of a dependency +flate2 = "flate2" + +[default.extend-words] +filetimes = "filetimes" + +[type.cargo_command] +extend-glob = ["cargo_command.rs"] + +[type.cargo_command.extend-words] +biuld = "biuld" + +[type.random-sample] +extend-glob = ["random-sample"] + +[type.random-sample.extend-words] +objekt = "objekt"