crates/kernel/ents-query/tests/grammar.rs
grammar.rshistorycomment 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 | |
| 6 | use ents_query::{ParseError, Query, SetOp}; |
| 7 | use rstest::rstest; |
| 8 | |
| 9 | fn parse(input: &str) -> Query { |
| 10 | input |
| 11 | .parse() |
| 12 | .unwrap_or_else(|e| panic!("{input:?} must parse: {e}")) |
| 13 | } |
| 14 | |
| 15 | fn 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) |
| 38 | fn 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) |
| 44 | fn 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) |
| 67 | fn 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) |
| 95 | fn 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) |
| 112 | fn 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) |
| 127 | fn 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) |
| 141 | fn 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) |
| 152 | fn 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) |
| 170 | fn 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) |
| 181 | fn 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) |
| 194 | fn 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) |
| 204 | fn 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) |
| 225 | fn 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) |
| 231 | fn 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) |
| 259 | fn 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) |
| 274 | fn 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) |
| 293 | fn downstream_of_an_effect_is_visible_in_the_text(#[case] input: &str, #[case] expected: &[&str]) { |
| 294 | assert_eq!(parse(input).results_dependencies(), expected); |
| 295 | } |