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/.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 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/.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" 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())