#!/usr/bin/env bash # A one-stop shell linting helper for this repo. # - Installs shell linters on Arch Linux (shellcheck, shfmt) and optionally via AUR if available # - Discovers shell scripts in the repository (by extension or shebang) # - Runs: shellcheck, shfmt (diff mode), optional: checkbashisms, bashate, and shell syntax checks (bash -n, zsh -n, sh/dash -n) # - Prints a summarized report and returns non-zero if any linter reports issues # # Usage: # scripts/meta/shell_check.sh [--path DIR] [--skip-install] [--install-only] [--list-only] [--verbose] # # Notes: # - Arch install uses pacman: shellcheck shfmt # - Optional linters if available (installed already or via AUR helper yay/paru): checkbashisms, bashate # - On non-Arch systems, install is skipped with a helpful hint set -uo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # Source common library for log_info, log_warn, log_error # shellcheck source=../lib/common.sh source "$SCRIPT_DIR/../lib/common.sh" DEFAULT_ROOT=$(cd -- "$SCRIPT_DIR/../../" && pwd) ROOT_DIR="$DEFAULT_ROOT" SKIP_INSTALL="false" INSTALL_ONLY="false" LIST_ONLY="false" VERBOSE="false" usage() { cat << EOF Usage: $(basename "$0") [options] Options: --path DIR Root directory to scan (default: repo root at $DEFAULT_ROOT) --skip-install Skip installing linters --install-only Only install linters, do not scan --list-only Only list discovered shell files, do not run linters --verbose Print additional details while running -h, --help Show this help Linters used: Required: shellcheck, shfmt Optional (if available): checkbashisms, bashate Syntax checks: bash -n, zsh -n (if installed), sh/dash -n EOF } while [[ $# -gt 0 ]]; do case "$1" in --path) ROOT_DIR="$2" shift 2 ;; --skip-install) SKIP_INSTALL="true" shift ;; --install-only) INSTALL_ONLY="true" shift ;; --list-only) LIST_ONLY="true" shift ;; --verbose) VERBOSE="true" shift ;; -h | --help) usage exit 0 ;; *) log_error "Unknown argument: $1" usage exit 2 ;; esac done if [[ ! -d $ROOT_DIR ]]; then log_error "Path not found: $ROOT_DIR" exit 2 fi is_cmd() { command -v "$1" > /dev/null 2>&1; } is_arch() { is_cmd pacman; } have_aur_helper() { is_cmd yay || is_cmd paru; } install_if_missing() { local pkg cmd pkg="$1" cmd="$2" if is_cmd "$cmd"; then [[ $VERBOSE == "true" ]] && log_info "Found $cmd" return 0 fi if [[ $SKIP_INSTALL == "true" ]]; then log_warn "Skipping install of $pkg ($cmd not found)" return 1 fi if is_arch; then log_info "Installing $pkg via pacman..." if ! sudo pacman -S --needed --noconfirm "$pkg"; then log_warn "Failed to install $pkg via pacman." return 1 fi return 0 else log_warn "Non-Arch system detected. Please install '$pkg' manually." return 1 fi } install_linters() { local ok=0 # Core linters install_if_missing shellcheck shellcheck || ok=1 install_if_missing shfmt shfmt || ok=1 # Optional linters (best-effort) # checkbashisms may be in repos or AUR; try pacman first, then AUR helper if ! is_cmd checkbashisms; then if is_arch; then if ! sudo pacman -S --needed --noconfirm checkbashisms 2> /dev/null; then if have_aur_helper; then log_info "Installing checkbashisms from AUR (requires yay/paru)..." if is_cmd yay; then yay -S --noconfirm checkbashisms || true; fi if is_cmd paru; then paru -S --noconfirm checkbashisms || true; fi else log_warn "checkbashisms not installed (no AUR helper)." fi fi fi fi # bashate (python-based), typically available as python-bashate in AUR if ! is_cmd bashate; then if is_arch && have_aur_helper; then log_info "Installing bashate from AUR (requires yay/paru)..." if is_cmd yay; then yay -S --noconfirm python-bashate || true; fi if is_cmd paru; then paru -S --noconfirm python-bashate || true; fi else # Try pip if user has it and wants to if is_cmd pipx; then log_info "Installing bashate via pipx..." pipx install bashate || true elif is_cmd pip3; then log_info "Installing bashate via pip (user)..." pip3 install --user bashate || true else log_warn "bashate not installed (no AUR helper or pip available)." fi fi fi return "$ok" } TMPDIR=$(mktemp -d) trap 'rm -rf "${TMPDIR:-}"' EXIT ABS_FILES_Z="$TMPDIR/files_abs.zlist" REL_FILES_Z="$TMPDIR/files_rel.zlist" discover_shell_files() { local base="$1" local -a all all=() if git -C "$base" rev-parse --is-inside-work-tree > /dev/null 2>&1; then while IFS= read -r -d '' f; do all+=("$f"); done < <(git -C "$base" ls-files -z) while IFS= read -r -d '' f; do all+=("$f"); done < <(git -C "$base" ls-files --others --exclude-standard -z) else while IFS= read -r -d '' f; do # trim leading ./ to keep consistent style with git paths f="${f#./}" f="${f#"${base}"/}" all+=("$f") done < <(find "$base" -type f -print0) fi local -a shells shells=() for rel in "${all[@]}"; do # skip binary-ish or huge files quickly by extension heuristic case "$rel" in *.png | *.jpg | *.jpeg | *.gif | *.ico | *.pdf | *.svg | *.zip | *.tar | *.gz | *.xz | *.7z | *.so | *.o | *.bin) continue ;; esac local abs="$base/$rel" [[ -f $abs && -r $abs ]] || continue if [[ $rel == *.sh || $rel == *.bash || $rel == *.zsh ]]; then shells+=("$rel") continue fi # Check shebang local first first=$(head -n 1 -- "$abs" 2> /dev/null || true) if [[ $first =~ ^#! && $first =~ (ba|z|d|k)?sh ]]; then shells+=("$rel") continue fi # Also catch executable files with shell shebang even without extension if [[ -x $abs ]]; then if [[ $first =~ ^#! && $first =~ (ba|z|d|k)?sh ]]; then shells+=("$rel") fi fi done # write lists : > "$REL_FILES_Z" : > "$ABS_FILES_Z" for rel in "${shells[@]}"; do printf '%s\0' "$rel" >> "$REL_FILES_Z" printf '%s\0' "$base/$rel" >> "$ABS_FILES_Z" done } print_file_list() { local count count=$(tr -cd '\0' < "$REL_FILES_Z" | wc -c) log_info "Discovered $count shell file(s) under $ROOT_DIR" if [[ $VERBOSE == "true" ]]; then tr '\0' '\n' < "$REL_FILES_Z" | sed 's/^/ - /' fi } run_linters() { local issues=0 local count count=$(tr -cd '\0' < "$ABS_FILES_Z" | wc -c) if [[ $count -eq 0 ]]; then log_warn "No shell files found to lint." return 0 fi mapfile -d '' -t FILES < "$ABS_FILES_Z" log_info "Running shellcheck..." local sc_out="$TMPDIR/shellcheck.txt" if is_cmd shellcheck; then if ! shellcheck -x -S style "${FILES[@]}" > "$sc_out" 2>&1; then issues=$((issues + 1)) fi else log_warn "shellcheck not found; skipping" fi log_info "Running shfmt (diff mode)..." local shfmt_out="$TMPDIR/shfmt.diff" if is_cmd shfmt; then if ! shfmt -d -i 2 -ci -sr -s "${FILES[@]}" > "$shfmt_out" 2>&1; then # shfmt returns non-zero when diff exists issues=$((issues + 1)) fi else log_warn "shfmt not found; skipping" fi log_info "Running checkbashisms (optional)..." local cbi_out="$TMPDIR/checkbashisms.txt" local cbi_status=0 if is_cmd checkbashisms; then # Only run checkbashisms on scripts that are intended for /bin/sh (or unspecified), # skip explicit bash/zsh scripts to avoid false positives. local -a CBI_FILES CBI_FILES=() for f in "${FILES[@]}"; do local first first=$(head -n 1 -- "$f" 2> /dev/null || true) if [[ $first =~ bash || $first =~ zsh ]]; then continue fi CBI_FILES+=("$f") done if [[ ${#CBI_FILES[@]} -gt 0 ]]; then # checkbashisms exits 0 if OK, 1 if issues, other codes for tool warnings checkbashisms "${CBI_FILES[@]}" > "$cbi_out" 2>&1 else : > "$cbi_out" fi cbi_status=$? if [[ $cbi_status -eq 1 ]]; then issues=$((issues + 1)) elif [[ $cbi_status -ne 0 ]]; then log_warn "checkbashisms exited with status $cbi_status (treated as warning)" fi else log_warn "checkbashisms not found; skipping" fi log_info "Running bash/zsh/sh syntax checks (-n)..." local bash_out="$TMPDIR/bash_syntax.txt" local zsh_out="$TMPDIR/zsh_syntax.txt" local sh_out="$TMPDIR/sh_syntax.txt" # Partition files by shebang for better accuracy local -a BASH_FILES ZSH_FILES SH_FILES BASH_FILES=() ZSH_FILES=() SH_FILES=() for f in "${FILES[@]}"; do local first first=$(head -n 1 -- "$f" 2> /dev/null || true) if [[ $first =~ bash ]]; then BASH_FILES+=("$f") elif [[ $first =~ zsh ]]; then ZSH_FILES+=("$f") else SH_FILES+=("$f") fi done if [[ ${#BASH_FILES[@]} -gt 0 ]] && is_cmd bash; then if ! bash -n "${BASH_FILES[@]}" 2> "$bash_out"; then issues=$((issues + 1)) fi fi if [[ ${#ZSH_FILES[@]} -gt 0 ]] && is_cmd zsh; then if ! zsh -n "${ZSH_FILES[@]}" 2> "$zsh_out"; then issues=$((issues + 1)) fi fi # prefer dash if present for /bin/sh style if [[ ${#SH_FILES[@]} -gt 0 ]]; then if is_cmd dash; then if ! dash -n "${SH_FILES[@]}" 2> "$sh_out"; then issues=$((issues + 1)) fi elif is_cmd sh; then if ! sh -n "${SH_FILES[@]}" 2> "$sh_out"; then issues=$((issues + 1)) fi fi fi echo log_info "========== Shell Lint Report ==========" if [[ -s $sc_out ]]; then printf '\n\033[1m-- shellcheck --\033[0m\n' cat "$sc_out" else printf '\n\033[1;32m-- shellcheck: PASS (no issues) --\033[0m\n' fi if [[ -s $shfmt_out ]]; then printf '\n\033[1m-- shfmt (diffs found) --\033[0m\n' cat "$shfmt_out" else printf '\n\033[1;32m-- shfmt: PASS (formatted) --\033[0m\n' fi if [[ -s $cbi_out ]]; then printf '\n\033[1m-- checkbashisms --\033[0m\n' cat "$cbi_out" else printf '\n\033[1;32m-- checkbashisms: PASS (or skipped) --\033[0m\n' fi if [[ -s $bash_out ]]; then printf '\n\033[1m-- bash -n (syntax) --\033[0m\n' cat "$bash_out" else printf '\n\033[1;32m-- bash -n: PASS (or none) --\033[0m\n' fi if [[ -s $zsh_out ]]; then printf '\n\033[1m-- zsh -n (syntax) --\033[0m\n' cat "$zsh_out" else printf '\n\033[1;32m-- zsh -n: PASS (or none) --\033[0m\n' fi if [[ -s $sh_out ]]; then printf '\n\033[1m-- sh/dash -n (syntax) --\033[0m\n' cat "$sh_out" else printf '\n\033[1;32m-- sh/dash -n: PASS (or none) --\033[0m\n' fi echo if [[ $issues -gt 0 ]]; then log_error "Linting completed with $issues tool(s) reporting issues." return 1 else log_info "All checks passed." return 0 fi } # Main if [[ $INSTALL_ONLY == "true" ]]; then install_linters exit $? fi # Only attempt installs if not list-only if [[ $LIST_ONLY != "true" ]]; then install_linters || true fi discover_shell_files "$ROOT_DIR" print_file_list if [[ $LIST_ONLY == "true" ]]; then exit 0 fi run_linters exit $?