Shift-Reduce Parser Part 2: Building Furniture (1)
Hello and welcome! I got a bit distracted by other things as you can tell, but I'm back with part 2 of my series on building a shift-reduce parser. If you're not sure what I'm talking about, then I'd advise reading part 1 first and then coming back here. It might be a good idea to re-read it anyway, juts to refresh your memory :-)
Last time, we created some data classes to store the various rules and tokens that we'll be generating. Today, we're going to build on that and start turning a set of rules into a parse table. Let's introduce the rules we'll working with:
<start> ::= <expression>
<expression> ::= <expression> PLUS <value>
| <term>
<term> ::= <term> DIVIDE <value>
| <value>
<value> ::= <number>
| BRACKET_OPEN <expression> BRACKET_CLOSE
<number> ::= DIGIT
| <number> DIGIT
The above represents a very basic calculator-style syntax, which only supports adding and dividing. It's written in Backus-Naur Form, which is basically a standardised way of writing parsing rules.
To build a parse table, we first must understand what such a thing actually is. Let's take a look at an example:
state | action | goto | |||||
---|---|---|---|---|---|---|---|
* | + | 0 | 1 | $ | E | B | |
0 | s1 | s2 | 3 | 4 | |||
1 | r4 | r4 | r4 | r4 | r4 | ||
2 | r5 | r5 | r5 | r5 | r5 | ||
3 | s5 | s6 | goal | ||||
4 | r3 | r3 | r3 | r3 | r3 | ||
5 | s1 | s2 | 7 | ||||
6 | s1 | s2 | 8 | ||||
7 | r1 | r1 | r1 | r1 | r1 | ||
8 | r2 | r2 | r2 | r2 | r2 |
_(Source: Adapted from the LR Parser on Wikipedia.)_
While it looks complex at first, let's break it down. There are 3 parts to this table: The state, the action, and the goto. The action and goto represent What should happen if a particular token is encountered. In this case, the input stream contains both terminal (i.e. DIGIT
, DIVIDE
, BRACKET_CLOSE
, etc. in the case of our BNF above) and non-terminal (i.e. number
, term
, expression
, etc. in the case of our BNF above) symbols - if understand it correctly, so there are actually 2 parts to the table here to make sure that both are handled correctly.
We'll be connecting this to our lexer, which outputs only terminal symbols, so we should be ok I believe (if you know better, please post a comment below!). The state
refers to the state in the table. As I've mentioned before, a given state may contain one or more configurations. It's these configurations that give rise to the actions in the table above, such as s2
(shift and then go to state 2) or r3
(reduce and jump to state 3).
To use the table, the parser must know what state it's in, and then take a look across the top row for the next symbol it has in the token stream. Once found, it can follow it down to figure out what action it should take, as explained above. If there isn't an action in the box, then there must be an error in the input, as the table doesn't tell us what to do in this situation. To that end, we should try and generate a meaningful error message to help the user to find the mistake in the input (or the developer in the parser!).
We're kind of getting ahead of ourselves here though. We need to build this table first, and to do that, we need to figure out which configurations go in which state. And, going down the rabbit hole, we need to know what a configuration is. Again, it's best if I demonstrate. Consider the following parsing rule from our example BNF at the beginning of this post:
<value> ::= BRACKET_OPEN <expression> BRACKET_CLOSE
A single configuration represent a possible state of the parser at a particular instant in time. I could split that above rule up like so:
<value> ::= BRACKET_OPEN * <expression> BRACKET_CLOSE
<value> ::= BRACKET_OPEN <expression> * BRACKET_CLOSE
<value> ::= BRACKET_OPEN <expression> BRACKET_CLOSE *
The asterisk represent where the parser might have gotten up to. Everything to the left is on the stack of the parser, and everything to the right hasn't happened yet.
Since this isn't a top-level rule (in our example that honour goes to the rule for the start
non-terminal), the parser will never be in a position where the first item there doesn't exist yet on the stack, so we can ignore the configuration in which the asterisk would be to the left of BRACKET_OPEN
.
Confused? Let me try and help here. Let's draw a diagram of how our parser is going to operate:
_(Source: Made by me, but adapted from the LR Parser article on Wikipedia)_
Basically, the parser will be taking in the input token stream and either shift a new terminal token onto the stack, or reduce one or more existing tokens on the stack into a single non-terminal token, which replaces those existing tokens on the stack. The configurations above represent possible states of the stack (the bit to the left of the asterisk), and possible directions that the parser could take when parsing (the bit to th right of the asterisk).
Finally, when the goal is reached, the output is returned to the caller (which, by the time we're done, should be a parse tree). Said tree can then be optimised and processed for whatever purpose we desire!
With this knowledge, we can deduce that we can build the entire table by recursing over the tree of rules from the start state. That way, we'll visit every rule that we'll need to parse everything required to reach the goal state by recursing over all the rules for all the non-terminals referenced by all the rules we visit. We could even generate a warning if we detect that some rules don't connect to this 'tree'. Here's a tree of our example ruleset from the beginning of this post:
It's a bit spaghetti-ish, but it should be legible enough :P This gives us an idea as to how we're going to tackle this. Taking into account the data classes we created in the last post, we need to make sure we keep the following in mind:
- Since the main
ShiftReduceParser
class is going to hold the rules, theParseTable
class will need a reference to its parentShiftReduceParser
in order to query the rules. - In light of this, the
SHiftReduceParser
should be responsible for satisfying any queries theParseTable
has about rules - theParseTable
should not have to go looking & filtering the rule list held byShiftReduceParser
itself. ParseTable
will need a recursive method that will take a single top-level rule and recurse over it and its child rules (according to the tree I've talked about above)- This method in
ParseTale
will need to be extremely careful it doesn't get stuck in a loop. To that end, it'll have to keep track of whether it's already processed a rule or not. - It'll probably also have to keep track of which configurations it has added to the table class structure we defined in the last post to avoid adding rules twice.
- Once
ParseTable
has figured out all the configurations and grouped them all into the right states, it will then have to recurse over the generated table and fill in all the shift / reduce / goal action(s) - not forgetting about the links to the other states they should point to.
It's quite the laundry list! Thankfully, most of this is quite simple if we tackle it one step at a time. The most annoying bit is the grouping of configurations into states. This is done by looking at the token immediately before the asterisk in each configuration - all the configurations with the same token here will get grouped into the same state (while there are more complex algorithms that allow for more complex grammars, we'll stick with this for now as anything else makes my head hurt! Maybe in the future I'll look as figuring out precisely what kind of LR-style parser this is, and upgrading it to be a canonical LR(1) parser - the most advanced type I know of).
This is quite a lot to take in, so I think I'll leave this post here for you to digest - and we'll get to writing some code in the next one.
Found this useful? Spotted a mistake? Having trouble getting your head around it? Post a comment below!