Note: This post is part of a multipart series on building a LilyPond lexer/parser. The code, as it is being built throughout the series, can be found at https://github.com/gpit2286/broucku.
In the last part, I looked at why the Flex lexer and Bison parser are difficult to reuse outside of LilyPond itself. The largest problem was the shared state between Flex and Bison. So how do we remove the hidden state if it is at the center of this process? The goal I outlined was to make lexing a pure function of the input. The parser would solely own the context later.
The first question is to deduce what the parser actually needs to do its job. Only then can I decide which tokens to produce. The tokens we are looking for are not going to be a direct equivalent of what Flex and Bison produce. Some rules in Flex collapse into a single token kind. Some tokens that used to be one token split apart so the parser can make sense of them later.
The scanner with no memory
To make lexing a pure function, the scanner itself only needs to be a tiny
machine. It is an Iterator over tokens and two pointers. One tells us where
the current token starts and the other the current length. The overall design
and many of the helper methods were highly inspired by the
rustc lexer.
| |
The hidden power in the lexer is in the lex_range method. Because there is no
state, I can point the scanner at any slice of the source and get the same
tokens I would have gotten from the whole file. This is the rescanning I
anticipated in part one that would make later re-parsing of sections possible.
This call is meant to be used without direct user input, though. Any caller that
re-lexes has to ensure it is asking for a sane slice, so lex_range panics on a
half-codepoint byte boundary, an out-of-range end, or a backwards range. It
fails on purpose. If it fails, it was either a previous logical error of the
lexer giving out an incorrect range or the caller calculating the range
incorrectly.
What is a command?
This question is where the new scanner and the Flex lexer notably part ways. In
the Flex lexer and Bison parser, exactly one character starts LilyPond commands:
the backslash. And nearly every backslash expression ends up in the same place:
lookup_identifier_symbol().1 In other words, a command in LilyPond is
not really a lexical category. It is a symbol lookup.
| |
That is the data that is captured at lex time. You may notice the absence of any kind of typing, again by design. Three pieces of information are captured:
- The span of the name (symbol/identifier), whatever shape the name happened to take.
- Whether the name was spelled with quotes around it.
- Whether the quoted command was terminated. This follows the design pattern of the string literal.
What the name means, what it takes, and whether it even exists is the parser’s
problem. I am not entirely sure how much weight the parser will put on quoted
in the end. LilyPond treats "var" and var as the same symbol, and when used
as an expression, \var and \"var" are resolved to the same place in the
symbol table. But quoted is cheap to include, and the day you need to tell
\var from \"var" apart is the day you would hate to have thrown it away. It
is the same reasoning that we keep whitespace in the token stream. One day, at
one time, someone downstream, maybe formatting, maybe an LSP, is going to want
it. Even though the quoted field adds data usage to all TokenKind variants,
this is negligible. The additional bool will live in padding that would exist
regardless.2
The many shapes after a backslash
The backslash has more to say for itself than any other character in the language. There are four commands and one error, decided entirely by what the backslash was allowed to see first:
\command: A backslash, then the longest symbol we can eat. The whole thing becomes oneCommandtoken whose name is exactly the span between the backslash and the end.\varAand\relativeand\versionall are scanned this way.\"command": The quoted spelling. On the Flex side,{SYMBOL}simply cannot carry a digit. The character class is letters and byte values 128 through 255, nothing else.3 So how do you write a command name that is not a symbol? The answer LilyPond settled on was to allow the quoted spelling.\violin1and\"violin1"would be treated the same. They live in the same symbol table, they can reference the same data, and they can appear in the same places. The only difference is that\"violin1"(with quotes) is valid and the other is not.\1: string numbers like\1,\2,\5. What is interesting is that on the Flex side, this one does not go throughlookup_identifier_symbolat all. Flex passes this to a{E_UNSIGNED}token, and the parser turns it straight into aStringNumberEvent.(.|\\.): Flex’s{SHORTHAND}rule is a single character or backslash plus any character. I understand why a regex lexer needs something like this. The items this catches include\(and\)for phrasing slurs and\<and\>for hairpins. But the rule reads a backslash and then (metaphorically) literally anything. LilyPond would likely benefit from restricting this to a set of symbols as opposed to the.regex… but that’s above my knowledge and pay grade.\: the error case. If there is nothing after the backslash at all, there is no grammar in either LilyPond or Scheme that wants a stray single backslash. So we produce the one error token the scanner knows how to make:IncompleteCommand.
What we lose without state
Because we are lexing all of these as just “commands,” we start losing some of
the benefits the Flex and Bison combination are able to leverage. In Flex, each
of these rules could resolve to a token in the middle of scanning, because the
lexer could ask the parser-ish machinery what the name meant. Here they all
agree on a Command with a name span, and the parser becomes the mythical
sorting hat in the next phase.
Statelessness has a flip side, and it shows up in the lone marks too. Bare
spellings like ~, (, [, ], and | all go through Flex’s {SHORTHAND}
rule into a cascading set of lookups. This can assign a symbol like [ to the
beginning of a beaming group or the start of a figured bass group.
Shorthands in the new scanner, because there is no backslash involved, come
through as their own structural tokens: Tilde, ParenOpen, BracketOpen,
BracketClose, Pipe. The same character is always the same token. This is
then passed to the parser that acts as the judge, jury, and executioner of the
provided tokens.
But again, the advantage of the new scanner is that it is deterministic regardless of how you got to it. Re-lexing a section to see what a user definition changed costs nothing4, because there is no state to warm up. No initialization files to re-scan.
Where we are going next
To sum it all up, commands were the most complicated part of the lexing, and
rightfully so. In the Flex lexer nearly every backslash path ended up in
lookup_identifier_symbol and here they all end up in the same Command arm.
The rest of the scanner is the supporting cast. There is some routine number/literal value lexing and a lot of Rust pattern matching. In the next part, I will start parsing these tokens together and building the context state machine.
Nearly all of the command paths in
lexer.llterminate inlookup_identifier_symbol(), usually viascan_escaped_word()orscan_shorthand(). ↩︎As it’s written now,
TokenKind::Commandtakes 24 bytes on my 64-bit system. The span is 16 bytes (2usizes) and twoboolvalues would be 1 byte each. These 18 bytes are being padded to 24 bytes. So in practice, this addedboollives in free space regardless. ↩︎The
{A}class in Flex is[a-zA-Z\200-\377], and{SYMBOL}is{A}([-_]{A}|{A})*. The\200-\377bytes are 128 through 255, covering every byte of any UTF-8 multibyte character. ↩︎Greater than a Planck time, but negligible. ↩︎