Browse Source

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.
merge-requests/2136/head
Kévin Commaille 2 months ago
parent
commit
0eaadd2439
No known key found for this signature in database
GPG Key ID: F26F4BE20A08255B
  1. 3
      .gitignore
  2. 2
      .gitlab-ci/check.yml
  3. 2
      CONTRIBUTING.md
  4. 798
      hooks/checks.sh
  5. 7
      hooks/checks/Cargo.lock
  6. 9
      hooks/checks/Cargo.toml
  7. 724
      hooks/checks/src/main.rs
  8. 467
      hooks/checks/src/utils.rs
  9. 4
      hooks/pre-commit.hook
  10. 46
      meson.build
  11. 7
      src/meson.build

3
.gitignore vendored

@ -15,5 +15,6 @@ target/
*.ui.in~ *.ui.in~
*.ui~ *.ui~
# Dynamically-generated file # Dynamically-generated files
src/config.rs src/config.rs
hooks/checks-bin

2
.gitlab-ci/check.yml

@ -8,7 +8,7 @@ pre-commit-checks:
image: "rustlang/rust:nightly-slim" image: "rustlang/rust:nightly-slim"
interruptible: true interruptible: true
script: 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 # Checks needing dependencies in the Flatpak runtime
flatpak-checks: flatpak-checks:

2
CONTRIBUTING.md

@ -129,7 +129,7 @@ sudo ninja -C _build install
## Pre-commit ## Pre-commit
We expect all code contributions to be correctly formatted. To help with that, a pre-commit hook 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 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, things. Make sure that this script is effectively run before submitting your merge request,
otherwise CI will probably fail right away. otherwise CI will probably fail right away.

798
hooks/checks.sh

@ -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="<file .*>(.*)</file>"
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 ""

7
hooks/checks/Cargo.lock generated

@ -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"

9
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]

724
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<Self, ScriptError> {
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<GitStagedFiles>,
/// 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::<Vec<_>>();
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::<Vec<_>>();
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<String> = 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 `<file*>` and `</file>`.
let Some((file_path, _)) = line
.split_once("<file")
.and_then(|(_, line_end)| line_end.split_once('>'))
.and_then(|(_, line_end)| line_end.split_once("</file>"))
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()
}
}

467
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<String>);
impl GitStagedFiles {
/// Load the staged files from git.
pub(crate) fn load() -> Result<Self, ScriptError> {
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::<Vec<_>>();
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<F>(&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<F>(&self, f: F) -> impl Iterator<Item = &str>
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<str>]) -> 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<Output, ScriptError> {
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<str>],
) -> Result<Output, ScriptError> {
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<Output, ScriptError> {
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<Vec<PathBuf>, ()> {
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::<Vec<_>>();
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}");
}

4
hooks/pre-commit.hook

@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# Depends on: hooks/checks.sh # Depends on: hooks/checks-bin (generated with `meson setup`)
# Style helpers # Style helpers
act="\e[1;32m" act="\e[1;32m"
@ -11,7 +11,7 @@ res="\e[0m"
echo "-- Pre-commit checks --" echo "-- Pre-commit checks --"
echo "To ignore these checks next time, run: git commit --no-verify" echo "To ignore these checks next time, run: git commit --no-verify"
echo "" echo ""
if hooks/checks.sh --git-staged; then if hooks/checks-bin --git-staged; then
echo "" echo ""
echo -e "Pre-commit checks result: ${pos}ok${res}" echo -e "Pre-commit checks result: ${pos}ok${res}"
elif [[ $? -eq 2 ]]; then elif [[ $? -eq 2 ]]; then

46
meson.build

@ -96,8 +96,16 @@ else
profile = 'Stable' profile = 'Stable'
endif 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' 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 == '' if vcs_tag == ''
devel_version = profile.to_lower() devel_version = profile.to_lower()
else else
@ -105,11 +113,41 @@ if profile == 'Devel'
endif endif
full_version += '-' + devel_version 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 # Setup pre-commit hook for ensuring coding style is always consistent
message('Setting up git pre-commit hook…') checks_build_result = run_command(
run_command('cp', '-f', 'hooks/pre-commit.hook', '.git/hooks/pre-commit') '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 endif
# Generate nextest config file # Generate nextest config file

7
src/meson.build

@ -97,13 +97,6 @@ else
message('Building in release mode') message('Building in release mode')
endif 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 if not build_env_only
# Build binary with cargo # Build binary with cargo
custom_target( custom_target(

Loading…
Cancel
Save