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}