Skip to main content

zen_expression/intellisense/
discriminant.rs

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}