Skip to content

Commit 86ba557

Browse files
authored
Merge pull request #523 from stan-is-hate/feat/array-contains-expression
feat: add arrayContains expression to matching rule definition parser
2 parents 7a5444d + c90ffae commit 86ba557

3 files changed

Lines changed: 218 additions & 11 deletions

File tree

rust/pact_ffi/src/mock_server/bodies.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ pub fn matchers_from_integration_json(m: &Map<String, Value>) -> anyhow::Result<
217217
}
218218
_ => {
219219
let val = json_to_string(value);
220-
if val != "eachKey" && val != "eachValue" && val != "notEmpty" && is_matcher_def(val.as_str()) {
220+
if val != "eachKey" && val != "eachValue" && val != "notEmpty" && val != "arrayContains" && is_matcher_def(val.as_str()) {
221221
let mut rules = vec![];
222222
let def = parse_matcher_def(val.as_str())?;
223223
for rule in def.rules {

rust/pact_models/src/matchingrules/expressions.rs

Lines changed: 213 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@
108108
//! There is a grammar for the definitions in [ANTLR4 format](https://github.com/pact-foundation/pact-plugins/blob/main/docs/matching-rule-definition.g4).
109109
//!
110110
111+
use std::collections::HashMap;
111112
use std::fmt::{Display, Formatter};
112113
use std::char::REPLACEMENT_CHARACTER;
113114
use std::str::from_utf8;
@@ -123,8 +124,9 @@ use tracing::{instrument, trace, warn};
123124
use crate::expression_parser::DataType;
124125
use crate::generators::Generator;
125126
use crate::generators::Generator::ProviderStateGenerator;
126-
use crate::matchingrules::MatchingRule;
127+
use crate::matchingrules::{MatchingRule, MatchingRuleCategory, RuleLogic};
127128
use crate::matchingrules::MatchingRule::{MaxType, MinType, NotEmpty};
129+
use crate::path_exp::DocPath;
128130

129131
/// Type to associate with an expression element
130132
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
@@ -288,6 +290,9 @@ enum MatcherDefinitionToken {
288290
#[token("atMost")]
289291
AtMost,
290292

293+
#[token("arrayContains")]
294+
ArrayContains,
295+
291296
#[token("(")]
292297
LeftBracket,
293298

@@ -352,7 +357,8 @@ pub fn is_matcher_def(v: &str) -> bool {
352357
if let Some(Ok(token)) = next {
353358
if token == MatcherDefinitionToken::Matching || token == MatcherDefinitionToken::NotEmpty ||
354359
token == MatcherDefinitionToken::EachKey || token == MatcherDefinitionToken::EachValue ||
355-
token == MatcherDefinitionToken::AtLeast || token == MatcherDefinitionToken::AtMost {
360+
token == MatcherDefinitionToken::AtLeast || token == MatcherDefinitionToken::AtMost ||
361+
token == MatcherDefinitionToken::ArrayContains {
356362
true
357363
} else {
358364
false
@@ -378,10 +384,10 @@ fn matching_definition(lex: &mut Lexer<MatcherDefinitionToken>, v: &str) -> anyh
378384

379385
let remainder = lex.remainder();
380386
if !remainder.is_empty() {
381-
Err(anyhow!("expected not more tokens, got '{}' with '{}' remaining", lex.slice(), remainder))
382-
} else {
383-
Ok(value)
387+
return Err(anyhow!("expected not more tokens, got '{}' with '{}' remaining", lex.slice(), remainder));
384388
}
389+
390+
Ok(value)
385391
}
386392

387393
// matchingDefinitionExp returns [ MatchingRuleDefinition value ] :
@@ -449,14 +455,17 @@ fn matching_definition_exp(lex: &mut Lexer<MatcherDefinitionToken>, v: &str) ->
449455
generator: None,
450456
expression: v.to_string()
451457
})
458+
} else if token == &MatcherDefinitionToken::ArrayContains {
459+
let definition = parse_array_contains(lex, v)?;
460+
Ok(definition)
452461
} else {
453462
let mut buffer = BytesMut::new().writer();
454463
let span = lex.span();
455464
let report = Report::build(ReportKind::Error, ("expression", span.start..span.start))
456465
.with_config(Config::default().with_color(false))
457466
.with_message(format!("Expected a type of matching rule definition, but got '{}'", lex.slice()))
458467
.with_label(Label::new(("expression", span)).with_message("Expected a matching rule definition here"))
459-
.with_note("valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost")
468+
.with_note("valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost, arrayContains")
460469
.finish();
461470
report.write(("expression", Source::from(v)), &mut buffer)?;
462471
let message = from_utf8(&*buffer.get_ref())?.to_string();
@@ -469,7 +478,7 @@ fn matching_definition_exp(lex: &mut Lexer<MatcherDefinitionToken>, v: &str) ->
469478
.with_config(Config::default().with_color(false))
470479
.with_message(format!("Expected a type of matching rule definition but got the end of the expression"))
471480
.with_label(Label::new(("expression", span)).with_message("Expected a matching rule definition here"))
472-
.with_note("valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost")
481+
.with_note("valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost, arrayContains")
473482
.finish();
474483
report.write(("expression", Source::from(v)), &mut buffer)?;
475484
let message = from_utf8(&*buffer.get_ref())?.to_string();
@@ -513,6 +522,88 @@ fn parse_each_value(lex: &mut Lexer<MatcherDefinitionToken>, v: &str) -> anyhow:
513522
}
514523
}
515524

525+
// LEFT_BRACKET matchingDefinitionExp ( COMMA matchingDefinitionExp )* RIGHT_BRACKET
526+
fn parse_array_contains(lex: &mut Lexer<MatcherDefinitionToken>, v: &str) -> anyhow::Result<MatchingRuleDefinition> {
527+
let next = lex.next()
528+
.ok_or_else(|| end_of_expression(v, "an opening bracket"))?;
529+
if let Ok(MatcherDefinitionToken::LeftBracket) = next {
530+
let first = matching_definition_exp(lex, v)?;
531+
let mut inner_results = vec![first];
532+
533+
// Parse comma-separated additional expressions
534+
loop {
535+
let next = lex.next().ok_or_else(|| end_of_expression(v, "a closing bracket or comma"))?;
536+
match next {
537+
Ok(MatcherDefinitionToken::RightBracket) => break,
538+
Ok(MatcherDefinitionToken::Comma) => {
539+
let next_exp = matching_definition_exp(lex, v)?;
540+
inner_results.push(next_exp);
541+
}
542+
_ => {
543+
return Err(anyhow!(error_message(lex, v, "Expected a closing bracket or comma", "Expected a closing bracket or comma before this")?));
544+
}
545+
}
546+
}
547+
548+
// Each inner expression becomes one entry in the rules vec:
549+
// - Inline matchers (Left): resolved into an ArrayContains variant
550+
// - References (Right): kept as-is for plugin-level resolution
551+
// Mixed is allowed: arrayContains(matching(equalTo, 'X'), matching($'ref'))
552+
let mut rules: Vec<Either<MatchingRule, MatchingReference>> = Vec::new();
553+
let mut inline_variants = Vec::new();
554+
let mut first_value = None;
555+
let mut variant_index = 0usize;
556+
557+
for result in inner_results {
558+
if first_value.is_none() && !result.value.is_empty() {
559+
first_value = Some(result.value.clone());
560+
}
561+
match result.rules.first() {
562+
Some(Either::Left(matching_rule)) => {
563+
let mut category = MatchingRuleCategory::empty("body");
564+
category.add_rule(DocPath::root(), matching_rule.clone(), RuleLogic::And);
565+
let generators: HashMap<DocPath, Generator> = result.generator
566+
.map(|g| {
567+
let mut map = HashMap::new();
568+
map.insert(DocPath::root(), g);
569+
map
570+
})
571+
.unwrap_or_default();
572+
inline_variants.push((variant_index, category, generators));
573+
}
574+
Some(Either::Right(reference)) => {
575+
rules.push(Either::Right(reference.clone()));
576+
}
577+
None => {}
578+
}
579+
variant_index += 1;
580+
}
581+
582+
if !inline_variants.is_empty() {
583+
rules.insert(0, Either::Left(MatchingRule::ArrayContains(inline_variants)));
584+
}
585+
586+
Ok(MatchingRuleDefinition {
587+
value: first_value.unwrap_or_default(),
588+
value_type: ValueType::Unknown,
589+
rules,
590+
generator: None,
591+
expression: v.to_string(),
592+
})
593+
} else {
594+
let mut buffer = BytesMut::new().writer();
595+
let span = lex.span();
596+
let report = Report::build(ReportKind::Error, ("expression", span.start..span.start))
597+
.with_config(Config::default().with_color(false))
598+
.with_message(format!("Expected an opening bracket, got '{}'", lex.slice()))
599+
.with_label(Label::new(("expression", span)).with_message("Expected an opening bracket before this"))
600+
.finish();
601+
report.write(("expression", Source::from(v)), &mut buffer)?;
602+
let message = from_utf8(&*buffer.get_ref())?.to_string();
603+
Err(anyhow!(message))
604+
}
605+
}
606+
516607
fn error_message(lex: &mut Lexer<MatcherDefinitionToken>, v: &str, error: &str, additional: &str) -> Result<String, Error> {
517608
let mut buffer = BytesMut::new().writer();
518609
let span = lex.span();
@@ -1128,6 +1219,7 @@ mod test {
11281219
use crate::generators::Generator::{Date, DateTime, Time};
11291220
use crate::matchingrules::MatchingRule;
11301221
use crate::matchingrules::MatchingRule::{Regex, Type};
1222+
use crate::path_exp::DocPath;
11311223

11321224
use super::*;
11331225

@@ -1587,7 +1679,7 @@ mod test {
15871679
| │ │\u{0020}
15881680
| │ ╰─ Expected a matching rule definition here
15891681
| │\u{0020}
1590-
| │ Note: valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost
1682+
| │ Note: valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost, arrayContains
15911683
|───╯
15921684
|
15931685
".trim_margin().unwrap()));
@@ -1602,7 +1694,7 @@ mod test {
16021694
| │ ──────┬────── \u{0020}
16031695
| │ ╰──────── Expected a matching rule definition here
16041696
| │\u{0020}
1605-
| │ Note: valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost
1697+
| │ Note: valid matching rule definitions are: matching, notEmpty, eachKey, eachValue, atLeast, atMost, arrayContains
16061698
|───╯
16071699
|
16081700
".trim_margin().unwrap()));
@@ -2117,4 +2209,116 @@ mod test {
21172209
fn is_matcher_def_test(#[case] expression: &str, #[case] expected: bool) {
21182210
expect!(is_matcher_def(expression)).to(be_equal_to(expected));
21192211
}
2212+
2213+
#[test]
2214+
fn is_matcher_def_array_contains() {
2215+
assert!(is_matcher_def("arrayContains(matching(equalTo, 'PUBLIC'))"));
2216+
assert!(is_matcher_def("arrayContains(matching(equalTo, 'A'), matching(equalTo, 'B'))"));
2217+
assert!(!is_matcher_def("notArrayContains"));
2218+
}
2219+
2220+
#[test]
2221+
fn parse_array_contains_single_variant() {
2222+
let result = parse_matcher_def("arrayContains(matching(equalTo, 'PUBLIC'))").unwrap();
2223+
assert_eq!(result.rules.len(), 1);
2224+
match &result.rules[0] {
2225+
Either::Left(MatchingRule::ArrayContains(variants)) => {
2226+
assert_eq!(variants.len(), 1);
2227+
let (index, rules, _generators) = &variants[0];
2228+
assert_eq!(*index, 0);
2229+
let rule_list = rules.rules.get(&DocPath::root()).expect("should have root path rule");
2230+
assert_eq!(rule_list.rules.len(), 1);
2231+
assert_eq!(rule_list.rules[0], MatchingRule::Equality);
2232+
}
2233+
_ => panic!("Expected ArrayContains rule, got {:?}", result.rules[0]),
2234+
}
2235+
assert_eq!(result.value, "PUBLIC");
2236+
}
2237+
2238+
#[test]
2239+
fn parse_array_contains_multi_variant() {
2240+
let result = parse_matcher_def("arrayContains(matching(equalTo, 'PUBLIC'), matching(equalTo, 'PRIVATE_LINK'))").unwrap();
2241+
assert_eq!(result.rules.len(), 1);
2242+
match &result.rules[0] {
2243+
Either::Left(MatchingRule::ArrayContains(variants)) => {
2244+
assert_eq!(variants.len(), 2);
2245+
assert_eq!(variants[0].0, 0);
2246+
assert_eq!(variants[1].0, 1);
2247+
}
2248+
_ => panic!("Expected ArrayContains rule"),
2249+
}
2250+
assert_eq!(result.value, "PUBLIC");
2251+
}
2252+
2253+
#[test]
2254+
fn parse_array_contains_with_reference() {
2255+
let result = parse_matcher_def("arrayContains(matching($'publicEntry'))").unwrap();
2256+
assert_eq!(result.rules.len(), 1);
2257+
match &result.rules[0] {
2258+
Either::Right(reference) => {
2259+
assert_eq!(reference.name, "publicEntry");
2260+
}
2261+
_ => panic!("Expected a reference, got {:?}", result.rules[0]),
2262+
}
2263+
}
2264+
2265+
#[test]
2266+
fn parse_array_contains_with_multiple_references() {
2267+
let result = parse_matcher_def("arrayContains(matching($'entry1'), matching($'entry2'))").unwrap();
2268+
assert_eq!(result.rules.len(), 2);
2269+
match (&result.rules[0], &result.rules[1]) {
2270+
(Either::Right(r1), Either::Right(r2)) => {
2271+
assert_eq!(r1.name, "entry1");
2272+
assert_eq!(r2.name, "entry2");
2273+
}
2274+
_ => panic!("Expected two references"),
2275+
}
2276+
}
2277+
2278+
#[test]
2279+
fn parse_array_contains_with_regex() {
2280+
let result = parse_matcher_def("arrayContains(matching(regex, 'PUBLIC|PRIVATE.*', 'PUBLIC'))").unwrap();
2281+
assert_eq!(result.rules.len(), 1);
2282+
match &result.rules[0] {
2283+
Either::Left(MatchingRule::ArrayContains(variants)) => {
2284+
assert_eq!(variants.len(), 1);
2285+
let rule_list = variants[0].1.rules.get(&DocPath::root()).unwrap();
2286+
match &rule_list.rules[0] {
2287+
MatchingRule::Regex(re) => assert_eq!(re, "PUBLIC|PRIVATE.*"),
2288+
other => panic!("Expected Regex, got {:?}", other),
2289+
}
2290+
}
2291+
_ => panic!("Expected ArrayContains"),
2292+
}
2293+
assert_eq!(result.value, "PUBLIC");
2294+
}
2295+
2296+
#[test]
2297+
fn parse_array_contains_empty_is_error() {
2298+
let result = parse_matcher_def("arrayContains()");
2299+
assert!(result.is_err());
2300+
}
2301+
2302+
#[test]
2303+
fn parse_array_contains_combined_with_at_least() {
2304+
let result = parse_matcher_def("atLeast(2), arrayContains(matching(equalTo, 'PUBLIC'))").unwrap();
2305+
assert!(result.rules.iter().any(|r| matches!(r, Either::Left(MatchingRule::MinType(2)))));
2306+
assert!(result.rules.iter().any(|r| matches!(r, Either::Left(MatchingRule::ArrayContains(_)))));
2307+
}
2308+
2309+
#[test]
2310+
fn parse_array_contains_combined_with_each_value() {
2311+
let result = parse_matcher_def("eachValue(matching(type, 'X')), arrayContains(matching(equalTo, 'PUBLIC'))").unwrap();
2312+
assert!(result.rules.iter().any(|r| matches!(r, Either::Left(MatchingRule::EachValue(_)))));
2313+
assert!(result.rules.iter().any(|r| matches!(r, Either::Left(MatchingRule::ArrayContains(_)))));
2314+
}
2315+
2316+
#[test]
2317+
fn parse_array_contains_mixed_inline_and_reference() {
2318+
let result = parse_matcher_def("arrayContains(matching(equalTo, 'X'), matching($'ref'))").unwrap();
2319+
// Should have ArrayContains with 1 inline variant + 1 reference
2320+
assert!(result.rules.iter().any(|r| matches!(r, Either::Left(MatchingRule::ArrayContains(_)))));
2321+
assert!(result.rules.iter().any(|r| matches!(r, Either::Right(_))));
2322+
assert_eq!(result.value, "X");
2323+
}
21202324
}

rust/pact_models/src/matchingrules/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -476,11 +476,14 @@ impl MatchingRule {
476476
}
477477
}
478478

479-
/// If this matching rule is a values matcher (ignores keys in maps)
479+
/// If this matching rule is a values matcher (ignores keys in maps).
480+
/// ArrayContains is included because, like EachValue, it applies to the
481+
/// collection as a whole rather than to individual elements.
480482
pub fn is_values_matcher(&self) -> bool {
481483
match self {
482484
MatchingRule::Values => true,
483485
MatchingRule::EachValue(_) => true,
486+
MatchingRule::ArrayContains(_) => true,
484487
_ => false
485488
}
486489
}

0 commit comments

Comments
 (0)