Skip to content

fix: evaluate an assignment used as an expression (#374) - #392

Open
leo-aa88 wants to merge 3 commits into
mainfrom
fix/374-assignment-expression
Open

leo-aa88 wants to merge 3 commits into
mainfrom
fix/374-assignment-expression

Conversation

@leo-aa88

@leo-aa88 leo-aa88 commented Oct 4, 2026

Copy link
Copy Markdown
Member

Description

An assignment used as an expression didn't work. That covers chaining (a = b = 5), nesting (rizz c = (b = 5);, (b = 5) + 1), a flex init (i = j = 0), conditions (goon ((n = n - 1) > 0)), and arguments (yapping("%d", b = 9)). The grammar already accepted all of these. But no value evaluator had a NODE_ASSIGNMENT case, so the inner write was skipped (or its value read as 0) and Invalid integer expression was printed.

As in C, an assignment expression now performs the write and yields the target's value after the write, in the target's type. So smol s; (s = 70000) is 4464, and ii = ff = 3.7 gives ff = 3.70 and ii = 3.

How:

  • execute_assignment() is now a thin wrapper over execute_assignment_slot(), which returns the address it wrote and the descriptor it wrote through.
  • Each value evaluator (int, long, short, float, double, bool, pointer, string) handles NODE_ASSIGNMENT by performing the write and reading the value back.
    • A plain variable target is re-read, which has no side effects.
    • Any other target (arr[k++], arr[bump(&m)], *p, s.f) is read back from the address that was written. Its subexpressions therefore run exactly once: rizz x = arr[k++] = 5; leaves k == 1.
  • The static type queries report the target's type for an assignment expression: get_expression_type, get_expression_pointer_level, expression_is_long, infer_runtime_expression_type_noeval, is_expression (used to marshal native arguments), and the semantic analyzer's infer_expression_type / infer_expression_pointer_level.

Parser fix needed for this to be safe. create_assignment_target_node() called get_current_modifiers(), which consumes and resets the parser's pending declaration modifiers. Bison reduces an assignment nested in an initializer before the declaration itself. So in thicc rizz v = (ra[1] = 5); the inner assignment took the declaration's thicc, which caused two problems:

  • v became a 32-bit rizz, so thicc rizz u = (t = 8000000000) gave 3705032704;
  • the write to the 4-byte rizz element ra[1] became an 8-byte packed_long_store. UBSan reports a misaligned store, and it writes past the element.

An assignment node now gets empty modifiers and leaves the parser state alone. Statement-level assignments already got empty modifiers, because the state is reset after every declaration, so their behavior is unchanged.

Writing a deadass variable inside an assignment expression is still refused during semantic analysis, before anything runs (new assignment_expression_const_fail fixture).

Pre-existing, not changed here (each reproduces on main as a plain statement):

  • ta[1] = 5000000000; on a thicc rizz ta[2] stores only 32 bits. An array or struct element write takes its width from the assignment node's modifiers rather than from the element's declaration. An assignment expression yields exactly what was stored, so it matches the statement form.
  • i = 3.7; on a rizz prints Cannot use double in integer context, then stores 3.
  • An uninitialized pointer declaration (rizz *p;) is rejected: Type mismatch in initialization of 'p' ... at line 1.
  • Assigning a struct by value (b = a;) is unsupported (Unsupported assignment type), so struct chains are too.

Related Issue

Fixes #374

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Performance improvement
  • Refactor

Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have documented my changes in the code or documentation
  • I have added tests that prove my change works, at the lowest appropriate layer (test_cases/*.brainrot for language-visible behavior; a host-side C test wired into make test for internal APIs Brainrot source cannot reach). Required for every bug fix, feature, refactor, performance change, and breaking change — not optional, not "if applicable". See CONTRIBUTING.md ("TESTS ARE MANDATORY").
  • Those tests cover the happy path, the original bug (for fixes), error cases, edge cases, and adversarial cases. If the existing harness could not reach the behavior, I extended it or added a lower-level test.
  • If this PR cannot affect program behavior (docs/comments/license only), I explained that in the Description instead of skipping the items above in silence
  • I have run make format-check locally (or make format to fix)
  • I have run the unit tests locally
  • I have run the valgrind memory tests locally
  • All new and existing tests pass

test_cases/assignment_expression.brainrot covers:

  • all four reproductions from the issue, and a triple chain;
  • edgy and goon conditions;
  • conversion to the target's type (float→int, int→float, smol truncation, thicc);
  • the two declaration-initializer cases above;
  • side-effecting targets (arr[k++], arr[bump(&m)]);
  • other target forms: a chad element, struct fields, pointer variables, *p, and a pointer initializer;
  • rant, yap and cap targets;
  • a native argument, a user-function argument converted to the parameter type, and a bussin value.

test_cases/assignment_expression_const_fail.brainrot locks in the const check.

make test: 544 passed. make format-check: clean. make valgrind: exit 0, ERROR SUMMARY: 0 and no definite leaks on all 478 fixtures.

🤖 Generated with Claude Code

`a = b = 5`, `rizz c = (b = 5);`, `(b = 5) + 1` and `i = j = 0` in a
flex init all parsed, but no value evaluator handled NODE_ASSIGNMENT: the
inner write was skipped or its value read as 0, and "Invalid integer
expression" was printed.

execute_assignment() now wraps execute_assignment_slot(), which returns
the address and descriptor it wrote through. Each value evaluator
(int/long/short/float/double/bool/pointer/string) performs the write and
yields the target's value after it, in the target's type as in C: a
plain variable is re-read, any other target is read back from the
written address so `x = arr[k++] = 5` evaluates `k++` once. The static
type queries (runtime and semantic) report the target's type.

create_assignment_target_node() no longer calls get_current_modifiers():
Bison reduces an assignment nested in an initializer before the
declaration, so it consumed the declaration's pending thicc -- leaving
`thicc rizz u = (t = 8000000000);` a 32-bit rizz, and turning the write
in `thicc rizz v = (ra[1] = 5);` into an 8-byte store into a 4-byte rizz
element.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@leo-aa88
leo-aa88 requested a review from SIGMazer as a code owner October 4, 2026 10:05

@leo-aa88 leo-aa88 left a comment

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.

REQUEST CHANGES

The core design is right. execute_assignment_slot() returns the address and descriptor it wrote through, so an assignment expression reads back exactly what was stored, converted to the target's type, without evaluating the target twice. I verified single evaluation on a local build:

  • yapping("%d", b = b + 1), id(b = b + 1), edgy ((c = c + 1) == 1) and goon ((n = n - 1) > 0) each run the assignment exactly once.
  • rizz x = arr[k++] = 5; leaving k == 1 follows from reading back through the stored address.

The parser fix is also correct and important: create_assignment_target_node() was consuming the enclosing declaration's pending modifiers.

There are two problems: CI is red, and after the required merge with main the PR's own contract ("yields the target's value ... in the target's type") breaks for nonut targets.

1. CI is red: static-analysis fails, so build and test never ran

BLOCKING (merge gate).

ast.c:7169:5: error: switch has 2 consecutive identical branches [bugprone-branch-clone,-warnings-as-errors]

The new case NODE_ASSIGNMENT: in is_expression() is byte-identical to the NODE_OPERATION case right above it. Fold it in as a fall-through label (case NODE_OPERATION: case NODE_ASSIGNMENT:) with the comment. Until then, none of the new fixtures have run in CI.

2. After merging main, an assignment expression drops the target's signedness, bringing #371 back through a new form

BLOCKING. CONFIRMED on this branch merged locally with current main (4988c99, which has #391). That merge is required anyway, because tests/expected_results.json conflicts.

nonut rizz u = 0;
thicc rizz t = (u = 3000000000);    🚽 -1294967296   (C: 3000000000)
(u = 3000000000) > 5                 🚽 0             (C: 1)
(u = 3000000000) / 2                 🚽 3647483648    (C: 1500000000)
u > 5                                🚽 1             (the plain read is right)

The PR adds NODE_ASSIGNMENT to every type query it knows about (get_expression_type, get_expression_pointer_level, expression_is_long, infer_runtime_expression_type_noeval, and the two semantic mirrors), each delegating to the target. #391 added a sixth: signedness, expression_is_unsigned() via static_value_descriptor(). That query has no NODE_ASSIGNMENT case, so an assignment to a nonut target reads as signed int. That's the exact wrong output from #371's issue text.

The read-back has the same blind spot: assigned_slot_long() sign-extends an identifier target through (long long)evaluate_expression_int(...) and a non-identifier one through numeric_load(..., VAR_INT), even though slot->mods.is_unsigned is sitting right there in the slot.

"Has the target's type" has to include all of the target's type. When you rebase:

  • add case NODE_ASSIGNMENT: to static_value_descriptor(), delegating to the target, so expression_is_unsigned() / operation_is_unsigned() see it;
  • zero-extend in assigned_slot_long() when the slot's descriptor is a 32-bit unsigned int;
  • add the three lines above to assignment_expression.brainrot.

The fixture currently has no nonut target at all, so it can't catch this.

3. Minor: one "pre-existing" bullet in the description is now stale

"ta[1] = 5000000000; on a thicc rizz ta[2] stores only 32 bits" was fixed by #390: element stores now take their width from the declaration via get_expression_modifiers(). On the merged build, ta[1] = 5000000000; thicc rizz v = (ta[0] = 6000000000); gives 5000000000 6000000000 6000000000. Update the description so the next reader doesn't go hunting for a bug that's gone.

VERDICT

The implementation is sound on its own base. The defect is in how the PR defines "an assignment expression has its target's type": as a list of type queries patched one by one. The list was complete when the branch was cut and is incomplete against current main, because #391 added a signedness query and a read-back path that ignore NODE_ASSIGNMENT (implementation, from a cross-PR interaction). Since the merge with main is mandatory (JSON conflict), the result as it would land is wrong for nonut targets.

CI is also red on a one-line branch-clone lint. Fix the lint, merge main, add NODE_ASSIGNMENT to the descriptor walk and zero-extension to the read-back, and cover it with a nonut target fixture. Not mergeable as is.

Comment thread ast.c
// For operations, check if the result type matches the target type
return get_expression_type(node) == type;
}
case NODE_ASSIGNMENT:

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.

BLOCKING (CI). Identical to the NODE_OPERATION branch above, so clang-tidy bugprone-branch-clone fails static-analysis and build/test are skipped. Make it a fall-through label on that case.

Comment thread ast.c
if (slot->target->type == NODE_IDENTIFIER)
return expression_is_long(slot->target)
? evaluate_expression_long(slot->target)
: (long long)evaluate_expression_int(slot->target);

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.

BLOCKING (after merging main). Sign-extends regardless of the target's signedness. For a nonut rizz u, thicc rizz t = (u = 3000000000); yields -1294967296 (C: 3000000000). The numeric_load(..., VAR_INT) path below does the same for element/field targets. Zero-extend when slot->mods.is_unsigned (32-bit). Also add NODE_ASSIGNMENT to #391's static_value_descriptor(): without it, (u = 3000000000) > 5 is 0 and / 2 is 3647483648, the issue #371 bug again. (Finding 2.)

Comment thread ast.c
return false;
switch (node->type)
{
case NODE_ASSIGNMENT:

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.

MAJOR. This delegation pattern (assignment has its target's type) is repeated per query, and on current main the list is one short: #391's signedness query (expression_is_unsigned() → static_value_descriptor()) has no NODE_ASSIGNMENT case. Add it there when you merge main. (Finding 2.)

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.

[Bug]: chained/nested assignment as an expression (a = b = 5) is broken

1 participant