nurlfmt)
This page specifies the deterministic source-level formatting that nurlfmt produces. The rules are opinionated and non-configurable: there is one canonical shape per source, full stop. Same ethos as gofmt / zig fmt / rustfmt --edition=....
This is the dual of the byte-identical-IR bootstrap acceptance criterion: nurlc(x) == nurlc(nurlc-roundtrip(x)) checks that the compiler is deterministic; nurlfmt(nurlfmt(x)) == nurlfmt(x) checks that the formatter is. The two together pin the project's output stack from source through IR.
The rules are designed to:
grammar has no precedence and no implicit grouping, so a brace-/paren-/bracket-aware token walker can produce the canonical form without building a CST,
the line break carries information (block bodies, top-level declarations, multi-arm matches) and collapse it where it does not (intra-expression alignment whitespace, blank-run collapsing),
semantically-equivalent program means models can target a single layout, and reviewers can run nurlfmt --check to gate diff noise.
{ and closed by }. Parentheses
(...) and brackets [...] do not add indent levels (call arguments and slice/type-list bodies stay on one line by default, see §6 for the multi-line rule).
} aligns with the column of the construct that
opened the matching {.
@ fizzbuzz i n → v {
: ~ i i 1
~ <= i n {
: b div3 == 0 % i 3
: b div5 == 0 % i 5
? & div3 div5
{ ( nurl_print `FizzBuzz\n` ) }
? div3
{ ( nurl_print `Fizz\n` ) }
{ ( nurl_print `\n` ) }
= i + i 1
}
}
multi-space alignment, no zero-width separation.
:: glue inside a single IDENT (json::parse →
json__parse) is preserved verbatim — :: is part of the token, not a separator.
sigil is part of the token itself (e.g. negative literals -7, -3.14 lex as one INT/FLOAT token; the - carries no space). All prefix operators that are their own token (!, ^, ~, \, ., #, ?, ??, =, :, ;, Z) take one space before their next token.
| Form | Canonical shape | |
|---|---|---|
| Call | ( fn arg arg ) | |
| Empty call | ( fn ) | |
| Slice literal | `[ T \ | 1 2 3 ]` |
| Empty slice | `[ T \ | ]` |
| Generic instantiation (type pos) | ( Vec i ) | |
| Type-arg list (call site) | ( id [ i ] 42 ) | |
| Type-param list (decl) | [ T ] / [ A B ] | |
| Aggregate / enum constructor | @ Rect { 3 7 } | |
| Block as expression | { stmt stmt } (single-line OK if short) |
The rule is mechanical: the opening delimiter is followed by one space, the closing delimiter is preceded by one space, **but both sides collapse to nothing when the inside is empty**: ( fn ) keeps the spaces because the inside is non-empty (the fn name); {} keeps no spaces because the inside is empty (legal as a no-op arm in a ternary, see fizzbuzz).
The (@ R P*) function-type form: the lexer accepts both the (@ digraph and the spaced ( @ … form; nurlfmt canonicalises to the spaced form ( @ R P* ), like every other parenthesised form.
{ ... } block, **each statement starts on its own
line.** A statement is a : let, = set, ; defer, ~ loop, ~ IDENT ... foreach, or a side-effecting expression.
?? expr { ... }) follow the same rule: each arm onits own line.
{ stays on the same line as the construct that ownsit.
} is on its own line, aligned with the opener.one line. The user is free to break a long expression across lines with their own newlines + indent, and nurlfmt **preserves user-introduced newlines inside a single expression** (see §7).
$, &, %, @, : decl) areseparated by exactly one blank line. Multiple consecutive user-introduced blank lines collapse to one.
if the user wrote one, but multiple consecutive blank lines collapse to one.
{ or immediately before }.\n (one final newline, notrailing blank line).
nurlfmt v1 does not automatically wrap long lines. Rationale:
one wrap "more right" than another.
( fn arg arg ) already gives the user anatural wrap point (each arg on its own indented line) when they want it; preserving that decision avoids fighting the user.
forms fit comfortably on 100 columns once stripped of decorative whitespace.
A future v2 may add a soft column budget (probably 100), but v1's contract is "preserve the user's expression-internal newlines if present, otherwise keep the expression on one line."
If the user breaks an expression across lines (commonly inside a ternary cascade or a multi-arg call), nurlfmt preserves those line breaks but **re-indents the continuation to the surrounding block's indent level** — it does not bump them by an extra level for cascading-construct alignment.
<!-- doctest-prelude @ a → v {} @ b → v {} @ c → v {} : b div3 T : b div5 F -->
// Before (a cascade the user broke across lines and indented by hand)
? & div3 div5
{ ( a ) }
? div3
{ ( b ) }
{ ( c ) }
<!-- doctest-prelude @ a → v {} @ b → v {} @ c → v {} : b div3 T : b div5 F -->
// After (formatter keeps the user's line breaks but indents every
// line at the surrounding block's level — no cascading bump):
? & div3 div5
{ ( a ) }
? div3
{ ( b ) }
{ ( c ) }
This is intentionally simple: indent is purely a function of brace depth (§1). A cascading-construct heuristic (bumping continuation lines of a multi-line construct by one extra level) is a possible future extension. It is the single biggest visible diff between nurlfmt output and conventionally hand-formatted source. The diff is layout-only: the formatted source still compiles to the same LLVM IR as the original, which compiler/tests/nurlfmt_idempotent.sh enforces (idempotence + IR equivalence over stdlib/, examples/, compiler/tests/ and the compiler itself).
Two recognised forms (grammar §LEXICAL):
// ... \n — line comment, runs to end of line.load).
Comment placement is preserved exactly in two flavours:
same line, separated from the preceding token by two spaces. `nurl : i n 0 // counter `
line at the same indent as the next non-comment token. `nurl // Boot-time setup. Plants index.html on first run. @ setup_public_dir → v { // … } `
A comment that the user placed between statements on its own line stays on its own line. A comment whose original position is on a line by itself is never collapsed onto an adjacent statement.
// and the comment text are preserved verbatim — internal whitespace and any trailing spaces in the comment body are kept as written.
Backtick-delimited strings are emitted verbatim, including their escape sequences (\n, \t, \r, \\, and any pass-through \X). The STR token is opaque to the formatter — it is never re-tokenised.
A - immediately followed by a digit (no intervening whitespace) is a single INT/FLOAT token in the grammar. nurlfmt emits it the same way: -7, -3.14. Binary minus stays separated: - a b for a - b.
nurlfmt(nurlfmt(x)) == nurlfmt(x) byte-for-byte for every legal NURL source x. This is enforced by compiler/tests/nurlfmt_idempotent.sh, which runs the formatter twice over the entire stdlib/, examples/, compiler/tests/, and compiler/nurlc.nu source tree and diffs the second pass against the first.
A second test confirms semantic equivalence: every formatted file must compile to the same LLVM IR as its original via nurlc. This catches any case where the formatter changes a token's context (e.g. by losing or inserting a space that affects lexing rules — the < < vs << boundary, the negative-literal-vs-binary- minus boundary).
// rough indentation, mixed widths, decorative columns
: Counter { i n i max }
@ inc_counter Counter c → Counter {
: ~ Counter r c
= . r n + . r n 1
^ r
}
@ main → i {
: ~ Counter c @ Counter { 0 10 }
= c (inc_counter c)
= c ( inc_counter c )
^ . c n
}
// rough indentation, mixed widths, decorative columns
: Counter { i n i max }
@ inc_counter Counter c → Counter {
: ~ Counter r c
= . r n + . r n 1
^ r
}
@ main → i {
: ~ Counter c @ Counter { 0 10 }
= c ( inc_counter c )
= c ( inc_counter c )
^ . c n
}
Concretely: indent normalised to 4 spaces, decorative inner spacing collapsed, ( inc_counter c ) re-spaced canonically (( fn ... ) keeps one space inside), top-level declarations separated by exactly one blank line, and the duplicate blank line between the two = c statements inside main collapsed to one.
nurlfmt [OPTIONS] [FILE...]
Options:
--check Exit 0 if the input is already canonical, exit 1
otherwise. Print the names of files that need
reformatting to stderr. Emits no source, including
with stdin. Suitable for CI.
--write Reformat each FILE in place. Without this flag the
formatted source is written to stdout.
--stdin Read a single source from stdin and write the
formatted result to stdout. Cannot be combined
with FILE arguments or --write.
-- End options; remaining arguments are file paths.
With no FILE and no --stdin, reads stdin → writes stdout (same as
--stdin).
Exit codes:
0 — success (formatted output written, or --check passed).1 — --check found non-canonical input.2 — usage error, I/O error or a NUL byte in the source.
--check and --write are mutually exclusive; --write requires file paths. Unknown options are errors. Read failures preserve the file, and NUL bytes are rejected before formatting to prevent writing only a source prefix. Writing an already canonical file leaves its modification time unchanged.
tools/nurlfmt/format.nu exposes format_source String → String for repeated use. It borrows the source and returns an owned result, dropped with the caller's binding. Each call's token stream is two allocations — the token vector and one byte buffer holding every token's NUL-terminated text, the EOF sentinel's included — released when the call returns.
:-typed columns or =-targets across lines.range formatting is a v2 concern).