Skip to content

Trustworthy LilyPond reading; release 0.3.0 - #11

Merged
matteospanio merged 10 commits into
masterfrom
dev
Sep 27, 2026
Merged

matteospanio merged 10 commits into
masterfrom
dev

Conversation

@matteospanio

Copy link
Copy Markdown
Member

LilyPond input you can trust (Epic J, J0–J7). Released as 0.3.0. See docs/changelog.md for the list of changes and docs/devlog.md for the notes.

What changes

  • No panic reaches Python. Readers raise lytk.ParseError (a ValueError); a Rust panic anywhere is lytk.InternalError, with nothing printed. The reader is bounded (500,000 notes, 100,000 whole notes or bars, 2,000 nesting levels), and flatten stops a diamond of includes.
  • Diagnostics and strict mode:
    • lytk.check_lilypond(text, semantic=False) and lytk check FILE… report Diagnostics: syntax errors from the tree (0.08 ms median per file), and with semantic=True what the walk rejects or does not read;
    • strict=True on every LilyPond reader raises LilyPondSyntaxError;
    • Score.diagnostics / MusicDocument.diagnostics.
  • Strings and headers are decoded as LilyPond's lexer does, in every context. \markup and #"…" header values are text, headers are scoped per movement, and there are Score.header and lytk.header_fields (with spans).
  • Pitch language: \include "english.ly" and the other language files set the language; \language works inside \score and music.
  • LilyPond semantics:
    • a movement per top-level music expression;
    • notes outside braces are a syntax error;
    • \book/\bookpart;
    • drum mode (GM keys, percussion staff, channel 10);
    • \fixed;
    • \chords/\figures/\lyrics;
    • markup is text, not notes.
  • Release hygiene: lytk.__version__, version from Cargo.toml only, Python CI on 3.10–3.13, and the release workflow refuses a tag that is not the version.

Checks

  • New LilyPond corpus CI job on LilyPond 2.26's 2,626 regression files and snippets:
    • 1 file with an error (a grammar gap) and 0 refused;
    • 499/502 structural mutations detected;
    • the texidoc/categories of the 389 snippets equal an independent decoder.
  • LilyPond-shaped fuzzing (1,024 cases in CI) and a 308-case hunt through every reader, writer and transform. Every panic, hang and quadratic path they found is fixed.
  • 1,143 Rust + 229 Python tests; all CI jobs green.

🤖 Generated with Claude Code

matteospanio and others added 10 commits September 27, 2026 15:03
Three releases that make lytk a dependable reader of LilyPond, from
lilycorpus's request list checked against 0.2.0:

- J (0.3.0): no panic reaches Python (six known families, a firewall,
  LilyPond-shaped fuzz), an exception hierarchy, diagnostics with a strict
  mode and check_lilypond(), strings read whole (one fix for fifteen
  callers) with a header accessor, pitch-language files, release hygiene.
- K (0.4.0): \version API, includes from strings, tokens, statistics.
- L (0.5.0): robust FolderDataset, a records dataset, ids, API reference.

The devlog records the grammar measurement behind J: 2 of 2,741 valid
LilyPond files produce ERROR nodes, every structural error tried is
caught, lexical and semantic ones are left to the walk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
… reading

J0 — tests/ly_corpus.rs: the grammar and the reader against LilyPond's own
regression tests and snippets (2 of 2,626 valid files flagged, none
refused; CI job lilypond-corpus on v2.26.0) and a structural-mutation
board on the committed fixtures.

J1 — no panic reaches Python:
- every binding runs in guard(): a Rust panic becomes lytk.InternalError
  (base lytk.LytkError) and prints nothing;
- the six known panic families are fixed at their source, the shared
  sinks are total, from_dict/from_json validate hand-supplied IR and the
  transforms bound their arguments;
- the LilyPond reader has bounds (500,000 elements, 100,000 whole notes and
  bars, multipliers up to 2^24, nesting 2,000, octaves -128..127) and walks
  on a 64 MB thread, so the hunt's hangs, OOM aborts and stack overflows
  are refusals (ValueError) instead;
- \repeat unfold inside \relative repeats the same pitches, as LilyPond does;
- MusicXML divisions fall back to 10080 instead of overflowing or being
  truncated to u16.

LilyPond-shaped fuzzing and the hunt's inputs are in tests/fuzz_inputs.rs
(LYTK_FUZZ_CASES for more cases; 1,024 in CI).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Plain \include inlines every time, so a file including the next one twice,
level after level, doubled the output per level until memory ran out.
Flattening now stops with FlattenError::TooLarge (a ValueError in Python)
after 10,000 includes or 64 MiB of output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ct mode

J2: every reader raises lytk.ParseError (LytkError and ValueError) on input
it cannot read, and LilyPondSyntaxError (a ParseError) carries .diagnostics.
A non-UTF-8 .ly is a ParseError; I/O stays OSError.

J3: Diagnostic (src/diagnostics.rs). Syntax checks from the tree alone
(ly_to_ir/syntax.rs): ERROR and MISSING nodes, unclosed brackets, a >>
without <<, the leftovers of a Scheme expression missing its "(", a markup
\override without a pair. Semantic ones at the walk's drop sites: invalid
durations and ratios, plain text, unknown commands (LilyPond 2.26's
commands come from scripts/ly_builtins.py), includes, unknown languages,
dropped music. check_lilypond, strict=False on the LilyPond readers,
Score/MusicDocument.diagnostics, `lytk check`. Markup is read as text, not
music; a byte-order mark is whitespace anywhere.

Boards on LilyPond 2.26.0: 1 of 2,626 valid files with an error (a grammar
gap), 499 of 502 broken fixtures caught, check median 0.08 ms.

Also: a tremolo past :1024 no longer overflows the LilyPond writer (found
by the fuzzer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
J4: ly_to_ir/text.rs decodes strings as LilyPond's lexer does (\n, \t, \\,
\", \'; other backslashes kept) in every context; a string used to end at
its first escape. Header values given as \markup become their plain text
and #"…" their string; each \score's \header stays with its movement and
the top-level one fills every movement. Score.header and
MusicDocument.header give every field; lytk.header_fields(text) locates
each one (character span, score index) from the tree alone. The writers
write every header field (sorted, quoted keys) and the text at notes, which
the Score path lost. The 389 snippets' texidoc and categories equal an
independent decoder (snippet_headers).

J5: \include "english.ly" and LilyPond's other language files set the
pitch language (arabic.ly: Italian); makam, persian, bagpipe, hel-arabic and
turkish-makam raise unknown-language. \language works inside \score and
music, and an unknown name keeps the language in force.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
One version source: Cargo.toml (pyproject declares it dynamic; uv.lock
records it so), exported as lytk.__version__ and printed by lytk --version.
The release workflow fails when the tag is not the version; the Python CI
job runs on 3.10–3.13. Stale docs corrected: the reader does not follow
\include (SECURITY.md, the 0.1.0 changelog), the pre-0.2.0 MIDI limitations,
design.md's test counts, development.md's Rust CLI, T11.2. The vendored
grammar's upstream commit (b3b38a6) is recorded.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The five areas the first hunt left (commands, values, modes, resources,
other readers): 308 cases through every reader, writer, transform and
representation, one process at a time under a memory limit.

- \ottava past three octaves panicked (n * 8, abs); sizes are MusicXML's
  8/15/22 everywhere now (the writers wrote \ottava #1 for a 15ma, or #8
  for an 8va, and lost an 8vb's direction).
- A **kern note with 256+ dots panicked.
- A MusicXML note of 25 million whole notes made the ABC writer tie it over
  every bar line until an allocation aborted: the MusicXML, ABC and kern
  readers refuse music past 100,000 whole notes (as LilyPond and MIDI do),
  and the ABC writer, fallible now, refuses more than 100,000 bars.
- Quadratic time: meter changes in Grid::build (5.8 s → 0.6 s), tuplet runs
  in the ABC writer, grace runs in the Music-path LilyPond writer, and
  diagnostics on one long line (columns are counted on along the line).
  repeated_constructs_scale_linearly pins them with a time ratio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Each top-level music expression is a movement, in order with the \score
blocks, as LilyPond makes a score of each; music beside \score and a music
variable at the top level are read, no longer dropped. Notes outside braces
at the top level are a syntax error. \book and \bookpart are walked like
the top level.

Drum mode reads LilyPond's 128 drum names as General MIDI keys on a
percussion staff (table generated from drumpitch-init.ly). \fixed reads
absolute pitches from its reference. \chords, \figures and \lyrics no
longer read their contents as notes.

Context bodies go through walk_body, so \new Staff \new Voice { } and a
mode prefix stay in their context; part ids no longer skip P1 after a
variable definition.

Corpus: dropped-music 77 -> 0, unrecognized-token 437 -> 247; board (a)
unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
After writing the figures due at a beat, a grace note (which takes no
time) asked for the figures of an empty range, and BTreeMap::range
panicked. Found by CI's fuzz job on \figuremode inside music, which J7
now reads as figures; \new FiguredBass hit it before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Epic J (J0–J7, trustworthy LilyPond reading): the changelog's
[Unreleased] becomes [0.3.0] - 2026-09-27; roadmap and development log
updated. The version is 0.3.0 in Cargo.toml since J6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@matteospanio
matteospanio merged commit 8b66432 into master Sep 27, 2026
24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant