refactor: name recovery boundaries and compact IR strings

This commit is contained in:
2026-10-24 01:47:33 +02:00
parent 9932750e21
commit 021cbecac8
8 changed files with 68 additions and 36 deletions
+1 -1
View File
@@ -55,6 +55,6 @@ type errors carry source spans and diagnostic identities; invalid operands propa
`lower.lower` turns the checked AST and binding table into an owned `@source/ir` module. functions, globals, local slots and instruction-result temporaries have explicit IDs; typed instructions execute in source evaluation order and basic blocks end in jump, conditional-branch, return or diagnostic-trap terminators. conditional arms join only when execution can continue; local slots and temporaries are function-wide, without SSA or phi nodes. literals are parsed and strings decoded during lowering; the module retains no AST, binding-table or source-buffer references. release it with `ir.deinit`; keep and reuse the diagnostic store used during lowering so diagnostic IDs remain valid. the IR is target-independent and is intended to feed later native backends as well as the current interpreter.
`interpreter.run` consumes only IR and returns a value or a diagnostic trap; returned strings borrow the module. heap-backed invocation frames track their current block and resume it after calls or global initialization. parameters are initialized on entry and locals execute in statement order; premature local reads trap. globals initialize lazily on their first read, cache their result for that run, and trap on initialization cycles. integers use signed 64-bit storage with checked arithmetic; floats use 64-bit IEEE arithmetic and comparisons, including NaN behavior. numeric literal overflow and float underflow to zero are diagnosed; representable subnormals are accepted. strings support `\\`, `\"`, `\n`, `\r`, `\t`, `\0` and `\xNN` escapes.
`interpreter.run` consumes only IR and returns a value or a diagnostic trap. string values carry a module-local `ir.StringId`, not a slice; resolve bytes with `ir.string_bytes(&module, id)` before releasing the module. decoded bytes and their full backing allocations remain module-owned, without copying strings during calls or returns. heap-backed invocation frames track their current block and resume it after calls or global initialization. parameters are initialized on entry and locals execute in statement order; premature local reads trap. globals initialize lazily on their first read, cache their result for that run, and trap on initialization cycles. integers use signed 64-bit storage with checked arithmetic; floats use 64-bit IEEE arithmetic and comparisons, including NaN behavior. numeric literal overflow and float underflow to zero are diagnosed; representable subnormals are accepted. strings support `\\`, `\"`, `\n`, `\r`, `\t`, `\0` and `\xNN` escapes.
recoverable source errors do not stop execution of unaffected code. unused invalid functions, globals and conditional arms retain diagnostic traps; reaching invalid code reports its original diagnostic identity. missing-return traps affect only paths that fall through, not arms that already return or trap. diagnostics include the filename, line and byte column. exit status is `0` for clean execution, `1` for source diagnostics or a runtime trap, and `2` for usage or infrastructure failure. native code generation, loops, assignment and boolean logical operators remain unimplemented.
+1 -1
View File
@@ -35,7 +35,7 @@ Frame :: struct {
}
#! executes only typed IR; invocation and global state are temporary and released on every exit.
#! returned strings borrow `module`; runtime source errors are traps, not infrastructure errors.
#! returned string IDs resolve through `module`; runtime source errors are traps, not infrastructure errors.
run proc(
allocator mem.Allocator,
module @ir.Module,
+8 -2
View File
@@ -153,12 +153,18 @@ function_aliases_and_borrowed_string_values_execute test {
store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&store)
text :: "line\n\"quoted\"\x00"
owned :: try mem.alloc(u8, module.allocator, text.len)
transferred := false
defer if (!transferred) mem.free(module.allocator, owned)
memcopy!(owned, text)
try arraylist.append(&module.strings, ir.StringStorage{ allocation = owned, len = text.len })
transferred = true
target :: try add_function(&module, mem.empty(ir.Type), 0, .string, 1)
try emit(&module, target, ir.Instruction{
op = .constant,
type = .string,
destination = ir.ValueId(0),
constant = ir.Value{ type = .string, data = ir.ValueData{ string = text } },
constant = ir.Value{ type = .string, data = ir.ValueData{ string = ir.StringId(0) } },
})
return_value(&module, target, ir.ValueId(0))
initializer :: try add_function(&module, mem.empty(ir.Type), 0, .function, 1)
@@ -173,7 +179,7 @@ function_aliases_and_borrowed_string_values_execute test {
outcome :: try run(mem.c_allocator, &module, &store)
try testing.expect(outcome.trap == null)
try testing.expect_equal(ir.Type.string, outcome.value.type)
try testing.expect_equal([]u8, text, outcome.value.data.string)
try testing.expect_equal([]u8, text, ir.string_bytes(&module, outcome.value.data.string))
}
premature_local_reads_and_global_cycles_report_instruction_spans test {
+18 -3
View File
@@ -7,6 +7,7 @@ GlobalId :: distinct u32
LocalId :: distinct u32
ValueId :: distinct u32
BlockId :: distinct u32
StringId :: distinct u32
no_value :: maxval!(ValueId)
Type :: enum {
@@ -23,7 +24,7 @@ ValueData :: union {
integer i64
real f64
boolean bool
string []u8
string StringId
function FunctionId
}
Value :: struct {
@@ -101,13 +102,19 @@ Global :: struct {
span diagnostics.Span
}
#! retains the original allocation for freeing while exposing only decoded bytes.
StringStorage :: struct {
allocation []mut u8
len usize
}
#! owns executable storage, including decoded strings; no AST or binding table is retained.
#! spans and diagnostic IDs refer to the caller's source and diagnostic store.
Module :: struct {
allocator mem.Allocator
functions arraylist.ArrayList(Function)
globals arraylist.ArrayList(Global)
strings arraylist.ArrayList([]mut u8)
strings arraylist.ArrayList(StringStorage)
entry ?FunctionId = null
entry_diagnostic diagnostics.DiagnosticId = diagnostics.no_diagnostic
}
@@ -136,6 +143,12 @@ init_block proc(allocator mem.Allocator) Block {
}
}
#! resolves a module-local ID to bytes borrowed until module deinit.
string_bytes proc(module @Module, id StringId) []u8 {
string :: module.strings.items[usize(id)]
return string.allocation[..string.len]
}
#! releases all executable storage using the retained allocator.
deinit proc(module @mut Module) void {
for (0..(module.functions.items.len)) |i| {
@@ -149,7 +162,9 @@ deinit proc(module @mut Module) void {
arraylist.deinit(&function.locals)
arraylist.deinit(&function.blocks)
}
for (module.strings.items) |string| mem.free(module.allocator, string)
for (module.strings.items) |string|
mem.free(module.allocator, string.allocation)
arraylist.deinit(&module.functions)
arraylist.deinit(&module.globals)
arraylist.deinit(&module.strings)
+13 -4
View File
@@ -560,8 +560,17 @@ lower_string proc(state @mut State, id ast.NodeId) ir.ValueId ! (mem.AllocError
trap(state, try diagnostics.add(state.diagnostics, .invalid_escape, span), span)
return ir.no_value
}
# retain the complete allocation for freeing, but expose only decoded bytes.
try arraylist.append(&state.module.strings, owned)
if (state.module.strings.items.len > usize(maxval!(ir.StringId)))
return .out_of_memory
string_id :: ir.StringId(state.module.strings.items.len)
try arraylist.append(&state.module.strings, ir.StringStorage{
allocation = owned,
len = length,
})
transferred = true
return try constant(state, ir.Value{ type = .string, data = ir.ValueData{ string = owned[..length] } }, span)
}
return try constant(state, ir.Value{
type = .string,
data = ir.ValueData{ string = string_id },
}, span)
}
+14 -1
View File
@@ -91,7 +91,20 @@ owns_decoded_strings_after_source_and_frontend_are_freed test {
outcome :: try interpreter.run(mem.c_allocator, &module, &diagnostic_store)
try testing.expect(outcome.trap == null)
try testing.expect_equal(ir.Type.string, outcome.value.type)
try testing.expect(mem.eql(outcome.value.data.string, "a\\\"\n\r\t\x00Az"))
try testing.expect(mem.eql(ir.string_bytes(&module, outcome.value.data.string), "a\\\"\n\r\t\x00Az"))
}
resolves_module_local_string_ids_after_frontend_cleanup test {
diagnostic_store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&diagnostic_store)
module := try compile_owned(
"first :: \"not the result\"\nidentity proc(value string) string { return value }\nmain proc() string { return identity(\"second\\n\\x00\") }\n",
&diagnostic_store,
)
defer ir.deinit(&module)
outcome :: try interpreter.run(mem.c_allocator, &module, &diagnostic_store)
try testing.expect(outcome.trap == null)
try testing.expect_equal([]u8, "second\n\x00", ir.string_bytes(&module, outcome.value.data.string))
}
honors_float_context_and_float_division test {
+3 -3
View File
@@ -133,19 +133,19 @@ run_file proc(allocator mem.Allocator, runtime io.Io, path [;0]u8) i32 ! DriverE
module := try compile(allocator, source.items, &diagnostic_store)
defer ir.deinit(&module)
outcome :: try interpreter.run(allocator, &module, &diagnostic_store)
if (outcome.trap == null) try print_result(io.stdout(runtime), outcome.value)
if (outcome.trap == null) try print_result(io.stdout(runtime), &module, outcome.value)
try report_diagnostics(allocator, io.stderr(runtime), path, source.items, &diagnostic_store, outcome.trap)
if (outcome.trap != null or diagnostics.count(&diagnostic_store) != 0) return 1
return 0
}
@hide:file
print_result proc(output io.Writer, value ir.Value) void ! io.WriteError {
print_result proc(output io.Writer, module @ir.Module, value ir.Value) void ! io.WriteError {
match value.type {
.int: try io.print("{}\n", output, {value.data.integer})
.float: try io.print("{d}\n", output, {value.data.real})
.bool: try io.print("{}\n", output, {value.data.boolean})
.string: try io.print("{s}\n", output, {value.data.string})
.string: try io.print("{s}\n", output, {ir.string_bytes(module, value.data.string)})
.void: {}
else: unreachable
}
+10 -21
View File
@@ -408,16 +408,7 @@ parse_call proc(state @mut State, callee ast.NodeId) ast.NodeId ! ParseError {
arguments arraylist.ArrayList(ast.NodeId) := arraylist.init(state.allocator)
defer arraylist.deinit(&arguments)
while (
!next_is(state, .close_paren)
and !next_is(state, .newline)
and !next_is(state, .close_curly)
and !next_is(state, .open_curly)
and !next_is(state, .else)
and !next_is(state, .return)
and !next_is(state, .if)
and !next_is(state, .eof)
) {
while (!is_expression_boundary(current(state).kind)) {
argument :: try parse_expr(state)
try arraylist.append(&arguments, argument)
if (!next_is(state, .comma)) break
@@ -482,17 +473,7 @@ parse_primary proc(state @mut State) ast.NodeId ! ParseError {
else
try diagnostics.add(state.diagnostics, .unexpected_token, token.span)
if (
token.kind != .newline
and token.kind != .comma
and token.kind != .close_paren
and token.kind != .close_curly
and token.kind != .open_curly
and token.kind != .else
and token.kind != .return
and token.kind != .if
and token.kind != .eof
) {
if token.kind != .comma and !is_expression_boundary(token.kind) {
state.next_token += 1
}
@@ -501,6 +482,14 @@ parse_primary proc(state @mut State) ast.NodeId ! ParseError {
}
}
@hide:file
is_expression_boundary proc(kind lexer.TokenKind) bool {
return match kind {
.close_paren, .newline, .close_curly, .open_curly, .else, .return, .if, .eof: true
else: false
}
}
@hide:file
recover_decl proc(
state @mut State,