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(