Skip to main content

lex_core/lex/testing/
ast_assertions.rs

1//! Fluent assertion API for AST nodes
2//!
3//!     This module provides a powerful fluent API for testing AST nodes. As mentioned, the
4//!     spec is in flux. This means that the lower level AST nodes are subject to change. If
5//!     a test walks through the node directly, on spec changes, it will break.
6//!
7//!     Additionally, low level ast tests tend to be very superficial, doing things like
8//!     element counts (which is bound to be wrong) and other minor checks.
9//!
10//!     For this reason, all AST testing is done by this powerful library. It will conveniently
11//!     let you verify your choice of information from any element, including children and
12//!     other nested nodes. Not only is it much faster and easier to write, but on spec changes,
13//!     only one change might be needed.
14//!
15//! Why Manual AST Walking Tests are Insufficient
16//!
17//!     Traditional tests that manually walk the AST have several problems:
18//!
19//!         - They are verbose and hard to read. A test checking a nested session might require
20//!           20+ lines of boilerplate code.
21//!         - They are fragile. When AST node structures change, tests break in many places.
22//!         - They tend to be superficial, only checking counts or shallow properties rather
23//!           than actual content and structure.
24//!         - They don't scale well. Testing deep hierarchies becomes exponentially more
25//!           complex.
26//!
27//! How the Fluent API Helps with Spec Changes
28//!
29//!     The fluent API abstracts over the actual AST structure. Instead of directly accessing
30//!     fields like `session.title` or `session.children`, you use semantic methods like
31//!     `.label()` and `.child_count()`. When the AST structure changes, only the assertion
32//!     implementation needs to change, not every test.
33//!
34//!     Example: If the AST changes from `session.title: String` to `session.title: TextContent`,
35//!     tests using `.label()` continue to work, while tests using `session.title` directly
36//!     break everywhere.
37//!
38//! Usage Example
39//!
40//!     ```rust,ignore
41//!     use crate::lex::testing::{assert_ast, lexplore::Lexplore};
42//!
43//!     #[test]
44//!     fn test_complex_document() {
45//!         let doc = Lexplore::benchmark(10).parse().unwrap();
46//!
47//!         assert_ast(&doc)
48//!             .item(0, |item| {
49//!                 item.assert_session()
50//!                     .label("Introduction")
51//!                     .child_count(3)
52//!                     .child(0, |child| {
53//!                         child.assert_paragraph()
54//!                             .text_starts_with("Welcome")
55//!                             .text_contains("lex format")
56//!                     })
57//!                     .child(1, |child| {
58//!                         child.assert_list()
59//!                             .item_count(2)
60//!                             .item(0, |item| {
61//!                                 item.text_starts_with("First")
62//!                             })
63//!                             .item(1, |item| {
64//!                                 item.text_starts_with("Second")
65//!                                 .child_count(1)
66//!                                 .child(0, |nested| {
67//!                                     nested.assert_paragraph()
68//!                                         .text_contains("nested")
69//!                                 })
70//!                             })
71//!                     })
72//!                     .child(2, |child| {
73//!                         child.assert_definition()
74//!                             .subject("Term")
75//!                             .child_count(1)
76//!                     })
77//!             });
78//!     }
79//!     ```
80//!
81//!     This single test verifies the entire document structure concisely. If the spec changes,
82//!     only the assertion library implementation needs updating, not hundreds of tests.
83
84mod assertions;
85
86pub use assertions::{
87    AnnotationAssertion, ChildrenAssertion, DefinitionAssertion, DocumentAssertion,
88    InlineAssertion, InlineExpectation, ListAssertion, ListItemAssertion, ParagraphAssertion,
89    ReferenceExpectation, SessionAssertion, TableAssertion, VerbatimBlockkAssertion,
90};
91
92use crate::lex::ast::traits::AstNode;
93use crate::lex::ast::{ContentItem, Document};
94
95// ============================================================================
96// Entry Point
97// ============================================================================
98
99/// Create an assertion builder for a document
100pub fn assert_ast(doc: &Document) -> DocumentAssertion<'_> {
101    DocumentAssertion { doc }
102}
103
104// ============================================================================
105// ContentItem Assertions
106// ============================================================================
107
108pub struct ContentItemAssertion<'a> {
109    pub(crate) item: &'a ContentItem,
110    pub(crate) context: String,
111}
112
113impl<'a> ContentItemAssertion<'a> {
114    /// Assert this item is a Paragraph and return paragraph-specific assertions
115    pub fn assert_paragraph(self) -> ParagraphAssertion<'a> {
116        match self.item {
117            ContentItem::Paragraph(p) => ParagraphAssertion {
118                para: p,
119                context: self.context,
120            },
121            _ => panic!(
122                "{}: Expected Paragraph, found {}",
123                self.context,
124                self.item.node_type()
125            ),
126        }
127    }
128
129    /// Assert this item is a Session and return session-specific assertions
130    pub fn assert_session(self) -> SessionAssertion<'a> {
131        match self.item {
132            ContentItem::Session(s) => SessionAssertion {
133                session: s,
134                context: self.context,
135            },
136            _ => panic!(
137                "{}: Expected Session, found {}",
138                self.context,
139                self.item.node_type()
140            ),
141        }
142    }
143
144    /// Assert this item is a List and return list-specific assertions
145    pub fn assert_list(self) -> ListAssertion<'a> {
146        match self.item {
147            ContentItem::List(l) => ListAssertion {
148                list: l,
149                context: self.context,
150            },
151            _ => panic!(
152                "{}: Expected List, found {}",
153                self.context,
154                self.item.node_type()
155            ),
156        }
157    }
158
159    /// Assert this item is a Definition and return definition-specific assertions
160    pub fn assert_definition(self) -> DefinitionAssertion<'a> {
161        match self.item {
162            ContentItem::Definition(d) => DefinitionAssertion {
163                definition: d,
164                context: self.context,
165            },
166            _ => panic!(
167                "{}: Expected Definition, found {}",
168                self.context,
169                self.item.node_type()
170            ),
171        }
172    }
173
174    /// Assert this item is an Annotation and return annotation-specific assertions
175    pub fn assert_annotation(self) -> AnnotationAssertion<'a> {
176        match self.item {
177            ContentItem::Annotation(a) => AnnotationAssertion {
178                annotation: a,
179                context: self.context,
180            },
181            _ => panic!(
182                "{}: Expected Annotation, found {}",
183                self.context,
184                self.item.node_type()
185            ),
186        }
187    }
188
189    /// Assert this item is a Table and return table-specific assertions
190    pub fn assert_table(self) -> TableAssertion<'a> {
191        match self.item {
192            ContentItem::Table(t) => TableAssertion {
193                table: t,
194                context: self.context,
195            },
196            _ => panic!(
197                "{}: Expected Table, found {}",
198                self.context,
199                self.item.node_type()
200            ),
201        }
202    }
203
204    /// Assert this item is a VerbatimBlock and return verbatim block-specific assertions
205    pub fn assert_verbatim_block(self) -> VerbatimBlockkAssertion<'a> {
206        match self.item {
207            ContentItem::VerbatimBlock(fb) => VerbatimBlockkAssertion {
208                verbatim_block: fb,
209                context: self.context,
210            },
211            _ => panic!(
212                "{}: Expected VerbatimBlock, found {}",
213                self.context,
214                self.item.node_type()
215            ),
216        }
217    }
218}
219
220// ============================================================================
221// Tests for Document-level Assertions (location tests)
222// ============================================================================
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use crate::lex::ast::range::{Position, Range};
228    use crate::lex::ast::{Document, Session};
229
230    #[test]
231    fn test_root_location_starts_at() {
232        let location = Range::new(0..0, Position::new(0, 0), Position::new(0, 10));
233        let mut session = Session::with_title(String::new());
234        session.location = location;
235        let doc = Document {
236            annotations: Vec::new(),
237            title: None,
238            root: session,
239        };
240
241        assert_ast(&doc).root_location_starts_at(0, 0);
242    }
243
244    #[test]
245    #[should_panic(expected = "Expected root session location start line 5, found 0")]
246    fn test_root_location_starts_at_fails_wrong_line() {
247        let location = Range::new(0..0, Position::new(0, 0), Position::new(0, 10));
248        let mut session = Session::with_title(String::new());
249        session.location = location;
250        let doc = Document {
251            annotations: Vec::new(),
252            title: None,
253            root: session,
254        };
255
256        assert_ast(&doc).root_location_starts_at(5, 0);
257    }
258
259    #[test]
260    fn test_root_location_ends_at() {
261        let location = Range::new(0..0, Position::new(0, 0), Position::new(2, 15));
262        let mut session = Session::with_title(String::new());
263        session.location = location;
264        let doc = Document {
265            annotations: Vec::new(),
266            title: None,
267            root: session,
268        };
269
270        assert_ast(&doc).root_location_ends_at(2, 15);
271    }
272
273    #[test]
274    #[should_panic(expected = "Expected root session location end column 10, found 15")]
275    fn test_root_location_ends_at_fails_wrong_column() {
276        let location = Range::new(0..0, Position::new(0, 0), Position::new(2, 15));
277        let mut session = Session::with_title(String::new());
278        session.location = location;
279        let doc = Document {
280            annotations: Vec::new(),
281            title: None,
282            root: session,
283        };
284
285        assert_ast(&doc).root_location_ends_at(2, 10);
286    }
287
288    #[test]
289    fn test_root_location_contains() {
290        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
291        let mut session = Session::with_title(String::new());
292        session.location = location;
293        let doc = Document {
294            annotations: Vec::new(),
295            title: None,
296            root: session,
297        };
298
299        assert_ast(&doc).root_location_contains(2, 5);
300    }
301
302    #[test]
303    #[should_panic(expected = "Expected root session location")]
304    fn test_root_location_contains_fails() {
305        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
306        let mut session = Session::with_title(String::new());
307        session.location = location;
308        let doc = Document {
309            annotations: Vec::new(),
310            title: None,
311            root: session,
312        };
313
314        assert_ast(&doc).root_location_contains(5, 5);
315    }
316
317    #[test]
318    fn test_root_location_excludes() {
319        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
320        let mut session = Session::with_title(String::new());
321        session.location = location;
322        let doc = Document {
323            annotations: Vec::new(),
324            title: None,
325            root: session,
326        };
327
328        assert_ast(&doc).root_location_excludes(5, 5);
329    }
330
331    #[test]
332    #[should_panic(expected = "Expected root session location")]
333    fn test_root_location_excludes_fails() {
334        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
335        let mut session = Session::with_title(String::new());
336        session.location = location;
337        let doc = Document {
338            annotations: Vec::new(),
339            title: None,
340            root: session,
341        };
342
343        assert_ast(&doc).root_location_excludes(2, 5);
344    }
345
346    #[test]
347    fn test_location_assertions_are_fluent() {
348        let location = Range::new(0..0, Position::new(0, 0), Position::new(5, 20));
349        let mut session = Session::with_title(String::new());
350        session.location = location;
351        let doc = Document {
352            annotations: Vec::new(),
353            title: None,
354            root: session,
355        };
356
357        assert_ast(&doc)
358            .root_location_starts_at(0, 0)
359            .root_location_ends_at(5, 20)
360            .root_location_contains(2, 10)
361            .root_location_excludes(10, 0)
362            .item_count(0);
363    }
364}