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::{check, eq};
163
164    #[test]
165    fn text_renders_verbatim() -> TestResult {
166        check!(Description::text("equal to 4").to_string())
167            .satisfies(eq("equal to 4".to_string()))?;
168        Ok(())
169    }
170
171    #[test]
172    fn not_negates_and_double_negation_cancels() -> TestResult {
173        let base = Description::text("equal to 4");
174        check!((!base.clone()).to_string()).satisfies(eq("not equal to 4".to_string()))?;
175        check!((!!base).to_string()).satisfies(eq("equal to 4".to_string()))?;
176        Ok(())
177    }
178
179    #[test]
180    fn and_flattens_and_joins() -> TestResult {
181        let combined = Description::text("greater than 0")
182            .and(Description::text("less than 100"))
183            .and(Description::text("even"));
184        check!(combined.to_string())
185            .satisfies(eq("greater than 0 and less than 100 and even".to_string()))?;
186        Ok(())
187    }
188
189    #[test]
190    fn or_flattens_and_joins() -> TestResult {
191        let combined = Description::text("zero")
192            .or(Description::text("one"))
193            .or(Description::text("two"));
194        check!(combined.to_string()).satisfies(eq("zero or one or two".to_string()))?;
195        Ok(())
196    }
197
198    #[test]
199    fn or_inside_and_is_parenthesized() -> TestResult {
200        let combined = Description::text("positive")
201            .and(Description::text("small").or(Description::text("huge")));
202        check!(combined.to_string()).satisfies(eq("positive and (small or huge)".to_string()))?;
203        Ok(())
204    }
205
206    #[test]
207    fn not_of_compound_is_parenthesized() -> TestResult {
208        let combined = !Description::text("a").and(Description::text("b"));
209        check!(combined.to_string()).satisfies(eq("not (a and b)".to_string()))?;
210        Ok(())
211    }
212
213    #[test]
214    fn labeled_indents_the_child() -> TestResult {
215        let described = Description::labeled("some", Description::text("equal to 42"));
216        check!(described.to_string()).satisfies(eq("some:\n  equal to 42".to_string()))?;
217        Ok(())
218    }
219
220    #[test]
221    fn nested_labels_indent_two_spaces_per_level() -> TestResult {
222        let described = Description::labeled(
223            "some",
224            Description::labeled("ok", Description::text("equal to 42")),
225        );
226        check!(described.to_string()).satisfies(eq("some:\n  ok:\n    equal to 42".to_string()))?;
227        Ok(())
228    }
229}