#!/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()