Skip to content

Type var declarations as void (fixes #22) - #26

Merged
wtholliday merged 3 commits into
mainfrom
fix-var-decl-void
Aug 8, 2026
Merged

Type var declarations as void (fixes #22)#26
wtholliday merged 3 commits into
mainfrom
fix-var-decl-void

Conversation

@wtholliday

@wtholliday wtholliday commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Fixes #22.

The bug

A var declaration is a statement, but the checker typed it as the variable's type. When a var declaration was the last expression in an if-else branch, the branch's block took on that type, so Expr::If (src/jit.rs:1459) decided the if produced a value and appended a merge block param typed from it. Codegen meanwhile returns a placeholder iconst(I32, 0) for a var declaration (src/jit.rs:1182), and that placeholder was jumped into the merge block.

main {
    let y = if true { var x = 1.0 } else { var z = 2.0 }
}
cranelift IR verification failed:
- inst24 (jump block7(v16)  ; v16 = 0): arg v16 has type i32, expected f32

The crash is the lucky case. With i32 branches the types line up and nothing complains:

case JIT VM
var x = 1.0 tail (f32) verifier panic compiles, no error
var x = 7 tail (i32) evaluates to 0 evaluates to 0

--check passed clean on all of these.

The fix

The Expr::Var arm in src/checker.rs now records the variable's type in types[id] and returns Void from check_expr.

The split matters: the backends read decl.types[var_expr_id] to size the stack slot (src/jit.rs:1129, src/llvm_jit.rs:1907, src/stack_codegen.rs:823), so the arm can't simply return Void and let the tail assignment at checker.rs:960 record it. Writing types[id] directly keeps slot sizing intact while the enclosing Block/If consume the Void return.

A var-tailed branch is now void, so is_value is false, no merge param is added, and the placeholder never crosses a block boundary. The i32 case becomes a compile error instead of a silent 0 (reported at the declaration — see the third commit below).

Tests

  • tests/cases/var_decl_tail_in_if.lyte — if-else with both branches ending in a var declaration, f32 and i32 variants. Panics the JIT verifier before this change.
  • tests/cases/checker/var_decl_not_a_value.lyte--check, expects the error for let y = if true { var x = 7 } else { var z = 9 }. Silently accepted before this change.

Both were verified to fail against the unfixed compiler. Full suite is green: 351 lib tests, 21 LSP tests, and the golden suite across all four backends built here (jit, vm, asm, stack). One existing test's expected output changed — checker/not_a_struct.lyte, explained below.

Defense in depth

Second commit: Expr::If now checks each branch value's Cranelift type against the merge block param before jumping (check_merge_arg in src/jit.rs). The param type comes from decl.types, the values from codegen, and nothing checked that the two agree.

The first commit removed the one path that could construct such a mismatch; this catches the class. With the checker fix reverted, the same test now fails as:

JIT internal error: then branch of if-else produced a i32 value, but the merge
block expects f32. The checker and codegen disagree about what this branch
evaluates to.

instead of an opaque verifier dump. Note it catches type mismatches only: the i32 variant of this bug is type-consistent (placeholder and param are both i32) and would still slip through, which is why the checker fix is the load-bearing one.

No void bindings

Third commit: a let/var whose type resolves to void is now an error at the declaration.

Void isn't a value type in lyte — nothing produces a void value you can hold, and nothing consumes one — so the binding is always a mistake. Previously it was accepted and the error surfaced at the first use, or nowhere if the variable went unused:

❌ var_decl_not_a_value.lyte:13:5: variable 'y' cannot have type void
        let y = if true { var x = 7 } else { var z = 9 }
        ^

check_void_declarations runs after solving, alongside check_unsolved_types and guarded the same way (only when no other errors were reported — unvisited expressions keep the Void fill value from check_fn_decl, so an early-bailing function would otherwise yield false positives).

This required a parser fix: let/var expressions recorded cx.lex.loc after parsing the initializer, so any diagnostic anchored to a declaration pointed at the following line. They now record the keyword's location. That also moves the second error in checker/not_a_struct.lyte off the closing brace and onto the declaration — the one expected-output change to an existing test.

Worth flagging: this is stricter than Rust, where () is an ordinary inhabited type and let y = (); is legal. Lyte has no way to produce or consume a void value, so the stricter rule costs nothing here.

Not addressed here

  • The check covers if-else merges on the Cranelift backend only. The VM, stack, and LLVM backends have their own codegen paths and no equivalent assert.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BX9w4k8qecsH9LMgBP8hXc

wtholliday and others added 3 commits August 7, 2026 22:34
A `var` declaration is a statement, but the checker typed it as the
variable's type. When a var declaration was the last expression in an
if-else branch, the branch's block took on that type, so `Expr::If`
decided the if produced a value and appended a merge block param typed
from it. Codegen meanwhile returns a placeholder `iconst(I32, 0)` for a
var declaration (src/jit.rs:1182), and that placeholder was jumped into
the merge block.

With f32 branches this failed Cranelift verification:

    jump block7(v16): arg v16 has type i32, expected f32

With i32 branches the types lined up and the if silently evaluated to 0
on both the JIT and VM backends.

The Expr::Var arm now records the variable's type in types[id] — the
backends read it to size the stack slot — and returns Void from
check_expr, which is what the enclosing block and if-else consume. A
var-tailed branch is void, so is_value is false, no merge param is
added, and the placeholder never crosses a block boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX9w4k8qecsH9LMgBP8hXc
The merge block param type comes from decl.types; the branch values come
from codegen. Nothing checked that the two agree, so a mismatch either
reached the Cranelift verifier as an opaque failure, or — when the
placeholder happened to be the right Cranelift type — produced
well-formed IR computing the wrong answer.

The previous commit removed the one path that could construct such a
mismatch. This catches the class: any future expression kind typed
non-void by the checker but returning a placeholder from codegen now
fails at the mismatch, naming the branch and both types.

Before, with the checker fix reverted:

    cranelift IR verification failed:
    - inst32 (jump block7(v22)  ; v22 = 0): arg v22 has type i32, expected f32

After:

    JIT internal error: then branch of if-else produced a i32 value, but
    the merge block expects f32. The checker and codegen disagree about
    what this branch evaluates to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX9w4k8qecsH9LMgBP8hXc
Void isn't a value type in lyte: there's nothing to store and nothing you
can do with the binding afterwards, so a void-typed declaration is always
a mistake. It used to be accepted silently, with the error surfacing at
whatever first tried to use the variable — or nowhere at all, if nothing
did.

check_void_declarations runs after solving and flags any let/var whose
solved type is void. Like check_unsolved_types it only runs when no other
errors have been reported: unvisited expressions keep the Void fill value
from check_fn_decl, so a function that bailed out early would otherwise
produce false positives.

The parser recorded let/var expressions at cx.lex.loc *after* parsing the
initializer, so diagnostics anchored to a declaration pointed at the
following line. Capture the keyword's location instead. This also moves
the second error in checker/not_a_struct.lyte from the closing brace onto
the declaration it belongs to.

Note this is stricter than Rust, where () is an ordinary inhabited type
and `let y = ();` is legal. Lyte has no way to produce or consume a void
value, so nothing is lost by rejecting the binding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BX9w4k8qecsH9LMgBP8hXc
@wtholliday
wtholliday merged commit 7011333 into main Aug 8, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

possible compiler bug with i32

1 participant