Which Parsing Approach?
tratt.net
tratt.net
The static typing of LR is nice, but trading off user experience for developer experience seems like a bad deal.
IMO there's a kind of funny progression in which parsing approach turns out to be the most appropriate depending on the scope of your project that circles back on itself:
- For pretty simple languages a hand-written recursive descent is obviously easiest
- Once your grammar is complicated enough that you start hitting precedence and ambiguity issues, or get sick of rewriting a big chunk of your parser as the grammar changes, you look into generating your parser from a BNF-like specification and end up with some variant of LL or LR
- At some point your language's grammar has mostly stabilized and you're struggling with providing good error messages or parsing performance or you've had to add one too many hacks to get around limitations of your parser generator and recursive descent starts looking good again
For my money, I tend to think that Pratt parsing/precedence climbing can extend recursive descent in a way that makes a lot of the common complaints about the complexity of dealing with operator precedence and associativity seem overstated. The trick is just that as you're building an AST, some symbols will cause you to reparent nodes that you thought you'd already placed, according to various rules. See: https://www.oilshell.org/blog/2017/03/31.html
I wrote a compiler for a vaguely C-like language by hand in javascript a while back that's intended to show how simple a hand-written parser (and code generator) can end up: https://github.com/j-s-n/WebBS
It's not that hard to statically track type information along the way - the above example requires a second pass at the AST to nail things into place and make sure all our operators are operating on the right type of thing, but a lot of that information is captured during the first parser pass or even during lexing.
In the end the recursive descent (+Pratt) beats them all, in my opinion:
- you can easily provide properly helpful error messages
- best general performance
- the parser can be debugged directly using normal debugging tools
- flexibility - all the tools and features of the host programming language are available
- zero dependencies, no build tool integration required.
The only issues I could see that the author has regarding recursive descent are excessive boiler plate and the complexity of handling precedence and associativity but:
- there should be no more boiler plate than there is for any other programming task - you write functions, methods, classes, etc. just like normal dev to reduce boilerplate.
- using Pratt provides the structure to handle all the operator rule complexity.
Recursive descent carries no tooling dependencies.
Recursive descent is re-entrant, unless you don't know what you're doing.
Well, it could, but not all of them do.
My hand-written parsers are pretty bad at it. At the lowest level I run through a string char by char and test whether it parses correctly, i.e. like: p++; if (*p != '(') throw "expected (";
At first this does not even give a line number with the error.
[1] https://en.wikipedia.org/wiki/Pratt_parser
[2] Mostly. The Pratt model does introduce potential context around operators (depending on the grammar). It's easy enough to extend the basic Pratt model to support this, but it isn't spelled out in many examples or literature.
Pretty fun, although requires some small hackery (I solved it by testing, at seeing the dot, that the "apply" list I've build to this point contains only variable names, and then re-using it as the "arglist").
module Joker_vDChallengeParser
include Parsby::Combinators
extend self
def parse(s)
(expr < eof).parse(s)
end
define_combinator(:exp_op) {|left, right| group(left, spaced(lit("^")), right) }
define_combinator(:pos_op) {|subexpr| group(lit("+") < ws, subexpr) }
define_combinator(:neg_op) {|subexpr| group(lit("-") < ws, subexpr) }
define_combinator(:div_op) {|left, right| group(left, spaced(lit("/")), right) }
define_combinator(:mul_op) {|left, right| group(left, spaced(lit("*")), right) }
define_combinator(:add_op) {|left, right| group(left, spaced(lit("+")), right) }
define_combinator(:sub_op) {|left, right| group(left, spaced(lit("-")), right) }
define_combinator(:call_op) {|left, right| group(left, ws_1, right) }
define_combinator :identifier do
first_char = char_in([*('a'..'z'), *('A'..'Z'), '_'].join)
rest_char = first_char | char_in([*('0'..'9')].join)
first_char + join(many(rest_char))
end
define_combinator :lambda_expr do
group(
sep_by_1(ws_1, identifier) < spaced(lit(".")),
expr,
)
end
def scope(x, &b)
b.call x
end
define_combinator :expr do
lazy do
e = choice(
decimal,
lambda_expr,
identifier,
between(lit("("), lit(")"), expr),
)
# Each e = scope ... block is a precedence level. You can switch
# them around to play with the precedence of the operators.
#
# hpe -- higher precedence level expression
# spe -- same precedence level expression
# Basing myself on Haskell and making function calls the highest
# precedence operators.
e = scope e do |hpe|
reduce hpe do |left_result|
choice(
call_op(pure(left_result), hpe),
)
end
end
# Our only right-associative precedence level.
e = scope e do |hpe|
recursive do |spe|
choice(
exp_op(hpe, spe),
hpe,
)
end
end
e = scope e do |hpe|
recursive do |spe|
choice(
neg_op(spe),
pos_op(spe),
hpe,
)
end
end
# Left-associativity done by parsing left operand bottom-up and
# right operands top-down.
e = scope e do |hpe|
reduce hpe do |left_result|
choice(
mul_op(pure(left_result), hpe),
div_op(pure(left_result), hpe),
)
end
end
e = scope e do |hpe|
reduce hpe do |left_result|
choice(
add_op(pure(left_result), hpe),
sub_op(pure(left_result), hpe),
)
end
end
end
end
end
That was a nice exercise. Here's some example parses: pry(main)> Joker_vDChallengeParser.parse "- 3 - foo bar . foo - bar - 5"
=> [["-", 3], "-", [["foo", "bar"], [["foo", "-", "bar"], "-", 5]]]
pry(main)> Joker_vDChallengeParser.parse "- 3 - (foo bar . foo - bar - 5) - 4 * -2 + 5 / 2"
=> [[[["-", 3], "-", [["foo", "bar"], [["foo", "-", "bar"], "-", 5]]], "-", [4, "*", ["-", 2]]], "+", [5, "/", 2]]
pry(main)> Joker_vDChallengeParser.parse "- 3 - (foo bar . foo 5 6 - bar - 5) - 4 * -2 + 5 / 2"
=> [[[["-", 3], "-", [["foo", "bar"], [[[["foo", " ", 5], " ", 6], "-", "bar"], "-", 5]]], "-", [4, "*", ["-", 2]]], "+", [5, "/", 2]]
pry(main)> Joker_vDChallengeParser.parse "foo bar . foo bar baz qux . baz qux"
=> [["foo", "bar"], [["foo", "bar", "baz", "qux"], ["baz", " ", "qux"]]]
pry(main)> Joker_vDChallengeParser.parse "foo bar . foo bar + baz qux . baz qux"
=> [["foo", "bar"], [["foo", " ", "bar"], "+", [["baz", "qux"], ["baz", " ", "qux"]]]]
You can try it by cloning my repo, running `./bin/console`, and pasting the module, or putting it in a file in `lib/`. If you put it in a file, you can reload changes with `reload!` in the console.[1] https://github.com/jolmg/parsby
EDIT: Fixed the syntax for newer versions of Ruby.
between(lit("("), lit(")"), spaced(expr)),
and using space as an operator prevents `(foo)(bar)` from being parsed as a call.This is better, just adjacent expressions with optional in-between whitespace:
define_combinator(:call_op) {|left, right| group(left < ws, right) }
Too late to edit, though. Oh well.Long ago, I made a prototype mixfix syntax for Scheme intended for a shell-like REPL with ISWIM overtones. The paren-less function call syntax (terminated by newline or semicolon, like shell) was done by modifying the Pratt engine to treat juxtaposition as application. Sexp syntax was also valid as parenthesized expressions.
(f. (x. f (v. x x v)) (x. f (v. x x v)))
fct. n.
(church0? n)
(_. church1)
_. church* n (fct (church- n 1))
Are "\" really needed there? You have to put lots parens to denote where lambdas end anyway, and they also happen to show where they start too, and a dot is a clear enough signal for a human (IMHO) to see that it's a lambda. So that's how I've came up with the idea of writing lambdas like that. Then I extended my lambda-calculus evaluator with some arithmetic primitives, and yes, the syntax got somewhat peculiar.I've done both, by hand and with parser generators (flex/bison and antlr) and getting the machine to do the boring work is total fuckload[0] faster and more productive.
Edit: and unless you know what you're doing, you will screw up when hand-writing a parser. I know of a commercial reporting tool that couldn't reliably parse good input (their embedded language).
[0] 3.14159 shedloads in metric
My experience has been the exact opposite - particularly as the language gets complicated and/or weird. In which case the generated parser becomes horribly brittle. Adding an innocent looking new rule to your working Antlr or Yacc grammar feels like fiddling with a ticking bomb - it might work straight away or it could explode in your face - leaving you with hours or days whack-a-moling grammar ambiguities.
I guess our experiences differ but I don't know why. I have written a seriously major SQL parser in antlr and had no problem. And it was huge and complex, well that's TSQL for you.
It may be you have been parsing non-LR(1) grammars in bison which could prove a headache but... well IDK. Maybe I've been lucky.
I don't think it's an LR vs LL thing either. I feel that there is no sense of locality with parser generators; it's a bit like quantum mechanics - every rule has a "connection" to every other one. Change one seemingly small part of the Antlr grammar and some "far away" seemingly unrelated parts of the grammar can suddenly blow up as being ambiguous.
It is strange that we're having such different experiences of it. I don't recognise your quantum view of it either, as a antlr rules, and bison, are very context-specific as they can only be triggered in the context of larger rules, and only when given piece of the target language (SQL here). They get triggered only in those very specific cases. I've never had your struggles with it. I don't understand.
I'm happy giving up some performance for a better chance at finding problems. Who cares if your compiler clocks at a bazillion lines a second if all it can say is "line 101: syntax error" or mumble something indistinct about a reduce-reduce conflict.
I'm not writing commercial compilers, just little language processors. And my users need all the help they can get.
2 reasons:
- it doesn’t warn you about ambiguous grammar, so you don’t know when you screw up
- it requires a lot of boilerplate for operator precedence (one function for each level), in a LR parser generator that’s trivial so I can spend time on more important things
Another area that should be mentioned is purposefully messy grammars. A common addition to languages these days are arrow functions:
(a, b, c) => { ...
These are really nice for defining functions, but they usually cause unbounded lookahead. For instance in JavaScript, the arguments list can also be parsed as a comma expression inside parentheses. In my language they can be parsed as a tuple expression. There might be some tricky way of doing this with an LR parser, but I can't figure it out. Therefore I needed to parse arrow functions/tuples by basically assuming they're an arrow function, then if I'm mistaken, I convert my parameters list into a tuple expression and continue parsing. For instance: (a, b, 10)
When we hit the 10, my parser goes "ah shoot, this is a tuple" and converts the (a, b to be expressions in a tuple.Some may argue that grammars should just stay within LR/LALR/LL, but I do think adding a little bit of parsing complexity for ease of use is a worthy tradeoff.
- Query-based compiler architecture: https://ollef.github.io/blog/posts/query-based-compilers.htm...
- Lezer [a parsing system used by CodeMirror]: https://marijnhaverbeke.nl/blog/lezer.html
- Anders Hejlsberg on Modern Compiler Construction: https://www.youtube.com/watch?v=wSdV1M7n4gQ
I recently hand-wrote my own parser so I could achieve some these results. Having used parser generators in the past, I realize now that hand writing a parser is SO much more flexible.
(a: int, b: float, c) => { ....
which is not a valid tuple expression. Just had to make my life harder haha expr ::= <all the other expressions>
| '(' [ expr { ',' expr } ] ')'
| '{' { expr } '}'
| expr '=>' expr
| identifer '(' expr { ',' expr } ')'
| literal
Tried adding it to the javascript grammar I play with and the parser generator (Coco/R) didn't complain about conflicts -- though it doesn't have tuples so YMMV.--edit--
Played with it a bit more and added tuples (as a replacement for '(' expr ')' grouping expression (exactly like the now-edited example above) and it still seems happy. If I were to add this to the grammar for real I'd have to monkey around with it some more to ensure there was an actual '(' ... ')' expression before the => but seems easily doable by transforming the grammar a bit.
On a more serious note, the grammar ambiguity mentioned in the article is one of the things that has drawn me to languages that use s-expressions. Lisp are just as varied in their semantics as any other set of languages. What sets them apart is in their shared syntactic core of prefix notation in combination with parenthesis to create s-expressions. This eliminates the vast majority (though not all) ambiguities concerning precedence and associativity.
It’s not really parsed until you have a data structure that lets you search for all occurrences of a special form. Using lists for everything ignores important distinctions between levels.
1. It's faster
2. It's quite high level, arguably higher level than LR
3. Can more easily support good error messages
4. Can easily escape hatch into the surrounding Turing complete programming language, so you can parse anything
5. Does character level parsing, so no lexer required, and easily supports parsing sublanguages, e.g. regex literals inside another programming language
6. Can make use of the abstraction facilities of the surrounding programming language
7. No shift/reduce errors
8. The whole parsing stack is WAY simpler than LR
The main advantages of LR are:
1. Its theory is nicer because CFGs have nicer denotational semantics
2. It perhaps supports better automatic error recovery
Anything I forgot?
P.S. whether CFGs are actually a good formalism for programming languages is debatable. At the character level, most programming languages grammars are not context free. So the context freeness depends on the non context freeness of the lexer. It's fine to use a lexer of course (as long as you do not need to combine multiple languages), but this observation makes CFGs lose some of their theoretical appeal, imo. Character level CFG parsers have some non context free extensions to actually parse the things we want to parse.
Ever since reading Crafting Interpreters I've found myself obsessed with writing parsers. I keep making new ones over and over, looking for little things to improve on in projects that nobody will ever use. I'm actually a little disappointed that parsing is considered "the easy part" of compilers; a "solved problem", compared to things like type analysis and bytecode optimization.
When he says LR parsers were hard for old machines to handle he was referring to the generating the parsing table which happens once, when the parser itself is created.
A serious compiler will inevitably use a hand-written parser. Once your language needs mature IDE support, you will need a parser that not only produces good error messages, but is also error tolerant, e.g. a local parsing failure does not result in a global cascade of errors in your editing environment.
But that's not the stage where grammars and parser-generators shine. LR(k) grammars accept the complete class of deterministic context-free languages, allowing you to produce an efficiently parsable language with no ambiguity. The grammar itself can be easily modified to introduce new syntax, just by updating some production rules, and it also serves as documentation to incorporate into your language specification.
Sure, sometimes you have to deal with shift-reduce and reduce-reduce conflicts (akin to static type errors), but once you get the hang of it, parser generators become instrumental tools in language prototyping. Moreover, current advancements in LR parsers address a lot of these drawbacks.
> While that could mean just about anything, nearly everyone who writes a half-decent hand-written parser, whether they know it or not, is writing a recursive descent parser [1].
> [1] Parsing Expression Grammars (PEG)s and “parser combinators” in some functional languages are just recursive descent parsers in disguise.
Which, while I haven't thought about it deeply, intuitively makes some sense: parser combinators encourage an approach mostly equivalent to little functions that do something like "parse a $thing, or parse a $different_thing" (etc.) which very much translates down like a recursive descent parser.
There was basically no discussion of PEGs or parser combinators, just a footnote that he doesn't care for them.
Which is fine! LR parsing is a great approach, he's committed significant amounts of his life energy to working with them.
I think PEGs are great, though, I'd recommend them to any developer as the default tool for munging structured data. Nearly any mess of regular expressions can be improved with PEGs.
There are unsolved problems in the PEG world, particularly with error recovery. But not insoluble problems by any means.
I'm not quite ready to show my work on PEGs but I can say I've gotten pretty far with the approach. It's noteworthy that he starts with recursive descent, notes that PEGs and combinators (same same basically) generalize recursive descent, and then just kinda walks away from that whole line of inquiry!
But hey, I'm a parsing nerd. I'm glad Laurence Tratt has pushed the state of the art in LR, just like I'm glad Terrence Parr has done the same for LL. I'm working in a similar space with PEG, and have a long way to go.
If you allow a, b = f < t1, t2 > ( c ), it can only be parsed if you know whether f is generic (if it's not, then a and b are bools), which requires whole program parsing because f could be defined above, below, or in another file.
Basically, if you want to have a sane grammar, don't use angle brackets for generics or pipes for lambdas. It looks good to humans but really screws up the computers.
IIRC, Elm uses this approach to disambiguate '.' and unary minus in function applications.
The major nuisance is that you need to somehow handle "<<" and ">>" properly: either by a "lexer hack"; or by lexing only single "<" and ">", and recombining them in the parser when appropriate; or by splitting "<<" and ">>" in the parser when appropriate; or by not having "<<" and ">>" in the first place.
specification: https://go.googlesource.com/proposal/+/refs/heads/master/des...
https://play.golang.org/p/NQL1rVp-wVC -- it's trying to interpret that as (1<2)<3, and the types make no sense for that.
> It does not [support chained comparisons], so the argument is very much valid.
Was your comment to the effect of "although Go does not support chained comparisons, it is syntactically valid for a comparison operator to be passed the result of a comparison so the issue can still arise"?
That's valid, but I would have expected "but" in place of "so".
(sorry if it seems I'm picking nits; I'm just trying to understand and explaining my attempts)
Not saying the grammar would be context-free though.
Relying on that is going to prevent you from including numbers as things you can be generic over (without some additional syntax for that case). That may or may not be something you care about.
Whats wrong with pipes? The only conflict I can think of is LOGICAL-OR || but afaik no language lets you shove shit between the pipes and still be an OR
Was that what you were referring to?
There are several possible solutions in language syntax (and it's possible you won't like any of them).
One is to use whitespace to disambiguate. a < b is parsed as a comparison, a< b or a<b as an opening angle bracket.
Another is to keep track of the type while parsing, and parse a < b as an angle bracket only if a is known to be callable.
Yet another would be to require some fancy quotes instead, like f«a, b» for generics, sidestepping the problem completely.
x[1] : array[int]
would be parsed as cast(item(ident("x"), int("1")), item(ident("array"), ident("int")))
Then the semantic disambiguation (`x[1]` -> expression, `array[int]` -> type) would be done in the next stage.- help better diagnose where/why shift/reduce arises in the context of a given grammar
- argument if Prat or another approach entirely avoids shif/reduce errors.
The nicest explanation of this fact is in Olivier Danvy and Kevin Millikin's paper Refunctionalisation at Work -- as an example of their program transformation technique, they demonstrate how operator precedence turns into shift-reduce. It's really cute.
My personal preference is pretty much the same as Tratt's, except I never had an aversion to LR grammars to begin with. I always found them intuitive. I had used them before understanding the theory.