Skip to content

v3: fix assert truncating a negated wide integer literal to 32 bits - #28051

Open
hunterjsb wants to merge 3 commits into
vlang:masterfrom
we-be:fix-v3-assert-negated-literal
Open

v3: fix assert truncating a negated wide integer literal to 32 bits#28051
hunterjsb wants to merge 3 commits into
vlang:masterfrom
we-be:fix-v3-assert-negated-literal

Conversation

@hunterjsb

@hunterjsb hunterjsb commented Aug 8, 2026

Copy link
Copy Markdown

Fixes #28050.

assert captures numeric operands into temps for its failure diagnostic, exempting literals. A signed literal parses as a .prefix over the literal, so it missed the exemption, resolved to the default int, and truncated:

i64 _t1 = (i64)(get_plain());
int _t2 = (int)(-123456789012345);   // 2045911175
if (!(_t1 == _t2)) {

The comparison reads the temp, so the assert evaluated wrong, not just reported wrong. Only macOS delegates to v3 by default, which is why it looked platform-specific; driving v3 directly on Linux reproduces it.

Fix: exempt side-effect-free constant operands from capture: literals, sign/~ prefixes, parens, and constant infix — -9223372036854775807 - 1 (math_test.v:1145) is an infix, so a literal-only exemption missed it. The classic backend never captures these shapes (assert_subexpression_to_ctemp allowlists only side-effecting exprs), so this restores parity: assert evaluates constants exactly like a plain if. Non-constant operands still capture, preserving single evaluation of side effects.

Diagnostics: per review, display suppression is a separate, narrower predicate — only a bare or sign-prefixed literal drops its = value (the label already spells it out). ~5, -(-1), (5), 2 * 3 keep their evaluated output; captured-then-truncated constants previously printed the truncated value and now print the true one.

Affected in vlib today: time_test.v:505,512,518, enum_explicit_size_big_and_small_test.v:78-80, math_test.v:1145.

Tests: vlib/v3/tests/assert_negated_literal_codegen_test.v inspects the emitted if (!( conditions for a negated literal and a constant product; each fails without its part of the fix. It asserts on emitted C rather than running a binary — v3's prelude omits <stdlib.h> and gcc 16 rejects the resulting implicit qsort, which also fails assert_stderr_shadow_codegen_test.v on master. The three v3_assert_* inout fixtures emit byte-identical C with and without this patch.

@hunterjsb

Copy link
Copy Markdown
Author

Two things I should disclose after reviewing this more adversarially.

1. A diagnostic change beyond the reported bug. Exempting ~ means a failing assert f == ~5 now prints the operand's source text rather than its computed value:

before:   right value: ~5 = -6
after:    right value: ~5

For a sign-prefixed literal this is lossless (-1 prints as -1 either way), and it matches how a bare .int_literal already prints. For ~ it does lose the folded value. I included .bit_not because ~<wide literal> truncates through the identical path, but it is the one case where the exemption costs information. There are currently zero ~-literal asserts in vlib/cmd/examples, so this is theoretical — happy to narrow the patch to .plus/.minus/.paren only if you would rather keep the scope tight and leave ~<wide> for a separate change.

More generally, a failing assert against any signed literal now prints right value: -1 instead of right value: -1 = -1. No .out fixture in the repo depends on that format, and it makes signed literals consistent with bare ones.

2. Scope is narrower than the issue title suggested. Positive wide literals were never affected — they are bare .int_literal nodes and already hit the exemption. Only sign-prefixed (and parenthesized) literals reach the capture path. assert factoriali(20) == 2432902008176640000 is correct on both sides of this patch.

The bug does reach vlib's own suite, though — these all exceed 32 bits and truncate under v3 today:

  • vlib/time/time_test.v:505,512== -62167132800
  • vlib/time/time_test.v:518== -62135596800
  • vlib/v/tests/enums/enum_explicit_size_big_and_small_test.v:78-80== -999999999999 and friends
  • vlib/math/math_test.v:1145== -9223372036854775807 - 1

Verification of no downstream effect: I compiled the three v3 assert fixtures (v3_assert_operand_once, v3_assert_percent_label, v3_assert_unsigned_value) with and without the patch — the emitted C is byte-identical apart from the embedded version string. Operand capture for non-constant expressions is untouched, so single-evaluation of side-effecting operands (values.pop()) is preserved, and -x where x is a variable still captures normally.

@hunterjsb
hunterjsb force-pushed the fix-v3-assert-negated-literal branch from eee243c to 5889122 Compare August 8, 2026 05:26
@medvednikov

Copy link
Copy Markdown
Member

Review result: request changes

CI ignored as requested. I reviewed the current PR head, 5889122c72e1ce0837dee1bc40c5d3c833de99c8.

1. [P2] Separate capture eligibility from diagnostic suppression

File: vlib/v3/gen/c/stmt.v:2995

is_constant_numeric_literal() is appropriate for deciding whether an operand must be captured: it limits the exemption to numeric literals wrapped in parentheses or +, -, and ~, so calls and other side-effecting expressions remain captured.

However, the same predicate is now used by gen_assert_numeric_value() to decide whether to omit the evaluated value. Because it recursively accepts .bit_not and nested prefixes, assertion diagnostics regress:

right value: ~5 = -6

becomes:

right value: ~5

Likewise, -(-1) loses its useful = 1 output. The PR description acknowledges the ~5 behavior change, and the implementation takes the source-only branch for all expressions accepted by the helper.

The capture and display decisions should use separate predicates. Keep the broad helper in gen_assert_capture_numeric_operands(), but make the display predicate narrower—at minimum, do not suppress evaluation for .bit_not or arbitrary nested unary expressions.

2. [P2] Make the regression test inspect the generated condition

File: vlib/v3/tests/assert_negated_literal_codegen_test.v:23-24

This check scans the entire generated C file:

assert code.contains('== -123456789012345')

The generator also embeds the original assertion source in the failure-detail string:

v3_eprint_lit("... assert get() == -123456789012345 ...");

Therefore, the positive assertion succeeds even when the actual generated condition still compares against a temporary.

The preceding negative check only rejects the exact spelling:

(int)(-123456789012345)

A regression emitting this would pass both checks:

int _t2 = -123456789012345;
if (!(_t1 == _t2)) {
    // diagnostic string still contains == -123456789012345
}

Extract the generated if (!( condition line and assert that the literal occurs inline there. It would also be useful to assert that no int temporary is initialized from the wide literal.

Overall

The capture-side change directly addresses the reported truncation and remains narrowly limited to side-effect-free constant expressions. I would hold approval for the diagnostic regression and the false-positive-prone regression test. This was a static source review; no CI results were considered.

@hunterjsb

hunterjsb commented Aug 8, 2026

Copy link
Copy Markdown
Author

Both addressed in ac6e506

1. Split the predicates. is_constant_numeric_literal() stays as capture eligibility. Display now uses is_signed_numeric_literal(), which is non-recursive and accepts only a bare literal or a single .plus/.minus prefix over one — the cases where the label already spells out the value. .bit_not, nested prefixes and parens all fall through to evaluation:

fprintf(..., "  right value", "~5", (long long)(~5));        // = -6
fprintf(..., "  right value", "-(-1)", (long long)(-(-1)));  // = 1
fprintf(..., "  right value", "(5)", (long long)((5)));      // = 5
v3_eprint_lit("  right value: -123456789012345\n");          // label is the value

Conditions still compare inline in all four cases, so nothing regains a truncating temp.

2. The test was false-positive-prone exactly as described. It now extracts the generated condition rather than scanning the file:

conditions := lines.filter(it.trim_space().starts_with('if (!(') && it.contains('123456789012345'))
assert conditions.len == 1
assert conditions[0].contains('== -123456789012345')
assert !lines.any(it.trim_space().starts_with('int _t') && it.contains('123456789012345'))

Against pristine master it now fails on conditions.len == 1 (got 0) rather than on the spelling of the cast, so your hypothetical int _t2 = -123456789012345; regression is caught by both the condition check and the temp check.

@hunterjsb

Copy link
Copy Markdown
Author

Pushed 2f3d857: re-checking adjacent shapes turned up a constant infix operand that was still captured and truncated — assert minof[i64]() == -9223372036854775807 - 1 (math_test.v:1145) emitted int _t2 = (int)(-9223372036854775807 - 1), i.e. compared against 0. The capture exemption now also covers infix over constant operands (predicate renamed to is_constant_numeric_expr); the display predicate is untouched, so 2 * 3 etc. still print their evaluated value. This matches the classic backend, which never captures constant operands (assert_subexpression_to_ctemp allowlists only side-effecting exprs). The test now covers both shapes and the v3_assert_* fixture C stays byte-identical.

hunterjsb and others added 3 commits August 8, 2026 20:20
assert captures each numeric operand into a temp for its failure
diagnostic, exempting literals. A signed literal parses as a prefix over
the literal, so it missed that exemption, resolved to the default `int`,
and was emitted as `int _t = (int)(-123456789012345)`. The comparison
reads that temp, so the assert evaluated wrong rather than merely
reporting wrong:

    assert get_plain() == -123456789012345
      right value: -123456789012345 = 2045911175

This only surfaced on macOS, which is the sole platform that delegates to
v3 by default; the classic backend emits the literal with an LL suffix
and is unaffected.

Extend the exemption to sign/bit-not prefixes and parens over a numeric
literal, matching the constant folding already done in
enum_field_expr_value. Fixes vlang#28050.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback. The broad predicate was also deciding whether to omit the
evaluated value, so `~5` and `-(-1)` lost their `= -6` and `= 1` output.
Capture keeps the broad rule, since those expressions are side-effect free;
display narrows to a bare or sign-prefixed literal, where the label already
spells out the value.

Tighten the regression test too: it scanned the whole C file, so the assert
source embedded in the failure detail satisfied the positive check even when
the condition still compared against a temp. Inspect the generated `if (!(`
condition instead, and reject an int temp initialized from the wide literal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A constant infix operand like `-9223372036854775807 - 1` was still
captured as the default `int`, truncating the comparison the same way
the negated literal was (math_test.v:1145 hits this exact shape).
The classic backend never captures constant operands at all.

Also shortens the predicate comments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@hunterjsb
hunterjsb force-pushed the fix-v3-assert-negated-literal branch from 2f3d857 to ddc37b2 Compare August 9, 2026 00:22
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.

v3: assert captures a negated out-of-range integer literal into an int temp, truncating the comparison

2 participants