Skip to content

g1t/crates/rules/src/text.rs

142 lines5,611 bytesCodeBlame
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
9use g1t_contracts::rules::{PatternOperator, PatternRule};
10use regex::{Regex, RegexBuilder};
11
12/// The most memory one compiled pattern may take.
13pub const SIZE_LIMIT: usize = 1 << 20;
14/// The most of a text a pattern is tested on.
15pub const MAX_TEXT: usize = 64 * 1024;
16
17/// A pattern rule, ready to test texts.
18pub struct Compiled {
19 rule: PatternRule,
20 regex: Option<Regex>,
21}
22
23/// Compiles a rule's pattern, or says why it cannot be.
24pub 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
41fn first_line(text: &str) -> String {
42 text.lines().last().unwrap_or(text).trim().trim_start_matches("error: ").to_owned()
43}
44
45impl 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.
67pub 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.
84fn 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)]
96mod 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}