From 901cdcc69c8f987cae923af4d1d1d47955a0abd7 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:16:48 +0800 Subject: [PATCH 1/2] Add pattern matching builtin hooks --- Cargo.lock | 1 + Cargo.toml | 1 + crates/prek/Cargo.toml | 1 + crates/prek/src/hooks/builtin_hooks/mod.rs | 38 +++- .../prek/src/hooks/builtin_hooks/pattern.rs | 182 ++++++++++++++++++ crates/prek/tests/builtin_hooks.rs | 164 ++++++++++++++++ crates/prek/tests/list_builtins.rs | 18 ++ docs/builtin.md | 51 +++++ docs/languages.md | 4 + prek.schema.json | 2 + 10 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 crates/prek/src/hooks/builtin_hooks/pattern.rs diff --git a/Cargo.lock b/Cargo.lock index 270405d8c..17793d8a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2017,6 +2017,7 @@ dependencies = [ "prek-pty", "pretty_assertions", "regex", + "regex-automata", "reqwest", "rustc-hash", "rustix", diff --git a/Cargo.toml b/Cargo.toml index 89ad8bfb4..c853b9495 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,6 +132,7 @@ markdown = { version = "1.0.0" } predicates = { version = "3.1.2" } pretty_assertions = { version = "1.4.1" } regex = { version = "1.11.0" } +regex-automata = { version = "0.4.14" } schemars = { version = "1.1.0" } textwrap = { version = "0.16.0" } diff --git a/crates/prek/Cargo.toml b/crates/prek/Cargo.toml index 133aacb10..e9c599b0a 100644 --- a/crates/prek/Cargo.toml +++ b/crates/prek/Cargo.toml @@ -62,6 +62,7 @@ mea = { workspace = true } memchr = { workspace = true } owo-colors = { workspace = true } regex = { workspace = true } +regex-automata = { workspace = true } reqwest = { workspace = true } aws-lc-rs = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/prek/src/hooks/builtin_hooks/mod.rs b/crates/prek/src/hooks/builtin_hooks/mod.rs index b731ba6aa..3f22af886 100644 --- a/crates/prek/src/hooks/builtin_hooks/mod.rs +++ b/crates/prek/src/hooks/builtin_hooks/mod.rs @@ -12,6 +12,7 @@ use crate::store::Store; mod check_illegal_windows_names; mod check_json5; +mod pattern; #[derive( Debug, @@ -41,6 +42,7 @@ pub(crate) enum BuiltinHooks { CheckVcsPermalinks, CheckXml, CheckYaml, + DenyPattern, DestroyedSymlinks, DetectPrivateKey, EndOfFileFixer, @@ -50,6 +52,7 @@ pub(crate) enum BuiltinHooks { MixedLineEnding, NoCommitToBranch, PrettyFormatJson, + RequirePattern, TrailingWhitespace, } @@ -76,10 +79,12 @@ impl BuiltinHooks { | Self::CheckVcsPermalinks | Self::CheckXml | Self::CheckYaml + | Self::DenyPattern | Self::DestroyedSymlinks | Self::DetectPrivateKey | Self::ForbidNewSubmodules - | Self::NoCommitToBranch => false, + | Self::NoCommitToBranch + | Self::RequirePattern => false, } } @@ -117,6 +122,7 @@ impl BuiltinHooks { } Self::CheckXml => pre_commit_hooks::check_xml(hook, filenames).await, Self::CheckYaml => pre_commit_hooks::check_yaml(hook, filenames).await, + Self::DenyPattern => pattern::deny_pattern(hook, filenames).await, Self::DestroyedSymlinks => pre_commit_hooks::destroyed_symlinks(hook, filenames).await, Self::DetectPrivateKey => pre_commit_hooks::detect_private_key(hook, filenames).await, Self::EndOfFileFixer => pre_commit_hooks::fix_end_of_file(hook, filenames).await, @@ -132,6 +138,7 @@ impl BuiltinHooks { Self::MixedLineEnding => pre_commit_hooks::mixed_line_ending(hook, filenames).await, Self::NoCommitToBranch => pre_commit_hooks::no_commit_to_branch(hook).await, Self::PrettyFormatJson => pre_commit_hooks::pretty_format_json(hook, filenames).await, + Self::RequirePattern => pattern::require_pattern(hook, filenames).await, Self::TrailingWhitespace => { pre_commit_hooks::fix_trailing_whitespace(hook, filenames).await } @@ -323,6 +330,20 @@ impl BuiltinHook { ..Default::default() }, }, + BuiltinHooks::DenyPattern => BuiltinHook { + id: "deny-pattern".to_string(), + name: "deny patterns".to_string(), + entry: "deny-pattern".to_string(), + priority: None, + groups: None, + options: HookOptions { + description: Some( + "fails if any file contains a matching regular expression.".to_string(), + ), + types: Some(tags::TAG_SET_TEXT), + ..Default::default() + }, + }, BuiltinHooks::DestroyedSymlinks => BuiltinHook { id: "destroyed-symlinks".to_string(), name: "detect destroyed symlinks".to_string(), @@ -442,6 +463,21 @@ impl BuiltinHook { ..Default::default() }, }, + BuiltinHooks::RequirePattern => BuiltinHook { + id: "require-pattern".to_string(), + name: "require patterns".to_string(), + entry: "require-pattern".to_string(), + priority: None, + groups: None, + options: HookOptions { + description: Some( + "fails if any file does not contain a matching regular expression." + .to_string(), + ), + types: Some(tags::TAG_SET_TEXT), + ..Default::default() + }, + }, BuiltinHooks::TrailingWhitespace => BuiltinHook { id: "trailing-whitespace".to_string(), name: "trim trailing whitespace".to_string(), diff --git a/crates/prek/src/hooks/builtin_hooks/pattern.rs b/crates/prek/src/hooks/builtin_hooks/pattern.rs new file mode 100644 index 000000000..55b70ca30 --- /dev/null +++ b/crates/prek/src/hooks/builtin_hooks/pattern.rs @@ -0,0 +1,182 @@ +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use clap::Parser; +use memchr::memchr_iter; +use regex_automata::{MatchKind, meta::Regex, util::syntax}; +use tokio::io::{AsyncBufReadExt, BufReader}; + +use crate::hook::Hook; +use crate::hooks::run_concurrent_file_checks; +use crate::run::INTERNAL_CONCURRENCY; + +#[derive(Parser)] +#[command(disable_help_subcommand = true)] +#[command(disable_version_flag = true)] +#[command(disable_help_flag = true)] +struct Args { + #[arg(short = 'i', long)] + ignore_case: bool, + #[arg(short = 'm', long)] + multiline: bool, + #[arg(required = true, value_name = "PATTERN")] + patterns: Vec, +} + +#[derive(Clone, Copy)] +enum MatchPolicy { + Deny, + Require, +} + +#[derive(Clone, Copy)] +enum ScanMode { + Lines, + Multiline, +} + +struct Matcher { + regex: Regex, + scan_mode: ScanMode, +} + +impl Matcher { + fn new(args: &Args) -> Result { + let syntax = syntax::Config::new() + // Enable case-insensitive matching for `-i` / `--ignore-case`. + .case_insensitive(args.ignore_case) + // Let `^` and `$` match line boundaries for `-m` / `--multiline`. + .multi_line(args.multiline) + // Let `.` match newlines for `-m` / `--multiline`. + .dot_matches_new_line(args.multiline) + // Compile byte-oriented patterns so arbitrary file bytes can match. + .utf8(false); + let regex = Regex::builder() + .configure( + Regex::config() + // Return the earliest match, using pattern order to break ties. + .match_kind(MatchKind::LeftmostFirst) + // Allow empty matches at any byte offset, as byte regexes do. + .utf8_empty(false), + ) + .syntax(syntax) + .build_many(&args.patterns) + .context("Failed to compile regex patterns")?; + + let scan_mode = if args.multiline { + ScanMode::Multiline + } else { + ScanMode::Lines + }; + + Ok(Self { regex, scan_mode }) + } +} + +pub(crate) async fn deny_pattern(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { + run(hook, filenames, MatchPolicy::Deny).await +} + +pub(crate) async fn require_pattern(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { + run(hook, filenames, MatchPolicy::Require).await +} + +async fn run(hook: &Hook, filenames: &[&Path], policy: MatchPolicy) -> Result<(i32, Vec)> { + let args = Args::try_parse_from(hook.entry.expect_direct().split_with_args(&hook.args)?)?; + let matcher = Matcher::new(&args)?; + let file_base = hook.project().relative_path(); + + run_concurrent_file_checks( + filenames.iter().copied(), + *INTERNAL_CONCURRENCY, + |filename| check_file(file_base, filename, &matcher, policy), + ) + .await +} + +async fn check_file( + file_base: &Path, + filename: &Path, + matcher: &Matcher, + policy: MatchPolicy, +) -> Result<(i32, Vec)> { + match matcher.scan_mode { + ScanMode::Lines => check_lines(file_base, filename, &matcher.regex, policy).await, + ScanMode::Multiline => check_multiline(file_base, filename, &matcher.regex, policy).await, + } +} + +async fn check_lines( + file_base: &Path, + filename: &Path, + patterns: &Regex, + policy: MatchPolicy, +) -> Result<(i32, Vec)> { + let file = fs_err::tokio::File::open(file_base.join(filename)).await?; + let mut reader = BufReader::new(file); + let mut matched = false; + let mut output = Vec::new(); + let mut line = Vec::new(); + let mut line_number = 0; + + while reader.read_until(b'\n', &mut line).await? != 0 { + line_number += 1; + let contents = trim_line_ending(&line); + if patterns.is_match(contents) { + if matches!(policy, MatchPolicy::Require) { + return Ok((0, Vec::new())); + } + + matched = true; + write!(output, "{}:{line_number}:", filename.display())?; + output.write_all(contents)?; + writeln!(output)?; + } + line.clear(); + } + + match policy { + MatchPolicy::Deny => Ok((i32::from(matched), output)), + MatchPolicy::Require => Ok(missing_match(filename)), + } +} + +async fn check_multiline( + file_base: &Path, + filename: &Path, + patterns: &Regex, + policy: MatchPolicy, +) -> Result<(i32, Vec)> { + let contents = fs_err::tokio::read(file_base.join(filename)).await?; + match policy { + MatchPolicy::Deny => { + let Some(matched) = patterns.find(&contents) else { + return Ok((0, Vec::new())); + }; + let line_number = memchr_iter(b'\n', &contents[..matched.start()]).count() + 1; + let matched = &contents[matched.range()]; + let mut output = Vec::new(); + write!(output, "{}:{line_number}:", filename.display())?; + output.write_all(matched)?; + if !matched.ends_with(b"\n") { + writeln!(output)?; + } + Ok((1, output)) + } + MatchPolicy::Require if patterns.is_match(&contents) => Ok((0, Vec::new())), + MatchPolicy::Require => Ok(missing_match(filename)), + } +} + +fn missing_match(filename: &Path) -> (i32, Vec) { + ( + 1, + format!("{}: no pattern matched\n", filename.display()).into_bytes(), + ) +} + +fn trim_line_ending(line: &[u8]) -> &[u8] { + let line = line.strip_suffix(b"\n").unwrap_or(line); + line.strip_suffix(b"\r").unwrap_or(line) +} diff --git a/crates/prek/tests/builtin_hooks.rs b/crates/prek/tests/builtin_hooks.rs index 9cd56d550..643ddb83d 100644 --- a/crates/prek/tests/builtin_hooks.rs +++ b/crates/prek/tests/builtin_hooks.rs @@ -77,6 +77,170 @@ fn builtin_hooks_unknown_hook() { "); } +#[test] +fn deny_pattern_hook_reports_matching_lines() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: deny-pattern + args: + - --ignore-case + - '\btodo\b' + - 'remove' + - '^#import\s+.+:\s+\*$' + files: '\.typ$' + "}); + + let cwd = context.work_dir(); + cwd.child("policy.typ").write_str(indoc::indoc! {" + permitted content + TODO: remove this + #import package: * + "})?; + cwd.child("ignored.txt") + .write_str("TODO: ignored by files filter\n")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 1 + ----- stdout ----- + deny patterns............................................................Failed + - hook id: deny-pattern + - exit code: 1 + + policy.typ:2:TODO: remove this + policy.typ:3:#import package: * + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn deny_pattern_hook_rejects_invalid_regex() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: deny-pattern + args: ['*invalid-pattern*'] + "}); + + context + .work_dir() + .child("file.txt") + .write_str("content\n")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r#" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to run hook `deny-pattern` + caused by: Failed to compile regex patterns + caused by: error parsing pattern 0 + caused by: regex parse error: + *invalid-pattern* + ^ + error: repetition operator missing expression + "#); + + Ok(()) +} + +#[test] +fn deny_pattern_hook_reports_earliest_multiline_match() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + // `END` is listed first, but `BEGIN.*END` starts earlier in the file. + // Multiline matching should report the earliest match, not the first pattern. + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: deny-pattern + args: [-m, 'END', 'BEGIN.*END'] + files: '\.txt$' + "}); + + context + .work_dir() + .child("block.txt") + .write_str(indoc::indoc! {" + before + BEGIN + middle + END + after + "})?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 1 + ----- stdout ----- + deny patterns............................................................Failed + - hook id: deny-pattern + - exit code: 1 + + block.txt:2:BEGIN + middle + END + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn require_pattern_hook_reports_files_without_any_match() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: require-pattern + args: [--ignore-case, --multiline, 'begin.*end', 'copyright'] + files: '\.txt$' + "}); + + let cwd = context.work_dir(); + cwd.child("block.txt").write_str("BEGIN\nmiddle\nEND\n")?; + cwd.child("copyright.txt").write_str("Copyright 2026\n")?; + cwd.child("missing.txt").write_str("No required marker\n")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 1 + ----- stdout ----- + require patterns.........................................................Failed + - hook id: require-pattern + - exit code: 1 + + missing.txt: no pattern matched + + ----- stderr ----- + "); + + Ok(()) +} + #[test] fn end_of_file_fixer_hook() -> Result<()> { let context = TestContext::new(); diff --git a/crates/prek/tests/list_builtins.rs b/crates/prek/tests/list_builtins.rs index 443eef32e..c6604ba34 100644 --- a/crates/prek/tests/list_builtins.rs +++ b/crates/prek/tests/list_builtins.rs @@ -23,6 +23,7 @@ fn list_builtins_basic() { check-vcs-permalinks check-xml check-yaml + deny-pattern destroyed-symlinks detect-private-key end-of-file-fixer @@ -32,6 +33,7 @@ fn list_builtins_basic() { mixed-line-ending no-commit-to-branch pretty-format-json + require-pattern trailing-whitespace ----- stderr ----- @@ -85,6 +87,9 @@ fn list_builtins_verbose() { check-yaml checks yaml files for parseable syntax. + deny-pattern + fails if any file contains a matching regular expression. + destroyed-symlinks detects symlinks that were replaced with regular files whose contents are the original symlink target path. @@ -111,6 +116,9 @@ fn list_builtins_verbose() { pretty-format-json checks that JSON files are pretty-formatted. + require-pattern + fails if any file does not contain a matching regular expression. + trailing-whitespace trims trailing whitespace. @@ -193,6 +201,11 @@ fn list_builtins_json() { "name": "check yaml", "description": "checks yaml files for parseable syntax." }, + { + "id": "deny-pattern", + "name": "deny patterns", + "description": "fails if any file contains a matching regular expression." + }, { "id": "destroyed-symlinks", "name": "detect destroyed symlinks", @@ -238,6 +251,11 @@ fn list_builtins_json() { "name": "pretty format json", "description": "checks that JSON files are pretty-formatted." }, + { + "id": "require-pattern", + "name": "require patterns", + "description": "fails if any file does not contain a matching regular expression." + }, { "id": "trailing-whitespace", "name": "trim trailing whitespace", diff --git a/docs/builtin.md b/docs/builtin.md index 53b7a9f0f..72e0c8fd0 100644 --- a/docs/builtin.md +++ b/docs/builtin.md @@ -109,6 +109,8 @@ For `repo: builtin`, the following hooks are supported: - [`check-vcs-permalinks`](#check-vcs-permalinks) (Check that VCS links are permalinks) - [`check-yaml`](#check-yaml) (Validate YAML files) - [`check-xml`](#check-xml) (Validate XML files) +- [`deny-pattern`](#deny-pattern) (Reject configured regular expression matches) +- [`require-pattern`](#require-pattern) (Require each file to match a configured regular expression) - [`mixed-line-ending`](#mixed-line-ending) (Normalize or check line endings) - [`check-symlinks`](#check-symlinks) (Check for broken symlinks) - [`destroyed-symlinks`](#destroyed-symlinks) (Detect destroyed symlinks) @@ -392,6 +394,55 @@ Attempts to load all XML files to verify syntax. --- +#### `deny-pattern` + +Fails when any selected text file matches a configured regular expression. +Patterns use the [Rust `regex` syntax](https://docs.rs/regex/latest/regex/#syntax). +When multiple patterns are provided, matching any one of them is sufficient. + +**Supported arguments** + +- `PATTERN...` (required) + - Positional regular expressions to deny. + - Use `--` before a pattern that begins with `-`. +- `-i`, `--ignore-case` + - Match all patterns case-insensitively. +- `-m`, `--multiline` + - Search each file as a whole, with `^` and `$` matching line boundaries and `.` matching newlines. + - Reads each selected file into memory. + +By default, each matching line is reported as `path:line:contents`. A line matching more than one pattern is reported only once. With `--multiline`, the earliest match in each file is reported as `path:start-line:matched-block`. + +```yaml +repos: + - repo: builtin + hooks: + - id: deny-pattern + name: disallow wildcard imports + args: ['^\s*#import\s+.+:\s*\*'] + files: \.typ$ +``` + +--- + +#### `require-pattern` + +Fails when any selected text file does not match at least one configured regular expression. This is a per-file requirement: every file must match, while different files may match different patterns. + +`require-pattern` supports the same positional `PATTERN...`, `-i` / `--ignore-case`, and `--multiline` arguments as [`deny-pattern`](#deny-pattern). Files without a match are reported as `path: no pattern matched`. + +```yaml +repos: + - repo: builtin + hooks: + - id: require-pattern + name: require a copyright notice + args: [--ignore-case, copyright] + files: '\.(rs|py)$' +``` + +--- + #### `mixed-line-ending` Replaces or checks mixed line endings. diff --git a/docs/languages.md b/docs/languages.md index 15691171a..456a29089 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -576,6 +576,10 @@ prek provides a Python-based grep implementation for file content matching. The Regex matching uses Python’s `re` semantics for compatibility with pre-commit. +!!! warning "Compatibility-only" + + `pygrep` is provided for compatibility with `pre-commit`. Because it uses Python’s `re` semantics, `prek` must find or provision a Python interpreter and spawn a Python process to perform the matching. If upstream `pre-commit` compatibility is not required, prefer the native [`deny-pattern`](builtin.md#deny-pattern) or [`require-pattern`](builtin.md#require-pattern) builtin. + ### system `system` runs a system executable without a managed environment. The command is taken from `entry`, and filenames are appended unless `pass_filenames: false` is set. Dependencies must be installed by the user. diff --git a/prek.schema.json b/prek.schema.json index 12428d278..ecdcee91b 100644 --- a/prek.schema.json +++ b/prek.schema.json @@ -1023,6 +1023,7 @@ "check-vcs-permalinks", "check-xml", "check-yaml", + "deny-pattern", "destroyed-symlinks", "detect-private-key", "end-of-file-fixer", @@ -1032,6 +1033,7 @@ "mixed-line-ending", "no-commit-to-branch", "pretty-format-json", + "require-pattern", "trailing-whitespace" ] }, From b8b5cb3bcbe91cd87268114380b7ca6bc9298a80 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:51:37 +0800 Subject: [PATCH 2/2] Remove test --- crates/prek/tests/languages/deno.rs | 35 +---------------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/crates/prek/tests/languages/deno.rs b/crates/prek/tests/languages/deno.rs index b8d3f6f99..237c1eba3 100644 --- a/crates/prek/tests/languages/deno.rs +++ b/crates/prek/tests/languages/deno.rs @@ -2,7 +2,7 @@ use assert_fs::assert::PathAssert; use assert_fs::fixture::{FileWriteStr, PathChild}; use prek_consts::env_vars::{EnvVars, EnvVarsRead}; -use crate::common::{TestContext, cmd_snapshot, remove_bin_from_path}; +use crate::common::{TestContext, cmd_snapshot}; /// Test basic Deno hook execution with an inline script. #[test] @@ -495,39 +495,6 @@ fn checksum_policy() { "); } -/// Test that deno hooks work without system deno in PATH. -/// Regression test ensuring run-time resolution still finds the managed toolchain. -#[test] -fn without_system_deno() { - let context = TestContext::new(); - context.init_project(); - - context.write_pre_commit_config(indoc::indoc! {r#" - repos: - - repo: local - hooks: - - id: deno-check - name: deno check - language: deno - entry: deno eval 'console.log("Hello")' - always_run: true - pass_filenames: false - "#}); - - context.git_add("."); - - let new_path = remove_bin_from_path("deno", None).expect("Failed to remove deno from PATH"); - - cmd_snapshot!(context.filters(), context.run().env("PATH", new_path), @r" - success: true - exit_code: 0 - ----- stdout ----- - deno check...............................................................Passed - - ----- stderr ----- - "); -} - /// Test semver range version specification. #[test] fn version_range() {