Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions crates/prek/src/hooks/builtin_hooks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ pub(crate) enum BuiltinHooks {
CheckVcsPermalinks,
CheckXml,
CheckYaml,
DenyFilenamePattern,
DenyPattern,
DestroyedSymlinks,
DetectPrivateKey,
Expand All @@ -61,6 +62,7 @@ pub(crate) enum BuiltinHooks {
MixedLineEnding,
NoCommitToBranch,
PrettyFormatJson,
RequireFilenamePattern,
RequirePattern,
RequirementsTxtFixer,
TrailingWhitespace,
Expand All @@ -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(),
Expand Down Expand Up @@ -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)),
Expand All @@ -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)),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
98 changes: 76 additions & 22 deletions crates/prek/src/hooks/builtin_hooks/pattern.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ pub(crate) struct Args {
patterns: Vec<String>,
}

#[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<String>,
}

#[derive(Clone, Copy)]
enum MatchPolicy {
Deny,
Expand All @@ -46,40 +58,46 @@ struct Matcher {

impl Matcher {
fn new(args: &Args) -> Result<Self> {
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: 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<Regex> {
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<HookOutput> {
run(hook, filenames, MatchPolicy::Deny).await
}
Expand All @@ -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<HookOutput> {
run_filename_pattern(hook, filenames, MatchPolicy::Deny)
}

pub(crate) fn require_filename_pattern(hook: &Hook, filenames: &[&Path]) -> Result<HookOutput> {
run_filename_pattern(hook, filenames, MatchPolicy::Require)
}

fn run_filename_pattern(
hook: &Hook,
filenames: &[&Path],
policy: MatchPolicy,
) -> Result<HookOutput> {
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<HookOutput> {
let args = Args::try_parse_from(hook.entry.expect_direct().split_with_args(&hook.args)?)?;
let matcher = Matcher::new(&args)?;
Expand Down
82 changes: 82 additions & 0 deletions crates/prek/tests/builtin_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
22 changes: 22 additions & 0 deletions crates/prek/tests/list_builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -145,6 +152,11 @@ fn list_builtins_verbose() {
--no-sort-keys Preserve object key order
--top-keys <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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading