Skip to content

Commit ed6272d

Browse files
committed
Add built-in trait bounds (Copy, Eq, Send) on generic type parameters with monomorphization-time checking, spec updates, integration tests, and LSP diagnostics for bound violations.
1 parent a1d72da commit ed6272d

21 files changed

Lines changed: 474 additions & 77 deletions

.cursor/skills/writing-ion-code/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ For programs under `tests/`, follow the `ion-integration-tests` skill (`test_exp
176176
These are **not** in Ion today. Check ION_SPEC.md section 10.3 before using anything similar:
177177

178178
- Capturing closures (fn literals that reference outer variables), or `impl` blocks in user code
179-
- Traits, trait bounds, or `where` clauses on generics
179+
- User-defined traits, `where` clauses, or bounds other than built-in `Copy`, `Eq`, and `Send`
180180
- Returning `&T` / `&mut T` or `Option<&T>` from functions
181181
- References in struct fields, enum payloads, or channels
182182
- Shared mutable state across threads (only channels + move)

ION_SPEC.md

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@ fn_decl = "fn" , identifier , type_params? , "(" , params? , ")" ,
288288
return_type? , block ;
289289
290290
type_params = "<" , type_param , { "," , type_param } , ">" ;
291-
type_param = identifier ; (* no trait bounds *)
291+
type_param = identifier , [ ":" , trait_bound , { "+" , trait_bound } ] ;
292+
trait_bound = identifier ; (* built-in: Copy, Eq, Send *)
292293
293294
params = param , { "," , param } ;
294295
param = identifier , ":" , type_expr ;
@@ -651,7 +652,8 @@ Ion supports a **local, Hindley–Milner-inspired inference**:
651652
The inference engine is intentionally limited:
652653

653654
- No higher-rank polymorphism.
654-
- No complex trait constraints; only simple, **structural** `Send` constraints. For a generic type `Wrapper<T>`, each monomorphized instantiation `Wrapper<U>` is `Send` if and only if all of its fields (with `T` replaced by `U`) are `Send`.
655+
- Generic type parameters may declare optional **trait bounds** (`Copy`, `Eq`, `Send`). Bounds are checked at monomorphization: each concrete instantiation must satisfy every bound on the corresponding parameter. There are no user-defined traits; bounds name structural capabilities checked by the compiler (see Section 4.8).
656+
- Structural `Send` still applies per instantiation even without an explicit bound: for a generic type `Wrapper<T>`, each monomorphized `Wrapper<U>` is `Send` if and only if all of its fields (with `T` replaced by `U`) are `Send`.
655657

656658
#### 4.5 Type Casting and Array Assignment
657659

@@ -677,6 +679,25 @@ The inference engine is intentionally limited:
677679
- **Match guards**: `pattern if expr => { ... }` where `expr` must be `bool`.
678680
- **Struct-style enum variants**: `enum E { Ok { value: int }; }` with matching literals and patterns.
679681

682+
#### 4.8 Trait Bounds on Generics
683+
684+
Generic functions, structs, enums, and type aliases may declare bounds on type parameters:
685+
686+
```ion
687+
fn send_value<T: Send>(value: T) { ... }
688+
struct Pair<T: Copy + Eq> { first: T; second: T; }
689+
```
690+
691+
Syntax: `identifier : Bound [ + Bound ... ]` after each type parameter name. Bounds are **built-in identifiers** only; there is no `trait` declaration syntax.
692+
693+
| Bound | Meaning (structural) |
694+
|-------|----------------------|
695+
| `Copy` | Type is copied rather than moved at the ownership level (primitives, references, function pointers). |
696+
| `Eq` | Type supports `==` and `!=` with correct semantics (primitives, `String`, references, arrays and tuples of `Eq` types, structs and enums whose fields or payloads are all `Eq`). |
697+
| `Send` | Type may cross thread boundaries (Section 7.3). |
698+
699+
At each monomorphization site (generic call, struct or enum construction, type-alias substitution), the compiler substitutes concrete types for parameters and rejects any instantiation where a concrete type does not satisfy a declared bound. Unknown bound names are rejected at the declaration site.
700+
680701
### 5. Ownership and Borrowing
681702

682703
#### 5.1 Ownership
@@ -715,7 +736,7 @@ fn main() {
715736

716737
By default, Ion types are **move-only**. For a small subset of primitive types (e.g., `int`, `bool`, pointers), the implementation may treat moves as cheap copies, but the semantic model is still “move”.
717738

718-
Ion does not expose a `Copy` marker to user code; whether a move is implemented as a copy is an implementation detail.
739+
Whether a move is implemented as a copy is an implementation detail. The `Copy` bound on generic parameters (Section 4.8) names types that are copied rather than moved at the ownership level.
719740

720741
After an `if` statement, ownership is merged from branches that can reach the following code. Branches that always `return`, `break`, or `continue` are omitted from the merge. If two fall-through paths disagree on whether a binding is still valid, the compiler reports an error.
721742

@@ -1253,7 +1274,7 @@ and [docs/ABI.md](docs/ABI.md). Features listed below are either intentionally
12531274
constrained in the beta subset or unstable until a later release documents a
12541275
stronger contract.
12551276

1256-
- No trait bounds on generics
1277+
- Trait bounds are limited to built-in `Copy`, `Eq`, and `Send` (no user-defined traits)
12571278
- String `for...in` iterates bytes (`u8`), not Unicode code points or graphemes
12581279
- `if`/`else` merge: ownership after an `if` is merged from branches that can fall through to the following code. A move in a branch that always `return`s, `break`s, or `continue`s does not block use after the `if`. If two fall-through paths disagree (one moved, one valid), it is still an error.
12591280
- `while`/`for` loops: a non-copy variable moved anywhere in the loop body is an error (repeated iteration would need the binding again); copy types and borrows are unchanged. For read-only scans over an owned `Vec<T>`, prefer `Vec::get_ref` (Section 8.2) or index/handle helpers; `Vec::get` move-out still requires consume-once or put-back per iteration.

docs/BETA.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ The following features may change shape before 1.0:
3535

3636
- tuples beyond flat two-field values;
3737
- capture-free function literals and function-pointer coercions;
38-
- generic ergonomics without trait bounds;
38+
- generic ergonomics with optional built-in trait bounds (`Copy`, `Eq`, `Send`);
3939
- byte-oriented string iteration;
4040
- conservative move analysis in `while` and `for` loops;
4141
- generated C layout details not covered by `docs/ABI.md`.

src/ast/mod.rs

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,52 @@ pub struct ExternFnDecl {
3636
pub span: Span,
3737
}
3838

39+
#[derive(Debug, Clone)]
40+
pub struct TypeParam {
41+
pub name: String,
42+
pub bounds: Vec<String>,
43+
}
44+
45+
impl TypeParam {
46+
pub fn simple(name: &str) -> Self {
47+
TypeParam {
48+
name: name.to_string(),
49+
bounds: Vec::new(),
50+
}
51+
}
52+
53+
pub fn names(params: &[TypeParam]) -> Vec<String> {
54+
params.iter().map(|p| p.name.clone()).collect()
55+
}
56+
57+
pub fn format_list(params: &[TypeParam]) -> String {
58+
if params.is_empty() {
59+
String::new()
60+
} else {
61+
format!(
62+
"<{}>",
63+
params
64+
.iter()
65+
.map(|p| {
66+
if p.bounds.is_empty() {
67+
p.name.clone()
68+
} else {
69+
format!("{}: {}", p.name, p.bounds.join(" + "))
70+
}
71+
})
72+
.collect::<Vec<_>>()
73+
.join(", ")
74+
)
75+
}
76+
}
77+
}
78+
3979
#[derive(Debug, Clone)]
4080
pub struct FnDecl {
4181
pub doc: Option<String>,
4282
pub pub_: bool,
4383
pub name: String,
44-
pub generics: Vec<String>,
84+
pub generics: Vec<TypeParam>,
4585
pub params: Vec<Param>,
4686
pub return_type: Option<Type>,
4787
pub body: Block,
@@ -53,7 +93,7 @@ pub struct StructDecl {
5393
pub doc: Option<String>,
5494
pub pub_: bool,
5595
pub name: String,
56-
pub generics: Vec<String>,
96+
pub generics: Vec<TypeParam>,
5797
pub fields: Vec<StructField>,
5898
pub span: Span,
5999
}
@@ -63,7 +103,7 @@ pub struct EnumDecl {
63103
pub doc: Option<String>,
64104
pub pub_: bool,
65105
pub name: String,
66-
pub generics: Vec<String>,
106+
pub generics: Vec<TypeParam>,
67107
pub variants: Vec<EnumVariant>,
68108
pub span: Span,
69109
}
@@ -73,7 +113,7 @@ pub struct TypeAliasDecl {
73113
pub doc: Option<String>,
74114
pub pub_: bool,
75115
pub name: String,
76-
pub generics: Vec<String>,
116+
pub generics: Vec<TypeParam>,
77117
pub target: Type,
78118
pub span: Span,
79119
}

src/cgen/mod.rs

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use self::types::{
99
};
1010

1111
use crate::ast::{
12-
BinOp, EnumDecl, EnumVariant, ExternBlock, Program, Span, StructDecl, Type, TypeAliasDecl, UnOp,
12+
BinOp, EnumDecl, EnumVariant, ExternBlock, Program, Span, StructDecl, Type, TypeAliasDecl,
13+
TypeParam, UnOp,
1314
};
1415
use crate::ir::*;
1516
use crate::types_util::{is_ref_to_vec, ref_to_vec_elem};
@@ -390,7 +391,7 @@ impl Codegen {
390391
doc: None,
391392
pub_: false,
392393
name: "Option".to_string(),
393-
generics: vec!["T".to_string()],
394+
generics: vec![TypeParam::simple("T")],
394395
variants: vec![
395396
EnumVariant {
396397
doc: None,
@@ -480,7 +481,7 @@ impl Codegen {
480481
doc: None,
481482
pub_: false,
482483
name: "Option".to_string(),
483-
generics: vec!["T".to_string()],
484+
generics: vec![TypeParam::simple("T")],
484485
variants: vec![
485486
EnumVariant {
486487
doc: None,
@@ -727,7 +728,7 @@ impl Codegen {
727728
doc: None,
728729
pub_: false,
729730
name: "Option".to_string(),
730-
generics: vec!["T".to_string()],
731+
generics: vec![TypeParam::simple("T")],
731732
variants: vec![
732733
EnumVariant {
733734
doc: None,
@@ -805,7 +806,7 @@ impl Codegen {
805806
doc: None,
806807
pub_: false,
807808
name: "Option".to_string(),
808-
generics: vec!["T".to_string()],
809+
generics: vec![TypeParam::simple("T")],
809810
variants: vec![
810811
EnumVariant {
811812
doc: None,
@@ -1037,9 +1038,9 @@ impl Codegen {
10371038
};
10381039
let decl = self.struct_map.get(base_name)?;
10391040
let mut substitutions = HashMap::new();
1040-
for (i, gen_name) in decl.generics.iter().enumerate() {
1041+
for (i, gen_param) in decl.generics.iter().enumerate() {
10411042
if i < params.len() {
1042-
substitutions.insert(gen_name.clone(), params[i].clone());
1043+
substitutions.insert(gen_param.name.clone(), params[i].clone());
10431044
}
10441045
}
10451046
Some((decl, substitutions))
@@ -1057,9 +1058,9 @@ impl Codegen {
10571058
};
10581059
let decl = self.enum_map.get(base_name)?;
10591060
let mut substitutions = HashMap::new();
1060-
for (i, gen_name) in decl.generics.iter().enumerate() {
1061+
for (i, gen_param) in decl.generics.iter().enumerate() {
10611062
if i < params.len() {
1062-
substitutions.insert(gen_name.clone(), params[i].clone());
1063+
substitutions.insert(gen_param.name.clone(), params[i].clone());
10631064
}
10641065
}
10651066
Some((decl, substitutions))
@@ -3327,7 +3328,7 @@ impl Codegen {
33273328
.generics
33283329
.iter()
33293330
.zip(type_params.iter())
3330-
.map(|(name, ty)| (name.clone(), ty))
3331+
.map(|(param, ty)| (param.name.clone(), ty))
33313332
.collect();
33323333

33333334
self.write_indent();
@@ -3952,7 +3953,7 @@ impl Codegen {
39523953
e.generics
39533954
.iter()
39543955
.zip(type_params.iter())
3955-
.map(|(name, ty)| (name.clone(), ty))
3956+
.map(|(name, ty)| (name.name.clone(), ty))
39563957
.collect()
39573958
})
39583959
.unwrap_or_default();
@@ -5126,9 +5127,9 @@ impl Codegen {
51265127

51275128
// Create substitution map: generic param name -> concrete type
51285129
let mut substitutions: HashMap<String, &Type> = HashMap::new();
5129-
for (i, param_name) in decl.generics.iter().enumerate() {
5130+
for (i, gen_param) in decl.generics.iter().enumerate() {
51305131
if i < params.len() {
5131-
substitutions.insert(param_name.clone(), &params[i]);
5132+
substitutions.insert(gen_param.name.clone(), &params[i]);
51325133
}
51335134
}
51345135

@@ -5161,9 +5162,9 @@ impl Codegen {
51615162

51625163
// Create substitution map: generic param name -> concrete type
51635164
let mut substitutions: HashMap<String, &Type> = HashMap::new();
5164-
for (i, param_name) in decl.generics.iter().enumerate() {
5165+
for (i, gen_param) in decl.generics.iter().enumerate() {
51655166
if i < params.len() {
5166-
substitutions.insert(param_name.clone(), &params[i]);
5167+
substitutions.insert(gen_param.name.clone(), &params[i]);
51675168
}
51685169
}
51695170

src/cgen/types.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ pub(crate) fn resolve_type_alias(ty: &Type, type_aliases: &HashMap<String, TypeA
232232
.generics
233233
.iter()
234234
.zip(params.iter())
235-
.map(|(gen_name, param_ty)| (gen_name.clone(), param_ty))
235+
.map(|(tp, param_ty)| (tp.name.clone(), param_ty))
236236
.collect();
237237
let resolved = substitute_type_params(&alias.target, &substitutions);
238238
return resolve_type_alias(&resolved, type_aliases);
@@ -251,7 +251,7 @@ pub(crate) fn resolve_type_alias(ty: &Type, type_aliases: &HashMap<String, TypeA
251251
.generics
252252
.iter()
253253
.zip(params.iter())
254-
.map(|(gen_name, param_ty)| (gen_name.clone(), param_ty))
254+
.map(|(tp, param_ty)| (tp.name.clone(), param_ty))
255255
.collect();
256256
let resolved = substitute_type_params(&alias.target, &substitutions);
257257
return resolve_type_alias(&resolved, type_aliases);

src/ir/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ impl IRBuilder {
464464

465465
IRFunction {
466466
name: function.name.clone(),
467-
generics: function.generics.clone(),
467+
generics: TypeParam::names(&function.generics),
468468
params,
469469
return_type: function.return_type.clone(),
470470
blocks,

src/lsp/server.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,20 @@ fn diagnostic_from_tc_error(err: &TypeCheckError) -> (crate::ast::Span, String)
400400
names.join(", ")
401401
),
402402
),
403+
TypeCheckError::TraitBoundNotSatisfied {
404+
span,
405+
type_name,
406+
bound,
407+
context,
408+
} => (
409+
*span,
410+
format!(
411+
"Trait bound not satisfied: type '{type_name}' does not satisfy '{bound}' in {context}"
412+
),
413+
),
414+
TypeCheckError::UnknownTraitBound { span, bound } => {
415+
(*span, format!("Unknown trait bound: '{bound}'"))
416+
}
403417
TypeCheckError::Message(msg) => (crate::ast::Span::default(), msg.clone()),
404418
}
405419
}

src/parser/mod.rs

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -807,24 +807,13 @@ impl Parser {
807807
}
808808
}
809809

810-
fn parse_generic_params(&mut self) -> Result<Vec<String>, ParseError> {
810+
fn parse_generic_params(&mut self) -> Result<Vec<TypeParam>, ParseError> {
811811
if !self.is_at_end() && matches!(self.peek().kind, TokenKind::Less) {
812812
self.advance(); // consume <
813813
let mut params = Vec::new();
814814

815815
loop {
816-
let name_idx = self.current;
817-
let name = if let TokenKind::Ident(ref ident_name) = self.tokens[name_idx].kind {
818-
ident_name.clone()
819-
} else {
820-
return Err(ParseError::UnexpectedToken {
821-
expected: "type parameter name".to_string(),
822-
got: self.tokens[name_idx].kind.clone(),
823-
span: Span::from_token(&self.tokens[name_idx]),
824-
});
825-
};
826-
self.current += 1; // consume identifier
827-
params.push(name);
816+
params.push(self.parse_type_param()?);
828817

829818
if !self.is_at_end() && matches!(self.peek().kind, TokenKind::Comma) {
830819
self.advance(); // consume ,
@@ -840,6 +829,47 @@ impl Parser {
840829
}
841830
}
842831

832+
fn parse_type_param(&mut self) -> Result<TypeParam, ParseError> {
833+
let name_idx = self.current;
834+
let name = if let TokenKind::Ident(ref ident_name) = self.tokens[name_idx].kind {
835+
ident_name.clone()
836+
} else {
837+
return Err(ParseError::UnexpectedToken {
838+
expected: "type parameter name".to_string(),
839+
got: self.tokens[name_idx].kind.clone(),
840+
span: Span::from_token(&self.tokens[name_idx]),
841+
});
842+
};
843+
self.current += 1; // consume identifier
844+
845+
let mut bounds = Vec::new();
846+
if !self.is_at_end() && matches!(self.peek().kind, TokenKind::Colon) {
847+
self.advance(); // consume :
848+
loop {
849+
let bound_idx = self.current;
850+
let bound = if let TokenKind::Ident(ref bound_name) = self.tokens[bound_idx].kind {
851+
bound_name.clone()
852+
} else {
853+
return Err(ParseError::UnexpectedToken {
854+
expected: "trait bound name".to_string(),
855+
got: self.tokens[bound_idx].kind.clone(),
856+
span: Span::from_token(&self.tokens[bound_idx]),
857+
});
858+
};
859+
self.current += 1;
860+
bounds.push(bound);
861+
862+
if !self.is_at_end() && matches!(self.peek().kind, TokenKind::Plus) {
863+
self.advance(); // consume +
864+
} else {
865+
break;
866+
}
867+
}
868+
}
869+
870+
Ok(TypeParam { name, bounds })
871+
}
872+
843873
fn parse_struct_decl(&mut self) -> Result<StructDecl, ParseError> {
844874
let struct_span = Span::from_token(self.expect(TokenKind::Struct)?);
845875

0 commit comments

Comments
 (0)