timed_fsm/parser.rs
1//! Shift-reduce parser framework with timer support.
2//!
3//! A streaming parser that receives tokens one at a time and produces
4//! output actions. Tokens can be buffered (Shift), recognized as patterns
5//! (Reduce), or passed through. Timers enable "timeout = forced reduce"
6//! semantics.
7//!
8//! # What is a shift-reduce parser?
9//!
10//! A shift-reduce parser builds up a stack (or buffer) of tokens and
11//! only decides how to interpret them once it has seen enough context.
12//! This is necessary whenever the correct interpretation of a token
13//! depends on tokens that have **not arrived yet**.
14//!
15//! Classic example: the first key of a two-key chord. When you see key A,
16//! you do not know yet whether:
17//! - The user intends a chord `A+B` (requires waiting for B), or
18//! - The user intends a single-key stroke `A` (requires a timeout).
19//!
20//! A plain [`TimedStateMachine`](crate::TimedStateMachine) handles one
21//! token per transition. This module's [`ShiftReduceParser`] lets the
22//! grammar say "buffer this token and wait" (`Shift`) or "I have
23//! recognized a complete pattern" (`Reduce`), with a timer forcing a
24//! `Reduce` when no further token arrives in time.
25//!
26//! # When to use `ShiftReduceParser` vs `TimedStateMachine`
27//!
28//! | Scenario | Use |
29//! |----------|-----|
30//! | Each event is self-contained (debounce, key repeat) | [`TimedStateMachine`](crate::TimedStateMachine) |
31//! | A pattern spans multiple tokens (chord, double-click) | [`ShiftReduceParser`] |
32//! | You need to re-process a token after a partial reduce | [`ShiftReduceParser`] with [`ReduceAndContinue`](ParseAction::ReduceAndContinue) |
33//!
34//! # Example: two-key chord detection
35//!
36//! The following sketch detects a simultaneous press of keys `A` and `B`
37//! within a 50 ms window and emits a `Chord` action; a single `A` or `B`
38//! press outside the window passes through.
39//!
40//! ```
41//! use std::time::Duration;
42//! use timed_fsm::parser::{ShiftReduceParser, ParseAction};
43//! use timed_fsm::TimerCommand;
44//!
45//! #[derive(Clone, Copy, Debug, PartialEq)]
46//! enum Key { A, B, Other(u8) }
47//!
48//! #[derive(Debug, PartialEq)]
49//! enum Out { Chord, Key(Key) }
50//!
51//! struct ChordParser { pending: Option<Key> }
52//! impl ChordParser {
53//! fn new() -> Self { Self { pending: None } }
54//! }
55//!
56//! impl ShiftReduceParser for ChordParser {
57//! type Action = Out;
58//! type Token = Key;
59//! type TimerId = ();
60//! type ReduceRecord = ();
61//!
62//! fn decide(&mut self, token: &Key)
63//! -> ParseAction<Out, Key, (), ()>
64//! {
65//! match (self.pending, token) {
66//! // No pending key yet — shift and open a 50 ms chord window.
67//! (None, &k @ (Key::A | Key::B)) => {
68//! self.pending = Some(k);
69//! ParseAction::Shift {
70//! timers: vec![TimerCommand::Set {
71//! id: (),
72//! duration: Duration::from_millis(50),
73//! }],
74//! }
75//! }
76//! // Second key arrived inside the window — chord recognized.
77//! (Some(Key::A), Key::B) | (Some(Key::B), Key::A) => {
78//! self.pending = None;
79//! ParseAction::Reduce {
80//! actions: vec![Out::Chord],
81//! record: (),
82//! timers: vec![TimerCommand::Kill { id: () }],
83//! }
84//! }
85//! // Unrelated key — pass through.
86//! _ => ParseAction::PassThrough { timers: vec![] },
87//! }
88//! }
89//!
90//! fn on_reduce(&mut self, (): ()) {}
91//! }
92//!
93//! // Both keys pressed in sequence → chord.
94//! let mut p = ChordParser::new();
95//! let _ = timed_fsm::parse(&mut p, Key::A); // Shift
96//! let r = timed_fsm::parse(&mut p, Key::B); // Reduce → Chord
97//! assert_eq!(r.actions, vec![Out::Chord]);
98//! ```
99
100use crate::response::{Response, TimerCommand};
101
102/// Parser action: the result of examining a token in the current state.
103///
104/// Returned by [`ShiftReduceParser::decide`] and consumed by the [`parse`]
105/// driver loop.
106///
107/// | Variant | Loop continues? | Actions accumulated? |
108/// |---------|----------------|----------------------|
109/// | `Shift` | No (terminal) | No — wait for more input |
110/// | `Reduce` | No (terminal) | Yes — emit and finish |
111/// | `ReduceAndContinue` | Yes — re-process `remaining` | Yes — partial emit, then loop |
112/// | `PassThrough` | No (terminal) | No (or yes if prior reduces accumulated some) |
113#[derive(Debug)]
114pub enum ParseAction<A, Token, T, R = ()> {
115 /// Buffer the token and wait for more input.
116 ///
117 /// The parser absorbs the token into its internal buffer (if any) and
118 /// sets a timer to force a decision if no further token arrives in time.
119 /// The [`parse`] loop returns immediately — the token is consumed.
120 Shift {
121 /// Timer commands to execute (typically a `Set` to open the chord window).
122 timers: Vec<TimerCommand<T>>,
123 },
124
125 /// Recognize a complete pattern and emit output. Terminal action.
126 ///
127 /// [`parse`] calls [`ShiftReduceParser::on_reduce`] with `record`, then
128 /// returns a consumed [`Response`] with all accumulated
129 /// actions and the given timer commands.
130 Reduce {
131 /// Output actions to emit.
132 actions: Vec<A>,
133 /// Metadata for bookkeeping (passed to [`ShiftReduceParser::on_reduce`]).
134 record: R,
135 /// Timer commands to execute (e.g., `Kill` the chord window timer).
136 timers: Vec<TimerCommand<T>>,
137 },
138
139 /// Recognize a partial pattern, emit output, and re-process the remaining token.
140 ///
141 /// Use this when two logically independent patterns are packed into a
142 /// single token stream and the second pattern starts immediately after
143 /// the first ends. For example:
144 ///
145 /// - A "forced reduce" on timeout that needs to flush buffered tokens and
146 /// then re-process the triggering event through the same grammar.
147 /// - A two-byte sequence where the first byte is a complete symbol and the
148 /// second byte starts a new one.
149 ///
150 /// [`parse`] calls [`on_reduce`](ShiftReduceParser::on_reduce) with `record`,
151 /// accumulates `actions`, and loops by calling `decide` on `remaining`.
152 /// Because the loop continues, the final `consumed` flag is `true` even if
153 /// the eventual terminal action is `PassThrough`.
154 ReduceAndContinue {
155 /// Output actions to emit for the recognized partial pattern.
156 actions: Vec<A>,
157 /// Metadata for bookkeeping (passed to [`ShiftReduceParser::on_reduce`]).
158 record: R,
159 /// The remaining token to re-process in the next loop iteration.
160 remaining: Token,
161 },
162
163 /// This parser does not handle the current token.
164 ///
165 /// Pass the event to the next handler in the chain. If no actions have
166 /// been accumulated by prior [`ReduceAndContinue`](Self::ReduceAndContinue)
167 /// steps, `consumed` will be `false`; otherwise `true` (the partial results
168 /// are still returned).
169 PassThrough {
170 /// Timer commands to execute (usually empty for pass-through).
171 timers: Vec<TimerCommand<T>>,
172 },
173}
174
175/// A shift-reduce parser that processes tokens with timer support.
176///
177/// Implement this trait to define your parser's grammar (action table).
178/// The framework provides the main loop via [`parse()`].
179///
180/// # Implementing `decide`
181///
182/// `decide` is the **action table** of the parser. It receives a reference
183/// to the current token together with whatever state is stored in `self`,
184/// and returns a [`ParseAction`] describing what to do next.
185///
186/// Typical skeleton:
187///
188/// ```
189/// use timed_fsm::parser::{ShiftReduceParser, ParseAction};
190/// use timed_fsm::TimerCommand;
191/// use std::time::Duration;
192///
193/// #[derive(Clone, Copy, Debug, PartialEq)]
194/// enum Key { Shift, A, Other }
195///
196/// struct MyParser { pending_shift: bool }
197///
198/// impl ShiftReduceParser for MyParser {
199/// type Action = String;
200/// type Token = Key;
201/// type TimerId = ();
202/// type ReduceRecord = ();
203///
204/// fn decide(&mut self, token: &Key)
205/// -> ParseAction<String, Key, (), ()>
206/// {
207/// match (self.pending_shift, token) {
208/// // Shift key down — buffer and open chord window.
209/// (false, Key::Shift) => {
210/// self.pending_shift = true;
211/// ParseAction::Shift {
212/// timers: vec![TimerCommand::Set {
213/// id: (),
214/// duration: Duration::from_millis(50),
215/// }],
216/// }
217/// }
218/// // Shift + A chord recognized.
219/// (true, Key::A) => {
220/// self.pending_shift = false;
221/// ParseAction::Reduce {
222/// actions: vec!["ShiftA".to_string()],
223/// record: (),
224/// timers: vec![TimerCommand::Kill { id: () }],
225/// }
226/// }
227/// // Unrecognized — pass through.
228/// _ => ParseAction::PassThrough { timers: vec![] },
229/// }
230/// }
231///
232/// fn on_reduce(&mut self, (): ()) { /* update history / stats */ }
233/// }
234///
235/// let mut p = MyParser { pending_shift: false };
236/// let _ = timed_fsm::parse(&mut p, Key::Shift); // Shift
237/// let r = timed_fsm::parse(&mut p, Key::A); // Reduce
238/// assert_eq!(r.actions, vec!["ShiftA".to_string()]);
239/// ```
240pub trait ShiftReduceParser {
241 /// Output action type.
242 type Action;
243 /// Input token type.
244 type Token;
245 /// Timer ID type.
246 type TimerId;
247 /// Metadata attached to each `Reduce` step (e.g., history tracking).
248 ///
249 /// Use `()` if no per-reduce bookkeeping is needed.
250 type ReduceRecord;
251
252 /// The action table: given the current state and input token, decide what to do.
253 ///
254 /// This method may mutate internal state (e.g., enter a pending state,
255 /// push a token onto a buffer). It is called once per token (or once per
256 /// `remaining` token in a `ReduceAndContinue` loop iteration) by [`parse`].
257 fn decide(
258 &mut self,
259 token: &Self::Token,
260 ) -> ParseAction<Self::Action, Self::Token, Self::TimerId, Self::ReduceRecord>;
261
262 /// Called after each `Reduce` or `ReduceAndContinue` to update internal bookkeeping.
263 ///
264 /// Receives the `record` from the matching [`ParseAction`]. Use this to
265 /// maintain reduce-count statistics, history logs, or any per-reduce state
266 /// that should not live inside `decide`.
267 fn on_reduce(&mut self, record: Self::ReduceRecord);
268}
269
270/// Process a token through a shift-reduce parser, producing a [`Response`].
271///
272/// # Loop semantics
273///
274/// The driver loop calls [`ShiftReduceParser::decide`] on the current token.
275/// Depending on the result:
276///
277/// | Result | Actions accumulated | `on_reduce` called | Next iteration |
278/// |--------|--------------------|--------------------|----------------|
279/// | `Shift` | — | No | Return immediately |
280/// | `Reduce` | Yes | Yes | Return immediately |
281/// | `ReduceAndContinue` | Yes | Yes | Loop with `remaining` |
282/// | `PassThrough` | — | No | Return immediately |
283///
284/// # `consumed` semantics
285///
286/// The returned `Response::consumed` flag is `true` if any of the
287/// following is true:
288///
289/// - The terminal action was `Shift` or `Reduce` (event was handled), **or**
290/// - At least one [`ReduceAndContinue`](ParseAction::ReduceAndContinue) step
291/// accumulated actions before a `PassThrough` was reached.
292///
293/// The second case means that even if the final `PassThrough` would normally
294/// mean "not consumed," the event is still considered consumed because the
295/// parser did useful work in earlier iterations of the loop.
296///
297/// # Termination
298///
299/// The loop always terminates because every path through `decide` either
300/// returns a terminal variant (`Shift`, `Reduce`, `PassThrough`) — which
301/// causes an immediate `return` — or returns `ReduceAndContinue` with a
302/// new token. The new token is always a value that already existed in the
303/// caller's token stream; the grammar must ensure no cycle exists (e.g.,
304/// a token that always `ReduceAndContinue`s into itself). In practice,
305/// the `remaining` token is typically processed via a different match arm
306/// that does not produce another `ReduceAndContinue`.
307pub fn parse<P>(parser: &mut P, initial: P::Token) -> Response<P::Action, P::TimerId>
308where
309 P: ShiftReduceParser,
310{
311 let mut actions: Vec<P::Action> = Vec::new();
312 let mut current = Some(initial);
313
314 while let Some(token) = current.take() {
315 match parser.decide(&token) {
316 ParseAction::Shift { timers } => {
317 return build_response(actions, true, timers);
318 }
319 ParseAction::Reduce {
320 actions: output,
321 record,
322 timers,
323 } => {
324 actions.extend(output);
325 parser.on_reduce(record);
326 return build_response(actions, true, timers);
327 }
328 ParseAction::ReduceAndContinue {
329 actions: output,
330 record,
331 remaining,
332 } => {
333 actions.extend(output);
334 parser.on_reduce(record);
335 current = Some(remaining);
336 }
337 ParseAction::PassThrough { timers } => {
338 let consumed = !actions.is_empty();
339 return build_response(actions, consumed, timers);
340 }
341 }
342 }
343
344 unreachable!("parse loop must terminate via Shift, Reduce, or PassThrough")
345}
346
347fn build_response<A, T>(
348 actions: Vec<A>,
349 consumed: bool,
350 timers: Vec<TimerCommand<T>>,
351) -> Response<A, T> {
352 Response {
353 consumed: consumed || !actions.is_empty(),
354 actions,
355 timers,
356 }
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use std::time::Duration;
363
364 /// A simple calculator parser for testing.
365 /// Tokens are i32 values. When it sees 0, it reduces and emits the sum.
366 /// Negative values pass through.
367 struct SumParser {
368 buffer: Vec<i32>,
369 reduce_count: usize,
370 }
371
372 impl SumParser {
373 fn new() -> Self {
374 Self {
375 buffer: Vec::new(),
376 reduce_count: 0,
377 }
378 }
379 }
380
381 impl ShiftReduceParser for SumParser {
382 type Action = i32;
383 type Token = i32;
384 type TimerId = u8;
385 type ReduceRecord = usize; // number of items reduced
386
387 fn decide(&mut self, token: &i32) -> ParseAction<i32, i32, u8, usize> {
388 match *token {
389 t if t < 0 => ParseAction::PassThrough { timers: vec![] },
390 0 => {
391 // Reduce: emit sum of buffer
392 let sum: i32 = self.buffer.drain(..).sum();
393 let count = usize::from(sum != 0);
394 ParseAction::Reduce {
395 actions: if sum == 0 { vec![] } else { vec![sum] },
396 record: count,
397 timers: vec![TimerCommand::Kill { id: 0 }],
398 }
399 }
400 t => {
401 // Shift: buffer the value
402 self.buffer.push(t);
403 ParseAction::Shift {
404 timers: vec![TimerCommand::Set {
405 id: 0,
406 duration: Duration::from_millis(100),
407 }],
408 }
409 }
410 }
411 }
412
413 fn on_reduce(&mut self, record: usize) {
414 self.reduce_count += record;
415 }
416 }
417
418 #[test]
419 fn shift_buffers_and_returns_consumed() {
420 let mut p = SumParser::new();
421 let r = parse(&mut p, 5);
422 assert!(r.consumed);
423 assert!(r.actions.is_empty());
424 assert_eq!(r.timers.len(), 1);
425 r.assert_timer_set(0);
426 assert_eq!(p.buffer, vec![5]);
427 }
428
429 #[test]
430 fn reduce_emits_sum() {
431 let mut p = SumParser::new();
432 p.buffer = vec![3, 7];
433 let r = parse(&mut p, 0);
434 assert!(r.consumed);
435 assert_eq!(r.actions, vec![10]);
436 r.assert_timer_kill(0);
437 assert_eq!(p.reduce_count, 1);
438 }
439
440 #[test]
441 fn pass_through_not_consumed() {
442 let mut p = SumParser::new();
443 let r = parse(&mut p, -1);
444 assert!(!r.consumed);
445 assert!(r.actions.is_empty());
446 }
447
448 /// A parser that uses `ReduceAndContinue`.
449 /// On token 99, it reduces current buffer and re-processes token 0 (which triggers final reduce).
450 struct SplitParser {
451 buffer: Vec<i32>,
452 reduce_count: usize,
453 }
454
455 impl SplitParser {
456 fn new() -> Self {
457 Self {
458 buffer: Vec::new(),
459 reduce_count: 0,
460 }
461 }
462 }
463
464 impl ShiftReduceParser for SplitParser {
465 type Action = String;
466 type Token = i32;
467 type TimerId = u8;
468 type ReduceRecord = ();
469
470 fn decide(&mut self, token: &i32) -> ParseAction<String, i32, u8, ()> {
471 match *token {
472 99 => {
473 let msg = format!("split:{}", self.buffer.len());
474 self.buffer.clear();
475 ParseAction::ReduceAndContinue {
476 actions: vec![msg],
477 record: (),
478 remaining: 0,
479 }
480 }
481 0 => ParseAction::Reduce {
482 actions: vec!["done".to_string()],
483 record: (),
484 timers: vec![],
485 },
486 t => {
487 self.buffer.push(t);
488 ParseAction::Shift { timers: vec![] }
489 }
490 }
491 }
492
493 fn on_reduce(&mut self, _record: ()) {
494 self.reduce_count += 1;
495 }
496 }
497
498 #[test]
499 fn reduce_and_continue_chains() {
500 let mut p = SplitParser::new();
501 p.buffer = vec![1, 2, 3];
502
503 let r = parse(&mut p, 99);
504 assert!(r.consumed);
505 assert_eq!(r.actions, vec!["split:3".to_string(), "done".to_string()]);
506 // on_reduce called twice (once for ReduceAndContinue, once for Reduce)
507 assert_eq!(p.reduce_count, 2);
508 }
509
510 #[test]
511 fn pass_through_after_reduce_and_continue_is_consumed() {
512 /// Parser that does `ReduceAndContinue` then the remaining token passes through.
513 struct RacParser;
514
515 impl ShiftReduceParser for RacParser {
516 type Action = &'static str;
517 type Token = i32;
518 type TimerId = u8;
519 type ReduceRecord = ();
520
521 fn decide(&mut self, token: &i32) -> ParseAction<&'static str, i32, u8, ()> {
522 match *token {
523 1 => ParseAction::ReduceAndContinue {
524 actions: vec!["first"],
525 record: (),
526 remaining: -1,
527 },
528 _ => ParseAction::PassThrough { timers: vec![] },
529 }
530 }
531
532 fn on_reduce(&mut self, (): ()) {}
533 }
534
535 let mut p = RacParser;
536 let r = parse(&mut p, 1);
537 // Has accumulated actions from the ReduceAndContinue, so consumed is true
538 assert!(r.consumed);
539 assert_eq!(r.actions, vec!["first"]);
540 }
541}