git-ents.gitmain
⌘K
foforge
grammar.rs295 lines · 10.2 KB · rusthistorycomment on this file
1//! Grammar, validation, footprint, and compatibility tests — rstest
2//! tables, because the spec enumerates the cases.
3
4#![expect(clippy::expect_used, clippy::panic, reason = "test code")]
5
6use ents_query::{ParseError, Query, SetOp};
7use rstest::rstest;
8
9fn parse(input: &str) -> Query {
10 input
11 .parse()
12 .unwrap_or_else(|e| panic!("{input:?} must parse: {e}"))
13}
14
15fn parse_err(input: &str) -> ParseError {
16 input
17 .parse::<Query>()
18 .err()
19 .unwrap_or_else(|| panic!("{input:?} must be rejected"))
20}
21
22// ---------------------------------------------------------------------
23// query.grammar / query.set-ops
24// ---------------------------------------------------------------------
25
26#[rstest]
27#[case::rev_atom("rev(refs/heads/main)")]
28#[case::rev_range("rev(main ^release)")]
29#[case::rev_dotdot("rev(release..main)")]
30#[case::results_atom("results(unit, pass)")]
31#[case::meta_atom("meta(refs/meta/issues/*)")]
32#[case::union("rev(a) | rev(b)")]
33#[case::intersection("rev(refs/heads/main) & results(unit, pass)")]
34#[case::difference("rev(refs/heads/*) - rev(refs/heads/wip/*)")]
35#[case::parenthesized("rev(a) & (rev(b) | rev(c))")]
36#[case::whitespace_tolerant(" rev( main ) &results(unit,any) ")]
37// @relation(query.grammar, scope=function, role=Verifies)
38fn well_formed_queries_parse(#[case] input: &str) {
39 let _query = parse(input);
40}
41
42#[rstest]
43// @relation(query.grammar, query.set-ops, scope=function, role=Verifies)
44fn operators_are_left_associative_at_one_precedence_level() {
45 // a | b & c parses as (a | b) & c — never as a | (b & c).
46 let query = parse("rev(a) | rev(b) & rev(c)");
47 let Query::Op {
48 op: SetOp::Intersect,
49 lhs,
50 rhs,
51 } = query
52 else {
53 panic!("top-level operator must be the rightmost one");
54 };
55 assert!(matches!(
56 *lhs,
57 Query::Op {
58 op: SetOp::Union,
59 ..
60 }
61 ));
62 assert!(matches!(*rhs, Query::Rev(_)));
63}
64
65#[rstest]
66// @relation(query.set-ops, scope=function, role=Verifies)
67fn parentheses_regroup_evaluation_order() {
68 let grouped = parse("rev(a) | (rev(b) & rev(c))");
69 let Query::Op {
70 op: SetOp::Union,
71 rhs,
72 ..
73 } = grouped
74 else {
75 panic!("parentheses must override left-to-right order");
76 };
77 assert!(matches!(
78 *rhs,
79 Query::Op {
80 op: SetOp::Intersect,
81 ..
82 }
83 ));
84}
85
86#[rstest]
87#[case::atom("rev(refs/heads/main)")]
88#[case::rev_with_negation("rev(main ^release)")]
89#[case::results("results(unit, pass)")]
90#[case::meta("meta(refs/meta/issues/*)")]
91#[case::left_chain("rev(a) | rev(b) & rev(c) - rev(d)")]
92#[case::grouped("rev(a) - (rev(b) | rev(c))")]
93#[case::bare_glob("refs/heads/*")]
94// @relation(query.grammar, scope=function, role=Verifies)
95fn display_round_trips_through_the_parser(#[case] input: &str) {
96 let query = parse(input);
97 let rendered = query.to_string();
98 assert_eq!(parse(&rendered), query, "display: {rendered:?}");
99}
100
101#[rstest]
102#[case::empty("")]
103#[case::bare_operator("| rev(a)")]
104#[case::dangling_operator("rev(a) |")]
105#[case::unbalanced_paren("(rev(a)")]
106#[case::unbalanced_atom("rev(main")]
107#[case::trailing_garbage("rev(a) rev(b)")]
108#[case::missing_status("results(unit)")]
109#[case::empty_rev("rev()")]
110#[case::only_negations("rev(^main)")]
111// @relation(query.grammar, scope=function, role=Verifies)
112fn malformed_queries_are_rejected(#[case] input: &str) {
113 let _err = parse_err(input);
114}
115
116// ---------------------------------------------------------------------
117// query.no-extensions — the atom set is closed.
118// ---------------------------------------------------------------------
119
120#[rstest]
121#[case::time_atom("time(5m)")]
122#[case::cron_atom("cron(hourly) & rev(main)")]
123#[case::content_atom("content(Cargo.toml)")]
124#[case::path_atom("rev(main) & path(src/*)")]
125#[case::webhook_atom("webhook(deploy)")]
126// @relation(query.no-extensions, scope=function, role=Verifies)
127fn content_time_and_external_event_atoms_do_not_exist(#[case] input: &str) {
128 assert!(matches!(parse_err(input), ParseError::UnknownAtom { .. }));
129}
130
131// ---------------------------------------------------------------------
132// query.rev — refs/meta/* is outside rev()'s domain.
133// ---------------------------------------------------------------------
134
135#[rstest]
136#[case::exact_meta_ref("rev(refs/meta/config)")]
137#[case::meta_glob("rev(refs/meta/issues/*)")]
138#[case::meta_short_name("rev(meta/issues)")]
139#[case::negated_meta("rev(main ^refs/meta/config)")]
140// @relation(query.rev, scope=function, role=Verifies)
141fn rev_naming_a_meta_pattern_is_malformed_not_empty(#[case] input: &str) {
142 assert!(matches!(parse_err(input), ParseError::MetaInRev { .. }));
143}
144
145#[rstest]
146#[case::triple_dot("rev(a...b)")]
147#[case::tilde_suffix("rev(main~3)")]
148#[case::caret_suffix("rev(main^2)")]
149#[case::reflog("rev(main@{1})")]
150#[case::peel("rev(v1.0^{commit})")]
151// @relation(query.rev, scope=function, role=Verifies)
152fn unsupported_revspec_forms_error_explicitly(#[case] input: &str) {
153 assert!(matches!(
154 parse_err(input),
155 ParseError::UnsupportedRev { .. }
156 ));
157}
158
159// ---------------------------------------------------------------------
160// query.meta — effect-written namespaces are unreachable.
161// ---------------------------------------------------------------------
162
163#[rstest]
164#[case::results_glob("meta(refs/meta/results/*)")]
165#[case::results_exact("meta(refs/meta/results/unit/abc)")]
166#[case::index_glob("meta(refs/meta/index/*)")]
167#[case::broad_meta_glob("meta(refs/meta/*)")]
168#[case::sneaky_prefix("meta(refs/meta/res*)")]
169// @relation(query.meta, query.recursion, scope=function, role=Verifies)
170fn meta_cannot_match_effect_written_namespaces(#[case] input: &str) {
171 assert!(matches!(
172 parse_err(input),
173 ParseError::MetaGlobEffectWritten { .. }
174 ));
175}
176
177#[rstest]
178#[case::heads_glob("meta(refs/heads/*)")]
179#[case::short_glob("meta(issues/*)")]
180// @relation(query.meta, scope=function, role=Verifies)
181fn meta_glob_must_stay_under_refs_meta(#[case] input: &str) {
182 assert!(matches!(
183 parse_err(input),
184 ParseError::MetaGlobOutside { .. }
185 ));
186}
187
188// ---------------------------------------------------------------------
189// query.workset — `self` is notation, not a keyword.
190// ---------------------------------------------------------------------
191
192#[rstest]
193// @relation(query.workset, scope=function, role=Verifies)
194fn results_self_cannot_be_written_in_a_trigger() {
195 assert_eq!(parse_err("results(self, any)"), ParseError::SelfKeyword);
196}
197
198#[rstest]
199#[case::bad_status("results(unit, maybe)")]
200#[case::empty_effect("results(, pass)")]
201#[case::effect_with_slash("results(a/b, pass)")]
202#[case::effect_with_star("results(a*, pass)")]
203// @relation(query.results, scope=function, role=Verifies)
204fn results_arguments_are_validated(#[case] input: &str) {
205 let err = parse_err(input);
206 assert!(
207 matches!(
208 err,
209 ParseError::BadStatus { .. } | ParseError::BadEffectName { .. }
210 ),
211 "got {err:?}"
212 );
213}
214
215// ---------------------------------------------------------------------
216// query.rev-pattern-compat — bare glob is the degenerate rev() query.
217// ---------------------------------------------------------------------
218
219#[rstest]
220#[case::heads_glob("refs/heads/*")]
221#[case::tags_glob("refs/tags/v*")]
222#[case::exact_ref("refs/heads/main")]
223#[case::short_name("main")]
224// @relation(query.rev-pattern-compat, scope=function, role=Verifies)
225fn a_bare_ref_glob_means_exactly_rev_of_that_glob(#[case] glob: &str) {
226 assert_eq!(parse(glob), parse(&format!("rev({glob})")));
227}
228
229#[rstest]
230// @relation(query.rev-pattern-compat, query.rev, scope=function, role=Verifies)
231fn a_bare_meta_glob_is_still_rejected() {
232 assert!(matches!(
233 parse_err("refs/meta/*"),
234 ParseError::MetaInRev { .. } | ParseError::UnexpectedEnd
235 ));
236}
237
238// ---------------------------------------------------------------------
239// query.footprint — static extraction from the syntax tree alone.
240// ---------------------------------------------------------------------
241
242#[rstest]
243#[case::exact_rev("rev(refs/heads/main)", &["refs/heads/main"])]
244#[case::results("results(unit, pass)", &["refs/meta/results/unit/*"])]
245#[case::meta("meta(refs/meta/issues/*)", &["refs/meta/issues/*"])]
246#[case::rev_negation_contributes(
247 "rev(refs/heads/main ^refs/heads/release)",
248 &["refs/heads/main", "refs/heads/release"]
249)]
250#[case::composite(
251 "rev(refs/heads/main) & results(unit, pass)",
252 &["refs/heads/main", "refs/meta/results/unit/*"]
253)]
254#[case::short_name_contributes_the_lookup_order(
255 "rev(main)",
256 &["refs/main", "refs/tags/main", "refs/heads/main", "refs/remotes/main"]
257)]
258// @relation(query.footprint, scope=function, role=Verifies)
259fn footprints_come_from_the_syntax_tree_alone(#[case] input: &str, #[case] expected: &[&str]) {
260 let footprint = parse(input).footprint();
261 let mut got: Vec<&str> = footprint.patterns().iter().map(|p| p.as_str()).collect();
262 let mut expected: Vec<&str> = expected.to_vec();
263 got.sort_unstable();
264 expected.sort_unstable();
265 assert_eq!(got, expected);
266}
267
268#[rstest]
269#[case::affected("refs/meta/results/unit/abc123", true)]
270#[case::other_effect("refs/meta/results/integ/abc123", false)]
271#[case::trigger_ref("refs/heads/main", true)]
272#[case::unrelated("refs/heads/dev", false)]
273// @relation(query.footprint, scope=function, role=Verifies)
274fn footprint_maps_a_ref_transition_to_affected_queries(
275 #[case] refname: &str,
276 #[case] affected: bool,
277) {
278 let query = parse("rev(refs/heads/main) & results(unit, pass)");
279 let name: gix::refs::FullName = refname.try_into().expect("valid");
280 assert_eq!(query.footprint().matches(name.as_ref()), affected);
281}
282
283// ---------------------------------------------------------------------
284// query.recursion — downstream-of is syntax.
285// ---------------------------------------------------------------------
286
287#[rstest]
288#[case::not_downstream("rev(refs/heads/main)", &[])]
289#[case::single("rev(main) & results(unit, pass)", &["unit"])]
290#[case::fan_in("results(unit, pass) & results(integ, pass)", &["unit", "integ"])]
291#[case::deduplicated("results(unit, pass) | results(unit, fail)", &["unit"])]
292// @relation(query.recursion, scope=function, role=Verifies)
293fn downstream_of_an_effect_is_visible_in_the_text(#[case] input: &str, #[case] expected: &[&str]) {
294 assert_eq!(parse(input).results_dependencies(), expected);
295}