Syntax Design
Status: active design (2026-09-04)
- Normative description of the target syntax: Syntax
- Implementation plan: syntax-rewrite
This note is the record of the research behind blr's surface syntax: what
other designers wrote, the principles distilled from it, surveys of specific
constructs (quote, sum labels), and every decision point with its rationale.
The design is the "whatever is easiest to implement" era re-examined against
those principles.
1. Research: what other designers wrote
| Source | Key ideas | What blr adopts |
|---|---|---|
| Bjarne Stroustrup, The Design of A Computer Language (CACM 56(9), 2013) | A language must grow but stay coherent; a small set of core concepts should compose; the language is a tool for thinking; don't burden the user with what the compiler can do. | Orthogonality: few forms, freely nestable (P8). |
| Thompson & Pike, The Go Programming Language (2015), preface | "The language is small, so it is easy to learn and the grammar is simple and consistent." Go's grammar is teachable in an hour. | A grammar a newcomer can hold in their head (P1, P4). |
| Rob Pike, Go Proverbs (Gopherfest 2015) | "Clear is better than clever." "gofmt's style is no one's favorite, yet gofmt is everyone's favorite." | Favor the boring, standard spelling over the clever short one (P1, P4). Mandate a formatter; syntax formattable without type info (P12). |
| Milner, Tofte & Harper, The Definition of Standard ML (1997), §1 design rationale | ML was designed to be "expressive, elegant, simple, consistent"; the surface syntax should have no "clutter or noise". | Minimal keywords in favor of identifiers (P5); one spelling per concept (P2). |
| PEP 20, The Zen of Python | "There should be one — and preferably only one — obvious way to do it." | No alternate spellings for the same construct (P2, P3). |
| Python docs, Design and History FAQ ("Why does Python use indentation for grouping of statements?") | Grouping delimiters must be unambiguous — the reader (and editor) must never have to guess which END closes which BEGIN. Whitespace-based grouping trades symbols for fragile line-structure dependence. | Open/close pairs for all blocks (P6); nothing hidden in whitespace (P7). |
| K&R, The C Programming Language (1978/1988) | C is a small language with a minimal grammar; familiar C constructs are what decades of readers already know. | The "familiar" punctuation set is the C-family vocabulary (P1, P4). |
| Stroustrup, The Design of the C++ Programming Language (1994) | New features must be justified against existing ones; consistency beats cleverness. | Every new syntax feature must be justified in the decision log (§7). |
| Ousterhout, A Philosophy of Software Design (2018) | Cognitive complexity: the number of distinct concepts a reader must hold at once; the principle of uniformity — do things one way. | Every token is a concept to remember; the token vocabulary has a cognitive budget (P3, P4). |
| Bob Nystrom, How to Design Good APIs and Frame Rule-Based Decisions (2024) | Consistency, discoverability, predictability: "make the obvious choice the right one". | Syntax should be self-describing: shape determines meaning (P2, P7). |
| The Rust Programming Language (ch. 1) & The Rust Reference | Design goal of predictability; the Reference publishes the full grammar as the single source of truth. | Publish a full EBNF grammar in-repo; no hidden rules (P12). |
| Zig language docs ("Design", "Language Reference") | "explicit is better than implicit"; no hidden control flow, no magic. | P7; nothing may depend on position or context. |
| Ruby community, Principle of Least Astonishment (POLA) | The language should not surprise a competent programmer coming from mainstream languages. | Operationalizes P1: when in doubt, spell it the way C/JS/Rust/Python/OCaml already do. |
| Cognitive load theory (Sweller, 1988 et seq.) | Working memory holds a handful of items; unfamiliar or inconsistent encoding forces readers to spend it on decoding instead of understanding. | The reason "familiar + consistent" beats "concise" (P1, P2). |
Cross-cutting observation: every surviving language (C, Go, Rust, OCaml,
Haskell, Python) made the same trade-off — decode cost beats writing cost.
Syntax is optimized for a reader who may never have seen the language, not
for the fastest keystrokes. Blr makes the same trade.
2. Principles
P1 — Familiar
Blr code should be decodable by someone who has never read blr docs, using
only the conventions of C, JS, Rust, OCaml, and Python. New spellings are
allowed only for constructs with no mainstream precedent, modeled on the
closest precedent. POLA test: show the snippet to a programmer from another
language; if they guess the meaning wrong, the syntax is wrong.
P2 — Consistent (isomorphic)
The type of a value and the value of that type should be written the same
way; the same mechanism in the same position everywhere; one spelling per
concept. Target:
| Concept | Type spelling | Value/definition spelling |
|---|---|---|
| function | fn (x: T) -> U | fn (x) -> x · fn f(x: T) -> U { ... } |
| record | {x: f64} | {x: 1.0} |
| list | []T (nested: [][]f64) | [v, w] |
| sum | [ A unit, B P ] | 'T::A(v) / 'A(v) · match t { T::A(_) => 1 } |
P3 — One meaning per token
Every token means exactly one thing, everywhere; meaning must not depend on
position or the type checker. A token may be reused only if (a) the two uses
are in positions that cannot occur together, or (b) mainstream languages
already reuse it that way (e.g. ( for grouping and call).
P4 — Minimal symbols
The punctuation budget is the C-family set:{ } ( ) [ ] , ; . : " / + - * = > ! | ' #. A new punctuation character
requires a decision-log entry proving no allowed alternative works. Prefer
multi-character familiar sequences (->, ::, ==) over exotic singles.
No Unicode punctuation, no confusable glyphs.
P5 — Minimal keywords
A keyword is an identifier the user may not name. Use a keyword only when
it informs the parser or the type checker. Decisions: true/false
demoted to built-in identifiers; match kept as keyword for P1 reasons
(overriding P5, recorded). No keyword is needed for sum types: [ A unit, B P ] is unambiguous by position (type position, comma-separated case
pairs) — the rejected sum keyword is recorded in the decision log.
P6 — Open/close pairs for every block
Every syntactic group is enclosed in a matched pair. A pair's role is fixed
by the form that introduces it: bare { ... } is a block or record;match { ... } pins arms, # { ... } pins a quote block. In type
position [ ... ] is a sum type (comma-separated case pairs) while the
prefix []T is a list type — decided by the token after [. No construct may end implicitly.
P7 — No hidden syntax
Nothing about meaning may depend on whitespace, line breaks, file position,
or a previous declaration. No context-sensitive tokens; no grammar "mode"
that changes mid-file.
P8 — Orthogonal composition
The same small set of expression forms works at every position and depth.
Prefer rewriting a special form into the general one.
P9 — Frequency-based explicitness
Frequent constructs get the shortest form; rare constructs the most
explicit. Quote is extremely common → one-character prefix. Sum types
are rare → their spelling may be less compact than a common one (they
share [ ... ] with list literals and rely on position), but common
forms never get longer to pay for it.
P10 — Uniform punctuation placement
One convention per punctuation role, documented in the grammar: , between
siblings (trailing allowed everywhere, required nowhere); ; statement
separator; : only for name : type and record fields; -> only for
function results.
P11 — Naming conventions (lint-enforced, not grammar)
One lexical class of identifiers; case carries meaning only by documented
convention + lint. PascalCase for types and sum case labels; snake_case
for values/functions/modules/fields; 'a for type variables.
Considered and rejected: Haskell-style case-as-lexemes (Varid/Conid
split at the lexer). Haskell is essentially the only mainstream language
that does it, it is a persistent source of confusion, and blr has a cleaner
mechanism — labels are distinguished from variables by path qualification,
not case (§5). Recorded in the decision log.
P12 — Toolable and documented
Full EBNF grammar in-repo as the single source of truth; highlighting,
formatting, and match-jump work on incomplete type-less code; every syntax
error names the construct and the expected form; every feature passes the
checklist (§8) and gets a decision-log entry.
3. Current surface inventory (pre-rewrite)
Keywords (9): pub fn type let import match externtrue false
Punctuation (15 distinct): { } ( ) [ ] , ; . : -> >>> = + - * / ! .. | ` # (comments only) @
(comments only)
Forms (as found in lang/tests/ and stdlib/):
| Form | Example |
|---|---|
| function item | pub fn main() -> i64 { ... } |
| extern item | pub extern fn id_bool(b: bool) -> i64; |
| type alias | pub type P = {x: f64, y: f64} |
| let binding | let a = 2 * 2; |
| import | import std::df |
| record type / value | {x: f64, y: f64} / {x: 1.5, y: 2.5} |
| row concat | p .. {c: 3} .. {d: 4} |
| sum type | [ A unit | B unit ] |
| sum injection | `A(unit) `Pt({x: 1.5, y: 2.5}) |
| match | match t { C(_) => 1, }` |
| closure | fn (x) => x |
| quote (compile-time) | :( fn (r) => r.b > r.a ) |
| list value | [true, false] |
| list type | list<f64> |
| pipe | df::fromcsv("test.csv") >> df::filter(f) |
| type variable | 'a |
| module path / member | std::df / df::filter / r.a |
| equality, comparison | a = b, a > b |
| top-level script mode | let a = 1; let b = 2; float(a+b)*c (no fn main) |
4. Survey: quote syntax
Constraint: blr's quote must be a syntactic form, not a function call —
a quoted expression is captured as data (compile-time rewrite to node
lists), so the type checker must not resolve a callee named quote and
type-check the argument in normal mode. The type system cannot distinguishquote-the-function from quote-the-operation, so the parser must. Every
language with this feature made it syntactic:
| Language | Quote spelling | Notes |
|---|---|---|
| Julia | :( expr ) and quote ... end | Both are parser forms; the long form is a compound statement. |
| Elixir | quote do ... end | Keyword special form; argument parsed but not type-checked normally. Direct precedent for "quote must be syntactic". |
| Scala 3 | '{ expr } (expr), '[ T ] (type) | ' prefix + paired block. |
| Scheme/Lisp | '(...) / `(...) | Prefix quote on a group. |
| Clojure | (quote x) / ` / ~ | Function and quasiquote operator coexist. |
| Haskell | `[ | ... |
| Racket | #'(...) | Prefix on an s-expr. |
Observations: (1) the cheap forms are all short prefixes (:, ', #)
— never a bare word, because a bare word would collide with the caller's
namespace (a function could be named quote); (2) short-form languages make
the prefix unambiguous by position — it only ever starts an expression;
(3) the paired-block form keeps true open/close pairs intact.
Decision (2026-09-04): # prefix quote, #{ ... } paired block form.
#expr— quotes the next complete expression (Scheme-'boundary model).#{ ... }— paired form for a group: multiple statements, or nested
structure where a single prefix expression is hard to bound.- Rationale: cheapest possible spelling for a very common form (P9);
#
was free once comments moved to//; prefix-reader precedent (Racket#/#', F##); no P3 collision. - Superseded candidates, kept on the record:
'{ expr }(Scala 3; true
pair, unified'family with'a, but 3+2 keystrokes) and:( expr )
(Julia; shortest familiar form for the data community, but not a true pair
and a third meaning of:).
5. Survey: naming sum case labels
With backticks off the table, how is a case label like A told apart from a
user variable named A? Options considered:
| Option | Spelling | Precedent | Cost |
|---|---|---|---|
| A. Namespace + position | A(unit), match t { A(_) => 1 } | Rust, OCaml, Scala, Kotlin, Swift | A resolution rule to document; bare names stay in the danger zone of user variables |
| B. Backtick prefix (status quo) | `A(unit), `A(_) | OCaml | The odd character (P1/P4) — what we are trying to remove |
| C. Owner-qualified path | T::A(unit), ::A(unit), match t { T::A(_) => 1 } | Rust (Enum::Variant), Zig (Color.red), Swift, Go | Verbose in match arms (accepted — arms are self-documenting) |
D. case keyword in patterns | match t { case A(_) => 1 } | Scala, Go switch, Java | Keyword per arm (P5); still leaves injection naming to A or C |
Decision (2026-09-04): C — owner-qualified paths, with a bare top-level
form.
T::A(unit)— caseAof sumT, qualified by its owner.— leading-::A(unit)::top-level form, superseded by the
anonymous'A(unit)(§5.1).- Match arms take the same path form:
match t { T::A(_) => 1, T::B(b) => b.x }. - Inside the definition, cases are written bare — declaration context binds
them:type T = [ A unit, B P ]defines the labelsT::A,T::B. - Why over A: a value can never begin with
::, so labels cannot collide
with user variables in any position — no resolution rule needed; the
"PascalCase variable" concern is void by construction. Rides the existing::path syntax (std::df,df::filter) — no new token. Self-documenting
arms; same-named cases across sums coexist (T1::A,T2::A). - Accepted cost (recorded against P9): match arms are longer (
T::A(_)vsA(_)); long owners can alias through the module path.
5.1 The construction ambiguity (2026-09-07)
The §5 decision made an injection a path application — T::A(x) is
syntactically identical to calling a function A in module T. The first
implementation (commit 2026-09-07, decision-log entry of the same date) broke
the tie in the crust lowering: an application whose final path segment is a
case of an in-scope sum lowers to an injection. That is wrong in three ways:
- P3 — the same tokens mean two things; the meaning depends on
declarations, not syntax. - P7 (spirit) —
T::A(x)changes meaning if a sum namedTappears
(or disappears) elsewhere in the file. When a local sumTand an
imported moduleTboth exist, the local sum silently wins. - P12 — an editor cannot classify
T::Aas constructor or callee
without full resolution.
Rust, Scala, Swift, and Zig all ship bare Owner::Variant(x) construction
without a marker. They can, because their namespaces forbid T from being
both a module and an enum in one scope — T::A is never both a function
and a case. blr has one item namespace, so the collision is legal; the Rust
model is unavailable here. The mode must be switched by syntax.
Constraint (agreed): the T::A path stays — it names the case explicitly and
reads well — but the path is used only to check that the final segment is
a case of the named sum (and the payload type matches), never to choose
between syntactic modes. The mode switch must be a lexical marker the parser
can see.
| Option | Spelling | Precedent | Pros | Cons |
|---|---|---|---|---|
| 1. Tick prefix | 'T::A(x), '::A(x) | blr's own 'a type variables; Scheme ' literal; Scala 3 ' quote family | One char (P9); ' is already in the P4 budget; no keyword (P5); the parser decides with zero resolution; the path is kept for checking; good error site ('T::B(1) → "T::B is not a case of T") | A second meaning for ' (P3 rule (a) — the two positions must stay disjoint); POLA risk: some readers reach for "quote" first (mitigated: blr's quote is #, and "literal value" is the closest correct decode) |
2. case keyword | case T::A(x) | Scala patterns; Go/Java switch | Self-documenting; zero token collision | A keyword per construction (P5, P9 — injections are a common form); P1: case T::A(x) decodes as the start of case analysis (Scala/Haskell), not as building a value |
3. @ prefix | @T::A(x) | C# identifier escape; Java/Scala annotations | A free character, no keyword | Not in the P4 budget (needs a justification entry); P1: decodes as attribute/annotation — a wrong guess |
| 4. Backtick prefix | `T::A(x) | OCaml; the pre-rewrite status quo | Solved this exact ambiguity before | Already rejected by the §5 rewrite — the odd character (P1/P4) is what we removed |
| 5. No marker (Rust model) | T::A(x), resolved later | Rust, Scala, Swift, Zig, Go | No new syntax | Requires the separate namespaces blr does not have; P3/P7: meaning depends on declarations; P12: untoolable |
Decision (2026-09-07): option 1 — tick prefix, with an anonymous label.
- Qualified:
'T::A(x)— caseAof sumT. - Anonymous:
'A(x)— the same, without naming the owner. Both spell
the same node: a value labeledA. There is no name-level resolution of
the owner at all — if two sums have a caseA, the expected type of the
position picks which one the value is (a case that is not in the declared
sum is a row-mismatch type error, like a bad match arm). This replaces the
leading-::top-level form, which is gone (from expressions and
patterns); imported sums stay qualified ('sums::Shape::Circle(2.0)). - Grammar: one new term alternative —
'InjectPath( Expr ), whereInjectPathis one or more::-separated identifiers. The tick is the
mode switch; everything after it is an ordinary path. No type info
anywhere in the parse. Unit payloads keep the(unit)spelling for now —
one form — revisit under P9. - The path is not consulted by the compiler at all: the label is the
final segment and nothing more; the owner (T::,import::T::) exists
for the reader. The lowering emits a labeled value with no lookup — no
sum-declaration map, no diagnostics from the lowering. Whether the value
has the right type is the inference phase's job: the injected row is
unified against the declared type of the position
('T::C(unit)where the position expectsT = [ A unit, B unit ]is a
row-mismatch type error). With no declared type in scope (a generic'aresult, an inferred let), the value simply gets an anonymous sum
type — the same treatment as any other unconstrained expression. - P3 entry for
': type variable (type position) vs injection prefix
(expression position). The two positions are disjoint nonterminals
(types occur only after:in parameters, intypedeclarations, and in
extern signatures); the grammar must keep it that way. Rule (a) applies —
recorded, not bent. - Patterns: qualified arms stay bare —
match t { T::A(_) => 1 }— and gain
the anonymous formA(_)mirroring'A(x). Pattern position has no
application, so no tick: the marker means "literal value spelling", and a
pattern does not spell a value. - Injection is an ordinary term: it composes in arguments, records, lists,
pipes, and quotes. As the function of an application it parses and fails
type-check, like(1)(x)— no special ban (unlike#, which must not be
applied because it captures syntax).
6. Audit: decision points
The pre-rewrite surface, row by row. Status: keep / change / decide.
| # | Current | Decision | Rationale |
|---|---|---|---|
| 1 | a = b for equality | Change to == | P1, P3: = already means assignment; every mainstream reader reads a = b as assignment. = kept for let/type binding only. |
| 2 | :( expr ) quote | Change to #expr / #{ ... } | Must stay syntactic; see §4. |
| 3 | Backtick sum labels | Change to 'T::A / 'A tick injections | See §5.1. Backticks leave the token budget entirely; the bare-path variant of this row was shipped and superseded the same day. |
| 4 | [] for both list values and sum types, |-separated, backtick labels | Change: sum types are [ A unit, B P ] — brackets enclosing comma-separated case pairs, no sum keyword (decided). Disambiguation is positional: value position, [ ... ] is a list literal; type position, []T (closed immediately) is a list type and [ label type, ... ] is a sum — the parser decides on the token after [. The , separator matches records and all other sibling lists (P10). [] also serves the list type prefix ([]i64, [][]f64), so [] is the list spelling in both positions — P2 isomorphism made explicit. P3 note: the sum and list readings of [ never co-occur in one position. | |
| 5 | >> pipe | Change to single | | Shortest mainstream pipe (Unix shell, Elixir, PowerShell); with sums now comma-separated, | has exactly one job. If || logical OR ever arrives, longest-match lexing separates it (or spell it or). > remains comparison-only. |
| 6 | .. row concat | Keep, with a recorded non-decision | P3 (soft): .. is the range spelling in Rust/JS/Python; blr has no ranges. Recorded: if ranges arrive they get different syntax. Row concat is common in blr (P9) and p .. {c: 3} is decodable. |
| 7 | { } for blocks and record literals | Keep | P3 rule (a): position-disambiguated pair; Rust/C mainstream spelling. Exact disambiguation rule pinned in the EBNF. |
| 8 | true / false as keywords | Change to built-in identifiers | P5: they inform nothing. Frees two names. |
| 9 | match as keyword | Keep (keyword) | P1 beats P5: the familiar spelling (Rust/OCaml/Scala). Recorded as an explicit override. |
| 10 | pub/extern prefixing | Keep and codify | P10: modifiers precede the item keyword, order fixed (pub extern fn). |
| 11 | Closure fn (x) => x | Change to fn (x) -> x | P2/P3: one arrow for "returns" across type, value, and definition. => reduced to a single job: match arms (keeps the Rust-familiar pattern arrow). |
| 12 | ; statement separator | Decided: exactly one ; per statement; no trailing ;. Write it into the EBNF. | One convention (P10); Go-style omission-on-{} adds a context rule and P7 argues against it. |
| 13 | Top-level script mode (tests without fn main) | Decided: keep, codified: a .blr file is a module whose top-level expression is main — items plus at most one top-level expression, which is the module's entry point (script form); pub fn main is the named form of the same entry point. One grammar, no hidden file mode. | P7: the mode must be codified, not incidental; P2: both forms are one entry point, so there is one way to express "a program". |
| 14 | 'a type variables | Keep | P1: OCaml spelling. Grammar forbids any second type-variable style. |
| 15 | :: module paths | Keep | P1: Rust/C++ spelling; also carries sum-label qualification in injections ('T::A) and match arms (T::A(_)), §5.1 — one mechanism. |
| 16 | #, @ comment-only; no doc-comment rule | Change: comments are //; a // directly above an item (no blank line) is its doc comment | Go's doc-comment model — the only strong-precedent style reusing the plain comment token; // is already what blr tests use. # freed for quote. @ remains an in-comment ticket-marker convention only, not syntax. |
| 17 | No if yet | Pre-commit spelling for when it arrives | if c { ... } else { ... } as an expression, no parens around c; while c { ... }. No begin/end, no then, no do. |
7. Decision log
Append-only. Every decision, and every deliberate "keep the wart", lives
here.
| Date | Decision | Principle bent | Justification |
|---|---|---|---|
| 2026-09-04 | match stays a keyword | P5 | P1: mainstream spelling wins |
| 2026-09-04 | Case is a lint convention, not a lexical rule (Haskell-style Varid/Conid split rejected) | — | Essentially no mainstream precedent; confuses users. Labels are path-qualified instead (§5). |
| 2026-09-04 | Sum case labels are owner-qualified paths: T::A, top-level ::A (§5) | P9 (arms get longer) | Zero collision with values by construction; rides existing :: path syntax; Rust Enum::Variant precedent. |
| 2026-09-04 | Pipe is single |, not >> or |> | P1, P3 | Shortest mainstream spelling (shell, Elixir); with sums comma-separated, | has one job. |
| 2026-09-04 | Quote is #expr; nested/multi-statement quote groups are #{ ... } | — | Cheapest spelling for a very common form (P9); # was comment-only and comments move to //; prefix-quote precedent (Scheme ', Racket #). Supersedes '{ ... } and :( ... ). |
| 2026-09-04 | Comments and doc comments are // (doc = comment directly above item) | P1 | Go's doc-comment model; // is already what blr tests use. |
| 2026-09-04 | Closures use -> (fn (x) -> x); => reserved for match arms | P2, P3 | One arrow for "returns" across type, value, and definition. |
| 2026-09-04 | List types are []T ([]i64, [][]f64) | P2 | Isomorphic to list literals [v, w]; [] = "list of" in both positions. |
| 2026-09-04 | Sum types are [ A unit, B P ] — no sum keyword (supersedes the same-day sum { ... } draft) | — | Positional disambiguation ([]T closes immediately; sums enclose case pairs) removes the need for a keyword (P5); , matches every other sibling separator (P10); no new brace role (P6). |
| 2026-09-04 | Exactly one ; per statement; no trailing ; | P10 (pinned) | One convention; no omission context rules (P7). |
| 2026-09-04 | Script mode kept: a file is a module; its top-level expression is main | P7 (codified, not removed) | Script form and pub fn main are the same entry point (P2); one grammar. |
| 2026-09-04 | #{ ... } quote block: the paired form's node is a block node whose payload is the index of the block's body; let statements in the block are inlined at compile time (the v1 node family has no binding node) | P9 (blocks cost one extra node) | The rewrite must distinguish the paired form from the prefix form; inlining keeps the v1 node set expression-only. Real let nodes are a follow-up. |
| 2026-09-04 | # quotes the next complete expression (greedy through operators and closures); a quote is never the function of an application nor an operand of an operator or pipe | — | The prefix boundary model (Scheme ') needs one unambiguous rule; # 1 + 2 = # (1 + 2). |
| 2026-09-04 | Doc comments (// directly above an item) are a documented convention only: the lexer does not yet skip // comments and no doc-extraction tool exists — both deferred to follow-up tooling | — | Unit 9 is doc-only per the plan (no grammar change); the spelling is pinned in docs/src/Syntax.md now so it is settled before the lexer + tooling land. |
| 2026-09-04 | true/false are built-in constants (hardwired in the crust resolver, not stdlib-provided): an unshadowed occurrence resolves to a bool literal; shadowing wins | — | Rides the existing identifier-lookup path (scope > prelude > item), produces a real constant (no fake item to link), and falls through to the standard undefined-item error if not recognized. The built-ins live in the crust's Prelude — one prepopulated map so all predeclared names share a home. |
| 2026-09-04 | Label-path resolution happens in the crust lowering, not the parser: a path whose final segment is a case of an in-scope sum type is an injection; the parser only emits the path application. Resolution checks local sums by name, unique local cases for ::A, and imported sums via import::Type | — | The parser has no type information, so T::A(x) is indistinguishable from an item call without it; a path whose module is neither an import nor a sum owner still fails in the item-lookup backstop. Superseded by the next entry (same day). |
| 2026-09-07 | Sum injection is a tick-prefixed path: 'T::A(v) qualified, 'A(v) anonymous; the leading-:: form is gone. The tick switches the syntactic mode (the injection is the pre-existing air-layer Expr::Variant(label, payload) node) and the label is the path's final segment; the owner is documentation for the reader. The lowering consults no sum declarations — type-checking the injection is inference's job (the injected row unifies against the declared type of the position). Match arms stay bare: T::A(_) qualified, A(_) anonymous (§5.1) | P9 (one char per injection); P3 rule (a) for ' (type-position vs expression-position stay disjoint) | A bare T::A(x) is indistinguishable from a path function application, and blr's single item namespace means both readings can be live — the Rust/Scala resolve-later model only works with disjoint namespaces. The 2026-09-04 crust-resolution entry above bent P3/P7/P12 to avoid a marker; the marker costs one in-budget char and restores all three. Superseded in part by the next entry (same day): the lowering's name-check of the path is also dropped. |
| 2026-09-07 | Lowering no longer consults sum declarations: Lower loses its sum_labels map (and the alias_source it only fed); an Inject lowers to a labeled value using only the path's final segment. A wrong case or payload is caught by inference unifying the injected row against the declared type of the position — the same mechanism that catches a bad match arm; with no declared type in scope the value gets an anonymous sum type, like any unconstrained expression | — | The name-check duplicated what unification already does when a declared type is present, and where none is present it had nothing to check against anyway — a declared type should constrain its values, not be carried into the lowering as a table of expectations. |
| 2026-09-07 | Unbound rows reachable from an item's signature stay open only when reachable from its parameter type; rows reachable only from the return type are closed to the fields the code actually referenced (the existing close_unbound_rows machinery). Fixes the "main dropped from exports" bug (blr#23): an unconstrained sum in an inferred result left a free row in main's type, making it row-polymorphic, never instantiated (nothing calls main), and silently dropped by the monomorph pass — the component was then left with no main export (no entry found for key at nucleus/component.rs:598) | — | On the current surface a call site cannot name an expected output row (no annotated let, no type application; row instantiation at monomorph is TypApp::Row = todo!), so a return-only free row is vacuous polymorphism: fatal for the entry item, silent Unit corruption for helpers. Parameter-position rows are inputs call sites instantiate and stay open. Rejected alternatives: closing all rows for main only (leaves the helper version broken; puts a name special-case in the type checker); synthesizing a bottom instance in the monomorph pass (the one-TypApp-per-layer machinery cannot instantiate multi-row schemes). Revisit if row instantiation at call sites lands: a declared-context caller could then constrain a callee's return row. |
| 2026-09-07 | Keep the wart: a single-case sum is represented by its payload (tagless), and its boundary/export type is the payload's type — 'A(41) of a one-case sum prints 41, and type S1 = [ A i64 ] exports as s64, not a variant. The same punning applies to single-field records (blr#24) | P1/P2 (the value no longer looks like its type at the boundary) | The 1-case discriminant is a constant; the punning is uniform across all four row-algebra combinators (unwrap_single/unwrap_prj) and zero-cost in the layout. Consequences accepted: the case label is visible to the type checker and to matching, but not to printing or to the module boundary (two distinct 1-case sums share a boundary type). Recorded alternatives if this ever bites: (B) always tag — Rust model, label in value and WIT variant, costs a discriminant per 1-case value and re-litigates the layout; (C) tagless values but a WIT type alias for 1-case sums — recovers boundary type identity at zero layout cost. |
8. Review checklist
Before any syntax is added or changed, answer (and show the answers in the
PR):
- Familiar? Would a Rust/OCaml/JS reader decode it unprompted? (P1)
- Isomorphic? Does the type look like its values, the definition like
its type? (P2) - One meaning? Does this token mean only one thing in this code? (P3)
- In the budget? Every symbol from the allowed set? (P4)
- Keyword justified? Does it change parsing, or could it be an
identifier? (P5) - Balanced? Which open/close pair wraps it, and is that pair's role
pinned? (P6) - No hidden state? Would the same tokens on any other line parse the
same? (P7) - Composable? Works in every expression position? (P8)
- Right weight? Short-and-common or long-and-rare, matching frequency?
(P9) - Uniform placement? Sibling separators, trailing commas,
:usage
match the one convention? (P10) - Named right? Case conventions followed? (P11)
- Toolable? Highlightable/formattable without types; in the EBNF; with
a good error message? (P12)
A "no" requires either a fix or a new entry in the decision log (§7).