Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions architecture/ADR-008-parameterised-const-type-declaration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# ADR-008: parameterised const type declaration `const Name(T) = struct { ... }`

Status: accepted
Date: 2026-08-15
Context: #2162

This ADR fixes the surface syntax and the AST shape BEFORE the parser is changed, so
that the change cannot widen the grammar by accident. Anything not listed as accepted
here is rejected, and there is a negative fixture for each rejection.

## Decision

`pub const Name(T) = struct { ... };` is an accepted **parameterised type
declaration**. The parser accepts it as

ConstDecl(name, generic_parameters, StructExpr)

and does not require a type identifier after `const`.

This is a parser defect, not a corpus error. The corpus is the evidence of intent:
**33 declarations across 28 files**, every one of them with `struct` on the right-hand
side, and not one instance of any other right-hand side.

## Evidence from the corpus, at `b1884f95`

Parameter lists that actually occur, exhaustively:

| parameter list | occurrences |
|---|---|
| `T` | 22 |
| `K, V` | 4 |
| `W, T` | 1 |
| `T, E` | 1 |
| `S, T` | 1 |
| `R, T` | 1 |
| `L, R` | 1 |
| `A, B, C` | 1 |
| `A, B` | 1 |

Right-hand sides that occur: `struct` × 33. Nothing else.

So the accepted form is narrow by evidence, not by taste: one to three bare
identifiers, comma-separated, and `struct` on the right.

## What is accepted

Grammar, and nothing outside it:

```
ParamConstDecl := [ "pub" ] "const" Ident "(" GenericParams ")" "=" "struct" "{" StructBody "}" [ ";" ]
GenericParams := Ident { "," Ident }
```

- one or more parameters
- each parameter is a bare `Ident`
- separator is exactly `,`
- the right-hand side is `struct` and only `struct`

## What is rejected, and why each rejection is deliberate

| rejected form | reason |
|---|---|
| `const Name() = struct {}` | an empty parameter list is not a parameterised type; if it is meant as a plain type it should be written without parentheses. Ambiguous, so refused |
| `const Name(T,) = struct {}` | trailing comma is not attested in the corpus. Accepting it is a grammar widening with no evidence behind it |
| `const Name([]T) = struct {}` | a type expression in a parameter position. Parameters are names being bound, not types being used |
| `const Name(T: Trait) = struct {}` | constrained parameters are a language feature, not a parser detail. Out of scope, and accepting the syntax now would commit the language to it |
| `const Name(T) = enum(i8) {}` | not attested. `enum` already has its own accepted form without parameters, and combining the two is a separate decision |
| `const Name(T) = 42;` | a parameterised value is not a type declaration |
| `const Name(T);` | a declaration with no right-hand side |

## `Name(T)` versus function-like syntax

The two are distinguished **by position and by the token after the closing paren**, not
by lookahead over the parameter list:

- `const Name(...) = struct` — reached from `parse_const_decl` after `const` and a name.
`(` here opens a generic parameter list
- `fn name(...)` — reached from `parse_fn_decl`, a different entry point entirely, where
`(` opens a value parameter list with `name: Type` pairs
- `Name(args)` as an expression — reached from expression parsing, never from
`parse_const_decl`'s name position

There is therefore no ambiguity to resolve: the const path never sees a call expression
in that position, and a value parameter list (`x: u8`) is rejected here by the
`Ident { "," Ident }` rule, which admits no colon.

## Accepted terminators

After the closing `}` of the struct body, both `;` and no `;` are accepted, matching the
existing non-parameterised `const Name = struct { ... }` path exactly. Nothing new is
introduced: whatever that path accepts, this path accepts.

## AST shape

- `Node.kind` becomes `NodeKind::StructDecl`, exactly as the non-parameterised form does.
The declaration is a struct declaration; being parameterised does not change its kind
- `Node.name` is the declared name, WITHOUT the parameter list. `Stack(T)` has
`name = "Stack"`
- `Node.params` carries the parameters as `(name, "")` pairs, reusing the existing
`Vec<(String, String)>` field. The second element is the empty string because a
parameter has no type: it IS a type. No new field is added to `Node`
- struct body children are parsed by the existing `parse_struct_body`, so field nodes are
indistinguishable from those of a non-parameterised struct

`params` being non-empty is what marks the declaration as parameterised. Nothing else in
the AST changes.

## Codegen is deliberately NOT changed

This ADR changes parsing only. A parameterised type has no single instantiation and the
Verilog backend has no notion of one, so no lowering is defined here.

This has a consequence that must be measured rather than assumed: these 28 files
previously failed to parse **as whole files**, so every declaration in them was invisible.
After this change they parse, and the backend will see declarations it has never seen.
Whether the result is correct emission, harmless emission, or wrong emission is an open
question and is the subject of the two-mode differential in the same tick. It is not
claimed here to be safe.

## What is not claimed

- Not that parameterised types are implemented. They parse; they do not instantiate
- Not that the 28 files now compile. Parsing is the only claim
- Not that this is the whole of #2162's population. The earlier figure of ten files was
the count of files whose FIRST failing construct was this one, measured on the repaired
corpus. The real population is 28 files and 33 declarations, and the difference is
exactly why a first-failure count must never be reported as a population
94 changes: 94 additions & 0 deletions bootstrap/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1894,6 +1894,100 @@ impl Parser {
));
}

// ADR-008 (#2162): optional generic parameter list, as in
// `pub const Stack(T) = struct { ... };`. The grammar accepted here is
// exactly `Ident { "," Ident }` with `struct` on the right-hand side and
// nothing else, because that is the whole of what the corpus attests:
// 33 declarations in 28 files, every one of them a struct, parameter
// lists of one to three bare identifiers. Every rejection below has a
// negative fixture in tests/fixtures/generic_const/.
if self.current.kind == TokenKind::LParen {
self.advance(); // consume (

if self.current.kind == TokenKind::RParen {
return Err(format!(
"ADR-008: empty generic parameter list in 'const {}()'. An empty \
list is ambiguous with a plain type declaration; write \
'const {}' without parentheses instead",
decl.name, decl.name
));
}

let mut generic_params: Vec<(String, String)> = Vec::new();
loop {
if self.current.kind != TokenKind::Ident {
return Err(format!(
"ADR-008: generic parameter of 'const {}' must be a bare \
identifier, got {:?} ('{}'). Parameters are names being \
bound, not types being used",
decl.name, self.current.kind, self.current.lexeme
));
}
// The second element stays empty on purpose: a generic parameter
// has no type, it IS a type. No new Node field is introduced.
generic_params.push((self.current.lexeme.clone(), String::new()));
self.advance();

if self.current.kind == TokenKind::Comma {
self.advance(); // consume ,
if self.current.kind == TokenKind::RParen {
return Err(format!(
"ADR-008: trailing comma in generic parameter list of \
'const {}'. Not attested anywhere in the corpus, so \
not accepted",
decl.name
));
}
continue;
}
break;
}

if self.current.kind != TokenKind::RParen {
return Err(format!(
"ADR-008: expected ')' or ',' after generic parameter of \
'const {}', got {:?} ('{}'). Constrained parameters such as \
'(T: Ord)' are a language feature, not a parser detail",
decl.name, self.current.kind, self.current.lexeme
));
}
self.advance(); // consume )

if self.current.kind != TokenKind::Equals {
return Err(format!(
"ADR-008: expected '=' after generic parameter list of 'const \
{}', got {:?} ('{}'). A parameterised declaration with no \
right-hand side is not a type declaration",
decl.name, self.current.kind, self.current.lexeme
));
}
self.advance(); // consume =

if self.current.kind != TokenKind::KwStruct {
return Err(format!(
"ADR-008: right-hand side of parameterised 'const {}' must be \
'struct', got {:?} ('{}'). All 33 attested declarations are \
structs; a parameterised enum or value is a separate decision",
decl.name, self.current.kind, self.current.lexeme
));
}
self.advance(); // consume 'struct'

// Being parameterised does not change the kind: this is a struct
// declaration, and a non-empty `params` is what marks it as generic.
decl.kind = NodeKind::StructDecl;
decl.params = generic_params;
self.expect(TokenKind::LBrace)?;
self.parse_struct_body(&mut decl)?;
self.expect(TokenKind::RBrace)?;
// Both ';' and no ';' are accepted, matching the non-parameterised
// `const Name = struct { ... }` path exactly. No new terminator.
if self.current.kind == TokenKind::Semicolon {
self.advance();
}
return Ok(decl);
}

// Optional type annotation `: Type`
if self.current.kind == TokenKind::Colon {
self.advance(); // consume :
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/stage0/FROZEN_HASH
Original file line number Diff line number Diff line change
@@ -1 +1 @@
375b2f88cc2f1c58e5ec26bae8efd1f78fa712491a98216cfd98a69d206ae041
315fbe1df4f09eb5a5bd2ac8b9fd748537ccd0b5dc8f0528d602d324a4c48715 bootstrap/src/compiler.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 negative: an empty parameter list is not a parameterised type.
// Ambiguous with a plain type declaration, so refused rather than guessed.
module NegEmptyParams {
pub const Nothing() = struct {
x : "u8",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 negative: a trailing comma is not attested anywhere in the corpus.
// Accepting it would widen the grammar with no evidence behind it.
module NegTrailingComma {
pub const Holder(T,) = struct {
x : "T",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 negative: a type expression in a parameter position. Parameters are
// names being bound, not types being used.
module NegTypeExprParam {
pub const Slice([]T) = struct {
x : "u8",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 negative: a constrained parameter is a language feature, not a parser
// detail. Accepting the syntax now would commit the language to it.
module NegConstrainedParam {
pub const Sorted(T: Ord) = struct {
x : "T",
};
}
7 changes: 7 additions & 0 deletions bootstrap/tests/fixtures/generic_const/neg_05_enum_rhs.t27
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 negative: only `struct` is attested on the right-hand side (33 of 33).
// A parameterised enum is a separate decision.
module NegEnumRhs {
pub const Tagged(T) = enum(i8) {
A = 0,
};
}
4 changes: 4 additions & 0 deletions bootstrap/tests/fixtures/generic_const/neg_06_value_rhs.t27
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// ADR-008 negative: a parameterised value is not a type declaration.
module NegValueRhs {
pub const Answer(T) = 42;
}
4 changes: 4 additions & 0 deletions bootstrap/tests/fixtures/generic_const/neg_07_no_rhs.t27
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// ADR-008 negative: a declaration with no right-hand side at all.
module NegNoRhs {
pub const Opaque(T);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 positive: one generic parameter, the attested majority form (22 of 33).
module GenericConstSingle {
pub const Stack(T) = struct {
items : "[]T",
len : "usize",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 positive: two generic parameters, attested as `K, V` in 4 files.
module GenericConstPair {
pub const Map(K, V) = struct {
key : "K",
value : "V",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// ADR-008 positive: three generic parameters, attested once as `A, B, C`.
module GenericConstTriple {
pub const Tuple3(A, B, C) = struct {
a : "A",
b : "B",
c : "C",
};
}
6 changes: 6 additions & 0 deletions bootstrap/tests/fixtures/generic_const/pos_04_no_pub.t27
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// ADR-008 positive: `pub` is optional, exactly as on the non-parameterised path.
module GenericConstPrivate {
const Cell(T) = struct {
value : "T",
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// ADR-008 positive: the trailing semicolon is optional, matching the
// non-parameterised `const Name = struct { }` path. No new terminator is introduced.
module GenericConstNoSemi {
pub const Box(T) = struct {
value : "T",
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// ADR-008 positive: a parameterised and a plain struct declaration in one module.
// Guards against the generic path swallowing the declaration that follows it.
module GenericConstMixed {
pub const Option(T) = struct {
present : "bool",
value : "T",
};
pub const Header = struct {
magic : "u32",
};
}
Loading
Loading