aboutsummaryrefslogtreecommitdiff
path: root/languages/elisp/claude
diff options
context:
space:
mode:
Diffstat (limited to 'languages/elisp/claude')
-rwxr-xr-xlanguages/elisp/claude/hooks/validate-el.sh102
-rw-r--r--languages/elisp/claude/rules/elisp-testing.md107
-rw-r--r--languages/elisp/claude/rules/elisp.md75
-rw-r--r--languages/elisp/claude/settings.json74
4 files changed, 358 insertions, 0 deletions
diff --git a/languages/elisp/claude/hooks/validate-el.sh b/languages/elisp/claude/hooks/validate-el.sh
new file mode 100755
index 0000000..803badf
--- /dev/null
+++ b/languages/elisp/claude/hooks/validate-el.sh
@@ -0,0 +1,102 @@
+#!/usr/bin/env bash
+# Validate and test .el files after Edit/Write/MultiEdit.
+# PostToolUse hook: receives tool-call JSON on stdin.
+#
+# On success: exit 0 silent.
+# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude
+# sees a structured error in its context, THEN exit 2 to block the tool
+# pipeline. stderr still echoes the error for terminal visibility.
+#
+# Phase 1: check-parens + byte-compile
+# Phase 2: for non-test .el files, run matching tests/test-<stem>*.el
+
+set -u
+
+# Emit a JSON failure payload and exit 2. Arguments:
+# $1 — short failure type (e.g. "PAREN CHECK FAILED")
+# $2 — file path
+# $3 — emacs output (error body)
+fail_json() {
+ local ctx
+ ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \
+ | jq -Rs .)"
+ cat <<EOF
+{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}}
+EOF
+ printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2
+ exit 2
+}
+
+# Portable project root: prefer Claude Code's env var, fall back to deriving
+# from this script's location ($project/.claude/hooks/validate-el.sh).
+PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
+
+f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')"
+[ -z "$f" ] && exit 0
+[ "${f##*.}" = "el" ] || exit 0
+
+MAX_AUTO_TEST_FILES=20 # skip if more matches than this (large test suites)
+
+# --- Phase 1: syntax + byte-compile ---
+case "$f" in
+ */init.el|*/early-init.el)
+ # Byte-compile here would load the full package graph. Parens only.
+ if ! output="$(emacs --batch --no-site-file --no-site-lisp "$f" \
+ --eval '(check-parens)' 2>&1)"; then
+ fail_json "PAREN CHECK FAILED" "$f" "$output"
+ fi
+ ;;
+ *.el)
+ if ! output="$(emacs --batch --no-site-file --no-site-lisp \
+ -L "$PROJECT_ROOT" \
+ -L "$PROJECT_ROOT/modules" \
+ -L "$PROJECT_ROOT/tests" \
+ --eval '(package-initialize)' \
+ "$f" \
+ --eval '(check-parens)' \
+ --eval "(or (byte-compile-file \"$f\") (kill-emacs 1))" 2>&1)"; then
+ fail_json "VALIDATION FAILED" "$f" "$output"
+ fi
+ ;;
+esac
+
+# --- Phase 2: test runner ---
+# Determine which tests (if any) apply to this edit. Works for projects with
+# source at root, in modules/, or elsewhere — stem-based test lookup is the
+# common pattern.
+tests=()
+case "$f" in
+ */init.el|*/early-init.el)
+ : # Phase 1 handled it; skip test runner
+ ;;
+ "$PROJECT_ROOT/tests/testutil-"*.el)
+ stem="$(basename "${f%.el}")"
+ stem="${stem#testutil-}"
+ mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort)
+ ;;
+ "$PROJECT_ROOT/tests/test-"*.el)
+ tests=("$f")
+ ;;
+ *.el)
+ # Any other .el under the project — find matching tests by stem
+ stem="$(basename "${f%.el}")"
+ mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort)
+ ;;
+esac
+
+count="${#tests[@]}"
+if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then
+ load_args=()
+ for t in "${tests[@]}"; do load_args+=("-l" "$t"); done
+ if ! output="$(emacs --batch --no-site-file --no-site-lisp \
+ -L "$PROJECT_ROOT" \
+ -L "$PROJECT_ROOT/modules" \
+ -L "$PROJECT_ROOT/tests" \
+ --eval '(package-initialize)' \
+ -l ert "${load_args[@]}" \
+ --eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1)"; then
+ fail_json "TESTS FAILED ($count test file(s))" "$f" "$output"
+ fi
+fi
+
+exit 0
diff --git a/languages/elisp/claude/rules/elisp-testing.md b/languages/elisp/claude/rules/elisp-testing.md
new file mode 100644
index 0000000..b5def78
--- /dev/null
+++ b/languages/elisp/claude/rules/elisp-testing.md
@@ -0,0 +1,107 @@
+# Elisp Testing Rules
+
+Applies to: `**/tests/*.el`
+
+Implements the core principles from `testing.md`. All rules there apply here —
+this file covers Elisp-specific patterns.
+
+## Framework: ERT
+
+Use `ert-deftest` for all tests. One test = one scenario.
+
+## File Layout
+
+- `tests/test-<module>.el` — tests for `<module>.el`
+- `tests/test-<module>--<helper>.el` — tests for a specific private helper (matches `<module>--<helper>` function naming)
+- `tests/testutil-<module>.el` — fixtures and mocks scoped to one module
+- `tests/testutil-*.el` — cross-module helpers (shared fixtures, generic mocks, filesystem helpers); name them for what they help with
+
+Tests must `(require 'module-name)` before the testutil file that stubs its internals, unless documented otherwise. Order matters — a testutil that defines a stub can be shadowed by a later `require` of the real module.
+
+## Test Naming
+
+```elisp
+(ert-deftest test-<module>-<function>-<scenario> ()
+ "Normal/Boundary/Error: brief description."
+ ...)
+```
+
+Put the category (Normal, Boundary, Error) in the docstring so the category is grep-able.
+
+## Required Coverage
+
+Every non-trivial function needs at least:
+- One **Normal** case (happy path)
+- One **Boundary** case (empty, nil, min, max, unicode, long string)
+- One **Error** case (invalid input, missing resource, failure mode)
+
+Missing a category is a test gap. If three cases look near-identical, parametrize with a loop or `dolist` rather than copy-pasting.
+
+## TDD Workflow
+
+Write the failing test first. A failing test proves you understand the change. Assume the bug is in production code until the test proves otherwise — never fix the test before proving the test is wrong.
+
+For untested code, write a **characterization test** that captures current behavior before you change anything. It becomes the safety net for the refactor.
+
+## Interactive vs Internal — Split for Testability
+
+When a function mixes business logic with user interaction, split it:
+
+- **Internal** (`cj/--foo`) — pure logic. All parameters explicit. No prompts,
+ no UI. Deterministic and trivially testable.
+- **Interactive wrapper** (`cj/foo`) — thin layer that reads user input and
+ delegates to the internal.
+
+```elisp
+(defun cj/--move-buffer-and-file (dir &optional ok-if-exists)
+ "Move the current buffer's file into DIR. Overwrite if OK-IF-EXISTS."
+ ...)
+
+(defun cj/move-buffer-and-file ()
+ "Interactive wrapper: prompt for DIR, delegate."
+ (interactive)
+ (let ((dir (read-directory-name "Move to: ")))
+ (cj/--move-buffer-and-file dir)))
+```
+
+Test the internal directly with parameter values — no `cl-letf` on
+`read-directory-name`, `yes-or-no-p`, etc. The wrapper gets a smoke test or
+nothing — Emacs already tests its own prompts. The internal also becomes
+reusable by other Elisp code without triggering UI.
+
+## Mocking
+
+Mock at boundaries:
+- Shell: `cl-letf` on `shell-command`, `shell-command-to-string`, `call-process`
+- File I/O when tests shouldn't touch disk
+- Network: URL retrievers, HTTP clients
+- Time: `cl-letf` on `current-time`, `format-time-string`
+
+Never mock:
+- The code under test
+- Core Emacs primitives (buffer ops, string ops, lists)
+- Your own domain logic — restructure it to be testable instead
+
+## Idioms
+
+- `cl-letf` for scoped overrides (self-cleaning)
+- `with-temp-buffer` for buffer manipulation tests
+- `make-temp-file` with `.el` suffix for on-disk fixtures
+- Tests must run in any order; no shared mutable state
+
+## Running Tests
+
+```bash
+make test # All
+make test-file FILE=tests/test-foo.el # One file
+make test-name TEST=pattern # Match by test name pattern
+```
+
+A PostToolUse hook runs matching tests automatically after edits to a module, when the match count is small enough to be fast.
+
+## Anti-Patterns
+
+- Hardcoded timestamps — generate relative to `current-time` or mock
+- Testing implementation details (private storage structure) instead of behavior
+- Mocking the thing you're testing
+- Skipping a failing test without an issue to track it
diff --git a/languages/elisp/claude/rules/elisp.md b/languages/elisp/claude/rules/elisp.md
new file mode 100644
index 0000000..e641058
--- /dev/null
+++ b/languages/elisp/claude/rules/elisp.md
@@ -0,0 +1,75 @@
+# Elisp / Emacs Rules
+
+Applies to: `**/*.el`
+
+## Style
+
+- 2-space indent, no tabs
+- Hyphen-case for identifiers: `cj/do-thing`, not `cj/doThing`
+- Naming prefixes:
+ - `cj/name` — user-facing functions and commands (bound to keys, called from init)
+ - `cj/--name` — private helpers (double-dash signals "internal")
+ - `<module>/name` — module-scoped where appropriate (e.g., `calendar-sync/parse-ics`)
+- File header: `;;; foo-config.el --- brief description -*- lexical-binding: t -*-`
+- `(provide 'foo-config)` at the bottom of every module
+- `lexical-binding: t` is mandatory — no file without it
+
+## Function Design
+
+- Keep functions under 15 lines where possible
+- One responsibility per function
+- Extract helpers instead of nesting deeply — 5+ levels of nesting is a refactor signal
+- Prefer named helpers over lambdas for anything nontrivial
+- No premature abstraction — three similar lines beats a clever macro
+
+Small functions are the single strongest defense against paren errors. Deeply nested code is where AI and humans both fail.
+
+## Requires and Loading
+
+- Every `(require 'foo)` must correspond to a loadable file on the load-path
+- Byte-compile warnings about free variables usually indicate a missing `require` or a typo in a symbol name — read them
+- Use `use-package` for external (MELPA/ELPA) packages
+- Use plain `(require 'foo-config)` for internal modules
+- For optional features, `(when (require 'foo nil t) ...)` degrades gracefully if absent
+
+## Lexical-Binding Traps
+
+- `(boundp 'x)` where `x` is a lexical variable always returns nil. Bind with `defvar` at top level if you need `boundp` to work, or use the value directly.
+- `setq` on an undeclared free variable is a warning — use `let` for locals or `defvar` for module-level state
+- Closures capture by reference. Avoid capturing mutating loop variables in nested defuns.
+
+## Regex Gotchas
+
+- `\s` is NOT whitespace in Emacs regex. Use `[ \t]` or `\\s-` (syntax class).
+- `^` in `string-match` matches after `\n` OR at position 0 — use `(= (match-beginning 0) start)` for positional checks when that matters.
+- `replace-regexp-in-string` interprets backslashes in the replacement. Pass `t t` (FIXEDCASE LITERAL) when the replacement contains literal backslashes.
+
+## Keybindings
+
+- `keymap-global-set` for global; `keymap-set KEYMAP ...` for mode-local
+- Group module-specific bindings inside the module's file
+- Autoload cookies (`;;;###autoload`) don't activate through plain `(require ...)` — use the form directly, not an autoloaded wrapper
+
+## Module Template
+
+```elisp
+;;; foo-config.el --- Foo feature configuration -*- lexical-binding: t -*-
+
+;;; Commentary:
+;; One-line description.
+
+;;; Code:
+
+;; ... code ...
+
+(provide 'foo-config)
+;;; foo-config.el ends here
+```
+
+Then `(require 'foo-config)` in `init.el` (or a config aggregator).
+
+## Editing Workflow
+
+- A PostToolUse hook runs `check-parens` and `byte-compile-file` on every `.el` save
+- If it blocks, read the error — don't retry blindly
+- Prefer Write over repeated Edits for nontrivial new code; incremental edits accumulate subtle paren mismatches
diff --git a/languages/elisp/claude/settings.json b/languages/elisp/claude/settings.json
new file mode 100644
index 0000000..9ab9f12
--- /dev/null
+++ b/languages/elisp/claude/settings.json
@@ -0,0 +1,74 @@
+{
+ "attribution": {
+ "commit": "",
+ "pr": ""
+ },
+ "permissions": {
+ "allow": [
+ "Bash(make)",
+ "Bash(make help)",
+ "Bash(make targets)",
+ "Bash(make test)",
+ "Bash(make test *)",
+ "Bash(make test-all)",
+ "Bash(make test-unit)",
+ "Bash(make test-integration)",
+ "Bash(make test-file *)",
+ "Bash(make test-name *)",
+ "Bash(make validate-parens)",
+ "Bash(make validate-modules)",
+ "Bash(make compile)",
+ "Bash(make lint)",
+ "Bash(make profile)",
+ "Bash(emacs --batch *)",
+ "Bash(emacs -Q --batch *)",
+ "Bash(git status)",
+ "Bash(git status *)",
+ "Bash(git diff)",
+ "Bash(git diff *)",
+ "Bash(git log)",
+ "Bash(git log *)",
+ "Bash(git show)",
+ "Bash(git show *)",
+ "Bash(git blame *)",
+ "Bash(git branch)",
+ "Bash(git branch -v)",
+ "Bash(git branch -a)",
+ "Bash(git branch --list *)",
+ "Bash(git remote)",
+ "Bash(git remote -v)",
+ "Bash(git remote show *)",
+ "Bash(git ls-files *)",
+ "Bash(git rev-parse *)",
+ "Bash(git cat-file *)",
+ "Bash(git stash list)",
+ "Bash(git stash show *)",
+ "Bash(jq *)",
+ "Bash(date)",
+ "Bash(date *)",
+ "Bash(which *)",
+ "Bash(file *)",
+ "Bash(ls)",
+ "Bash(ls *)",
+ "Bash(wc *)",
+ "Bash(du *)",
+ "Bash(readlink *)",
+ "Bash(realpath *)",
+ "Bash(basename *)",
+ "Bash(dirname *)"
+ ]
+ },
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write|MultiEdit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-el.sh"
+ }
+ ]
+ }
+ ]
+ }
+}