g1t/crates/actions/src/filter.rs
| 1 | //! Branch, tag and path filters, with GitHub's pattern syntax: |
| 2 | //! |
| 3 | //! - `*` matches any characters except `/`; `**` matches any characters. |
| 4 | //! - `?` makes the character before it optional; `+` repeats it. |
| 5 | //! - `[a-z0-9]` matches one character of a set. |
| 6 | //! - `!` at the start of a pattern excludes what it matches. Patterns are |
| 7 | //! read in order and the last one that matches decides. |
| 8 | //! - `\` escapes the next character. |
| 9 | |
| 10 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 11 | enum Atom { |
| 12 | Char(char), |
| 13 | Class(Vec<(char, char)>), |
| 14 | /// `*`: anything but `/`. |
| 15 | Star, |
| 16 | /// `**`: anything. |
| 17 | Globstar, |
| 18 | } |
| 19 | |
| 20 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 21 | enum Repeat { |
| 22 | One, |
| 23 | /// `?` |
| 24 | Optional, |
| 25 | /// `+` |
| 26 | OneOrMore, |
| 27 | } |
| 28 | |
| 29 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 30 | struct Piece { |
| 31 | atom: Atom, |
| 32 | repeat: Repeat, |
| 33 | } |
| 34 | |
| 35 | /// One compiled pattern. |
| 36 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 37 | pub struct Pattern { |
| 38 | pieces: Vec<Piece>, |
| 39 | pub negated: bool, |
| 40 | pub source: String, |
| 41 | } |
| 42 | |
| 43 | impl Pattern { |
| 44 | pub fn parse(source: &str) -> Pattern { |
| 45 | let (negated, body) = match source.strip_prefix('!') { |
| 46 | Some(rest) => (true, rest), |
| 47 | None => (false, source), |
| 48 | }; |
| 49 | let chars: Vec<char> = body.chars().collect(); |
| 50 | let mut pieces: Vec<Piece> = Vec::new(); |
| 51 | let mut i = 0; |
| 52 | while i < chars.len() { |
| 53 | let c = chars[i]; |
| 54 | match c { |
| 55 | '*' if chars.get(i + 1) == Some(&'*') => { |
| 56 | pieces.push(Piece { atom: Atom::Globstar, repeat: Repeat::One }); |
| 57 | i += 2; |
| 58 | } |
| 59 | '*' => { |
| 60 | pieces.push(Piece { atom: Atom::Star, repeat: Repeat::One }); |
| 61 | i += 1; |
| 62 | } |
| 63 | '?' | '+' if pieces.last().is_some_and(|p| p.repeat == Repeat::One && !matches!(p.atom, Atom::Star | Atom::Globstar)) => { |
| 64 | pieces.last_mut().expect("checked").repeat = if c == '?' { Repeat::Optional } else { Repeat::OneOrMore }; |
| 65 | i += 1; |
| 66 | } |
| 67 | '[' => { |
| 68 | // A set, up to the next `]`; without one, a literal `[`. |
| 69 | match chars[i + 1..].iter().position(|&x| x == ']') { |
| 70 | Some(len) if len > 0 => { |
| 71 | let inner = &chars[i + 1..i + 1 + len]; |
| 72 | let mut ranges = Vec::new(); |
| 73 | let mut j = 0; |
| 74 | while j < inner.len() { |
| 75 | if j + 2 < inner.len() && inner[j + 1] == '-' { |
| 76 | ranges.push((inner[j], inner[j + 2])); |
| 77 | j += 3; |
| 78 | } else { |
| 79 | ranges.push((inner[j], inner[j])); |
| 80 | j += 1; |
| 81 | } |
| 82 | } |
| 83 | pieces.push(Piece { atom: Atom::Class(ranges), repeat: Repeat::One }); |
| 84 | i += len + 2; |
| 85 | } |
| 86 | _ => { |
| 87 | pieces.push(Piece { atom: Atom::Char('['), repeat: Repeat::One }); |
| 88 | i += 1; |
| 89 | } |
| 90 | } |
| 91 | } |
| 92 | '\\' if i + 1 < chars.len() => { |
| 93 | pieces.push(Piece { atom: Atom::Char(chars[i + 1]), repeat: Repeat::One }); |
| 94 | i += 2; |
| 95 | } |
| 96 | other => { |
| 97 | pieces.push(Piece { atom: Atom::Char(other), repeat: Repeat::One }); |
| 98 | i += 1; |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | Pattern { pieces, negated, source: source.to_owned() } |
| 103 | } |
| 104 | |
| 105 | /// Whether the text matches, ignoring `!`. |
| 106 | pub fn matches(&self, text: &str) -> bool { |
| 107 | let text: Vec<char> = text.chars().collect(); |
| 108 | matches_at(&self.pieces, &text) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | fn atom_matches(atom: &Atom, c: char) -> bool { |
| 113 | match atom { |
| 114 | Atom::Char(expected) => *expected == c, |
| 115 | Atom::Class(ranges) => ranges.iter().any(|(low, high)| (*low..=*high).contains(&c)), |
| 116 | Atom::Star => c != '/', |
| 117 | Atom::Globstar => true, |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | fn matches_at(pieces: &[Piece], text: &[char]) -> bool { |
| 122 | let Some((piece, rest)) = pieces.split_first() else { |
| 123 | return text.is_empty(); |
| 124 | }; |
| 125 | match (&piece.atom, &piece.repeat) { |
| 126 | (Atom::Star | Atom::Globstar, _) => { |
| 127 | // `**/` also matches nothing, so `**/README.md` finds the root's. |
| 128 | if piece.atom == Atom::Globstar |
| 129 | && rest.first().is_some_and(|next| next.atom == Atom::Char('/') && next.repeat == Repeat::One) |
| 130 | && matches_at(&rest[1..], text) |
| 131 | { |
| 132 | return true; |
| 133 | } |
| 134 | // Zero or more, as long as each character is allowed. |
| 135 | for taken in 0..=text.len() { |
| 136 | if matches_at(rest, &text[taken..]) { |
| 137 | return true; |
| 138 | } |
| 139 | if taken < text.len() && !atom_matches(&piece.atom, text[taken]) { |
| 140 | return false; |
| 141 | } |
| 142 | } |
| 143 | false |
| 144 | } |
| 145 | (atom, Repeat::One) => text.first().is_some_and(|&c| atom_matches(atom, c)) && matches_at(rest, &text[1..]), |
| 146 | (atom, Repeat::Optional) => { |
| 147 | matches_at(rest, text) || (text.first().is_some_and(|&c| atom_matches(atom, c)) && matches_at(rest, &text[1..])) |
| 148 | } |
| 149 | (atom, Repeat::OneOrMore) => { |
| 150 | let mut taken = 0; |
| 151 | while taken < text.len() && atom_matches(atom, text[taken]) { |
| 152 | taken += 1; |
| 153 | if matches_at(rest, &text[taken..]) { |
| 154 | return true; |
| 155 | } |
| 156 | } |
| 157 | false |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | /// A list of patterns, read in order: a later `!pattern` excludes what an |
| 163 | /// earlier one included, and a later pattern can include it again. |
| 164 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 165 | pub struct Patterns(pub Vec<Pattern>); |
| 166 | |
| 167 | impl Patterns { |
| 168 | pub fn new<S: AsRef<str>>(sources: &[S]) -> Patterns { |
| 169 | Patterns(sources.iter().map(|source| Pattern::parse(source.as_ref())).collect()) |
| 170 | } |
| 171 | |
| 172 | /// Whether the text is included. |
| 173 | pub fn includes(&self, text: &str) -> bool { |
| 174 | let mut included = false; |
| 175 | for pattern in &self.0 { |
| 176 | if pattern.matches(text) { |
| 177 | included = !pattern.negated; |
| 178 | } |
| 179 | } |
| 180 | included |
| 181 | } |
| 182 | |
| 183 | /// Whether any pattern matches the text (for `-ignore` lists, where |
| 184 | /// `!` patterns put a text back). |
| 185 | pub fn ignores(&self, text: &str) -> bool { |
| 186 | self.includes(text) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /// A filter as a workflow gives it: `branches` or `branches-ignore`, |
| 191 | /// `tags` or `tags-ignore`, `paths` or `paths-ignore`. |
| 192 | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
| 193 | pub struct Filter { |
| 194 | pub only: Option<Patterns>, |
| 195 | pub ignore: Option<Patterns>, |
| 196 | } |
| 197 | |
| 198 | impl Filter { |
| 199 | pub fn is_set(&self) -> bool { |
| 200 | self.only.is_some() || self.ignore.is_some() |
| 201 | } |
| 202 | |
| 203 | /// Whether one name (a branch or a tag) passes. |
| 204 | pub fn allows(&self, name: &str) -> bool { |
| 205 | if let Some(only) = &self.only |
| 206 | && !only.includes(name) |
| 207 | { |
| 208 | return false; |
| 209 | } |
| 210 | if let Some(ignore) = &self.ignore |
| 211 | && ignore.ignores(name) |
| 212 | { |
| 213 | return false; |
| 214 | } |
| 215 | true |
| 216 | } |
| 217 | |
| 218 | /// Whether a set of changed paths passes: `paths` needs at least one |
| 219 | /// included path; `paths-ignore` needs at least one path not ignored. |
| 220 | /// With no paths known, it passes. |
| 221 | pub fn allows_paths(&self, paths: &[String]) -> bool { |
| 222 | if paths.is_empty() { |
| 223 | return true; |
| 224 | } |
| 225 | if let Some(only) = &self.only |
| 226 | && !paths.iter().any(|path| only.includes(path)) |
| 227 | { |
| 228 | return false; |
| 229 | } |
| 230 | if let Some(ignore) = &self.ignore |
| 231 | && paths.iter().all(|path| ignore.ignores(path)) |
| 232 | { |
| 233 | return false; |
| 234 | } |
| 235 | true |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | #[cfg(test)] |
| 240 | mod tests { |
| 241 | use super::*; |
| 242 | |
| 243 | fn m(pattern: &str, text: &str) -> bool { |
| 244 | Pattern::parse(pattern).matches(text) |
| 245 | } |
| 246 | |
| 247 | #[test] |
| 248 | fn stars_and_globstars() { |
| 249 | assert!(m("main", "main")); |
| 250 | assert!(!m("main", "mainline")); |
| 251 | assert!(m("releases/*", "releases/v1")); |
| 252 | assert!(!m("releases/*", "releases/v1/hotfix")); |
| 253 | assert!(m("releases/**", "releases/v1/hotfix")); |
| 254 | assert!(m("feature/**", "feature/a/b/c")); |
| 255 | assert!(m("*", "main")); |
| 256 | assert!(!m("*", "feature/x")); |
| 257 | assert!(m("**", "feature/x")); |
| 258 | assert!(m("**.js", "src/app/index.js")); |
| 259 | assert!(m("*.js", "index.js")); |
| 260 | assert!(!m("*.js", "src/index.js")); |
| 261 | assert!(m("docs/**", "docs/guide/intro.md")); |
| 262 | assert!(m("**/README.md", "a/b/README.md")); |
| 263 | assert!(m("**/*.md", "README.md")); |
| 264 | assert!(m("**/README.md", "README.md")); |
| 265 | assert!(m("**/README.md", "server/README.md")); |
| 266 | } |
| 267 | |
| 268 | #[test] |
| 269 | fn repeats_classes_and_escapes() { |
| 270 | assert!(m("v[12].[0-9]+.[0-9]+", "v1.10.3")); |
| 271 | assert!(!m("v[12].[0-9]+.[0-9]+", "v3.1.0")); |
| 272 | assert!(m("v2*", "v2.0.0")); |
| 273 | assert!(m("colou?r", "color")); |
| 274 | assert!(m("colou?r", "colour")); |
| 275 | assert!(m("v[0-9]+", "v123")); |
| 276 | assert!(!m("v[0-9]+", "v")); |
| 277 | assert!(m("a\\*b", "a*b")); |
| 278 | assert!(!m("a\\*b", "axb")); |
| 279 | } |
| 280 | |
| 281 | #[test] |
| 282 | fn order_decides_with_negations() { |
| 283 | let list = Patterns::new(&["releases/**", "!releases/**-alpha"]); |
| 284 | assert!(list.includes("releases/v1")); |
| 285 | assert!(!list.includes("releases/v1-alpha")); |
| 286 | let again = Patterns::new(&["**", "!docs/**", "docs/api/**"]); |
| 287 | assert!(again.includes("src/a.rs")); |
| 288 | assert!(!again.includes("docs/intro.md")); |
| 289 | assert!(again.includes("docs/api/x.md")); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn filters_for_names_and_paths() { |
| 294 | let branches = Filter { only: Some(Patterns::new(&["main", "release/**"])), ignore: None }; |
| 295 | assert!(branches.allows("main")); |
| 296 | assert!(branches.allows("release/2.0")); |
| 297 | assert!(!branches.allows("feature/x")); |
| 298 | let ignored = Filter { only: None, ignore: Some(Patterns::new(&["dependabot/**"])) }; |
| 299 | assert!(!ignored.allows("dependabot/npm/x")); |
| 300 | assert!(ignored.allows("main")); |
| 301 | |
| 302 | let paths = Filter { only: Some(Patterns::new(&["src/**", "!src/**/*.md"])), ignore: None }; |
| 303 | assert!(paths.allows_paths(&["src/main.rs".into()])); |
| 304 | assert!(!paths.allows_paths(&["src/notes.md".into(), "README.md".into()])); |
| 305 | let docs = Filter { only: None, ignore: Some(Patterns::new(&["docs/**", "*.md"])) }; |
| 306 | assert!(!docs.allows_paths(&["docs/a.md".into(), "README.md".into()])); |
| 307 | assert!(docs.allows_paths(&["docs/a.md".into(), "src/lib.rs".into()])); |
| 308 | assert!(docs.allows_paths(&[])); |
| 309 | } |
| 310 | } |