Skip to main content

test_better_matchers/
description.rs

1//! [`Description`]: a composable, human-readable account of what a matcher
2//! expects.
3//!
4//! A `Description` is a tree, not a string. Combinators (`not`, `all_of`,
5//! `any_of`) build a description out of their children's descriptions, and
6//! nested matchers (`some(ok(eq(..)))`) nest them. Text is produced once, at
7//! the end, by [`Display`](std::fmt::Display) — so the structure is still
8//! inspectable right up until it is rendered.
9
10use std::borrow::Cow;
11use std::fmt;
12
13/// A composable description of a matcher's expectation.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Description {
16    node: Node,
17}
18
19/// The tree behind a [`Description`].
20#[derive(Debug, Clone, PartialEq, Eq)]
21enum Node {
22    /// A leaf: a finished phrase such as `equal to 4`.
23    Text(Cow<'static, str>),
24    /// Negation: `not <child>`.
25    Not(Box<Node>),
26    /// Conjunction: every child must hold. Flattened on construction.
27    All(Vec<Node>),
28    /// Disjunction: at least one child must hold. Flattened on construction.
29    Any(Vec<Node>),
30    /// A `header:` line with `child` rendered indented beneath it. This is how
31    /// nested matchers (`some(ok(..))`) keep their expected blocks aligned.
32    Labeled {
33        /// The line shown above the indented child.
34        header: Cow<'static, str>,
35        /// The nested description.
36        child: Box<Node>,
37    },
38}
39
40impl Description {
41    /// A leaf description: a finished phrase like `equal to 4`.
42    #[must_use]
43    pub fn text(text: impl Into<Cow<'static, str>>) -> Self {
44        Self {
45            node: Node::Text(text.into()),
46        }
47    }
48
49    /// Combines this description with `other` under conjunction. Nested
50    /// conjunctions are flattened, so `a.and(b).and(c)` is a single `All`.
51    #[must_use]
52    pub fn and(self, other: Description) -> Self {
53        let mut parts = match self.node {
54            Node::All(parts) => parts,
55            node => vec![node],
56        };
57        match other.node {
58            Node::All(more) => parts.extend(more),
59            node => parts.push(node),
60        }
61        Self {
62            node: Node::All(parts),
63        }
64    }
65
66    /// Combines this description with `other` under disjunction. Nested
67    /// disjunctions are flattened, so `a.or(b).or(c)` is a single `Any`.
68    #[must_use]
69    pub fn or(self, other: Description) -> Self {
70        let mut parts = match self.node {
71            Node::Any(parts) => parts,
72            node => vec![node],
73        };
74        match other.node {
75            Node::Any(more) => parts.extend(more),
76            node => parts.push(node),
77        }
78        Self {
79            node: Node::Any(parts),
80        }
81    }
82
83    /// Places `child` indented beneath a `header:` line. Used by nested
84    /// matchers to keep their expected blocks readable.
85    #[must_use]
86    pub fn labeled(header: impl Into<Cow<'static, str>>, child: Description) -> Self {
87        Self {
88            node: Node::Labeled {
89                header: header.into(),
90                child: Box::new(child.node),
91            },
92        }
93    }
94}
95
96impl std::ops::Not for Description {
97    type Output = Description;
98
99    /// Negates this description. Double negation cancels, so `!!x` renders as
100    /// `x`.
101    fn not(self) -> Description {
102        let node = match self.node {
103            Node::Not(inner) => *inner,
104            other => Node::Not(Box::new(other)),
105        };
106        Description { node }
107    }
108}
109
110impl fmt::Display for Description {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(&render(&self.node))
113    }
114}
115
116/// Renders a node to text. Each [`Node::Labeled`] level prefixes its child's
117/// lines with two spaces; because the recursion does this at every level, a
118/// chain of labels indents by a steady two spaces per level.
119fn render(node: &Node) -> String {
120    match node {
121        Node::Text(text) => text.to_string(),
122        Node::Not(inner) => format!("not {}", parenthesize_under_not(inner)),
123        Node::All(parts) => parts
124            .iter()
125            .map(parenthesize_under_all)
126            .collect::<Vec<_>>()
127            .join(" and "),
128        Node::Any(parts) => parts.iter().map(render).collect::<Vec<_>>().join(" or "),
129        Node::Labeled { header, child } => {
130            let body = render(child)
131                .lines()
132                .map(|line| format!("  {line}"))
133                .collect::<Vec<_>>()
134                .join("\n");
135            format!("{header}:\n{body}")
136        }
137    }
138}
139
140/// `not` binds tighter than `and`/`or`, so a compound child is parenthesized.
141fn parenthesize_under_not(node: &Node) -> String {
142    match node {
143        Node::All(_) | Node::Any(_) => format!("({})", render(node)),
144        _ => render(node),
145    }
146}
147
148/// Inside `and`, an `or` child is parenthesized; everything else binds at
149/// least as tightly and needs no parentheses.
150fn parenthesize_under_all(node: &Node) -> String {
151    match node {
152        Node::Any(_) => format!("({})", render(node)),
153        _ => render(node),
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use test_better_core::TestResult;
160
161    use super::*;
162    use crate::{eq, expect};
163
164    #[test]
165    fn text_renders_verbatim() -> TestResult {
166        expect!(Description::text("equal to 4").to_string()).to(eq("equal to 4".to_string()))?;
167        Ok(())
168    }
169
170    #[test]
171    fn not_negates_and_double_negation_cancels() -> TestResult {
172        let base = Description::text("equal to 4");
173        expect!((!base.clone()).to_string()).to(eq("not equal to 4".to_string()))?;
174        expect!((!!base).to_string()).to(eq("equal to 4".to_string()))?;
175        Ok(())
176    }
177
178    #[test]
179    fn and_flattens_and_joins() -> TestResult {
180        let combined = Description::text("greater than 0")
181            .and(Description::text("less than 100"))
182            .and(Description::text("even"));
183        expect!(combined.to_string())
184            .to(eq("greater than 0 and less than 100 and even".to_string()))?;
185        Ok(())
186    }
187
188    #[test]
189    fn or_flattens_and_joins() -> TestResult {
190        let combined = Description::text("zero")
191            .or(Description::text("one"))
192            .or(Description::text("two"));
193        expect!(combined.to_string()).to(eq("zero or one or two".to_string()))?;
194        Ok(())
195    }
196
197    #[test]
198    fn or_inside_and_is_parenthesized() -> TestResult {
199        let combined = Description::text("positive")
200            .and(Description::text("small").or(Description::text("huge")));
201        expect!(combined.to_string()).to(eq("positive and (small or huge)".to_string()))?;
202        Ok(())
203    }
204
205    #[test]
206    fn not_of_compound_is_parenthesized() -> TestResult {
207        let combined = !Description::text("a").and(Description::text("b"));
208        expect!(combined.to_string()).to(eq("not (a and b)".to_string()))?;
209        Ok(())
210    }
211
212    #[test]
213    fn labeled_indents_the_child() -> TestResult {
214        let described = Description::labeled("some", Description::text("equal to 42"));
215        expect!(described.to_string()).to(eq("some:\n  equal to 42".to_string()))?;
216        Ok(())
217    }
218
219    #[test]
220    fn nested_labels_indent_two_spaces_per_level() -> TestResult {
221        let described = Description::labeled(
222            "some",
223            Description::labeled("ok", Description::text("equal to 42")),
224        );
225        expect!(described.to_string()).to(eq("some:\n  ok:\n    equal to 42".to_string()))?;
226        Ok(())
227    }
228}