Skip to content

Commit 1e4ff95

Browse files
authored
feat!: Introduce structured destructuring plans for bindings (microsoft#485)
- add a dedicated `compiler/destructuring_planner` feature that precomputes binding plans for assignments, parameters, and `some in` expressions - enrich `ScopeContext` with same-scope tracking, local scheduling hints, and module globals so the planner enforces := shadowing rules without blocking parent scopes - wire the planner through compiler, hoist, interpreter, and engine paths while updating binding plan variants and adding query traversal helpers for dependency analysis - document the new planner architecture and ship interpreter regressions that exercise nested destructuring, shadowing, and error reporting Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
1 parent 25a7dda commit 1e4ff95

27 files changed

Lines changed: 3332 additions & 1007 deletions

File tree

docs/destructuring.md

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
# Destructuring Planner
2+
3+
The destructuring planner pre-computes how Rego assignments, function parameters, loop indices, and `some ... in` expressions bind variables. By materializing explicit plans during compilation, the interpreter can execute complex binding patterns without re-inspecting the abstract syntax tree (AST) each time an expression runs.
4+
5+
```
6+
+--------------+ +----------------------------+ +-------------------+
7+
| AST walker | ---> | Destructuring planner core | ---> | BindingPlans table |
8+
+--------------+ +----------------------------+ +-------------------+
9+
| | ^ |
10+
| v | v
11+
| +------------------+ +-------------------+
12+
| | ScopeContext | <--------> | Planner utilities |
13+
| +------------------+ +-------------------+
14+
v
15+
+------------------+
16+
| Scheduler output |
17+
+------------------+
18+
19+
Downstream compiler passes reuse the same plans:
20+
21+
```
22+
BindingPlans table
23+
|
24+
+--> Rego VM compiler (RVM) for bytecode emission
25+
+--> Type propagation pass
26+
+--> Constant folding and other analyzers
27+
```
28+
```
29+
30+
## Planner building blocks
31+
32+
### Scope awareness
33+
34+
The planner relies on `ScopeContext` implementations to answer two questions for every variable candidate:
35+
36+
| Question | Method | Why it matters |
37+
| :----------------------------------------- | :----------------------------- | :--------------------------------------------------------------------- |
38+
| "Is this name currently unbound?" | `is_var_unbound(var, scoping)` | Determines whether a symbol becomes a new binding or should be treated as an equality check. |
39+
| "Has this scope already introduced the name?" | `has_same_scope_binding(var)` | Blocks same-scope rebinding for `:=` while still permitting shadowing in child scopes. |
40+
41+
The planner uses two scoping modes:
42+
43+
| Scoping mode | Description | Used by |
44+
| :-------------- | :-------------------------------------------------------------------------- | :---------------------------------------------------------- |
45+
| `RespectParent` | Honors existing bindings. Only treats names that are not yet visible as new bindings. | `=` comparisons, loop indices, `some ... in` value/key plans. |
46+
| `AllowShadowing` | Allows new bindings even if the name is defined in an ancestor scope. | Function parameters, `:=` LHS, `some ... in` overlay contexts. |
47+
48+
### Plan families
49+
50+
Three layers of plan types describe the complete binding strategy.
51+
52+
#### `DestructuringPlan`
53+
54+
| Variant | Purpose | Notes on bindings |
55+
| :------------------------------------- | :------------------------------------------------- | :-------------------------------------------------------------- |
56+
| `Var(span)` | Bind the complete value to the variable at `span`. | Adds the variable to the current scope. |
57+
| `Ignore` | Consume a wildcard (`_`). | No bindings emitted. |
58+
| `EqualityExpr(expr)` | Require runtime equality with a dynamic expression. | Used when a candidate variable is already bound. |
59+
| `EqualityValue(value)` | Require equality with a literal known at compile time. | Enables static structural checks. |
60+
| `Array { element_plans }` | Destructure arrays element-by-element. | Recursively nests `DestructuringPlan` values. |
61+
| `Object { field_plans, dynamic_fields }` | Destructure objects. Literal keys use `field_plans`; dynamic keys appear in `dynamic_fields`. | Ensures literal shape compatibility during planning. |
62+
63+
#### `AssignmentPlan`
64+
65+
| Variant | Triggers | Binding behavior |
66+
| :--------------- | :-------------------------- | :-------------------------------------------------------------------------------- |
67+
| `ColonEquals` | `:=` | Only LHS may introduce bindings; RHS must match structure/literals. Same-scope rebinding raises an error. |
68+
| `EqualsBindLeft` | `=` where LHS has free vars | Binds the LHS pattern after structural + literal checks. |
69+
| `EqualsBindRight` | `=` where RHS has free vars | Symmetric to `EqualsBindLeft`. |
70+
| `EqualsBothSides` | `=` where both sides have free vars | Flattens matching sub-expressions into `(value_expr, plan)` pairs and orders them using dependency analysis. |
71+
| `EqualityCheck` | `=` with no free vars | Pure equality comparison. |
72+
| `WildcardMatch` | `=` when either side is `_` | Short-circuits to avoid materializing a plan. |
73+
74+
#### `BindingPlan`
75+
76+
| Variant | Created by | Typical consumers |
77+
| :----------- | :---------------------------------- | :-------------------------------------------------- |
78+
| `Assignment` | `create_assignment_binding_plan` | Rule bodies for `:=` and `=`. |
79+
| `LoopIndex` | `create_loop_index_binding_plan` | Hoisted loops and comprehensions. |
80+
| `Parameter` | `create_parameter_binding_plan` | Functions and rule heads. |
81+
| `SomeIn` | `create_some_in_binding_plan` | `some key, value in collection` statements. |
82+
83+
## Planner workflow
84+
85+
1. **Entry point selection** — The compiler pass decides which helper to call based on the AST node (assignment, comprehension, function parameter, etc.).
86+
2. **Pattern inspection**`create_destructuring_plan` walks the candidate pattern and records which names would become new bindings under the selected scoping rules.
87+
3. **Conflict detection** — The planner asks the context for same-scope bindings and raises `VariableAlreadyDefined` when a duplicate `:=` appears in the same block.
88+
4. **Structural validation** — Helpers such as `ensure_structural_compatibility` and `ensure_literal_match` verify that literal shapes are consistent.
89+
5. **Plan assembly** — The resulting `DestructuringPlan`, `AssignmentPlan`, or higher-level `BindingPlan` is stored in the binding lookup table for quick interpreter access.
90+
91+
### Example flow
92+
93+
```
94+
[Rule body] -- := --> [create_assignment_binding_plan]
95+
|
96+
v
97+
[create_destructuring_plan]
98+
|
99+
+------v--------------+
100+
| ScopeContext checks |
101+
+------+--------------+
102+
|
103+
+-----------v-----------+
104+
| AssignmentPlan::ColonEquals |
105+
+-----------+-----------+
106+
|
107+
stores in BindingPlans table
108+
```
109+
110+
## Worked examples
111+
112+
Each example shows the original Rego snippet, the resulting binding plan, and highlights of the emitted bindings.
113+
114+
### 1. Nested `:=` patterns
115+
116+
```rego
117+
package test
118+
119+
result := {
120+
"outer": outer,
121+
"inner": inner,
122+
"tag": tag,
123+
} if {
124+
[outer, {"meta": {"inner": inner, "tag": tag}}] := [
125+
"alpha",
126+
{"meta": {"inner": "omega", "tag": "v1"}},
127+
]
128+
}
129+
```
130+
131+
Plan overview:
132+
133+
```
134+
BindingPlan::Assignment
135+
└── AssignmentPlan::ColonEquals
136+
├── lhs_expr: array pattern
137+
└── lhs_plan: DestructuringPlan::Array
138+
├── [0] -> Var("outer")
139+
└── [1] -> DestructuringPlan::Object
140+
└── key "meta": DestructuringPlan::Object
141+
├── key "inner": Var("inner")
142+
└── key "tag": Var("tag")
143+
```
144+
145+
| New binding | Source span | Notes |
146+
| --- | --- | --- |
147+
| `outer` | LHS array index 0 | New symbol in scope. |
148+
| `inner` | Object field `meta.inner` | Shares scope with `outer`. |
149+
| `tag` | Object field `meta.tag` | Must not reappear in same `:=` block. |
150+
151+
### 2. Symmetric `=` binding
152+
153+
```rego
154+
package test
155+
156+
values := [[left_id, right_id, val] |
157+
some left, right, left_id, right_id, val
158+
data.transitions[_] = [left, right]
159+
[{"id": left_id, "next": {"target": right_id}}, {"id": right_id, "payload": {"value": val}}] = [left, right]
160+
]
161+
```
162+
163+
Plan fragments:
164+
165+
```
166+
BindingPlan::Assignment
167+
└── AssignmentPlan::EqualsBothSides
168+
└── element_pairs (ordered)
169+
1. value_expr -> rhs[0]
170+
plan -> DestructuringPlan::Object
171+
key "id" -> Var("left_id")
172+
key "next" -> DestructuringPlan::Object { key "target" -> Var("right_id") }
173+
2. value_expr -> rhs[1]
174+
plan -> DestructuringPlan::Object
175+
key "id" -> Var("right_id")
176+
key "payload" -> DestructuringPlan::Object { key "value" -> Var("val") }
177+
```
178+
179+
Dependency ordering ensures `left_id` is available before `right_id`/`val` comparisons run.
180+
181+
### 3. Function parameter destructuring
182+
183+
```rego
184+
package test
185+
186+
# f([id, payload]) := payload
187+
f([id, payload]) := result {
188+
result := payload
189+
}
190+
```
191+
192+
```
193+
BindingPlan::Parameter
194+
└── param_expr: array pattern
195+
destructuring_plan:
196+
Array
197+
├── [0] -> Var("id")
198+
└── [1] -> Var("payload")
199+
```
200+
201+
Both bindings use `ScopingMode::AllowShadowing`, allowing `id` or `payload` to shadow outer names when the function executes.
202+
203+
### 4. `some ... in` loop
204+
205+
```rego
206+
package test
207+
208+
some user, record in data.users
209+
record.role == "admin"
210+
```
211+
212+
Plan summary:
213+
214+
```
215+
BindingPlan::SomeIn
216+
├── collection_expr: data.users
217+
├── key_plan: DestructuringPlan::Var("user")
218+
└── value_plan: DestructuringPlan::Var("record")
219+
```
220+
221+
Tables for bindings:
222+
223+
| Element | Plan | New bindings |
224+
| --- | --- | --- |
225+
| `key_plan` | `Var("user")` | Introduces `user` if unbound. |
226+
| `value_plan` | `Var("record")` | Introduces `record`. |
227+
228+
Literal arrays used in `collection_expr` are checked so the planner can report mismatched element shapes upfront.
229+
230+
### 5. Rebinding error detection
231+
232+
```rego
233+
package test
234+
235+
flag := true if {
236+
value := "initial"
237+
value := "shadowed"
238+
}
239+
```
240+
241+
```
242+
BindingPlan::Assignment
243+
└── AssignmentPlan::ColonEquals (lhs := value)
244+
```
245+
246+
During planning, the second `:=` consults `has_same_scope_binding("value")` which returns `true`. The planner emits `BindingPlannerError::VariableAlreadyDefined` and compilation reports:
247+
248+
```
249+
error: var `value` used before definition below
250+
```
251+
252+
## Interpreter handoff
253+
254+
Planned bindings are stored in the same lookup tables as hoisted loops. At runtime the interpreter:
255+
256+
1. Fetches the `BindingPlan` using `(module_id, expr_idx)`.
257+
2. Executes the plan, binding or validating values without re-walking the AST.
258+
3. Falls back to legacy evaluation if a plan is missing (useful for incremental compilation or mixed modules).
259+
260+
This division keeps the hot execution path small while letting the compiler perform aggressive validation and error reporting ahead of time.

src/builtins/bitwise.rs

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
use crate::ast::{Expr, Ref};
55
use crate::builtins;
6-
use crate::builtins::utils::{ensure_args_count, ensure_numeric};
6+
use crate::builtins::utils::{ensure_args_count, ensure_numeric, validate_integer_arg};
77

88
use crate::lexer::Span;
99
use crate::value::Value;
@@ -19,77 +19,111 @@ pub fn register(m: &mut builtins::BuiltinsMap<&'static str, builtins::BuiltinFcn
1919
m.insert("bits.xor", (xor, 2));
2020
}
2121

22-
fn and(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
22+
fn and(span: &Span, params: &[Ref<Expr>], args: &[Value], strict: bool) -> Result<Value> {
2323
let name = "bits.and";
2424
ensure_args_count(span, name, params, args, 2)?;
2525

2626
let v1 = ensure_numeric(name, &params[0], &args[0])?;
2727
let v2 = ensure_numeric(name, &params[1], &args[1])?;
2828

29+
if !validate_integer_arg(name, &params[0], &args[0], &v1, strict, true)?
30+
|| !validate_integer_arg(name, &params[1], &args[1], &v2, strict, true)?
31+
{
32+
return Ok(Value::Undefined);
33+
}
34+
2935
Ok(match v1.and(&v2) {
3036
Some(v) => Value::from(v),
3137
_ => Value::Undefined,
3238
})
3339
}
3440

35-
fn lsh(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
41+
fn lsh(span: &Span, params: &[Ref<Expr>], args: &[Value], strict: bool) -> Result<Value> {
3642
let name = "bits.lsh";
3743
ensure_args_count(span, name, params, args, 2)?;
3844

3945
let v1 = ensure_numeric(name, &params[0], &args[0])?;
4046
let v2 = ensure_numeric(name, &params[1], &args[1])?;
4147

48+
if !validate_integer_arg(name, &params[0], &args[0], &v1, strict, true)?
49+
|| !validate_integer_arg(name, &params[1], &args[1], &v2, strict, false)?
50+
{
51+
return Ok(Value::Undefined);
52+
}
53+
4254
Ok(match v1.lsh(&v2) {
4355
Some(v) => Value::from(v),
4456
_ => Value::Undefined,
4557
})
4658
}
4759

48-
fn negate(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
60+
fn negate(span: &Span, params: &[Ref<Expr>], args: &[Value], strict: bool) -> Result<Value> {
4961
let name = "bits.negate";
5062
ensure_args_count(span, name, params, args, 1)?;
5163

5264
let v = ensure_numeric(name, &params[0], &args[0])?;
5365

66+
if !validate_integer_arg(name, &params[0], &args[0], &v, strict, true)? {
67+
return Ok(Value::Undefined);
68+
}
69+
5470
Ok(match v.neg() {
5571
Some(v) => Value::from(v),
5672
_ => Value::Undefined,
5773
})
5874
}
5975

60-
fn or(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
76+
fn or(span: &Span, params: &[Ref<Expr>], args: &[Value], strict: bool) -> Result<Value> {
6177
let name = "bits.or";
6278
ensure_args_count(span, name, params, args, 2)?;
6379

6480
let v1 = ensure_numeric(name, &params[0], &args[0])?;
6581
let v2 = ensure_numeric(name, &params[1], &args[1])?;
6682

83+
if !validate_integer_arg(name, &params[0], &args[0], &v1, strict, true)?
84+
|| !validate_integer_arg(name, &params[1], &args[1], &v2, strict, true)?
85+
{
86+
return Ok(Value::Undefined);
87+
}
88+
6789
Ok(match v1.or(&v2) {
6890
Some(v) => Value::from(v),
6991
_ => Value::Undefined,
7092
})
7193
}
7294

73-
fn rsh(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
95+
fn rsh(span: &Span, params: &[Ref<Expr>], args: &[Value], strict: bool) -> Result<Value> {
7496
let name = "bits.rsh";
7597
ensure_args_count(span, name, params, args, 2)?;
7698

7799
let v1 = ensure_numeric(name, &params[0], &args[0])?;
78100
let v2 = ensure_numeric(name, &params[1], &args[1])?;
79101

102+
if !validate_integer_arg(name, &params[0], &args[0], &v1, strict, true)?
103+
|| !validate_integer_arg(name, &params[1], &args[1], &v2, strict, false)?
104+
{
105+
return Ok(Value::Undefined);
106+
}
107+
80108
Ok(match v1.rsh(&v2) {
81109
Some(v) => Value::from(v),
82110
_ => Value::Undefined,
83111
})
84112
}
85113

86-
fn xor(span: &Span, params: &[Ref<Expr>], args: &[Value], _strict: bool) -> Result<Value> {
114+
fn xor(span: &Span, params: &[Ref<Expr>], args: &[Value], strict: bool) -> Result<Value> {
87115
let name = "bits.xor";
88116
ensure_args_count(span, name, params, args, 2)?;
89117

90118
let v1 = ensure_numeric(name, &params[0], &args[0])?;
91119
let v2 = ensure_numeric(name, &params[1], &args[1])?;
92120

121+
if !validate_integer_arg(name, &params[0], &args[0], &v1, strict, true)?
122+
|| !validate_integer_arg(name, &params[1], &args[1], &v2, strict, true)?
123+
{
124+
return Ok(Value::Undefined);
125+
}
126+
93127
Ok(match v1.xor(&v2) {
94128
Some(v) => Value::from(v),
95129
_ => Value::Undefined,

0 commit comments

Comments
 (0)