1use std::rc::Rc;
2
3use rust_decimal::Decimal;
4
5use crate::lexer::{ArithmeticOperator, Bracket, ComparisonOperator, LogicalOperator, Operator};
6use crate::parser::Node;
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum ArmTest {
10 Enum {
11 path: Vec<Rc<str>>,
12 values: Vec<Rc<str>>,
13 },
14 Bool {
15 path: Vec<Rc<str>>,
16 values: Vec<bool>,
17 },
18 Number {
19 path: Vec<Rc<str>>,
20 cover: NumberCover,
21 },
22 Default,
23 Unrecognized,
24}
25
26#[derive(Debug, Clone, PartialEq)]
27pub struct NumberCover {
28 segments: Vec<NumberSegment>,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
32struct NumberSegment {
33 lo: Bound,
34 hi: Bound,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq)]
38enum Bound {
39 Unbounded,
40 Inclusive(Decimal),
41 Exclusive(Decimal),
42}
43
44enum Operand {
45 Path(Vec<Rc<str>>),
46 Num(Decimal),
47 Str(Rc<str>),
48 Bool(bool),
49 Other,
50}
51
52impl ArmTest {
53 pub(crate) fn from_node(node: &Node) -> ArmTest {
54 match node {
55 Node::Parenthesized(inner) => Self::from_node(inner),
56 Node::Binary {
57 left,
58 operator,
59 right,
60 } => Self::binary(left, *operator, right),
61 _ => ArmTest::Unrecognized,
62 }
63 }
64
65 fn binary(left: &Node, operator: Operator, right: &Node) -> ArmTest {
66 match operator {
67 Operator::Comparison(ComparisonOperator::Equal) => Self::equality(left, right),
68 Operator::Comparison(ComparisonOperator::In) => Self::in_set(left, right),
69 Operator::Comparison(
70 op @ (ComparisonOperator::LessThan
71 | ComparisonOperator::LessThanOrEqual
72 | ComparisonOperator::GreaterThan
73 | ComparisonOperator::GreaterThanOrEqual),
74 ) => Self::numeric(left, op, right),
75 Operator::Logical(LogicalOperator::And) => {
76 Self::and(Self::from_node(left), Self::from_node(right))
77 }
78 Operator::Logical(LogicalOperator::Or) => {
79 Self::or(Self::from_node(left), Self::from_node(right))
80 }
81 _ => ArmTest::Unrecognized,
82 }
83 }
84
85 fn equality(left: &Node, right: &Node) -> ArmTest {
86 let (path, literal) = match (Operand::classify(left), Operand::classify(right)) {
87 (Operand::Path(p), literal) => (p, literal),
88 (literal, Operand::Path(p)) => (p, literal),
89 _ => return ArmTest::Unrecognized,
90 };
91 match literal {
92 Operand::Str(s) => ArmTest::Enum {
93 path,
94 values: vec![s],
95 },
96 Operand::Bool(b) => ArmTest::Bool {
97 path,
98 values: vec![b],
99 },
100 Operand::Num(n) => ArmTest::Number {
101 path,
102 cover: NumberCover::point(n),
103 },
104 _ => ArmTest::Unrecognized,
105 }
106 }
107
108 fn in_set(left: &Node, right: &Node) -> ArmTest {
109 let Operand::Path(path) = Operand::classify(left) else {
110 return ArmTest::Unrecognized;
111 };
112 if let Node::Interval {
113 left: lo,
114 right: hi,
115 left_bracket,
116 right_bracket,
117 } = Operand::unwrap(right)
118 {
119 let (Operand::Num(lo), Operand::Num(hi)) =
120 (Operand::classify(lo), Operand::classify(hi))
121 else {
122 return ArmTest::Unrecognized;
123 };
124 return match NumberCover::interval(lo, *left_bracket, hi, *right_bracket) {
125 Some(cover) => ArmTest::Number { path, cover },
126 None => ArmTest::Unrecognized,
127 };
128 }
129 let Node::Array(items) = Operand::unwrap(right) else {
130 return ArmTest::Unrecognized;
131 };
132 if items.is_empty() {
133 return ArmTest::Unrecognized;
134 }
135 if let Some(values) = items
136 .iter()
137 .map(|n| match Operand::unwrap(n) {
138 Node::String(s) => Some(Rc::from(*s)),
139 _ => None,
140 })
141 .collect::<Option<Vec<Rc<str>>>>()
142 {
143 return ArmTest::Enum { path, values };
144 }
145 if let Some(values) = items
146 .iter()
147 .map(|n| match Operand::unwrap(n) {
148 Node::Bool(b) => Some(*b),
149 _ => None,
150 })
151 .collect::<Option<Vec<bool>>>()
152 {
153 return ArmTest::Bool { path, values };
154 }
155 if let Some(values) = items
156 .iter()
157 .map(|n| match Operand::classify(n) {
158 Operand::Num(d) => Some(d),
159 _ => None,
160 })
161 .collect::<Option<Vec<Decimal>>>()
162 {
163 let mut numbers = values.into_iter();
164 let Some(first) = numbers.next() else {
165 return ArmTest::Unrecognized;
166 };
167 let cover = numbers.fold(NumberCover::point(first), |mut cover, n| {
168 cover.merged_with(&NumberCover::point(n));
169 cover
170 });
171 return ArmTest::Number { path, cover };
172 }
173 ArmTest::Unrecognized
174 }
175
176 fn numeric(left: &Node, op: ComparisonOperator, right: &Node) -> ArmTest {
177 let (path, num, op) = match (Operand::classify(left), Operand::classify(right)) {
178 (Operand::Path(p), Operand::Num(n)) => (p, n, op),
179 (Operand::Num(n), Operand::Path(p)) => (p, n, Self::flip(op)),
180 _ => return ArmTest::Unrecognized,
181 };
182 match NumberCover::single(op, num) {
183 Some(cover) => ArmTest::Number { path, cover },
184 None => ArmTest::Unrecognized,
185 }
186 }
187
188 fn and(left: ArmTest, right: ArmTest) -> ArmTest {
189 match (left, right) {
190 (
191 ArmTest::Number {
192 path: pa,
193 cover: ca,
194 },
195 ArmTest::Number {
196 path: pb,
197 cover: cb,
198 },
199 ) if pa == pb => match ca.intersect(&cb) {
200 Some(cover) => ArmTest::Number { path: pa, cover },
201 None => ArmTest::Unrecognized,
202 },
203 _ => ArmTest::Unrecognized,
204 }
205 }
206
207 fn or(left: ArmTest, right: ArmTest) -> ArmTest {
208 match (left, right) {
209 (
210 ArmTest::Enum {
211 path: pa,
212 values: mut va,
213 },
214 ArmTest::Enum {
215 path: pb,
216 values: vb,
217 },
218 ) if pa == pb => {
219 va.extend(vb);
220 ArmTest::Enum {
221 path: pa,
222 values: va,
223 }
224 }
225 (
226 ArmTest::Bool {
227 path: pa,
228 values: mut va,
229 },
230 ArmTest::Bool {
231 path: pb,
232 values: vb,
233 },
234 ) if pa == pb => {
235 va.extend(vb);
236 ArmTest::Bool {
237 path: pa,
238 values: va,
239 }
240 }
241 (
242 ArmTest::Number {
243 path: pa,
244 cover: mut ca,
245 },
246 ArmTest::Number {
247 path: pb,
248 cover: cb,
249 },
250 ) if pa == pb => {
251 ca.merged_with(&cb);
252 ArmTest::Number {
253 path: pa,
254 cover: ca,
255 }
256 }
257 _ => ArmTest::Unrecognized,
258 }
259 }
260
261 fn flip(op: ComparisonOperator) -> ComparisonOperator {
262 match op {
263 ComparisonOperator::LessThan => ComparisonOperator::GreaterThan,
264 ComparisonOperator::LessThanOrEqual => ComparisonOperator::GreaterThanOrEqual,
265 ComparisonOperator::GreaterThan => ComparisonOperator::LessThan,
266 ComparisonOperator::GreaterThanOrEqual => ComparisonOperator::LessThanOrEqual,
267 other => other,
268 }
269 }
270}
271
272impl Operand {
273 fn classify(node: &Node) -> Operand {
274 match Self::unwrap(node) {
275 Node::Number(n) => Operand::Num(*n),
276 Node::String(s) => Operand::Str(Rc::from(*s)),
277 Node::Bool(b) => Operand::Bool(*b),
278 Node::Unary {
279 operator: Operator::Arithmetic(ArithmeticOperator::Subtract),
280 node,
281 } => match Self::unwrap(node) {
282 Node::Number(n) => Operand::Num(-*n),
283 _ => Operand::Other,
284 },
285 other => match Self::extract_path(other) {
286 Some(path) => Operand::Path(path),
287 None => Operand::Other,
288 },
289 }
290 }
291
292 fn unwrap<'a, 'n>(node: &'a Node<'n>) -> &'a Node<'n> {
293 match node {
294 Node::Parenthesized(inner) => Self::unwrap(inner),
295 other => other,
296 }
297 }
298
299 fn extract_path(node: &Node) -> Option<Vec<Rc<str>>> {
300 match node {
301 Node::Identifier(name) => Some(vec![Rc::from(*name)]),
302 Node::Member { node, property } => {
303 let mut path = Self::extract_path(node)?;
304 match property {
305 Node::String(key) => {
306 path.push(Rc::from(*key));
307 Some(path)
308 }
309 _ => None,
310 }
311 }
312 _ => None,
313 }
314 }
315}
316
317impl NumberCover {
318 fn point(n: Decimal) -> Self {
319 Self {
320 segments: vec![NumberSegment {
321 lo: Bound::Inclusive(n),
322 hi: Bound::Inclusive(n),
323 }],
324 }
325 }
326
327 fn interval(lo: Decimal, left: Bracket, hi: Decimal, right: Bracket) -> Option<Self> {
328 let lo = match left {
329 Bracket::LeftSquareBracket => Bound::Inclusive(lo),
330 Bracket::LeftParenthesis => Bound::Exclusive(lo),
331 _ => return None,
332 };
333 let hi = match right {
334 Bracket::RightSquareBracket => Bound::Inclusive(hi),
335 Bracket::RightParenthesis => Bound::Exclusive(hi),
336 _ => return None,
337 };
338 Some(Self {
339 segments: vec![NumberSegment { lo, hi }],
340 })
341 }
342
343 fn single(op: ComparisonOperator, n: Decimal) -> Option<Self> {
344 let segment = match op {
345 ComparisonOperator::LessThan => NumberSegment {
346 lo: Bound::Unbounded,
347 hi: Bound::Exclusive(n),
348 },
349 ComparisonOperator::LessThanOrEqual => NumberSegment {
350 lo: Bound::Unbounded,
351 hi: Bound::Inclusive(n),
352 },
353 ComparisonOperator::GreaterThan => NumberSegment {
354 lo: Bound::Exclusive(n),
355 hi: Bound::Unbounded,
356 },
357 ComparisonOperator::GreaterThanOrEqual => NumberSegment {
358 lo: Bound::Inclusive(n),
359 hi: Bound::Unbounded,
360 },
361 _ => return None,
362 };
363 Some(Self {
364 segments: vec![segment],
365 })
366 }
367
368 pub fn points(&self) -> Option<Vec<Decimal>> {
369 self.segments
370 .iter()
371 .map(|s| match (s.lo, s.hi) {
372 (Bound::Inclusive(lo), Bound::Inclusive(hi)) if lo == hi => Some(lo),
373 _ => None,
374 })
375 .collect()
376 }
377
378 pub fn merged_with(&mut self, other: &NumberCover) {
379 self.segments.extend(other.segments.iter().copied());
380 }
381
382 fn intersect(&self, other: &NumberCover) -> Option<NumberCover> {
383 let ([a], [b]) = (self.segments.as_slice(), other.segments.as_slice()) else {
384 return None;
385 };
386 Some(NumberCover {
387 segments: vec![NumberSegment {
388 lo: Bound::tighter_lo(a.lo, b.lo),
389 hi: Bound::tighter_hi(a.hi, b.hi),
390 }],
391 })
392 }
393
394 pub fn is_total(&self) -> bool {
395 let mut segments = self.segments.clone();
396 segments.sort_by(|a, b| Bound::lo_rank(a.lo).cmp(&Bound::lo_rank(b.lo)));
397 let Some((first, rest)) = segments.split_first() else {
398 return false;
399 };
400 if first.lo != Bound::Unbounded {
401 return false;
402 }
403 let mut frontier = first.hi;
404 for segment in rest {
405 if frontier == Bound::Unbounded {
406 return true;
407 }
408 if !Bound::connects(frontier, segment.lo) {
409 return false;
410 }
411 frontier = Bound::wider_hi(frontier, segment.hi);
412 }
413 frontier == Bound::Unbounded
414 }
415}
416
417impl Bound {
418 fn lo_rank(self) -> (u8, Decimal, u8) {
419 match self {
420 Bound::Unbounded => (0, Decimal::ZERO, 0),
421 Bound::Inclusive(n) => (1, n, 0),
422 Bound::Exclusive(n) => (1, n, 1),
423 }
424 }
425
426 fn hi_rank(self) -> (Decimal, u8) {
427 match self {
428 Bound::Exclusive(n) => (n, 0),
429 Bound::Inclusive(n) => (n, 1),
430 Bound::Unbounded => (Decimal::MAX, 2),
431 }
432 }
433
434 fn tighter_lo(a: Bound, b: Bound) -> Bound {
435 match (a, b) {
436 (Bound::Unbounded, other) | (other, Bound::Unbounded) => other,
437 _ if a.lo_rank() >= b.lo_rank() => a,
438 _ => b,
439 }
440 }
441
442 fn tighter_hi(a: Bound, b: Bound) -> Bound {
443 match (a, b) {
444 (Bound::Unbounded, other) | (other, Bound::Unbounded) => other,
445 _ if a.hi_rank() <= b.hi_rank() => a,
446 _ => b,
447 }
448 }
449
450 fn wider_hi(a: Bound, b: Bound) -> Bound {
451 match (a, b) {
452 (Bound::Unbounded, _) | (_, Bound::Unbounded) => Bound::Unbounded,
453 _ if a.hi_rank() >= b.hi_rank() => a,
454 _ => b,
455 }
456 }
457
458 fn connects(frontier_hi: Bound, next_lo: Bound) -> bool {
459 let (Bound::Inclusive(f) | Bound::Exclusive(f), Bound::Inclusive(l) | Bound::Exclusive(l)) =
460 (frontier_hi, next_lo)
461 else {
462 return true;
463 };
464 if l < f {
465 return true;
466 }
467 if l > f {
468 return false;
469 }
470 !(matches!(frontier_hi, Bound::Exclusive(_)) && matches!(next_lo, Bound::Exclusive(_)))
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::intellisense::IntelliSense;
478
479 fn test_of(source: &str) -> ArmTest {
480 IntelliSense::new().arm_test(source)
481 }
482
483 fn path(segments: &[&str]) -> Vec<Rc<str>> {
484 segments.iter().map(|s| Rc::from(*s)).collect()
485 }
486
487 fn number_cover(source: &str) -> NumberCover {
488 match test_of(source) {
489 ArmTest::Number { cover, .. } => cover,
490 other => panic!("expected number cover, got {other:?}"),
491 }
492 }
493
494 #[test]
495 fn equality_string_is_enum() {
496 assert_eq!(
497 test_of("customer.segment == \"retail\""),
498 ArmTest::Enum {
499 path: path(&["customer", "segment"]),
500 values: vec![Rc::from("retail")],
501 }
502 );
503 }
504
505 #[test]
506 fn equality_literal_left_is_flipped_into_enum() {
507 assert_eq!(
508 test_of("\"retail\" == customer.segment"),
509 ArmTest::Enum {
510 path: path(&["customer", "segment"]),
511 values: vec![Rc::from("retail")],
512 }
513 );
514 }
515
516 #[test]
517 fn in_string_set_is_enum() {
518 assert_eq!(
519 test_of("customer.segment in [\"retail\", \"corporate\"]"),
520 ArmTest::Enum {
521 path: path(&["customer", "segment"]),
522 values: vec![Rc::from("retail"), Rc::from("corporate")],
523 }
524 );
525 }
526
527 #[test]
528 fn equality_bool_is_bool() {
529 assert_eq!(
530 test_of("customer.active == true"),
531 ArmTest::Bool {
532 path: path(&["customer", "active"]),
533 values: vec![true],
534 }
535 );
536 }
537
538 #[test]
539 fn empty_condition_is_default() {
540 assert_eq!(test_of(""), ArmTest::Default);
541 }
542
543 #[test]
544 fn not_equal_is_unrecognized() {
545 assert_eq!(
546 test_of("customer.segment != \"retail\""),
547 ArmTest::Unrecognized
548 );
549 }
550
551 #[test]
552 fn not_in_is_unrecognized() {
553 assert_eq!(test_of("customer.n not in [1, 2]"), ArmTest::Unrecognized);
554 }
555
556 #[test]
557 fn garbage_is_unrecognized() {
558 assert_eq!(test_of("customer."), ArmTest::Unrecognized);
559 }
560
561 #[test]
562 fn same_property_and_is_interval_intersection() {
563 let cover = number_cover("customer.p >= 10 and customer.p < 20");
564 assert!(!cover.is_total());
565 }
566
567 #[test]
568 fn different_property_and_is_guard() {
569 assert_eq!(
570 test_of("customer.p < 10 and customer.region == \"EU\""),
571 ArmTest::Unrecognized
572 );
573 }
574
575 #[test]
576 fn same_property_or_is_enum_union() {
577 assert_eq!(
578 test_of("customer.segment == \"retail\" or customer.segment == \"corporate\""),
579 ArmTest::Enum {
580 path: path(&["customer", "segment"]),
581 values: vec![Rc::from("retail"), Rc::from("corporate")],
582 }
583 );
584 }
585
586 #[test]
587 fn number_tiling_no_gap_is_total() {
588 let mut cover = number_cover("customer.age < 18");
589 cover.merged_with(&number_cover("customer.age >= 18"));
590 assert!(cover.is_total());
591 }
592
593 #[test]
594 fn number_tiling_with_gap_is_not_total() {
595 let mut cover = number_cover("customer.age < 18");
596 cover.merged_with(&number_cover("customer.age > 18"));
597 assert!(!cover.is_total());
598 }
599
600 #[test]
601 fn number_tiling_inclusive_overlap_is_total() {
602 let mut cover = number_cover("customer.age <= 18");
603 cover.merged_with(&number_cover("customer.age >= 18"));
604 assert!(cover.is_total());
605 }
606
607 #[test]
608 fn number_point_fills_seam() {
609 let mut cover = number_cover("customer.age < 18");
610 cover.merged_with(&NumberCover::point(Decimal::from(18)));
611 cover.merged_with(&number_cover("customer.age > 18"));
612 assert!(cover.is_total());
613 }
614
615 #[test]
616 fn lone_point_is_not_total() {
617 assert!(!NumberCover::point(Decimal::from(5)).is_total());
618 }
619
620 #[test]
621 fn disjoint_union_with_gap_is_not_total() {
622 let mut cover = number_cover("customer.age < 10");
623 cover.merged_with(&number_cover("customer.age >= 20"));
624 assert!(!cover.is_total());
625 }
626
627 fn cell_of(source: &str) -> ArmTest {
628 IntelliSense::new().cell_test(source)
629 }
630
631 fn cell_cover(source: &str) -> NumberCover {
632 match cell_of(source) {
633 ArmTest::Number { cover, .. } => cover,
634 other => panic!("expected number cover, got {other:?}"),
635 }
636 }
637
638 #[test]
639 fn cell_string_literal_is_enum() {
640 assert_eq!(
641 cell_of("\"US\""),
642 ArmTest::Enum {
643 path: path(&["$"]),
644 values: vec![Rc::from("US")],
645 }
646 );
647 }
648
649 #[test]
650 fn cell_comma_list_is_enum_union() {
651 assert_eq!(
652 cell_of("\"US\", \"CA\""),
653 ArmTest::Enum {
654 path: path(&["$"]),
655 values: vec![Rc::from("US"), Rc::from("CA")],
656 }
657 );
658 }
659
660 #[test]
661 fn cell_in_array_is_enum() {
662 assert_eq!(
663 cell_of("in [\"US\", \"CA\"]"),
664 ArmTest::Enum {
665 path: path(&["$"]),
666 values: vec![Rc::from("US"), Rc::from("CA")],
667 }
668 );
669 }
670
671 #[test]
672 fn cell_bool_literal() {
673 assert_eq!(
674 cell_of("true"),
675 ArmTest::Bool {
676 path: path(&["$"]),
677 values: vec![true],
678 }
679 );
680 }
681
682 #[test]
683 fn cell_comparison_tiles() {
684 let mut cover = cell_cover("< 18");
685 cover.merged_with(&cell_cover(">= 18"));
686 assert!(cover.is_total());
687 }
688
689 #[test]
690 fn cell_closed_interval() {
691 let mut cover = cell_cover("[0..18]");
692 assert!(!cover.is_total());
693 cover.merged_with(&cell_cover("> 18"));
694 cover.merged_with(&cell_cover("< 0"));
695 assert!(cover.is_total());
696 }
697
698 #[test]
699 fn cell_open_interval_leaves_seam() {
700 let mut cover = cell_cover("(0..18)");
701 cover.merged_with(&cell_cover(">= 18"));
702 cover.merged_with(&cell_cover("<= 0"));
703 assert!(cover.is_total());
704 }
705
706 #[test]
707 fn cell_negative_bounds() {
708 let mut cover = cell_cover("[-10..10]");
709 cover.merged_with(&cell_cover("> 10"));
710 cover.merged_with(&cell_cover("< -10"));
711 assert!(cover.is_total());
712 }
713
714 #[test]
715 fn cell_and_intersects() {
716 let cover = cell_cover(">= 10 and < 20");
717 assert!(!cover.is_total());
718 }
719
720 #[test]
721 fn cell_subpath_test_is_unrecognized() {
722 assert_eq!(cell_of("$.foo == 1"), ArmTest::Unrecognized);
723 }
724
725 #[test]
726 fn cell_function_condition_is_unrecognized() {
727 assert_eq!(cell_of("startsWith($, \"a\")"), ArmTest::Unrecognized);
728 }
729
730 #[test]
731 fn cell_empty_is_default() {
732 assert_eq!(cell_of(""), ArmTest::Default);
733 }
734}