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, 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 VerbatimBlock and return verbatim block-specific assertions
190    pub fn assert_verbatim_block(self) -> VerbatimBlockkAssertion<'a> {
191        match self.item {
192            ContentItem::VerbatimBlock(fb) => VerbatimBlockkAssertion {
193                verbatim_block: fb,
194                context: self.context,
195            },
196            _ => panic!(
197                "{}: Expected VerbatimBlock, found {}",
198                self.context,
199                self.item.node_type()
200            ),
201        }
202    }
203}
204
205// ============================================================================
206// Tests for Document-level Assertions (location tests)
207// ============================================================================
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::lex::ast::range::{Position, Range};
213    use crate::lex::ast::{Document, Session};
214
215    #[test]
216    fn test_root_location_starts_at() {
217        let location = Range::new(0..0, Position::new(0, 0), Position::new(0, 10));
218        let mut session = Session::with_title(String::new());
219        session.location = location;
220        let doc = Document {
221            annotations: Vec::new(),
222            root: session,
223        };
224
225        assert_ast(&doc).root_location_starts_at(0, 0);
226    }
227
228    #[test]
229    #[should_panic(expected = "Expected root session location start line 5, found 0")]
230    fn test_root_location_starts_at_fails_wrong_line() {
231        let location = Range::new(0..0, Position::new(0, 0), Position::new(0, 10));
232        let mut session = Session::with_title(String::new());
233        session.location = location;
234        let doc = Document {
235            annotations: Vec::new(),
236            root: session,
237        };
238
239        assert_ast(&doc).root_location_starts_at(5, 0);
240    }
241
242    #[test]
243    fn test_root_location_ends_at() {
244        let location = Range::new(0..0, Position::new(0, 0), Position::new(2, 15));
245        let mut session = Session::with_title(String::new());
246        session.location = location;
247        let doc = Document {
248            annotations: Vec::new(),
249            root: session,
250        };
251
252        assert_ast(&doc).root_location_ends_at(2, 15);
253    }
254
255    #[test]
256    #[should_panic(expected = "Expected root session location end column 10, found 15")]
257    fn test_root_location_ends_at_fails_wrong_column() {
258        let location = Range::new(0..0, Position::new(0, 0), Position::new(2, 15));
259        let mut session = Session::with_title(String::new());
260        session.location = location;
261        let doc = Document {
262            annotations: Vec::new(),
263            root: session,
264        };
265
266        assert_ast(&doc).root_location_ends_at(2, 10);
267    }
268
269    #[test]
270    fn test_root_location_contains() {
271        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
272        let mut session = Session::with_title(String::new());
273        session.location = location;
274        let doc = Document {
275            annotations: Vec::new(),
276            root: session,
277        };
278
279        assert_ast(&doc).root_location_contains(2, 5);
280    }
281
282    #[test]
283    #[should_panic(expected = "Expected root session location")]
284    fn test_root_location_contains_fails() {
285        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
286        let mut session = Session::with_title(String::new());
287        session.location = location;
288        let doc = Document {
289            annotations: Vec::new(),
290            root: session,
291        };
292
293        assert_ast(&doc).root_location_contains(5, 5);
294    }
295
296    #[test]
297    fn test_root_location_excludes() {
298        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
299        let mut session = Session::with_title(String::new());
300        session.location = location;
301        let doc = Document {
302            annotations: Vec::new(),
303            root: session,
304        };
305
306        assert_ast(&doc).root_location_excludes(5, 5);
307    }
308
309    #[test]
310    #[should_panic(expected = "Expected root session location")]
311    fn test_root_location_excludes_fails() {
312        let location = Range::new(0..0, Position::new(1, 0), Position::new(3, 10));
313        let mut session = Session::with_title(String::new());
314        session.location = location;
315        let doc = Document {
316            annotations: Vec::new(),
317            root: session,
318        };
319
320        assert_ast(&doc).root_location_excludes(2, 5);
321    }
322
323    #[test]
324    fn test_location_assertions_are_fluent() {
325        let location = Range::new(0..0, Position::new(0, 0), Position::new(5, 20));
326        let mut session = Session::with_title(String::new());
327        session.location = location;
328        let doc = Document {
329            annotations: Vec::new(),
330            root: session,
331        };
332
333        assert_ast(&doc)
334            .root_location_starts_at(0, 0)
335            .root_location_ends_at(5, 20)
336            .root_location_contains(2, 10)
337            .root_location_excludes(10, 0)
338            .item_count(0);
339    }
340}