209 lines
6.9 KiB
Python
Executable File
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()
|