Skip to main content

ferrox_models/grammar/
lazy.rs

1//! Lazy grammars: the trigger half of llama.cpp's `llama_grammar`.
2//!
3//! A port of the `lazy` / `awaiting_trigger` / `trigger_buffer` /
4//! `trigger_buffer_positions` / `trigger_tokens` / `trigger_patterns`
5//! fields of `src/llama-grammar.h`, of
6//! `llama_grammar_trigger_pattern::find`, and of the `awaiting_trigger`
7//! branch of `llama_grammar_accept_impl`. The trigger-kind mapping in
8//! [`LazyTriggers`] is `common/sampling.cpp`'s
9//! `COMMON_GRAMMAR_TRIGGER_TYPE_*` switch.
10//!
11//! # What a lazy grammar is
12//!
13//! An ordinary grammar constrains from the first token. That is wrong for
14//! a tool call: the model is allowed to say "let me look that up" before
15//! it emits one, and a grammar applied from token zero forbids the prose.
16//! A lazy grammar therefore starts **not constraining at all** -- the mask
17//! is a no-op, every token is legal, and the grammar is not advanced --
18//! and switches on when a trigger matches. This is why it is a separate
19//! mechanism rather than a flag on the sampler: the grammar's state before
20//! the trigger is not "at the start of the parse", it is "not applied".
21//!
22//! # The three things that are easy to get wrong
23//!
24//! - **What the trigger matches against.** Not the last token's piece: the
25//!   ACCUMULATED text of every token seen since generation began. A
26//!   trigger word like `<tool_call>` is several tokens, and no single
27//!   piece contains it.
28//! - **What happens to the text before the trigger.** It is NOT fed to the
29//!   grammar. The grammar is fed the buffer from the match start onward,
30//!   by replaying the buffered *tokens* whose byte spans overlap that
31//!   point -- so a token that straddles the match start is replayed with
32//!   its piece truncated to the overlapping part, and keeps its token id.
33//! - **How a trigger token differs from a trigger pattern.** A trigger
34//!   token matches one token **id**, exactly, and when it fires the whole
35//!   buffer is DISCARDED and the grammar is fed only that token. A trigger
36//!   pattern matches text, and when it fires the grammar is fed the
37//!   buffered text from the match start on. So `<tool_call>` as a single
38//!   special token and `<tool_call>` as a pattern seed the grammar
39//!   identically only because the pattern's match starts where the token
40//!   does.
41//!
42//! # Where the match starts
43//!
44//! `llama_grammar_trigger_pattern::find` returns the position of the first
45//! capture group that matched something non-empty, and the position of the
46//! whole match when there is none. That is the mechanism behind upstream's
47//! gpt-oss triggers: `<\|start\|>assistant(\s+to)` fires on the whole
48//! phrase but hands the grammar only `\s+to` onward.
49//!
50//! # Deviations from upstream, and why
51//!
52//! - Upstream matches with `std::regex` over raw `std::string` bytes.
53//!   [`fancy_regex`] -- already this crate's engine, and the one that can
54//!   compile upstream's `>>>(?!all)` -- matches over `&str`, so the buffer
55//!   is matched as its longest valid UTF-8 prefix. A token piece that ends
56//!   mid-codepoint contributes no characters until the next piece
57//!   completes it, which is the same rule the grammar machine already
58//!   applies to partial UTF-8; the one observable difference is a
59//!   `$`-anchored pattern, which sees the end of the buffer one token
60//!   earlier than upstream would when the buffer's tail is a half
61//!   character.
62//! - A trigger pattern this engine cannot compile is
63//!   [`GrammarError::TriggerPatternInvalid`], not a silently inert
64//!   grammar.
65//! - A lazy grammar with no triggers at all can never fire, which makes it
66//!   an unconstrained generation wearing a grammar. Upstream permits it;
67//!   [`crate::grammar::Grammar::into_lazy`] refuses it.
68//!
69//! # Not ported
70//!
71//! Upstream keeps `trigger_tokens` on NON-lazy grammars too, "to force
72//! printing of special trigger tokens". That is a detokenizer decision
73//! about what reaches the client, not a grammar decision about what may be
74//! sampled, and it lives in llama.cpp's server rather than in the grammar.
75//! Nothing here needs it and it is not represented.
76
77use std::fmt;
78
79use fancy_regex::Regex;
80
81use super::error::GrammarError;
82
83/// A regular expression that switches a lazy grammar on.
84///
85/// `llama_grammar_trigger_pattern`. Holds two compiled forms because
86/// upstream's `find` uses two: `std::regex_match` (the whole buffer must
87/// match) is tried first for a pattern written `^...$`, and
88/// `std::regex_search` (match anywhere) is the fallback for every pattern.
89/// The two can disagree about *which* alternative matches, and therefore
90/// about where the first capture group starts.
91#[derive(Clone)]
92pub struct TriggerPattern {
93    pattern: String,
94    search: Regex,
95    full: Option<Regex>,
96}
97
98impl TriggerPattern {
99    /// Compile `pattern`.
100    pub fn new(pattern: &str) -> Result<Self, GrammarError> {
101        let search = compile(pattern)?;
102        // `^...$` is upstream's signal to try a whole-buffer match first.
103        // `\A(?:...)\z` is that, and the non-capturing wrapper leaves
104        // every capture group's number -- and so `start_of_match` -- alone.
105        let full = if pattern.starts_with('^') && pattern.ends_with('$') {
106            Some(compile(&format!(r"\A(?:{pattern})\z"))?)
107        } else {
108            None
109        };
110        Ok(Self {
111            pattern: pattern.to_string(),
112            search,
113            full,
114        })
115    }
116
117    /// The pattern as written.
118    pub fn pattern(&self) -> &str {
119        &self.pattern
120    }
121
122    /// Where the grammar should start reading `input`, or `None` if this
123    /// pattern does not match it yet.
124    ///
125    /// `llama_grammar_trigger_pattern::find`, whose `npos` is this `None`.
126    pub fn find(&self, input: &str) -> Result<Option<usize>, GrammarError> {
127        if let Some(full) = &self.full {
128            if let Some(caps) = self.captures(full, input)? {
129                return Ok(Some(start_of_match(&caps)));
130            }
131        }
132        match self.captures(&self.search, input)? {
133            Some(caps) => Ok(Some(start_of_match(&caps))),
134            None => Ok(None),
135        }
136    }
137
138    fn captures<'t>(
139        &self,
140        re: &Regex,
141        input: &'t str,
142    ) -> Result<Option<fancy_regex::Captures<'t>>, GrammarError> {
143        re.captures(input)
144            .map_err(|e| GrammarError::TriggerPatternFailed {
145                pattern: self.pattern.clone(),
146                reason: e.to_string(),
147            })
148    }
149}
150
151impl fmt::Debug for TriggerPattern {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.debug_tuple("TriggerPattern")
154            .field(&self.pattern)
155            .finish()
156    }
157}
158
159/// Two patterns are the same trigger when they are the same source. The
160/// compiled forms are a function of it.
161impl PartialEq for TriggerPattern {
162    fn eq(&self, other: &Self) -> bool {
163        self.pattern == other.pattern
164    }
165}
166
167impl Eq for TriggerPattern {}
168
169fn compile(pattern: &str) -> Result<Regex, GrammarError> {
170    Regex::new(pattern).map_err(|e| GrammarError::TriggerPatternInvalid {
171        pattern: pattern.to_string(),
172        reason: e.to_string(),
173    })
174}
175
176/// `find_start_pos`: the first capture group that matched something
177/// non-empty, else the whole match.
178///
179/// A group that participated but matched the empty string is skipped --
180/// upstream's test is `match.length(i) > 0`, not "did it participate".
181fn start_of_match(caps: &fancy_regex::Captures<'_>) -> usize {
182    for i in 1..caps.len() {
183        if let Some(m) = caps.get(i) {
184            if m.end() > m.start() {
185                return m.start();
186            }
187        }
188    }
189    caps.get(0).map(|m| m.start()).unwrap_or(0)
190}
191
192/// The set of things that switch a lazy grammar on.
193///
194/// The constructors are `common/sampling.cpp`'s mapping from the four
195/// `COMMON_GRAMMAR_TRIGGER_TYPE_*` kinds onto the two the grammar itself
196/// knows: a word is an escaped pattern, a full pattern is an anchored one,
197/// and only tokens stay tokens.
198#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct LazyTriggers {
200    tokens: Vec<u32>,
201    patterns: Vec<TriggerPattern>,
202    mandatory: bool,
203}
204
205impl LazyTriggers {
206    pub fn new() -> Self {
207        Self::default()
208    }
209
210    /// The generation may NOT end before a trigger has fired.
211    ///
212    /// **This is not upstream.** llama.cpp's lazy grammars are always
213    /// optional: `awaiting_trigger` masks nothing, end-of-generation
214    /// included, so a model that never triggers simply produces
215    /// unconstrained text. Upstream enforces OpenAI's `tool_choice:
216    /// "required"` a different way -- with an EAGER grammar
217    /// (`grammar_lazy = false` for `COMMON_CHAT_TOOL_CHOICE_REQUIRED` in
218    /// `common/chat.cpp`) whose root IS a tool call, so the very first
219    /// token is already inside one.
220    ///
221    /// That trade does not survive this server: several checkpoint
222    /// families open a reasoning block in the PROMPT
223    /// (`ferrox_server::policy::parser::reasoning`'s `always_open`), so
224    /// the model's first token is inside `<think>`, and a grammar that
225    /// forces a tool call there produces a call that this server's own
226    /// reasoning parser then reads as thinking. Marking the trigger
227    /// mandatory keeps the prefix free -- thinking, prose, whatever the
228    /// checkpoint does -- while making the *turn* unable to end until a
229    /// call has begun, after which the grammar forces it to be complete
230    /// and schema-valid.
231    ///
232    /// The cost is stated where it is wired: a model that never begins a
233    /// call runs to `max_tokens` instead of stopping. For a caller who
234    /// said `required`, that is a visible failure rather than an answer
235    /// that quietly ignores what they asked for.
236    pub fn mandatory(mut self) -> Self {
237        self.mandatory = true;
238        self
239    }
240
241    /// Whether the generation may end before a trigger fires.
242    pub fn is_mandatory(&self) -> bool {
243        self.mandatory
244    }
245
246    /// `COMMON_GRAMMAR_TRIGGER_TYPE_TOKEN`: this token id, exactly.
247    pub fn with_token(mut self, id: u32) -> Self {
248        self.tokens.push(id);
249        self
250    }
251
252    /// `COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN`: a regex, matched anywhere in
253    /// the accumulated output.
254    pub fn with_pattern(mut self, pattern: &str) -> Result<Self, GrammarError> {
255        self.patterns.push(TriggerPattern::new(pattern)?);
256        Ok(self)
257    }
258
259    /// `COMMON_GRAMMAR_TRIGGER_TYPE_WORD`: a literal string, matched
260    /// anywhere in the accumulated output. `regex_escape(word)`.
261    pub fn with_word(mut self, word: &str) -> Result<Self, GrammarError> {
262        self.patterns
263            .push(TriggerPattern::new(&fancy_regex::escape(word))?);
264        Ok(self)
265    }
266
267    /// `COMMON_GRAMMAR_TRIGGER_TYPE_PATTERN_FULL`: a regex that must match
268    /// the whole accumulated output. Anchored the way upstream anchors it,
269    /// including its empty case.
270    pub fn with_full_pattern(mut self, pattern: &str) -> Result<Self, GrammarError> {
271        let anchored = if pattern.is_empty() {
272            "^$".to_string()
273        } else {
274            let head = if pattern.starts_with('^') { "" } else { "^" };
275            let tail = if pattern.ends_with('$') { "" } else { "$" };
276            format!("{head}{pattern}{tail}")
277        };
278        self.patterns.push(TriggerPattern::new(&anchored)?);
279        Ok(self)
280    }
281
282    /// True when nothing here could ever fire.
283    pub fn is_empty(&self) -> bool {
284        self.tokens.is_empty() && self.patterns.is_empty()
285    }
286
287    /// The trigger token ids.
288    pub fn tokens(&self) -> &[u32] {
289        &self.tokens
290    }
291
292    /// The trigger patterns.
293    pub fn patterns(&self) -> &[TriggerPattern] {
294        &self.patterns
295    }
296}
297
298/// What one observed token did to a not-yet-triggered grammar.
299#[derive(Debug, Clone, PartialEq, Eq)]
300pub enum TriggerStep {
301    /// Nothing matched. The token was buffered and the grammar is
302    /// untouched -- it is not advanced by unconstrained output.
303    Awaiting,
304    /// A trigger fired. These `(token, piece)` pairs are what the grammar
305    /// must now be fed, in order, as if they had been sampled under it.
306    /// A piece here can be a *fragment* of the token's real piece: the
307    /// token that straddles the match start is truncated to the part at or
308    /// after it.
309    Fired(Vec<(u32, Vec<u8>)>),
310}
311
312/// The lazy state carried by a grammar that is waiting for a trigger.
313///
314/// Held by [`crate::grammar::Grammar`], which is what makes the trigger
315/// check unskippable: there is one `accept_token` and it consults this.
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub struct LazyState {
318    triggers: LazyTriggers,
319    /// `awaiting_trigger`. Starts true; once false, never true again --
320    /// a grammar does not un-trigger.
321    awaiting: bool,
322    /// `trigger_buffer`. Bytes, not `String`: a BPE piece can end
323    /// mid-codepoint and the byte spans below must stay exact.
324    buffer: Vec<u8>,
325    /// `trigger_buffer_positions`: `(token, start, end)` in `buffer`.
326    spans: Vec<(u32, usize, usize)>,
327}
328
329impl LazyState {
330    /// A grammar that has not triggered yet.
331    pub fn new(triggers: LazyTriggers) -> Self {
332        Self {
333            triggers,
334            awaiting: true,
335            buffer: Vec::new(),
336            spans: Vec::new(),
337        }
338    }
339
340    /// Whether the grammar is still unapplied.
341    pub fn awaiting(&self) -> bool {
342        self.awaiting
343    }
344
345    /// The output accumulated since generation began, and not yet handed
346    /// to the grammar. Empty once a trigger has fired.
347    pub fn buffer(&self) -> &[u8] {
348        &self.buffer
349    }
350
351    /// The triggers being waited on.
352    pub fn triggers(&self) -> &LazyTriggers {
353        &self.triggers
354    }
355
356    /// Whether the generation may not end before a trigger fires. See
357    /// [`LazyTriggers::mandatory`], which is not upstream.
358    pub fn is_mandatory(&self) -> bool {
359        self.triggers.mandatory
360    }
361
362    /// Offer one sampled token to the triggers.
363    ///
364    /// The `awaiting_trigger` branch of `llama_grammar_accept_impl`. The
365    /// caller must not have applied the grammar to this token, and must
366    /// feed the grammar exactly what [`TriggerStep::Fired`] carries.
367    pub fn observe(&mut self, token: u32, piece: &[u8]) -> Result<TriggerStep, GrammarError> {
368        debug_assert!(self.awaiting, "observe on a grammar that already fired");
369
370        // A trigger TOKEN throws the buffer away: the prose before it is
371        // not part of the constrained output, and the grammar starts at
372        // the token itself.
373        if self.triggers.tokens.contains(&token) {
374            self.fire();
375            return Ok(TriggerStep::Fired(vec![(token, piece.to_vec())]));
376        }
377
378        let start = self.buffer.len();
379        self.buffer.extend_from_slice(piece);
380        self.spans.push((token, start, self.buffer.len()));
381
382        // Patterns match the whole accumulated buffer, this token
383        // included -- the trigger is a property of the output, not of the
384        // token that completed it.
385        let Some(at) = self.find_trigger()? else {
386            return Ok(TriggerStep::Awaiting);
387        };
388
389        // Replay every token whose span reaches past the match start,
390        // truncating the one that straddles it.
391        let mut replay = Vec::new();
392        for &(tok, tok_start, tok_end) in &self.spans {
393            if tok_end <= at {
394                continue;
395            }
396            let from = tok_start.max(at);
397            replay.push((tok, self.buffer[from..tok_end].to_vec()));
398        }
399        self.fire();
400        Ok(TriggerStep::Fired(replay))
401    }
402
403    /// The first pattern that matches, and where it says to start.
404    fn find_trigger(&self) -> Result<Option<usize>, GrammarError> {
405        if self.triggers.patterns.is_empty() {
406            return Ok(None);
407        }
408        // The tail of the buffer can be half a character; it is not text
409        // yet, and the token that completes it will bring it back here.
410        let text = match std::str::from_utf8(&self.buffer) {
411            Ok(s) => s,
412            Err(e) => {
413                let valid = e.valid_up_to();
414                // SAFETY-adjacent: `valid_up_to` is by definition the
415                // length of a valid prefix, so this cannot fail.
416                std::str::from_utf8(&self.buffer[..valid]).unwrap_or("")
417            }
418        };
419        for pattern in &self.triggers.patterns {
420            if let Some(at) = pattern.find(text)? {
421                return Ok(Some(at));
422            }
423        }
424        Ok(None)
425    }
426
427    fn fire(&mut self) {
428        self.awaiting = false;
429        self.buffer.clear();
430        self.spans.clear();
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    fn triggers_with(pattern: &str) -> LazyState {
439        LazyState::new(LazyTriggers::new().with_pattern(pattern).unwrap())
440    }
441
442    /// The headline rule: a trigger is matched against the ACCUMULATED
443    /// output. `<tool_call>` arrives as three pieces and no piece
444    /// contains it.
445    #[test]
446    fn a_pattern_matches_across_token_boundaries() {
447        let mut s = triggers_with("<tool_call>");
448        assert_eq!(s.observe(1, b"<tool").unwrap(), TriggerStep::Awaiting);
449        assert_eq!(s.observe(2, b"_ca").unwrap(), TriggerStep::Awaiting);
450        let TriggerStep::Fired(replay) = s.observe(3, b"ll>").unwrap() else {
451            panic!("the buffer now holds the whole trigger word");
452        };
453        assert_eq!(
454            replay,
455            vec![
456                (1, b"<tool".to_vec()),
457                (2, b"_ca".to_vec()),
458                (3, b"ll>".to_vec())
459            ]
460        );
461        assert!(!s.awaiting());
462        assert!(s.buffer().is_empty());
463    }
464
465    /// Text before the match start is dropped, and the token that
466    /// straddles the start is replayed as a FRAGMENT, keeping its id.
467    #[test]
468    fn text_before_the_trigger_is_dropped_and_the_straddling_token_is_truncated() {
469        let mut s = triggers_with("<tool_call>");
470        assert_eq!(
471            s.observe(7, b"sure, let me look").unwrap(),
472            TriggerStep::Awaiting
473        );
474        let TriggerStep::Fired(replay) = s.observe(8, b" up<tool_call>").unwrap() else {
475            panic!("trigger is complete");
476        };
477        assert_eq!(
478            replay,
479            vec![(8, b"<tool_call>".to_vec())],
480            "the prose token must not reach the grammar, and token 8 must lose its \" up\""
481        );
482    }
483
484    /// A trigger TOKEN is an id match, and it discards the buffer rather
485    /// than replaying it -- the opposite of a pattern.
486    #[test]
487    fn a_trigger_token_matches_an_id_and_discards_the_prose() {
488        let mut s = LazyState::new(LazyTriggers::new().with_token(42));
489        assert_eq!(s.observe(1, b"thinking...").unwrap(), TriggerStep::Awaiting);
490        let step = s.observe(42, b"<tool_call>").unwrap();
491        assert_eq!(
492            step,
493            TriggerStep::Fired(vec![(42, b"<tool_call>".to_vec())]),
494            "only the trigger token itself is fed to the grammar"
495        );
496    }
497
498    /// A token whose PIECE spells a trigger word is not a trigger token:
499    /// the id is what is compared.
500    #[test]
501    fn a_trigger_token_does_not_match_by_piece() {
502        let mut s = LazyState::new(LazyTriggers::new().with_token(42));
503        assert_eq!(s.observe(9, b"<tool_call>").unwrap(), TriggerStep::Awaiting);
504    }
505
506    /// `find_start_pos`: the grammar starts at the first non-empty
507    /// capture group, not at the start of the match. This is upstream's
508    /// gpt-oss trigger shape.
509    #[test]
510    fn the_grammar_starts_at_the_first_non_empty_capture_group() {
511        let mut s = triggers_with(r"<\|start\|>assistant(\s+to)");
512        let TriggerStep::Fired(replay) = s.observe(1, b"<|start|>assistant to").unwrap() else {
513            panic!("trigger matches");
514        };
515        assert_eq!(
516            replay,
517            vec![(1, b" to".to_vec())],
518            "the grammar must be fed from the capture group, not the match"
519        );
520    }
521
522    /// An empty capture group is skipped, and with no non-empty group at
523    /// all the whole match's start is used.
524    #[test]
525    fn an_empty_capture_group_is_skipped() {
526        let p = TriggerPattern::new(r"ab(x?)(c)").unwrap();
527        assert_eq!(
528            p.find("zzabc").unwrap(),
529            Some(4),
530            "group 1 matched empty, so group 2 decides"
531        );
532        let p = TriggerPattern::new(r"ab(?:c)").unwrap();
533        assert_eq!(
534            p.find("zzabc").unwrap(),
535            Some(2),
536            "no group: the match start"
537        );
538    }
539
540    /// A `^...$` pattern must match the WHOLE accumulated output.
541    #[test]
542    fn an_anchored_pattern_matches_only_the_whole_buffer() {
543        let p = TriggerPattern::new(r"^\s+to$").unwrap();
544        assert_eq!(p.find("  to").unwrap(), Some(0));
545        assert_eq!(
546            p.find("  to ").unwrap(),
547            None,
548            "a trailing space means the buffer is no longer the whole match"
549        );
550        assert_eq!(p.find("x  to").unwrap(), None);
551    }
552
553    /// A word trigger is a literal: its regex metacharacters are escaped.
554    #[test]
555    fn a_word_trigger_is_matched_literally() {
556        let t = LazyTriggers::new().with_word("[TOOL_CALLS]").unwrap();
557        let p = &t.patterns()[0];
558        assert_eq!(p.find("say [TOOL_CALLS] now").unwrap(), Some(4));
559        assert_eq!(
560            p.find("say TOOL_CALLS now").unwrap(),
561            None,
562            "unescaped, the brackets would be a character class"
563        );
564    }
565
566    /// A full pattern is anchored on both ends, once.
567    #[test]
568    fn a_full_pattern_is_anchored_at_both_ends() {
569        let t = LazyTriggers::new().with_full_pattern("to").unwrap();
570        assert_eq!(t.patterns()[0].pattern(), "^to$");
571        let t = LazyTriggers::new().with_full_pattern("^to").unwrap();
572        assert_eq!(t.patterns()[0].pattern(), "^to$");
573        let t = LazyTriggers::new().with_full_pattern("").unwrap();
574        assert_eq!(t.patterns()[0].pattern(), "^$");
575    }
576
577    /// A piece that ends mid-codepoint contributes nothing until it is
578    /// completed, and then the completed character can trigger.
579    #[test]
580    fn a_partial_codepoint_does_not_break_matching() {
581        let mut s = triggers_with("é!");
582        // "é" is \xc3\xa9, split across two pieces.
583        assert_eq!(s.observe(1, b"\xc3").unwrap(), TriggerStep::Awaiting);
584        let TriggerStep::Fired(replay) = s.observe(2, b"\xa9!").unwrap() else {
585            panic!("the character is complete now");
586        };
587        assert_eq!(
588            replay,
589            vec![(1, b"\xc3".to_vec()), (2, b"\xa9!".to_vec())],
590            "the half-character token is replayed too: it is inside the match"
591        );
592    }
593
594    /// A pattern this engine cannot compile is a refusal naming it, not a
595    /// grammar that quietly never fires.
596    #[test]
597    fn an_uncompilable_pattern_is_refused() {
598        let err = LazyTriggers::new().with_pattern("(unclosed").unwrap_err();
599        assert!(
600            matches!(err, GrammarError::TriggerPatternInvalid { .. }),
601            "{err}"
602        );
603    }
604
605    /// Upstream's functionary trigger uses a negative lookahead, which is
606    /// why this compiles patterns with `fancy_regex`.
607    #[test]
608    fn a_lookahead_pattern_compiles_and_matches() {
609        let p = TriggerPattern::new(r">>>(?!all)").unwrap();
610        assert_eq!(p.find(">>>get_weather").unwrap(), Some(0));
611        assert_eq!(p.find(">>>all").unwrap(), None);
612    }
613}
614
615/// The half of the port that lives on [`Grammar`]: what a lazy grammar
616/// does to acceptance, to end-of-generation, and to the candidate walk.
617#[cfg(test)]
618mod grammar_tests {
619    use super::*;
620    use crate::grammar::candidates::{reject_candidates, Candidate};
621    use crate::grammar::machine::Grammar;
622
623    fn lazy_grammar(src: &str, triggers: LazyTriggers) -> Grammar {
624        Grammar::from_str_with_root(src, "root")
625            .expect("grammar parses")
626            .into_lazy(triggers)
627            .expect("triggers are not empty")
628    }
629
630    /// The whole point: prose the grammar forbids passes untouched while
631    /// awaiting, and the same grammar without the trigger dies on it.
632    #[test]
633    fn prose_the_grammar_forbids_is_accepted_while_awaiting() {
634        let src = r#"root ::= "<t>" "{}""#;
635        let mut lazy = lazy_grammar(src, LazyTriggers::new().with_word("<t>").unwrap());
636        lazy.accept_token(1, b"sure!")
637            .expect("an untriggered grammar accepts anything");
638        assert!(lazy.is_awaiting_trigger());
639        assert_eq!(lazy.trigger_buffer(), b"sure!");
640
641        let mut eager = Grammar::from_str_with_root(src, "root").unwrap();
642        eager
643            .accept_token(1, b"sure!")
644            .expect_err("without a trigger the same prose kills the parse");
645    }
646
647    /// After the trigger the grammar is live and mid-parse: it has been
648    /// fed the trigger text and wants what follows it.
649    #[test]
650    fn the_replay_leaves_the_grammar_where_the_trigger_text_put_it() {
651        let mut g = lazy_grammar(
652            r#"root ::= "<t>" "{}""#,
653            LazyTriggers::new().with_word("<t>").unwrap(),
654        );
655        g.accept_token(1, b"hmm <").unwrap();
656        g.accept_token(2, b"t>").unwrap();
657        assert!(!g.is_awaiting_trigger(), "the trigger word is complete");
658        assert!(g.trigger_buffer().is_empty());
659        assert!(
660            g.accept_token(3, b"{").is_ok(),
661            "the grammar should be past \"<t>\" and expecting \"{{\""
662        );
663        g.accept_token(4, b"}").unwrap();
664        assert!(g.allows_eog(), "the parse is complete");
665    }
666
667    /// A grammar the replay cannot satisfy is a refusal, not a dead
668    /// machine that silently rejects everything afterwards.
669    #[test]
670    fn a_replay_the_grammar_rejects_is_an_error() {
671        let mut g = lazy_grammar(
672            r#"root ::= "{}""#,
673            LazyTriggers::new().with_word("<t>").unwrap(),
674        );
675        let err = g
676            .accept_token(1, b"<t>")
677            .expect_err("this grammar cannot consume its own trigger word");
678        assert!(matches!(err, GrammarError::NoViableStack { .. }), "{err}");
679    }
680
681    /// An untriggered grammar has no opinion about ending: a generation
682    /// that never calls a tool must be able to stop.
683    #[test]
684    fn an_untriggered_grammar_allows_end_of_generation() {
685        let mut g = lazy_grammar(
686            r#"root ::= "<t>" "{}""#,
687            LazyTriggers::new().with_word("<t>").unwrap(),
688        );
689        assert!(g.allows_eog());
690        assert!(g.accept_eog().is_ok());
691
692        g.accept_token(1, b"<t>").unwrap();
693        assert!(
694            !g.allows_eog(),
695            "once triggered it is an ordinary unsatisfied grammar"
696        );
697    }
698
699    /// Asking an untriggered grammar what it forbids is refused. Answering
700    /// it from the stacks would forbid the prose the trigger exists for.
701    #[test]
702    fn the_candidate_walk_refuses_an_untriggered_grammar() {
703        let g = lazy_grammar(
704            r#"root ::= "<t>" "{}""#,
705            LazyTriggers::new().with_word("<t>").unwrap(),
706        );
707        let cands = [Candidate::new(0, 0, b"zzz")];
708        let err = reject_candidates(&g, &cands).expect_err("no answer to give yet");
709        assert!(matches!(err, GrammarError::AwaitingTrigger), "{err}");
710    }
711
712    /// A lazy grammar with no triggers can never fire, so it is refused
713    /// rather than accepted as a grammar that constrains nothing.
714    #[test]
715    fn a_lazy_grammar_with_no_triggers_is_refused() {
716        let err = Grammar::from_str_with_root(r#"root ::= "a""#, "root")
717            .unwrap()
718            .into_lazy(LazyTriggers::new())
719            .expect_err("nothing could ever switch this on");
720        assert!(matches!(err, GrammarError::LazyWithoutTriggers), "{err}");
721    }
722
723    /// A grammar that is not lazy is unchanged by any of this.
724    #[test]
725    fn an_eager_grammar_is_not_awaiting_anything() {
726        let g = Grammar::from_str_with_root(r#"root ::= "a""#, "root").unwrap();
727        assert!(!g.is_lazy());
728        assert!(!g.is_awaiting_trigger());
729        assert!(g.trigger_buffer().is_empty());
730    }
731}