Skip to main content

expectation_language/
step.rs

1//! Step text to meaning. A step is either an event ("the user presses reset") or a fact about
2//! a state: `<subject> is [not] <predicate>`, where the predicate is one or more of the
3//! state's values ("off or heating") or, for numbers, a comparison ("above 55 %",
4//! "at most 10", "between 20 and 40 %", "0 %"). A Then can also be relative:
5//! `<subject> goes up by <n>`, `goes down by <n>` or `stays the same`.
6
7use expectation_model::text;
8use expectation_model::{
9    Constraint, Diagnostic, EventId, Fact, Interval, PhraseId, Said, Shift, Span, VarId, VarKind, Variable, Vocabulary,
10};
11
12pub enum Meaning {
13    Fact(Fact),
14    Event(EventId),
15    /// A relative Then.
16    Shift(VarId, Shift),
17    /// One of the suite's own phrases.
18    Phrase(PhraseId),
19}
20
21impl Meaning {
22    /// What docs need to know about it.
23    pub fn said(&self) -> Said {
24        match self {
25            Meaning::Fact(f) => Said::Fact(f.var, f.constraint),
26            Meaning::Event(e) => Said::Event(*e),
27            Meaning::Shift(v, s) => Said::Shift(*v, *s),
28            Meaning::Phrase(p) => Said::Phrase(*p),
29        }
30    }
31}
32
33/// Negated forms first, so " is not " isn't read as " is " + "not …".
34const COPULAS: &[(&str, bool)] = &[
35    (" is not ", true),
36    (" are not ", true),
37    (" isn't ", true),
38    (" aren't ", true),
39    (" doesn't have ", true),
40    (" don't have ", true),
41    (" is ", false),
42    (" are ", false),
43    (" has ", false),
44    (" have ", false),
45];
46
47pub fn resolve(raw: &str, span: &Span, vocab: &Vocabulary) -> Result<Meaning, Diagnostic> {
48    let k = text::key(raw);
49    if let Some(e) = vocab.events.iter().find(|e| e.key == k) {
50        return Ok(Meaning::Event(e.id));
51    }
52    if let Some(p) = vocab.phrases.iter().find(|p| p.key == k) {
53        return Ok(Meaning::Phrase(p.id));
54    }
55    if let Some(shift) = shift(raw, &k, span, vocab) {
56        return shift;
57    }
58    let mut best: Option<(&Variable, bool, &str)> = None;
59    for v in &vocab.variables {
60        let Some(rest) = k.strip_prefix(&v.key) else { continue };
61        let Some((neg, pred)) = COPULAS.iter().find_map(|(c, neg)| Some((*neg, rest.strip_prefix(c)?))) else {
62            continue;
63        };
64        if best.is_none_or(|(b, ..)| v.key.len() > b.key.len()) {
65            best = Some((v, neg, pred));
66        }
67    }
68    let Some((var, neg, pred)) = best else { return Err(unknown_step(raw, &k, span, vocab)) };
69    let constraint = match &var.kind {
70        VarKind::Enum(values) => enum_constraint(var, values, pred, neg),
71        VarKind::Number { min, max, unit } => number_constraint(pred, neg, *min, *max, unit),
72    }
73    .map_err(|(msg, help)| {
74        let d = Diagnostic::error("bad-value", msg).at(span, "");
75        if let Some(h) = help { d.help(h) } else { d }
76    })?;
77    Ok(Meaning::Fact(Fact { var: var.id, constraint, span: span.clone() }))
78}
79
80/// "the drawing goes up by 1", "the selected item stays the same", for the longest subject
81/// it starts with.
82fn shift(raw: &str, k: &str, span: &Span, vocab: &Vocabulary) -> Option<Result<Meaning, Diagnostic>> {
83    let (var, rest) = vocab
84        .variables
85        .iter()
86        .filter_map(|v| Some((v, k.strip_prefix(&v.key)?)))
87        .filter(|(_, rest)| rest.starts_with(" goes ") || *rest == " stays the same")
88        .max_by_key(|(v, _)| v.key.len())?;
89    if rest == " stays the same" {
90        return Some(Ok(Meaning::Shift(var.id, Shift::Same)));
91    }
92    let bad = |msg: String| Some(Err(Diagnostic::error("bad-value", msg).at(span, "")));
93    let (sign, n) = if let Some(n) = rest.strip_prefix(" goes up by ") {
94        (1.0, n)
95    } else if let Some(n) = rest.strip_prefix(" goes down by ") {
96        (-1.0, n)
97    } else {
98        return bad(format!("`{raw}`: expected `goes up by <n>` or `goes down by <n>`"));
99    };
100    let VarKind::Number { unit, .. } = &var.kind else {
101        return bad(format!("{} isn't a number, so it can't go up or down", var.subject));
102    };
103    match parse_number(n, unit) {
104        Some(v) => Some(Ok(Meaning::Shift(var.id, Shift::By(sign * v)))),
105        None => bad(format!("expected a number after `goes {} by`", if sign > 0.0 { "up" } else { "down" })),
106    }
107}
108
109type Problem = (String, Option<String>);
110
111fn enum_constraint(var: &Variable, values: &[String], pred: &str, neg: bool) -> Result<Constraint, Problem> {
112    let mut mask = 0u64;
113    for part in pred.split(" or ") {
114        let Some(i) = var.value_index(part) else {
115            let help = match text::closest(&text::normalize(part), values.iter().map(|v| v.as_str())) {
116                Some(c) => format!("did you mean `{c}`?"),
117                None => format!("{} is one of: {}", var.subject, values.join(", ")),
118            };
119            return Err((format!("`{part}` isn't a value of {}", var.subject), Some(help)));
120        };
121        mask |= 1 << i;
122    }
123    let Constraint::OneOf(all) = var.full() else { unreachable!() };
124    Ok(Constraint::OneOf(if neg { all & !mask } else { mask }))
125}
126
127fn number_constraint(pred: &str, neg: bool, min: f64, max: f64, unit: &Option<String>) -> Result<Constraint, Problem> {
128    if neg {
129        return Err((
130            "a number can't be negated".into(),
131            Some("say `above`, `below`, `at least` or `at most` instead".into()),
132        ));
133    }
134    let num = |s: &str| -> Result<f64, Problem> {
135        parse_number(s, unit).ok_or_else(|| {
136            let expected = match unit {
137                Some(u) => format!("a number, optionally followed by `{u}`"),
138                None => "a number".into(),
139            };
140            (format!("expected {expected}, found `{s}`"), None)
141        })
142    };
143    let range = if let Some(rest) = pred.strip_prefix("between ") {
144        let (a, b) = rest.split_once(" and ").ok_or_else(|| ("expected `between <a> and <b>`".to_string(), None))?;
145        Interval::closed(num(a)?, num(b)?)
146    } else if let Some(v) = strip_any(pred, &["above ", "over ", "more than ", "greater than ", "higher than "]) {
147        Interval { lo: num(v)?, lo_open: true, hi: max, hi_open: false }
148    } else if let Some(v) = strip_any(pred, &["below ", "under ", "less than ", "fewer than ", "lower than "]) {
149        Interval { lo: min, lo_open: false, hi: num(v)?, hi_open: true }
150    } else if let Some(v) = pred.strip_prefix("at least ") {
151        Interval { lo: num(v)?, lo_open: false, hi: max, hi_open: false }
152    } else if let Some(v) = pred.strip_prefix("at most ") {
153        Interval { lo: min, lo_open: false, hi: num(v)?, hi_open: false }
154    } else {
155        Interval::point(num(pred)?)
156    };
157    let clipped = range.intersect(&Interval::closed(min, max));
158    if clipped.is_empty() {
159        let u = unit.as_deref().map(|u| format!(" {u}")).unwrap_or_default();
160        return Err((format!("no value in {}..{}{u} satisfies this", text::number(min), text::number(max)), None));
161    }
162    Ok(Constraint::Range(clipped))
163}
164
165fn strip_any<'a>(s: &'a str, prefixes: &[&str]) -> Option<&'a str> {
166    prefixes.iter().find_map(|p| s.strip_prefix(p))
167}
168
169/// "55", "55%", "55 %", "-3.5 m".
170fn parse_number(s: &str, unit: &Option<String>) -> Option<f64> {
171    let s = s.trim();
172    let end = s.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+'))).unwrap_or(s.len());
173    let v: f64 = s[..end].parse().ok()?;
174    let rest = s[end..].trim();
175    // Units match either way round in number: "1 item" and "3 items" are both items.
176    let singular = |s: &str| text::normalize(s).trim_end_matches('s').to_string();
177    let unit_ok = rest.is_empty() || unit.as_deref().is_some_and(|u| singular(u) == singular(rest));
178    unit_ok.then_some(v)
179}
180
181fn unknown_step(raw: &str, k: &str, span: &Span, vocab: &Vocabulary) -> Diagnostic {
182    let d = Diagnostic::error("unknown-step", format!("`{raw}` isn't in the vocabulary")).at(span, "");
183    let subject = COPULAS.iter().find_map(|(c, _)| k.split_once(c).map(|(s, _)| s));
184    let suggestion = match subject {
185        Some(s) => text::closest(s, vocab.variables.iter().map(|v| v.key.as_str()))
186            .and_then(|key| vocab.variables.iter().find(|v| v.key == key))
187            .map(|v| format!("did you mean `{}`? It {}", v.subject, describe_kind(v))),
188        None => text::closest(k, vocab.events.iter().map(|e| e.key.as_str()))
189            .and_then(|key| vocab.events.iter().find(|e| e.key == key))
190            .map(|e| format!("did you mean the event `{}`?", e.text)),
191    };
192    d.help(suggestion.unwrap_or_else(|| "declare it in a `Vocabulary:` block, with `State:` or `Event:`".into()))
193}
194
195fn describe_kind(v: &Variable) -> String {
196    match &v.kind {
197        VarKind::Enum(values) => format!("is {}", values.join(" | ")),
198        VarKind::Number { min, max, unit } => {
199            format!(
200                "is a number in {}..{}{}",
201                text::number(*min),
202                text::number(*max),
203                unit.as_deref().map(|u| format!(" {u}")).unwrap_or_default()
204            )
205        }
206    }
207}
208
209/// `State: <subject> is <a> | <b> | …` or `State: <subject> is <min>..<max> [unit]`.
210pub fn declare_state(decl: &str, span: &Span, id: VarId) -> Result<Variable, Diagnostic> {
211    let bad = |msg: &str| {
212        Diagnostic::error("bad-state", msg.to_string())
213            .at(span, "")
214            .help("write `State: the kettle is off | heating` or `State: the dial is 0..100 %`")
215    };
216    let (at, len) = [" is ", " are ", " has ", " have "]
217        .iter()
218        .filter_map(|c| decl.find(c).map(|i| (i, c.len())))
219        .min()
220        .ok_or_else(|| bad("expected `<subject> is <values>`"))?;
221    let subject = decl[..at].trim().to_string();
222    let values = decl[at + len..].trim();
223    let kind = if let Some((lo, hi)) = values.split_once("..") {
224        let lo: f64 = lo.trim().parse().map_err(|_| bad("expected a number before `..`"))?;
225        let hi = hi.trim();
226        let end = hi.find(|c: char| !(c.is_ascii_digit() || matches!(c, '.' | '-' | '+'))).unwrap_or(hi.len());
227        let max: f64 = hi[..end].parse().map_err(|_| bad("expected a number after `..`"))?;
228        let unit = Some(hi[end..].trim().to_string()).filter(|u| !u.is_empty());
229        if lo >= max {
230            return Err(bad("the range is empty"));
231        }
232        VarKind::Number { min: lo, max, unit }
233    } else {
234        let names: Vec<String> = values.split('|').map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).collect();
235        if names.len() < 2 {
236            return Err(bad("a state needs at least two values, separated by `|`"));
237        }
238        if names.len() > expectation_model::vocabulary::MAX_VALUES {
239            return Err(bad("a state can have at most 64 values"));
240        }
241        for (i, n) in names.iter().enumerate() {
242            if names[..i].iter().any(|m| text::normalize(m) == text::normalize(n)) {
243                return Err(bad(&format!("`{n}` is listed twice")));
244            }
245            if n.contains(" or ") {
246                return Err(bad(&format!("`{n}` contains ` or `, which steps use to list values")));
247            }
248        }
249        VarKind::Enum(names)
250    };
251    let verb = decl[at + 1..at + len - 1].to_string();
252    Ok(Variable { id, key: text::key(&subject), subject, kind, verb, span: span.clone(), set: None, shows: vec![] })
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use expectation_model::Event;
259
260    fn vocab() -> Vocabulary {
261        let span = Span::new("t", 1);
262        let kettle = declare_state("the kettle is off | heating | dry", &span, VarId(0)).ok().unwrap();
263        let dial = declare_state("the dial is 0..100 %", &span, VarId(1)).ok().unwrap();
264        let reset = Event {
265            id: EventId(0),
266            text: "the user presses reset".into(),
267            key: text::key("the user presses reset"),
268            span: span.clone(),
269            say: None,
270        };
271        Vocabulary { variables: vec![kettle, dial], events: vec![reset], phrases: vec![] }
272    }
273
274    fn fact(s: &str) -> Constraint {
275        match resolve(s, &Span::new("t", 1), &vocab()) {
276            Ok(Meaning::Fact(f)) => f.constraint,
277            Ok(Meaning::Event(_) | Meaning::Shift(..) | Meaning::Phrase(_)) => panic!("{s}: not a fact"),
278            Err(d) => panic!("{s}: {}", d.message),
279        }
280    }
281
282    #[test]
283    fn enum_facts() {
284        assert_eq!(fact("the kettle is heating"), Constraint::OneOf(0b010));
285        assert_eq!(fact("The Kettle is Off"), Constraint::OneOf(0b001));
286        assert_eq!(fact("the kettle is not dry"), Constraint::OneOf(0b011));
287        assert_eq!(fact("kettle is heating or dry"), Constraint::OneOf(0b110));
288    }
289
290    #[test]
291    fn number_facts() {
292        let r = |lo, lo_open, hi, hi_open| Constraint::Range(Interval { lo, lo_open, hi, hi_open });
293        assert_eq!(fact("the dial is above 55 %"), r(55.0, true, 100.0, false));
294        assert_eq!(fact("the dial is 0%"), r(0.0, false, 0.0, false));
295        assert_eq!(fact("the dial is at most 10"), r(0.0, false, 10.0, false));
296        assert_eq!(fact("the dial is between 20 and 40 %"), r(20.0, false, 40.0, false));
297    }
298
299    #[test]
300    fn has_reads_like_has() {
301        let span = Span::new("t", 1);
302        let plan = declare_state("the drawing has 0..100 shapes", &span, VarId(0)).ok().unwrap();
303        let v = Vocabulary { variables: vec![plan], events: vec![], phrases: vec![] };
304        let c = |s: &str| match resolve(s, &span, &v) {
305            Ok(Meaning::Fact(f)) => f.constraint,
306            _ => panic!("{s}"),
307        };
308        let p = &v.variables[0];
309        assert_eq!(p.describe(&c("the drawing has 1 shape")), "the drawing has 1 shape");
310        assert_eq!(p.describe(&c("the drawing has more than 3 shapes")), "the drawing has more than 3 shapes");
311        assert_eq!(p.describe(&c("the drawing has at least 1 shape")), "the drawing has at least 1 shape");
312    }
313
314    #[test]
315    fn relative_thens() {
316        let v = vocab();
317        let span = Span::new("t", 1);
318        let shift = |s: &str| match resolve(s, &span, &v) {
319            Ok(Meaning::Shift(_, shift)) => Ok(shift),
320            Ok(_) => panic!("{s}: not a shift"),
321            Err(d) => Err(d.code),
322        };
323        assert_eq!(shift("the dial goes up by 10 %"), Ok(Shift::By(10.0)));
324        assert_eq!(shift("the dial goes down by 5"), Ok(Shift::By(-5.0)));
325        assert_eq!(shift("the kettle stays the same"), Ok(Shift::Same));
326        assert_eq!(shift("the kettle goes up by 1"), Err("bad-value"));
327    }
328
329    #[test]
330    fn events_and_errors() {
331        let v = vocab();
332        let span = Span::new("t", 1);
333        assert!(matches!(resolve("The user presses reset.", &span, &v), Ok(Meaning::Event(EventId(0)))));
334        let err = |s| resolve(s, &span, &v).err().unwrap();
335        assert_eq!(err("the kettle is boiling").code, "bad-value");
336        assert!(err("the kettel is heating").help.unwrap().contains("the kettle"));
337        assert!(err("the user presses rest").help.unwrap().contains("reset"));
338        assert_eq!(err("the dial is above 120 %").code, "bad-value");
339        assert_eq!(err("the dial is not 5").code, "bad-value");
340    }
341}