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}