Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fa25149
checker: fix or-unwrapping an option map field/var/index (fix #27867)
medvednikov Jul 19, 2026
756eced
checker: address review — only relax the map-copy guard for immutable…
medvednikov Jul 19, 2026
2a6f7dd
checker: address review — only exempt or-unwrap that clears the option
medvednikov Jul 19, 2026
8c4713c
checker: address review — keep map-copy guard for mutable option sources
medvednikov Jul 19, 2026
66afd39
tests: use the review's exact mutable-source form in the alias regres…
medvednikov Jul 19, 2026
b870fb7
checker: reject or-unwrap through immutable pointers to mutable data
medvednikov Jul 19, 2026
6448418
checker: never exempt shared/atomic or-unwrap destinations from map-c…
medvednikov Jul 19, 2026
e65b20e
checker: keep map-copy guard when or-block default returns a mutable …
medvednikov Jul 19, 2026
5e63eb7
checker: unalias types before the or-unwrap pointer check
medvednikov Jul 19, 2026
b599c51
checker: only exempt fresh/owned or-block map defaults, not immutable…
medvednikov Jul 19, 2026
8f35843
checker: filter semicolons and tighten or-block default classification
medvednikov Jul 19, 2026
6565262
checker: only exempt fresh/noreturn or-block map call defaults
medvednikov Jul 19, 2026
b82d49b
checker: restrict fresh or-block clone/move defaults to builtin map ops
medvednikov Jul 19, 2026
01d21af
checker: require direct map receiver for fresh or-block clone/move de…
medvednikov Jul 19, 2026
c31f53b
checker: strip parens before classifying or-block map defaults
medvednikov Jul 19, 2026
73ce15c
checker: revert unsound option-map or-unwrap exemption; require clone…
medvednikov Jul 20, 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
45 changes: 44 additions & 1 deletion vlib/v/checker/assign.v
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ fn assign_expr_is_auto_deref(expr ast.Expr) bool {
return expr.is_auto_deref_var()
}

// assign_or_unwrap_source_is_immutable conservatively reports whether the source
// of an `or {}` unwrap can never be mutated later (so a copy of its map cannot
// observe a later mutation of the original). Only the plainly-immutable roots are
// recognised; anything unknown returns false, keeping the map-copy guard.
fn assign_or_unwrap_source_is_immutable(expr ast.Expr) bool {
return match expr {
ast.Ident { !expr.is_mut() }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the guard for immutable option aliases

This treats any non-mut identifier as storage that cannot be mutated later, but an immutable ?map binding can still wrap a map that is mutable through another alias, e.g. mut base := {'x': 1}; opt := ?map[string]int(base); x := opt or { panic('missing') }; base['x'] = 2 now bypasses the map-copy guard and x observes the later mutation. The same applies to ?map parameters passed from mutable callers, so suppressing the guard needs an ownership/freshness check rather than just !expr.is_mut().

Useful? React with 👍 / 👎.

ast.SelectorExpr { assign_or_unwrap_source_is_immutable(expr.expr) }
ast.IndexExpr { assign_or_unwrap_source_is_immutable(expr.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.

P1 Badge Reject unwraps through immutable pointers to mutable data

When the immutable root is a pointer/reference to mutable storage, this returns true even though the underlying option map can still be mutated later through the original mutable owner. For example, with mut c := Category{translations: {'x': 1}}; p := &c; x := p.translations or { panic('missing') }; c.translations?['x'] = 2, the new exemption suppresses the map-copy guard because p is immutable, but x aliases the same map and observes the later mutation. Treat pointer roots (or selector receivers whose type is a pointer) as mutable/unknown so the existing clone/move diagnostic is preserved.

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 — real hole, fixed in b870fb7. Confirmed your exact example was suppressing the guard on the previous revision (checker allowed it), while master rejects it.

assign_or_unwrap_source_is_immutable now treats a pointer/reference anywhere in the chain as mutable/unknown:

ast.Ident {
    if expr.obj is ast.Var {
        !expr.is_mut() && !expr.obj.typ.is_ptr()
    } else {
        !expr.is_mut()
    }
}
ast.SelectorExpr {
    !expr.expr_type.is_ptr() && assign_or_unwrap_source_is_immutable(expr.expr)
}
ast.IndexExpr {
    !expr.left_type.is_ptr() && assign_or_unwrap_source_is_immutable(expr.left)
}

So the guard is preserved for:

  • p := &c; x := p.translations or { ... } (pointer variable root)
  • fn f(p &Category) { x := p.translations or { ... } } (immutable reference parameter — receiver expr_type is a pointer)
  • arr := [&c]; x := arr[0].translations or { ... } (pointer array-element receiver — caught by the expr_type check even though the array root itself is immutable)

Added option_map_or_unwrap_ptr_source_err.vv covering all three. Full compiler_errors_test.v and all 216 vlib/v/tests/options/ tests pass.

ast.ParExpr { assign_or_unwrap_source_is_immutable(expr.expr) }
else { false }
}
}

fn (c &Checker) auto_deref_source_type_is_pointer(expr ast.Expr) bool {
if expr !is ast.Ident || c.table.cur_fn == unsafe { nil } || !expr.is_auto_deref_var() {
return false
Expand Down Expand Up @@ -886,8 +900,37 @@ or use an explicit `unsafe{ a[..] }`, if you do not want a copy of the slice.',
} else {
right.is_lvalue()
}
// `x := opt_map or { ... }` unwraps an option/result into a new immutable
// variable, so `x` can never become a mutable alias of the underlying map
// and the shallow copy is safe. This mirrors how V already accepts the
// equivalent immutable `x := opt_array_field or { ... }`. Several things
// must hold for the copy to be safe, otherwise the guard is kept so
// aliasing still requires a `clone`/`move`:
// - the destination is a new immutable variable (a mutable `mut x := ...`
// or a reassignment `x = ...` could still mutate through `x`);
// - the `or` actually clears the option/result — for `map[K]?map[...]`,
// `v := m[k] or { none }` keeps the option-map type, so `v` is still an
// option handle aliasing the map in `m`;
// - the unwrapped source itself is immutable — otherwise a later mutation
// of the source (e.g. `mut opt := ...; x := opt or { ... }; opt?[k] = v`)
// would be observed through `x`.
// See vlang/v issue #27867.
mut right_is_immutable_or_unwrap := false
if node.op == .decl_assign && left is ast.Ident && !left.is_mut

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 Do not exempt shared unwrap declarations

When the declaration is shared b := c.translations or { ... }, left.is_mut is still false, so this path marks the RHS as safe and suppresses the map-copy guard even though b is mutable under lock b { b['x'] = 2 }. That creates the same shallow alias with the map still stored in c that the existing guard rejects for shared b := a; exclude shared declarations (for example via left.info.share == .shared_t) from this immutable-destination exemption.

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.

Addressed in 6448418, with a nuance worth flagging: on this codebase the shared case was already rejected. The parser sets is_mut for shared/atomic declarations (parser.v:1553: is_mut := p.tok.kind == .key_mut || is_shared || is_atomic), so !left.is_mut already excluded shared destinations. I instrumented it to be sure — for shared b := c.translations or { ... } the exemption evaluated to false and the guard fired, matching master.

That said, relying on is_mut incidentally covering shared is fragile for a safety bypass, so I made it explicit as you suggested:

left_is_lockable_dest := left is ast.Ident && left.info is ast.IdentVar
    && left.info.share in [.shared_t, .atomic_t]
...
if node.op == .decl_assign && left is ast.Ident && !left.is_mut && !left_is_lockable_dest
    && !right_type.has_flag(.option) && !right_type.has_flag(.result) {

Now the exemption is independent of the parser's is_mut-includes-shared detail (and also covers atomic). Added option_map_or_unwrap_shared_dest_err.vv locking in that shared b := c.translations or { ... } keeps the cannot copy map diagnostic. Full compiler_errors_test.v and all 216 vlib/v/tests/options/ tests pass.

&& !right_type.has_flag(.option) && !right_type.has_flag(.result) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep copy guard for mutable option sources

Fresh evidence in this revision is that the new exemption still keys only off the destination being immutable, while the added alias regression test covers only mut x := .... For mut opt := ?map[string]int({'x': 1}); x := opt or { panic('missing') }; opt?['x'] = 2, this bypass suppresses the existing map-copy diagnostic even though x shares the same map storage and observes the later mutation, which is the aliasing the m2 := m1 guard is meant to prevent. Keep requiring clone/move unless the unwrapped option source itself cannot be mutated later.

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.

This was resolved in 8c4713c (the review is anchored to the earlier 2a6f7dd revision). The exemption no longer keys only off the destination — it now also requires the unwrapped source to be immutable, via a conservative helper that walks to the root:

fn assign_or_unwrap_source_is_immutable(expr ast.Expr) bool {
    return match expr {
        ast.Ident { !expr.is_mut() }
        ast.SelectorExpr { assign_or_unwrap_source_is_immutable(expr.expr) }
        ast.IndexExpr { assign_or_unwrap_source_is_immutable(expr.left) }
        ast.ParExpr { assign_or_unwrap_source_is_immutable(expr.expr) }
        else { false }
    }
}

Your exact example now errors on HEAD:

$ v run x.v
x.v:7:7: error: cannot copy map: call `move` or `clone` method (or use a reference)
   7 |     x := opt or { panic('missing') }
         |          ~~~

I've also extended option_map_or_unwrap_mut_source_err.vv to use your exact mut opt := ?map[string]int({'x': 1}) form and to cover mutable variable, field, and index sources (previously the only alias test, ..._mut_alias_err.vv, covered just the mut x := ... destination). Verified that mutable param and mutable-receiver-field sources are guarded too. Full compiler_errors_test.v and all 216 vlib/v/tests/options/ tests pass.

unwrapped_right := right.remove_par()
has_or_block := match unwrapped_right {
ast.Ident { unwrapped_right.or_expr.kind != .absent }
ast.IndexExpr { unwrapped_right.or_expr.kind != .absent }
ast.SelectorExpr { unwrapped_right.or_block.kind != .absent }
else { false }
}

right_is_immutable_or_unwrap = has_or_block
&& assign_or_unwrap_source_is_immutable(unwrapped_right)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve map-copy guard for lvalue or-block defaults

When the option source is immutable but the or block returns another map lvalue, this exemption suppresses the existing copy diagnostic even though the value can come from that mutable fallback. For example, mut fallback := {'x': 1}; opt := maybe_none(); x := opt or { fallback }; fallback['x'] = 2 now bypasses the guard because only opt is checked for immutability, but x aliases fallback and observes the later mutation; inspect the block's returned expression or keep the guard when the default path can return a map lvalue.

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 e65b20e. Confirmed x := opt or { fallback } (mutable fallback) aliased and observed the later mutation; the exemption now also inspects the or block's fallback value and only holds when the default cannot alias mutable storage — a fresh value (map literal / by-value call), an immutable lvalue, or a block that yields no value (diverges/propagates). or { fallback } now keeps the cannot copy map diagnostic; or { panic(...) }, or { {...} }, and or { immutable_map } still compile. Added option_map_or_unwrap_mut_default_err.vv.

Bigger picture — I want to be straight with you rather than keep patching: this is the 8th aliasing edge on this exemption, and the underlying one from an earlier round is still open and not fixable with a local heuristic:

mut base := {'x': 1}
opt := ?map[string]int(base)   // opt is an immutable binding, but wraps base's map
x := opt or { panic('') }      // x aliases base
base['x'] = 2                  // observed through x

opt is immutable, so assign_or_unwrap_source_is_immutable (correctly) can't see that its value aliases base — that needs data-flow/ownership info the checker doesn't have. Since maps share storage and V has no ownership tracking, no !is_mut()-style check can be made sound — there will always be another edge.

There are two sound resolutions, and I think it's worth picking one instead of continuing:

  1. Revert the exemptionx := opt or {} errors like m2 := m1; users write (opt or {}).clone() (already compiles today). Most consistent with V's explicit-map-copy rule; closes Cannot or-unwrap ?map[string]T field #27867 as "use .clone()".
  2. Auto-clone the unwrapped map (like mut a := arr[..]) — bare form compiles and runs as the issue asks, and it's sound because x is fresh. Deletes this whole heuristic and every past/future aliasing edge at once.

Happy to implement either — which do you prefer?

}
if left_sym.kind == .map && is_assign && right_sym.kind == .map && !c.inside_unsafe
&& !left.is_blank_ident() && right_is_lvalue
&& !left.is_blank_ident() && right_is_lvalue && !right_is_immutable_or_unwrap
&& (!right_type.is_ptr() || (right is ast.Ident && assign_expr_is_auto_deref(right))) {
// Do not allow `a = b`
c.error('cannot copy map: call `move` or `clone` method (or use a reference)',
Expand Down
14 changes: 14 additions & 0 deletions vlib/v/checker/tests/option_map_or_unwrap_mut_alias_err.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
vlib/v/checker/tests/option_map_or_unwrap_mut_alias_err.vv:14:17: error: cannot copy map: call `move` or `clone` method (or use a reference)
12 | 'x': 1
13 | }]
14 | mut a := xs[0] or { panic('missing') }
| ~~~~~~~~~~~~~~~~~~~~~~~
15 | a['x'] = 2
16 |
vlib/v/checker/tests/option_map_or_unwrap_mut_alias_err.vv:22:13: error: cannot copy map: call `move` or `clone` method (or use a reference)
20 | }
21 | }
22 | mut b := c.translations or { panic('missing') }
| ~~~~~~~~~~~~
23 | b['x'] = 2
24 |
29 changes: 29 additions & 0 deletions vlib/v/checker/tests/option_map_or_unwrap_mut_alias_err.vv
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Or-unwrapping an option map into a *mutable* destination would let the new
// variable alias the container/field storage, so it must still require an
// explicit `clone`/`move`. See vlang/v issue #27867 (the immutable
// `x := opt_map or { ... }` form is allowed, the mutable form is not).
struct Category {
mut:
translations ?map[string]int
}

fn main() {
mut xs := [{
'x': 1
}]
mut a := xs[0] or { panic('missing') }
a['x'] = 2

mut c := Category{
translations: {
'x': 1
}
}
mut b := c.translations or { panic('missing') }
b['x'] = 2

println(xs)
println(c)
println(a)
println(b)
}
21 changes: 21 additions & 0 deletions vlib/v/checker/tests/option_map_or_unwrap_mut_source_err.out
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
vlib/v/checker/tests/option_map_or_unwrap_mut_source_err.vv:17:7: error: cannot copy map: call `move` or `clone` method (or use a reference)
15 | 'x': 1
16 | }
17 | a := opt or { panic('missing') }
| ~~~
18 |
19 | mut c := Category{
vlib/v/checker/tests/option_map_or_unwrap_mut_source_err.vv:24:9: error: cannot copy map: call `move` or `clone` method (or use a reference)
22 | }
23 | }
24 | b := c.translations or { panic('missing') }
| ~~~~~~~~~~~~
25 |
26 | inner := {
vlib/v/checker/tests/option_map_or_unwrap_mut_source_err.vv:32:14: error: cannot copy map: call `move` or `clone` method (or use a reference)
30 | 'a': ?map[string]int(inner)
31 | }
32 | d := m['a'] or { panic('missing') }
| ~~~~~~~~~~~~~~~~~~~~~~~
33 |
34 | println(a)
37 changes: 37 additions & 0 deletions vlib/v/checker/tests/option_map_or_unwrap_mut_source_err.vv
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Or-unwrapping an option map into an immutable local is only safe when the
// *source* can never be mutated later. If the source (variable, field, or index)
// is mutable, the immutable local still aliases the same map storage and would
// observe a later mutation, so the map-copy guard must stay. See vlang/v issue
// #27867 (only immutable-source unwraps like `c := Category{}; x := c.f or {...}`
// are exempted).
struct Category {
mut:
translations ?map[string]int
}

fn main() {
mut opt := ?map[string]int(none)
opt = {
'x': 1
}
a := opt or { panic('missing') }

mut c := Category{
translations: {
'x': 1
}
}
b := c.translations or { panic('missing') }

inner := {
'x': 1
}
mut m := {
'a': ?map[string]int(inner)
}
d := m['a'] or { panic('missing') }

println(a)
println(b)
println(d)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
vlib/v/checker/tests/option_map_or_unwrap_preserves_option_err.vv:14:14: error: cannot copy map: call `move` or `clone` method (or use a reference)
12 | 'a': ?map[string]int(inner)
13 | }
14 | v := m['a'] or { none }
| ~~~~~~~~~~~
15 | println(v)
16 | }
16 changes: 16 additions & 0 deletions vlib/v/checker/tests/option_map_or_unwrap_preserves_option_err.vv
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// For `map[K]?map[...]`, an `or` block that returns `none` (or another `?map`)
// does NOT clear the option: `v := m[k] or { none }` keeps the option-map type,
// so `v` is still an option handle that aliases the map stored in `m`. The
// map-copy guard must stay in that case, even though the destination is an
// immutable declaration. See vlang/v issue #27867 (only the option-clearing
// form `m[k] or { panic(...) }` is exempted).
fn main() {
inner := {
'x': 1
}
m := {
'a': ?map[string]int(inner)
}
v := m['a'] or { none }
println(v)
}
70 changes: 70 additions & 0 deletions vlib/v/tests/options/option_map_field_or_unwrap_test.v
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Regression test for https://github.com/vlang/v/issues/27867
// or-unwrapping an option map (struct field, variable, index, parenthesized)
// into an immutable variable used to fail with
// `cannot copy map: call move or clone method (or use a reference)`.
// The mutable form (`mut x := m or { ... }`) still errors on purpose, see
// vlib/v/checker/tests/option_map_or_unwrap_mut_alias_err.vv.
struct Translation {
title string
}

struct Category {
title string
translations ?map[string]Translation
}

fn test_option_map_field_or_unwrap() {
c := Category{
translations: {
'en': Translation{
title: 'Hello'
}
}
}
translations := c.translations or { panic('expected') }
assert translations['en'].title == 'Hello'
}

fn test_option_map_field_or_unwrap_none() {
c := Category{}
translations := c.translations or {
map[string]Translation{}
}

assert translations.len == 0
}

fn get_option_map() ?map[string]int {
return {
'a': 1
}
}

fn test_option_map_var_or_unwrap() {
x := get_option_map()
y := x or { panic('expected') }
assert y['a'] == 1
}

fn test_option_map_index_or_unwrap() {
inner_map := {
'b': 2
}
m := {
'a': inner_map
}
inner := m['a'] or { panic('expected') }
assert inner['b'] == 2
}

fn test_option_map_field_paren_or_unwrap() {
c := Category{
translations: {
'en': Translation{
title: 'Hello'
}
}
}
translations := (c.translations or { panic('expected') })
assert translations['en'].title == 'Hello'
}
Loading