Repository navigation
v1.23.0
This release fixes bugs found while auditing the documents and the structure of the parser: error reports, packrat parsing, left recursion, macros, and the checks made when a grammar is loaded. Many of the fixes change what a grammar loads, matches or reports, and blobs must be written again, so please read the first section before upgrading.
⚠️ Changes to be aware of
Grammar blobs
Blobs are now PEG4: load_blob() returns false for a blob written by v1.21.0 or v1.22.0. Write them again with serialize_grammar(). A recovery (^label, %recover(e)) is now held as itself instead of as a call of a built-in %recover macro, and %recover is no longer one of the grammar's rules.
A blob is also checked for what only the text loader made sure of. A rule without a body, which parser["Typo"] = ... makes for a name the grammar does not define, is left out of a blob; such a blob used to crash load_blob(). A blob that holds a missing operator, a class without a range, or parameters on a rule that is not a macro is rejected.
Grammars that no longer load
- A repetition of something that can match empty is found wherever it is reached. It used to be missed in an element after one that consumes, at the start of a sequence under a repetition, in an argument of a recursive macro call, and in a rule made with the combinators that repeats itself. Such a repetition stops at its first empty match, so these grammars ran, but not as written:
S <- (A ';')* # 2:10 infinite loop is detected in 'B'. A <- 'a' B* B <- 'b'?Line <- (Sp* Word)*withSp <- ' '?andS <- (('a'?)* 'b')*are rejected too. Where loops are several or rules form a cycle, the rule reported can differ: forS <- AandA <- S*, it is now2:6 infinite loop is detected in 'S'.instead of1:6 ... 'A'. - A cut matches empty in these checks, so
S <- (↑ / 'a')*is rejected as('' / 'a')*is. - A
%wordthat can match the empty string is rejected:'%word' can match the empty string.With%word <- [a-z]*, every word-like literal used to fail. A lookahead counts as possibly empty, so%word <- &[a-z] [a-z]*, which worked, is rejected too. Write[a-z]+. - A macro parameter called with arguments, as
x('b')inM(x) <- x('b') 'c', is reported as'x' is not macro.The arguments used to be dropped. %whitespaceor%worddefined as a macro, as%whitespace(x) <- [ ]*, is reported as'%whitespace' cannot be a macro.- A rule made with the combinators and given in
Rulesis now known to match empty when it can, so a grammar that reaches one can report an infinite loop or a%wordthat can match empty, as the same grammar written out does.
Error reports
Error reports no longer depend on shortcuts or on packrat parsing: they list what a parse that tries everything would list.
- Packrat parsing: a reused result lists what parsing the rule again would. With
S <- R 'x' / T,T <- < R 'y' >andR <- 'a' [b],acreportedexpecting <R>.with packrat parsing andexpecting <R>, <T>.without. It is nowexpecting <R>, <T>.with both. After a recovery that reported a custom message, a reused result still lists none of its failures from before the report (see the README). - Skipped alternatives: an alternative skipped because it cannot start with the next byte lists what trying it would. With
S <- A / B,A <- ('x' / 'y') 'z'andB <- [0-9] 'w',qnow reportsexpecting 'x', 'y', <B>.instead ofexpecting <A>, <B>.Some reports list more, and some grammar load errors are reported further on:S <- 'a' { error_message }is a syntax error at 1:26 instead of 1:12. - The operand of
!reports nothing: its failures are not listed and its messages are not given, as in Peggy, Ohm and pest. WithIdent <- !Keyword < [a-z]+ >andKeyword <- 'let' / 'if' / 'else',let 1 = 2reportsexpecting <Ident>.instead ofexpecting 'let', 'if', 'else', <Ident>. - A custom message stands only where its failure is the error, as Peggy counts a named rule's failure:
- With
E <- N (Op N)*andOp <- [+-] { error_message "expected an operator" }, Op fails at the end of every expression, so1+2;3+;reported1:4 expected an operator. It now reports1:7 syntax error, unexpected ';', expecting <N>. - A message given by an alternative tried later no longer hides an error that an earlier alternative went further to. With
S <- Let / Exprand a message on Expr,let x = ;reported1:1 bad expressionand now reports1:9 syntax error, unexpected ';', expecting <Num>. - No rule in
%whitespacegives a message. A comment rule witherror_message "unterminated comment"in%whitespacetook over errors where the whitespace stops, such as at the end of the input. To report an unterminated comment, give it a recovery label:Comment <- '/*' (!'*/' .)* '*/'^unterminated unterminated <- '' { error_message "unterminated comment" }
- With
- Labels (
ErrorReport::labeland the rule passed to a logger):- An error that a recovery reports is labeled with its recovery label, whether or not the label has an
error_message. Without one, the label used to be the first rule expected, or empty. - Other errors no longer keep the label of an earlier message. With
S <- A? 'x'andA <- 'a' { error_message "bad a" },expected end of inputonxywas labeledA, and now has no label.
- An error that a recovery reports is labeled with its recovery label, whether or not the label has an
- A recovery reports once where it runs. Where backtracking parsed a rule again at the same position, its recovery reported again whatever was pending, such as what an abandoned alternative expected. With
Item <- Labeled / StmtandLabeled <- Stmt ':' Stmt, a statement missing its;also reportedexpecting ':'.without packrat parsing. It now reports the missing;only, with or without packrat parsing. - An undefined back reference sets its message as other messages do: only where errors are reported, and not over a message at or after its position.
Parse results
- A cut or a recovery in a lookahead (
&or!) no longer commits the choice around the lookahead. WithItem <- !Decl Expr ';' / 'let' Name ';' / DeclandDecl <- 'let' ↑ Name '=' Expr ';',let x;andlet x = 1;failed and now match. - A recovery in the operand of
!recovers without reporting an error or failing the parse:S <- !('x' ';'^semi) . / 'x' 'y'failed onxyand now matches. - Packrat parsing and cuts: a rule with a cut or a recovery that commits the choice around it is not memoized, as a rule with captures is not. With
Func <- Sig Block / Sig ';'andSig <- 'fn' ↑ Name '(' ')',fnmatched with packrat parsing only. It now fails with both. %whitespaceand token boundaries: a rule used where no whitespace is skipped (in a token boundary, a{ no_whitespace }rule or the whitespace) is parsed there apart from its uses elsewhere, as if it had a copy there that skips no whitespace. A rule that calls itself in its own token boundary therefore grows only inside it:Q <- < Q '.' I / I >matches a singleI. It used to accepta.b.c, but alsoa .b, carrying whitespace into the token. Put the recursion in a rule of its own:Q <- < QBody >withQBody <- QBody '.' I / I. Without%whitespace, nothing changes.- Left recursion: a match built from an earlier seed was reused after the seed changed, so
S <- &S G / 'a'withG <- S 'z' / 'q'rejectedazz. It now matches. In left-recursive grammars, rules are entered and actions run far fewer times. - Macros:
- A macro instance that is not left-recursive fails where it is entered again at the position where it is being parsed, as a rule does. With
S <- Pair(E, T),E <- Pair(E, T) / TandPair(A, B) <- A '+' B,Snow matches1+2but not1+2+3; it matched neither.M(x) <- x M('b'*) / 'c'used to overflow the stack. ~on a macro's definition throws its values away: with~M(x) <- x,S <- A M(B)received the value ofBand now does not.- A macro parameter named like a rule is no longer taken for that rule: with
S <- x,x <- M('a')andM(x) <- x 'b', the rulexwas taken to be left recursive, and with left recursion disabled the grammar did not load.
- A macro instance that is not left-recursive fails where it is entered again at the position where it is being parsed, as a rule does. With
- Dictionaries: a word gets the choice index it is first listed at. With
'January' | 'Jan',vs.choice()forJanwas 0 and is now 1. {n,m}withmbelownacts as{n}everywhere:[0-9]{2,1}rejected12.
Callbacks, tracing and nesting limits
- With a logger or an error reporter, a rule that cannot start with the next byte is skipped as it is without them, so its
enterandleavehandlers do not run. A rule that gives a message or is left-recursive is tried instead of skipped where errors are reported or nesting is limited. The same rules are entered with or without a logger. - Under
set_max_depth, a skipped rule counts no level. With a tracer, whichenable_tracingandenable_profilingset, such a rule is still entered, so the limit can be reached a few levels sooner. - A trace no longer shows what a recovery skips unless it is verbose, and no longer shows a
%recoverrule.
API and ABI
ErrorInfo::addtakes different arguments, andFirstSet::failuresreplacesfirst_literalandfirst_rule.- New:
Context::reports_errors. - Removed:
Context::in_token_boundary_count(Context::skips_no_whitespaceis now a member),Reference::get_core_operator, and theenable_shared_from_thisbase of seven operator types. - The layouts of
Context,ErrorInfo,Recoveryand other types changed, which changes the ABI.
🐛 Fixes
- Crashes:
- Loading a grammar given a combinator rule that refers to itself, as
NUM <= seq(cls("0-9"), opt(NUM))inRules, overflowed the stack. - Left recursion through a cut went unseen:
S <- M('x')withM(x) <- 'a' / ↑ !M('b')overflowed the stack. - Arithmetic on a null pointer, undefined behavior, where a parse stops before the end of its input.
- Loading a grammar given a combinator rule that refers to itself, as
- Exponential time (process times on the shapes below, v1.22.0 -> v1.23.0):
- Parsing a ladder of left-recursive rules,
E1 <- E1 'a' E2 / E2,E2 <- E2 'b' E3 / E3, ...: 24 levels, 7.5 s -> 43 ms. - Loading levels that each start with the next in two alternatives, ending in a left-recursive rule: 22 levels, 1.6 s -> 33 ms. Such levels under a repetition: 23 levels, 0.8 s -> 47 ms.
- Loading a chain of macros that each pass their parameter twice,
A1(x) <- A2(x x), ...: 24 levels, 1.6 s -> 26 ms. - Error reporting dropped repeated expected items in quadratic time, which a grammar listing hundreds of keywords at one position pays at the end of every input.
- Parsing a ladder of left-recursive rules,
- Packrat parsing:
- A rule used in
%whitespaceand in tokens could make the whole error vanish (since v1.20.0): with%whitespace <- (R / ' ')*andT <- < R 'y' >, the parse reported nothing. - Where left recursion detection misses a cycle that a macro argument brings, packrat parsing could change the result. Packrat parsing now stops for the rest of the parse where that shows.
- A rule used in
- Rules made with the combinators that can match empty were taken not to: with
X <= opt(chr('x'))inRulesandB <- A 'b' / X,brdid not parse. A combinator start rule whose whitespace differs from the grammar's%whitespacecould fail where it should match.
🛠 Tools
- peglint gives its hints about speed, such as alternatives that start with the same rule, only with
--hints. The README's calculator used to print two such warnings. The warning about a literal before a predicate (#319), which is about correctness, still prints by default. cd lint; cmake ..builds peglint with a compiler that defaults to C++14, and pl0's Makefile builds with Homebrew LLVM.
📝 Documentation
- The README matches the code:
$(...)and%recover(...)in the syntax list, the repetition forms and←; aprecedenceinstruction's first entry binds the loosest (the README said the opposite); the forms of a semantic action; where whitespace is skipped; the fields of an AST node; tracing, profiling andset_max_depth. - New limitations in the README: a left-recursive macro whose recursive call wraps its parameter, as
Sum(A) <- Sum(A / 'x') '+' A / A, overflows the stack (set_max_depthmakes it fail with an error instead); packrat parsing inside!; and what a reused result misses across a recovery. - The Java example of error recovery recovers by tokens instead of statements.
- CI compiles every C++ example in the READMEs and checks every peglint example against what peglint prints.
⚡ Performance
Instructions of one parse against v1.22.0. The large grammar is the grammar of DuckDB's PEG parser ported to cpp-peglib (about 1,100 rules), on a 1.2MB SQL file with packrat parsing.
- Left-recursive grammars:
grammar/monkey_left_recursive.pegon 100KB of Monkey programs, 5.48G -> 0.26G instructions (-95%), 546ms -> 24ms; with packrat parsing, -87%.grammar/pl0_left_recursive.pegon 200KB of PL/0, -50%; with packrat parsing, -44%. - With a logger: the large grammar, 4.84G -> 3.74G (-23%), whether the input is valid or fails near its end.
benchmark/data/sql.pegwithout packrat parsing, -8.6%. - With
set_max_depth: the large grammar, -15%. - Slower: without a logger, the large grammar takes 3.0% more for recognition, 3.5% more with
enable_ast(true)and 2.3% more withenable_ast().benchmark/data/sql.pegtakes up to 1.0% more, and the calculator of the README's macro example 1.1% more, or 9.3% more with a logger. - Loading a grammar: the same or a little less (-0.2% to -2.4%).