From a0314ce269c34d9517cceccb11d9f28d5ca51a9a Mon Sep 17 00:00:00 2001 From: Thomas Lockney Date: Mon, 27 Jul 2026 11:47:44 -0700 Subject: [PATCH 1/3] shell: guard optional sources that abort a fresh shell .zshrc, .bash_profile and .profile each sourced ~/.local/bin/env with no existence check. That file is written by the uv and rustup installers, so on a machine that has not run them the source fails and takes the whole shell with it: `zsh -i -c exit` returns 127, and every new terminal opens with an error. .zshrc routes every other source through source_if_exists, and .bash_profile guards the atuin line directly above; these three were the exceptions. .profile additionally sourced ~/.cargo/env. ~/.cargo is a symlink to /Volumes/Secondary, and .zshenv and .bash_profile both carry a comment explaining that any filesystem operation on it blocks indefinitely in uninterruptible D-state I/O wait when that volume is unresponsive -- which is why they add the path directly instead. .profile was the one place still touching it, so it now does the same. --- .bash_profile | 5 ++++- .profile | 15 +++++++++++++-- .zshrc | 4 +++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.bash_profile b/.bash_profile index 5674f15..2eb6f4f 100644 --- a/.bash_profile +++ b/.bash_profile @@ -1,7 +1,10 @@ # Atuin shell history — guard against missing binary [ -f "$HOME/.atuin/bin/env" ] && . "$HOME/.atuin/bin/env" -. "$HOME/.local/bin/env" +# Guarded like the atuin line above: written by the uv/rustup installers, and +# absent on a machine that has not run them, where sourcing it unconditionally +# aborts the shell with status 127. +[ -f "$HOME/.local/bin/env" ] && . "$HOME/.local/bin/env" # Cargo environment # ~/.cargo is a symlink to /Volumes/Secondary which can hang in uninterruptible diff --git a/.profile b/.profile index 1580d89..3990dfa 100644 --- a/.profile +++ b/.profile @@ -19,8 +19,19 @@ if [ -n "$BASH_VERSION" ] && [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc" fi -. "$HOME/.local/bin/env" -. "$HOME/.cargo/env" +# Written by the uv/rustup installers; absent on a machine that has not run +# them, where sourcing it unconditionally aborts the shell with status 127. +[ -f "$HOME/.local/bin/env" ] && . "$HOME/.local/bin/env" + +# Cargo environment. Deliberately not sourced, and deliberately not tested +# with [ -f ]: ~/.cargo is a symlink to /Volumes/Secondary, and any filesystem +# operation on it blocks indefinitely in uninterruptible D-state I/O wait when +# that volume is unresponsive. .zshenv and .bash_profile add the path this way +# for the same reason; this file was the one place still touching it. +case ":$PATH:" in + *":$HOME/.cargo/bin:"*) ;; # already in PATH + *) export PATH="$HOME/.cargo/bin:$PATH" ;; +esac # Hermes Agent — ensure ~/.local/bin is on PATH export PATH="$HOME/.local/bin:$PATH" diff --git a/.zshrc b/.zshrc index 98ff6a9..f387cf2 100644 --- a/.zshrc +++ b/.zshrc @@ -18,4 +18,6 @@ source_if_exists "$HOME/.config/zsh/prompt.zsh" source_if_exists "$HOME/.config/zsh/aliases.zsh" source_if_exists "$HOME/.config/zsh/open-agent.zsh" -. "$HOME/.local/bin/env" +# Written by the uv/rustup installers; absent on a machine that has not run +# them yet, where an unguarded source aborts the whole shell with status 127. +source_if_exists "$HOME/.local/bin/env" From 221f247bea5b72eb0f86702e98c456c64f1b7197 Mon Sep 17 00:00:00 2001 From: Thomas Lockney Date: Mon, 27 Jul 2026 11:48:05 -0700 Subject: [PATCH 2/3] emacs: opt into tree-sitter and eglot per language, and fix which-key on 29 Tree-sitter modes are now enabled one language at a time and only where `treesit-language-available-p' says the grammar is actually present, with treesit-language-source-alist populated so the missing ones can be built on demand. The config this replaced used treesit-auto with `(treesit-auto-add-to-auto-mode-alist 'all)', which remapped every language whether or not its grammar existed, so opening a JSON file failed with "Tree-sitter for JSON isn't available"; gating on availability makes that class of error impossible. Eglot replaces the removed lsp-mode stack. It is never required at startup -- naming eglot-ensure in a hook is enough to autoload it -- so startup pays nothing until a supported file is opened. TypeScript projects dispatch on the presence of a deno.json, since Deno and typescript-language-server disagree about module resolution and eglot ships no Deno entry. Also: which-key is built in as of Emacs 30, which is what the Macs run, but Ubuntu 24.04 ships 29, where calling which-key-mode is a void-function error. Require it first and install the package only as a fallback, so one init.el works on both. On 30 the built-in is used and nothing is downloaded. --- .emacs.d/init.el | 97 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/.emacs.d/init.el b/.emacs.d/init.el index 294de90..705d16d 100644 --- a/.emacs.d/init.el +++ b/.emacs.d/init.el @@ -240,7 +240,11 @@ Run for each new frame rather than once at startup, because under :config (setq dabbrev-case-fold-search nil)) -;; Built in as of Emacs 30 -- no package needed. +;; Built in as of Emacs 30. Older builds -- Ubuntu 24.04 still ships 29 -- +;; need the package, so ask for it only if the library isn't already there. +(unless (require 'which-key nil 'noerror) + (package-install 'which-key) + (require 'which-key)) (setq which-key-idle-delay 0.8 which-key-max-display-columns 4 which-key-max-description-length 25) @@ -267,6 +271,97 @@ Run for each new frame rather than once at startup, because under (use-package markdown-mode :mode ("\\.md\\'" "\\.markdown\\'")) +;;; -------------------------------------------------------------- editing qol + +;; project.el already binds C-x p; nothing to configure. C-x p f finds a file +;; in the current project, C-x p g greps it, C-x p p switches projects. +(electric-pair-mode 1) +(add-hook 'prog-mode-hook #'subword-mode) + +;;; ----------------------------------------------------------- tree-sitter + +;; Emacs 30 ships the *-ts-modes but no grammar sources, so +;; `treesit-install-language-grammar' has nothing to install from until this +;; alist is populated. bash, toml and yaml are already built here; the rest +;; install on demand with M-x treesit-install-language-grammar. +(setq treesit-language-source-alist + '((typescript "https://github.com/tree-sitter/tree-sitter-typescript" "master" "typescript/src") + (tsx "https://github.com/tree-sitter/tree-sitter-typescript" "master" "tsx/src") + (rust "https://github.com/tree-sitter/tree-sitter-rust") + (json "https://github.com/tree-sitter/tree-sitter-json") + (python "https://github.com/tree-sitter/tree-sitter-python"))) + +;; Opt in per language, and only where the grammar is actually present. The +;; previous config used treesit-auto with `(treesit-auto-add-to-auto-mode-alist +;; 'all)', which remapped every language whether or not its grammar existed -- +;; opening any JSON file then failed with "Tree-sitter for JSON isn't +;; available". Gating on `treesit-language-available-p' makes that class of +;; error impossible: a missing grammar just means you get the plain mode. +;; +;; Two levers are needed, because they solve different problems. Where a +;; built-in mode already claims the extension, remap it: +(dolist (spec '((json js-json-mode . json-ts-mode) + (python python-mode . python-ts-mode) + (toml conf-toml-mode . toml-ts-mode))) + (when (treesit-language-available-p (car spec)) + (add-to-list 'major-mode-remap-alist (cdr spec)))) + +;; ...and where nothing claims it at all, register the extension directly. +;; Emacs 30 ships typescript-ts-mode, tsx-ts-mode, rust-ts-mode and +;; yaml-ts-mode but puts none of them in `auto-mode-alist', so .ts, .tsx, .rs +;; and .yaml all fall through to text-mode on a stock build. +;; +;; sh-mode is deliberately left alone: it also handles .zsh, and bash-ts-mode +;; would be the wrong grammar for those. +(dolist (spec '((typescript "\\.ts\\'" . typescript-ts-mode) + (tsx "\\.tsx\\'" . tsx-ts-mode) + (rust "\\.rs\\'" . rust-ts-mode) + (yaml "\\.ya?ml\\'" . yaml-ts-mode))) + (when (treesit-language-available-p (car spec)) + (add-to-list 'auto-mode-alist (cons (cadr spec) (cddr spec))))) + +;;; ------------------------------------------------------------------- eglot + +;; Emacs 30's built-in LSP client. Deliberately not required here: naming +;; `eglot-ensure' in a hook is enough to autoload it, so eglot stays unloaded +;; until the first file of a supported type is opened, and startup pays +;; nothing for it. + +(defun tl/typescript-server (_interactive project) + "Return the language server contact for a TypeScript PROJECT. + +Deno and typescript-language-server disagree about module resolution, so +this has to follow the project rather than a global preference: a tree with +a deno.json gets `deno lsp', anything else gets tsserver. Eglot has no +built-in Deno entry, which is why this exists at all." + (let ((root (and project (project-root project)))) + (if (and root + (or (file-exists-p (expand-file-name "deno.json" root)) + (file-exists-p (expand-file-name "deno.jsonc" root)))) + '("deno" "lsp" :initializationOptions (:enable t :lint t)) + '("typescript-language-server" "--stdio")))) + +(with-eval-after-load 'eglot + (add-to-list 'eglot-server-programs + '(((typescript-ts-mode :language-id "typescript") + (tsx-ts-mode :language-id "typescriptreact") + (js-ts-mode :language-id "javascript")) + . tl/typescript-server))) + +;; Only the modes whose servers are actually installed. Eglot signals if it +;; can't find a server, so hooking a mode with no server is a per-file error, +;; not a silent no-op. +(dolist (hook '(rust-ts-mode-hook + typescript-ts-mode-hook + tsx-ts-mode-hook)) + (add-hook hook #'eglot-ensure)) + +;;; --------------------------------------------------------------------- git + +;; Autoloaded on the keybinding, so nothing loads until C-x g is pressed. +(use-package magit + :bind ("C-x g" . magit-status)) + ;;; ------------------------------------------------------------------ server ;; $EDITOR is `emacsclient -a emacs', so the server needs to be up. Started From 76a101eaab4ecc3613fa07ae036265f0b7b54297 Mon Sep 17 00:00:00 2001 From: Thomas Lockney Date: Mon, 27 Jul 2026 11:48:21 -0700 Subject: [PATCH 3/3] ci: replace shellcheck with checks that match how this repo breaks The ShellCheck job scanned 23 files under bin/ and had never passed, on any branch, going back to at least November 2025. It could not read the 12 zsh files where most of this configuration lives -- shellcheck does not parse zsh -- and its only findings were advisory notes about a source path it had no way to resolve. It was aimed at a failure mode this repo does not have. The failure mode this repo does have is "I merged, opened a terminal, and my shell is broken", or "I provisioned a new laptop and the bootstrap died". bin/check-dotfiles targets that: - shell startup: stages the tracked shell config into an empty HOME and actually starts zsh -i, zsh -l and bash -l there, asserting a zero exit and no broken references. This is what found the unguarded ~/.local/bin/env sources fixed two commits back; no syntax-level tool could have. - syntax: zsh -n over the zsh files, bash -n over the bash and sh files. Covers what ShellCheck nominally covered, including the files it could not read at all. - configs: TOML, YAML, JSON and Lua parse checks. VS Code's settings.json is JSONC, so comments and trailing commas are stripped before parsing rather than the file being skipped, and errors are reported against the line numbers of the file as written. - emacs: boots init.el against an empty init-directory, the way a new machine does, and fails on errors raised loading our own config. - ansible: --syntax-check on the provisioning playbook. Every section runs locally through `just check`, so a broken shell can be caught before yadm merge rather than after. A check whose tool is missing reports SKIP rather than silently passing, so a green run never means "we checked nothing". bin/lint-shell stays as an advisory local tool; it already tolerated these findings by running shellcheck with `|| true`. --- .github/workflows/checks.yml | 59 ++++++++ .github/workflows/lint.yml | 35 ----- Justfile | 6 +- bin/check-dotfiles | 265 +++++++++++++++++++++++++++++++++++ bin/lib/check-json.py | 68 +++++++++ 5 files changed, 397 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/checks.yml delete mode 100644 .github/workflows/lint.yml create mode 100755 bin/check-dotfiles create mode 100755 bin/lib/check-json.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..b16a23b --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,59 @@ +name: Checks + +# These run bin/check-dotfiles, the same script you can run locally with +# `just check`. CI and local should never disagree about what "passing" means, +# so the logic lives in the script and this file only installs what it needs. + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + name: Validate dotfiles + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install check dependencies + run: | + sudo apt-get update -qq + # zsh: the shell most of this config is written in, and the one + # shellcheck could never parse. + # lua5.4: luac, for the hammerspoon and nvim configs. + # emacs-nox: 29.x on Ubuntu 24.04, deliberately older than the 30.2 + # on the Macs -- if init.el works here it works there. + sudo apt-get install -y -qq zsh lua5.4 emacs-nox + python3 -m pip install --quiet --disable-pip-version-check pyyaml ansible-core + + - name: Report versions + run: | + zsh --version + bash --version | head -1 + emacs --version | head -1 + python3 --version + ansible-playbook --version | head -1 + + - name: Shell syntax + run: ./bin/check-dotfiles syntax + + - name: Config file syntax + run: ./bin/check-dotfiles configs + + # The one that earns its keep: stages the tracked shell config into an + # empty HOME and actually starts a shell there. Catches an unguarded + # `source` of a file that exists on your machine but not a fresh one -- + # which is exactly how this repo has broken before. + - name: Shell startup + run: ./bin/check-dotfiles shell + + - name: Emacs cold start + run: ./bin/check-dotfiles emacs + + - name: Ansible playbook + run: ./bin/check-dotfiles ansible diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 8a74ac4..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Lint Shell Scripts - -on: - pull_request: - paths: - - 'bin/**' - - '.config/shell/**' - - '.config/zsh/**' - - '.profile' - - '.bashrc' - - '.bash_profile' - - '.zprofile' - -jobs: - shellcheck: - name: ShellCheck - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Run ShellCheck - uses: ludeeus/action-shellcheck@2.0.0 - with: - scandir: './bin' - ignore_names: '*.md' - additional_files: '.config/shell/common-profile.sh .config/zsh/update-completions.sh' - # Fail on real defects, leave info/style advisory -- the same posture - # bin/lint-shell already takes by running shellcheck with `|| true`. - # - # Every info-level finding here is SC1091 "Not following: - # lib/common.sh", raised because the scripts source it via a - # SCRIPT_DIR computed at runtime. `shellcheck -x -P SCRIPTDIR` - # resolves all of them, but action-shellcheck@2.0.0 declares an - # `options` input and never uses it, so there is no way to pass - # those flags at this version. - severity: warning diff --git a/Justfile b/Justfile index 6f747b8..3de376a 100644 --- a/Justfile +++ b/Justfile @@ -9,7 +9,11 @@ default: check-env: ~/bin/check-env -# Lint all shell scripts with shellcheck +# Run the same checks CI runs (syntax, config parsing, shell startup, emacs, ansible) +check *SECTION: + ~/bin/check-dotfiles {{ SECTION }} + +# Lint all shell scripts with shellcheck (advisory; not run in CI) lint: ~/bin/lint-shell diff --git a/bin/check-dotfiles b/bin/check-dotfiles new file mode 100755 index 0000000..528438a --- /dev/null +++ b/bin/check-dotfiles @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# +# Validate the dotfiles the way they actually fail. +# +# The failure mode for this repo is not an unquoted variable in a helper +# script -- it is "I merged, opened a terminal, and my shell is broken", or +# "I provisioned a new laptop and the bootstrap died". These checks target +# that, and every one of them runs locally as well as in CI, so a broken +# shell can be caught before `yadm merge` rather than after. +# +# Usage: +# check-dotfiles # everything available on this machine +# check-dotfiles syntax # one section (syntax|configs|shell|emacs|ansible) +# +# Exits non-zero if any check fails. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib/common.sh +source "${SCRIPT_DIR}/lib/common.sh" + +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +cd "${REPO_ROOT}" || exit 1 + +FAILURES=0 +CHECKED=0 + +fail() { echo " FAIL $*" >&2; FAILURES=$((FAILURES + 1)); } +pass() { CHECKED=$((CHECKED + 1)); } + +# Only consider files git knows about, so untracked scratch never breaks CI. +tracked() { git ls-files -z "$@"; } + +# A check that needs a tool we don't have is reported and skipped, never +# silently passed -- a green run that quietly checked nothing is worse than +# a red one. +need() { + if has_command "$1"; then + return 0 + fi + warn " SKIP ${2:-$1 checks}: $1 not installed" + return 1 +} + +# ---------------------------------------------------------------- syntax + +check_syntax() { + print_section "Shell syntax" + + if need zsh "zsh syntax"; then + while IFS= read -r -d '' f; do + if out=$(zsh -n "$f" 2>&1); then pass; else fail "$f: $out"; fi + done < <(tracked '*.zsh' .zshrc .zshenv .zprofile) + fi + + if need bash "bash syntax"; then + while IFS= read -r -d '' f; do + if out=$(bash -n "$f" 2>&1); then pass; else fail "$f: $out"; fi + done < <(tracked .bashrc .bash_profile .profile '.config/shell/*.sh') + + # Executables in bin/ that declare a bash or sh shebang. + while IFS= read -r -d '' f; do + head -n1 "$f" | grep -qE '^#! */[^ ]*/(env +)?(bash|sh)' || continue + if out=$(bash -n "$f" 2>&1); then pass; else fail "$f: $out"; fi + done < <(tracked 'bin/*') + fi +} + +# --------------------------------------------------------------- configs + +check_configs() { + print_section "Config file syntax" + + if need python3 "TOML/JSON checks"; then + while IFS= read -r -d '' f; do + if out=$(python3 -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],"rb"))' "$f" 2>&1); then + pass + else + fail "$f: ${out##*tomllib.}" + fi + done < <(tracked '*.toml') + + # VS Code settings are JSONC: comments and trailing commas are legal + # there and a strict JSON parser rejects them, so they are checked + # with the comments stripped rather than skipped outright. + while IFS= read -r -d '' f; do + if out=$(python3 "${SCRIPT_DIR}/lib/check-json.py" "$f" 2>&1); then + pass + else + fail "$f: $out" + fi + done < <(tracked '*.json') + fi + + # yq if it is around, otherwise PyYAML, so CI does not need a yq install + # just for this. Note neither uses `yq -e`: a comment-only document is + # valid YAML that evaluates to null, and -e would call that a failure. + local yaml_check=() + if has_command yq; then + yaml_check=(yq '.') + elif python3 -c 'import yaml' 2>/dev/null; then + yaml_check=(python3 -c 'import yaml,sys; yaml.safe_load(open(sys.argv[1]))') + else + warn " SKIP YAML checks: neither yq nor PyYAML available" + fi + if [[ ${#yaml_check[@]} -gt 0 ]]; then + while IFS= read -r -d '' f; do + if out=$("${yaml_check[@]}" "$f" 2>&1 >/dev/null); then pass; else fail "$f: $out"; fi + done < <(tracked '*.yml' '*.yaml') + fi + + # Distros ship the compiler as luac5.4, luac5.3, ... rather than plain luac. + local luac="" + for candidate in luac luac5.4 luac5.3 luac5.2 luajit; do + has_command "$candidate" && { luac="$candidate"; break; } + done + if [[ -z "$luac" ]]; then + warn " SKIP Lua checks: no luac found" + else + while IFS= read -r -d '' f; do + if out=$("$luac" -p "$f" 2>&1); then pass; else fail "$f: $out"; fi + done < <(tracked '*.lua') + fi +} + +# ----------------------------------------------------------------- shell + +# The check that earns its keep. Stages the tracked shell config into an +# empty HOME and actually starts a shell there, which is the only way to +# catch an unguarded `source` of a file that happens to exist on your own +# machine but not on a fresh one. +check_shell_startup() { + print_section "Shell startup (clean HOME)" + + need zsh "shell startup" || return 0 + + local fake + fake=$(mktemp -d) || { fail "could not create temp HOME"; return 1; } + trap 'rm -rf "${fake}"' RETURN + + mkdir -p "${fake}/.config" + while IFS= read -r -d '' f; do + mkdir -p "${fake}/$(dirname "$f")" + cp "$f" "${fake}/$f" + done < <(tracked .zshrc .zshenv .zprofile .bashrc .bash_profile .profile \ + '.config/zsh/*' '.config/shell/*') + + # Anything matching these in stderr means the config reached for + # something that was not there. Warnings from tools that are genuinely + # absent on a bare machine are fine; broken references are not. + local broken='no such file or directory|command not found|parse error|syntax error|not found$' + + local shell_desc + for shell_desc in "zsh -i:interactive zsh" "zsh -l:login zsh" "bash -l:login bash"; do + local cmd="${shell_desc%%:*}" + local desc="${shell_desc##*:}" + local bin="${cmd%% *}" + + has_command "$bin" || { warn " SKIP ${desc}: ${bin} not installed"; continue; } + + local err rc + err=$(HOME="${fake}" ZDOTDIR="${fake}" $cmd -c 'exit 0' 2>&1 >/dev/null) + rc=$? + + if [[ $rc -ne 0 ]]; then + fail "${desc} exited ${rc}" + [[ -n "$err" ]] && echo "${err}" | sed 's/^/ /' >&2 + elif echo "$err" | grep -qiE "$broken"; then + fail "${desc} referenced something that does not exist:" + echo "$err" | grep -iE "$broken" | sed 's/^/ /' >&2 + else + pass + fi + done +} + +# ----------------------------------------------------------------- emacs + +# Boots init.el against an empty init-directory, which is what a new machine +# does. Installs packages from ELPA, so it needs the network. +check_emacs() { + print_section "Emacs cold start" + + need emacs "Emacs checks" || return 0 + + local dir + dir=$(mktemp -d) || { fail "could not create temp init dir"; return 1; } + trap 'rm -rf "${dir}"' RETURN + + cp .emacs.d/init.el .emacs.d/early-init.el "${dir}/" || { fail "could not stage init files"; return 1; } + + local log="${dir}/boot.log" + emacs --batch --init-directory="${dir}/" \ + -l "${dir}/early-init.el" -l "${dir}/init.el" \ + --eval '(message "COLD-START-OK")' >"${log}" 2>&1 + + if ! grep -q 'COLD-START-OK' "${log}"; then + fail "init.el did not finish loading" + tail -30 "${log}" | sed 's/^/ /' >&2 + return + fi + + # Byte-compile warnings from third-party packages are upstream's problem; + # an error raised while loading our own config is ours. + if grep -iE '^(Error|Symbol.s (value|function) as variable)' "${log}" | grep -qv 'pkg\.el'; then + fail "errors during cold start:" + grep -iE '^Error' "${log}" | head -10 | sed 's/^/ /' >&2 + else + pass + fi +} + +# --------------------------------------------------------------- ansible + +check_ansible() { + print_section "Ansible playbook" + + local playbook=".config/dotfiles/playbook.yml" + [[ -f "$playbook" ]] || { warn " SKIP no playbook at ${playbook}"; return 0; } + + local runner=() + if has_command ansible-playbook; then + runner=(ansible-playbook) + elif has_command uvx; then + runner=(uvx --from ansible-core ansible-playbook) + else + warn " SKIP Ansible checks: neither ansible-playbook nor uvx installed" + return 0 + fi + + if out=$(cd "$(dirname "$playbook")" && "${runner[@]}" --syntax-check "$(basename "$playbook")" 2>&1); then + pass + else + fail "playbook syntax:" + echo "$out" | tail -20 | sed 's/^/ /' >&2 + fi +} + +# ------------------------------------------------------------------ main + +case "${1:-all}" in + syntax) check_syntax ;; + configs) check_configs ;; + shell) check_shell_startup ;; + emacs) check_emacs ;; + ansible) check_ansible ;; + all) + check_syntax + check_configs + check_shell_startup + check_emacs + check_ansible + ;; + *) + error "unknown section '$1' (expected: syntax|configs|shell|emacs|ansible|all)" + ;; +esac + +echo +if [[ $FAILURES -gt 0 ]]; then + echo "${FAILURES} check(s) failed, ${CHECKED} passed" >&2 + exit 1 +fi +success "All ${CHECKED} checks passed" diff --git a/bin/lib/check-json.py b/bin/lib/check-json.py new file mode 100755 index 0000000..a4fd75b --- /dev/null +++ b/bin/lib/check-json.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Validate a JSON file, tolerating the JSONC that some editors emit. + +VS Code's settings.json and keybindings.json legitimately contain // and /* +*/ comments and trailing commas. A strict json.load rejects those, so the +choice is between skipping those files entirely or parsing what they +actually are. This does the latter: strip comments and trailing commas, then +parse. Anything that still fails is a real syntax error. +""" + +import json +import re +import sys + +# Matches a double-quoted JSON string, or a // or /* */ comment. Alternating +# on the string case first means a // inside a string value is left alone. +_TOKENS = re.compile( + r'"(?:\\.|[^"\\])*"' # string literal, escapes included + r"|//[^\n]*" # line comment + r"|/\*.*?\*/", # block comment + re.DOTALL, +) + +_TRAILING_COMMA = re.compile(r",(\s*[}\]])") + + +def strip_jsonc(text: str) -> str: + def replace(match: re.Match) -> str: + token = match.group(0) + # Keep strings verbatim; replace comments with equivalent whitespace + # so that reported line numbers still line up with the source. + if token.startswith('"'): + return token + return re.sub(r"\S", " ", token) + + return _TRAILING_COMMA.sub(r"\1", _TOKENS.sub(replace, text)) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: check-json.py ", file=sys.stderr) + return 2 + + path = sys.argv[1] + with open(path, encoding="utf-8") as handle: + text = handle.read() + + try: + json.loads(text) + return 0 + except json.JSONDecodeError: + pass + + # Strict parsing failed. That is expected for a JSONC file -- the strict + # error just points at the first comment -- so re-parse with comments + # blanked out and report *that* error, whose line numbers still refer to + # the file as written because comments are replaced by equal-length + # whitespace rather than removed. + try: + json.loads(strip_jsonc(text)) + except json.JSONDecodeError as error: + print(f"line {error.lineno}: {error.msg}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())