Files
dots/common/.config/claude/hooks/format.py
T
2026-09-17 20:45:06 +02:00

209 lines
6.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""PostToolUse hook: format an edited file with the right formatter.
Reads the hook JSON from stdin, extracts the edited file path, and runs a
language-appropriate formatter -- but, to avoid imposing a style on a project
that never asked for it, every formatter here only runs when the project opts in
via a config file discoverable by walking up from the edited file: stylua, ruff,
prettier and taplo on their own configs, gofmt and rustfmt on go.mod / Cargo.toml,
shfmt and fish_indent on .editorconfig. No marker, no formatting -- a repo that
hand-formats its shell keeps its own layout.
Best-effort: always exits 0 so a format hiccup never blocks an edit. jq is not
guaranteed on the host, hence python for the stdin JSON parse.
"""
import fnmatch
import json
import os
import re
import shutil
import subprocess
import sys
def find_up(start, names):
"""Nearest ancestor dir (incl. the file's own) containing any of `names`."""
d = os.path.dirname(os.path.abspath(start))
while True:
if any(os.path.exists(os.path.join(d, n)) for n in names):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def _expand_braces(pat):
"""`a{b,c}` -> [`ab`, `ac`] (editorconfig sections use {,} alternation)."""
m = re.search(r"\{([^{}]*)\}", pat)
if not m:
return [pat]
out = []
for alt in m.group(1).split(","):
out.extend(_expand_braces(pat[: m.start()] + alt + pat[m.end():]))
return out
def editorconfig_covers(path):
"""True if the nearest ancestor .editorconfig has a section matching `path`.
Mere existence is NOT opt-in: this repo's .editorconfig is deliberately scoped
to `[{*.sh,dotsync,dotpub}]` with no [*] block precisely so fish files stay
untouched, and a generic upstream .editorconfig must not switch a formatter on
for filetypes it never mentions. Patterns without a `/` match the basename
(the editorconfig convention); ones with a `/` match the path relative to the
.editorconfig's directory.
"""
d = find_up(path, [".editorconfig"])
if not d:
return False
name = os.path.basename(path)
rel = os.path.relpath(os.path.abspath(path), d)
try:
with open(os.path.join(d, ".editorconfig"), errors="ignore") as fh:
for line in fh:
line = line.strip()
if not (line.startswith("[") and line.endswith("]")):
continue
for pat in _expand_braces(line[1:-1]):
target = rel if "/" in pat else name
if fnmatch.fnmatch(target, pat.lstrip("/")):
return True
except Exception:
pass
return False
def has_toml_section(start, filename, section):
"""True if the nearest ancestor `filename` contains `section`."""
d = find_up(start, [filename])
if not d:
return False
try:
with open(os.path.join(d, filename), errors="ignore") as fh:
return section in fh.read()
except Exception:
return False
def stylua_cmd(path):
exe = os.path.expanduser("~/.local/share/nvim/mason/bin/stylua")
if not os.access(exe, os.X_OK):
exe = shutil.which("stylua")
if exe and find_up(path, [".stylua.toml", "stylua.toml"]):
return [exe, path]
return None
def ruff_cmd(path):
exe = shutil.which("ruff")
if exe and (find_up(path, ["ruff.toml", ".ruff.toml"])
or has_toml_section(path, "pyproject.toml", "[tool.ruff")):
return [exe, "format", path]
return None
_PRETTIER_CFGS = [
".prettierrc", ".prettierrc.json", ".prettierrc.yaml", ".prettierrc.yml",
".prettierrc.json5", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.mjs",
".prettierrc.toml", "prettier.config.js", "prettier.config.cjs",
"prettier.config.mjs",
]
def prettier_cmd(path):
exe = shutil.which("prettier")
if exe and find_up(path, _PRETTIER_CFGS):
return [exe, "--write", path]
return None
def gofmt_cmd(path):
exe = shutil.which("gofmt")
if exe and find_up(path, ["go.mod"]): # one canonical style
return [exe, "-w", path]
return None
def rustfmt_cmd(path):
exe = shutil.which("rustfmt")
if exe and find_up(path, ["Cargo.toml", "rustfmt.toml", ".rustfmt.toml"]):
return [exe, path]
return None
# Shell and fish are opt-in too, gated on an .editorconfig SECTION that matches the file
# (shfmt reads .editorconfig natively for its settings, so the marker and the tool agree).
# "One canonical style" turned out not to mean "the style the project uses": 3 of ~/.dots's
# 25 fish files are not fish_indent-clean, so running these on sight rewrote whole files and
# buried a 40-line change in a 200-line diff. ~/.dots opts its shell in via
# `[{*.sh,dotsync,dotpub}]` while its fish files stay hand-formatted — existence of the
# .editorconfig alone must not turn either formatter on.
# Neovim agrees: conform.nvim there lists lua/css/html/ts/js only — never sh or fish.
def fish_cmd(path):
exe = shutil.which("fish_indent")
if exe and editorconfig_covers(path):
return [exe, "-w", path]
return None
def shfmt_cmd(path):
exe = shutil.which("shfmt")
if exe and editorconfig_covers(path):
return [exe, "-w", path]
return None
def taplo_cmd(path):
exe = shutil.which("taplo")
# opt-in: TOML reflow/align is intrusive, so only where the project asked.
if exe and find_up(path, ["taplo.toml", ".taplo.toml"]):
return [exe, "format", path]
return None
HANDLERS = {
".lua": stylua_cmd,
".py": ruff_cmd,
".js": prettier_cmd, ".jsx": prettier_cmd, ".mjs": prettier_cmd,
".cjs": prettier_cmd, ".ts": prettier_cmd, ".tsx": prettier_cmd,
".css": prettier_cmd, ".scss": prettier_cmd, ".less": prettier_cmd,
".html": prettier_cmd, ".vue": prettier_cmd, ".json": prettier_cmd,
".md": prettier_cmd, ".yaml": prettier_cmd, ".yml": prettier_cmd,
".go": gofmt_cmd,
".rs": rustfmt_cmd,
".fish": fish_cmd,
".sh": shfmt_cmd, ".bash": shfmt_cmd,
".toml": taplo_cmd,
}
def main():
try:
data = json.load(sys.stdin)
except Exception:
return
ti = data.get("tool_input") or {}
tr = data.get("tool_response") or {}
path = ti.get("file_path") or tr.get("filePath") or ""
if not path or not os.path.isfile(path):
return
# Resolve symlinks before walking for markers: deployed dotfiles get edited via
# their ~/.config/... links, but the opt-in configs live beside the real file in
# the repo — a lexical walk from the link's path would never find them.
path = os.path.realpath(path)
handler = HANDLERS.get(os.path.splitext(path)[1].lower())
if not handler:
return
cmd = handler(path)
if not cmd:
return
try:
subprocess.run(cmd, timeout=15, capture_output=True)
except Exception:
pass
if __name__ == "__main__":
main()