Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 1 | //! Pattern rules: whether a commit message, an email address, or a branch |
| 2 | //! or tag name is as a rule asks. | |
| 3 | //! | |
| 4 | //! Regular expressions run on the `regex` crate's engine, which takes time | |
| 5 | //! in proportion to the text and never backtracks, so no pattern can make a | |
| 6 | //! push slow. Look-around and back-references are not supported, and a | |
| 7 | //! pattern that would compile to more than [`SIZE_LIMIT`] is refused. | |
| 8 | ||
| 9 | use g1t_contracts::rules::{PatternOperator, PatternRule}; | |
| 10 | use regex::{Regex, RegexBuilder}; | |
| 11 | ||
| 12 | /// The most memory one compiled pattern may take. | |
| 13 | pub const SIZE_LIMIT: usize = 1 << 20; | |
| 14 | /// The most of a text a pattern is tested on. | |
| 15 | pub const MAX_TEXT: usize = 64 * 1024; | |
| 16 | ||
| 17 | /// A pattern rule, ready to test texts. | |
| 18 | pub struct Compiled { | |
| 19 | rule: PatternRule, | |
| 20 | regex: Option<Regex>, | |
| 21 | } | |
| 22 | ||
| 23 | /// Compiles a rule's pattern, or says why it cannot be. | |
| 24 | pub fn compile(rule: &PatternRule) -> Result<Compiled, String> { | |
| 25 | let regex = match rule.operator { | |
| 26 | PatternOperator::Regex => Some( | |
| 27 | RegexBuilder::new(&rule.pattern) | |
| 28 | .size_limit(SIZE_LIMIT) | |
| 29 | .dfa_size_limit(SIZE_LIMIT) | |
| 30 | .build() | |
| 31 | .map_err(|error| match error { | |
| 32 | regex::Error::CompiledTooBig(_) => "The regular expression is too large.".to_owned(), | |
| 33 | other => format!("The regular expression is not valid: {}", first_line(&other.to_string())), | |
| 34 | })?, | |
| 35 | ), | |
| 36 | _ => None, | |
| 37 | }; | |
| 38 | Ok(Compiled { rule: rule.clone(), regex }) | |
| 39 | } | |
| 40 | ||
| 41 | fn first_line(text: &str) -> String { | |
| 42 | text.lines().last().unwrap_or(text).trim().trim_start_matches("error: ").to_owned() | |
| 43 | } | |
| 44 | ||
| 45 | impl Compiled { | |
| 46 | /// Whether `text` is as the rule asks: it matches, or with `negate` | |
| 47 | /// does not. | |
| 48 | pub fn allows(&self, text: &str) -> bool { | |
| 49 | let text = cut(text); | |
| 50 | let pattern = self.rule.pattern.as_str(); | |
| 51 | let found = match self.rule.operator { | |
| 52 | PatternOperator::StartsWith => text.starts_with(pattern), | |
| 53 | PatternOperator::EndsWith => text.ends_with(pattern), | |
| 54 | PatternOperator::Contains => text.contains(pattern), | |
| 55 | PatternOperator::Regex => self.regex.as_ref().is_some_and(|regex| regex.is_match(text)), | |
| 56 | }; | |
| 57 | found != self.rule.negate | |
| 58 | } | |
| 59 | ||
| 60 | /// What the rule asks, in words: `start with "feat: "`, `match /^v\d/`. | |
| 61 | pub fn wants(&self) -> String { | |
| 62 | describe(&self.rule) | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /// What a pattern rule asks, in words. | |
| 67 | pub fn describe(rule: &PatternRule) -> String { | |
| 68 | let not = if rule.negate { "not " } else { "" }; | |
| 69 | let pattern = &rule.pattern; | |
| 70 | let what = match rule.operator { | |
| 71 | PatternOperator::StartsWith => format!("{not}start with \"{pattern}\""), | |
| 72 | PatternOperator::EndsWith => format!("{not}end with \"{pattern}\""), | |
| 73 | PatternOperator::Contains => format!("{not}contain \"{pattern}\""), | |
| 74 | PatternOperator::Regex => format!("{not}match /{pattern}/"), | |
| 75 | }; | |
| 76 | if rule.name.trim().is_empty() { | |
| 77 | what | |
| 78 | } else { | |
| 79 | format!("{what} ({})", rule.name.trim()) | |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | /// At most [`MAX_TEXT`] bytes of `text`, cut on a character boundary. | |
| 84 | fn cut(text: &str) -> &str { | |
| 85 | if text.len() <= MAX_TEXT { | |
| 86 | return text; | |
| 87 | } | |
| 88 | let mut end = MAX_TEXT; | |
| 89 | while !text.is_char_boundary(end) { | |
| 90 | end -= 1; | |
| 91 | } | |
| 92 | &text[..end] | |
| 93 | } | |
| 94 | ||
| 95 | #[cfg(test)] | |
| 96 | mod tests { | |
| 97 | use super::*; | |
| 98 | ||
| 99 | fn rule(operator: PatternOperator, pattern: &str, negate: bool) -> PatternRule { | |
| 100 | PatternRule { name: String::new(), operator, pattern: pattern.to_owned(), negate } | |
| 101 | } | |
| 102 | ||
| 103 | #[test] | |
| 104 | fn each_operator_compares_as_it_says() { | |
| 105 | let starts = compile(&rule(PatternOperator::StartsWith, "feat", false)).unwrap(); | |
| 106 | assert!(starts.allows("feat: add rules")); | |
| 107 | assert!(!starts.allows("fix: rules")); | |
| 108 | let ends = compile(&rule(PatternOperator::EndsWith, "@acme.com", false)).unwrap(); | |
| 109 | assert!(ends.allows("ada@acme.com")); | |
| 110 | assert!(!ends.allows("ada@example.com")); | |
| 111 | let contains = compile(&rule(PatternOperator::Contains, "WIP", true)).unwrap(); | |
| 112 | assert!(contains.allows("Finish rules")); | |
| 113 | assert!(!contains.allows("WIP: rules")); | |
| 114 | let regex = compile(&rule(PatternOperator::Regex, r"^(feat|fix)(\(.+\))?: ", false)).unwrap(); | |
| 115 | assert!(regex.allows("fix(api): snake case")); | |
| 116 | assert!(!regex.allows("Update things")); | |
| 117 | } | |
| 118 | ||
| 119 | #[test] | |
| 120 | fn a_bad_or_huge_expression_is_refused() { | |
| 121 | assert!(compile(&rule(PatternOperator::Regex, "(unclosed", false)).is_err()); | |
| 122 | // Back-references need backtracking, which the engine never does. | |
| 123 | assert!(compile(&rule(PatternOperator::Regex, r"(a)\1", false)).is_err()); | |
| 124 | let huge = compile(&rule(PatternOperator::Regex, r"\w{1000}\w{1000}\w{1000}", false)); | |
| 125 | assert_eq!(huge.err().as_deref(), Some("The regular expression is too large.")); | |
| 126 | } | |
| 127 | ||
| 128 | #[test] | |
| 129 | fn a_pathological_pattern_stays_linear() { | |
| 130 | let compiled = compile(&rule(PatternOperator::Regex, "(a+)+$", false)).unwrap(); | |
| 131 | let text = format!("{}!", "a".repeat(50_000)); | |
| 132 | assert!(!compiled.allows(&text)); | |
| 133 | } | |
| 134 | ||
| 135 | #[test] | |
| 136 | fn rules_are_described_in_words() { | |
| 137 | assert_eq!(describe(&rule(PatternOperator::StartsWith, "feat", false)), "start with \"feat\""); | |
| 138 | assert_eq!(describe(&rule(PatternOperator::Regex, "^v", true)), "not match /^v/"); | |
| 139 | let named = PatternRule { name: "Conventional commits".into(), ..rule(PatternOperator::Contains, ":", false) }; | |
| 140 | assert_eq!(describe(&named), "contain \":\" (Conventional commits)"); | |
| 141 | } | |
| 142 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.