0%

Section 02: Parser — assignment_target + AssignTarget AST

Goal

See frontmatter. Proposal Phase 2. Parser accepts the extended LHS and emits a single chain-capturing node; all desugaring is deferred to the type checker (section-03).

Implementation Sketch

The current grammar restricts assignment LHS to a bare identifier. This section extends the assignment-target dispatcher to accept an identifier followed by any combination of [expr] (index) and .ident (field) suffixes, emitting one AssignTarget node. The parser does NOT decide spread-vs-updated (that needs types); it records the raw chain. The root must remain a bare identifier (mutability is checked in section-03).

Design Decisions

  • DD-3 (steps storage = flattened-arena range, NOT inline Vec): Expr + ExprKind derive Copy (ori_ir/src/ast/expr.rs:35,91) and are size-pinned via static_assert_size!(ExprKind, 24) / static_assert_size!(Expr, 32) (expr.rs:518-523). An inline Vec<AccessStep> breaks BOTH (loses Copy, 24-byte Vec field exceeds the ExprKind budget). Store the chain in a parallel AccessStep arena addressed by AccessStepRange { start: u32, len: u16 } built with the existing define_range! macro — the same shape every variadic-child IR node already uses (ExprRange/ArmRange/CallArgRange, expr.rs:21-24; arena pattern per ir.md §Arena Allocation + §Range Types). AccessStep is itself a Copy enum (Index(ExprId) / Field(Name)). This is mission-aligned: ori_ir’s Flatten Everything (expr.rs:13: No Box<Expr>, use ExprId(u32) indices) is the ori_ir per-crate mission’s load-bearing invariant.
  • DD-4 (root payload = root: ExprId, NOT root: Name+span): the grammar restricts the assignment-target root to a bare identifier (grammar.ebnf assignment_target = identifier { ... }), so a Name+span representation is expressible. root: ExprId is chosen instead because (a) the 00-overview.md Architecture / Data Flow block already pins root: ExprId and section-03’s desugar consumes that field as an ExprId (right-to-left wrapping reads the root as an expression node); (b) it keeps AssignTarget uniform with the existing Index { receiver: ExprId, ... } / Field { receiver: ExprId, ... } variants whose receivers are ExprId (expr.rs:165,168), so section-03 reuses the same receiver-resolution path; (c) the root ExprId resolves to an ExprKind::Ident node, preserving its span for diagnostics without a parallel Span field. The parser validates the root IS a bare-identifier expression (rejecting f()[0] = x, (a+b)[0] = x, (a as T).f = x) — the syntactic restriction is enforced at parse time, not encoded in the field type.
  • DD-5 (zero-step boundary — bare x = expr keeps ExprKind::Ident target, never AssignTarget { steps: [] }): grammar.ebnf assignment_target = identifier { "[" expression "]" | "." identifier } makes a bare identifier a valid zero-step assignment_target. The parser MUST guard against emitting an empty-chain AssignTarget node: when the LHS resolves to a bare identifier with zero index/field suffixes, ExprKind::Assign { target, value } keeps target resolving to ExprKind::Ident exactly as today (DD-1 leaves bare-identifier targets unchanged). AssignTarget is emitted ONLY when ≥1 access step is present. This preserves the unchanged-shape invariant for the overwhelmingly-common bare-identifier assignment and keeps section-03’s desugar from running on a degenerate empty chain.
  • DD-6 (**= operator-table representation — verify before the matrix runs): success_criteria DD-2 claims ALL parse.md §PR-5 compound operators (incl. **=) accept the extended LHS uniformly. **= (power-assign) must already tokenize/fuse in the operator tables (ori_ir/src/token/tag.rs, ori_ir/src/ast/operators.rs) for the PR-5 parse-time rewrite to reach it. The matrix’s **= row is gated on a precondition check: confirm **= is representable (the ** operator desugars per ori-syntax.md §Operators precedence-2 ** right-assoc + compound **= per §Compound assignment); if a token/operator-fusion gap surfaces, that gap is a discovered bug — file via /add-bug and pull into scope per CLAUDE.md §Tests That Expose Bugs (NOT a silent matrix-row skip).

02.1 assignment_target Grammar + AssignTarget AST Node

  • TDD-first: failing parser test matrix — target shapes x / x[i] / x.f / x.f[i] / x[i].f / x.f.g[i].h × ALL parse.md §PR-5 operators = / += / -= / *= / /= / %= / **= / @= / &= / |= / ^= / <<= / >>= / &&= / ||=. Negative (non-identifier root / non-target postfix rejection): f()[0] = x, (a+b)[0] = x, a?.b = x, a.m() = x, a.m()[0] = x, (a as T).f = x — method-call LHS and cast LHS rejected. Negatives live in BOTH the Rust parser tests (ori_parse/src/grammar/expr/mod.rs) AND tests/spec/expressions/index_assignment_syntax.ori (#compile_fail pins).
  • Add AssignTarget AST node in ori_ir/src/ast/expr.rs as an ADDITIVE ExprKind variant — ExprKind::AssignTarget { root: ExprId, steps: AccessStepRange } (DD-3 + DD-4). steps is a flattened-arena range (NOT Vec<AccessStep> — a Vec field breaks Copy on ExprKind/Expr at expr.rs:35,91 AND blows static_assert_size!(ExprKind, 24) at expr.rs:518-523). Define AccessStep::Index(ExprId) / AccessStep::Field(Name) as a Copy element enum, store it in a parallel arena, and address it via AccessStepRange { start: u32, len: u16 } built with the define_range! macro (sibling to ExprRange/ArmRange/CallArgRange). All three derive Copy/Clone/Eq/PartialEq/Hash/Debug for Salsa. Add the AssignTarget arm to the ExprKind Debug impl (expr.rs:377-512) and re-check the size assertion holds. ExprKind::Assign { target: ExprId, value: ExprId } field shape is UNCHANGED — target resolves to the new ExprKind::AssignTarget node for chain targets, or stays ExprKind::Ident for bare-identifier targets per DD-5 (DD-1 resolution; not a field-type change, not a parallel struct).
    • Rust Tests: ori_ir/src/ast/tests.rs — assert ExprKind size unchanged (the static_assert_size! compile-time check), AccessStepRange round-trips through the arena, and AssignTarget derives Copy.
  • Keep ExprKind::Assign consumers + incremental copier compiling; close the cross-crate exhaustive-match gate (agy-F2): add an ExprKind::AssignTarget arm to ori_parse/src/incremental/copier/expr.rs (additive copier variant, mirrors the existing per-variant pattern near line 251). Walk EVERY exhaustive ExprKind match site that lacks a _ catch-all — ori_ir Debug impl (expr.rs:377-512) + visitor walks, ori_types, ori_canon, ori_eval, ori_arc, ori_fmt, oric — adding the explicit AssignTarget arm. Gate: cargo c (workspace) compiles green; the Rust non-exhaustive-match error IS the enforcement (impl-hygiene.md §IR Variant Exhaustiveness). Sites matching Assign (shape unchanged) stay compiling untouched; chain-target routing sites hand ExprKind::AssignTarget to section-03’s desugar (no eval/canon/AIMS handling here — parser stays syntax-only). Banned: a _ => unreachable!() / _ => todo!() catch-all silently swallowing the new variant (deferred GAP).
    • Rust Tests: ori_parse/src/incremental/copier/expr.rs copier round-trip tests
  • Extend parser to accept assignment_target on the LHS of = + compound operators — ori_parse/src/grammar/expr/mod.rs (assignment-target dispatcher) + ori_parse/src/grammar/expr/postfix.rs (chain resolution). Emit ONE AssignTarget node ONLY when ≥1 index/field step is present (DD-5 zero-step guard); a bare-identifier root with zero steps keeps ExprKind::Assign { target: ExprKind::Ident }. Reject non-identifier roots + non-index/field postfix.
    • Rust Tests: ori_parse/src/grammar/expr/mod.rs parser tests
    • Ori Tests: tests/spec/expressions/index_assignment_syntax.ori
  • Zero-step boundary guard test (DD-5 / opencode-F1): pin a parser unit test that x = expr (bare identifier, zero suffixes) produces ExprKind::Assign { target: <ExprKind::Ident> }, NOT ExprKind::AssignTarget { steps: <empty range> }. Asserts the dispatch never emits a degenerate empty-chain node for the common bare-identifier case.
    • Rust Tests: ori_parse/src/grammar/expr/mod.rs zero-step-boundary test
  • **= operator-table precondition check (DD-6 / codex-F1): BEFORE the **= matrix row runs, confirm **= is representable in the operator/token tables (ori_ir/src/token/tag.rs, ori_ir/src/ast/operators.rs) — the PR-5 parse-time rewrite of x[i] **= y -> x[i] = x[i] ** y requires **= to tokenize/fuse. If a token/operator-fusion gap surfaces, file via /add-bug and pull into scope (CLAUDE.md §Tests That Expose Bugs) — NOT a silent matrix-row skip.
  • Compound-operator LHS: verify ALL parse.md §PR-5 compound operators (+= -= *= /= %= **= @= &= |= ^= <<= >>= &&= ||=) accept the extended target (no subset deferral); compound expand stays the parse-time rewrite (parse.md §PR-5) over the extended target (per typeck.md §EX-17 — compound desugars to x = x op y, then section-03 desugars the index/field target).
  • Compound chain-target shared-identity invariant (AR-1 / DD-2 / opencode-F2): the PR-5 parse-time expansion of x[i] op= rhs -> x[i] = x[i] op rhs over a chain target MUST reuse the already-parsed chain for the RHS read-copy via ExprId clone of the index/field-step expressions, NOT re-parse the LHS from token source. Re-parsing would resolve a side-effecting index key (x[f()] += 1) twice; cloning the parsed ExprIds gives the read-copy and the write-copy (the AssignTarget) the SAME chain identity (structurally-identical steps), so section-03 desugars read-copy via Index (read) and write-copy via IndexSet (write) into their correct roles, evaluating f() exactly once (per typeck.md §EX-17 + parse.md §PR-5; the eval-once count-pinned test lives in section-05). Pin a parser test asserting both copies carry the structurally-identical chain (same step ExprIds) for state.items[i] += 1; banned: re-parsing or two independently-resolved chain targets that section-03 could desugar inconsistently.
    • Rust Tests: ori_parse/src/grammar/expr/mod.rs compound-chain-identity test
  • Verify: parser matrix green; no regression in cargo t -p ori_parse + ./test-all.sh.

Intelligence Reconnaissance

2026-06-01 — feature-mode scaffold; proposal is the research artifact. Verify the exact module against the shipped tree before editing — do NOT trust a guessed path.

  • scripts/intel-query.sh file-symbols "ori_parse/src/grammar/expr" --repo ori — assignment-target dispatcher + chain resolution [ori:ori_parse/src/grammar/expr/].
  • scripts/intel-query.sh symbols "Assign" --repo ori --kind type — existing assignment AST node to extend [ori:ori_ir/src/ast/expr.rs].
  • scripts/intel-query.sh callers "parse_assignment" --repo ori — assignment dispatch entry.

Spec References

  • Proposal §Grammar Changes, §Implementation Note: Type-Directed Desugaring (parser is syntax-only).
  • grammar.ebnf production (synced in section-04): assignment_target = identifier { "[" expression "]" | "." identifier }.
  • Spec §13.6 Assignment Semantics — §13.6.1 field assignment, §13.6.2 index assignment (13-variables.md); the surface this parser accepts.
  • parse.md §PR-5 (compound-assignment parse-time rewrite).
  • ir.md §Arena Allocation + §Range Types + §DerivedTrait; ori_ir/src/ast/expr.rs (Copy derives + static_assert_size! budget) — AST-representation grounding for DD-3.

Tests

tests/spec/expressions/index_assignment_syntax.ori + Rust parser/AST unit tests per items.

HISTORY

  • 2026-06-11 — Stale review_pipeline: marker cleared by /continue-roadmap orchestrator: marker carried stage: ?, next_step: ?, updated: ?. Per /review-plan SKILL.md §Step 1a stale-marker rule (reviewed: false + marker present → STALE by definition), marker invalid; prior diagnosis preserved here for traceability. Cure rooted in scripts/plan_orchestrator/markers.py:clear_stale_marker_if_unreviewed.