broaden type inference from context (first pass)

This commit is contained in:
2026-06-25 23:57:12 +02:00
parent 70a6d69d29
commit ff69e1da83
3 changed files with 855 additions and 38 deletions
+359 -4
View File
@@ -198,9 +198,40 @@
a `float` param accepts an integer-literal argument as f64 (e.g. `f(3)`)
- a function result is narrowed to the constraint's family
14. broaden type inference to surrounding context
14. broaden type inference to surrounding context (implemented)
- a slot's concrete type is the join of demands reachable from its declaration,
flowing backward as well as forward to a fixpoint (the existing global/spec
fixpoint in `infer_all`), generalizing milestone 13's forward-only resolution
- an "open constant" (a global or local with no concrete annotation plus a compile-time
integer initializer) is sign-agnostic until used: a backward demand from any reachable
use picks its family/width as long as the value fits, so `A :: 10` followed by
`B u16 :: A` resolves both to u16 — the literal's smallest-signed default no longer
blocks an unsigned demand; absent any demand it defaults to the smallest signed type
- a concrete declared type flows backward through a chain of bare-name references:
`X :: 1000; Y int :: X; Z i32 :: Y` resolves X and Y to i32 (previously they stayed
at the literal's i16)
- locals resolve identically to globals (no scope asymmetry): demands flow through
bare-name typed declarations, call arguments (a concrete parameter type demands its
argument, e.g. `take_u16(a)`), and returns — including from inside a function body
back onto a referenced global
- demands flow only through bare names; they do not cross arithmetic or other operators,
nor back across a call's result (the result-to-argument direction is milestone 14.5)
- a non-fitting or family-conflicting demand is not applied (first demand wins); the
genuine mismatch then surfaces as the usual boundary coercion error at the use
(e.g. `C u8 :: BIG` where `BIG :: 100000`)
15. add slice-by-range
14.5. backward type-demand propagation through call boundaries (deferred)
- a callee's result/return demand flows back through the function body to constrain
the caller's arguments, so `R u32 :: echo(A)` (with `echo :: func(p int) int`)
resolves A to u32 instead of erroring at the call's result coercion
- requires reversing the per-call data flow: a specialization's argument types
(`spec.args`) become outputs to solve, not just inputs — a new back-edge threaded
through every call site and the specialization fixpoint
- only meaningful on top of milestone 14's open constants
15. broaden type inference to infer type of declaration based on arithmetic expressions too
16. add slice-by-range
- allow the use of a range in slice expressions:
```
excl_range range :: 0..10
@@ -210,6 +241,22 @@
some_arr[incl_range] # slice by named inclusive range
```
17. for if statements, allow `if (cond) one-line statement` (instead of forcing either `if (cond) { block }` or `if cond { block }`)
- if statements without a bracketed body must enclose the condition in parentheses
18. add `defer` statement (inspired by zig)
19. multi-line strings (see below)
20. unions and tagged unions
21. match statements with tagged unions payload unwrapping
22. dynamic heap allocation
- see below for direction
- notes below are too big in scope for a first pass and the language is not mature enough to support it yet
- this first pass should focus on just basic heap allocation, so we have something to work with
## A word on multi-unwrap
Unwrap multiple optionals with `and`. This **short-circuits**: if the first optional is none, subsequent expressions are not evaluated.
@@ -276,7 +323,7 @@ This rule keeps the grammar simple and forces clarity at the call site — no pr
For-loop captures are immutable and scoped to the loop body. Sequence index captures are `usize`. Pointer capture uses `|@item|`; arrays must be passed by pointer (for example `&items`), while slices can be used directly. Sentinel elements are not included in iteration.
# A word on distinct types
## A word on distinct types
Distinct types are considered distinct from their backing type. They do not implicitly coerce to their backing type.
@@ -288,7 +335,7 @@ UserID :: distinct u32
my_id UserID :: UserID(42) # value must have the exact backing type
```
# A word on enums
## A word on enums
```
# standard enums
@@ -347,3 +394,311 @@ value native.Imported_Enum :: native.IMPORTED_ENUM_VALUE
The imported enum type is an alias of its target-selected C integer backing, and imported
enumerators are package-level constants.
## A word on multi-line strings
Multi-line strings use the `` ` `` character to mark each line. Content starts immediately after the backtick. Newlines between lines are implicit.
```
config =
`# Database configuration
`host = localhost
`port = 5432
`
`[server]
`address = 0.0.0.0
```
Key properties:
* Content begins immediately after `` ` ``
* Newlines are automatically inserted between lines
* Empty `` ` `` produces a blank line
* No escape sequence processing (raw content)
* No trailing newline after the last line
Only the leading `` ` `` is special; the rest is treated as raw content.
If you need a trailing newline, add an empty line at the end:
```
# No trailing newline
msg =
`hello
`world
# With trailing newline
msg =
`hello
`world
`
```
Mixing multi-line strings with inline strings (using concatenation):
```
message =
"Header:\t" ++
`more content here
`even more content
`
++ "Footer"
```
Formatting alternative (purely aesthetics/preference, no effect on program):
```
message = "Header:\t" ++
`more content here
`even more content
`
++ "Footer"
```
## A word on memory allocation
(NOTE THAT SYNTAX MAY NOT MATCH BROLANG EXACTLY AND SHOULD BE TAKEN WITH A GRAIN OF SALT - INSPIRATION ONLY)
(FURTHER, EXAMPLES ASSUME ARGUMENTS WITH DEFAULT VALUES AND COMPTIME POLYMORPHISM IN THE FORM OF GENERIC TYPE PARAMETERS - MONOMORPHISED)
Memory allocation in Brolang is designed to be **explicit but not verbose**. We reject the dogma that global state is inherently evil — allocators are a cross-cutting concern that nearly every function needs, making them a perfect candidate for sensible defaults.
### Philosophy
```
┌─────────────────────────────────────────────────────────────┐
│ DESIGN PRINCIPLES │
│ │
│ 1. No hidden magic: allocation calls are visible │
│ 2. Sensible defaults: thread-local heap for common cases │
│ 3. Explicit override: custom allocators when needed │
│ 4. Build-mode aware: different behavior for debug/release │
│ 5. Immutable defaults: no "action at a distance" bugs │
│ 6. Escaping allocations: caller provides allocator │
└─────────────────────────────────────────────────────────────┘
```
### The Default Heap Allocator
Brolang provides a **thread-local global heap allocator** that is:
* Determined at compile time by build mode
* **Immutable at runtime** — cannot be reconfigured
```
import "std/mem/heap"
process :: func(input []u8) u64 {
# heap used for internal temporary work — does not escape
temp := heap.alloc(u8, size: input.len * 2)
defer heap.free(temp)
# ... work with temp ...
return compute_hash(temp) # only the result escapes, not the allocation
}
```
The behavior of `heap` depends on build mode:
| Build Mode | Allocator Behavior |
| -- | -- |
| Debug | Tracking allocator with leak detection |
| Release | Fast allocator, zero overhead |
| ReleaseSafe | Bounds-checking allocator |
This is configured at compile time. You cannot change which allocator `heap` uses at runtime. This is intentional — it prevents bugs where memory allocated with one allocator is freed with another.
### The Escaping Allocation Rule
**If a function heap-allocates memory that escapes its scope — whether via the return value or via writes through mutable parameters — the function must accept an allocator parameter.** The presence of an allocator parameter is the contract that says "heap memory escapes here, and you're responsible for it."
This rule makes ownership transfer visible at the function signature level. The caller never needs to read the function's implementation to know whether heap cleanup is involved:
```
import "std/mem"
import "std/mem/heap"
# Allocation escapes via return value — requires allocator
duplicate :: func(input []u8, allocator @mem.Allocator) []u8 {
result := allocator.alloc(u8, size: input.len)
mem.copy(result, input)
return result # caller manages this memory
}
# Allocation escapes via mutable parameter — requires allocator
init :: func(obj: @mut MyStruct, allocator: @mem.Allocator) void {
obj.buffer = allocator.alloc(u8, size: 100)
# caller now knows heap memory was written into obj
}
# No allocation escapes — no allocator needed
process :: func(input: []u8) u64 {
temp := heap.alloc(u8, size: input.len)
defer heap.free(temp)
# ... work with temp ...
return compute_hash(temp)
}
# No heap allocation at all — no allocator needed
reset :: func(obj: @mut MyStruct) void {
obj.count = 0
}
main :: func() void {
data := duplicate("hello", heap)
defer heap.free(data)
mut obj := MyStruct{ ... }
init(&obj, heap)
defer heap.free(obj.buffer)
}
```
**Why this matters:**
* Without the rule, a function like `init(obj: @mut MyStruct) void` is ambiguous — did it heap-allocate into `obj`, or just set some fields to stack/static data? The caller has no way to know without reading the implementation.
* With the rule, the allocator parameter is a clear signal: "this function produces heap memory that outlives its scope, and you are responsible for cleaning it up."
* Internal allocations (temporary buffers, scratch space) use `heap` directly and are freed before the function returns. No allocator parameter needed, no burden on the caller.
**The compiler enforces this rule.** If a function heap-allocates memory that escapes without accepting an allocator parameter, the compiler emits an error.
### Why Immutable Defaults?
Consider what would happen if you could reconfigure the default allocator:
```
# ❌ THIS IS NOT ALLOWED (and doesn't exist in Brolang)
mem.heap_set(my_custom_heap)
# Somewhere else in the codebase...
data := heap.alloc(u8, size: 100)
# Later, someone changes it again...
mem.heap_set(different_heap)
# Now who frees `data`? With which allocator?
heap.free(data) # 💥 Wrong allocator - undefined behavior!
```
This is "action at a distance" — the behavior of `heap.free()` depends on what some unrelated code did earlier. By making `heap` immutable, Brolang guarantees:
**Whatever you allocate with, you free with.**
### Custom Allocators
For specialized needs, you create explicit allocator instances. These are not global — you manage their lifetime and pass them where needed.
**Arena Allocator**: Fast bump allocation, bulk deallocation:
```
import "std/mem"
import "std/mem/heap"
process_file :: func(path: []u8, allocator: @mem.Allocator) !Data {
# arena manages its own backing memory via heap
arena := mem.Arena.init(heap, capacity: mem.megabytes(1))
defer arena.deinit()
# all temporary allocations from arena (fast bump allocation)
file_contents := arena.alloc(u8, size: file_size)
parsed := arena.alloc(ParsedData) # size defaults to 1
tokens := arena.alloc(Token, size: 1000)
# ... process ...
# escaping allocation uses the caller's allocator
result := allocator.create(Data)
mem.copy(result, parsed)
return result
# arena.deinit() frees all arena memory — no individual frees needed
}
```
**Pool Allocator**: O(1) fixed-size allocation, no fragmentation:
```
import "std/mem"
import "std/mem/heap"
EntitySystem :: struct {
pool: mem.Pool(Entity),
}
init_entities :: func(allocator: @mem.Allocator) EntitySystem {
return EntitySystem{
pool = mem.Pool(Entity).init(allocator, capacity: 10_000),
}
}
spawn :: func(sys: @mut EntitySystem) @Entity {
return sys.pool.alloc() # O(1), no fragmentation
}
despawn :: func(sys: @mut EntitySystem, entity: @Entity) void {
sys.pool.free(entity) # returned to pool for reuse
}
```
### Passing Allocators to Functions
As described in the escaping allocation rule, when a function heap-allocates memory that escapes its scope, it must accept an allocator parameter. The caller decides which allocator to use:
```
import "std/mem"
# Function that uses caller's allocator
parse :: func(input: []u8, allocator: @mem.Allocator) !ParseResult {
buffer := allocator.alloc(u8, size: input.len)
defer allocator.free(buffer)
# ... parse into buffer ...
result := allocator.alloc(ParseResult) # size defaults to 1
return result
}
# Caller decides which allocator to use
main :: func() void {
# use an arena for this parsing work
arena := mem.Arena.init(heap, capacity: mem.kilobytes(64))
defer arena.deinit()
result := parse(input, &arena) catch |err| {
# handle error
}
# or use a pool
pool := mem.Pool(ParseResult).init(capacity: 100)
defer pool.deinit()
result := parse(input, &pool) catch |err| {
# handle error
}
}
```
### Memory Allocation Summary
| What | How | When to Use |
| -- | -- | -- |
| `heap.alloc(T, size: n)` | Thread-local global | General purpose, 90% of cases |
| `heap.create(T)` | Thread-local global | Allocate single item |
| `allocator.alloc(T, size: n)` | Caller-provided | Escaping allocations (returned or written to caller's data) |
| `arena.alloc(T, size: n)` | Explicit instance | Temporary/scoped work, bulk free |
| `pool.alloc()` | Explicit instance | Many same-sized objects, O(1) |
Note that `heap` satisfies the `Allocator` interface, so callers can pass `heap` as the allocator argument when they don't need a specialized allocator — which is most of the time.
**The golden rule:** Allocate and free with the same allocator. The type system helps enforce this — memory from `heap` can only be freed with `heap`, memory from your arena can only be freed with that arena. The escaping allocation rule ensures the caller always knows which allocator was used.
### Compared to Other Languages
| Language | Approach | Brolang's Advantage |
| -- | -- | -- |
| C | Hidden malloc, easy to mismatch | Explicit allocator at call site |
| C++ | Allocator templates, complex | Simple, no template complexity |
| Rust | Explicit everywhere, verbose | Sensible defaults reduce noise |
| Zig | Allocator parameter threading | Only required for escaping allocations, not internal work |
| Odin | Hidden context parameter | Fully transparent, nothing hidden |
| Go | Hidden GC | Explicit control, no GC pauses |
Brolang sits in a sweet spot: explicit enough to always know what's happening, convenient enough that you don't drown in boilerplate.
+294 -34
View File
@@ -38,6 +38,12 @@ Infer_Local :: struct {
declared: types.Type,
statement: ast.Stmt_Id,
mutable: bool,
// open_const marks a local whose initializer is a compile-time integer with no
// concrete annotation: like an open-constant global, it is sign-agnostic until a
// backward demand from a use picks its family/width (see merge_local_demand).
open_const: bool,
const_value: i128,
demanded: bool,
}
Build_Local :: struct {
@@ -105,6 +111,14 @@ Checker :: struct {
global_index: []Global_Index_Entry,
import_index: []Import_Index_Entry,
global_types: []types.Type,
// Backward type-demand state for open-constant globals (milestone 14). global_demands
// accumulates demands reachable from any use (other globals' initializers and function
// bodies); global_demands_dirty lets a demand pushed from a function body re-trigger the
// inference fixpoint.
global_demands: []types.Type,
global_open_const: []bool,
global_const_value: []i128,
global_demands_dirty: bool,
external_global_canonical: []ast.Global_Id,
external_global_diagnostics: []source.Diagnostic_Id,
constants: []Constant,
@@ -1088,11 +1102,12 @@ infer_nested_expr :: proc(
pkg: ast.Package_Id,
file: ast.File_Id,
demanded: ^[dynamic]Spec_Id,
local_types: []types.Type = nil,
) -> types.Type {
outer := checker.infer_stack
checker.infer_stack = nil
checker.infer_stack.allocator = checker.allocator
result := infer_expr(checker, expr_id, locals, pkg, file, demanded)
result := infer_expr(checker, expr_id, locals, pkg, file, demanded, local_types)
delete(checker.infer_stack)
checker.infer_stack = outer
return result
@@ -1105,21 +1120,22 @@ infer_compound_expr :: proc(
pkg: ast.Package_Id,
file: ast.File_Id,
demanded: ^[dynamic]Spec_Id,
local_types: []types.Type = nil,
) -> types.Type {
store := &checker.module.types
#partial switch expr.kind {
case .Bool:
return types.BOOL
case .Not:
_ = infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
_ = infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
return types.BOOL
case .Eq, .Ne, .Lt, .Le, .Gt, .Ge, .And, .Or:
_ = infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
_ = infer_nested_expr(checker, expr.right, locals, pkg, file, demanded)
_ = infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
_ = infer_nested_expr(checker, expr.right, locals, pkg, file, demanded, local_types)
return types.BOOL
case .Range:
left := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
right := infer_nested_expr(checker, expr.right, locals, pkg, file, demanded)
left := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
right := infer_nested_expr(checker, expr.right, locals, pkg, file, demanded, local_types)
left_const := eval_constant(checker, expr.left)
right_const := eval_constant(checker, expr.right)
child := types.INVALID
@@ -1139,7 +1155,7 @@ infer_compound_expr :: proc(
case .Array:
element := types.INVALID
for arg in expr.args {
actual := infer_nested_expr(checker, arg, locals, pkg, file, demanded)
actual := infer_nested_expr(checker, arg, locals, pkg, file, demanded, local_types)
if !types.is_valid(element) {
element = actual
} else if !types.equal(element, actual) {
@@ -1157,25 +1173,25 @@ infer_compound_expr :: proc(
case .Enum_Literal:
return types.INVALID
case .Address:
child := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
child := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
return types.pointer(store, child, false, false)
case .Deref:
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
return types.child_type(value, store) if types.is_pointer(value, store) else types.INVALID
case .Index:
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
_ = infer_nested_expr(checker, expr.right, locals, pkg, file, demanded)
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
_ = infer_nested_expr(checker, expr.right, locals, pkg, file, demanded, local_types)
item, ok := types.container(value, store)
return item.child if ok else types.INVALID
case .Slice:
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
item, ok := types.container(value, store)
if !ok || item.kind == .Pointer {
return types.INVALID
}
for bound in expr.args {
if bound != ast.INVALID_EXPR {
_ = infer_nested_expr(checker, bound, locals, pkg, file, demanded)
_ = infer_nested_expr(checker, bound, locals, pkg, file, demanded, local_types)
}
}
preserve := item.has_sentinel && expr.args[1] == ast.INVALID_EXPR
@@ -1185,7 +1201,7 @@ infer_compound_expr :: proc(
_, member_ok := find_enum_member(checker, enum_type, expr.name)
return enum_type if member_ok else types.INVALID
}
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
field_name := symbol_text(checker, expr.name)
item, has_item := types.container(value, store)
if has_item && (item.kind == .Array || item.kind == .Slice) {
@@ -1203,21 +1219,21 @@ infer_compound_expr :: proc(
_, field, ok := find_struct_field(checker, value, expr.name)
return field.type if ok else types.INVALID
case .Unwrap:
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
return types.child_type(value, store) if types.is_optional(value, store) else types.INVALID
case .Orelse:
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
_ = infer_nested_expr(checker, expr.right, locals, pkg, file, demanded)
value := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
_ = infer_nested_expr(checker, expr.right, locals, pkg, file, demanded, local_types)
return types.child_type(value, store) if types.is_optional(value, store) else types.INVALID
case .Struct_Literal:
for keyed in expr.args {
_ = infer_nested_expr(checker, checker.ast_module.exprs[keyed].left, locals, pkg, file, demanded)
_ = infer_nested_expr(checker, checker.ast_module.exprs[keyed].left, locals, pkg, file, demanded, local_types)
}
target_pkg, available := expr_package(checker, expr, pkg, file)
value := types.find_named(store, u32(target_pkg), u32(expr.name)) if available else types.INVALID
return types.resolve_alias(value, store)
case .Keyed:
return infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
return infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
case:
return types.INVALID
}
@@ -1230,6 +1246,7 @@ infer_expr :: proc(
pkg := ast.Package_Id(0),
file := ast.File_Id(0),
demanded: ^[dynamic]Spec_Id = nil,
local_types: []types.Type = nil,
) -> types.Type {
stack := checker.infer_stack
clear_dynamic_array(&stack)
@@ -1281,7 +1298,7 @@ infer_expr :: proc(
case .String, .Array, .None, .Undefined, .Address, .Deref, .Index, .Slice,
.Field, .Unwrap, .Orelse, .Struct_Literal, .Keyed, .Enum_Literal,
.Bool, .Not, .Eq, .Ne, .Lt, .Le, .Gt, .Ge, .And, .Or, .Range:
last = infer_compound_expr(checker, expr, locals, pkg, file, demanded)
last = infer_compound_expr(checker, expr, locals, pkg, file, demanded, local_types)
_ = pop(&stack)
case .Name:
last = types.INVALID
@@ -1349,7 +1366,7 @@ infer_expr :: proc(
append(&stack, Infer_Frame{expr=expr.left, template=ast.INVALID_FUNCTION})
case .Call:
if expr.left != ast.INVALID_EXPR {
callee_type := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded)
callee_type := infer_nested_expr(checker, expr.left, locals, pkg, file, demanded, local_types)
_, function_item, function_type, ok := types.function_pointer(callee_type, &checker.module.types)
if !ok {
last = types.INVALID
@@ -1464,6 +1481,13 @@ infer_expr :: proc(
}
}
function := checker.ast_module.functions[frame.template]
// A bare-name argument passed to a concrete (non-constraint) parameter pushes
// that parameter type back onto the argument's slot, so an open constant adopts
// it (e.g. `take_u16(a)` resolves `a` to u16). Constraint params have no single
// type to demand; the callee's result flowing back is milestone 14.5.
for arg_index in 0..<len(expr.args) {
record_demand(checker, expr.args[arg_index], call_arg_expected(function, arg_index), locals, local_types, pkg, file)
}
if valid_call_arity(function, len(expr.args)) &&
can_specialize(checker, function, stack[frame_index].args) {
spec := INVALID_SPEC
@@ -1612,7 +1636,7 @@ infer_statements :: proc(
declared_local := resolve_inferred_array(checker, type_from_syntax(statement.type), statement.expr)
value_type := types.INVALID
if !is_undefined_expr(checker, statement.expr) {
value_type = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
value_type = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded, local_types)
}
if is_runtime_type(checker, declared_local) {
value_type = declared_local
@@ -1621,19 +1645,33 @@ infer_statements :: proc(
// build_block reports). FLOAT defaults integers to f64.
value_type = types.constraint_target(declared_local, value_type, &checker.module.types)
}
open := false
const_val := i128(0)
if !is_runtime_type(checker, declared_local) && !is_undefined_expr(checker, statement.expr) {
constant := eval_constant(checker, statement.expr)
if constant.kind == .Value && fits_i64(constant.value) {
open = true
const_val = constant.value
}
}
local := Infer_Local{
name=statement.name,
type=value_type,
declared=declared_local,
statement=statement_id,
mutable=!statement.immutable,
open_const=open,
const_value=const_val,
}
append(locals, local)
record_infer_local_type(local, local_types)
// A typed/constraint declaration initialized by a bare name pushes its resolved
// type backward onto that name (mirrors the `Y int :: X; Z i32 :: Y` global chain).
record_demand(checker, statement.expr, value_type, locals^[:], local_types, pkg, file)
case .Assignment:
value_type := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
value_type := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded, local_types)
if statement.target != ast.INVALID_EXPR {
_ = infer_expr(checker, statement.target, locals^[:], pkg, file, demanded)
_ = infer_expr(checker, statement.target, locals^[:], pkg, file, demanded, local_types)
target_expr := checker.ast_module.exprs[statement.target]
if target_expr.kind == .Name && !symbol.is_valid(target_expr.qualifier) {
if local_index, ok := find_infer_local_index(locals^[:], target_expr.name); ok &&
@@ -1648,16 +1686,23 @@ infer_statements :: proc(
}
}
case .Expression:
_ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
_ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded, local_types)
case .Return:
if statement.expr != ast.INVALID_EXPR {
returned := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
returned := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded, local_types)
if is_runtime_type(checker, result_hint) {
expr := checker.ast_module.exprs[statement.expr]
if expr.kind == .Name && !symbol.is_valid(expr.qualifier) {
if local_index, ok := find_infer_local_index(locals^[:], expr.name); ok {
_ = merge_infer_local_type(checker, &locals^[local_index], result_hint, local_types)
// Open constants can adopt the result type cross-family; other
// locals widen within family as before.
if !merge_local_demand(checker, &locals^[local_index], result_hint, local_types) {
_ = merge_infer_local_type(checker, &locals^[local_index], result_hint, local_types)
}
returned = result_hint
} else {
// `return G` for a global const: demand the result type onto it.
record_demand(checker, statement.expr, result_hint, locals^[:], local_types, pkg, file)
}
}
}
@@ -1674,7 +1719,7 @@ infer_statements :: proc(
flatten_conditional_unwrap_operands(checker.ast_module, statement.expr, &operands)
operand_types := make([]types.Type, len(operands), checker.allocator)
for operand, index in operands {
operand_types[index] = infer_expr(checker, operand, locals^[:], pkg, file, demanded)
operand_types[index] = infer_expr(checker, operand, locals^[:], pkg, file, demanded, local_types)
}
capture_start := len(locals^)
for capture, index in statement.captures {
@@ -1689,7 +1734,7 @@ infer_statements :: proc(
append(locals, Infer_Local{name=capture, type=capture_type, declared=capture_type, statement=ast.INVALID_STMT})
}
if statement.guard != ast.INVALID_EXPR {
_ = infer_expr(checker, statement.guard, locals^[:], pkg, file, demanded)
_ = infer_expr(checker, statement.guard, locals^[:], pkg, file, demanded, local_types)
}
infer_statements(checker, statement.body, locals, local_types, pkg, file, demanded, result, result_hint)
resize(locals, capture_start)
@@ -1697,19 +1742,19 @@ infer_statements :: proc(
delete(operand_types, checker.allocator)
delete(operands)
} else {
_ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
_ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded, local_types)
infer_statements(checker, statement.body, locals, local_types, pkg, file, demanded, result, result_hint)
infer_statements(checker, statement.else_body, locals, local_types, pkg, file, demanded, result, result_hint)
}
case .While:
_ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
_ = infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded, local_types)
infer_statements(checker, statement.body, locals, local_types, pkg, file, demanded, result, result_hint)
if statement.update != ast.INVALID_STMT {
update := [1]ast.Stmt_Id{statement.update}
infer_statements(checker, update[:], locals, local_types, pkg, file, demanded, result, result_hint)
}
case .For:
iterable_type := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded)
iterable_type := infer_expr(checker, statement.expr, locals^[:], pkg, file, demanded, local_types)
capture_start := len(locals^)
capture_type := types.INVALID
if types.is_range(iterable_type, &checker.module.types) {
@@ -1797,11 +1842,158 @@ merge_inferred_type :: proc(store: ^types.Store, current: ^types.Type, inferred:
return false
}
// root_demand_target returns the global that an initializer pushes a backward type
// demand onto: when the initializer's root expression is a bare name referencing a
// global (e.g. `Z i32 :: Y`). Returns INVALID_GLOBAL for any other shape — demands
// deliberately do not flow through arithmetic, calls, or other operators (that is L3).
root_demand_target :: proc(checker: ^Checker, expr_id: ast.Expr_Id, pkg: ast.Package_Id, file: ast.File_Id) -> ast.Global_Id {
if expr_id == ast.INVALID_EXPR || int(expr_id) >= len(checker.ast_module.exprs) {
return ast.INVALID_GLOBAL
}
expr := checker.ast_module.exprs[expr_id]
if expr.kind != .Name {
return ast.INVALID_GLOBAL
}
target_pkg, available := expr_package(checker, expr, pkg, file)
if !available {
return ast.INVALID_GLOBAL
}
return find_global(checker, expr.name, target_pkg)
}
// merge_open_const_demand records a concrete integer demand onto an open-constant
// global's slot. The constant is sign-agnostic until used, so it may adopt any
// integer family/width whose range holds its value (first demand wins; later demands
// may only widen within the chosen family). Non-integer or non-fitting demands are
// ignored, leaving the constant to default and the genuine mismatch to surface at the
// use's boundary coercion.
merge_open_const_demand :: proc(checker: ^Checker, slot: ^types.Type, demand: types.Type, value: i128) -> bool {
if !types.is_concrete_integer(demand) || !fits_integer_type(value, demand, checker.target) {
return false
}
if !is_runtime_type(checker, slot^) {
slot^ = demand
return true
}
if types.equal(slot^, demand) {
return false
}
merged := types.widest(slot^, demand)
if types.is_concrete_scalar(merged) && !types.equal(slot^, merged) {
slot^ = merged
return true
}
return false
}
// merge_global_demand routes a concrete demand onto a global's slot: open constants
// adopt any fitting family, other referents widen within family. Sets a dirty flag so
// a demand pushed from a function body re-triggers the inference fixpoint.
merge_global_demand :: proc(checker: ^Checker, global: ast.Global_Id, demand: types.Type) -> bool {
index := int(global)
if index < 0 || index >= len(checker.global_demands) {
return false
}
changed: bool
if checker.global_open_const[index] {
changed = merge_open_const_demand(checker, &checker.global_demands[index], demand, checker.global_const_value[index])
} else {
changed = merge_inferred_type(&checker.module.types, &checker.global_demands[index], demand)
}
checker.global_demands_dirty = checker.global_demands_dirty || changed
return changed
}
// merge_local_demand records a concrete integer demand onto an open-constant local.
// Like an open-constant global it adopts any integer family/width whose range holds its
// value (gated by its constraint family if it has one); the first demand replaces the
// literal's signed default, later demands may only widen within the chosen family.
merge_local_demand :: proc(checker: ^Checker, local: ^Infer_Local, demand: types.Type, local_types: []types.Type) -> bool {
if !local.open_const || !types.is_concrete_integer(demand) {
return false
}
if types.is_constraint(local.declared) &&
!types.constraint_accepts(local.declared, demand, &checker.module.types) {
return false
}
if !fits_integer_type(local.const_value, demand, checker.target) {
return false
}
if !local.demanded {
local.type = demand
local.demanded = true
record_infer_local_type(local^, local_types)
return true
}
if types.equal(local.type, demand) {
return false
}
merged := types.widest(local.type, demand)
if types.is_concrete_scalar(merged) && !types.equal(local.type, merged) {
local.type = merged
record_infer_local_type(local^, local_types)
return true
}
return false
}
// record_demand pushes a concrete type demand onto the slot of a bare-name expression
// (a typed declaration's initializer, a call argument, a return value). When the name
// resolves to an open-constant local or global, that slot adopts the demand; any other
// shape is ignored — demands flow only through bare names, never through arithmetic or
// across a call's result (the latter is milestone 14.5).
record_demand :: proc(
checker: ^Checker,
expr_id: ast.Expr_Id,
demand: types.Type,
locals: []Infer_Local,
local_types: []types.Type,
pkg: ast.Package_Id,
file: ast.File_Id,
) {
if !is_runtime_type(checker, demand) ||
expr_id == ast.INVALID_EXPR || int(expr_id) >= len(checker.ast_module.exprs) {
return
}
expr := checker.ast_module.exprs[expr_id]
if expr.kind != .Name {
return
}
if !symbol.is_valid(expr.qualifier) {
if index, ok := find_infer_local_index(locals, expr.name); ok {
_ = merge_local_demand(checker, &locals[index], demand, local_types)
return
}
}
target_pkg, available := expr_package(checker, expr, pkg, file)
if !available {
return
}
global := find_global(checker, expr.name, target_pkg)
if global != ast.INVALID_GLOBAL {
_ = merge_global_demand(checker, global, demand)
}
}
infer_all :: proc(checker: ^Checker) {
// An "open constant" global has no concrete declared type and a compile-time
// integer initializer. Its slot stays sign-agnostic so a backward demand from any
// reachable use can pick its family/width; absent a demand it defaults to the
// smallest signed type (legacy behaviour). Demands accumulate in global_demands so
// the default never blocks a later cross-family (e.g. unsigned) demand.
for global, index in checker.ast_module.globals {
declared := type_from_syntax(global.type)
if is_runtime_type(checker, declared) {
checker.global_types[index] = declared
continue
}
if global.external {
continue
}
constant := eval_constant(checker, global.expr)
if constant.kind == .Value && fits_i64(constant.value) {
checker.global_open_const[index] = true
checker.global_const_value[index] = constant.value
}
}
@@ -1812,7 +2004,29 @@ infer_all :: proc(checker: ^Checker) {
for {
changed := false
checker.global_demands_dirty = false
spec_count := len(checker.specs)
// Backward demands: a global whose initializer's root is a bare name referencing
// another global pushes its own (declared or already-resolved) type onto that
// referent. Open constants adopt any fitting family; other referents widen only.
for global, index in checker.ast_module.globals {
if global.external {
continue
}
demand := checker.global_types[index]
if !is_runtime_type(checker, demand) {
continue
}
target := root_demand_target(checker, global.expr, global.pkg, global.file)
if target != ast.INVALID_GLOBAL {
merge_global_demand(checker, target, demand)
}
}
// Forward / resolution. infer_expr runs for every non-external global (even
// concrete-typed ones) for its side effect of specializing called functions and
// recording demands from call arguments in their initializers.
for global, index in checker.ast_module.globals {
if global.external {
continue
@@ -1821,8 +2035,24 @@ infer_all :: proc(checker: ^Checker) {
if is_runtime_type(checker, type_from_syntax(global.type)) {
continue
}
changed = merge_inferred_type(&checker.module.types, &checker.global_types[index], inferred) || changed
if is_runtime_type(checker, checker.global_demands[index]) {
// A backward demand is authoritative; assign directly (it may cross the
// signed/unsigned family that widening would reject).
if !types.equal(checker.global_types[index], checker.global_demands[index]) {
checker.global_types[index] = checker.global_demands[index]
changed = true
}
} else if checker.global_open_const[index] {
resolved := types.smallest_signed_for_literal(i64(checker.global_const_value[index]))
if !types.equal(checker.global_types[index], resolved) {
checker.global_types[index] = resolved
changed = true
}
} else {
changed = merge_inferred_type(&checker.module.types, &checker.global_types[index], inferred) || changed
}
}
for index := 0; index < len(checker.specs); index += 1 {
id := spec_id(index)
inferred := infer_spec_result(checker, id)
@@ -1831,6 +2061,11 @@ infer_all :: proc(checker: ^Checker) {
if len(checker.specs) != spec_count {
changed = true
}
// A demand pushed onto a global from inside a function body (via the spec loop)
// is picked up by the next pass's resolution, so keep iterating for it.
if checker.global_demands_dirty {
changed = true
}
if !changed {
break
}
@@ -3487,9 +3722,19 @@ build_block :: proc(
switch statement.kind {
case .Declaration:
declared := resolve_inferred_array(checker, type_from_syntax(statement.type), statement.expr)
// Adopt the type inference resolved for this local when the declaration has no
// concrete annotation (a constraint, `undefined`, or an un-annotated open
// integer constant): the slot may have absorbed a backward demand (e.g. `a :: 10`
// built as u16 after `take_u16(a)`). Gated to compile-time integer constants so
// strings/arrays/pointers keep their own initializer type.
open_const_decl := !is_runtime_type(checker, declared) && !is_undefined_expr(checker, statement.expr)
if open_const_decl {
constant := eval_constant(checker, statement.expr)
open_const_decl = constant.kind == .Value && fits_i64(constant.value)
}
if statement_id != ast.INVALID_STMT && int(statement_id) < len(ctx.local_types) &&
is_runtime_type(checker, ctx.local_types[statement_id]) &&
(types.is_constraint(declared) || is_undefined_expr(checker, statement.expr)) {
(types.is_constraint(declared) || is_undefined_expr(checker, statement.expr) || open_const_decl) {
declared = ctx.local_types[statement_id]
}
// A still-unresolved constraint means the initializer's numeric
@@ -4352,6 +4597,15 @@ build_globals :: proc(checker: ^Checker) {
expected := types.INVALID
if is_runtime_type(checker, declared) {
expected = declared
} else if constant := eval_constant(checker, global.expr);
constant.kind == .Value && fits_i64(constant.value) &&
is_runtime_type(checker, checker.global_types[global_index]) {
// Open constant: build the initializer against the type inference resolved
// for this slot, so it adopts its demanded/defaulted type (e.g. `A :: 10`
// built as u16 when a use demanded u16). Gated to compile-time values fitting
// i64 — exactly the infer-side open-constant condition — so out-of-range
// constants keep their original "exceeds signed i64 range" diagnostic.
expected = checker.global_types[global_index]
}
expr := build_expr(checker, global.expr, nil, &dependencies, &calls, expected, global.pkg, global.file)
global_type := checker.global_types[global_index]
@@ -4645,6 +4899,9 @@ check :: proc(
checker.cycle_stack.allocator = allocator
build_symbol_indexes(&checker)
checker.global_types = make([]types.Type, len(ast_module.globals), allocator)
checker.global_demands = make([]types.Type, len(ast_module.globals), allocator)
checker.global_open_const = make([]bool, len(ast_module.globals), allocator)
checker.global_const_value = make([]i128, len(ast_module.globals), allocator)
checker.external_global_canonical = make([]ast.Global_Id, len(ast_module.globals), allocator)
checker.external_global_diagnostics = make([]source.Diagnostic_Id, len(ast_module.globals), allocator)
for &canonical in checker.external_global_canonical {
@@ -4667,6 +4924,9 @@ check :: proc(
delete(checker.global_index, allocator)
delete(checker.import_index, allocator)
delete(checker.global_types, allocator)
delete(checker.global_demands, allocator)
delete(checker.global_open_const, allocator)
delete(checker.global_const_value, allocator)
delete(checker.external_global_canonical, allocator)
delete(checker.external_global_diagnostics, allocator)
delete(checker.constants, allocator)
+202
View File
@@ -6427,3 +6427,205 @@ main :: func() void {
}
testing.expect_value(t, len(diagnostics.items), 0)
}
@(test)
contextual_inference_resolves_signed_const_chain :: proc(t: ^testing.T) {
text := `X :: 1000
Y int :: X
Z i32 :: Y
main :: func() void {}
`
source_file := source.Source{path="test.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
ast_module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&ast_module)
hir_module := checker.check(&ast_module, &diagnostics, &symbols)
defer hir.destroy_module(&hir_module)
// The concrete i32 on Z flows backward through Y to the open constant X, so all
// three resolve to i32 instead of X/Y staying at the literal's smallest signed type.
testing.expect_value(t, len(diagnostics.items), 0)
testing.expect(t, types.equal(hir_module.globals[0].type, types.I32))
testing.expect(t, types.equal(hir_module.globals[1].type, types.I32))
testing.expect(t, types.equal(hir_module.globals[2].type, types.I32))
}
@(test)
contextual_inference_open_constants_adopt_unsigned_demand :: proc(t: ^testing.T) {
text := `A :: 10
B u16 :: A
P :: 10
R u32 :: P
N :: 42
main :: func() void {}
`
source_file := source.Source{path="test.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
ast_module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&ast_module)
hir_module := checker.check(&ast_module, &diagnostics, &symbols)
defer hir.destroy_module(&hir_module)
// An open constant is sign-agnostic until used: it adopts the unsigned family a use
// demands (the literal's signed default would block this). Unconstrained N defaults.
testing.expect_value(t, len(diagnostics.items), 0)
testing.expect(t, types.equal(hir_module.globals[0].type, types.U16)) // A
testing.expect(t, types.equal(hir_module.globals[1].type, types.U16)) // B
testing.expect(t, types.equal(hir_module.globals[2].type, types.U32)) // P
testing.expect(t, types.equal(hir_module.globals[3].type, types.U32)) // R
testing.expect(t, types.equal(hir_module.globals[4].type, types.I8)) // N
}
@(test)
contextual_inference_rejects_constant_that_does_not_fit_demand :: proc(t: ^testing.T) {
text := `BIG :: 100000
C u8 :: BIG
main :: func() void {}
`
source_file := source.Source{path="test.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
ast_module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&ast_module)
hir_module := checker.check(&ast_module, &diagnostics, &symbols)
defer hir.destroy_module(&hir_module)
// 100000 does not fit u8, so the demand is rejected, BIG defaults to i32, and the
// genuine mismatch surfaces at the use's boundary coercion.
found := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "cannot implicitly convert i32 to u8")
}
testing.expect(t, found)
}
@(test)
contextual_inference_does_not_cross_call_boundaries :: proc(t: ^testing.T) {
text := `echo :: func(p int) int { return p }
A :: 10
R u32 :: echo(A)
main :: func() void {}
`
source_file := source.Source{path="test.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
ast_module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&ast_module)
hir_module := checker.check(&ast_module, &diagnostics, &symbols)
defer hir.destroy_module(&hir_module)
// The u32 demand on R must not flow through echo into A (that is L3, deferred). A
// stays at its default i8, so the call result fails to coerce to u32.
found := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "cannot implicitly convert i8 to u32")
}
testing.expect(t, found)
}
@(test)
contextual_inference_resolves_locals_like_globals :: proc(t: ^testing.T) {
text := `take_u16 :: func(v u16) void {}
get :: func() u16 {
c :: 10
return c
}
main :: func() void {
x :: 1000
y int :: x
z i32 :: y
a :: 10
b u16 :: a
n :: 5
take_u16(n)
_ = get()
}
`
source_file := source.Source{path="test.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
ast_module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&ast_module)
hir_module := checker.check(&ast_module, &diagnostics, &symbols)
defer hir.destroy_module(&hir_module)
// The same backward propagation works for locals: a constant local adopts the
// unsigned/wider type a later use demands (declaration, call argument, or return),
// so none of these need an explicit annotation. Without it, i8->u16/u32 would error.
testing.expect_value(t, len(diagnostics.items), 0)
}
@(test)
contextual_inference_demand_from_function_body_reaches_global :: proc(t: ^testing.T) {
text := `take_u16 :: func(v u16) void {}
G :: 10
main :: func() void {
take_u16(G)
}
`
source_file := source.Source{path="test.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
ast_module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&ast_module)
hir_module := checker.check(&ast_module, &diagnostics, &symbols)
defer hir.destroy_module(&hir_module)
// A demand originating inside a function body (passing G to a u16 parameter) flows
// back to the open-constant global G, resolving it to u16.
testing.expect_value(t, len(diagnostics.items), 0)
testing.expect(t, types.equal(hir_module.globals[0].type, types.U16))
}
@(test)
contextual_inference_rejects_local_constant_that_does_not_fit :: proc(t: ^testing.T) {
text := `main :: func() void {
big :: 100000
c u8 :: big
}
`
source_file := source.Source{path="test.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
ast_module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&ast_module)
hir_module := checker.check(&ast_module, &diagnostics, &symbols)
defer hir.destroy_module(&hir_module)
// 100000 does not fit u8, so big keeps its i32 default and the use errors.
found := false
for diagnostic in diagnostics.items {
found = found || strings.contains(diagnostic.message, "cannot implicitly convert i32 to u8")
}
testing.expect(t, found)
}