3. Composition
Chapter 2 composed parsers with ordinary function calls. That scales surprisingly far, but three shapes recur in every grammar — A then B, zero or more A, A separated by commas — and the combinator layer expresses them declaratively. tokora has two combinator families:
ParseInput— a parser that must produce a value or fail. Everyfn(&mut InputRef<…>) -> Result<O, E>implements it for free.TryParseInput— a parser that may also decline: itsParseAttemptresult is eitherAccept(value)orDecline, and a decline consumes no valid tokens — the input is rewound so whatever comes next can look at the same tokens. (Lexer-error tokens and already-emitted diagnostics are not rolled back; see the transactional contract.) Declining elements are what let the repetition drivers stop cleanly without arbitrary lookahead.
Sequencing
then keeps both outputs as a tuple;
ignore_then and
then_ignore keep one side;
map transforms the output, and
spanned / sliced /
located attach where it came from. A delimited shape is
just sequencing with the brackets ignored — open.ignore_then(body).then_ignore(close) —
which is how the argument-list example below wraps its comma list in parentheses. That
hand-roll is the lesson here; the combinator reference packages it ready-made as the
parens shape (with braces/brackets/angles
and the generic delimited).
Repetition
repeated drives a TryParseInput element until it
declines, and collect accumulates the values into any
Container (a Vec here; arrays and bounded containers
work too). If your element is a plain ParseInput and you would rather supply the
stopping decision yourself, repeated_while takes an
explicit peek-window condition instead —
while_head and while_kind spell the common
width-1 conditions (“continue while the head satisfies this”, “…while its kind is that”)
without a Peeked window or a turbofish. For the very common “repeat until a sentinel
token, and leave it in place” shape there is a one-liner:
list_until(until) collects into a Vec and stops before
the token until accepts, so the caller’s next step still sees it.
use tokora::{Token as TokenT, logos::{self, Logos}};
#[derive(Clone, Debug, Default, PartialEq)]
struct LexError;
impl From<()> for LexError { fn from(_: ()) -> Self { LexError } }
#[derive(Debug, Clone, PartialEq, Logos)]
#[logos(crate = logos, skip r"[ \t\r\n]+", error = LexError)]
enum Tok {
#[regex(r"[0-9]+", |lex| lex.slice().parse::<i64>().map_err(|_| LexError))]
Int(i64),
#[token("let")] Let,
#[token("print")] Print,
#[regex(r"[A-Za-z_][A-Za-z0-9_]*")] Ident,
#[token("+")] Plus,
#[token("-")] Minus,
#[token("*")] Star,
#[token("/")] Slash,
#[token("^")] Caret,
#[token("=")] Assign,
#[token(";")] Semi,
#[token(",")] Comma,
#[token("(")] LParen,
#[token(")")] RParen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum TokKind { Int, Let, Print, Ident, Plus, Minus, Star, Slash, Caret, Assign, Semi, Comma, LParen, RParen }
impl core::fmt::Display for TokKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::Int => "integer", Self::Let => "`let`", Self::Print => "`print`",
Self::Ident => "identifier", Self::Plus => "`+`", Self::Minus => "`-`",
Self::Star => "`*`", Self::Slash => "`/`", Self::Caret => "`^`",
Self::Assign => "`=`", Self::Semi => "`;`", Self::Comma => "`,`",
Self::LParen => "`(`", Self::RParen => "`)`",
})
}
}
impl core::fmt::Display for Tok {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Tok::Int(n) => write!(f, "{n}"),
other => core::fmt::Display::fmt(&other.kind(), f),
}
}
}
impl TokenT<'_> for Tok {
type Kind = TokKind;
type Error = LexError;
const SCAN_LOOKAHEAD: tokora::ScanLookahead = tokora::ScanLookahead::Unbounded;
fn kind(&self) -> TokKind {
match self {
Tok::Int(_) => TokKind::Int, Tok::Let => TokKind::Let, Tok::Print => TokKind::Print,
Tok::Ident => TokKind::Ident, Tok::Plus => TokKind::Plus, Tok::Minus => TokKind::Minus,
Tok::Star => TokKind::Star, Tok::Slash => TokKind::Slash, Tok::Caret => TokKind::Caret,
Tok::Assign => TokKind::Assign, Tok::Semi => TokKind::Semi, Tok::Comma => TokKind::Comma,
Tok::LParen => TokKind::LParen, Tok::RParen => TokKind::RParen,
}
}
fn is_trivia(&self) -> bool { false }
}
type CalcLexer<'a> = tokora::lexer::LogosLexer<'a, Tok>;
use tokora::error::{UnexpectedEot, syntax::FullContainer, token::UnexpectedToken};
#[derive(Debug, Clone, PartialEq)]
enum CalcError { Lex, Unexpected, UnexpectedEnd }
impl From<LexError> for CalcError { fn from(_: LexError) -> Self { CalcError::Lex } }
impl<'a, T, K: Clone, S, Lang: ?Sized> From<UnexpectedToken<'a, T, K, S, Lang>> for CalcError {
fn from(_: UnexpectedToken<'a, T, K, S, Lang>) -> Self { CalcError::Unexpected }
}
impl<O, Lang: ?Sized, Set: Clone + 'static> From<UnexpectedEot<O, Lang, Set>> for CalcError {
fn from(_: UnexpectedEot<O, Lang, Set>) -> Self { CalcError::UnexpectedEnd }
}
impl<'inp, L: tokora::Lexer<'inp>, Lang: ?Sized> tokora::emitter::FromUnclosed<'inp, L, Lang> for CalcError {
fn from_unclosed<D>(_: tokora::error::Unclosed<D, L::Span, Lang>) -> Self { CalcError::UnexpectedEnd }
}
impl<S, Lang: ?Sized> From<FullContainer<S, Lang>> for CalcError {
fn from(_: FullContainer<S, Lang>) -> Self { CalcError::Unexpected }
}
use tokora::{
Emitter, InputRef, Parse, ParseContext, Parser, TryParseInput,
emitter::FullContainerEmitter,
try_parse_input::ParseAttempt,
};
/// A `let` binding as a *try*-shaped element: decline unless the next token is
/// `let`, and only then commit to the strict tail of the statement.
fn try_let<'inp, Ctx>(
inp: &mut InputRef<'inp, '_, CalcLexer<'inp>, Ctx>,
) -> Result<ParseAttempt<(&'inp str, i64)>, CalcError>
where
Ctx: ParseContext<'inp, CalcLexer<'inp>>,
Ctx::Emitter: Emitter<'inp, CalcLexer<'inp>, Error = CalcError>,
{
// The decision point: a non-`let` token is *put back* and we decline.
if inp.try_expect(|t| matches!(t.data(), Tok::Let))?.is_none() {
return Ok(ParseAttempt::Decline);
}
// Committed from here on: failures are real errors, not declines.
if inp.try_expect(|t| matches!(t.data(), Tok::Ident))?.is_none() {
return Err(CalcError::Unexpected);
}
let name = inp.slice();
if inp.try_expect(|t| matches!(t.data(), Tok::Assign))?.is_none() {
return Err(CalcError::Unexpected);
}
let value = match inp.next()? {
Some(tok) => match tok.into_data() {
Tok::Int(n) => n,
_ => return Err(CalcError::Unexpected),
},
None => return Err(CalcError::UnexpectedEnd),
};
if inp.try_expect(|t| matches!(t.data(), Tok::Semi))?.is_none() {
return Err(CalcError::Unexpected);
}
Ok(ParseAttempt::Accept((name, value)))
}
/// Zero or more bindings: repeat the element until it declines, collect into a `Vec`.
fn parse_bindings<'inp, Ctx>(
inp: &mut InputRef<'inp, '_, CalcLexer<'inp>, Ctx>,
) -> Result<Vec<(&'inp str, i64)>, CalcError>
where
Ctx: ParseContext<'inp, CalcLexer<'inp>>,
Ctx::Emitter:
Emitter<'inp, CalcLexer<'inp>, Error = CalcError> + FullContainerEmitter<'inp, CalcLexer<'inp>>,
{
use tokora::{Accumulator, ParseInput as _};
try_let.repeated().collect().parse_input(inp)
}
let bindings = Parser::new()
.apply(parse_bindings)
.parse_str("let a = 1 ; let b = 2 ; let c = 3 ;")
.unwrap();
assert_eq!(bindings, [("a", 1), ("b", 2), ("c", 3)]);
// The element declines on the first non-`let` token, so the repetition stops
// cleanly — an empty input is zero bindings, not an error.
let none = Parser::new().apply(parse_bindings).parse_str("").unwrap();
assert!(none.is_empty());
Separation — separators are typed punctuators
Comma-separated lists could be hand-rolled with try_expect, but separator handling is
where edge cases breed: leading separators, trailing separators, doubled separators,
minimum and maximum element counts. separated — and
its ready-made spellings like
separated_by_comma — puts the policy in one
place. Two small impls wire your token type to the separator vocabulary in
punct:
PunctuatorTokentells the driver which of your kinds is a comma (semicolon, parenthesis, …);From<Comma<(), (), ()>>for your kind type lets the zero-sizedCommapunctuator name itself in diagnostics.
The Separated driver’s knobs — its element-count bounds and leading/trailing separator
policies — are documented on Separated; each reports through
its own emitter trait, which is why the where clause below names them.
(There is also separated_while for elements that
cannot decline, where you provide the lookahead condition — and
separated1_by::<Sep, _>(peek), the committed-first
“light” spelling of it: one-or-more elements, an optional leading separator, a trailing one
refused, collected into a Vec, with the whole policy already chosen.)
use tokora::{Token as TokenT, logos::{self, Logos}};
#[derive(Clone, Debug, Default, PartialEq)]
struct LexError;
impl From<()> for LexError { fn from(_: ()) -> Self { LexError } }
#[derive(Debug, Clone, PartialEq, Logos)]
#[logos(crate = logos, skip r"[ \t\r\n]+", error = LexError)]
enum Tok {
#[regex(r"[0-9]+", |lex| lex.slice().parse::<i64>().map_err(|_| LexError))]
Int(i64),
#[token("let")] Let,
#[token("print")] Print,
#[regex(r"[A-Za-z_][A-Za-z0-9_]*")] Ident,
#[token("+")] Plus,
#[token("-")] Minus,
#[token("*")] Star,
#[token("/")] Slash,
#[token("^")] Caret,
#[token("=")] Assign,
#[token(";")] Semi,
#[token(",")] Comma,
#[token("(")] LParen,
#[token(")")] RParen,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum TokKind { Int, Let, Print, Ident, Plus, Minus, Star, Slash, Caret, Assign, Semi, Comma, LParen, RParen }
impl core::fmt::Display for TokKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(match self {
Self::Int => "integer", Self::Let => "`let`", Self::Print => "`print`",
Self::Ident => "identifier", Self::Plus => "`+`", Self::Minus => "`-`",
Self::Star => "`*`", Self::Slash => "`/`", Self::Caret => "`^`",
Self::Assign => "`=`", Self::Semi => "`;`", Self::Comma => "`,`",
Self::LParen => "`(`", Self::RParen => "`)`",
})
}
}
impl core::fmt::Display for Tok {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Tok::Int(n) => write!(f, "{n}"),
other => core::fmt::Display::fmt(&other.kind(), f),
}
}
}
impl TokenT<'_> for Tok {
type Kind = TokKind;
type Error = LexError;
const SCAN_LOOKAHEAD: tokora::ScanLookahead = tokora::ScanLookahead::Unbounded;
fn kind(&self) -> TokKind {
match self {
Tok::Int(_) => TokKind::Int, Tok::Let => TokKind::Let, Tok::Print => TokKind::Print,
Tok::Ident => TokKind::Ident, Tok::Plus => TokKind::Plus, Tok::Minus => TokKind::Minus,
Tok::Star => TokKind::Star, Tok::Slash => TokKind::Slash, Tok::Caret => TokKind::Caret,
Tok::Assign => TokKind::Assign, Tok::Semi => TokKind::Semi, Tok::Comma => TokKind::Comma,
Tok::LParen => TokKind::LParen, Tok::RParen => TokKind::RParen,
}
}
fn is_trivia(&self) -> bool { false }
}
type CalcLexer<'a> = tokora::lexer::LogosLexer<'a, Tok>;
use tokora::error::{
UnexpectedEot,
syntax::{FullContainer, MissingSyntax, TooFew, TooMany},
token::{MissingToken, SeparatedError, UnexpectedToken},
};
#[derive(Debug, Clone, PartialEq)]
enum CalcError { Lex, Unexpected, UnexpectedEnd }
impl From<LexError> for CalcError { fn from(_: LexError) -> Self { CalcError::Lex } }
impl<'a, T, K: Clone, S, Lang: ?Sized> From<UnexpectedToken<'a, T, K, S, Lang>> for CalcError {
fn from(_: UnexpectedToken<'a, T, K, S, Lang>) -> Self { CalcError::Unexpected }
}
impl<O, Lang: ?Sized, Set: Clone + 'static> From<UnexpectedEot<O, Lang, Set>> for CalcError {
fn from(_: UnexpectedEot<O, Lang, Set>) -> Self { CalcError::UnexpectedEnd }
}
impl<'inp, L: tokora::Lexer<'inp>, Lang: ?Sized> tokora::emitter::FromUnclosed<'inp, L, Lang> for CalcError {
fn from_unclosed<D>(_: tokora::error::Unclosed<D, L::Span, Lang>) -> Self { CalcError::UnexpectedEnd }
}
impl<O, Lang: ?Sized> From<MissingSyntax<O, Lang>> for CalcError {
fn from(_: MissingSyntax<O, Lang>) -> Self { CalcError::Unexpected }
}
impl<'a, T, K: Clone, S, Lang: ?Sized> From<SeparatedError<'a, T, K, S, Lang>> for CalcError {
fn from(_: SeparatedError<'a, T, K, S, Lang>) -> Self { CalcError::Unexpected }
}
impl<'a, K: Clone, O, Lang: ?Sized> From<MissingToken<'a, K, O, Lang>> for CalcError {
fn from(_: MissingToken<'a, K, O, Lang>) -> Self { CalcError::Unexpected }
}
impl<S, Lang: ?Sized> From<FullContainer<S, Lang>> for CalcError {
fn from(_: FullContainer<S, Lang>) -> Self { CalcError::Unexpected }
}
impl<S, Lang: ?Sized> From<TooFew<S, Lang>> for CalcError {
fn from(_: TooFew<S, Lang>) -> Self { CalcError::Unexpected }
}
impl<S, Lang: ?Sized> From<TooMany<S, Lang>> for CalcError {
fn from(_: TooMany<S, Lang>) -> Self { CalcError::Unexpected }
}
use tokora::{
Emitter, InputRef, Parse, ParseContext, Parser, TryParseInput,
try_parse_input::ParseAttempt,
};
use tokora::{
Accumulator, ParseInput,
emitter::{
FullContainerEmitter, SeparatedEmitter, UnexpectedLeadingSeparatorEmitter,
UnexpectedTrailingSeparatorEmitter,
},
parser::expect,
punct::Comma,
token::PunctuatorToken,
utils::Expected,
};
// Wire `Tok` into the punctuator vocabulary: name which kind is the comma.
impl PunctuatorToken<'_> for Tok {
fn comma() -> Option<TokKind> {
Some(TokKind::Comma)
}
}
// And let the zero-sized `Comma` punctuator name itself as a kind.
impl From<Comma<(), (), ()>> for TokKind {
fn from(_: Comma<(), (), ()>) -> Self {
TokKind::Comma
}
}
/// A *try*-shaped integer element for the separated driver.
fn try_int<'inp, Ctx>(
inp: &mut InputRef<'inp, '_, CalcLexer<'inp>, Ctx>,
) -> Result<ParseAttempt<i64>, CalcError>
where
Ctx: ParseContext<'inp, CalcLexer<'inp>>,
Ctx::Emitter: Emitter<'inp, CalcLexer<'inp>, Error = CalcError>,
{
Ok(match inp.try_expect(|t| matches!(t.data(), Tok::Int(_)))? {
Some(tok) => match tok.into_data() {
Tok::Int(n) => ParseAttempt::Accept(n),
_ => unreachable!("the predicate admits only integers"),
},
None => ParseAttempt::Decline,
})
}
/// `( int , int , … )` — a delimited, comma-separated list: sequencing for the
/// parentheses, `separated_by_comma` for the elements.
fn parse_args<'inp, Ctx>(
inp: &mut InputRef<'inp, '_, CalcLexer<'inp>, Ctx>,
) -> Result<Vec<i64>, CalcError>
where
Ctx: ParseContext<'inp, CalcLexer<'inp>>,
Ctx::Emitter: Emitter<'inp, CalcLexer<'inp>, Error = CalcError>
+ SeparatedEmitter<'inp, CalcLexer<'inp>>
+ FullContainerEmitter<'inp, CalcLexer<'inp>>
+ UnexpectedLeadingSeparatorEmitter<'inp, CalcLexer<'inp>>
+ UnexpectedTrailingSeparatorEmitter<'inp, CalcLexer<'inp>>,
{
expect(|t: &Tok| {
if matches!(t, Tok::LParen) {
Ok(())
} else {
Err(Expected::one(TokKind::LParen))
}
})
.ignore_then(try_int.separated_by_comma().collect())
.then_ignore(expect(|t: &Tok| {
if matches!(t, Tok::RParen) {
Ok(())
} else {
Err(Expected::one(TokKind::RParen))
}
}))
.parse_input(inp)
}
let args: Vec<i64> = Parser::new()
.apply(parse_args)
.parse_str("( 1 , 2 , 3 )")
.unwrap();
assert_eq!(args, [1, 2, 3]);
// Zero elements: the element declines at `)`, the list is empty, the closer matches.
let empty: Vec<i64> = Parser::new().apply(parse_args).parse_str("( )").unwrap();
assert!(empty.is_empty());
// A doubled separator is a structured failure, not a mis-parse.
let doubled = Parser::new().apply(parse_args).parse_str("( 1 , , 2 )");
assert!(doubled.is_err());
Calc now has its print 1 , 2 ; argument shape and statement lists. What it does not
have yet is a way to choose which statement parser to run based on the next token —
that is dispatch. Next: chapter 4.