Paper
Swara: A LilyPond Toolkit for Neovim
Active

The Grammar: tree-sitter-lilypond

A Tree-sitter grammar, a Rust language server and a Neovim plugin for LilyPond, the plain-text music engraver, built from the ground up for speed.

lilypondneovimtree-sitterrustluamusic

01.What the Grammar Reads

Take one line of a score, \relative c' { c4 d e f | }. To colour it, an editor has to know that \relative is a command, that c' is the pitch it starts from, that the braces open and close a block of music, that c4 is a note with a length, and that the | is a bar check. A grammar is the set of rules that says how text breaks into parts like these, and Tree-sitter turns those rules into a parser, a small program that reads the text and builds the tree of parts. tree-sitter-lilypond is the grammar Swara uses. It lives in a repository of its own, so any editor that embeds Tree-sitter can use it without the rest of Swara.

Version 0.1.0 reads whole LilyPond 2.26 files. That covers the top-level forms, such as \header blocks, \score blocks and variable definitions, the music itself, and the input modes LilyPond switches into for particular kinds of text. Lyric mode holds the words sung under the notes, markup mode holds formatted text such as titles, chord mode holds chord names such as c:m7 for C minor seventh, figure mode holds figured bass, the numbers written under a bass line to name the harmony above it, and drum mode holds percussion.

It also reads the Scheme embedded in a score. Anything after a # is Scheme, and Scheme can in turn hold LilyPond music written between #{ and #}. The grammar parses both languages into one tree, so the music inside the Scheme is read as music rather than as an opaque string.

The grammar ships four queries. A query is a pattern that picks nodes out of the tree, and each query file serves one editor feature. The highlight query decides colours, the fold query decides which blocks can be collapsed, the indent query decides how far each line is indented, and the locals query records where a variable is defined and where it is used. The names of the nodes the grammar produces, and the rules behind them, are fixed in the node reference.

02.What It Aims For

The grammar was written against four aims, each of which can be checked.

  • Agreement with LilyPond. Every example in the grammar's tests is a complete file, and a test runs LilyPond 2.26.0 on each one. LilyPond must accept every example except those marked as errors, which it must reject. So every fact about LilyPond's syntax that the grammar encodes has been checked against the compiler itself rather than taken from memory.
  • One tree for both languages. Embedded Scheme, and LilyPond embedded back inside that Scheme with #{ #}, parse into the same tree as the music around them.
  • Speed. The grammar is measured against a fixed corpus of public scores, and a one-character edit in a large score must reparse within the time an editor has to recolour the screen.
  • Queries with no word lists. The queries colour by the shape of the syntax, not by lists of LilyPond command names. A score can define commands of its own, so no list of names could be complete, while the shape of a command, a backslash followed by a name, stays the same.

03.Speed

The grammar's speed test times two files: a 100-bar test song, and a 10,000-bar file that repeats the song's music a hundred times inside one pair of braces. A full parse reads a file from nothing. A reparse follows an edit, here typing one ' into a note in the middle bar, which raises that note an octave.

Table 1. Parse and reparse times for the grammar

FileFull parseOne-character reparse
100 bars, 5,892 bytes0.40 ms0.02 ms
10,000 bars, 292,992 bytes26.8 ms1.36 ms

Each figure is the median of 20 runs after 3 warm-up runs, measured on 2026-10-24. A warm-up run is timed and thrown away, so that one-time start-up costs do not land in the figures. The grammar's README gives the command that reproduces them.

The reparse in the large file stays well inside the 16.7 ms a screen frame lasts at 60 frames a second because of the bar checks. Each bar that ends in a bar check | becomes a hidden node of its own, a group the parser keeps internally but leaves out of the tree that queries see. After an edit, the parser reuses every untouched bar whole and rebuilds only the bar that changed. Music written without bar checks gets no such grouping, and its reparse time grows with the length of the passage: with the bar checks removed, the same reparse takes 2.6 ms at 1,000 bars and 20.9 ms at 10,000. A score with bar checks is therefore faster to edit as well as safer to write.

The test also guards these figures. It fails if the 100-bar file takes more than 5 ms to parse or 1 ms to reparse, if the 10,000-bar parse takes more than 110 times the 100-bar one, or if a reparse in the 10,000-bar file takes more than 2 ms. A change to the grammar that made it slower past those limits would fail its own tests.

04.Compared with nwhetsell/tree-sitter-lilypond

An existing LilyPond grammar for Tree-sitter, nwhetsell/tree-sitter-lilypond, was there first. The case for a second grammar rests on a measured comparison, run on 2026-10-25, with the rule fixed in advance that the new grammar would count as better only if it passed all three criteria below.

The comparison needs files that are known to be valid. The corpus gathers 2,915 LilyPond files from four public sources. LilyPond 2.26.0 accepted 2,757 of them as they were, and 17 more after convert-ly updated their syntax, so 2,774 valid files remain. The other 141 were rejected by LilyPond and left out.

Table 2. The valid files in the comparison corpus

SourceWhat it isValid filesShown with an error by this grammar
regressionLilyPond's own tests, each exercising one feature2,1812
snippetsThe examples printed in LilyPond's documentation4233
initThe files LilyPond loads at startup, heavy on embedded Scheme561
mutopiaPublic-domain scores from the Mutopia Project, at one pinned commit1140

The Mutopia scores matter most, because they are the closest thing in the corpus to music written by ordinary users. Beside the valid files sit 36 broken ones, each broken in one known place. A grammar catches a broken file when it reports an error within one line of where LilyPond reports it. Throughput is how many bytes of LilyPond a grammar reads per millisecond, so higher is faster.

Table 3. The three criteria and the result on each

CriterionThis grammarnwhetsellResult
Valid files shown with an error, fewer is better62fails
Throughput, nwhetsell timed with its embedded Scheme grammar26,112 bytes/ms15,593 bytes/mspasses
Broken files caught within one line of LilyPond's error30 of 3616 of 36passes

The grammar passes two criteria and fails the first, so by the rule set in advance it is not yet better.

Speed in the Comparison

This grammar reads the corpus 1.67 times as fast as nwhetsell's, and its slowest run was still 1.39 times as fast. The median file takes 16 µs against 25 µs, where a µs, a microsecond, is a thousandth of a millisecond. The comparison is fair only because both grammars are timed doing the same work. nwhetsell's grammar leaves embedded Scheme as unparsed ranges and hands each one to a separate Scheme grammar, so its time includes that second pass, while this grammar reads the Scheme into the same tree in one pass. Against nwhetsell's LilyPond grammar alone, about 25,000 bytes/ms with the Scheme left unparsed, the two are about level. The throughput above is the median of five runs, which ranged from 21,602 to 27,997 bytes/ms while background services were busy on the same machine.

Broken Files

Of the 36 broken files, 15 are caught by both grammars, 15 only by this one and 1 only by nwhetsell's. nwhetsell's grammar reports no error at all on 12 of the broken files, and 10 of this grammar's 15 extra catches are among them, such as a setting missing its = and a stray >>. Of the six this grammar misses, a %{ that never closes is typical: it cannot be a block comment, so the grammar reads it as a one-character line comment followed by an opening brace, and reports that brace as unclosed on the last line of the file, two lines after the line LilyPond names.

Valid Files Shown with an Error

Six of the 2,774 valid files, 0.2% of the corpus, show an error in this grammar. Three of them define new syntax while LilyPond runs them: a music function named @ written before notes, and a #[ form added to Scheme's reader. The file itself changes what the text means as it runs, and a grammar reads text without running it, so no grammar can know those forms in advance. The other three use rare forms inside \with blocks and override values. Of the two files nwhetsell's grammar errs on, this grammar parses one cleanly and errs on the other at the same line.

The count came down in three steps. The first measurement found 432 valid files with an error, and 15 of the Mutopia scores among them. The constructs behind most of those were then taught to the grammar, such as a dotted name like \omit Staff.TimeSignature and a direction mark before a slur, as in f^(, which took the count to 118. A third round, worked file by file from each one's first error line, took it to 6, and no file that parsed cleanly at an earlier step shows an error now.

The Trade-Off

The two grammars sit at opposite ends of one trade. nwhetsell's accepts nearly everything, so it shows almost no false errors and misses most broken files. This one rejects what it does not recognise, so it catches more real mistakes and still flags six valid files. Which end suits a given use depends on whether a missed mistake or a false mark costs more. Inside Swara the question is softened, because LilyPond's own check runs once typing pauses and has the final word. The full report, with every broken file this grammar misses and why, is the comparison verdict, and the corpus note records where every file came from.

05.Using It in Neovim

Swara's plugin loads the grammar from the swara program, so this section is for using the grammar on its own through nvim-treesitter, the plugin that installs and manages Tree-sitter parsers for Neovim. An autocommand is a function Neovim runs when an event happens, and nvim-treesitter's main branch documents a User TSUpdate autocommand as the place to register a parser it does not ship.

Lua
vim.api.nvim_create_autocmd('User', { pattern = 'TSUpdate',
callback = function()
require('nvim-treesitter.parsers').lilypond = {
install_info = {
url = 'https://github.com/anirbanchakraborty-dev/tree-sitter-lilypond',
revision = 'v0.1.0',
queries = 'queries',
},
}
end,
})
vim.api.nvim_create_autocmd('FileType', { pattern = 'lilypond',
callback = function() vim.treesitter.start() end,
})

Neovim already gives .ly and .ily files the lilypond filetype, so the grammar needs no further registration. The second autocommand runs each time a LilyPond file opens and turns on highlighting, which nvim-treesitter's main branch leaves to the user. Then run :TSInstall lilypond once. Building the parser needs the Tree-sitter command-line tool, version 0.26.1 or later, and a C compiler on the path, as every nvim-treesitter install does. With Swara installed, the installation page explains how to keep the two in step.

06.Using It from Rust

Cargo, Rust's package manager, can take a dependency straight from a git repository at a tag.

TOML
[dependencies]
tree-sitter = "0.27.1"
tree-sitter-lilypond = { git = "https://github.com/anirbanchakraborty-dev/tree-sitter-lilypond", tag = "v0.1.0" }

The crate exposes the language as tree_sitter_lilypond::LANGUAGE, the node types as NODE_TYPES, and the four queries as HIGHLIGHTS_QUERY, FOLDS_QUERY, INDENTS_QUERY and LOCALS_QUERY. Swara's own language server uses the grammar through this crate.

07.Building and Testing

Generating the parser needs the Tree-sitter command-line tool, version 0.27.1, and Node.js, because the grammar is written in JavaScript and the tool turns it into C. The tests need Rust 1.98. The first command generates the parser, the second checks the parse trees of the examples in test/corpus/, and the third runs the Rust tests.

Bash
tree-sitter generate
Bash
tree-sitter test
Bash
cargo test

cargo test includes the agreement test, which needs LilyPond 2.26.0 on the path. Without it, or with a different version, the test prints one line saying so and passes. The generated src/parser.c, src/grammar.json and src/node-types.json are committed, and they are regenerated in the same commit as any change to grammar.js, so the C that a user builds always matches the grammar beside it.

08.Licence

The grammar is under the MIT licence. It is written from LilyPond's documented behaviour and from what the compiler accepts, and no code, queries, word lists or documentation are copied from LilyPond or from any other project under the GPL.

tree-sitter-lilypondMIT

— Anirban

Hardware–software at the seam where compilers meet microcode.

FeedbackBook modePaper