Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
fab69be
parser: mark match-arm calls as return-used when a match is a block v…
medvednikov Jul 31, 2026
0efc15b
parser: recurse through ParExpr when marking match/if block-value cal…
medvednikov Aug 2, 2026
439a513
parser, v3: handle unsafe-wrapped match/if block values with propagat…
medvednikov Aug 2, 2026
1546c32
parser, checker, v3: handle cast/as-cast-wrapped match/if block value…
medvednikov Aug 2, 2026
b210e50
parser, checker, cgen, v3: look through unsafe wrappers for cast-wrap…
medvednikov Aug 2, 2026
77a271a
parser, checker, v3: traverse both infix operands for match/if block …
medvednikov Aug 2, 2026
b3b5a13
parser: mark match/if arm calls in call arguments as return-used (#28…
medvednikov Aug 2, 2026
e3c7894
parser: recurse through nested call arguments for block-value match/i…
medvednikov Aug 2, 2026
f46183f
parser: recurse through infix operands inside call arguments for bloc…
medvednikov Aug 2, 2026
eb4b179
parser, checker: handle array-literal match/if block values with prop…
medvednikov Aug 2, 2026
353a90a
parser, checker: handle struct-field match values and scope the array…
medvednikov Aug 2, 2026
23236ba
parser, checker: handle map-literal match/if block values with propag…
medvednikov Aug 2, 2026
9225506
parser, checker, v3: handle prefix-expression match/if block values w…
medvednikov Aug 2, 2026
7020743
parser, checker, v3: handle index-expression match/if block values wi…
medvednikov Aug 2, 2026
d61a5db
checker, parser, v3: value-context left-hand match for membership and…
medvednikov Aug 2, 2026
be7a393
parser, checker: value-context map keys and array spreads for match/i…
medvednikov Aug 2, 2026
6bb7234
parser, checker, v3: struct/map update and string-interp match/if blo…
medvednikov Aug 2, 2026
58fba14
parser, checker, v3: handle dump/likely-wrapped match/if block values…
medvednikov Aug 2, 2026
4095e5f
parser, checker: handle multi-return match/if block values with propa…
medvednikov Aug 2, 2026
9c8e75b
parser, checker: method-call receiver and slice-bound match/if block …
medvednikov Aug 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 84 additions & 48 deletions vlib/v/checker/checker.v
Original file line number Diff line number Diff line change
Expand Up @@ -76,53 +76,54 @@ pub mut:
error_details []string
should_abort bool // when too many errors are accumulated, .should_abort becomes true. It is checked in statement/expression loops, so the checker can return early, instead of wasting time.

expected_type ast.Type
expected_or_type ast.Type // fn() or { 'this type' } eg. string. expected or block type
expected_expr_type ast.Type // if/match is_expr: expected_type
mod string // current module name
has_globals_in_module bool // true if the current module has @[has_globals] attribute
strict_map_index_in_module bool // true if the current module has @[strict_map_index] attribute
const_var &ast.ConstField = unsafe { nil } // the current constant, when checking const declarations
const_deps []string
const_eval_stack []string // names of constants currently being recursively resolved (to break cycles via anon fn bodies)
const_names []string
global_names []string
locked_names []string // vars that are currently locked
rlocked_names []string // vars that are currently read-locked
in_for_count int // if checker is currently in a for loop
returns bool
scope_returns bool
is_builtin_mod bool // true inside the 'builtin', 'os' or 'strconv' modules; TODO: remove the need for special casing this
is_just_builtin_mod bool // true only inside 'builtin'
is_generated bool // true for `@[generated] module xyz` .v files
unresolved_fixed_sizes []&ast.Stmt // funcs with unresolved array fixed size e.g. fn func() [const1]int
inside_recheck bool // true when rechecking rhs assign statement
inside_unsafe bool // true inside `unsafe {}` blocks
inside_const bool // true inside `const ( ... )` blocks
inside_anon_fn bool // true inside `fn() { ... }()`
inside_lambda bool // true inside `|...| ...`
inside_ref_lit bool // true inside `a := &something`
inside_defer bool // true inside `defer {}` blocks
inside_return bool // true inside `return ...` blocks
inside_fn_arg bool // `a`, `b` in `a.f(b)`
inside_ct_attr bool // true inside `[if expr]`
inside_x_is_type bool // true inside the Type expression of `if x is Type {`
inside_x_matches_type bool // true inside the match branch of `match x.type { Type {} }`
anon_struct_should_be_mut bool // true when `mut var := struct { ... }` is used
inside_generic_struct_init bool
inside_integer_literal_cast bool // true inside `int(123)`
cur_struct_generic_types []ast.Type
cur_struct_concrete_types []ast.Type
anon_fn_generic_names []string
anon_fn_concrete_types []ast.Type
skip_flags bool // should `#flag` and `#include` be skipped
fn_level int // 0 for the top level, 1 for `fn abc() {}`, 2 for a nested fn, etc
smartcast_mut_pos token.Pos // match mut foo, if mut foo is Foo
smartcast_cond_pos token.Pos // match cond
ct_cond_stack []ast.Expr
ct_user_defines map[string]bool
ct_system_defines map[string]bool
cur_ct_id int // id counter for $if $match branches
expected_type ast.Type
expected_or_type ast.Type // fn() or { 'this type' } eg. string. expected or block type
expected_expr_type ast.Type // if/match is_expr: expected_type
mod string // current module name
has_globals_in_module bool // true if the current module has @[has_globals] attribute
strict_map_index_in_module bool // true if the current module has @[strict_map_index] attribute
const_var &ast.ConstField = unsafe { nil } // the current constant, when checking const declarations
const_deps []string
const_eval_stack []string // names of constants currently being recursively resolved (to break cycles via anon fn bodies)
const_names []string
global_names []string
locked_names []string // vars that are currently locked
rlocked_names []string // vars that are currently read-locked
in_for_count int // if checker is currently in a for loop
returns bool
scope_returns bool
is_builtin_mod bool // true inside the 'builtin', 'os' or 'strconv' modules; TODO: remove the need for special casing this
is_just_builtin_mod bool // true only inside 'builtin'
is_generated bool // true for `@[generated] module xyz` .v files
unresolved_fixed_sizes []&ast.Stmt // funcs with unresolved array fixed size e.g. fn func() [const1]int
inside_recheck bool // true when rechecking rhs assign statement
inside_unsafe bool // true inside `unsafe {}` blocks
inside_const bool // true inside `const ( ... )` blocks
inside_anon_fn bool // true inside `fn() { ... }()`
inside_lambda bool // true inside `|...| ...`
inside_ref_lit bool // true inside `a := &something`
inside_defer bool // true inside `defer {}` blocks
inside_return bool // true inside `return ...` blocks
inside_fn_arg bool // `a`, `b` in `a.f(b)`
inside_ct_attr bool // true inside `[if expr]`
inside_x_is_type bool // true inside the Type expression of `if x is Type {`
inside_x_matches_type bool // true inside the match branch of `match x.type { Type {} }`
inside_array_init_value_elem bool // true when checking a value `match`/`if` array element, e.g. `[match x { .. }]`
anon_struct_should_be_mut bool // true when `mut var := struct { ... }` is used
inside_generic_struct_init bool
inside_integer_literal_cast bool // true inside `int(123)`
cur_struct_generic_types []ast.Type
cur_struct_concrete_types []ast.Type
anon_fn_generic_names []string
anon_fn_concrete_types []ast.Type
skip_flags bool // should `#flag` and `#include` be skipped
fn_level int // 0 for the top level, 1 for `fn abc() {}`, 2 for a nested fn, etc
smartcast_mut_pos token.Pos // match mut foo, if mut foo is Foo
smartcast_cond_pos token.Pos // match cond
ct_cond_stack []ast.Expr
ct_user_defines map[string]bool
ct_system_defines map[string]bool
cur_ct_id int // id counter for $if $match branches
mut:
stmt_level int // the nesting level inside each stmts list;
// .stmt_level is used to check for `evaluated but not used` ExprStmts like `1 << 1`
Expand Down Expand Up @@ -4867,7 +4868,18 @@ pub fn (mut c Checker) expr(mut node ast.Expr) ast.Type {
return c.array_init(mut node)
}
ast.AsCast {
node.expr_type = c.expr(mut node.expr)
if c.expected_type == ast.void_type && operand_is_value_match_or_if(node.expr) {
// A `match`/`if` operand of an `as` cast is a value expression, e.g.
// `(match x { ... }) as Variant`. Give it a non-void expected type so
// it is checked as an expression (`is_expr`) even when nested in a
// void context (e.g. an if-branch), instead of being typed as `void`.
old_expected_type := c.expected_type
c.expected_type = node.typ
node.expr_type = c.expr(mut node.expr)
c.expected_type = old_expected_type
} else {
node.expr_type = c.expr(mut node.expr)
}
expr_type_sym := c.table.sym(node.expr_type)
type_sym := c.table.sym(c.unwrap_generic(node.typ))
if mut node.expr is ast.Ident {
Expand Down Expand Up @@ -5414,6 +5426,23 @@ fn integer_literal_from_pointer_cast_expr(expr ast.Expr) ?ast.IntegerLiteral {
}
}

// operand_is_value_match_or_if reports whether an expression is a `match`/`if`
// expression used as a value, looking through transparent `(...)` and
// `unsafe { }` wrappers (including compositions like `unsafe { match ... }`).
// Such an operand of a cast or infix expression must be checked with a non-void
// expected type so it is treated as `is_expr`, even when the surrounding expected
// type is void (e.g. nested inside an if-branch) — otherwise it is mistyped as
// `void`.
fn operand_is_value_match_or_if(expr ast.Expr) bool {
if expr is ast.ParExpr {
return operand_is_value_match_or_if(expr.expr)
}
if expr is ast.UnsafeExpr {
return operand_is_value_match_or_if(expr.expr)
}
return expr is ast.MatchExpr || expr is ast.IfExpr
Comment on lines +5461 to +5467

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unwrap unsafe operands when recognizing value matches

Although the separate unsafe and cast spellings are covered, composing them—for example i64(unsafe { match value { First { lower_first(value)! } Second { lower_second(value)! } } }) in an if-expression branch—still returns false here because this predicate only peels ParExpr. cast_expr therefore leaves expected_type as void; unsafe_expr forwards that void context to the match, which is checked as a statement rather than a value, so valid code is rejected. The v3 predicate has the equivalent unsafe-block gap; recurse through this wrapper in both implementations and add a composition regression.

AGENTS.md reference: AGENTS.md:L652-L658

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b210e50. You're right — the predicates only peeled (...). Recursing through unsafe { } in both, plus one extra fix the composition surfaced:

  • vlib/v checker: cast_operand_is_value_match_or_if now also recurses through ast.UnsafeExpr, so i64(unsafe { match ... }) (in any paren composition) is checked as a value.
  • vlib/v cgen: recognizing the composed as-cast spelling exposed a pre-existing bug — as_cast_operand_needs_tmp_eval didn't look through ast.UnsafeExpr either, so (unsafe { match ... }) as Variant emitted invalid C (a temp decl inside the __as_cast argument). It now recurses through the wrapper like the ParExpr case; this also fixes the direct (non-if-branch) spelling that was already broken.
  • vlib/v3: is_value_match_or_if_operand now looks through unsafe { } (a .block whose value tail is the expression) and a trailing expr_stmt.

Both regression tests now cover i64(unsafe { match ... }) and (unsafe { match ... }) as Circle. Verified: v3 still self-hosts, and compiler_errors_test (1617 snapshots) + the cast/sumtype/as suites are unchanged.

}

fn (mut c Checker) cast_expr(mut node ast.CastExpr) ast.Type {
// Given: `Outside( Inside(xyz) )`,
// node.expr_type: `Inside`
Expand Down Expand Up @@ -5454,6 +5483,13 @@ fn (mut c Checker) cast_expr(mut node ast.CastExpr) ast.Type {
c.expected_type = base_to_type
} else if node.expr is ast.IndexExpr && to_type.has_flag(.option) {
c.expected_type = to_type
} else if c.expected_type == ast.void_type && operand_is_value_match_or_if(node.expr) {
// A `match`/`if` operand of a cast is a value expression, e.g.
// `i64(match x { ... })`. Propagate the cast target as its expected type
// so it is checked as an expression (`is_expr`) even in contexts where
// the surrounding expected type is void (e.g. nested inside an if-branch),
// instead of being mistyped as `void` ("does not return a value").
c.expected_type = base_to_type
}
expr_is_ident_or_cast := node.expr is ast.Ident || node.expr is ast.CastExpr
node.expr_type = c.expr(mut node.expr) // type to be casted
Expand Down
15 changes: 15 additions & 0 deletions vlib/v/checker/containers.v
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,22 @@ fn (mut c Checker) array_init(mut node ast.ArrayInit) ast.Type {
expr_pos)
continue
}
// A value `match`/`if` array element, e.g. `[match x { ... }]`, in a
// void context (e.g. nested in an if-branch) must be checked as an
// expression (`is_expr`) so its arms produce values and infer the
// element type, instead of being lowered as void statements. Signal
// that via a flag rather than a forced expected type, which would
// mistype the arms.
mut restore_array_elem_flag := false
if c.expected_type == ast.void_type && !c.inside_array_init_value_elem
&& operand_is_value_match_or_if(expr) {
c.inside_array_init_value_elem = true
restore_array_elem_flag = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope the array-value flag to the outer expression

When [match ...] is checked in a void context and one of its arms contains a statement-level match or if before the final propagated value, this flag remains set throughout the recursive c.expr call, so the nested statement is also reclassified as a value expression. For example, a nested statement match whose first arm calls an int-returning side-effect function and whose second arm is empty is valid as a statement, but now fails because the checker requires every branch to produce a value; consume the flag at the intended outer node or otherwise bind it to that exact expression.

AGENTS.md reference: AGENTS.md:L657-L658

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 353a90a. The flag now lives only for the outer element node: match_expr and if_expr capture inside_array_init_value_elem into a local and immediately clear c.inside_array_init_value_elem before checking their arms/branches, so a nested statement-level match/if no longer inherits it.

Confirmed with your exact scenario: an array-element match arm containing a nested statement match cond { true { side_effect() /* int */ } else {} } (and a statement if) before the propagated value now compiles — the nested statement match keeps its void/empty branch, and only the outer [match ...] element is treated as a value. Added that as a regression test (select_value_arraylit_nested_stmt). compiler_errors_test + the array/match/if suites are unchanged.

}
typ = c.check_expr_option_or_result_call(expr, c.expr(mut expr))
if restore_array_elem_flag {
c.inside_array_init_value_elem = false
}
sym := c.table.sym(expected_value_type)
if sym.kind == .interface {
c.type_implements(typ, expected_value_type, expr.pos())
Expand Down
7 changes: 7 additions & 0 deletions vlib/v/checker/if.v
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,20 @@ fn (mut c Checker) gen_branch_context_string() string {

fn (mut c Checker) if_expr(mut node ast.IfExpr) ast.Type {
if_kind := if node.is_comptime { '\$if' } else { 'if' }
// Consume the array-element value flag so it applies only to this outer node,
// not to nested statement-level match/if inside the branches. `[if ... ]` as a
// value array element in a void context must still be treated as an expression.
is_array_init_value_elem := c.inside_array_init_value_elem
c.inside_array_init_value_elem = false
mut node_is_expr := false
if node.branches.len > 0 && node.has_else {
stmts := node.branches[0].stmts
if stmts.len > 0 && stmts.last() is ast.ExprStmt && stmts.last().typ != ast.void_type {
node_is_expr = true
} else if node.is_expr {
node_is_expr = true
} else if is_array_init_value_elem {
node_is_expr = true
}
}
if c.expected_type == ast.void_type && node_is_expr {
Expand Down
19 changes: 14 additions & 5 deletions vlib/v/checker/infix.v
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,29 @@ fn (mut c Checker) infix_expr(mut node ast.InfixExpr) ast.Type {
}
// In bool contexts like `assert` and `return`, short enum literals on the left
// need the right operand type first, so `.a == x` resolves `.a` correctly.
mut check_right_type_first_for_left_short_enum := false
mut check_right_type_first := false
if node.op in [.eq, .ne] && node.left is ast.EnumVal {
left_enum := node.left as ast.EnumVal
if left_enum.enum_name.len == 0 {
if node.right is ast.EnumVal {
right_enum := node.right as ast.EnumVal
check_right_type_first_for_left_short_enum = right_enum.enum_name.len > 0
check_right_type_first = right_enum.enum_name.len > 0
} else {
check_right_type_first_for_left_short_enum = true
check_right_type_first = true
}
}
}
if !check_right_type_first && c.expected_type == ast.void_type
&& operand_is_value_match_or_if(node.left) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the membership element type for a left-hand match

When an inferred branch tail uses a membership expression such as (match node { First { lower_first(node)! } Second { lower_second(node)! } }) in [1, 2], this path checks the right operand first and later uses its full type ([]int) as the expected type of the left-hand match. The match arms return int, so valid code is rejected as though each arm needed to return []int; not in and map membership have the same issue. Derive the array element/map key type for membership operators, or force the match to be a value without imposing the right operand's container type, and add a propagation regression test.

AGENTS.md reference: AGENTS.md:L657-L658

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d61a5db. You're right that imposing the right operand's container type is wrong — I switched infix_expr to force the left match to be a value via the force_value_match_or_if flag (the same mechanism used for array/map/prefix/index operands), so its arms infer their own type and no container type is imposed. This replaces the earlier check-right-first-for-match path.

Confirmed the concrete-int case was passing before, but the flag is genuinely needed for context-dependent arms: I added an enum-shorthand regression ((match v { First { make_color(v)! } ... }) in [Color.red, Color.blue], arms returning .red/.green) which fails under the old container-type imposition and passes now. in, !in, and map membership all work. Regression test covers (match ...) in [1, 2]; compiler_errors_test + the infix/enum/membership suites are unchanged.

// A `match`/`if` value operand on the left, e.g. `(match x { ... }) + 1`,
// would otherwise be checked with the (void) surrounding expected type and
// mistyped as a statement (e.g. when nested inside an if-branch). Resolve
// the right operand's type first and use it as the expected type so the
// left operand is checked as a value expression.
check_right_type_first = true
}
mut right_type := ast.void_type
if check_right_type_first_for_left_short_enum {
if check_right_type_first {
right_type = c.expr(mut node.right)
if right_type == ast.no_type {
node.right_type = right_type
Expand Down Expand Up @@ -229,7 +238,7 @@ fn (mut c Checker) infix_expr(mut node ast.InfixExpr) ast.Type {
}
}
}
if !check_right_type_first_for_left_short_enum {
if !check_right_type_first {
right_type = c.expr(mut node.right)
if right_type == ast.no_type {
node.right_type = right_type
Expand Down
9 changes: 8 additions & 1 deletion vlib/v/checker/match.v
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ import v.token
import strings

fn (mut c Checker) match_expr(mut node ast.MatchExpr) ast.Type {
// `c.inside_array_init_value_elem` marks a value match used as an array element
// in a void context (`[match x { .. }]`), which must be treated as an
// expression even though the surrounding expected type is void. Consume the
// flag here so it applies only to this outer element node, not to nested
// statement-level match/if inside the arms.
is_array_init_value_elem := c.inside_array_init_value_elem
c.inside_array_init_value_elem = false
if !node.is_comptime {
node.is_expr = c.expected_type != ast.void_type
node.is_expr = c.expected_type != ast.void_type || is_array_init_value_elem
}
node.expected_type = c.expected_type
if mut node.cond is ast.ParExpr && !c.pref.translated && !c.file.is_translated {
Expand Down
3 changes: 3 additions & 0 deletions vlib/v/gen/c/cgen.v
Original file line number Diff line number Diff line change
Expand Up @@ -13976,6 +13976,9 @@ fn as_cast_operand_needs_tmp_eval(expr ast.Expr) bool {
ast.ParExpr {
as_cast_operand_needs_tmp_eval(expr.expr)
}
ast.UnsafeExpr {
as_cast_operand_needs_tmp_eval(expr.expr)
}
ast.SelectorExpr {
as_cast_operand_needs_tmp_eval(expr.expr)
}
Expand Down
Loading
Loading