BCad fork

Unified expression evaluation (design)
Login

Unified expression evaluation (design)

Status: design draft. Target architecture for the "everything is an expression" wave. The goal is to halve interpreter code, not to grow a third emulation layer over legacy statement dispatch.

1. Motivation

The grammar is already unified: scadlike_parser.py builds only expr_* nodes (p_expression_if, p_expression_for, p_expression_let, ...). But the interpreter still lives in two worlds:

The result: one syntax construct has two implementations. if in an element position is stat_if handled by parse_statement; if in a value position is expr_if handled by parse_expr via _eval_block_value. Same for let (stat_let/expr_let), for (statement/expr_array_for vs the expr_for value path), assign, field, modifier.

This is a hybrid, not an architecture: an emulation layer sits on top of the legacy statement executor instead of replacing it. The debt surfaces as crashes such as UnhandledCaseError: unknown expression type 'expr_for' — the grammar accepts a construct in a value position for which the interpreter has no handler.

2. Vision

Everything is a function that yields a value; a statement is the same expression plus an emit policy on the result.

The callable model already proves this pattern works: SCL._call(node, emit) is a single dispatch whose behavior is selected by an emit flag (emit=True → active context, emit=False → isolated node). The axis exists; extending it to if/for/let/assign/modifier is the job of this design.

3. Current architecture inventory

3.1 Node types (statement vs expression)

construct element position value position status
call stat_call → _call(emit=True) expr_call → _call(emit=False) already unified — the pattern to follow
if stat_if (parse_statement) expr_if (parse_expr + _eval_block_value) duplicated
for stat_for (_exec_for + SCLGroup wrap) expr_for / comprehension expr_array_for (parse_expr) duplicated
intersection_for stat_intersection_for (_exec_for + SCLIntersection wrap) expr_intersection_for (_intersection_result) duplicated
let stat_let expr_let duplicated
assign stat_assign (eval_scope phase 1 merge) expr_assign (side effect + value) duplicated (expr_assign already used internally)
field / label stat_field (name: expr;) expr_field duplicated
modifier stat_modifier (root/debug/background/disable, 3-phase root) expr_modifier (child !/#) duplicated
bare value stat_expr → _emit_value (node → child, data → field/warning) plain expression emulation

Definitions (stat_function_definition/stat_module_definition) and use/include have no expr_* duplicates — they are declarative nodes.

3.2 Dispatchers and phases

3.3 Runtime state (kept as-is in the target model)

4. Target design

4.1 Evaluation protocol

eval(node, emit=False) -> Value      # plain value; side effects = var binds only
eval(node, emit=True)  -> Value      # value + emit result into active context
eval_seq(block, mode)  -> Value      # run elements in order, each with emit
                                     #   mode 'scene': emit into active context
                                     #   mode 'value': emit into isolated draft,
                                     #                 return block value

4.1.1 Value shape: lists while stored, group at emit

A "block value" is not a compound node at the moment it is produced. The rule is narrower:

Tests: tests/test_block_value.scad, tests/test_import_value.scad.

4.2 Outcome policies (derive from the existing _emit_value)

policy syntax effect
bind a = expr; set variable; nothing leaves the scope (no field, no emit)
emit expr; Node → add_child_context; data → field on active node if named, otherwise computed and unclaimed (silent)
label a: expr; bind + emit with field name a

Emission is the only thing that names: bind never creates a field and never emits, so what leaves a block is what was emitted. label and a named emit both write into active_context.fields, the root included (root.fields), so top-level data behaves like top-level nodes instead of warning. A node value is left to the explicit x; — x = sphere(5); draws nothing, x; draws one sphere and x; x; draws two (repeat emit is legal).

Fields are a flat stream of emissions: emitting a field is no different from emitting geometry. When a block's body ends (pop_context), the block's whole field dict is merged into the parent's — at that block's position in the parent's body — and the merge is a plain update, so the last emission of a name wins. Reading is a dict lookup, node.fields.get(name): no subtree walk, no sentinel, no scoping of names by depth. Consequently

group(){ c: undef; group(){ c: 5; } }   ≡   group(){ c: undef; c: 5; }   → 5
group(){ c: 1; group(){ c: 2; } }                                    → 2
group(){ group(){ c: 5; } c: undef; }                                → undef

The transfer cannot happen at attach time: push_context links the node to its parent before the body runs, when it has no fields yet. That is why it lives in pop_context, and why a draft (attach=False, value nodes) is marked in_tree = False and forwards nothing — its fields arrive only when it is really emitted, through add_child_context. An un-emitted local is a draft, so its fields are never found.

Tests: tests/test_field_scope.scad.

4.3 Unified node table

now after
stat_if + expr_if one if node
stat_for + expr_for (+ comprehension expr_array_for) one for node
stat_intersection_for + expr_intersection_for one intersection_for node
stat_let + expr_let one let node
stat_assign + expr_assign one assign node (bind side effect + value)
stat_field + expr_field one label node (bind + field emit)
stat_modifier + expr_modifier one modifier node
stat_expr disappears (bare value element emits directly)
statementize() disappears

4.4 for as the pilot

One recursion over the counter product, parameterized by a leaf mode:

_for_iterate(counters, env, leaf, progress=False)  # single implementation

expr_for/expr_intersection_for value handlers live in parse_expr, on the same iteration core.

4.5 Scope model

4.6 Modifiers

! ~ # @ (plus */% operators) become functions over Node values: root(node), disable(node), debug(node), background(node). The 3-phase root selection (_last_root_tagged, root_first/root_last) moves into the render/display layer as data flow over tagged nodes. Behavior must stay identical (regression: test_modifiers_root_filter.scad).

4.7 Left unchanged

5. Migration plan (full test suite green after every step)

6. Acceptance criteria

7. Risks and decisions