From 0eaadd243969be68bc9f2a370cceeaf2b28e847e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Tue, 13 Jan 2026 12:11:15 +0100 Subject: [PATCH] ci: Rewrite pre-commit checks script The script has become quite big so it is easier to maintain it in Rust to add new features. Ideally we would declare it as a single file cargo script, but that still requires the nightly toolchain. So we make it a separate checks crate with no dependencies, to make compilation fast. It is a separate crate instead of a member of the workspace because cargo insists on downloading the git dependencies of the fractal crate before building the checks crate, which adds 20-30 seconds to the first compilation. Sadly that means the IDEs will probably not be very helpful when modifying it, and cargo commands run from the root of the workspace need to point explicitly to the manifest with the `--manifest-path` argument. It is pre-compiled during `meson setup` and a symlink is created at `hooks/checks-bin`, that is used by the pre-commit hook. It is a requirement for me because I use different environments to compile crates and to commit changes. Incidentally, this fixes the check about sorting the POTFILEs alphabetically. --- .gitignore | 3 +- .gitlab-ci/check.yml | 2 +- CONTRIBUTING.md | 2 +- hooks/checks.sh | 798 -------------------------------------- hooks/checks/Cargo.lock | 7 + hooks/checks/Cargo.toml | 9 + hooks/checks/src/main.rs | 724 ++++++++++++++++++++++++++++++++++ hooks/checks/src/utils.rs | 467 ++++++++++++++++++++++ hooks/pre-commit.hook | 4 +- meson.build | 46 ++- src/meson.build | 7 - 11 files changed, 1255 insertions(+), 814 deletions(-) delete mode 100755 hooks/checks.sh create mode 100644 hooks/checks/Cargo.lock create mode 100644 hooks/checks/Cargo.toml create mode 100644 hooks/checks/src/main.rs create mode 100644 hooks/checks/src/utils.rs diff --git a/.gitignore b/.gitignore index fcafaf2c..898e24dd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ target/ *.ui.in~ *.ui~ -# Dynamically-generated file +# Dynamically-generated files src/config.rs +hooks/checks-bin diff --git a/.gitlab-ci/check.yml b/.gitlab-ci/check.yml index fff7cc7c..4c44e416 100644 --- a/.gitlab-ci/check.yml +++ b/.gitlab-ci/check.yml @@ -8,7 +8,7 @@ pre-commit-checks: image: "rustlang/rust:nightly-slim" interruptible: true script: - - hooks/checks.sh --verbose --force-install + - RUST_BACKTRACE=1 cargo run --manifest-path hooks/checks/Cargo.toml -- --verbose --force-install # Checks needing dependencies in the Flatpak runtime flatpak-checks: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 946cc6b9..975542cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,7 +129,7 @@ sudo ninja -C _build install ## Pre-commit We expect all code contributions to be correctly formatted. To help with that, a pre-commit hook -should get installed as part of the building process. It runs the `hooks/checks.sh` script. It's a +should get installed as part of the building process. It runs the `hooks/checks` crate. It's a quick script that makes sure that the code is correctly formatted with `rustfmt`, among other things. Make sure that this script is effectively run before submitting your merge request, otherwise CI will probably fail right away. diff --git a/hooks/checks.sh b/hooks/checks.sh deleted file mode 100755 index 450c0138..00000000 --- a/hooks/checks.sh +++ /dev/null @@ -1,798 +0,0 @@ -#!/usr/bin/env bash - -export LC_ALL=C - -# Usage info -show_help() { -cat << EOF -Run conformity checks on the current Rust project. - -If a dependency is not found, helps the user to install it. - -USAGE: ${0##*/} [OPTIONS] - -OPTIONS: - -s, --git-staged Only check files staged to be committed - -f, --force-install Install missing dependencies without asking - -v, --verbose Use verbose output - -h, --help Display this help and exit - -ERROR CODES: - 1 Check failed - 2 Missing dependency -EOF -} - -# Style helpers -act="\e[1;32m" -err="\e[1;31m" -pos="\e[32m" -neg="\e[31m" -res="\e[0m" - -# Common styled strings -Installing="${act}Installing${res}" -Checking=" ${act}Checking${res}" -Could_not=" ${err}Could not${res}" -error="${err}error:${res}" -invalid="${neg}Invalid input${res}" -ok="${pos}ok${res}" -fail="${neg}fail${res}" - -# Initialize variables -git_staged=0 -force_install=0 -verbose=0 - -# Helper functions -# Sort to_sort in natural order. -sort() { - local size=${#to_sort[@]} - local swapped=0; - - for (( i = 0; i < $size-1; i++ )) - do - swapped=0 - for ((j = 0; j < $size-1-$i; j++ )) - do - if [[ "${to_sort[$j]}" > "${to_sort[$j+1]}" ]] - then - temp="${to_sort[$j]}"; - to_sort[$j]="${to_sort[$j+1]}"; - to_sort[$j+1]="$temp"; - swapped=1; - fi - done - - if [[ $swapped -eq 0 ]]; then - break; - fi - done -} - -# Remove common entries in to_diff1 and to_diff2. -diff() { - for i in ${!to_diff1[@]}; do - for j in ${!to_diff2[@]}; do - if [[ "${to_diff1[$i]}" == "${to_diff2[$j]}" ]]; then - unset to_diff1[$i] - unset to_diff2[$j] - break - fi - done - done -} - -# Check if rustup is available. -# Argument: -# '-i' to install if missing. -check_rustup() { - if ! which rustup &> /dev/null; then - if [[ "$1" == '-i' ]]; then - echo -e "$Installing rustup…" - curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain nightly - export PATH=$PATH:$HOME/.cargo/bin - if ! which rustup &> /dev/null; then - echo -e "$Could_not install rustup" - exit 2 - fi - else - exit 2 - fi - fi -} - -# Install cargo via rustup. -install_cargo() { - check_rustup -i - if ! which cargo >/dev/null 2>&1; then - echo -e "$Could_not install cargo" - exit 2 - fi -} - -# Check if cargo is available. If not, ask to install it. -check_cargo() { - if ! which cargo >/dev/null 2>&1; then - echo "Could not find cargo for pre-commit checks" - - if [[ $force_install -eq 1 ]]; then - install_cargo - elif [ ! -t 1 ]; then - exit 2 - elif check_rustup; then - echo -e "$error rustup is installed but the cargo command isn’t available" - exit 2 - else - echo "" - echo "y: Install cargo via rustup" - echo "N: Don’t install cargo and abort checks" - echo "" - while true; do - echo -n "Install cargo? [y/N]: "; read yn < /dev/tty - case $yn in - [Yy]* ) - install_cargo - break - ;; - [Nn]* | "" ) - exit 2 - ;; - * ) - echo $invalid - ;; - esac - done - fi - fi - - if [[ $verbose -eq 1 ]]; then - echo "" - rustc -Vv && cargo +nightly -Vv - fi -} - -# Install rustfmt with rustup. -install_rustfmt() { - check_rustup -i - - echo -e "$Installing rustfmt…" - rustup component add --toolchain nightly rustfmt - if ! cargo +nightly fmt --version >/dev/null 2>&1; then - echo -e "$Could_not install rustfmt" - exit 2 - fi -} - -# Run rustfmt to enforce code style. -run_rustfmt() { - if ! cargo +nightly fmt --version >/dev/null 2>&1; then - if [[ $force_install -eq 1 ]]; then - install_rustfmt - elif [ ! -t 1 ]; then - echo "Could not check Fractal’s code style, because rustfmt could not be run" - exit 2 - else - echo "Rustfmt is needed to check Fractal’s code style, but it isn’t available" - echo "" - echo "y: Install rustfmt via rustup" - echo "N: Don’t install rustfmt and abort checks" - echo "" - while true; do - echo -n "Install rustfmt? [y/N]: "; read yn < /dev/tty - case $yn in - [Yy]* ) - install_rustfmt - break - ;; - [Nn]* | "" ) - exit 2 - ;; - * ) - echo $invalid - ;; - esac - done - fi - fi - - echo -e "$Checking code style…" - - if [[ $verbose -eq 1 ]]; then - echo "" - cargo +nightly fmt --version - echo "" - fi - - - if [[ $git_staged -eq 1 ]]; then - staged_files=`git diff --name-only --cached | xargs ls -d 2>/dev/null | grep '.rs$'` - result=0 - for file in ${staged_files[@]}; do - if ! cargo +nightly fmt -- --unstable-features --skip-children --check $file; then - result=1 - fi - done - - if [[ $result -eq 1 ]]; then - echo -e " Checking code style result: $fail" - echo "Please fix the above issues, either manually or by running: cargo fmt --all" - exit 1 - else - echo -e " Checking code style result: $ok" - fi - else - if ! cargo +nightly fmt --all -- --check; then - echo -e " Checking code style result: $fail" - echo "Please fix the above issues, either manually or by running: cargo fmt --all" - exit 1 - else - echo -e " Checking code style result: $ok" - fi - fi -} - - -# Install typos with cargo. -install_typos() { - echo -e "$Installing typos…" - cargo install typos-cli - if ! typos --version >/dev/null 2>&1; then - echo -e "$Could_not install typos" - exit 2 - fi -} - -# Run typos to check for spelling mistakes. -run_typos() { - if ! typos --version >/dev/null 2>&1; then - if [[ $force_install -eq 1 ]]; then - install_typos - elif [ ! -t 1 ]; then - echo "Could not check spelling mistakes, because typos could not be run" - exit 2 - else - echo "Typos is needed to check spelling mistakes, but it isn’t available" - echo "" - echo "y: Install typos via cargo" - echo "N: Don’t install typos and abort checks" - echo "" - while true; do - echo -n "Install typos? [y/N]: "; read yn < /dev/tty - case $yn in - [Yy]* ) - install_typos - break - ;; - [Nn]* | "" ) - exit 2 - ;; - * ) - echo $invalid - ;; - esac - done - fi - fi - - echo -e "$Checking spelling mistakes…" - - if [[ $verbose -eq 1 ]]; then - echo "" - typos --version - echo "" - fi - - staged_files=`git diff --name-only --cached | xargs ls -d 2>/dev/null` - - if ! typos --color always ${staged_files}; then - echo -e " Checking spelling mistakes result: $fail" - echo "Please fix the above issues, either manually or by running: typos -w" - exit 1 - else - echo -e " Checking spelling mistakes result: $ok" - fi -} - -# Install machete with cargo. -install_machete() { - echo -e "$Installing cargo-machete…" - cargo install cargo-machete - if ! cargo machete --version>/dev/null 2>&1; then - echo -e "$Could_not install cargo-machete" - exit 2 - fi -} - -# Run machete to check for unused dependencies. -run_machete() { - if ! cargo machete --version >/dev/null 2>&1; then - if [[ $force_install -eq 1 ]]; then - install_machete - elif [ ! -t 1 ]; then - echo "Could not check for unused dependencies, because cargo-machete could not be run" - exit 2 - else - echo "cargo-machete is needed to check for unused dependencies, but it isn’t available" - echo "" - echo "y: Install cargo-machete via cargo" - echo "N: Don’t install cargo-machete and abort checks" - echo "" - while true; do - echo -n "Install cargo-machete? [y/N]: "; read yn < /dev/tty - case $yn in - [Yy]* ) - install_machete - break - ;; - [Nn]* | "" ) - exit 2 - ;; - * ) - echo $invalid - ;; - esac - done - fi - fi - - echo -e "$Checking for unused dependencies…" - - if [[ $verbose -eq 1 ]]; then - echo "" - cargo machete --version - echo "" - fi - - if ! cargo machete --with-metadata; then - echo -e " Checking for unused dependencies result: $fail" - echo "Please fix the above issues, either by removing the dependencies, or by adding the necessary configuration option in Cargo.toml (see cargo-machete documentation)" - exit 1 - else - echo -e " Checking for unused dependencies result: $ok" - fi -} - -# Install cargo-deny with cargo. -install_cargo_deny() { - echo -e "$Installing cargo-deny…" - cargo install cargo-deny - if ! cargo deny --version>/dev/null 2>&1; then - echo -e "$Could_not install cargo-deny" - exit 2 - fi -} - -# Run cargo-deny to check Rust dependencies. -run_cargo_deny() { - if ! cargo deny --version >/dev/null 2>&1; then - if [[ $force_install -eq 1 ]]; then - install_cargo_deny - elif [ ! -t 1 ]; then - echo "Could not check Rust dependencies, because cargo-deny could not be run" - exit 2 - else - echo "cargo-deny is needed to check the Rust dependencies, but it isn’t available" - echo "" - echo "y: Install cargo-deny via cargo" - echo "N: Don’t install cargo-deny and abort checks" - echo "" - while true; do - echo -n "Install cargo-deny? [y/N]: "; read yn < /dev/tty - case $yn in - [Yy]* ) - install_cargo_deny - break - ;; - [Nn]* | "" ) - exit 2 - ;; - * ) - echo $invalid - ;; - esac - done - fi - fi - - echo -e "$Checking Rust dependencies…" - - if [[ $verbose -eq 1 ]]; then - echo "" - cargo deny --version - echo "" - fi - - if ! cargo deny check; then - echo -e " Checking Rust dependencies result: $fail" - echo "Please fix the above issues, either by removing the dependencies, or by adding the necessary configuration option in deny.toml (see cargo-deny documentation)" - exit 1 - else - echo -e " Checking Rust dependencies result: $ok" - fi -} - -# Check if files in POTFILES.in are correct. -# -# This checks, in that order: -# - All files exist -# - All files with translatable strings are present and only those -# - Files are sorted alphabetically -# -# This assumes the following: -# - POTFILES is located at 'po/POTFILES.in' -# - UI (Glade) files are located in 'src' and use 'translatable="yes"' -# - Blueprint files are located in 'src' and use '_(' -# - Rust files are located in 'src' and use '*gettext' methods or macros -check_potfiles() { - echo -e "$Checking po/POTFILES.in…" - - local ret=0 - - # Check that files in POTFILES.in exist. - while read -r line; do - if [[ -n $line && ${line::1} != '#' ]]; then - if [[ ! -f $line ]]; then - echo -e "$error File '$line' in POTFILES.in does not exist" - ret=1 - fi - if [[ ${line:(-3):3} == '.ui' ]]; then - ui_potfiles+=($line) - elif [[ ${line:(-4):4} == '.blp' ]]; then - blp_potfiles+=($line) - elif [[ ${line:(-3):3} == '.rs' ]]; then - rs_potfiles+=($line) - fi - fi - done < po/POTFILES.in - - if [[ ret -eq 1 ]]; then - echo -e " Checking po/POTFILES.in result: $fail" - exit 1 - fi - - # Check that files in POTFILES.skip exist. - while read -r line; do - if [[ -n $line && ${line::1} != '#' ]]; then - if [[ ! -f $line ]]; then - echo -e "$error File '$line' in POTFILES.skip does not exist" - ret=1 - fi - if [[ ${line:(-3):3} == '.ui' ]]; then - ui_skip+=($line) - elif [[ ${line:(-4):4} == '.blp' ]]; then - blp_skip+=($line) - elif [[ ${line:(-3):3} == '.rs' ]]; then - rs_skip+=($line) - fi - fi - done < po/POTFILES.skip - - if [[ ret -eq 1 ]]; then - echo -e " Checking po/POTFILES.skip result: $fail" - exit 1 - fi - - # Get UI files with 'translatable="yes"'. - ui_files=(`grep -lIr --include=*.ui 'translatable="yes"' src`) - - # Get blueprint files with '_('. - blp_files=(`grep -lIr --include=*.blp '_(' src`) - - # Get Rust files with regex 'gettext(_f)?\('. - rs_files=(`grep -lIrE --include=*.rs 'gettext(_f)?\(' src`) - - # Get Rust files with macros, regex 'gettext!\('. - rs_macro_files=(`grep -lIrE --include=*.rs 'gettext!\(' src`) - - # Remove common files - to_diff1=("${ui_skip[@]}") - to_diff2=("${ui_files[@]}") - diff - ui_skip=("${to_diff1[@]}") - ui_files=("${to_diff2[@]}") - - to_diff1=("${ui_potfiles[@]}") - to_diff2=("${ui_files[@]}") - diff - ui_potfiles=("${to_diff1[@]}") - ui_files=("${to_diff2[@]}") - - to_diff1=("${blp_skip[@]}") - to_diff2=("${blp_files[@]}") - diff - blp_skip=("${to_diff1[@]}") - blp_files=("${to_diff2[@]}") - - to_diff1=("${blp_potfiles[@]}") - to_diff2=("${blp_files[@]}") - diff - blp_potfiles=("${to_diff1[@]}") - blp_files=("${to_diff2[@]}") - - to_diff1=("${rs_skip[@]}") - to_diff2=("${rs_files[@]}") - diff - rs_skip=("${to_diff1[@]}") - rs_files=("${to_diff2[@]}") - - to_diff1=("${rs_potfiles[@]}") - to_diff2=("${rs_files[@]}") - diff - rs_potfiles=("${to_diff1[@]}") - rs_files=("${to_diff2[@]}") - - potfiles_count=$((${#ui_potfiles[@]} + ${#blp_potfiles[@]} + ${#rs_potfiles[@]})) - if [[ $potfiles_count -eq 1 ]]; then - echo "" - echo -e "$error Found 1 file in POTFILES.in without translatable strings:" - ret=1 - elif [[ $potfiles_count -ne 0 ]]; then - echo "" - echo -e "$error Found $potfiles_count files in POTFILES.in without translatable strings:" - ret=1 - fi - for file in ${ui_potfiles[@]}; do - echo $file - done - for file in ${blp_potfiles[@]}; do - echo $file - done - for file in ${rs_potfiles[@]}; do - echo $file - done - - let files_count=$((${#ui_files[@]} + ${#blp_files[@]} + ${#rs_files[@]})) - if [[ $files_count -eq 1 ]]; then - echo "" - echo -e "$error Found 1 file with translatable strings not present in POTFILES.in:" - ret=1 - elif [[ $files_count -ne 0 ]]; then - echo "" - echo -e "$error Found $files_count files with translatable strings not present in POTFILES.in:" - ret=1 - fi - for file in ${ui_files[@]}; do - echo $file - done - for file in ${blp_files[@]}; do - echo $file - done - for file in ${rs_files[@]}; do - echo $file - done - - let rs_macro_count=$((${#rs_macro_files[@]})) - if [[ $rs_macro_count -eq 1 ]]; then - echo "" - echo -e "$error Found 1 Rust file that uses a gettext-rs macro, use the corresponding i18n method instead:" - ret=1 - elif [[ $rs_macro_count -ne 0 ]]; then - echo "" - echo -e "$error Found $rs_macro_count Rust files that use a gettext-rs macro, use the corresponding i18n method instead:" - ret=1 - fi - for file in ${rs_macro_files[@]}; do - echo $file - done - - if [[ ret -eq 1 ]]; then - echo "" - echo -e " Checking po/POTFILES.in result: $fail" - exit 1 - fi - - # Check sorted alphabetically - to_sort=("${potfiles[@]}") - sort - for i in ${!potfiles[@]}; do - if [[ "${potfiles[$i]}" != "${to_sort[$i]}" ]]; then - echo -e "$error Found file '${potfiles[$i]}' before '${to_sort[$i]}' in POTFILES.in" - ret=1 - break - fi - done - - if [[ ret -eq 1 ]]; then - echo "" - echo -e " Checking po/POTFILES.in result: $fail" - exit 1 - else - echo -e " Checking po/POTFILES.in result: $ok" - fi -} - -# Check if files in resource files are sorted alphabetically. -check_resources() { - echo -e "$Checking $1…" - - local ret=0 - local files=() - - # Get files. - regex="(.*)" - while read -r line; do - if [[ $line =~ $regex ]]; then - files+=("${BASH_REMATCH[1]}") - fi - done < $1 - - # Check sorted alphabetically - local to_sort=("${files[@]}") - sort - for i in ${!files[@]}; do - if [[ "${files[$i]}" != "${to_sort[$i]}" ]]; then - echo -e "$error Found file '${files[$i]#src/}' before '${to_sort[$i]#src/}' in $1" - ret=1 - break - fi - done - - if [[ ret -eq 1 ]]; then - echo "" - echo -e " Checking $1 result: $fail" - exit 1 - else - echo -e " Checking $1 result: $ok" - fi -} - -# Check if files in blp-resources.in are sorted alphabetically. -check_blueprint_resources() { - input_file="src/ui-blueprint-resources.in" - echo -e "$Checking $input_file…" - - local ret=0 - local files=() - - # Get files. - while read -r line; do - if [[ -n $line && ${line::1} != '#' ]]; then - if [[ ! -f "src/${line}" ]]; then - echo -e "$error File '$line' in $input_file does not exist" - ret=1 - fi - files+=($line) - fi - done < $input_file - - # Check sorted alphabetically - local to_sort=("${files[@]}") - sort - for i in ${!files[@]}; do - if [[ "${files[$i]}" != "${to_sort[$i]}" ]]; then - echo -e "$error Found file '${files[$i]#src/}' before '${to_sort[$i]#src/}' in $input_file" - ret=1 - break - fi - done - - if [[ ret -eq 1 ]]; then - echo "" - echo -e " Checking $input_file result: $fail" - exit 1 - else - echo -e " Checking $input_file result: $ok" - fi -} - -# Install cargo-sort with cargo. -install_cargo_sort() { - echo -e "$Installing cargo-sort…" - cargo install cargo-sort - if ! cargo-sort --version >/dev/null 2>&1; then - echo -e "$Could_not install cargo-sort" - exit 2 - fi -} - -# Run cargo-sort to check if Cargo.toml is sorted. -run_cargo_sort() { - if ! cargo-sort --version >/dev/null 2>&1; then - if [[ $force_install -eq 1 ]]; then - install_cargo_sort - elif [ ! -t 1 ]; then - echo "Could not check Cargo.toml sorting, because cargo-sort could not be run" - exit 2 - else - echo "Cargo-sort is needed to check the sorting in Cargo.toml, but it isn’t available" - echo "" - echo "y: Install cargo-sort via cargo" - echo "N: Don’t install cargo-sort and abort checks" - echo "" - while true; do - echo -n "Install cargo-sort? [y/N]: "; read yn < /dev/tty - case $yn in - [Yy]* ) - install_cargo_sort - break - ;; - [Nn]* | "" ) - exit 2 - ;; - * ) - echo $invalid - ;; - esac - done - fi - fi - - echo -e "$Checking Cargo.toml sorting…" - - if [[ $verbose -eq 1 ]]; then - echo "" - cargo-sort --version - echo "" - fi - - if ! cargo-sort --check --grouped --order package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies; then - echo -e " Cargo.toml sorting result: $fail" - echo "Please fix the Cargo.toml file, either manually or by running: cargo-sort --grouped --order package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies" - exit 1 - else - echo -e " Cargo.toml sorting result: $ok" - fi -} - -# Check arguments -while [[ "$1" ]]; do case $1 in - -s | --git-staged ) - git_staged=1 - ;; - -f | --force-install ) - force_install=1 - ;; - -v | --verbose ) - verbose=1 - ;; - -h | --help ) - show_help - exit 0 - ;; - *) - show_help >&2 - exit 1 -esac; shift; done - -if [[ $git_staged -eq 1 ]]; then - staged_files=`git diff --name-only --cached` - if [[ -z $staged_files ]]; then - echo -e "$Could_not check files because none where staged" - exit 2 - fi -else - staged_files="" -fi - - -# Run -check_cargo -echo "" -run_rustfmt -echo "" -run_typos -echo "" -run_machete -echo "" -run_cargo_deny -echo "" -check_potfiles -echo "" -if [[ -n $staged_files ]]; then - if [[ $staged_files = *src/ui-blueprint-resources.in* ]]; then - check_blueprint_resources - echo "" - fi - if [[ $staged_files = *data/resources/resources.gresource.xml* ]]; then - check_resources "data/resources/resources.gresource.xml" - echo "" - fi -else - check_blueprint_resources - echo "" - check_resources "data/resources/resources.gresource.xml" - echo "" -fi -run_cargo_sort -echo "" diff --git a/hooks/checks/Cargo.lock b/hooks/checks/Cargo.lock new file mode 100644 index 00000000..4eae7674 --- /dev/null +++ b/hooks/checks/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "checks" +version = "0.1.0" diff --git a/hooks/checks/Cargo.toml b/hooks/checks/Cargo.toml new file mode 100644 index 00000000..3b9b3696 --- /dev/null +++ b/hooks/checks/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "checks" +version = "0.1.0" +license = "GPL-3.0-or-later" +edition = "2024" +rust-version = "1.85" +publish = false + +[dependencies] diff --git a/hooks/checks/src/main.rs b/hooks/checks/src/main.rs new file mode 100644 index 00000000..d3fb7caf --- /dev/null +++ b/hooks/checks/src/main.rs @@ -0,0 +1,724 @@ +use std::{ + collections::BTreeSet, + fs::File, + io::{BufRead, BufReader}, + path::Path, + process::{ExitCode, Output}, + sync::atomic::{AtomicBool, Ordering}, +}; + +mod utils; + +use crate::utils::{ + CargoManifest, CheckDependency, CommandData, GitStagedFiles, InstallationCommand, + check_files_sorted, file_contains, load_files, print_error, visit_dir, +}; + +/// The path to the directory containing the workspace. +const WORKSPACE_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../"); + +/// Whether to use verbose output. +/// +/// Prefer to use [`is_verbose()`], which hides the complexity of the +/// [`AtomicBool`] API. +static VERBOSE: AtomicBool = AtomicBool::new(false); + +/// Whether to use verbose output. +/// +/// This is helper around the [`VERBOSE`] variable to simplify the API. +fn is_verbose() -> bool { + VERBOSE.load(Ordering::Relaxed) +} + +fn main() -> ExitCode { + let result = ScriptCommand::parse_args().and_then(|cmd| cmd.run()); + println!(); + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => error.exit_code(), + } +} + +/// The possible commands to run in this script. +enum ScriptCommand { + /// A command that prints the help of this script. + PrintHelp(PrintHelpCmd), + /// A command that prints the version of this script. + PrintVersion(PrintVersionCmd), + /// A command that runs conformity checks on the current Rust and GTK /// + /// project. + Check(CheckCmd), +} + +impl ScriptCommand { + /// Parse the arguments passed to this script. + fn parse_args() -> Result { + let mut args = std::env::args(); + let _script_path = args + .next() + .expect("the first argument to the script should be its path"); + + let mut check_cmd = CheckCmd::default(); + let mut git_staged = false; + + for arg in args { + if let Some(long_flag) = arg.strip_prefix("--") { + match long_flag { + "git-staged" => { + git_staged = true; + } + "force-install" => { + check_cmd.force_install = true; + } + "verbose" => { + VERBOSE.store(true, Ordering::Relaxed); + } + "version" => { + return Ok(Self::PrintVersion(PrintVersionCmd)); + } + "help" => { + return Ok(Self::PrintHelp(PrintHelpCmd)); + } + _ => { + print_error(&format!("unsupported flag `--{long_flag}`")); + PrintHelpCmd.run(); + return Err(ScriptError::Check); + } + } + } else if let Some(short_flags) = arg.strip_prefix('-') { + // We allow to combine short flags. + for short_flag in short_flags.chars() { + match short_flag { + 's' => { + git_staged = true; + } + 'f' => { + check_cmd.force_install = true; + } + 'v' => { + VERBOSE.store(true, Ordering::Relaxed); + } + 'h' => { + return Ok(Self::PrintHelp(PrintHelpCmd)); + } + _ => { + print_error(&format!("unsupported flag `-{short_flag}`")); + PrintHelpCmd.run(); + return Err(ScriptError::Check); + } + } + } + } else { + print_error(&format!("unsupported argument `{arg}`")); + PrintHelpCmd.run(); + return Err(ScriptError::Check); + } + } + + if git_staged { + check_cmd.staged_files = Some(GitStagedFiles::load()?); + } + + Ok(Self::Check(check_cmd)) + } + + /// Run the current command. + fn run(self) -> Result<(), ScriptError> { + match self { + Self::PrintHelp(cmd) => cmd.run(), + Self::PrintVersion(cmd) => cmd.run(), + Self::Check(cmd) => cmd.run()?, + } + + Ok(()) + } +} + +/// A command that prints the help of this script. +struct PrintHelpCmd; + +impl PrintHelpCmd { + /// Run this command. + fn run(self) { + let CargoManifest { name, .. } = CargoManifest::load(); + println!( + "\ +Run conformity checks on the current Rust project. + +If a dependency is not found, helps the user to install it. + +USAGE: {name} [OPTIONS] + +OPTIONS: + -s, --git-staged Only check files staged to be committed + -f, --force-install Install missing dependencies without asking + -v, --verbose Use verbose output + --version Print the version of this script + -h, --help Print this help and exit + +ERROR CODES: + 1 Check failed + 2 Setup failed +", + ); + } +} + +/// A command that prints the version of this script. +struct PrintVersionCmd; + +impl PrintVersionCmd { + /// Run this command. + fn run(self) { + let CargoManifest { name, version } = CargoManifest::load(); + println!("{name} {version}"); + } +} + +/// A command that runs conformity checks on the current Rust and GTK project. +#[derive(Default)] +struct CheckCmd { + /// The staged files to check. + /// + /// If this is `None`, all files are checked. + staged_files: Option, + /// Whether to install missing dependencies without asking. + force_install: bool, +} + +impl CheckCmd { + /// Run this command. + fn run(self) -> Result<(), ScriptError> { + self.code_style()?; + self.lint_script()?; + self.spelling()?; + self.unused_dependencies()?; + self.allowed_dependencies()?; + self.potfiles()?; + self.blueprints()?; + self.data_gresources()?; + self.cargo_manifest()?; + + Ok(()) + } + + /// Check code style with rustfmt nightly. + fn code_style(&self) -> Result<(), ScriptError> { + let mut check = Check::start("code style") + .with_fix("either manually or by running: cargo +nightly fmt --all"); + + CheckDependency { + name: "rustfmt", + version: CommandData::new("cargo", &["+nightly", "fmt", "--version"]), + install: InstallationCommand::Custom(CommandData::new( + "rustup", + &["component", "add", "--toolchain", "nightly", "rustfmt"], + )), + } + .check(self.force_install)?; + + if let Some(staged_files) = &self.staged_files { + let cmd = CommandData::new( + "cargo", + &[ + "+nightly", + "fmt", + "--check", + "--", + "--unstable-features", + "--skip-children", + ], + ) + .print_output(); + + for rust_file in staged_files.filter(|file| file.ends_with(".rs")) { + let output = cmd.run_with_args(&[rust_file])?; + check.merge_output(output); + } + } else { + let output = CommandData::new("cargo", &["+nightly", "fmt", "--check", "--all"]) + .print_output() + .run()?; + check.merge_output(output); + } + + check.end() + } + + /// Lint this crate with clippy and rustfmt. + fn lint_script(&self) -> Result<(), ScriptError> { + if self + .staged_files + .as_ref() + .is_some_and(|staged_files| !staged_files.any(|file| file.starts_with("hooks/checks"))) + { + // No check necessary. + return Ok(()); + } + + let mut check = Check::start("hooks/checks"); + + CheckDependency { + name: "clippy", + version: CommandData::new("cargo", &["clippy", "--version"]), + install: InstallationCommand::Custom(CommandData::new( + "rustup", + &["component", "add", "clippy"], + )), + } + .check(self.force_install)?; + + let manifest_path = format!("{WORKSPACE_DIR}/hooks/checks/Cargo.toml"); + + let output = CommandData::new("cargo", &["clippy", "--all-targets", "--manifest-path"]) + .print_output() + .run_with_args(&[&manifest_path])?; + check.merge_output(output); + + // We should have already checked that rustfmt is installed. + let output = CommandData::new("cargo", &["+nightly", "fmt", "--check", "--manifest-path"]) + .print_output() + .run_with_args(&[&manifest_path])?; + check.merge_output(output); + + check.end() + } + + /// Check spelling with typos. + fn spelling(&self) -> Result<(), ScriptError> { + let mut check = + Check::start("spelling mistakes").with_fix("either manually or by running: typos -w"); + + CheckDependency { + name: "typos", + version: CommandData::new("typos", &["--version"]), + install: InstallationCommand::Cargo("typos-cli"), + } + .check(self.force_install)?; + + let cmd = CommandData::new("typos", &["--color", "always"]).print_output(); + + let output = if let Some(staged_files) = &self.staged_files { + cmd.run_with_args(staged_files.as_slice())? + } else { + cmd.run()? + }; + + check.merge_output(output); + check.end() + } + + /// Check unused dependencies with cargo-machete. + fn unused_dependencies(&self) -> Result<(), ScriptError> { + let mut check = Check::start("unused dependencies").with_fix( + "either by removing the dependencies, or by adding \ + the necessary configuration option in Cargo.toml \ + (see cargo-machete documentation)", + ); + + CheckDependency { + name: "cargo-machete", + version: CommandData::new("cargo-machete", &["--version"]), + install: InstallationCommand::Cargo("cargo-machete"), + } + .check(self.force_install)?; + + let output = CommandData::new("cargo-machete", &["--with-metadata"]) + .print_output() + .run()?; + + check.merge_output(output); + check.end() + } + + /// Check allowed dependencies with cargo-deny. + fn allowed_dependencies(&self) -> Result<(), ScriptError> { + let mut check = Check::start("allowed dependencies").with_fix( + "either by removing the dependencies, or by adding \ + the necessary configuration option in deny.toml \ + (see cargo-deny documentation)", + ); + + CheckDependency { + name: "cargo-deny", + version: CommandData::new("cargo", &["deny", "--version"]), + install: InstallationCommand::Cargo("cargo-deny"), + } + .check(self.force_install)?; + + let output = CommandData::new("cargo", &["deny", "check"]) + .print_output() + .run()?; + + check.merge_output(output); + check.end() + } + + /// Check that files in `POTFILES.in` and `POTFILES.skip` are correct. + /// + /// This applies the following checks, in that order: + /// + /// - All listed files exist + /// - All files with translatable strings are listed and only those + /// - Listed files are sorted alphabetically + /// - No Rust files use the gettext-rs macros + /// + /// This assumes the following: + /// + /// - The POTFILES are located at `po/POTFILES.(in/skip)`. + /// - UI (GtkBuilder) files are located under `src` and use + /// `translatable="yes"`. + /// - Blueprint files are located under `src` and use `_(`. + /// - Rust files are located under `src` and use `*gettext(_f)` methods. + fn potfiles(&self) -> Result<(), ScriptError> { + let base_dir = Path::new(WORKSPACE_DIR); + + let potfiles_in_exist_check = Check::start("files exist in po/POTFILES.in"); + let Ok(potfiles_in) = load_files(base_dir, Path::new("po/POTFILES.in")) else { + return potfiles_in_exist_check.fail(); + }; + potfiles_in_exist_check.end()?; + + let potfiles_in_order_check = + Check::start("files are ordered alphabetically in po/POTFILES.in"); + if check_files_sorted(base_dir, &potfiles_in).is_err() { + return potfiles_in_order_check.fail(); + } + potfiles_in_order_check.end()?; + + let potfiles_skip_exist_check = Check::start("files exist in po/POTFILES.skip"); + let Ok(potfiles_skip) = load_files(base_dir, Path::new("po/POTFILES.skip")) else { + return potfiles_skip_exist_check.fail(); + }; + potfiles_skip_exist_check.end()?; + + let potfiles_skip_order_check = + Check::start("files are ordered alphabetically in po/POTFILES.skip"); + if check_files_sorted(base_dir, &potfiles_skip).is_err() { + return potfiles_skip_order_check.fail(); + } + potfiles_skip_order_check.end()?; + + let mut translatable_files_check = Check::start( + "all files with translatable strings are present in po/POTFILES.in or po/POTFILES.skip", + ); + let mut translatable_ui = BTreeSet::new(); + let mut translatable_blp = BTreeSet::new(); + let mut translatable_rs = BTreeSet::new(); + + visit_dir(&base_dir.join("src"), &mut |path| { + if potfiles_skip.contains(&path) { + return; + } + let Some(extension) = path.extension() else { + return; + }; + + if extension == "ui" { + if file_contains(&path, &[r#"translatable="yes""#]) { + translatable_ui.insert(path); + } + } else if extension == "blp" { + if file_contains(&path, &["_("]) { + translatable_blp.insert(path); + } + } else if extension == "rs" { + if file_contains(&path, &["gettext!("]) { + let relative_path = path + .strip_prefix(base_dir) + .expect("all visited files should be in the workspace") + .to_owned(); + + print_error(&format!( + "file '{}' uses a gettext-rs macro, use the corresponding i18n method instead", + relative_path.to_string_lossy(), + )); + translatable_files_check.record_failure(); + } + if file_contains(&path, &["gettext(", "gettext_f("]) { + translatable_rs.insert(path); + } + } + }); + + let mut potfiles_in_ui = BTreeSet::new(); + let mut potfiles_in_blp = BTreeSet::new(); + let mut potfiles_in_rs = BTreeSet::new(); + + for path in potfiles_in { + let Some(extension) = path.extension() else { + continue; + }; + + if extension == "ui" { + potfiles_in_ui.insert(path); + } else if extension == "blp" { + potfiles_in_blp.insert(path); + } else if extension == "rs" { + potfiles_in_rs.insert(path); + } + } + + let not_translatable = potfiles_in_ui + .difference(&translatable_ui) + .chain(potfiles_in_blp.difference(&translatable_blp)) + .chain(potfiles_in_rs.difference(&translatable_rs)) + .collect::>(); + if !not_translatable.is_empty() { + translatable_files_check.record_failure(); + let count = not_translatable.len(); + + if count == 1 { + print_error("Found 1 file with translatable strings not present in POTFILES.in:"); + } else { + print_error(&format!( + "Found {count} files with translatable strings not present in POTFILES.in:" + )); + } + } + for path in not_translatable { + let relative_path = path + .strip_prefix(base_dir) + .expect("all visited files should be in the workspace") + .to_owned(); + + println!("{}", relative_path.to_string_lossy()); + } + + let missing_translatable = translatable_ui + .difference(&potfiles_in_ui) + .chain(translatable_blp.difference(&potfiles_in_blp)) + .chain(translatable_rs.difference(&potfiles_in_rs)) + .collect::>(); + if !missing_translatable.is_empty() { + translatable_files_check.record_failure(); + let count = missing_translatable.len(); + + if count == 1 { + print_error("Found 1 file in POTFILES.in without translatable strings:"); + } else { + print_error(&format!( + "Found {count} files in POTFILES.in without translatable strings:" + )); + } + } + for path in missing_translatable { + let relative_path = path + .strip_prefix(base_dir) + .expect("all visited files should be in the workspace") + .to_owned(); + + println!("{}", relative_path.to_string_lossy()); + } + + translatable_files_check.end() + } + + /// Check `src/ui-blueprint-resources.in`. + /// + /// Checks that the files exist and are sorted alphabetically. + fn blueprints(&self) -> Result<(), ScriptError> { + let base_dir = Path::new(WORKSPACE_DIR).join("src"); + + if self.staged_files.as_ref().is_some_and(|staged_files| { + !staged_files.any(|file| file == "src/ui-blueprint-resources.in") + }) { + // No check necessary. + return Ok(()); + } + + let files_exist_check = Check::start("files exist in src/ui-blueprint-resources.in"); + let Ok(blueprint_files) = load_files(&base_dir, Path::new("ui-blueprint-resources.in")) + else { + return files_exist_check.fail(); + }; + files_exist_check.end()?; + + let files_order_check = + Check::start("files are ordered alphabetically in src/ui-blueprint-resources.in"); + if check_files_sorted(&base_dir, &blueprint_files).is_err() { + return files_order_check.fail(); + } + files_order_check.end() + } + + /// Check that files listed in `data/resources/resources.gresource.xml` are + /// sorted alphabetically. + fn data_gresources(&self) -> Result<(), ScriptError> { + const GRESOURCES_PATH: &str = "data/resources/resources.gresource.xml"; + + if self + .staged_files + .as_ref() + .is_some_and(|staged_files| !staged_files.any(|file| file == GRESOURCES_PATH)) + { + // No check necessary. + return Ok(()); + } + + let check = Check::start( + "files are ordered alphabetically in data/resources/resources.gresource.xml", + ); + + let reader = match File::open(Path::new(WORKSPACE_DIR).join(GRESOURCES_PATH)) { + Ok(file) => BufReader::new(file), + Err(error) => { + print_error(&format!("could not open file `{GRESOURCES_PATH}`: {error}")); + return check.fail(); + } + }; + + let mut previous_file_path: Option = None; + + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(error) => { + print_error(&format!( + "could not read line of file `{GRESOURCES_PATH}`: {error}" + )); + return check.fail(); + } + }; + + // The file path is between `` and ``. + let Some((file_path, _)) = line + .split_once("')) + .and_then(|(_, line_end)| line_end.split_once("")) + else { + continue; + }; + + if let Some(previous_file_path) = previous_file_path.as_deref() { + if previous_file_path > file_path { + print_error(&format!("file `{previous_file_path}` before `{file_path}`")); + return check.fail(); + } + } + + previous_file_path = Some(file_path.to_owned()); + } + + check.end() + } + + /// Check `Cargo.toml` with cargo-sort. + fn cargo_manifest(&self) -> Result<(), ScriptError> { + if self + .staged_files + .as_ref() + .is_some_and(|staged_files| !staged_files.any(|file| file == "Cargo.toml")) + { + // No check necessary. + return Ok(()); + } + + let mut check = Check::start("Cargo.toml sorting").with_fix( + "either manually or by running: cargo-sort --grouped --order \ + package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies", + ); + + CheckDependency { + name: "cargo-sort", + version: CommandData::new("cargo", &["sort", "--version"]), + install: InstallationCommand::Cargo("cargo-sort"), + } + .check(self.force_install)?; + + let output = CommandData::new( + "cargo", + &["sort", "--check", "--grouped", "--order", "workspace,package,lib,profile,features,dependencies,target,dev-dependencies,build-dependencies"] + ).print_output().run()?; + + check.merge_output(output); + check.end() + } +} + +/// A check in this script. +struct Check { + /// The name of this check. + name: &'static str, + /// The way to fix a failure of this check. + fix: Option<&'static str>, + /// Whether this check was successful. + success: bool, +} + +impl Check { + /// Start the check with the given name. + fn start(name: &'static str) -> Self { + println!("\n\x1B[1;92mChecking\x1B[0m {name}"); + + Self { + name, + fix: None, + success: true, + } + } + + /// Set the way to fix a failure of this check. + fn with_fix(mut self, fix: &'static str) -> Self { + self.fix = Some(fix); + self + } + + /// Record the failure of this check. + fn record_failure(&mut self) { + self.success = false; + } + + /// Merge the given output for the result of this check. + fn merge_output(&mut self, output: Output) { + self.success &= output.status.success(); + } + + /// Finish this check. + /// + /// Print the result and convert it to a Rust result. + fn end(self) -> Result<(), ScriptError> { + let Self { name, fix, success } = self; + + if success { + println!("Checking {name} result: \x1B[1;92mok\x1B[0m",); + Ok(()) + } else { + println!("Checking {name} result: \x1B[1;91mfail\x1B[0m",); + + if let Some(fix) = fix { + println!("Please fix the above issues, {fix}"); + } else { + println!("Please fix the above issues"); + } + + Err(ScriptError::Check) + } + } + + /// Fail this check immediately. + fn fail(mut self) -> Result<(), ScriptError> { + self.record_failure(); + self.end() + } +} + +/// The possible errors returned by this script. +enum ScriptError { + /// A check failed. + Check, + /// The setup for a check failed. + Setup, +} + +impl ScriptError { + /// The exit code to return for this error. + fn exit_code(&self) -> ExitCode { + match self { + Self::Check => 1, + Self::Setup => 2, + } + .into() + } +} diff --git a/hooks/checks/src/utils.rs b/hooks/checks/src/utils.rs new file mode 100644 index 00000000..40061a84 --- /dev/null +++ b/hooks/checks/src/utils.rs @@ -0,0 +1,467 @@ +use std::{ + fs::{self, File}, + io::{BufRead, BufReader, IsTerminal, Write, stdin, stdout}, + path::{Path, PathBuf}, + process::{Command, Output, Stdio}, +}; + +use crate::{ScriptError, is_verbose}; + +/// The data from this script's cargo manifest. +pub(crate) struct CargoManifest { + /// The name of this script. + pub(crate) name: String, + /// The version of this script. + pub(crate) version: String, +} + +impl CargoManifest { + /// Load the script's cargo manifest data. + pub(crate) fn load() -> Self { + let manifest = include_str!("../Cargo.toml"); + let mut name = None; + let mut version = None; + + for line in manifest.lines().map(str::trim) { + if let Some(value) = line.strip_prefix("name = ") { + name = Some(value.trim_matches('"').to_owned()) + } else if let Some(value) = line.strip_prefix("version = ") { + version = Some(value.trim_matches('"').to_owned()) + } + } + + Self { + name: name.expect("name should be in cargo manifest"), + version: version.expect("version should be in cargo manifest"), + } + } +} + +/// Files staged for git. +pub(crate) struct GitStagedFiles(Vec); + +impl GitStagedFiles { + /// Load the staged files from git. + pub(crate) fn load() -> Result { + let output = CommandData::new( + "git", + &["diff", "--name-only", "--cached", "--diff-filter=d"], + ) + .run()?; + + if !output.status.success() { + print_error(&format!( + "could not get the list of staged files: {}", + String::from_utf8(output.stderr).expect("git output should be valid UTF-8"), + )); + return Err(ScriptError::Check); + } + + let files = String::from_utf8(output.stdout) + .expect("git output should be valid UTF-8") + .trim() + .lines() + .map(|line| line.trim()) + .filter(|line| !line.is_empty()) + .map(ToOwned::to_owned) + .collect::>(); + + if files.is_empty() { + print_error("could not check staged files: no files are staged"); + return Err(ScriptError::Setup); + } + + Ok(Self(files)) + } + + /// Access the inner slice of this list. + pub(crate) fn as_slice(&self) -> &[String] { + &self.0 + } + + /// Whether any of the files in this list match the given predicate. + pub(crate) fn any(&self, f: F) -> bool + where + F: Fn(&str) -> bool, + { + self.0.iter().any(|file| f(file.as_str())) + } + + /// Filter this list with the given predicate. + pub(crate) fn filter(&self, f: F) -> impl Iterator + where + F: Fn(&str) -> bool, + { + self.0.iter().filter_map(move |file| { + let file = file.as_str(); + f(file).then_some(file) + }) + } +} + +/// A check for the presence of a dependency. +#[derive(Clone, Copy)] +pub(crate) struct CheckDependency { + /// The name of the dependency. + pub(crate) name: &'static str, + /// The command to print the version of the dependency. + /// + /// It will be used to check whether the dependency is available. + pub(crate) version: CommandData, + /// The command to run to install the dependency. + pub(crate) install: InstallationCommand, +} + +impl CheckDependency { + /// Check whether the dependency is available. + /// + /// Returns `Ok` if the dependency was available or successfully installed. + pub(crate) fn check(self, force_install: bool) -> Result<(), ScriptError> { + let Self { + name, + version, + install, + } = self; + + let version = if is_verbose() { + version.print_output() + } else { + version.ignore_output() + }; + + // Do not forward errors here as it might just be the program that is missing. + if version.run().is_ok_and(|output| output.status.success()) { + // The dependency is available. + return Ok(()); + } + + if !force_install { + self.ask_install()?; + } + + println!("\x1B[1;92mInstalling\x1B[0m {name}…"); + + if install.run()?.status.success() && version.run()?.status.success() { + // The dependency was installed successfully. + Ok(()) + } else { + print_error(&format!("could not install {name}",)); + Err(ScriptError::Setup) + } + } + + /// Ask the user whether we should try to install the dependency, if we are + /// in a terminal. + fn ask_install(self) -> Result<(), ScriptError> { + let name = self.name; + + let stdin = stdin(); + + if !stdin.is_terminal() { + print_error(&format!("could not run {name}")); + return Err(ScriptError::Setup); + } + + println!("{name} is needed for this check, but it isn’t available\n"); + println!("y: Install {name} via {}", self.install.via()); + println!("N: Don’t install {name} and abort checks\n"); + + let mut input = String::new(); + let mut stdout = stdout(); + + // Repeat the question until the user selects a proper response. + loop { + print!("Install {name}? [y/N]: "); + stdout.flush().expect("should succeed to flush stdout"); + + let mut handle = stdin.lock(); + handle + .read_line(&mut input) + .expect("should succeed to read from stdin"); + + input = input.trim().to_ascii_lowercase(); + + match input.as_str() { + "y" | "yes" => return Ok(()), + "n" | "no" | "" => return Err(ScriptError::Setup), + _ => { + println!(); + print_error("invalid input"); + } + } + + input.clear(); + } + } +} + +#[derive(Clone, Copy)] +pub(crate) struct CommandData { + /// The program to execute. + program: &'static str, + /// The arguments of the program. + args: &'static [&'static str], + /// The behavior for the output of this command. + output: OutputBehavior, +} + +impl CommandData { + /// Create a new `CommandData` for the given program and arguments. + /// + /// By default stdout and stderr will be available in the output of the + /// command. + #[must_use] + pub(crate) fn new(program: &'static str, args: &'static [&'static str]) -> Self { + Self { + program, + args, + output: OutputBehavior::Read, + } + } + + /// Print the output of the command in the shell. + #[must_use] + pub(crate) fn print_output(mut self) -> Self { + self.output = OutputBehavior::Print; + self + } + + /// Ignore the output of the command. + /// + /// It will neither be printed or read. + #[must_use] + pub(crate) fn ignore_output(mut self) -> Self { + self.output = OutputBehavior::Ignore; + self + } + + /// Get the string representation of this command with the given extra + /// arguments. + fn to_string_with_args(self, extra_args: &[impl AsRef]) -> String { + let mut string = self.program.to_owned(); + + for arg in self + .args + .iter() + .copied() + .chain(extra_args.iter().map(AsRef::as_ref)) + { + string.push(' '); + string.push_str(arg); + } + + string + } + + /// Run this command. + pub(crate) fn run(self) -> Result { + self.run_with_args(&[] as &[&str]) + } + + /// Run this command with the given extra arguments. + pub(crate) fn run_with_args( + self, + extra_args: &[impl AsRef], + ) -> Result { + if is_verbose() { + println!("\x1B[90m{}\x1B[0m", self.to_string_with_args(extra_args)); + } + + let mut cmd = Command::new(self.program); + cmd.args(self.args); + + if !extra_args.is_empty() { + cmd.args(extra_args.iter().map(AsRef::as_ref)); + } + + match self.output { + OutputBehavior::Read => {} + OutputBehavior::Print => { + cmd.stdout(Stdio::inherit()).stderr(Stdio::inherit()); + } + OutputBehavior::Ignore => { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + } + } + + cmd.output().map_err(|error| { + print_error(&format!("could not run command: {error}")); + ScriptError::Check + }) + } +} + +/// The behavior for the output of a command. +#[derive(Clone, Copy)] +enum OutputBehavior { + /// Read the output. + Read, + /// Print the output in the shell. + Print, + /// Ignore the output. + Ignore, +} + +/// The command to use to install a dependency. +#[derive(Clone, Copy)] +pub(crate) enum InstallationCommand { + /// Use `cargo install` for the given crate. + Cargo(&'static str), + /// Use the given command. + Custom(CommandData), +} + +impl InstallationCommand { + /// The program used for the installation. + pub(crate) fn via(self) -> &'static str { + match self { + Self::Cargo(_) => "cargo", + Self::Custom(cmd) => cmd.program, + } + } + + /// Run this command. + pub(crate) fn run(self) -> Result { + match self { + Self::Cargo(dep) => CommandData::new("cargo", &["install"]) + .print_output() + .run_with_args(&[dep]), + Self::Custom(cmd) => cmd.print_output().run(), + } + } +} + +/// Visit the given directory recursively and apply the given function to files. +pub(crate) fn visit_dir(dir: &Path, on_file: &mut dyn FnMut(PathBuf)) { + let dir_entries = match fs::read_dir(dir) { + Ok(dir_entries) => dir_entries, + Err(error) => { + print_error(&format!( + "could not read entries in directory `{}`: {error}", + dir.to_string_lossy() + )); + return; + } + }; + + for entry in dir_entries { + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + print_error(&format!( + "could not read entry in directory `{}`: {error}", + dir.to_string_lossy() + )); + continue; + } + }; + + let path = entry.path(); + + if path.is_dir() { + visit_dir(&path, on_file); + } else { + on_file(path); + } + } +} + +/// Whether the given file contains one of the given strings. +/// +/// Logs errors when reading a file. +pub(crate) fn file_contains(path: &Path, needles: &[&str]) -> bool { + let reader = match File::open(path) { + Ok(file) => BufReader::new(file), + Err(error) => { + print_error(&format!( + "could not open file `{}`: {error}", + path.to_string_lossy() + )); + return false; + } + }; + + for line in reader.lines() { + let line = match line { + Ok(line) => line, + Err(error) => { + print_error(&format!( + "could not read line of file `{}`: {error}", + path.to_string_lossy() + )); + return false; + } + }; + + if needles.iter().any(|needle| line.contains(needle)) { + return true; + } + } + + false +} + +/// Load a list of files from the given file under the given base directory. +/// +/// The path of the file to load must be relative to the base directory. +/// +/// Each file must be on its own line, and lines that start with `#` are +/// ignored. Each file must be relative to the base directory. +/// +/// Returns an error if any of the files doesn't exist. The files that don't +/// exist are printed. +pub(crate) fn load_files(base_dir: &Path, file: &Path) -> Result, ()> { + let mut success = true; + + let files = fs::read_to_string(base_dir.join(file)) + .expect("file should be readable") + .lines() + .map(str::trim) + .filter(|line| !line.starts_with('#') && !line.is_empty()) + .map(|relative_path| { + let path = base_dir.join(relative_path); + + if !path.exists() { + print_error(&format!("file `{relative_path}` does not exist")); + success = false; + } + + path + }) + .collect::>(); + + success.then_some(files).ok_or(()) +} + +/// Check whether the given list of files is ordered alphabetically. +/// +/// Returns an error at the first file in the wrong place. The file is printed. +pub(crate) fn check_files_sorted(base_dir: &Path, files: &[PathBuf]) -> Result<(), ()> { + if let Some((file1, file2)) = files + .windows(2) + .find_map(|window| (window[0] > window[1]).then_some((&window[0], &window[1]))) + { + let relative_file1 = file1 + .strip_prefix(base_dir) + .expect("all files should be in the base directory") + .to_owned(); + let relative_file2 = file2 + .strip_prefix(base_dir) + .expect("all files should be in the base directory") + .to_owned(); + + print_error(&format!( + "file `{}` before `{}`", + relative_file1.to_string_lossy(), + relative_file2.to_string_lossy(), + )); + return Err(()); + } + + Ok(()) +} + +/// Print an error message. +pub(crate) fn print_error(msg: &str) { + println!("\x1B[91merror:\x1B[0m {msg}"); +} diff --git a/hooks/pre-commit.hook b/hooks/pre-commit.hook index 4960692e..5ecc9759 100755 --- a/hooks/pre-commit.hook +++ b/hooks/pre-commit.hook @@ -1,5 +1,5 @@ #!/bin/bash -# Depends on: hooks/checks.sh +# Depends on: hooks/checks-bin (generated with `meson setup`) # Style helpers act="\e[1;32m" @@ -11,7 +11,7 @@ res="\e[0m" echo "-- Pre-commit checks --" echo "To ignore these checks next time, run: git commit --no-verify" echo "" -if hooks/checks.sh --git-staged; then +if hooks/checks-bin --git-staged; then echo "" echo -e "Pre-commit checks result: ${pos}ok${res}" elif [[ $? -eq 2 ]]; then diff --git a/meson.build b/meson.build index 18f2e8b7..d3127987 100644 --- a/meson.build +++ b/meson.build @@ -96,8 +96,16 @@ else profile = 'Stable' endif +cargo_target_dir = meson.project_build_root() / 'cargo-target' +cargo_home = meson.project_build_root() / 'cargo-home' + +cargo_env = [ + 'CARGO_HOME=' + cargo_home, + 'CARGO_TARGET_DIR=' + cargo_target_dir, +] + if profile == 'Devel' - vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD').stdout().strip() + vcs_tag = run_command('git', 'rev-parse', '--short', 'HEAD', check: false).stdout().strip() if vcs_tag == '' devel_version = profile.to_lower() else @@ -105,11 +113,41 @@ if profile == 'Devel' endif full_version += '-' + devel_version - release_date = run_command('git', 'show', '-s', '--format=%cI').stdout().strip() + release_date = run_command('git', 'show', '-s', '--format=%cI', check: true).stdout().strip() # Setup pre-commit hook for ensuring coding style is always consistent - message('Setting up git pre-commit hook…') - run_command('cp', '-f', 'hooks/pre-commit.hook', '.git/hooks/pre-commit') + checks_build_result = run_command( + 'env', + cargo_env, + cargo, + 'build', + '--manifest-path', + 'hooks/checks/Cargo.toml', + check: true, + ) + + if checks_build_result.returncode() == 0 + run_command( + 'ln', + '-s', + cargo_target_dir / 'debug/checks', + 'hooks/checks-bin', + check: false, + ) + cp_result = run_command( + 'cp', + '-f', + 'hooks/pre-commit.hook', + '.git/hooks/pre-commit', + check: false, + ) + + if cp_result.returncode() == 0 + message('Pre-commit hook installed') + else + message('Could not install pre-commit hook: ' + cp_result.stderr()) + endif + endif endif # Generate nextest config file diff --git a/src/meson.build b/src/meson.build index 99e69cdb..e9c3c943 100644 --- a/src/meson.build +++ b/src/meson.build @@ -97,13 +97,6 @@ else message('Building in release mode') endif -cargo_target_dir = meson.project_build_root() / 'cargo-target' - -cargo_env = [ - 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home', - 'CARGO_TARGET_DIR=' + cargo_target_dir, -] - if not build_env_only # Build binary with cargo custom_target(