flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/actions/src/filter.rs

310 lines10,983 bytesCodeBlame
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)]
11enum 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)]
21enum Repeat {
22 One,
23 /// `?`
24 Optional,
25 /// `+`
26 OneOrMore,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
30struct Piece {
31 atom: Atom,
32 repeat: Repeat,
33}
34
35/// One compiled pattern.
36#[derive(Clone, Debug, PartialEq, Eq)]
37pub struct Pattern {
38 pieces: Vec<Piece>,
39 pub negated: bool,
40 pub source: String,
41}
42
43impl 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
112fn 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
121fn 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)]
165pub struct Patterns(pub Vec<Pattern>);
166
167impl 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)]
193pub struct Filter {
194 pub only: Option<Patterns>,
195 pub ignore: Option<Patterns>,
196}
197
198impl 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)]
240mod 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}