Skip to main content

expectation_language/
lib.rs

1//! The `.expect` file format: a Gherkin-flavoured language for a suite's vocabulary,
2//! expectations and user stories.
3//!
4//! ```text
5//! Suite: Kettle
6//!
7//! Vocabulary:
8//!   State: the kettle is off | heating | dry
9//!   State: the dial is 0..100 %
10//!   Event: the user presses reset
11//!
12//! Background:
13//!   Given the kettle is off
14//!
15//! Feature: Heating
16//!   Expectation: Turning the dial up starts the kettle
17//!     Given the kettle is off
18//!     When the dial is above 55 %
19//!     Then the kettle is heating
20//!
21//! Story: First cup
22//!   As a tea drinker
23//!   I want to start the kettle by turning the dial
24//!
25//!   Scenario: Turn it on
26//!     When the dial is 80 %
27//!     Then the kettle is heating
28//! ```
29//!
30//! [`load_dir`] reads every `*.expect` file under a directory into one suite;
31//! [`load_sources`] does the same from strings.
32
33mod lens;
34mod parse;
35mod resolve;
36pub mod step;
37
38use expectation_model::{Diagnostic, Suite};
39use std::path::{Path, PathBuf};
40
41pub const EXTENSION: &str = "expect";
42
43/// A suite and everything wrong with it. Check [`Loaded::has_errors`] before using the suite:
44/// steps that didn't resolve are left out of it.
45pub struct Loaded {
46    pub suite: Suite,
47    pub diagnostics: Vec<Diagnostic>,
48}
49
50impl Loaded {
51    pub fn has_errors(&self) -> bool {
52        expectation_model::diagnostic::has_errors(&self.diagnostics)
53    }
54
55    pub fn render_diagnostics(&self) -> String {
56        expectation_model::diagnostic::render_all(&self.diagnostics, &self.suite.sources)
57    }
58}
59
60/// Parse named sources, in order, as one suite (for its default platform).
61pub fn load_sources(name: &str, sources: &[(&str, &str)]) -> Loaded {
62    load_sources_for(name, sources, None)
63}
64
65/// As [`load_sources`], read for `platform` (see `lens`): its variants replace the plain lines
66/// and what is `On:` elsewhere is left out. `None` is the default platform.
67pub fn load_sources_for(name: &str, sources: &[(&str, &str)], platform: Option<&str>) -> Loaded {
68    let mut diags = vec![];
69    let mut suite = Suite { name: name.to_string(), ..Suite::default() };
70    let mut files = vec![];
71    for (file, text) in sources {
72        let mut raw = parse::parse(file, text, &mut diags);
73        raw.dir = file.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default();
74        files.push(raw);
75        suite.sources.insert((*file).into(), text.to_string());
76    }
77    (suite.platforms, suite.platform) = lens::apply(&mut files, platform, &mut diags);
78    resolve::resolve(&mut suite, &files, &mut diags);
79    Loaded { suite, diagnostics: diags }
80}
81
82/// Every `*.expect` file under `dir` (recursively, in path order) as one suite, named by its
83/// `Suite:` line or else after the directory. File names in diagnostics are relative to `dir`.
84pub fn load_dir(dir: &Path) -> std::io::Result<Loaded> {
85    load_dir_for(dir, None)
86}
87
88/// As [`load_dir`], read for `platform`.
89pub fn load_dir_for(dir: &Path, platform: Option<&str>) -> std::io::Result<Loaded> {
90    let mut paths = vec![];
91    collect(dir, &mut paths)?;
92    paths.sort();
93    let mut texts = vec![];
94    for p in &paths {
95        let rel = p.strip_prefix(dir).unwrap_or(p).to_string_lossy().replace('\\', "/");
96        texts.push((rel, std::fs::read_to_string(p)?));
97    }
98    let refs: Vec<(&str, &str)> = texts.iter().map(|(a, b)| (a.as_str(), b.as_str())).collect();
99    let mut loaded = load_sources_for(&suite_name(dir), &refs, platform);
100    if paths.is_empty() {
101        loaded
102            .diagnostics
103            .push(Diagnostic::error("empty-suite", format!("no .{EXTENSION} files in {}", dir.display())));
104    }
105    Ok(loaded)
106}
107
108fn collect(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
109    for entry in std::fs::read_dir(dir)? {
110        let p = entry?.path();
111        if p.is_dir() {
112            collect(&p, out)?;
113        } else if p.extension().is_some_and(|e| e == EXTENSION) {
114            out.push(p);
115        }
116    }
117    Ok(())
118}
119
120/// "child_lock" -> "Child lock".
121fn suite_name(dir: &Path) -> String {
122    let base = dir.canonicalize().ok().and_then(|d| d.file_name().map(|n| n.to_string_lossy().into_owned()));
123    let base = base.unwrap_or_else(|| "Expectations".into()).replace(['_', '-'], " ");
124    let mut c = base.chars();
125    c.next().map(|f| f.to_uppercase().chain(c).collect()).unwrap_or_default()
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use expectation_model::{Constraint, ExpId, Keyword};
132
133    const SUITE: &str = "
134Suite: Kettle basics
135
136Vocabulary:
137  State: the kettle is off | heating | dry
138  State: the dial is 0..100 %
139  State: the child lock is on | off
140  Event: the user presses reset
141
142Background:
143  Given the kettle is off
144  And the dial is 0 %
145
146Feature: Heating
147  The kettle starts off.
148
149  Expectation: Turning the dial up starts the kettle
150    Given the kettle is off
151    When the dial is above 55 %
152    Then the kettle is heating
153
154  Expectation: The child lock holds the kettle
155    Overrides: Turning the dial up starts the kettle
156    Given the kettle is off
157    And the child lock is on
158    When the dial is above 55 %
159    Then the kettle is off
160
161  Expectation: Never dry while off
162    Given the kettle is off
163    Then the kettle is not dry
164
165Story: First cup
166  As a tea drinker
167  I want to boil the kettle
168
169  Given the dial is 0 %
170  When the dial is 80 %
171  Then the kettle is heating
172  When the dial is 0 %
173  And the user presses reset
174  Then the kettle is off
175";
176
177    #[test]
178    fn parses_a_suite() {
179        let l = load_sources("t", &[("t.expect", SUITE)]);
180        assert!(!l.has_errors(), "{}", l.render_diagnostics());
181        let s = &l.suite;
182        assert_eq!(s.name, "Kettle basics");
183        assert_eq!(s.vocabulary.variables.len(), 3);
184        assert_eq!(s.background.vars().count(), 2);
185        assert_eq!(s.features[0].description, vec!["The kettle starts off."]);
186        let [start, lock, never] = &s.expectations[..] else { panic!() };
187        assert!(start.trigger.event.is_none() && start.trigger.changes.vars().count() == 1);
188        assert_eq!(lock.overrides, vec![start.id]);
189        assert!(never.is_invariant());
190        assert_eq!(never.then.get(expectation_model::VarId(0)), Some(&Constraint::OneOf(0b011)));
191
192        let story = &s.stories[0];
193        assert_eq!(story.narrative.len(), 2);
194        let sc = &story.scenarios[0];
195        assert_eq!(sc.name, "First cup");
196        assert_eq!(sc.steps.len(), 2);
197        assert!(sc.steps[1].action.event.is_some() && sc.steps[1].action.changes.vars().count() == 1);
198        assert_eq!(sc.steps[1].steps[1].keyword, Keyword::And);
199    }
200
201    #[test]
202    fn sections_nest_by_directory_and_layer_their_setup() {
203        let vocab = "Suite: Sketch\nVocabulary:\n State: the tool is none | PEN\n State: the drawing has 0..100 shapes\n Event: the user presses PEN\n Event: the user clicks the canvas\nBackground:\n Given the tool is none\n And the drawing has 0 shapes\n";
204        let tools = "Feature: Tools\n Expectation: PEN picks the pen\n  Given the tool is none\n  When the user presses PEN\n  Then the tool is PEN\n";
205        let drawing = "Section: Drawing\n Everything here starts with the pen.\nBackground:\n Given the tool is PEN\nFeature: Shapes\n Expectation: Clicking adds a shape\n  Given the tool is PEN\n  When the user clicks the canvas\n  Then the drawing goes up by 1\n";
206        let l = load_sources(
207            "x",
208            &[("root.expect", vocab), ("tools.expect", tools), ("03-drawing/shapes.expect", drawing)],
209        );
210        assert!(!l.has_errors(), "{}", l.render_diagnostics());
211        let s = &l.suite;
212        assert_eq!(s.sections.len(), 2);
213        let (root, drawing) = (&s.sections[0], &s.sections[1]);
214        assert_eq!((root.name.as_str(), drawing.name.as_str()), ("Sketch", "Drawing"));
215        assert_eq!(drawing.slug, "drawing");
216        assert_eq!(drawing.parent, Some(root.id));
217        assert_eq!(drawing.description, vec!["Everything here starts with the pen."]);
218        // The drawing section starts from the root's start with its own setup on top.
219        assert_eq!(s.describe(&drawing.start), "the tool is PEN and the drawing has 0 shapes");
220        assert_eq!(s.feature(s.exp(ExpId(1)).feature).section, drawing.id);
221        assert_eq!(s.exp(ExpId(1)).shifts, vec![(expectation_model::VarId(1), expectation_model::Shift::By(1.0))]);
222    }
223
224    #[test]
225    fn phrases_say_more_with_less() {
226        let src = "Vocabulary:
227 State: the tool is pan | PEN
228   Set: pick the {value} tool
229 State: the drawing has 0..100 shapes
230 Event: the user presses PEN
231   Say: press PEN
232 Event: the user clicks the canvas
233 Phrase: the user picks the pen
234   Say: press PEN
235   When the user presses PEN
236 Phrase: the user draws two shapes
237   Say: click the canvas twice
238   When the user clicks the canvas
239   When the user clicks the canvas
240 Phrase: drawing has started
241   Given the tool is PEN
242   And the drawing has 0 shapes
243Feature: F
244 Expectation: A click adds a shape
245  Given drawing has started
246  When the user clicks the canvas
247  Then the drawing has 1 shape
248Story: S
249 As an artist
250 I want to draw
251 Drawing starts on a blank canvas.
252 When the user picks the pen
253 When the user draws two shapes
254 Then the drawing has 2 shapes
255";
256        let l = load_sources("t", &[("t.expect", src)]);
257        assert!(!l.has_errors(), "{}", l.render_diagnostics());
258        let s = &l.suite;
259        assert_eq!(s.vocabulary.events[0].say.as_deref(), Some("press PEN"));
260        assert_eq!(s.vocabulary.variables[0].set.as_deref(), Some("pick the {value} tool"));
261        assert_eq!(s.vocabulary.phrases.len(), 3);
262        // A setup phrase in a Given is its facts.
263        assert_eq!(s.exp(ExpId(0)).given.vars().count(), 2);
264        let story = &s.stories[0];
265        assert_eq!(story.narrative.len(), 2);
266        assert_eq!(story.description, vec!["Drawing starts on a blank canvas."]);
267        // Two written steps make three actions: the two-click phrase is one group of two.
268        let steps = &story.scenarios[0].steps;
269        assert_eq!(steps.iter().map(|st| st.group).collect::<Vec<_>>(), vec![1, 2, 2]);
270        assert_eq!(steps[1].written.len(), 2, "the When and the Then, as written");
271        assert!(steps[2].written.is_empty());
272        assert_eq!(steps[2].checks.len(), 1, "the Then checks after the last click");
273        assert!(matches!(steps[1].written[0].meaning, Some(expectation_model::Said::Phrase(_))));
274    }
275
276    #[test]
277    fn docs_lines_say_what_the_reader_sees() {
278        let src = "Vocabulary:
279 State: the kettle is off | dry
280   Shows: dry: the display says DRY
281 State: the drawing has 0..650 shapes
282   Shows: the counter reads {value} SHAPES
283 State: the ink is 0..100 %
284   Shows: at least 99 %: the ink is full
285 Event: the user starts the kettle
286Term: display
287  The panel on the front of the kettle.
288Feature: F
289  When the kettle is off, Space starts it.
290  Expectation: E
291    Docs: hidden
292    Given the kettle is off
293    When the user starts the kettle
294    Then the kettle is dry
295";
296        let l = load_sources("t", &[("t.expect", src)]);
297        assert!(!l.has_errors(), "{}", l.render_diagnostics());
298        let s = &l.suite;
299        let v = &s.vocabulary;
300        let shown = |step: &str| match step::resolve(step, &expectation_model::Span::new("t", 1), v) {
301            Ok(step::Meaning::Fact(f)) => v.var(f.var).shown(&f.constraint),
302            _ => panic!("{step} isn't a state"),
303        };
304        assert_eq!(shown("the kettle is dry").as_deref(), Some("the display says DRY"));
305        assert_eq!(shown("the kettle is off"), None);
306        assert_eq!(shown("the drawing has 3 shapes").as_deref(), Some("the counter reads 3 SHAPES"));
307        assert_eq!(shown("the ink is at least 99 %").as_deref(), Some("the ink is full"));
308        assert_eq!(s.glossary[0].name, "display");
309        // A "When" line in a feature's description is prose: features hold no steps.
310        assert_eq!(s.features[0].description, vec!["When the kettle is off, Space starts it."]);
311        assert!(s.expectations[0].hidden);
312    }
313
314    const PLATFORMS: &str = "Suite: Sketch
315Vocabulary:
316  Platform: desktop | iOS
317  State: the door is locked | unlocked
318    Shows: unlocked: the UNLOCKED light comes on
319    Shows[iOS]: unlocked: the UNLOCKED badge turns green
320  State: the tool is pan | PEN
321  Event: the user unlocks the door
322    Say: press UNLOCK
323    Say[iOS]: tap UNLOCK
324  Phrase: the app is open
325    Say: open it on the Mac
326    Given the door is locked
327    And the tool is pan
328  Phrase[iOS]: the app is open
329    Say: open the app on the phone
330    Given the door is locked
331Background:
332  Given the app is open
333
334Feature: Unlocking
335  Expectation: UNLOCK unlocks
336    Given the door is locked
337    When the user unlocks the door
338    Then the door is unlocked
339
340Feature: Drawing
341  On: desktop
342  Expectation: PEN picks the pen
343    Given the tool is pan
344    When the user unlocks the door
345    Then the tool is PEN
346";
347
348    #[test]
349    fn a_platform_reads_its_own_variants() {
350        let sources = [("s.expect", PLATFORMS)];
351        let desktop = load_sources_for("s", &sources, None);
352        assert!(!desktop.has_errors(), "{}", desktop.render_diagnostics());
353        let s = &desktop.suite;
354        assert_eq!((s.platforms.clone(), s.platform.as_deref()), (vec!["desktop".into(), "iOS".into()], Some("desktop")));
355        assert_eq!(s.vocabulary.events[0].say.as_deref(), Some("press UNLOCK"));
356        assert_eq!(s.expectations.len(), 2);
357        assert_eq!(s.background.vars().count(), 2, "the plain phrase sets the tool too");
358
359        let ios = load_sources_for("s", &sources, Some("iOS"));
360        assert!(!ios.has_errors(), "{}", ios.render_diagnostics());
361        let s = &ios.suite;
362        assert_eq!(s.platform.as_deref(), Some("iOS"));
363        assert_eq!(s.vocabulary.events[0].say.as_deref(), Some("tap UNLOCK"));
364        assert_eq!(s.vocabulary.phrases.len(), 1, "one `the app is open`, the iOS one");
365        assert_eq!(s.vocabulary.phrases[0].say.as_deref(), Some("open the app on the phone"));
366        assert_eq!(s.background.vars().count(), 1, "iOS has no tool in its start");
367        assert_eq!(s.expectations.len(), 1, "Drawing is on desktop only");
368        let shown = s.vocabulary.variables[0].shows.iter().map(|(_, t)| t.as_str()).collect::<Vec<_>>();
369        assert_eq!(shown, vec!["the UNLOCKED badge turns green"]);
370    }
371
372    #[test]
373    fn platform_mistakes_are_reported() {
374        let vocab = "Vocabulary:\n Platform: desktop | iOS\n State: the door is locked | unlocked\n Event: the user unlocks the door\n";
375        let with = |rest: &str| format!("{vocab}{rest}");
376        assert_eq!(errors(&with("Event: x\n Say[watch]: tap\n")), vec!["unknown-platform"]);
377        assert_eq!(errors(&with("Event: y\n Say[desktop]: tap\n")), vec!["default-platform"]);
378        assert_eq!(errors(&with("Feature: F\n On: Android\n")), vec!["unknown-platform"]);
379        assert_eq!(errors("Feature: F\n On: iOS\n"), vec!["unknown-platform"]);
380        let l = load_sources_for("t", &[("t.expect", vocab)], Some("Android"));
381        assert_eq!(l.diagnostics.iter().map(|d| d.code).collect::<Vec<_>>(), vec!["unknown-platform"]);
382    }
383
384    fn errors(src: &str) -> Vec<&'static str> {
385        let l = load_sources("t", &[("t.expect", src)]);
386        l.diagnostics.iter().filter(|d| d.is_error()).map(|d| d.code).collect()
387    }
388
389    #[test]
390    fn reports_mistakes() {
391        let vocab = "Vocabulary:\n State: the kettle is off | heating\n Event: the user presses reset\n";
392        let with = |rest: &str| format!("{vocab}{rest}");
393        assert_eq!(errors(&with("Feature: F\n Expectation: E\n  Given the kettle is heating\n")), vec!["no-then"]);
394        assert_eq!(errors(&with("Feature: F\n Expectation: E\n  Then the kettle is boiling\n")), vec!["bad-value"]);
395        assert_eq!(errors(&with("Feature: F\n Expectation: E\n  Then the teapot is heating\n")), vec!["unknown-step"]);
396        assert_eq!(
397            errors(&with(
398                "Feature: F\n Expectation: E\n  Given the kettle is heating\n  And the kettle is off\n  Then the kettle is heating\n"
399            )),
400            vec!["contradiction"]
401        );
402        assert_eq!(
403            errors(&with("Feature: F\n Expectation: E\n  Then the user presses reset\n")),
404            vec!["misplaced-event"]
405        );
406        assert_eq!(
407            errors(&with("Feature: F\n Expectation: E\n  Overrides: Nope\n  Then the kettle is heating\n")),
408            vec!["bad-override"]
409        );
410        assert_eq!(errors(&with("Expectation: E\n")), vec!["syntax"]);
411        assert_eq!(errors(&with("Story: S\n Then the kettle is heating\n")), vec!["syntax", "empty-story"]);
412        let vacuous = load_sources(
413            "t",
414            &[(
415                "t.expect",
416                &with(
417                    "Feature: F\n Expectation: E\n  When the kettle is off or heating\n  Then the kettle is heating\n",
418                ),
419            )],
420        );
421        assert_eq!(vacuous.diagnostics.iter().map(|d| d.code).collect::<Vec<_>>(), vec!["says-nothing"]);
422        let typo =
423            load_sources("t", &[("t.expect", &with("Feature: F\n Expectation: E\n  Gvien the kettle is heating\n"))]);
424        let codes: Vec<_> = typo.diagnostics.iter().map(|d| d.code).collect();
425        assert_eq!(codes, vec!["maybe-step", "no-then"]);
426    }
427}