blob: efd20897f4d23be1217ffa609303bfc0123eecfe [file] [log] [blame]
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0 OR MIT
# shellcheck disable=SC2046
set -eEuo pipefail
IFS=$'\n\t'
cd "$(dirname "$0")"/..
# shellcheck disable=SC2154
trap 's=$?; echo >&2 "$0: error on line "${LINENO}": ${BASH_COMMAND}"; exit ${s}' ERR
# USAGE:
# ./tools/tidy.sh
#
# Note: This script requires the following tools:
# - shfmt
# - shellcheck
# - npm
# - jq and yq
# - rustup (if Rust code exists)
# - clang-format (if C/C++ code exists)
#
# This script is shared with other repositories, so there may also be
# checks for files not included in this repository, but they will be
# skipped if the corresponding files do not exist.
check_diff() {
if [[ -n "${CI:-}" ]]; then
if ! git --no-pager diff --exit-code "$@"; then
should_fail=1
fi
else
if ! git --no-pager diff --exit-code "$@" &>/dev/null; then
should_fail=1
fi
fi
}
info() {
echo >&2 "info: $*"
}
warn() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then
echo "::warning::$*"
else
echo >&2 "warning: $*"
fi
should_fail=1
}
error() {
if [[ -n "${GITHUB_ACTIONS:-}" ]]; then
echo "::error::$*"
else
echo >&2 "error: $*"
fi
should_fail=1
}
if [[ $# -gt 0 ]]; then
cat <<EOF
USAGE:
$0
EOF
exit 1
fi
# Rust (if exists)
if [[ -n "$(git ls-files '*.rs')" ]]; then
info "checking Rust code style"
if type -P rustup &>/dev/null; then
# `cargo fmt` cannot recognize files not included in the current workspace and modules
# defined inside macros, so run rustfmt directly.
# We need to use nightly rustfmt because we use the unstable formatting options of rustfmt.
rustc_version=$(rustc -Vv | grep 'release: ' | sed 's/release: //')
if [[ "${rustc_version}" == *"nightly"* ]] || [[ "${rustc_version}" == *"dev"* ]]; then
rustup component add rustfmt &>/dev/null
echo "+ rustfmt \$(git ls-files '*.rs')"
rustfmt $(git ls-files '*.rs')
else
rustup component add rustfmt --toolchain nightly &>/dev/null
echo "+ rustfmt +nightly \$(git ls-files '*.rs')"
rustfmt +nightly $(git ls-files '*.rs')
fi
check_diff $(git ls-files '*.rs')
else
warn "'rustup' is not installed; skipped Rust code style check"
fi
cast_without_turbofish=$(grep -n -E '\.cast\(\)' $(git ls-files '*.rs') || true)
if [[ -n "${cast_without_turbofish}" ]]; then
error "please replace \`.cast()\` with \`.cast::<type_name>()\`:"
echo "${cast_without_turbofish}"
fi
first='1'
for readme in $(git ls-files '*README.md'); do
if ! grep -q '^<!-- tidy:crate-doc:start -->' "${readme}"; then
continue
fi
lib="$(dirname "${readme}")/src/lib.rs"
if [[ -n "${first}" ]]; then
first=''
info "checking readme and crate-level doc are synchronized"
fi
if ! grep -q '^<!-- tidy:crate-doc:end -->' "${readme}"; then
bail "missing '<!-- tidy:crate-doc:end -->' comment in ${readme}"
fi
if ! grep -q '^<!-- tidy:crate-doc:start -->' "${lib}"; then
bail "missing '<!-- tidy:crate-doc:start -->' comment in ${lib}"
fi
if ! grep -q '^<!-- tidy:crate-doc:end -->' "${lib}"; then
bail "missing '<!-- tidy:crate-doc:end -->' comment in ${lib}"
fi
new=$(tr <"${readme}" '\n' '\a' | grep -o '<!-- tidy:crate-doc:start -->.*<!-- tidy:crate-doc:end -->' | sed 's/\&/\\\&/g; s/\\/\\\\/g')
new=$(tr <"${lib}" '\n' '\a' | awk -v new="${new}" 'gsub("<!-- tidy:crate-doc:start -->.*<!-- tidy:crate-doc:end -->",new)' | tr '\a' '\n')
echo "${new}" >"${lib}"
check_diff "${lib}"
done
# Make sure that public Rust crates don't contain executables.
failed_files=''
metadata=$(cargo metadata --format-version=1 --all-features --no-deps)
has_public_crate=''
for id in $(jq <<<"${metadata}" '.workspace_members[]'); do
pkg=$(jq <<<"${metadata}" ".packages[] | select(.id == ${id})")
publish=$(jq <<<"${pkg}" -r '.publish')
# Publishing is unrestricted if null, and forbidden if an empty array.
if [[ "${publish}" == "[]" ]]; then
continue
fi
has_public_crate='1'
done
if [[ -n "${has_public_crate}" ]]; then
info "checking file permissions"
if [[ -f Cargo.toml ]] && grep -Eq '^\[package\]' Cargo.toml && ! grep -Eq '^publish = false' Cargo.toml; then
if ! grep -Eq '^exclude = \[.*\.\*.*\]' Cargo.toml; then
error "top-level Cargo.toml of real manifest should have exclude field with \"/.*\" and \"/tools\""
elif ! grep -Eq '^exclude = \[.*/tools.*\]' Cargo.toml; then
error "top-level Cargo.toml of real manifest should have exclude field with \"/.*\" and \"/tools\""
fi
fi
for p in $(git ls-files); do
# Skip directories.
if [[ -d "${p}" ]]; then
continue
fi
# Top-level hidden files/directories and tools/* are excluded from crates.io (ensured by the above check).
# TODO: fully respect exclude field in Cargo.toml.
case "${p}" in
.* | tools/*) continue ;;
esac
if [[ -x "${p}" ]]; then
failed_files+="${p}"$'\n'
fi
done
if [[ -n "${failed_files}" ]]; then
error "file-permissions-check failed: executable should be in tools/ directory"
echo "======================================="
echo -n "${failed_files}"
echo "======================================="
fi
fi
fi
# C/C++ (if exists)
if [[ -n "$(git ls-files '*.c')$(git ls-files '*.cpp')" ]]; then
info "checking C/C++ code style"
if [[ ! -e .clang-format ]]; then
warn "could not fount .clang-format in the repository root"
fi
if type -P clang-format &>/dev/null; then
echo "+ clang-format -i \$(git ls-files '*.c') \$(git ls-files '*.cpp')"
clang-format -i $(git ls-files '*.c') $(git ls-files '*.cpp')
check_diff $(git ls-files '*.c') $(git ls-files '*.cpp')
else
warn "'clang-format' is not installed; skipped C/C++ code style check"
fi
fi
# YAML/JavaScript/JSON (if exists)
if [[ -n "$(git ls-files '*.yml')$(git ls-files '*.js')$(git ls-files '*.json')" ]]; then
info "checking YAML/JavaScript/JSON code style"
if type -P npm &>/dev/null; then
echo "+ npx -y prettier -l -w \$(git ls-files '*.yml') \$(git ls-files '*.js') \$(git ls-files '*.json')"
npx -y prettier -l -w $(git ls-files '*.yml') $(git ls-files '*.js') $(git ls-files '*.json')
check_diff $(git ls-files '*.yml') $(git ls-files '*.js') $(git ls-files '*.json')
else
warn "'npm' is not installed; skipped YAML/JavaScript/JSON code style check"
fi
# Check GitHub workflows.
if [[ -d .github/workflows ]]; then
info "checking GitHub workflows"
if type -P jq &>/dev/null && type -P yq &>/dev/null; then
for workflow in .github/workflows/*.yml; do
# The top-level permissions must be weak as they are referenced by all jobs.
permissions=$(yq '.permissions' "${workflow}" | jq -c)
case "${permissions}" in
'{"contents":"read"}' | '{"contents":"none"}') ;;
null) error "${workflow}: top level permissions not found; it must be 'contents: read' or weaker permissions" ;;
*) error "${workflow}: only 'contents: read' and weaker permissions are allowed at top level; if you want to use stronger permissions, please set job-level permissions" ;;
esac
# Make sure the 'needs' section is not out of date.
if grep -q '# tidy:needs' "${workflow}" && ! grep -Eq '# *needs: \[' "${workflow}"; then
# shellcheck disable=SC2207
jobs_actual=($(yq '.jobs' "${workflow}" | jq -r 'keys_unsorted[]'))
unset 'jobs_actual[${#jobs_actual[@]}-1]'
# shellcheck disable=SC2207
jobs_expected=($(yq -r '.jobs."ci-success".needs[]' "${workflow}"))
if [[ "${jobs_actual[*]}" != "${jobs_expected[*]+"${jobs_expected[*]}"}" ]]; then
printf -v jobs '%s, ' "${jobs_actual[@]}"
sed -i "s/needs: \[.*\] # tidy:needs/needs: [${jobs%, }] # tidy:needs/" "${workflow}"
check_diff "${workflow}"
error "${workflow}: please update 'needs' section in 'ci-success' job"
fi
fi
done
else
warn "'jq' or 'yq' is not installed; skipped GitHub workflow check"
fi
fi
fi
if [[ -n "$(git ls-files '*.yaml')" ]]; then
error "please use '.yml' instead of '.yaml' for consistency"
git ls-files '*.yaml'
fi
# Markdown (if exists)
if [[ -n "$(git ls-files '*.md')" ]]; then
info "checking Markdown style"
if type -P npm &>/dev/null; then
echo "+ npx -y markdownlint-cli2 \$(git ls-files '*.md')"
npx -y markdownlint-cli2 $(git ls-files '*.md')
else
warn "'npm' is not installed; skipped Markdown style check"
fi
fi
if [[ -n "$(git ls-files '*.markdown')" ]]; then
error "please use '.md' instead of '.markdown' for consistency"
git ls-files '*.markdown'
fi
# Shell scripts
info "checking Shell scripts"
if type -P shfmt &>/dev/null; then
echo "+ shfmt -l -w \$(git ls-files '*.sh')"
shfmt -l -w $(git ls-files '*.sh')
check_diff $(git ls-files '*.sh')
else
warn "'shfmt' is not installed; skipped Shell scripts style check"
fi
if type -P shellcheck &>/dev/null; then
echo "+ shellcheck \$(git ls-files '*.sh')"
if ! shellcheck $(git ls-files '*.sh'); then
should_fail=1
fi
if [[ -n "$(git ls-files '*Dockerfile')" ]]; then
# SC2154 doesn't seem to work on dockerfile.
echo "+ shellcheck -e SC2148,SC2154,SC2250 \$(git ls-files '*Dockerfile')"
if ! shellcheck -e SC2148,SC2154,SC2250 $(git ls-files '*Dockerfile'); then
should_fail=1
fi
fi
else
warn "'shellcheck' is not installed; skipped Shell scripts style check"
fi
# License check
# TODO: This check is still experimental and does not track all files that should be tracked.
if [[ -f tools/.tidy-check-license-headers ]]; then
info "checking license headers (experimental)"
failed_files=''
for p in $(eval $(<tools/.tidy-check-license-headers)); do
case "$(basename "${p}")" in
*.stderr | *.expanded.rs) continue ;; # generated files
*.sh | *.py | *.rb | *Dockerfile) prefix=("# ") ;;
*.rs | *.c | *.h | *.cpp | *.hpp | *.s | *.S | *.js) prefix=("// " "/* ") ;;
*.ld | *.x) prefix=("/* ") ;;
# TODO: More file types?
*) continue ;;
esac
# TODO: The exact line number is not actually important; it is important
# that it be part of the top-level comments of the file.
line="1"
if IFS= LC_ALL=C read -rn3 -d '' shebang <"${p}" && [[ "${shebang}" == '#!/' ]]; then
line="2"
elif [[ "${p}" == *"Dockerfile" ]] && IFS= LC_ALL=C read -rn9 -d '' syntax <"${p}" && [[ "${syntax}" == '# syntax=' ]]; then
line="2"
fi
header_found=''
for pre in "${prefix[@]}"; do
# TODO: check that the license is valid as SPDX and is allowed in this project.
if [[ "$(grep -E -n "${pre}SPDX-License-Identifier: " "${p}")" == "${line}:${pre}SPDX-License-Identifier: "* ]]; then
header_found='1'
break
fi
done
if [[ -z "${header_found}" ]]; then
failed_files+="${p}:${line}"$'\n'
fi
done
if [[ -n "${failed_files}" ]]; then
error "license-check failed: please add SPDX-License-Identifier to the following files"
echo "======================================="
echo -n "${failed_files}"
echo "======================================="
fi
fi
# Spell check (if config exists)
if [[ -f .cspell.json ]]; then
info "spell checking"
project_dictionary=.github/.cspell/project-dictionary.txt
if type -P npm &>/dev/null; then
has_rust=''
if [[ -n "$(git ls-files '*Cargo.toml')" ]]; then
has_rust='1'
dependencies=''
for manifest_path in $(git ls-files '*Cargo.toml'); do
if [[ "${manifest_path}" != "Cargo.toml" ]] && ! grep -Eq '\[workspace\]' "${manifest_path}"; then
continue
fi
metadata=$(cargo metadata --format-version=1 --all-features --no-deps --manifest-path "${manifest_path}")
for id in $(jq <<<"${metadata}" '.workspace_members[]'); do
dependencies+="$(jq <<<"${metadata}" ".packages[] | select(.id == ${id})" | jq -r '.dependencies[].name')"$'\n'
done
done
# shellcheck disable=SC2001
dependencies=$(sed <<<"${dependencies}" 's/[0-9_-]/\n/g' | LC_ALL=C sort -f -u)
fi
config_old=$(<.cspell.json)
config_new=$(grep <<<"${config_old}" -v ' *//' | jq 'del(.dictionaries[] | select(index("organization-dictionary") | not))' | jq 'del(.dictionaryDefinitions[] | select(.name == "organization-dictionary" | not))')
trap -- 'echo "${config_old}" >.cspell.json; echo >&2 "$0: trapped SIGINT"; exit 1' SIGINT
echo "${config_new}" >.cspell.json
if [[ -n "${has_rust}" ]]; then
dependencies_words=$(npx <<<"${dependencies}" -y cspell stdin --no-progress --no-summary --words-only --unique || true)
fi
all_words=$(npx -y cspell --no-progress --no-summary --words-only --unique $(git ls-files | (grep -v "${project_dictionary//\./\\.}" || true)) || true)
echo "${config_old}" >.cspell.json
trap - SIGINT
cat >.github/.cspell/rust-dependencies.txt <<EOF
// This file is @generated by $(basename "$0").
// It is not intended for manual editing.
EOF
if [[ -n "${dependencies_words:-}" ]]; then
echo $'\n'"${dependencies_words}" >>.github/.cspell/rust-dependencies.txt
fi
check_diff .github/.cspell/rust-dependencies.txt
if ! grep -Eq "^\.github/\.cspell/rust-dependencies.txt linguist-generated" .gitattributes; then
echo "warning: you may want to mark .github/.cspell/rust-dependencies.txt linguist-generated"
fi
echo "+ npx -y cspell --no-progress --no-summary \$(git ls-files)"
if ! npx -y cspell --no-progress --no-summary $(git ls-files); then
error "spellcheck failed: please fix uses of above words or add to ${project_dictionary} if correct"
fi
# Make sure the project-specific dictionary does not contain duplicated words.
for dictionary in .github/.cspell/*.txt; do
if [[ "${dictionary}" == "${project_dictionary}" ]]; then
continue
fi
dup=$(sed '/^$/d' "${project_dictionary}" "${dictionary}" | LC_ALL=C sort -f | uniq -d -i | (grep -v '//.*' || true))
if [[ -n "${dup}" ]]; then
error "duplicated words in dictionaries; please remove the following words from ${project_dictionary}"
echo "======================================="
echo "${dup}"
echo "======================================="
fi
done
# Make sure the project-specific dictionary does not contain unused words.
unused=''
for word in $(grep -v '//.*' "${project_dictionary}" || true); do
if ! grep <<<"${all_words}" -Eq -i "^${word}$"; then
unused+="${word}"$'\n'
fi
done
if [[ -n "${unused}" ]]; then
error "unused words in dictionaries; please remove the following words from ${project_dictionary}"
echo "======================================="
echo -n "${unused}"
echo "======================================="
fi
else
warn "'npm' is not installed; skipped spell check"
fi
fi
if [[ -n "${should_fail:-}" ]]; then
exit 1
fi