Skip to main content

standard_githooks/
parse.rs

1/// The execution mode for a hook command.
2///
3/// Controls what happens when the command exits with a non-zero status.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum Prefix {
6    /// No explicit prefix — uses the hook's default execution mode.
7    Default,
8    /// `!` prefix — abort the hook immediately on failure.
9    FailFast,
10    /// `?` prefix — report as a warning, never cause the hook to fail.
11    Advisory,
12    /// `~` prefix — auto-format staged files and re-stage (pre-commit only).
13    /// In other hooks, treated as `!` with a warning.
14    Fix,
15}
16
17/// A single command parsed from a `.githooks/<hook>.hooks` file.
18///
19/// Each non-blank, non-comment line in a hooks file produces one
20/// `HookCommand`. The line format is:
21///
22/// ```text
23/// [prefix]command [arguments] [glob]
24/// ```
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct HookCommand {
27    /// The execution mode prefix (`!`, `?`, or none).
28    pub prefix: Prefix,
29    /// The command text (executable and arguments, without prefix or glob).
30    pub command: String,
31    /// Optional trailing glob pattern that restricts the command to matching
32    /// staged or tracked files.
33    pub glob: Option<String>,
34}
35
36/// Parse the text content of a `.githooks/<hook>.hooks` file.
37///
38/// Blank lines and comment lines (starting with `#`) are skipped.
39/// Each remaining line is parsed into a [`HookCommand`] with its
40/// prefix, command text, and optional trailing glob pattern.
41///
42/// # Example
43///
44/// ```
45/// use standard_githooks::{parse, Prefix};
46///
47/// let input = "# Formatting\ndprint check\n!cargo clippy --workspace -- -D warnings *.rs\n? detekt --input modules/ *.kt\n";
48///
49/// let commands = parse(input);
50/// assert_eq!(commands.len(), 3);
51/// assert_eq!(commands[0].prefix, Prefix::Default);
52/// assert_eq!(commands[0].command, "dprint check");
53/// assert_eq!(commands[0].glob, None);
54///
55/// assert_eq!(commands[1].prefix, Prefix::FailFast);
56/// assert_eq!(commands[1].command, "cargo clippy --workspace -- -D warnings");
57/// assert_eq!(commands[1].glob, Some("*.rs".to_string()));
58///
59/// assert_eq!(commands[2].prefix, Prefix::Advisory);
60/// assert_eq!(commands[2].command, "detekt --input modules/");
61/// assert_eq!(commands[2].glob, Some("*.kt".to_string()));
62/// ```
63pub fn parse(content: &str) -> Vec<HookCommand> {
64    content
65        .lines()
66        .filter_map(|line| parse_line(line.trim()))
67        .collect()
68}
69
70/// Parse a single trimmed line into a `HookCommand`, or `None` if the
71/// line is blank or a comment.
72fn parse_line(line: &str) -> Option<HookCommand> {
73    if line.is_empty() || line.starts_with('#') {
74        return None;
75    }
76
77    let (prefix, rest) = extract_prefix(line);
78    let rest = rest.trim();
79
80    let (command, glob) = extract_glob(rest);
81
82    Some(HookCommand {
83        prefix,
84        command: command.to_string(),
85        glob,
86    })
87}
88
89/// Extract the prefix character and return the remaining text.
90fn extract_prefix(line: &str) -> (Prefix, &str) {
91    if let Some(rest) = line.strip_prefix('!') {
92        (Prefix::FailFast, rest)
93    } else if let Some(rest) = line.strip_prefix('?') {
94        (Prefix::Advisory, rest)
95    } else if let Some(rest) = line.strip_prefix('~') {
96        (Prefix::Fix, rest)
97    } else {
98        (Prefix::Default, line)
99    }
100}
101
102/// Extract an optional trailing glob pattern from the command text.
103///
104/// A glob is the last whitespace-separated token on the line, but only
105/// if it looks like a file-matching pattern (contains `*`, `[`, or
106/// brace expansion like `*.{js,ts}`). Quoted tokens and substitution
107/// tokens like `{msg}` are not treated as globs.
108fn extract_glob(text: &str) -> (&str, Option<String>) {
109    // Split at the last whitespace boundary
110    if let Some(pos) = text.rfind(|c: char| c.is_ascii_whitespace()) {
111        let last_token = &text[pos + 1..];
112        if !last_token.starts_with('"') && is_glob(last_token) {
113            let command = text[..pos].trim_end();
114            return (command, Some(last_token.to_string()));
115        }
116    }
117    (text, None)
118}
119
120/// Check whether a token looks like a glob pattern.
121///
122/// Recognises `*` and `[` as glob metacharacters. Brace expansion
123/// (`{a,b}`) counts only when the braces contain a comma, so that
124/// substitution tokens like `{msg}` are not mistaken for globs.
125fn is_glob(token: &str) -> bool {
126    if token.contains('*') || token.contains('[') {
127        return true;
128    }
129    // Brace expansion requires a comma inside braces
130    if let Some(open) = token.find('{')
131        && let Some(close) = token[open..].find('}')
132    {
133        let inner = &token[open + 1..open + close];
134        return inner.contains(',');
135    }
136    false
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn blank_lines_are_skipped() {
145        let commands = parse("\n\n  \n");
146        assert!(commands.is_empty());
147    }
148
149    #[test]
150    fn comment_lines_are_skipped() {
151        let commands = parse("# This is a comment\n  # indented comment\n");
152        assert!(commands.is_empty());
153    }
154
155    #[test]
156    fn simple_command_no_prefix_no_glob() {
157        let commands = parse("dprint check\n");
158        assert_eq!(commands.len(), 1);
159        assert_eq!(commands[0].prefix, Prefix::Default);
160        assert_eq!(commands[0].command, "dprint check");
161        assert_eq!(commands[0].glob, None);
162    }
163
164    #[test]
165    fn fail_fast_prefix() {
166        let commands = parse("!cargo build --workspace\n");
167        assert_eq!(commands.len(), 1);
168        assert_eq!(commands[0].prefix, Prefix::FailFast);
169        assert_eq!(commands[0].command, "cargo build --workspace");
170        assert_eq!(commands[0].glob, None);
171    }
172
173    #[test]
174    fn advisory_prefix() {
175        let commands = parse("? detekt --input modules/ *.kt\n");
176        assert_eq!(commands.len(), 1);
177        assert_eq!(commands[0].prefix, Prefix::Advisory);
178        assert_eq!(commands[0].command, "detekt --input modules/");
179        assert_eq!(commands[0].glob, Some("*.kt".to_string()));
180    }
181
182    #[test]
183    fn advisory_prefix_no_space() {
184        let commands = parse("?detekt --input modules/ *.kt\n");
185        assert_eq!(commands.len(), 1);
186        assert_eq!(commands[0].prefix, Prefix::Advisory);
187        assert_eq!(commands[0].command, "detekt --input modules/");
188        assert_eq!(commands[0].glob, Some("*.kt".to_string()));
189    }
190
191    #[test]
192    fn trailing_glob_pattern() {
193        let commands = parse("cargo clippy --workspace -- -D warnings *.rs\n");
194        assert_eq!(commands.len(), 1);
195        assert_eq!(
196            commands[0].command,
197            "cargo clippy --workspace -- -D warnings"
198        );
199        assert_eq!(commands[0].glob, Some("*.rs".to_string()));
200    }
201
202    #[test]
203    fn command_with_arguments_no_glob() {
204        let commands = parse("prettier --check \"**/*.md\"\n");
205        assert_eq!(commands.len(), 1);
206        assert_eq!(commands[0].command, "prettier --check \"**/*.md\"");
207    }
208
209    #[test]
210    fn command_with_msg_substitution() {
211        let commands = parse("! git std check --file {msg}\n");
212        assert_eq!(commands.len(), 1);
213        assert_eq!(commands[0].prefix, Prefix::FailFast);
214        assert_eq!(commands[0].command, "git std check --file {msg}");
215        assert_eq!(commands[0].glob, None);
216    }
217
218    #[test]
219    fn mixed_content() {
220        let input = "\
221# ── Formatting ────────────────────────────
222dprint check
223prettier --check \"**/*.md\"
224
225# ── Rust ──────────────────────────────────
226cargo clippy --workspace -- -D warnings *.rs
227cargo test --workspace --lib *.rs
228
229# ── Android ───────────────────────────────
230? detekt --input modules/ *.kt
231";
232        let commands = parse(input);
233        assert_eq!(commands.len(), 5);
234
235        assert_eq!(commands[0].prefix, Prefix::Default);
236        assert_eq!(commands[0].command, "dprint check");
237        assert_eq!(commands[0].glob, None);
238
239        assert_eq!(commands[1].prefix, Prefix::Default);
240        assert_eq!(commands[1].command, "prettier --check \"**/*.md\"");
241        assert_eq!(commands[1].glob, None);
242
243        assert_eq!(commands[2].prefix, Prefix::Default);
244        assert_eq!(
245            commands[2].command,
246            "cargo clippy --workspace -- -D warnings"
247        );
248        assert_eq!(commands[2].glob, Some("*.rs".to_string()));
249
250        assert_eq!(commands[3].prefix, Prefix::Default);
251        assert_eq!(commands[3].command, "cargo test --workspace --lib");
252        assert_eq!(commands[3].glob, Some("*.rs".to_string()));
253
254        assert_eq!(commands[4].prefix, Prefix::Advisory);
255        assert_eq!(commands[4].command, "detekt --input modules/");
256        assert_eq!(commands[4].glob, Some("*.kt".to_string()));
257    }
258
259    #[test]
260    fn commit_msg_hooks_file() {
261        let input = "! git std check --file {msg}\n";
262        let commands = parse(input);
263        assert_eq!(commands.len(), 1);
264        assert_eq!(commands[0].prefix, Prefix::FailFast);
265        assert_eq!(commands[0].command, "git std check --file {msg}");
266        assert_eq!(commands[0].glob, None);
267    }
268
269    #[test]
270    fn glob_with_brackets() {
271        let commands = parse("lint src/[a-z]*.rs\n");
272        assert_eq!(commands.len(), 1);
273        assert_eq!(commands[0].command, "lint");
274        assert_eq!(commands[0].glob, Some("src/[a-z]*.rs".to_string()));
275    }
276
277    #[test]
278    fn glob_with_braces() {
279        let commands = parse("check *.{js,ts}\n");
280        assert_eq!(commands.len(), 1);
281        assert_eq!(commands[0].command, "check");
282        assert_eq!(commands[0].glob, Some("*.{js,ts}".to_string()));
283    }
284
285    #[test]
286    fn single_word_command() {
287        let commands = parse("lint\n");
288        assert_eq!(commands.len(), 1);
289        assert_eq!(commands[0].command, "lint");
290        assert_eq!(commands[0].glob, None);
291    }
292
293    #[test]
294    fn whitespace_handling() {
295        let commands = parse("  cargo test  \n");
296        assert_eq!(commands.len(), 1);
297        assert_eq!(commands[0].command, "cargo test");
298        assert_eq!(commands[0].glob, None);
299    }
300
301    #[test]
302    fn empty_input() {
303        let commands = parse("");
304        assert!(commands.is_empty());
305    }
306
307    #[test]
308    fn prefix_display_coverage() {
309        assert_ne!(Prefix::Default, Prefix::FailFast);
310        assert_ne!(Prefix::Default, Prefix::Advisory);
311        assert_ne!(Prefix::FailFast, Prefix::Advisory);
312    }
313
314    // --- Edge-case tests (#115) ---
315
316    #[test]
317    fn prefix_only_bang_produces_empty_command() {
318        let commands = parse("!\n");
319        assert_eq!(commands.len(), 1);
320        assert_eq!(commands[0].prefix, Prefix::FailFast);
321        assert_eq!(commands[0].command, "");
322        assert_eq!(commands[0].glob, None);
323    }
324
325    #[test]
326    fn prefix_only_question_mark_produces_empty_command() {
327        let commands = parse("?\n");
328        assert_eq!(commands.len(), 1);
329        assert_eq!(commands[0].prefix, Prefix::Advisory);
330        assert_eq!(commands[0].command, "");
331        assert_eq!(commands[0].glob, None);
332    }
333
334    #[test]
335    fn whitespace_only_lines_are_skipped() {
336        let commands = parse("   \n\t\n  \t  \n");
337        assert!(commands.is_empty());
338    }
339
340    #[test]
341    fn lines_with_only_tabs() {
342        let commands = parse("\t\t\t\n");
343        assert!(commands.is_empty());
344    }
345
346    #[test]
347    fn malformed_glob_no_star_or_bracket() {
348        // A trailing token without glob metacharacters is treated as part
349        // of the command, not a glob.
350        let commands = parse("cargo test src/main.rs\n");
351        assert_eq!(commands.len(), 1);
352        assert_eq!(commands[0].command, "cargo test src/main.rs");
353        assert_eq!(commands[0].glob, None);
354    }
355
356    #[test]
357    fn braces_without_comma_are_not_glob() {
358        // `{msg}` has no comma, so it's not treated as a brace-expansion glob.
359        let commands = parse("echo {msg}\n");
360        assert_eq!(commands.len(), 1);
361        assert_eq!(commands[0].command, "echo {msg}");
362        assert_eq!(commands[0].glob, None);
363    }
364
365    #[test]
366    fn braces_with_comma_are_glob() {
367        let commands = parse("lint src/*.{js,ts}\n");
368        assert_eq!(commands.len(), 1);
369        assert_eq!(commands[0].command, "lint");
370        assert_eq!(commands[0].glob, Some("src/*.{js,ts}".to_string()));
371    }
372
373    #[test]
374    fn empty_braces_are_not_glob() {
375        let commands = parse("echo {}\n");
376        assert_eq!(commands.len(), 1);
377        assert_eq!(commands[0].command, "echo {}");
378        assert_eq!(commands[0].glob, None);
379    }
380
381    #[test]
382    fn prefix_with_space_before_command() {
383        // `! ` (prefix + space) should work the same as `!`
384        let commands = parse("! cargo test\n");
385        assert_eq!(commands.len(), 1);
386        assert_eq!(commands[0].prefix, Prefix::FailFast);
387        assert_eq!(commands[0].command, "cargo test");
388    }
389
390    #[test]
391    fn comment_after_whitespace() {
392        // Indented comment should still be skipped.
393        let commands = parse("    # indented comment\n");
394        assert!(commands.is_empty());
395    }
396
397    #[test]
398    fn mixed_edge_cases() {
399        let input = "\n\
400            !\n\
401            ?\n\
402            #comment\n\
403            \n\
404            cargo test\n\
405            \t\n";
406        let commands = parse(input);
407        assert_eq!(commands.len(), 3);
408        assert_eq!(commands[0].prefix, Prefix::FailFast);
409        assert_eq!(commands[0].command, "");
410        assert_eq!(commands[1].prefix, Prefix::Advisory);
411        assert_eq!(commands[1].command, "");
412        assert_eq!(commands[2].prefix, Prefix::Default);
413        assert_eq!(commands[2].command, "cargo test");
414    }
415
416    // --- Fix prefix (~) tests (#197) ---
417
418    #[test]
419    fn fix_prefix_with_space() {
420        let commands = parse("~ cargo fmt\n");
421        assert_eq!(commands.len(), 1);
422        assert_eq!(commands[0].prefix, Prefix::Fix);
423        assert_eq!(commands[0].command, "cargo fmt");
424        assert_eq!(commands[0].glob, None);
425    }
426
427    #[test]
428    fn fix_prefix_no_space() {
429        let commands = parse("~cargo fmt\n");
430        assert_eq!(commands.len(), 1);
431        assert_eq!(commands[0].prefix, Prefix::Fix);
432        assert_eq!(commands[0].command, "cargo fmt");
433        assert_eq!(commands[0].glob, None);
434    }
435
436    #[test]
437    fn fix_prefix_with_glob() {
438        let commands = parse("~ dprint fmt *.rs\n");
439        assert_eq!(commands.len(), 1);
440        assert_eq!(commands[0].prefix, Prefix::Fix);
441        assert_eq!(commands[0].command, "dprint fmt");
442        assert_eq!(commands[0].glob, Some("*.rs".to_string()));
443    }
444
445    #[test]
446    fn fix_prefix_only_produces_empty_command() {
447        let commands = parse("~\n");
448        assert_eq!(commands.len(), 1);
449        assert_eq!(commands[0].prefix, Prefix::Fix);
450        assert_eq!(commands[0].command, "");
451        assert_eq!(commands[0].glob, None);
452    }
453
454    #[test]
455    fn fix_prefix_distinct_from_others() {
456        assert_ne!(Prefix::Fix, Prefix::Default);
457        assert_ne!(Prefix::Fix, Prefix::FailFast);
458        assert_ne!(Prefix::Fix, Prefix::Advisory);
459    }
460
461    #[test]
462    fn fix_prefix_in_mixed_content() {
463        let input = "! cargo clippy\n~ cargo fmt\n? cargo test\n";
464        let commands = parse(input);
465        assert_eq!(commands.len(), 3);
466        assert_eq!(commands[0].prefix, Prefix::FailFast);
467        assert_eq!(commands[1].prefix, Prefix::Fix);
468        assert_eq!(commands[2].prefix, Prefix::Advisory);
469    }
470}