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}