diff --git a/TODO.md b/TODO.md index a0379de..10c634f 100644 --- a/TODO.md +++ b/TODO.md @@ -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. diff --git a/compiler/checker/checker.odin b/compiler/checker/checker.odin index 6916faf..fde52ae 100644 --- a/compiler/checker/checker.odin +++ b/compiler/checker/checker.odin @@ -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.. 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) diff --git a/compiler_tests.odin b/compiler_tests.odin index cfa3ab7..10c8e5e 100644 --- a/compiler_tests.odin +++ b/compiler_tests.odin @@ -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) +}