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. `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. 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. #! 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( run proc(
allocator mem.Allocator, allocator mem.Allocator,
module @ir.Module, 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) store := diagnostics.init(mem.c_allocator)
defer diagnostics.deinit(&store) defer diagnostics.deinit(&store)
text :: "line\n\"quoted\"\x00" 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) target :: try add_function(&module, mem.empty(ir.Type), 0, .string, 1)
try emit(&module, target, ir.Instruction{ try emit(&module, target, ir.Instruction{
op = .constant, op = .constant,
type = .string, type = .string,
destination = ir.ValueId(0), 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)) return_value(&module, target, ir.ValueId(0))
initializer :: try add_function(&module, mem.empty(ir.Type), 0, .function, 1) 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) outcome :: try run(mem.c_allocator, &module, &store)
try testing.expect(outcome.trap == null) try testing.expect(outcome.trap == null)
try testing.expect_equal(ir.Type.string, outcome.value.type) 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 { premature_local_reads_and_global_cycles_report_instruction_spans test {
+18 -3
View File
@@ -7,6 +7,7 @@ GlobalId :: distinct u32
LocalId :: distinct u32 LocalId :: distinct u32
ValueId :: distinct u32 ValueId :: distinct u32
BlockId :: distinct u32 BlockId :: distinct u32
StringId :: distinct u32
no_value :: maxval!(ValueId) no_value :: maxval!(ValueId)
Type :: enum { Type :: enum {
@@ -23,7 +24,7 @@ ValueData :: union {
integer i64 integer i64
real f64 real f64
boolean bool boolean bool
string []u8 string StringId
function FunctionId function FunctionId
} }
Value :: struct { Value :: struct {
@@ -101,13 +102,19 @@ Global :: struct {
span diagnostics.Span 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. #! 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. #! spans and diagnostic IDs refer to the caller's source and diagnostic store.
Module :: struct { Module :: struct {
allocator mem.Allocator allocator mem.Allocator
functions arraylist.ArrayList(Function) functions arraylist.ArrayList(Function)
globals arraylist.ArrayList(Global) globals arraylist.ArrayList(Global)
strings arraylist.ArrayList([]mut u8) strings arraylist.ArrayList(StringStorage)
entry ?FunctionId = null entry ?FunctionId = null
entry_diagnostic diagnostics.DiagnosticId = diagnostics.no_diagnostic 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. #! releases all executable storage using the retained allocator.
deinit proc(module @mut Module) void { deinit proc(module @mut Module) void {
for (0..(module.functions.items.len)) |i| { for (0..(module.functions.items.len)) |i| {
@@ -149,7 +162,9 @@ deinit proc(module @mut Module) void {
arraylist.deinit(&function.locals) arraylist.deinit(&function.locals)
arraylist.deinit(&function.blocks) 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.functions)
arraylist.deinit(&module.globals) arraylist.deinit(&module.globals)
arraylist.deinit(&module.strings) arraylist.deinit(&module.strings)
+12 -3
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) trap(state, try diagnostics.add(state.diagnostics, .invalid_escape, span), span)
return ir.no_value return ir.no_value
} }
# retain the complete allocation for freeing, but expose only decoded bytes. if (state.module.strings.items.len > usize(maxval!(ir.StringId)))
try arraylist.append(&state.module.strings, owned) 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 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) outcome :: try interpreter.run(mem.c_allocator, &module, &diagnostic_store)
try testing.expect(outcome.trap == null) try testing.expect(outcome.trap == null)
try testing.expect_equal(ir.Type.string, outcome.value.type) 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 { 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) module := try compile(allocator, source.items, &diagnostic_store)
defer ir.deinit(&module) defer ir.deinit(&module)
outcome :: try interpreter.run(allocator, &module, &diagnostic_store) 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) 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 if (outcome.trap != null or diagnostics.count(&diagnostic_store) != 0) return 1
return 0 return 0
} }
@hide:file @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 { match value.type {
.int: try io.print("{}\n", output, {value.data.integer}) .int: try io.print("{}\n", output, {value.data.integer})
.float: try io.print("{d}\n", output, {value.data.real}) .float: try io.print("{d}\n", output, {value.data.real})
.bool: try io.print("{}\n", output, {value.data.boolean}) .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: {} .void: {}
else: unreachable 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) arguments arraylist.ArrayList(ast.NodeId) := arraylist.init(state.allocator)
defer arraylist.deinit(&arguments) defer arraylist.deinit(&arguments)
while ( while (!is_expression_boundary(current(state).kind)) {
!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)
) {
argument :: try parse_expr(state) argument :: try parse_expr(state)
try arraylist.append(&arguments, argument) try arraylist.append(&arguments, argument)
if (!next_is(state, .comma)) break if (!next_is(state, .comma)) break
@@ -482,17 +473,7 @@ parse_primary proc(state @mut State) ast.NodeId ! ParseError {
else else
try diagnostics.add(state.diagnostics, .unexpected_token, token.span) try diagnostics.add(state.diagnostics, .unexpected_token, token.span)
if ( if token.kind != .comma and !is_expression_boundary(token.kind) {
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
) {
state.next_token += 1 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 @hide:file
recover_decl proc( recover_decl proc(
state @mut State, state @mut State,