Skip to main content

amont_runtime/hooks/
prettier.rs

1//! pre-commit-prettier — format check over staged files, scoped to repos that
2//! actually opt into prettier.
3
4use super::common::{
5    fail, first_existing, fixing_enabled, hl, ok, repo_root, resolve_tool, restage,
6    run as run_tool, run_quiet, staged_files, warn, Restaged,
7};
8use crate::check::Outcome;
9use std::path::Path;
10
11/// Everything prettier is asked to look at.
12///
13/// Exported, even though `registry.rs` deliberately declares
14/// `Scope::files(&[])` for this check (it is opt-in by CONFIG, not by file
15/// type): the drift guard needs to know what the check actually consumes, and
16/// an empty declared set is a subset of this one.
17pub const EXTS: &[&str] = &[
18    ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".json", ".jsonc", ".css", ".scss", ".less",
19    ".html", ".vue", ".md", ".mdx", ".yaml", ".yml",
20];
21
22/// A repo opts in via a prettier config file or a `"prettier"` key in
23/// package.json. Without that signal the hook does nothing — in a repo that
24/// does not use prettier, `npx prettier` would download it and flag every file
25/// against defaults.
26fn has_config(root: &str) -> bool {
27    if first_existing(
28        root,
29        &[
30            ".prettierrc",
31            "prettier.config.js",
32            "prettier.config.mjs",
33            "prettier.config.cjs",
34            ".prettierrc.json",
35            ".prettierrc.yml",
36            ".prettierrc.yaml",
37            ".prettierrc.js",
38            ".prettierrc.cjs",
39            ".prettierrc.mjs",
40            ".prettierrc.toml",
41        ],
42    )
43    .is_some()
44    {
45        return true;
46    }
47    std::fs::read_to_string(Path::new(root).join("package.json"))
48        .map(|p| p.contains("\"prettier\""))
49        .unwrap_or(false)
50}
51
52pub fn run(_args: &[std::ffi::OsString]) -> Outcome {
53    let files = staged_files(EXTS);
54    if files.is_empty() {
55        return Outcome::Passed;
56    }
57    let root = repo_root();
58
59    let tool = resolve_tool(&root, "prettier");
60    // A pinned local prettier is itself an opt-in signal; without config AND
61    // without a local binary the repo simply does not use prettier.
62    let local_pinned = tool
63        .as_ref()
64        .map(|t| t[0].contains("node_modules/.bin"))
65        .unwrap_or(false);
66    if !has_config(&root) && !local_pinned {
67        return Outcome::Passed;
68    }
69    let Some(argv) = tool else {
70        warn(&format!(
71            "prettier config found but no prettier binary. Run {}",
72            hl("npm install")
73        ));
74        return Outcome::Unavailable;
75    };
76
77    // Ignore a GLOBAL ~/.editorconfig when this repo defines none of its own:
78    // prettier otherwise walks up past the repo and enforces machine-wide
79    // defaults (4-space, say) on a repo formatted differently — a false
80    // positive that also disagrees with CI, which has no ~/.editorconfig.
81    // A repo-level .editorconfig is still honoured, matching CI.
82    let mut flags: Vec<String> = Vec::new();
83    if !Path::new(&root).join(".editorconfig").is_file() {
84        flags.push("--no-editorconfig".into());
85    }
86
87    // `--` before the file list at each of these: a staged file named e.g.
88    // `-x.js` would otherwise be read as a flag by prettier's own parser —
89    // and prettier does not even error on that, it exits 0 having checked
90    // nothing, so the file would silently never be linted at all.
91    // ONE `--check` pass. It used to run twice on the clean path — once to
92    // decide whether to repair, once to decide the verdict — which is a whole
93    // extra prettier startup on every commit that had nothing wrong with it.
94    let mut check = flags.clone();
95    check.push("--check".into());
96    check.push("--".into());
97    check.extend(files.iter().cloned());
98    if run_quiet(&root, &argv, &check) {
99        ok("Prettier passed");
100        return Outcome::Passed;
101    }
102
103    if fixing_enabled() {
104        // Asked to repair, so repair rather than reporting an instruction the
105        // author would carry out identically by hand.
106        let mut write = flags.clone();
107        write.push("--write".into());
108        write.push("--".into());
109        write.extend(files.iter().cloned());
110        if run_quiet(&root, &argv, &write) {
111            // Dropping the confirming re-`--check` after the write is safe:
112            // `prettier --write` exits NON-ZERO on a parse error, so a
113            // successful write means the files are formatted.
114            match restage(&files) {
115                Restaged::Staged => {
116                    ok("Prettier reformatted and re-staged");
117                    return Outcome::Fixed;
118                }
119                // The index still holds the content prettier has just replaced
120                // on disk. Reporting anything but a failure here is how
121                // unformatted code reached a commit the hook called clean.
122                Restaged::Failed(stuck) => {
123                    fail(&format!(
124                        "Prettier reformatted these files but {} failed — the index still \
125                         holds the UNFORMATTED content: {}",
126                        hl("git add"),
127                        stuck.join(", ")
128                    ));
129                    return Outcome::Failed;
130                }
131                // Nothing differed from the index, so the write changed
132                // nothing that was staged. Fall through and report.
133                Restaged::Nothing => {}
134            }
135        }
136    }
137
138    fail(&format!(
139        "Prettier found unformatted files. Run {} on:",
140        hl("prettier --write")
141    ));
142    let mut list = flags;
143    list.push("--list-different".into());
144    list.push("--".into());
145    list.extend(files);
146    let _ = run_tool(&root, &argv, &list);
147    Outcome::Failed
148}