Keep a song's text readable and preview supported syntax migrations with the formatter.
The canonical Volta source formatter. Design & rationale: Format and modernize source.
What it does#
Formats whitespace while preserving literal spelling, mini-notation strings, comments and their order. The printer measures nested groups and chooses a compact layout when they fit, or an indented layout when they do not. It:
- re-indents (2 spaces per bracket level, anchored at the opener's line; closers return to the opener's indent; continuations +2);
- normalizes spacing (single spaces, with fixed rules around operators — see below);
- caps blank-line runs at 2, trims line-end whitespace, one final newline;
- fits nested calls, records, patches, lists, tuples, pipelines, branches, types, mixer/song blocks and timed blocks to a target width;
- joins code again when the width grows — with room to spare (below); blank section breaks and comments remain, and function declaration heads stay on one line for the parser;
- defaults to 100 columns outside the app. Width is a target: indivisible strings, comments, and declaration heads can exceed it. Soft wrap in the editor can display those lines within the viewport without changing text.
How a song is laid out#
The layout follows how songs are read (Fmt Music):
- Operators break loosest first, operator leading. A
+chain (parallel sources) puts each operand on its own line; a*chain (a gain tail such as(…) * ampEnv * vel * 0.5) breaks once, after its first operand, and keeps its factors together. A call's arguments break last and pack. - A bracket hugs its call. The widest bracketed argument whose trailing arguments are short keeps its opener on the call's line:
tempoMap [,play (patch {,bouncesOf 3 2c (\prev -> …) seed. A pipeline whose head ends in a list, record, lambda or(patch …)keeps a short tail on the closer's line (] |> once).x with {never breaks beforewith. - Parentheses hang:
(a…+ b), never a lone(or). - Lists keep the author's rows. A list written over several lines keeps its rows (bars or sections per line) and never rejoins; an overlong row packs. A list of same-shaped rows (
tempoMap,form.markers, section lists) is a table: one row per line, columns aligned, numeric columns after the first right-aligned ([0, 128bpm]/[216, 80bpm]). Lists of up to four atoms ([8, launch],[1, !"…"]) never break. - Definitions stay vertical.
patch,song,mixerandinstrumentrecords with three or more fields are one field per line; a mixerinserts = [ … ]chain, when broken, is one effect per line. - Sibling pipelines break together. In a block of declarations between blank lines, when one pipeline of two or more stages must break, every one of two or more stages breaks.
- Alignment is computed, never preserved. After layout, the formatter pads
=in runs of declarations and record fields, thewithcolumn of a scene table, the->and:columns ofedges, table columns, and trailing comments on consecutive lines (two spaces past the longest code, while that stays left of ¾ of the width). A run is a blank-line-separated block; a multi-line entry ends it; a column that would push a fitting line past the width is skipped. Hand alignment in the input is collapsed first, so a rename re-aligns its block. A lone trailing comment keeps a gap of one or two spaces. - Rulers (
-- ── Drums ─────) are re-padded with─to 72 columns, or the width when it is narrower. - Layout is sticky. A group the source already broke rejoins only with
max(4, width/12)columns to spare, so editing a number near the edge does not flip a line back and forth. Narrowing then widening therefore does not always restore the wide layout; it converges in one pass.
What it deliberately preserves:
- tight-vs-spaced
+ - * /per operator instance, symmetrized (1/4canda*bstay tight); :/@spacing (root: Pitchvslead : Sig,@docvs@ 1/4c);f (x)vsf(x)call spacing.
volta fmt --width 72 song.volta chooses a different column count (20–1000). --preserve-lines selects the original conservative style: keep authored line breaks, normalize indentation and spacing, preserve hand alignment runs, and add pipeline/declaration breaks when needed. It can be combined with --width and --check.
FormatOptions::reflow (a byte range) reformats only the declaration blocks — runs of declarations between blank lines — that the range touches; every other block keeps its text byte for byte. Auto and LSP range formatting use it.
A file that doesn't parse is never modified. A leading -- volta-fmt: off line opts a file out entirely.
Grammar-aware anchors#
Three things the token stream alone cannot see are read off the AST (fmt.rs::Anchors), because getting them wrong made the formatter disagree with the corpus (production-gaps W2):
- declaration starts — column 0 at bracket depth 0, including each
@key "…"annotation line; - element starts in comma-less lists — the
mixer { … }grammar separates entries by newline as well as by comma, and the generic "an element starts after,/;/ an opener" test therefore only recognised the first strip entry, pushing every later one to two indent units. Mixer entries, their fields, nestedsends = { … }maps andinserts = [ … ]elements all anchor from the block's own depth, so a hand-written block round-trips unchanged; - unary minus in an insert argument — in the mixer/bus grammar application is juxtaposition and
-Nis a negated literal (clipOnly -0.5dB,limiter -1dB 45ms), not a subtraction. The generic rule sees an identifier to the left and would re-space it asclipOnly - 0.5dB— correct-compiling but misleading. Only the-tokens the parser actually consumed as an argument's sign are exempted, so ordinary subtraction is untouched.
Effect measured when the anchors landed, over the then-281-file examples/songs/: total fmt --check drift dropped from 11 808 to 9 574 changed lines, 124 songs' diffs shrank, none grew, and console.volta + nineteen.volta became fully fmt-clean. The rest still differ for the preserved-alignment reasons above (hand-aligned = runs and pipe columns that fmt collapses to single spaces) — the deferred one-shot corpus reformat below, not a bug. (Historical: alignment is now computed, and the corpus gate's churn ratchet, below, tracks the remaining distance.)
Surfaces#
| Surface | How |
|---|---|
| CLI | volta fmt [--width 72] [--preserve-lines] <files/dirs…> (in-place) · --check previews a diff and exits nonzero for changes · --modernize [--check] [--force] and --steps [--check] are separate (below) |
| LSP | textDocument/formatting — VS Code Format Document / format-on-save, Neovim vim.lsp.buf.format(); rangeFormatting reflows only the declaration blocks the range touches and returns the changed line window when it intersects the request |
| LSP code action | Align pattern steps (kind source) on a line holding a grid --steps would re-space; it applies the proven re-spacings for the whole document |
| Workbench | Format beside Run, Shift-Alt-F, or Edit → Format Document formats the active song/import to its editor width, as one undoable change |
Automatic editor layout#
The Auto button beside Format is opt-in for each full editor. It reformats after typing or resizing has settled for 650 ms, using the actual code width after the gutter and margins. It changes source text. Native jobs run on a worker thread; browser jobs share the page's compiler Worker. Revision and width checks discard outdated results. Auto reflows only the declaration blocks changed since its last check (the span between the common prefix and suffix of the two texts); its first check and any width change reflow the whole document. Incomplete source is left alone until the next edit. Selection drags, IME composition, completion, rename/code-action popups and room co-editing pause auto formatting. Source-relative editor actions finish before reflow can move their spans. An unavailable browser Worker leaves automatic formatting idle.
Formatting keeps all carets and selections attached to the same characters, including Unicode and comments. Undo restores the previous text and selection; Auto waits for another edit or resize before trying again. It does not Run or save a song. The live editor's Auto preference persists with shell settings; new import and parked editors start with Auto off. Explicit Save already formats the saved buffer; if it cannot parse, Save writes the current text unchanged.
Wrap is a separate display control: it never edits source. It is on by default; the footer wrap chip switches it off, and the choice persists in shell state (wrap=). With Wrap on, the editor allocates exactly its viewport width, disables horizontal scrolling, and resets any previous horizontal offset. Format and Auto remain available in compact editors; wrapping/folding controls move into more ….
--modernize#
volta fmt --modernize <files/dirs…> retires the legacy spellings the reference's canonical-spelling rules (Language reference §1) replace:
| before | after |
|---|---|
cutoff = const 900Hz · |> lpf (const 2kHz) (const 4) | cutoff = 900Hz · |> lpf 2kHz 4 |
const 200Hz + env * 900Hz | 200Hz + env * 900Hz |
patch { … } by { decide } | patch { … } |
(0.8) · (-3dB) as an argument | 0.8 · -3dB |
bright = macro "bright" 0.5 | bright = macro 0.5 (the knob takes the binding's name) |
every 4 rev p (pattern-last) | (p |> every 4 rev) (Read and write pattern pipes) |
duck "kickT" 0.6 · lastPitch "bassT" ~F2 · record "t" 2c from "keys" · vis.onset "kick" | duck kickT 0.6 · lastPitch bassT ~F2 · record "t" 2c from keys · vis.onset kick |
The track rewrite (compiler/src/fmt/track_names.rs) fires only on a plain string literal at a seat that names a track — a declared track-name port (operand::names_a_track), a shred's record … from, or a visual bridge — whose text is an identifier naming a top-level TRACK binding that no local binder shadows there ("input", "master" and bus names have no binding and stay strings). It proves itself: the rewritten file must lower to the same IR (positions aside), for the whole batch and otherwise literal by literal, so one held rewrite never blocks the rest. A feedback reference — a track read inside its own definition chain — is always held: its binding would be a cycle. The proof compiles THIS file: a library whose track a host consumes with a sole re-binding (keep = lib.kick, Names and library imports § Imported tracks) is where the two spellings still differ, so migrate a library together with its importers and compare them.
It is not the formatter: layout is left as the author wrote it (the corpus is not fmt-clean, and a reformat would bury the change). Every kind of rewrite is proven: the file is compiled before and after, and a kind is written only while the IR stays identical apart from source positions — units, diagnostics, IDs and seeded events included (fmt::legacy, modernize_proven). All kinds are tried together first; when that fails they are added one at a time, so a const that cannot go does not hold the file's parenthesised literals. A kind that would move the IR is held: the file keeps it as written and the run reports it (breathwork.volta: held 2 \const\literals), then tallies what it retired and held by kind. Two things hold a const today: a dimensionless literal whose Const node would take its slot's unit tag (() → Normalized), and a field under a record-type annotation, where resonance = 7 does not lift to Modulator Q the way const 7 does. --check previews the diff and exits nonzero for changes; --force writes every token rewrite anyway, for a reviewed migration.
Pattern subjects need a resolved builtin and a known pattern atom and keep their aliases and imports; comments, multiline calls and parenthesised subjects stay as written, and --force cannot bypass their proof. An identity derived from a byte offset holds a file — today only a seed-less neuralPrior (below); give it an explicit seed to migrate the file. orbit's sim_id follows its binding and occurrence, so it holds nothing.
The editor shows the same set as hints. Each spelling the proven rewrite would retire is a HINT-severity diagnostic on the spelling itself, naming its canonical form ("legacy spelling: write 900Hz for const 900Hz — …"): an LSP Hint (a faint dotted underline), an Info note in the workbench, and a level: "hint" row in volta --diagnostics-json with a kind of LegacyConstLiteral, LegacyParenLiteral, LegacyMacroName, LegacyDecideProof, LegacyArgumentOrder or LegacyTrackName. The track rewrite proves each literal before the kind is proven as a whole, so a held feedback reference is never hinted. A file shows no hint exactly when --modernize would leave it alone, and a held spelling is never hinted. A hint is not a warning: it never enters the module's diagnostics, so it cannot fail a gate, @strict or a snapshot. Only the open file is hinted. The song corpus shows none (compiler/tests/canonical_spelling.rs).
Step-grid alignment#
volta fmt --steps [--check] <files/dirs…> re-spaces the whitespace INSIDE flat mini-notation grids so they read by beat. Like --modernize it is a separate operation: layout outside the strings is left as written, and the ordinary formatter still treats every string as opaque.
kickPat = !"c2 ~ ~ ~ ~ ~ c2 ~ c2 ~ ~ ~ ~ ~ ~ ~"
snarePat = !"~ ~ ~ ~ ~ ~ ~ ~ c3 ~ ~ ~ ~ ~ ~ ~"
hatPat = !"c5 c5 ~ c5 c5 ~ c5 c5 ~ c5 c5 ~ c5 c5 ~ c5"
- Grouping — one space between steps within a beat, two between beats. With
beatsthe file's meter numerator (a single column-0meter = p/qormeter = (p, q)with literal integers; otherwise 4):steps / beatsper group when that divides evenly into at least 3, else groups of 4 when the step count is a multiple of 4 and at least 8. Any other string is left untouched. - Columns — consecutive lines that each hold exactly one
!"…"literal with the same step count and grouping form a run, wherever each string starts; this covers rows like[1, !"a3 ~ e4 ~ c4 ~ a3 ~"],in anarrangelist. In a run of two or more, steps are left-aligned in cells, each as wide as the widest step at its index across the run, measured from each string's own opening quote — so the cells line up oncevolta fmthas aligned the=before them. A lone string is grouped only, never padded. The last step is never padded. The result is a fixed point: a second run proposes nothing. - Eligibility is conservative. Only
!"…"literals (plain"…"strings are never touched) whose body is a flat run of self-contained steps: atom characters (letters, digits,~ # : ? = . - + ^ _ * /),[ ]balanced within one step and,only inside them ([a2,c3,e3],c4*2,bd:*,c#4=0.8). A string is skipped whole if it holds a newline,< >,{ },|,( ),@,!,%, an escape or quote,--,.., a detached_,.or?, a[closed in a later step ([a b]), a top-level,, or a step that starts or ends with an operator (c4 *2). A-- volta-fmt: offfile is skipped.
Every re-spacing is proven. Mini-notation treats any whitespace run as one separator, but the file is still compiled before and after, and a string is written only while the IR stays identical apart from source positions (the per-event source_offset/source_len inside a literal are position keys the comparison already strips). All strings are tried together first; when that fails they are added one at a time, so one held string does not hold the rest. A held string is reported (held 1 pattern string(s) — …); a file that does not compile is left untouched. The run prints a count per file and a total. Over a copy of examples/songs/ it re-spaces 11 606 strings in 1 007 files and holds none; every rewritten file compiles to identical IR in an independent check, and a second run proposes nothing. The corpus itself is not rewritten. compiler/tests/fmt_steps_corpus.rs re-proves the corpus sample with the typed origin split and checks idempotence.
Timing-version migration#
volta fmt --timing-version 1 --check song.volta # preview explicit legacy timing
volta fmt --timing-version 1 song.volta # apply a proven preserving edit
volta fmt --timing-version 2 --check song.volta # attempt a preserving opt-in
This separate operation appends or updates the entry's literal timingVersion setting without reformatting the file or writing imports. Both complete programs must compile with their imports, round-trip losslessly through the IR reader, and match apart from source positions. Units, diagnostics, IDs, seeded events and effects participate in the proof. --force cannot bypass it. Refused files are untouched and cause a nonzero exit. Missing imports and serializer collisions also refuse; a selectively imported setting must be made entry-owned first.
The proof is conservative: even a different loop representation with the same audible schedule can be refused. To intentionally change a song's rhythm, review its schedules/audio before changing the setting yourself. Unversioned files use version 2; ordinary formatting and --modernize do not add a version declaration. The migration proof compares the current compiler's interpretation. To retain the former behavior of an older unversioned file, add timingVersion = 1 explicitly before using the formatter. See Patterns: events over time §Timing versions and Pattern Timing.
Guarantees (tested)#
compiler/tests/fmt_corpus.rs proves, over the corpus sample in compiler/tests/fixtures/test_corpus.txt (every .volta in examples/songs/ under VOLTA_FULL_CORPUS=1):
- token stream identical (⇒ same program);
- comments identical (text + order; a ruler compares without its
─run); - idempotent (
fmt ∘ fmt = fmt); - compiled IR identical modulo source offsets — strictly for every file;
- idempotent through the wrapper: each file is also fed back with its
|>/ post-=breaks re-joined (a whitespace-only "unwrap"), and the formatter must converge on that in one pass too (the suite prints how many files qualified; the rest have no re-joinable break). Invariant 3 alone only exercises long-line breaking when a song happens to be checked in over-long, which is how the wrapper shipped needing two passes to settle: it broke a 116-column binding at its|>without re-measuring the 106-column head left behind, and derived a broken-off stage's indent from the line it came off rather than from the re-indenter. The gate is what keeps both fixed once a song is reflowed around the symptom (as burndown.volta was, 465b571). - reflow is idempotent at 40, 72 and 120 columns, preserves tokens and comments at every width, and widening back to 100 columns converges in one pass (it need not restore the original layout: authored rows and sticky groups are kept), with compiled IR verified at 40, 72, 100 and 120 columns; no source file is reformatted on disk;
- churn ratchet — the suite prints how many sample lines the formatter would change and fails above its pin (
CHURN_PER_MILLE_PIN, 500‰; the Stage 3 printer scored 578‰ on the same sample). Lower the pin when the number falls.
Identical token KINDS do not prove identical parsing: the lexer keeps line_start / col0 trivia the parser reads. So the formatter owns layout only — it keeps a function declaration's head on one line, cuts at AST declaration boundaries, and the gate compares compiled IR at every width rather than trusting the token stream. The corpus sample is deliberately not fmt-clean.
Generative salts are whitespace-stable (Stage 2, lower/salt.rs). rwalk/humanize once salted their seed with the expression's source byte offset, so any whitespace edit above one re-rolled its baked events. They now seed from the owning binding name + occurrence index, so a formatting (or any whitespace) edit never changes the music. The corpus test keeps its probe self-calibration as a regression guard: the offset-seeded bucket is asserted empty — reintroducing an offset seed (or adding a generative combinator that bypasses lower::next_salt) fails the test. See Format and modernize source §6.
Region identity is whitespace-stable for the same reason. A region with no explicit id = "…" is given a document-local one derived from the owning binding and the region's position in its list — not from a source offset — so re-indentation, a wrapped line and an added comment all leave a placement's identity alone, and selection, Undo and UI write-back survive a format (Write a song as a timeline). compiler/tests/timeline.rs pins it by compiling a tight and a loosely formatted spelling of the same two regions and comparing the ids.
@identity "oldBindingName" pins a binding's compile-time random coordinate across subsequent renames. The formatter preserves this annotation. Without it, the binding's name remains part of the salt for compatibility. See Patterns that respond to playback.
Not yet / deferred#
Deliberately not shipped; harvested to Ideas rather than tracked as open stages (Format and modernize source §6):
- Workbench format-on-save toggle — Save already formats the saved buffer; there is no separate setting to disable that behavior.
- The one-shot corpus reformat — the corpus test proves it is safe, but landing the churn commit is the maintainer's call; the shipped songs are still hand-aligned in their original styles.
- Pipeline stage argument columns (
|> grainSize 140ms) are not aligned; a+chain whose last operand is… with {breaks at the+before hugging the record (Fmt Music §4). neuralPrior's seed still falls back tospan.startrather than the Stage-2 salt (a neural-only path outside the salt scope).
Files#
compiler/src/fmt.rs (spacing, options, block-local reflow) · compiler/src/fmt/pretty.rs (music-aware layout, the alignment post-pass, unit tests) · compiler/src/fmt/legacy.rs (legacy spellings, the modernize proof and hints) · compiler/src/fmt/steps.rs (step-grid alignment) · compiler/tests/fmt_steps_corpus.rs · compiler/tests/fmt_corpus.rs (invariants) · cli/src/main.rs (run_fmt) · lsp/src/server.rs (formatting) · ui/src/editor/formatting.rs (worker + editor transactions) · ui/src/app/buffers.rs (workbench command) · ui-wasm/src/web.rs and runtime/public/studio-analysis.js + runtime/public/studio-analysis-worker.js (browser worker transport).