From 77628b1efcce2008efab757c7ecd3e5181b5ec87 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:24:09 +0800 Subject: [PATCH] Add deny-filename-pattern and require-filename-pattern hooks --- crates/prek/src/hooks/builtin_hooks/mod.rs | 39 ++++++++ .../prek/src/hooks/builtin_hooks/pattern.rs | 98 ++++++++++++++----- crates/prek/tests/builtin_hooks.rs | 82 ++++++++++++++++ crates/prek/tests/list_builtins.rs | 22 +++++ docs/builtin.md | 50 ++++++++++ prek.schema.json | 2 + 6 files changed, 271 insertions(+), 22 deletions(-) diff --git a/crates/prek/src/hooks/builtin_hooks/mod.rs b/crates/prek/src/hooks/builtin_hooks/mod.rs index 51d3748a6..8afc81071 100644 --- a/crates/prek/src/hooks/builtin_hooks/mod.rs +++ b/crates/prek/src/hooks/builtin_hooks/mod.rs @@ -51,6 +51,7 @@ pub(crate) enum BuiltinHooks { CheckVcsPermalinks, CheckXml, CheckYaml, + DenyFilenamePattern, DenyPattern, DestroyedSymlinks, DetectPrivateKey, @@ -61,6 +62,7 @@ pub(crate) enum BuiltinHooks { MixedLineEnding, NoCommitToBranch, PrettyFormatJson, + RequireFilenamePattern, RequirePattern, RequirementsTxtFixer, TrailingWhitespace, @@ -73,6 +75,9 @@ impl BuiltinHooks { Self::CheckMergeConflict => check_merge_conflict::Args::command(), Self::CheckVcsPermalinks => check_vcs_permalinks::Args::command(), Self::CheckYaml => check_yaml::Args::command(), + Self::DenyFilenamePattern | Self::RequireFilenamePattern => { + pattern::FilenameArgs::command() + } Self::DenyPattern | Self::RequirePattern => pattern::Args::command(), Self::FileContentsSorter => file_contents_sorter::Args::command(), Self::MixedLineEnding => mixed_line_ending::Args::command(), @@ -117,6 +122,9 @@ impl BuiltinHooks { Self::CheckVcsPermalinks => Box::pin(check_vcs_permalinks::run(hook, filenames)), Self::CheckXml => Box::pin(check_xml::run(hook, filenames)), Self::CheckYaml => Box::pin(check_yaml::run(hook, filenames)), + Self::DenyFilenamePattern => Box::pin(std::future::ready( + pattern::deny_filename_pattern(hook, filenames), + )), Self::DenyPattern => Box::pin(pattern::deny_pattern(hook, filenames)), Self::DestroyedSymlinks => Box::pin(destroyed_symlinks::run(hook, filenames)), Self::DetectPrivateKey => Box::pin(detect_private_key::run(hook, filenames)), @@ -127,6 +135,9 @@ impl BuiltinHooks { Self::MixedLineEnding => Box::pin(mixed_line_ending::run(hook, filenames)), Self::NoCommitToBranch => Box::pin(no_commit_to_branch::run(hook)), Self::PrettyFormatJson => Box::pin(pretty_format_json::run(hook, filenames)), + Self::RequireFilenamePattern => Box::pin(std::future::ready( + pattern::require_filename_pattern(hook, filenames), + )), Self::RequirePattern => Box::pin(pattern::require_pattern(hook, filenames)), Self::RequirementsTxtFixer => Box::pin(requirements_txt_fixer::run(hook, filenames)), Self::TrailingWhitespace => Box::pin(fix_trailing_whitespace::run(hook, filenames)), @@ -319,6 +330,20 @@ impl BuiltinHook { ..Default::default() }, }, + BuiltinHooks::DenyFilenamePattern => BuiltinHook { + id: "deny-filename-pattern".to_string(), + name: "deny filename patterns".to_string(), + entry: "deny-filename-pattern".to_string(), + priority: None, + groups: None, + options: HookOptions { + description: Some( + "Fails if any selected filename matches a regular expression." + .to_string(), + ), + ..Default::default() + }, + }, BuiltinHooks::DenyPattern => BuiltinHook { id: "deny-pattern".to_string(), name: "deny patterns".to_string(), @@ -455,6 +480,20 @@ impl BuiltinHook { ..Default::default() }, }, + BuiltinHooks::RequireFilenamePattern => BuiltinHook { + id: "require-filename-pattern".to_string(), + name: "require filename patterns".to_string(), + entry: "require-filename-pattern".to_string(), + priority: None, + groups: None, + options: HookOptions { + description: Some( + "Fails if any selected filename does not match a regular expression." + .to_string(), + ), + ..Default::default() + }, + }, BuiltinHooks::RequirePattern => BuiltinHook { id: "require-pattern".to_string(), name: "require patterns".to_string(), diff --git a/crates/prek/src/hooks/builtin_hooks/pattern.rs b/crates/prek/src/hooks/builtin_hooks/pattern.rs index 9c76b7011..4b7ceabb2 100644 --- a/crates/prek/src/hooks/builtin_hooks/pattern.rs +++ b/crates/prek/src/hooks/builtin_hooks/pattern.rs @@ -27,6 +27,18 @@ pub(crate) struct Args { patterns: Vec, } +#[derive(Parser)] +#[command(disable_help_subcommand = true)] +#[command(disable_version_flag = true)] +#[command(disable_help_flag = true)] +pub(crate) struct FilenameArgs { + /// Match patterns case-insensitively. + #[arg(short = 'i', long)] + ignore_case: bool, + #[arg(required = true, value_name = "PATTERN")] + patterns: Vec, +} + #[derive(Clone, Copy)] enum MatchPolicy { Deny, @@ -46,27 +58,6 @@ struct Matcher { 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 { @@ -74,12 +65,39 @@ impl Matcher { }; Ok(Self { - regex: Arc::new(regex), + regex: Arc::new(build_regex( + &args.patterns, + args.ignore_case, + args.multiline, + )?), scan_mode, }) } } +fn build_regex(patterns: &[String], ignore_case: bool, multiline: bool) -> Result { + let syntax = syntax::Config::new() + // Enable case-insensitive matching for `-i` / `--ignore-case`. + .case_insensitive(ignore_case) + // Let `^` and `$` match line boundaries for `-m` / `--multiline`. + .multi_line(multiline) + // Let `.` match newlines for `-m` / `--multiline`. + .dot_matches_new_line(multiline) + // Compile byte-oriented patterns so arbitrary file bytes can match. + .utf8(false); + 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(patterns) + .context("Failed to compile regex patterns") +} + pub(crate) async fn deny_pattern(hook: &Hook, filenames: &[&Path]) -> Result { run(hook, filenames, MatchPolicy::Deny).await } @@ -88,6 +106,42 @@ pub(crate) async fn require_pattern(hook: &Hook, filenames: &[&Path]) -> Result< run(hook, filenames, MatchPolicy::Require).await } +pub(crate) fn deny_filename_pattern(hook: &Hook, filenames: &[&Path]) -> Result { + run_filename_pattern(hook, filenames, MatchPolicy::Deny) +} + +pub(crate) fn require_filename_pattern(hook: &Hook, filenames: &[&Path]) -> Result { + run_filename_pattern(hook, filenames, MatchPolicy::Require) +} + +fn run_filename_pattern( + hook: &Hook, + filenames: &[&Path], + policy: MatchPolicy, +) -> Result { + let args = + FilenameArgs::try_parse_from(hook.entry.expect_direct().split_with_args(&hook.args)?)?; + let regex = build_regex(&args.patterns, args.ignore_case, false)?; + let mut failed = false; + let mut output = Vec::new(); + + for filename in filenames { + let matched = filename + .file_name() + .is_some_and(|basename| regex.is_match(basename.as_encoded_bytes())); + let message = match policy { + MatchPolicy::Deny if matched => "filename matches a denied pattern", + MatchPolicy::Require if !matched => "filename does not match any required pattern", + MatchPolicy::Deny | MatchPolicy::Require => continue, + }; + + failed = true; + writeln!(output, "{}: {message}", filename.display())?; + } + + Ok(HookOutput::unchanged(i32::from(failed), output)) +} + async fn run(hook: &Hook, filenames: &[&Path], policy: MatchPolicy) -> Result { let args = Args::try_parse_from(hook.entry.expect_direct().split_with_args(&hook.args)?)?; let matcher = Matcher::new(&args)?; diff --git a/crates/prek/tests/builtin_hooks.rs b/crates/prek/tests/builtin_hooks.rs index 60534fcaa..dbc8baaa8 100644 --- a/crates/prek/tests/builtin_hooks.rs +++ b/crates/prek/tests/builtin_hooks.rs @@ -77,6 +77,44 @@ fn builtin_hooks_unknown_hook() { "); } +#[test] +fn deny_filename_pattern_hook_matches_only_basename() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: deny-filename-pattern + args: [--ignore-case, 'readme'] + files: '\.md$' + "}); + + let cwd = context.work_dir(); + cwd.child("docs").create_dir_all()?; + cwd.child("README").create_dir_all()?; + cwd.child("docs/README.md").touch()?; + cwd.child("README/guide.md").touch()?; + cwd.child("docs/guide.md").touch()?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 1 + ----- stdout ----- + deny filename patterns...................................................Failed + - hook id: deny-filename-pattern + - exit code: 1 + + docs/README.md: filename matches a denied pattern + + ----- stderr ----- + "); + + Ok(()) +} + #[test] fn deny_pattern_hook_reports_matching_lines() -> Result<()> { let context = TestContext::new(); @@ -205,6 +243,50 @@ fn deny_pattern_hook_reports_earliest_multiline_match() -> Result<()> { Ok(()) } +#[test] +fn require_filename_pattern_hook_accepts_any_pattern_for_basename() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: builtin + hooks: + - id: require-filename-pattern + args: + - '^test_.*\.py$' + - '^__init__\.py$' + - '^conftest\.py$' + files: '(^|/)tests/.+\.py$' + "}); + + let cwd = context.work_dir(); + cwd.child("tests/unit").create_dir_all()?; + cwd.child("tests/test_unit").create_dir_all()?; + cwd.child("tests/unit/test_parser.py").touch()?; + cwd.child("tests/unit/parser_test.py").touch()?; + cwd.child("tests/unit/__init__.py").touch()?; + cwd.child("tests/unit/conftest.py").touch()?; + cwd.child("tests/test_unit/parser.py").touch()?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 1 + ----- stdout ----- + require filename patterns................................................Failed + - hook id: require-filename-pattern + - exit code: 1 + + tests/test_unit/parser.py: filename does not match any required pattern + tests/unit/parser_test.py: filename does not match any required pattern + + ----- stderr ----- + "); + + Ok(()) +} + #[test] fn require_pattern_hook_reports_files_without_any_match() -> Result<()> { let context = TestContext::new(); diff --git a/crates/prek/tests/list_builtins.rs b/crates/prek/tests/list_builtins.rs index 0d8c3fc03..3f3ab9bb9 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-filename-pattern deny-pattern destroyed-symlinks detect-private-key @@ -33,6 +34,7 @@ fn list_builtins_basic() { mixed-line-ending no-commit-to-branch pretty-format-json + require-filename-pattern require-pattern requirements-txt-fixer trailing-whitespace @@ -97,6 +99,11 @@ fn list_builtins_verbose() { flags: -m, --allow-multiple-documents Allow multiple YAML documents [alias: --multi] + deny-filename-pattern + Fails if any selected filename matches a regular expression. + flags: + -i, --ignore-case Match patterns case-insensitively + deny-pattern Fails if any file contains a matching regular expression. flags: @@ -145,6 +152,11 @@ fn list_builtins_verbose() { --no-sort-keys Preserve object key order --top-keys Object keys to move to the front, comma-separated + require-filename-pattern + Fails if any selected filename does not match a regular expression. + flags: + -i, --ignore-case Match patterns case-insensitively + require-pattern Fails if any file does not contain a matching regular expression. flags: @@ -239,6 +251,11 @@ fn list_builtins_json() { "name": "check yaml", "description": "Checks YAML files for parseable syntax." }, + { + "id": "deny-filename-pattern", + "name": "deny filename patterns", + "description": "Fails if any selected filename matches a regular expression." + }, { "id": "deny-pattern", "name": "deny patterns", @@ -289,6 +306,11 @@ fn list_builtins_json() { "name": "pretty format json", "description": "Checks that JSON files are pretty-formatted." }, + { + "id": "require-filename-pattern", + "name": "require filename patterns", + "description": "Fails if any selected filename does not match a regular expression." + }, { "id": "require-pattern", "name": "require patterns", diff --git a/docs/builtin.md b/docs/builtin.md index 52167d69a..442a90950 100644 --- a/docs/builtin.md +++ b/docs/builtin.md @@ -113,7 +113,9 @@ For `repo: builtin`, the following hooks are supported: - [`check-vcs-permalinks`](#check-vcs-permalinks) (Ensures that links to VCS websites are permalinks.) - [`check-yaml`](#check-yaml) (Checks YAML files for parseable syntax.) - [`check-xml`](#check-xml) (Checks XML files for parseable syntax.) +- [`deny-filename-pattern`](#deny-filename-pattern) (Fails if any selected filename matches a regular expression.) - [`deny-pattern`](#deny-pattern) (Fails if any file contains a matching regular expression.) +- [`require-filename-pattern`](#require-filename-pattern) (Fails if any selected filename does not match a regular expression.) - [`require-pattern`](#require-pattern) (Fails if any file does not contain a matching regular expression.) - [`mixed-line-ending`](#mixed-line-ending) (Replaces or checks mixed line endings.) - [`check-symlinks`](#check-symlinks) (Checks for symlinks which do not point to anything.) @@ -427,6 +429,33 @@ Attempts to load all XML files to verify syntax. --- +#### `deny-filename-pattern` + +Fails when the final path component (the basename) of any selected file matches a configured regular expression. Patterns use the [Rust `regex` syntax](https://docs.rs/regex/latest/regex/#syntax). When multiple patterns are provided, the hook fails when a basename matches any one of them. + +The standard `files`, `exclude`, and type filters select which project-relative paths are checked. The patterns passed to this hook are then matched only against each selected basename. + +**Supported arguments** + +- `PATTERN...` (required) + - Positional regular expressions to deny. + - Use `--` before a pattern that begins with `-`. +- `-i`, `--ignore-case` + - Match all patterns case-insensitively. + +Each matching file is reported once as `path: filename matches a denied pattern`. + +```yaml +repos: + - repo: builtin + hooks: + - id: deny-filename-pattern + name: disallow spaces in filenames + args: ['\s'] +``` + +--- + #### `deny-pattern` Fails when any selected text file matches a configured regular expression. @@ -458,6 +487,27 @@ repos: --- +#### `require-filename-pattern` + +Fails when the final path component (the basename) of any selected file does not match at least one configured regular expression. This is a per-file requirement: every selected basename must match, while different basenames may match different patterns. + +`require-filename-pattern` supports the same positional `PATTERN...` and `-i` / `--ignore-case` arguments as [`deny-filename-pattern`](#deny-filename-pattern). Matching uses search semantics; use `^` and `$` when the pattern must match the entire basename. Files without a match are reported as `path: filename does not match any required pattern`. + +```yaml +repos: + - repo: builtin + hooks: + - id: require-filename-pattern + name: python tests naming + args: + - '^test_.*\.py$' + - '^__init__\.py$' + - '^conftest\.py$' + files: '(^|/)tests/.+\.py$' +``` + +--- + #### `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. diff --git a/prek.schema.json b/prek.schema.json index b9d438826..6cd4d5745 100644 --- a/prek.schema.json +++ b/prek.schema.json @@ -1045,6 +1045,7 @@ "check-vcs-permalinks", "check-xml", "check-yaml", + "deny-filename-pattern", "deny-pattern", "destroyed-symlinks", "detect-private-key", @@ -1055,6 +1056,7 @@ "mixed-line-ending", "no-commit-to-branch", "pretty-format-json", + "require-filename-pattern", "require-pattern", "requirements-txt-fixer", "trailing-whitespace"