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.
The scanner is done. It produces a flat, stateless stream of tokens. This set of tokens accounts for every byte of the source (whitespace and comments included). But before we start building out the parser, there are some design choices that need to be made to support the parser.
The parser, holistically
The lexer generates the tokens and the parser owns them. Parser::parse_all()
calls Scanner::lex_all and stores the resulting vector. Nothing outside the
parser module ever needs to hold those tokens. The AST will then be comprised of
spans and not tokens. With these spans, if anything wants to look at the lexical
structure, it can call Scanner::lex_range to get its information. (Think like
maybe a formatter)
Unlike the lexer, which streams chars through an iterator, the parser keeps
the entire Vec<Token> and points a cursor into it. That is mostly because I
anticipate needing to look ahead and parse tokens speculatively. The cursor
methods come in two pairs: bump and first skip trivia on the way, while
bump_raw and first_raw do not. Skipping is lazy as the Whitespace and
Comment tokens are still there, but the cursor simply does not stop on them.
As of now, we have no error handling, and I’m still trying to suss out how I want to handle it. My primary conflict is how large a scope the future error type lives in. The scanner produces a couple of errors: non-terminated strings and commands that have no symbol attached to them. But are those the same class of errors the parser will emit? If the parser reinterprets them, are the parser errors the same as what the AST reports downstream to consumers? A unified error system makes some of the process easier, but it also means one large error class. Smaller error groups that get reinterpreted mean more remapping.
Checkpoints for save scumming
Because the parser saves its place in the token array via an Option<usize>
cursor, saving and restoring its place is fairly cheap. This lets the parser be
more optimistic about trying parsing strategies, since re-trying is as simple as
reapplying a saved state.
| |
context_len stores the length of the parsing context stack which is the only
state we need to keep track of other than the token position. Eventually, as the
parser grows other state, more will be saved here. restore_checkpoint reverts
everything a snapshot records. By funneling save and restore through these two
functions, we (hopefully) close off whole areas of logical errors. Simple in
theory.
The checkpoint’s behavior is pinned by unit tests in the same file. One resets
the cursor from mid-stream back to None, another pushes contexts and restores
them to the recorded length.
From 8-bit to 2D
One of the goals is to map these tokens to spans that will be represented in the AST. However, it’s fairly useless to downstream consumers without being able to position them in a document somehow. Everything from formatters to highlighters to error marking all depend on being able to accurately show someone where a span is. There is one major conflict in how this is done.
LilyPond reports positions in a 1-based system, while most consumers (e.g., an LSP) use a 0-based one. On top of that, the LSP spec expects positions in UTF-16 code units, and LilyPond produces its positions in raw bytes. Rather than pick one, we will provide both for now. Because both conventions can be computed in a single pass over the source, we get everything we need at once.
| |
A lot of this is self-explanatory, but the wide-character handling is one place
to dive into. One of the things to remember is that for programming languages,
and markup languages for that matter, are mostly written in English. Because of
this, they primarily us characters in the ASCII unicode space. So the guard
checking for characters larger than 1 byte barely ever fires. For those lines
the len_utf8() > 1 check is a cheap test that simply fails. But when it does
detect a character wider than one byte, we add its position to the wide_chars
table, which records the character’s raw byte range within its line and its
width in UTF-16 code units. The byte offsets are relative to the start of the
line and the unit counts are relative to all of the units seen in the line so
far.
That leaves position reporting, which is done differently depending on which method you call.
| |
The context stack arrives
We also finally have to address the statefulness part of the LilyPond language. After going through the states in the Flex lexer, I have removed the states that were only there to set file metadata (filename, line number, version, etc.) After all of that, this is the list of the states that were left.
| |
Now, one would incorrectly assume that we should start in the Initial context.
The LilyPond parser actually starts in the Notes context, which is why a user
can skip setting up a score and just write c4 to get a glorious quarter note
C. As the flex lexer is being initialized, before the first token is scanned,
the Notes state is pushed onto the stack. (lily-lexer.cc @129, @154)1
The Notes context also covers top-level expressions and assignments.
This may lead you to believe that INITIAL is a blank canvas the designers
never got around to using. It isn’t. The grammar switches to it in exactly one
place, the output_def_head_with_mode_switch rule, for the bodies of the output
definitions such as \paper, \midi, and \layout blocks. (parser.yy @1368)
The name is a trap.
These output definitions are special because they can appear in many different places. Placed at the top level, an output definition applies its settings to all of the books and scores that follow. Placing header blocks before scores are a common example. There’s one more level of nuance to this as well.
Technically these output definitions can appear at the top level (parser.yy
@507), inside \book (the paper_block reduction, parser.yy @1046), inside
\bookpart (@1132), and inside \score (score_item, @1232). But the kinds
are limited. A \paper block is explicitly disallowed inside \score (“use
\layout instead”, parser.yy @1247). All of this to say, “it’s complicated.”
What is the point of it all?
The goal, after all of this, is a public entry point of
pub fn parse_all(source: &str) -> Result<Document, ()>. Document will be the
AST, and the () will eventually be a real error type. On the way to making
that “work,” we have set up a system where we can walk tokens, remember our
place, report positions with line numbers, and keep track of which context we
are operating in. It parses nothing yet. The next part is where the grammar
starts for real: pitch names and easily parsed grammars like durations.
All LilyPond source references in this post are to the
v2.26.0release. ↩︎