diff --git a/docs/src/content/docs/pattern-syntax/matching-behavior.md b/docs/src/content/docs/pattern-syntax/matching-behavior.md index fefe503..ad394fe 100644 --- a/docs/src/content/docs/pattern-syntax/matching-behavior.md +++ b/docs/src/content/docs/pattern-syntax/matching-behavior.md @@ -82,6 +82,18 @@ Negation patterns where all alternatives start with `-` also use order-independe | `find . -delete` | Does not match | | `find -fprint output .` | Does not match | +This also works with `=`-joined flags. For example, `!--pre` rejects both `--pre value` (space-separated) and `--pre=value` (`=`-joined): + +```yaml +- allow: 'rg !--pre *' +``` + +| Command | Result | +| ------------------------ | -------------- | +| `rg pattern file.txt` | Matches | +| `rg --pre pdftotext pat` | Does not match | +| `rg --pre=pdftotext pat` | Does not match | + ### Non-flag Tokens are Position-dependent Tokens that do not start with `-` are matched **in order**: diff --git a/src/rules/pattern_matcher.rs b/src/rules/pattern_matcher.rs index 54956c7..f7a77e3 100644 --- a/src/rules/pattern_matcher.rs +++ b/src/rules/pattern_matcher.rs @@ -231,7 +231,7 @@ fn match_tokens_core<'a>( let negation_passed = if is_flag_only_negation(inner) { !cmd_tokens .iter() - .any(|t| match_single_token(inner, t, definitions)) + .any(|t| match_flag_token_with_equals(inner, t, definitions)) } else { !match_single_token(inner, cmd_tokens[0], definitions) }; @@ -503,7 +503,7 @@ fn extract_placeholder_all<'a>( let negation_passed = if is_flag_only_negation(inner) { !cmd_tokens .iter() - .any(|t| match_single_token(inner, t, definitions)) + .any(|t| match_flag_token_with_equals(inner, t, definitions)) } else { !match_single_token(inner, cmd_tokens[0], definitions) }; @@ -837,6 +837,29 @@ fn is_flag_only_negation(inner: &PatternToken) -> bool { } } +/// Like [`match_single_token`] but also matches the flag portion of +/// `=`-joined command tokens (e.g. `--pre=pdftotext` matches pattern `--pre`). +/// This is used for flag-only negation so that `\!--pre` correctly rejects +/// `--pre=value` in addition to the space-separated `--pre value` form. +fn match_flag_token_with_equals( + pattern: &PatternToken, + cmd_token: &str, + definitions: &Definitions, +) -> bool { + if match_single_token(pattern, cmd_token, definitions) { + return true; + } + // If the command token contains `=` and the flag part starts with `-`, + // try matching only the flag portion before `=`. + if let Some(eq_pos) = cmd_token.find('=') { + let flag_part = &cmd_token[..eq_pos]; + if flag_part.starts_with('-') { + return match_single_token(pattern, flag_part, definitions); + } + } + false +} + /// Remove elements at the given indices from a slice, returning a new Vec. fn remove_indices<'a>(tokens: &[&'a str], indices: &[usize]) -> Vec<&'a str> { tokens @@ -988,6 +1011,23 @@ mod tests { "find . -type f -name foo", true )] + // Flag-only negation with `=`-joined tokens + #[case::flag_negation_rejects_equals_form("rg !--pre *", "rg --pre=pdftotext pattern", false)] + #[case::flag_negation_allows_different_flag_equals( + "rg !--pre *", + "rg --color=always pattern", + true + )] + #[case::flag_negation_alt_rejects_equals_form( + "sort !-o|--output|--compress-program *", + "sort --output=result.txt file.txt", + false + )] + #[case::flag_negation_alt_allows_equals_different_flag( + "sort !-o|--output|--compress-program *", + "sort --reverse=true file.txt", + true + )] fn negation_matching( #[case] pattern_str: &str, #[case] command_str: &str, diff --git a/tests/integration/config_to_rule_evaluation.rs b/tests/integration/config_to_rule_evaluation.rs index 6790bd7..e28f986 100644 --- a/tests/integration/config_to_rule_evaluation.rs +++ b/tests/integration/config_to_rule_evaluation.rs @@ -955,3 +955,48 @@ fn flag_negation_order_independent( let result = evaluate_command(&config, command, &empty_context).unwrap(); expected(&result.action); } + +// ======================================== +// Flag-only negation with `=`-joined tokens +// ======================================== + +#[rstest] +#[case::equals_form_rejected( + "rg --pre=pdftotext pattern", + assert_default as ActionAssertion, +)] +#[case::space_form_rejected( + "rg --pre pdftotext pattern", + assert_default as ActionAssertion, +)] +#[case::different_flag_equals_allowed( + "rg --color=always pattern", + assert_allow as ActionAssertion, +)] +#[case::no_flag_allowed( + "rg pattern file.txt", + assert_allow as ActionAssertion, +)] +#[case::alt_negation_equals_rejected( + "sort --output=result.txt file.txt", + assert_default as ActionAssertion, +)] +#[case::alt_negation_equals_different_flag_allowed( + "sort --reverse file.txt", + assert_allow as ActionAssertion, +)] +fn flag_negation_equals_form( + #[case] command: &str, + #[case] expected: ActionAssertion, + empty_context: EvalContext, +) { + let config = parse_config(indoc! {" + rules: + - allow: 'rg !--pre *' + - allow: 'sort !-o|--output|--compress-program *' + "}) + .unwrap(); + + let result = evaluate_command(&config, command, &empty_context).unwrap(); + expected(&result.action); +}