Building a LilyPond Parser - Part 1

This part of the series explores the Flex lexer and Bison parser that are the backbone of the LilyPond language. It also discusses the future plans and perceived problems.

After diving into compiler and parser creation with Crafting Interpreters, I wanted to apply it to something that would be helpful to me. As I do a lot of working with musical scores in LilyPond, I thought how great it would be to have an LSP (Language Server Protocol) server to help detect errors before I hit “compile.”

The state is the problem

Looking at the built-in lexer and parser, there are some significant challenges. The first one, and the largest one I anticipate dealing with, is that the Flex lexer and Bison parser are constantly communicating with each other about the state of the lexing. The lexer defines thirteen definitive states, however some of them are not interacting with the lexical states and rather just telling the overall system information.1 The lexical states we are concerned with are:

  • notes: Where we can actually enter music
  • chords: Used for entering music in chord mode
  • figures: Used for entering figured bass
  • lyrics: You guessed it, used for entering lyrics
  • markup: The markup system is used for entering text and symbols that are not in any other context. Primarily though, this is used for generating text such as instrumentation notes or music direction

For example, if we lexed a symbol/identifier c:

  • In a notes context, it would be treated as the pitch of a note
  • In a chords context, it would be treated as a C major chord
  • In a lyrics context, it would be treated as the word “c”
  • In a markup context, it would also be treated as the word “c”

And the deep state

But the lexical states are only half of the problem. Part of the reason the lexer needs to talk to the parser so often is because the user, through the Scheme (Guile) backend, can change the environment and/or overwrite anything except for the built-in lexical keywords.

Let’s go back to the example of a single letter symbol. This time let’s look at the letter b. You would expect that in a notes context, it would simply be the pitch “B.” Most of the time it is, but if the user switched the music language to German, it is now B-flat. h would be B natural. This can also happen in the drum mode where the user can switch the drums style. This not only changes the symbols that are accepted but all of a sudden the bass drum note can be below the staff and not on the bottom space.

Expressions such as \command present an even larger problem. Many commands are coded into the “startup” files for LilyPond and are read before the parsing of a user file begins. These commands are also not typed in any way. \command could create or modify markup, it could be a musical expression, it could be a string or another scalar value. And to build on this, within the user files, the user can overwrite any of these values (by design). This means that trying to check function arguments can’t only rely on built in command signatures. They need to be read from the LilyPond initialization files and then superseded by user definitions.

So how do we remove the state if it is the backbone of so many different pipelines within the lexer and parser? It truly can’t be zero state as described above. But rather, maybe thinking about it as “no hidden state.” Instead of the lexer carrying modes and pitch tables around internally, I want to make lexing a pure function2 of the input.

Removing some of the state?

The question then becomes, “who is responsible for what?” In my design, I hope to accomplish:

  • A lexer that produces output that does not change. Tokens are the same regardless of context. Not only does this have all of the benefits above, but it also allows the lexer to look at smaller chunks of code and produce the same output for that section.
  • A parser that keeps track of its own context and builds the AST from that knowledge. Even though the tokens were lexed differently with Flex and Bison, our goal is to produce an AST that is very similar (if Flex/Bison let us inspect their AST). The parser then would also take the ownership of storing commands and their signatures by using a progressive scanning method that would have placeholders for yet-to-be discovered grammars. Because of the stateless lexer, rescanning partial parts of the code is trivial.
  • Downstream consumers that drive the parser. The parser should be used for one thing: generating the AST. It would be downstream consumers, like an LSP server, that would follow included files, load initialization files, and delegate other work to the parser and generate the “whole picture.”

This overall design will, hopefully, fix some of the problems that plague the current lexer as well. One example is the trailing-context rules that exist only to avoid backup states. Rests (r, R), skips, and chord repetitions (q) are music events that can take post-events like -.. Because of this, the lexer needs special /[-\_] patterns to tell the event apart from what follows. It isn’t a bug. This is rather a shortcoming of trying to lex the syntax with regular expressions.

382/* Flex picks the longest matching pattern including trailing
383 * contexts.  Without the backup pattern, r-. does not trigger the
384 * {RESTNAME} rule but rather the {SYMBOL}/[-_] rule coming later,
385 * needed for avoiding backup states.
386*/
lexer.ll (d304582e)

Some rules are extremely long and complex that don’t need to be.

601[^|*.=$#{}\"\\ \t\n\r\f0-9][^$#{}\"\\ \t\n\r\f0-9]* {
602    /* ugr. This sux. */
603	string s (YYText_utf8 ());
604    yylval = SCM_UNSPECIFIED;
605	if (s == "__")
606		return EXTENDER;
607	if (s == "--")
608		return HYPHEN;
609	s = lyric_fudge (s);
610	yylval = to_scm (s);
611
612	return SYMBOL;
613}
lexer.ll (d304582e)

Other functions in lexer.ll seem out of place as well. YYText_utf8() is a function in the lexer that is called many times and analyzes every byte to make sure that the utf-8 is valid. lyric_fudge() exists only to replace _ with spaces in a loop as opposed to using a find and replace method. Overall, the goal is a system that doesn’t need comments like “Shut up lexer warnings.”3

The Scheme question

The part that is the largest unknown for me, and one that I have not quite figured out yet, is what place does the Scheme interpreter play in the lexer and parser and when does it need to be introduced. At a minimum, it needs to be parsed. But since this is a library that is planned to be used for static analysis, I don’t know if it ever needs to be evaluated. To keep the design outlined above, nothing beyond parsing should happen in the lexer. LilyPond code can also appear in Scheme code which turns this whole implementation into a turducken at best.

Where we’re going first

To sum it all up, the Flex lexer is managing many different states. There are the lexical modes (notes, chords, figures, lyrics, markup) and there’s the environment (the pitch tables and user definitions that decide what a token even is, etc.). Neither can be deleted. The goal is a scanner with no hidden state: the parser owns the modes and the environment, and lexing becomes a pure function of the input plus the context handed to it. Downstream consumers, like our eventual LSP, drive the parser, load what needs loading, and feed it all back in. In the next part, I’ll start designing the scanner itself. It’s going to be a lot of Rust pattern matching.


  1. These include version number parsing, including other files, parsing strings (quote and commandquote) and internal commands such as naming source file name and line. ↩︎

  2. “Pure function” in a programming sense. The same input always produces the same output, with no side effects. ↩︎

  3. lexer.ll:1324 ↩︎