diff --git a/LANGUAGE.md b/LANGUAGE.md index 60d8d85..5f6aa99 100644 --- a/LANGUAGE.md +++ b/LANGUAGE.md @@ -211,6 +211,71 @@ that is the message you get rather than a crash. → `examples/recursion.hb`, `examples/recursion-anonymous.hb` (§9/§10) +## Cyclic data + +`let rec` builds data that reaches itself, not just functions that call +themselves. A `Table` literal bound with `rec` is created and named *before* its +entries run, so an entry can mention the Table it is part of: + +```hashedbuild +let rec p { .n = 1, .self = p }; p.self.self.self.n // => 1 +let rec ones {1, ones}; ones[2][2][2][1] // => 1 +``` + +Friendship is mutual, so a social graph has to close — and mutual references +between entries need nothing extra, because entries are evaluated **on demand +rather than in source order**. Reaching `people.bob` while `.alice` is still +being built simply evaluates `.bob` there and then: + +```hashedbuild +let rec people { + .alice = { .name = "Alice", .friends = { people.bob, people.carol } }, + .bob = { .name = "Bob", .friends = { people.alice } }, + .carol = { .name = "Carol", .friends = { people.alice, people.bob } }, +}; +people.alice.friends[1].friends[1].name // => "Alice", back where we started +``` + +That demand ordering also settles plain forward references, cycle or no cycle — +`let rec p { .a = p.b + 1, .b = 2 }; p.a` is `3`. Entries still *print* in the +order they were written, whatever order they ran in. + +**A cycle prints with a label**, so the back-edge is visible and the output is +finite. The label marks the node the cycle returns to: + +``` +#1{n: 1, self: #1} +#1{name: "Alice", friends: {{name: "Bob", friends: {#1}}}} +``` + +**Equality follows the cycle** rather than comparing pointers, so two rings of +the same shape built by separate bindings are the same value: + +```hashedbuild +let rec ring { .x = { .tag = "x", .next = ring.y }, .y = { .tag = "y", .next = ring.x } }; +let rec other { .x = { .tag = "x", .next = other.y }, .y = { .tag = "y", .next = other.x } }; +ring.x == other.x // => true +``` + +Two things this deliberately does **not** do. It builds cyclic structures — +finite graphs with back-edges — and not unbounded ones: `let rec from (let n; +{n, from (n + 1)})` has nothing to close a loop with and recurses to the depth +limit, exactly as it did before. And an entry that needs another entry's value +*while that one is still being computed* has no answer to give, so it fails +rather than hanging: + +```hashedbuild +let rec p { .a = p.b + 1, .b = p.a + 1 }; p.a +// error: circular definition: p.a is needed before it has a value +``` + +Storing a reference to an entry still under construction is fine — that is what +makes the back-edge. Looking *through* one is what fails. Only a `Table` literal +written directly as the bound value gets any of this; `let rec x x + 1; x` still +reports `undefined name`, as §10 says it should. + +→ `examples/cyclic-data.hb` (§6/§10) + ## Branching and pattern matching Branching is built from ordinary composable operators rather than dedicated @@ -411,7 +476,14 @@ file's executable bit, and two of the three targets cannot report one: WASI's So there is no way to compute the specified digest everywhere the interpreter runs. Building it means first deciding what a directory hashes as somewhere that cannot see an exec bit. `Function` is unbuilt because §15 needs it for `cached` but never -says how a closure is encoded. +says how a closure is encoded. A **cyclic value** (above) is the third case, and +unbuilt for a related reason: the digest is a Merkle fold, a composite's hash +built from its children's, and a cycle has no bottom to start from. `SPEC.md` +§6 describes what the answer looks like — components hashed canonically, so the +digest does not depend on where the walk entered the cycle — but §3 pins what a +digest encodes, so it is a spec decision first. `sha256` of one fails cleanly +meanwhile. Equality over cyclic values *is* built, and does not depend on any of +this. Also absent: `true`/`false` literals, loops of any kind (recursion is the only repetition there is — see above), a `Bytes`-returning counterpart to diff --git a/SPEC.md b/SPEC.md index 7f5d467..2c5272b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -28,7 +28,7 @@ A condition (of `then`, `and`, `or`, `is`) can itself be, or contain, an async e **No separate dependency graph exists between async operations, because none is needed.** All values are immutable (§6) and there's no mutable handle/promise/channel type in the language — the only way one expression can use another's result is by syntactically containing it. If some expression needs an async sub-expression's value, that sub-expression is, by construction, nested inside it, and since pass 2 walks the exact same route as pass 1 in the same order, it necessarily resolves an inner async value before evaluating anything outside it that consumes it. Ordinary nested-expression evaluation order *is* the dependency graph. -**Implementation note (2026-08-27):** the evaluator (`src/eval_async.odin`) doesn't literally run two tree walks. `async ` spawns `` on a real OS thread and returns an opaque handle immediately (that alone *is* "pass 1: start it"); a generic `await_value` resolves a handle (joining its thread, or propagating its failure) and is inserted at the small set of places that genuinely need a concrete value — arithmetic/comparison/concat/table-access operands, a call target, a ctx swap, a guard's condition, `Table`/`Variant` entries. Since `await_value` is a no-op for any other kind of `Value`, this reproduces "pass 2 is skipped entirely when there's no async" for free, at zero cost to programs that never use it. `then`/`else` and `and`/`or` additionally do a purely structural scan (`contains_async_anywhere`, deliberately ignoring §7's hole-boundary rules) to decide whether their untaken side must still be force-evaluated-and-awaited for its side effects — ordinary (non-async) branching is completely unaffected. Firing several sibling `async`s before awaiting any of them (as `Table`/`Variant` construction does) is what makes them actually run concurrently with each other. Known simplifications: an `async` sub-evaluation doesn't participate in the step-trace or interactive-debugger mechanisms (its own `Interpreter` never sets either), and if a *sibling*, already-fired async is abandoned because another entry in the same `Table`/call fails synchronously first, its background thread is left to finish unjoined rather than being explicitly cleaned up — consistent with this evaluator's existing "runtime values are never freed" stance, not a new gap. +**Implementation note (2026-08-27):** the evaluator (`src/eval_async.odin`) doesn't literally run two tree walks. `async ` spawns `` on a real OS thread and returns an opaque handle immediately (that alone *is* "pass 1: start it"); a generic `await_value` resolves a handle (joining its thread, or propagating its failure) and is inserted at the small set of places that genuinely need a concrete value — arithmetic/comparison/concat/table-access operands, a call target, a ctx swap, a guard's condition, `Table`/`Variant` entries. Since `await_value` is a no-op for any other kind of `Value`, this reproduces "pass 2 is skipped entirely when there's no async" for free, at zero cost to programs that never use it. (Since 2026-08-31 those places call `concrete_value`, which is `await_value` plus §10's forward-reference resolution — the same idea at a different scale, a value that is not ready yet, which is why both resolve at exactly these points. Places that *store* a value rather than looking at it — `Table`/`Variant` entry construction — still call `await_value` alone, because storing a forward reference unread is what makes a cycle's back-edge.) `then`/`else` and `and`/`or` additionally do a purely structural scan (`contains_async_anywhere`, deliberately ignoring §7's hole-boundary rules) to decide whether their untaken side must still be force-evaluated-and-awaited for its side effects — ordinary (non-async) branching is completely unaffected. Firing several sibling `async`s before awaiting any of them (as `Table`/`Variant` construction does) is what makes them actually run concurrently with each other. Known simplifications: an `async` sub-evaluation doesn't participate in the step-trace or interactive-debugger mechanisms (its own `Interpreter` never sets either), and if a *sibling*, already-fired async is abandoned because another entry in the same `Table`/call fails synchronously first, its background thread is left to finish unjoined rather than being explicitly cleaned up — consistent with this evaluator's existing "runtime values are never freed" stance, not a new gap. ## 3. Primitive types @@ -86,7 +86,8 @@ Revised 2026-08-26: `Map`, `Array`, and `Variant` are retired as separate types, - **`Table`** — maps arbitrary hashable keys (§6) to values. There's no structural distinction between "array-like" and "map-like" usage anymore — a sequence is just a `Table` whose keys happen to be sequential integers **starting at 1** (revised 2026-08-26: the language is 1-indexed, retroactively — a sequence's first element is `t[1]`/`t.1`, not `t[0]`/`t.0`). - **Access**: `[]` for an arbitrary key, or dotted-suffix sugar: `
.field` for an identifier-shaped suffix (sugar for `
["field"]`, an `Utf8` key), `
.5` for a numeral-shaped suffix (sugar for `
[5]`, an `Integer` key). One rule now covers what used to be two separate types' access sugar — the suffix's own shape (identifier vs. numeral) picks the key's type. - **General dot sugar** (resolved 2026-08-26): `.` is this exact same sugar — "the identifier's own spelling, as a `Utf8` string, substituted wherever a bracketed key/expression would go" — everywhere a dot-form like this appears in the language, not just plain access: the `:.`/`!.` variant forms (below) and `{.field}` pattern selectors (§8) are all the identical rule applied in different contexts, not separate mechanisms that happen to share a symbol. - - **Hashing/equality/ordering**: `hash(Table) = hash(sorted [(key, value) for each entry], ordered by key per §6's generic total order)` — the same sort-then-hash pattern §3 already uses for `File` directory hashing. + - **Hashing**: `hash(Table) = hash(sorted [(key, value) for each entry])` — the same sort-then-hash pattern §3 already uses for `File` directory hashing, so that the digest does not depend on the order entries were written in. **Amended 2026-08-31** to say what the sort is actually keyed on: entries are ordered by their **key's digest**, not by §6's generic total order. The two would agree, but §6's cross-type ordering is not built, and a digest order is both deterministic and available without it. A cyclic `Table` (§10) has no digest at all — see §6. + - **Equality** is structural, not hash-derived: entries are matched by key and compared pairwise, so entry order never affects it. Only `File` compares *through* its digest, because §3 defines a `File`'s identity that way. Since 2026-08-31 the walk also terminates on a cyclic `Table` — see §6 for the algorithm. - **`empty`** — a bare keyword that, in an ordinary expression context, constructs an empty `Table` (zero entries) — the first concrete answer to this section's "is there `Table` literal syntax" question, at least for the trivial case; a nonempty literal form is still unspecified. `empty` also doubles as a pattern (§8) matching a `Table` with zero entries. Subject to pattern matching via `is` (see [§8](#8-control-flow)). @@ -118,12 +119,18 @@ Mixing the two forms in one literal is an error — not necessarily a *syntax*-l ## 6. Value semantics -- All values are **hashable** and have some arbitrary total order defined by HashedBuild, such that any value can be compared with any other value. +- All values are **hashable** and have some arbitrary total order defined by HashedBuild, such that any value can be compared with any other value. One exception exists as of 2026-08-31 — see cycles, below. - All values are **serializable** — a canonical byte encoding exists for every value, and is what §15's hash is computed over. It is internal: as of 2026-08-28 no builtin returns it (see §15 on `serialize`/`serialize_file`'s removal). §15's `sha256`/`cached` are what operationalize this. - All values are **immutable**. -- Complex types **reference** their subtypes rather than copying them — cycles are possible. +- Complex types **reference** their subtypes rather than copying them — cycles are possible. As of 2026-08-31 they are also *constructible*: `let rec` over a `Table` literal (§10) is how a value comes to reach itself. - There is **no type system** in the conventional sense (as in most other languages) — instead there is static analysis (§11). +**What a cycle means for the three properties above (resolved 2026-08-31).** A cyclic value is a finite graph with back-edges, not an infinite object — every one is built by a `let rec` that finishes, so there are always finitely many nodes. That is what makes the first two properties answerable at all, and it is the reason `let rec` deliberately stops short of unbounded structures: those would have no finite representation, and equality between two of them would not be decidable by anything. + +- **Equality is bisimulation.** Two values are equal when no walk can tell them apart, which for a cycle means unrolling one against the other and finding no difference at any depth. Concretely: on first meeting a pair of `Table`s, *assume* they are equal and compare their entries under that assumption; a back-edge then arrives at a pair already assumed equal and stops. This keeps equality about content rather than identity — two rings of the same shape, built by separate bindings that share no node, are the same value — which is what the rest of this section already required of every other type. The implementation records assumptions in a union-find with path compression, so the cost stays near-linear rather than quadratic. +- **Ordering** over cyclic values follows from whatever canonical form the hash below settles on, and is unspecified until then. §6's total order is not built for any type yet. +- **Hashing a cyclic value is not yet defined.** §15's digest is a Merkle fold — a composite's digest is built from its children's — and a cycle has no bottom to start that fold from. A definition exists (decompose into strongly connected components, fold the acyclic part, and give each component a digest canonical under bisimulation, so it does not depend on which node the walk entered by), but §3 pins what a digest *encodes*, so choosing one is a decision to be made here rather than in an implementation. Until it is, `sha256` of a cyclic value fails, the same way a directory `File` or a `Function` does. + ## 7. Functions Per the program model (§2), every function threads an explicit argument and an implicit context in, and produces an explicit result and an implicit context out. There are three ways to write one: @@ -193,10 +200,12 @@ Anywhere a `Boolean` is expected among these primitives' operands, an expression **Resolved 2026-08-28**: **exhausting the evaluator's nesting budget** is a fifth failure source, fatal on the same terms as the rest and catchable by nothing. Recursion (§9's `#self`, §10's `let rec`) makes unbounded evaluation writable for the first time, and an interpreter that walks a tree on the host's own call stack has a finite amount of it; running that stack into the ground is a crash, not a language behaviour. So there is a limit, and reaching it ends the evaluation with an ordinary failure that says so. -The budget is counted in *evaluation nesting*, not in HashedBuild-level calls. Native stack use is proportional to how deeply the evaluator is nested, whereas the stack cost of one call depends entirely on the shape of the body being evaluated — a call-depth limit safe for one program is a crash in another. Programs are therefore not owed any particular recursion depth: a simple function recurses several times deeper than one whose body nests heavily, and neither number is promised. The limit is an implementation quantity (`MAX_EVAL_DEPTH`, `src/eval.odin`), sized against the smallest stack the evaluator runs on, and may be raised or lowered without that being a language change. What *is* specified: the failure is diagnosable, and it is fatal. +The budget is counted in *evaluation nesting*, not in HashedBuild-level calls. Native stack use is proportional to how deeply the evaluator is nested, whereas the stack cost of one call depends entirely on the shape of the body being evaluated — a call-depth limit safe for one program is a crash in another. Programs are therefore not owed any particular recursion depth: a simple function recurses several times deeper than one whose body nests heavily, and neither number is promised. The limit is an implementation quantity (`MAX_NEST_DEPTH`, `src/eval.odin`), sized against the smallest stack the evaluator runs on, and may be raised or lowered without that being a language change. What *is* specified: the failure is diagnosable, and it is fatal. **Resolved 2026-08-27**: a **failed builtin call** (§16) is a fourth failure source, and it is fatal on the same terms — a missing file, a containment violation, a denied `io` permission all end the evaluation, uncatchable. §16 previously described these as "catchable by an enclosing `then`/`else`, fatal if uncaught, same as `check`/`error`", which contradicted both this section and itself, `check`/`error` being precisely the uncatchable ones; §11's `error` entry carried the same stale phrasing. Both now match this section, which is the one the evaluator implements. +**Resolved 2026-08-31**: a **circular definition** in a `let rec` (§10) is a sixth failure source, fatal on the same terms — an entry that needs another entry's value while that one is still being computed has no value to be given, and no `else` can catch that. It is distinct from the nesting budget above: this one is detected and names the entry it is about, rather than being noticed by running out of stack. A `let rec` that merely recurses without end still hits the budget instead, since nothing there is circular. + Consequently the only recoverable failure in the language is a false `then` caught by its immediately-following `else`. "Try to read this file, fall back if it isn't there" is **not** currently expressible; a program must instead be structured so the read only happens where it must succeed. Whether to add a recoverable I/O channel is left open — it would be a new mechanism, not a re-reading of this one. **Resolved 2026-08-26**: `and`/`or` do short-circuit, the ordinary way — except under §2's async exception, where *all* operands still get walked/awaited regardless (only the discarded side's *value* goes unused), the same rule that already applies to `then`/`else` branches. @@ -266,10 +275,23 @@ Each scope establishes its own names. Child scopes overlay (shadow into) parent - **`let` and `rec` are context-sensitive** like every other keyword (below). `rec` reads as the recursion marker only when a name follows it, so `let rec 5; …` still binds an ordinary name spelled `rec`. - **`as` stays where it is the *pattern* binder** (§8's ` as `). A pattern binder has no body to terminate, and no `let`-shaped spelling of it reads as anything at all. §8 used to describe pattern binding as reusing this section's syntax; it is now its own, and says so. -**`let rec`, added 2026-08-28.** A plain `let` evaluates `` in the *enclosing* scope — the name does not exist yet — so a function bound that way cannot call itself. `let rec` evaluates `` in the child scope the name is about to land in. Since a closure captures the scope it was made in rather than a snapshot of that scope's contents (§7/§9), the name is bound by the time anything calls the function, and the function recurses. Two consequences worth stating: +**`let rec`, added 2026-08-28, amended 2026-08-31.** A plain `let` evaluates `` in the *enclosing* scope — the name does not exist yet — so a function bound that way cannot call itself. `let rec` evaluates `` in the child scope the name is about to land in. Since a closure captures the scope it was made in rather than a snapshot of that scope's contents (§7/§9), the name is bound by the time anything calls the function, and the function recurses. + +That is the general rule, and it is the whole rule for every shape but one: a bound value written as a `Table` literal is evaluated differently, so that *data* can reach itself and not only functions — see "Cyclic data" below. Two consequences of the general rule are worth stating first: + +- **A self-reference that has to be read immediately, rather than captured and read later, still fails.** `let rec x x + 1; x` reports the ordinary "undefined name" — `x` is in scope but nothing has been stored there yet. That is the honest answer, not a special case: for every shape but the `Table` literal below, `rec` changes *which scope* the value is computed in, and nothing else. +- **Mutual recursion goes through one `rec`, not several.** Two functions that call each other cannot be bound one after the other, since whichever came first would name one that did not exist. Binding a single `Table` of them recursively works: `let rec fns { .even = …fns.odd…, .odd = …fns.even… }; fns.even 8`. Whether to add a form that binds several names at once is left open; nothing needs it yet. (**Amended 2026-08-31**: this bullet used to explain the trick as "the entries are closures over the scope holding the name they reach each other through", which was true when only functions could do it. A `Table` literal under `rec` is now bound *before* its entries run and its entries are evaluated on demand, so the same spelling works for entries that are data rather than closures. The closure property is still why a *call* made later resolves; it is no longer what makes the binding work.) + +**Cyclic data, added 2026-08-31.** The two bullets above describe `let rec` serving *functions*: a closure captures the scope, so the name is bound by the time anything calls it. Data cannot wait that long — a `Table` entry reading `people.bob` needs that entry's value during construction — so a `let rec` whose bound value is written as a `Table` literal evaluates that literal differently: + +- The `Table` is created and **bound to the name before any entry runs**, so the name always resolves to the object being built. +- Entries are evaluated **on demand, not in source order**. Reaching `people.bob` while `.alice` is mid-flight evaluates `.bob` there and then. This dissolves every dependency between entries that has a topological order at all, which is why mutual references between entries need nothing further — and why a form binding several names at once is still not needed. +- What survives is the residual true cycle: `.bob` reaching back into `.alice`, which is already in progress and so has no value to give. That, and only that, yields a **forward reference** — a stand-in filled the moment `.alice` completes. It may be **stored** (inside a `Table`, a variant, a closure's environment), and that storing is what makes the back-edge. **Inspecting** one before it is filled — a field access, a call, arithmetic, a comparison — is a genuinely circular definition and fails. Since every entry completes before the `let rec` returns, no finished value ever contains an unfilled one, which is why this is not one of §3's types: it exists only during construction and is invisible afterwards. + +Entries keep the order they were written in whatever order they were evaluated in, per §5. Two consequences follow from the demand rule, and both are deliberate: -- **A self-reference that has to be read immediately, rather than captured and read later, still fails.** `let rec x x + 1; x` reports the ordinary "undefined name" — `x` is in scope but nothing has been stored there yet. That is the honest answer, not a special case: `rec` changes *which scope* the value is computed in, and nothing else. -- **Mutual recursion goes through one `rec`, not several.** Two functions that call each other cannot be bound one after the other, since whichever came first would name one that did not exist. Binding a single `Table` of them recursively works, because the entries are closures over the scope holding the very name they reach each other through: `let rec fns { .even = …fns.odd…, .odd = …fns.even… }; fns.even 8`. Whether to add a form that binds several names at once is left open; nothing needs it yet. +- **Evaluation order within the literal is the dependency order, not the source order.** Where entries have effects — a `createfile`, a fired `async`, a failing `check` — that is the order they happen in, and which failure surfaces first can differ from the source reading. +- **The shape is what enables this, so it is where it stops.** Only a `Table` literal written directly as the bound value has entries to reorder. `let rec p (build_it p);` has none and still fails with "undefined name", exactly as before; so does `let rec x x + 1;`, which the first bullet above already covered. `let rec` only serves a function that *has* a name. An anonymous one — an omission section, a `func` — recurses through `#self` instead (§9). @@ -302,7 +324,7 @@ Any `Utf8` value or `File` can be imported, which turns it into a function. Synt Two builtins operationalizing §6's "every value is hashable" claim. Like `import` and `func`, each is a bare keyword prefix taking one trailing expression — no parentheses: -- **`sha256 `** — hashes a value, returning the digest base64-encoded as `Utf8`. **Resolved 2026-08-26**: this *is* §6's "every value is hashable" mechanism — the same one, not a second cryptographic-specific hash living alongside it. +- **`sha256 `** — hashes a value, returning the digest base64-encoded as `Utf8`. **Resolved 2026-08-26**: this *is* §6's "every value is hashable" mechanism — the same one, not a second cryptographic-specific hash living alongside it. **Amended 2026-08-31**: one kind of value has no digest to return, and it is this document's gap rather than an implementation's — a cyclic value (§10), because the Merkle construction below has no bottom to fold from and §6 has not yet settled what one encodes. `sha256` of one fails, saying so. - **`cached `** — caches a value, or loads it from cache if already present. (`check`/`static_check` (§11) are the odd ones out, needing parens — plausibly because they take two comma-separated arguments, one optional, and parens are what make multiple arguments unambiguous; a single trailing expression needs no such grouping. Not confirmed as a general rule, just the pattern so far.) diff --git a/examples/cyclic-data.hb b/examples/cyclic-data.hb new file mode 100644 index 0000000..3356d8e --- /dev/null +++ b/examples/cyclic-data.hb @@ -0,0 +1,35 @@ +// Cyclic data (SPEC.md §10). Friendship is mutual, so a social graph has to +// close: Alice's friends hold Bob, and Bob's hold Alice. `let rec` builds the +// Table before its entries run, so an entry can name the Table it is part of; +// entries are then evaluated on demand rather than in source order, so +// reaching `people.bob` while `.alice` is still being built simply evaluates +// `.bob` there and then. Only the reference that closes the loop - `.bob` +// reaching back into `.alice`, which is still in progress - is left to be +// filled in when `.alice` finishes. +// +// `.reordered` shows the same machinery with no cycle in it at all: `.a` is +// written first but needs `.b`, and demand alone sorts that out. +// +// `.same_shape` compares two rings built by separate bindings that share no +// entry between them. Equality is about content (§6), so walking one against +// the other says they are the same value - which needs a walk that assumes a +// pair equal on first meeting it, since a plain structural comparison would +// follow the loop forever. +// +// Evaluates to { round_trip: "Alice", mutual: true, second_hop: "Carol", +// reordered: 3, same_shape: true }. +let rec people { + .alice = { .name = "Alice", .friends = { people.bob, people.carol } }, + .bob = { .name = "Bob", .friends = { people.alice } }, + .carol = { .name = "Carol", .friends = { people.alice, people.bob } }, +}; +let rec reordered { .a = reordered.b + 1, .b = 2 }; +let rec ring { .x = { .tag = "x", .next = ring.y }, .y = { .tag = "y", .next = ring.x } }; +let rec other { .x = { .tag = "x", .next = other.y }, .y = { .tag = "y", .next = other.x } }; +{ + .round_trip = people.alice.friends[1].friends[1].name, + .mutual = people.alice.friends[1].friends[1] == people.alice, + .second_hop = people.alice.friends[2].name, + .reordered = reordered.a, + .same_shape = ring.x == other.x, +} diff --git a/src/eval.odin b/src/eval.odin index c431be0..c5e5f9d 100644 --- a/src/eval.odin +++ b/src/eval.odin @@ -102,6 +102,11 @@ Interpreter :: struct { // has_base_dir == false. base_dir_path: string, + // The `let rec` Table literals currently being built, innermost last + // (rec_build.odin). Empty for every program that doesn't write a cyclic + // `let rec`, which is what keeps table_access's check to a length test. + rec_builds: [dynamic]^Rec_Build, + // Every `async` task this run has started (eval_async.odin). Shared with // each task's own Interpreter so that one run's tasks are all drained // together before it ends, however it ends. @@ -403,7 +408,7 @@ eval :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (ret_val: Value if n.children_count > 0 { msg_val, ok := eval_slot(interp, interp.ast.extra_children[n.children_start], env) if ok { - msg_val, ok = await_value(interp, msg_val) + msg_val, ok = concrete_value(interp, msg_val) } if ok { if s, is_str := msg_val.(string); is_str do return fail(interp, s) @@ -437,7 +442,7 @@ eval_unary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, b op := interp.ast.nodes[interp.ast.extra_children[n.children_start]].kind val, ok := eval(interp, interp.ast.extra_children[n.children_start + 1], env) if !ok do return nil, false - val, ok = await_value(interp, val) + val, ok = concrete_value(interp, val) if !ok do return nil, false #partial switch op { case .Op_Minus: @@ -514,7 +519,7 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, defer pop(&interp.arg_stack) right_val, rok := eval(interp, right_idx, env) if !rok do return nil, false - right_val, rok = await_value(interp, right_val) // need its concrete type to know whether to call it + right_val, rok = concrete_value(interp, right_val) // need its concrete type to know whether to call it if !rok do return nil, false if fn, is_fn := right_val.(^Function_Value); is_fn { return call_function(interp, fn, left_val) @@ -524,7 +529,7 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, case .Op_Call: fn_val, fok := eval_slot(interp, left_idx, env) if !fok do return nil, false - fn_val, fok = await_value(interp, fn_val) // need its concrete type before the is_fn check below + fn_val, fok = concrete_value(interp, fn_val) // need its concrete type before the is_fn check below if !fok do return nil, false arg_val, aok := eval_slot(interp, right_idx, env) if !aok do return nil, false @@ -535,7 +540,7 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, case .Op_Dot: base, bok := eval(interp, left_idx, env) if !bok do return nil, false - base, bok = await_value(interp, base) + base, bok = concrete_value(interp, base) if !bok do return nil, false right_node := interp.ast.nodes[right_idx] key: Value = node_text(interp, right_idx) @@ -547,9 +552,9 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, if !bok do return nil, false key, kok := eval(interp, right_idx, env) if !kok do return nil, false - base, bok = await_value(interp, base) + base, bok = concrete_value(interp, base) if !bok do return nil, false - key, kok = await_value(interp, key) + key, kok = concrete_value(interp, key) if !kok do return nil, false return table_access(interp, base, key) @@ -564,9 +569,9 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, if !kok do return nil, false key = k } - base, bok = await_value(interp, base) + base, bok = concrete_value(interp, base) if !bok do return nil, false - awaited_key, kok := await_value(interp, key) + awaited_key, kok := concrete_value(interp, key) if !kok do return nil, false key = awaited_key t, is_table := base.(^Table_Value) @@ -580,9 +585,9 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, if !lok do return nil, false r, rok := eval(interp, right_idx, env) if !rok do return nil, false - l, lok = await_value(interp, l) + l, lok = concrete_value(interp, l) if !lok do return nil, false - r, rok = await_value(interp, r) + r, rok = concrete_value(interp, r) if !rok do return nil, false if ls, lis := l.(string); lis { rs, ris := r.(string) @@ -601,9 +606,9 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, if !lok do return nil, false r, rok := eval(interp, right_idx, env) if !rok do return nil, false - l, lok = await_value(interp, l) + l, lok = concrete_value(interp, l) if !lok do return nil, false - r, rok = await_value(interp, r) + r, rok = concrete_value(interp, r) if !rok do return nil, false if op == .Op_EqEq do return values_equal(l, r), true return compare_ordered(interp, op, l, r) @@ -613,9 +618,9 @@ eval_binary :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, if !lok do return nil, false r, rok := eval(interp, right_idx, env) if !rok do return nil, false - l, lok = await_value(interp, l) + l, lok = concrete_value(interp, l) if !lok do return nil, false - r, rok = await_value(interp, r) + r, rok = concrete_value(interp, r) if !rok do return nil, false return arithmetic(interp, op, l, r) } @@ -713,6 +718,14 @@ table_concat :: proc(a: ^Table_Value, b: ^Table_Value) -> ^Table_Value { table_access :: proc(interp: ^Interpreter, base: Value, key: Value) -> (Value, bool) { t, is_table := base.(^Table_Value) if !is_table do return fail(interp, "cannot index a non-Table value") + // A Table still being built by a `let rec` (§10) answers through + // rec_build.odin instead: the entry asked for may not have run yet, and + // reading it is exactly the demand that makes it run. Ordinary Tables - + // every Table in a program that has no cyclic `let rec` - skip this on a + // length check. + if len(interp.rec_builds) > 0 { + if rb := rec_build_for(interp, t); rb != nil do return rec_access(interp, rb, key) + } val, found := table_find(t, key) if !found do return fail(interp, "no such key in Table") return val, true @@ -818,6 +831,18 @@ eval_let_bind :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value child_env := env_make_child(env) bound_env := env if .Is_Rec not_in n.flags else child_env + // `let rec
;` is the one shape whose entries can be + // reordered, so it gets §10's demand-driven construction (rec_build.odin) - + // which is what lets one entry reach another, and lets the Table reach + // itself. Every other shape, `let rec` over a function included, keeps the + // ordinary path below unchanged. + if .Is_Rec in n.flags && interp.ast.nodes[bound_idx].kind == .Table_Construct { + if _, rok := eval_rec_table(interp, bound_idx, node_text(interp, name_idx), child_env); !rok { + return nil, false + } + return eval_slot(interp, body_idx, child_env) + } + bound_val: Value bok: bool if interp.ast.nodes[bound_idx].kind == .Hole { @@ -850,7 +875,7 @@ eval_with_ctx :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value new_ctx_val, cok := eval_slot(interp, new_ctx_idx, env) if !cok do return nil, false - new_ctx_val, cok = await_value(interp, new_ctx_val) // ctx.permissions reads need a concrete Table + new_ctx_val, cok = concrete_value(interp, new_ctx_val) // ctx.permissions reads need a concrete Table if !cok do return nil, false old_ctx := interp.current_ctx @@ -874,14 +899,14 @@ eval_chctx :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, b fn_val, fok := eval_slot(interp, fn_idx, env) if !fok do return nil, false - fn_val, fok = await_value(interp, fn_val) + fn_val, fok = concrete_value(interp, fn_val) if !fok do return nil, false fn, is_fn := fn_val.(^Function_Value) if !is_fn do return fail(interp, "chctx's right side must be a function") new_ctx_val, cok := call_function(interp, fn, interp.current_ctx) if !cok do return nil, false - new_ctx_val, cok = await_value(interp, new_ctx_val) + new_ctx_val, cok = concrete_value(interp, new_ctx_val) if !cok do return nil, false old_ctx := interp.current_ctx @@ -975,7 +1000,7 @@ eval_guard_chain :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (re case .Op_Is: subject_val, sok := eval(interp, left_idx, env) if !sok do return false, env, false - subject_val, sok = await_value(interp, subject_val) + subject_val, sok = concrete_value(interp, subject_val) if !sok do return false, env, false matched, new_env, mok := match_pattern(interp, right_idx, subject_val, env) if !mok do return false, env, false @@ -998,7 +1023,7 @@ eval_guard_chain :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (re // current #arg per §8's "implicit function application in a guard position"). val, ok1 := eval_slot(interp, node, env) if !ok1 do return false, env, false - val, ok1 = await_value(interp, val) + val, ok1 = concrete_value(interp, val) if !ok1 do return false, env, false if fn, is_fn := val.(^Function_Value); is_fn { if len(interp.arg_stack) == 0 { @@ -1007,7 +1032,7 @@ eval_guard_chain :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (re } applied, aok := call_function(interp, fn, interp.arg_stack[len(interp.arg_stack) - 1]) if !aok do return false, env, false - applied, aok = await_value(interp, applied) + applied, aok = concrete_value(interp, applied) if !aok do return false, env, false val = applied } @@ -1058,7 +1083,7 @@ eval_then_or_else :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (V discarded_val, dok := eval_slot(interp, discarded_idx, guard_env) interp.discard_depth -= 1 if !dok do return nil, false - _, aok := await_value(interp, discarded_val) + _, aok := concrete_value(interp, discarded_val) if !aok do return nil, false } @@ -1079,7 +1104,7 @@ eval_sha256 :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, // An operand that is still an async handle gets awaited first, exactly as // every other operator does - `sha256 async ` hashes the result, not // the handle. - val, ok = await_value(interp, val) + val, ok = concrete_value(interp, val) if !ok do return nil, false encoded, herr := value_digest_base64(val) @@ -1097,7 +1122,7 @@ eval_check :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, b cond_idx := interp.ast.extra_children[n.children_start] cond_val, cok := eval_slot(interp, cond_idx, env) if !cok do return nil, false - cond_val, cok = await_value(interp, cond_val) + cond_val, cok = concrete_value(interp, cond_val) if !cok do return nil, false cond_bool, is_bool := cond_val.(bool) if !is_bool do return fail(interp, "check condition must be a Boolean") @@ -1108,7 +1133,7 @@ eval_check :: proc(interp: ^Interpreter, node: Node_Idx, env: ^Env) -> (Value, b if !cond_bool { if has_msg { msg_val, mok := eval_slot(interp, interp.ast.extra_children[n.children_start + 1], env) - if mok do msg_val, mok = await_value(interp, msg_val) + if mok do msg_val, mok = concrete_value(interp, msg_val) if mok { if s, is_str := msg_val.(string); is_str do return fail(interp, s) } diff --git a/src/examples_test.odin b/src/examples_test.odin index c52847c..89c18c9 100644 --- a/src/examples_test.odin +++ b/src/examples_test.odin @@ -36,6 +36,7 @@ EXAMPLE_CASES := []Example_Case{ {"check-and-invariants.hb", "100"}, {"comparison-and-logic.hb", "{ordered: true, both: true, either: true, mixed: false}"}, {"context-permissions.hb", "{ambient: {io: nothing}, io_denied: {}, replaced: {}, still_ambient: {io: nothing}}"}, + {"cyclic-data.hb", `{round_trip: "Alice", mutual: true, second_hop: "Carol", reordered: 3, same_shape: true}`}, {"files-symlink.hb", `"optiona.txt"`}, {"functions.hb", "121"}, {"functions-and-holes.hb", "{section: 11, explicit: 49, nested: 507, stored: 42, asserted: 9}"}, diff --git a/src/hash.odin b/src/hash.odin index 92f59e6..7da15f4 100644 --- a/src/hash.odin +++ b/src/hash.odin @@ -79,6 +79,7 @@ Hash_Error :: enum { Function, // §15 needs it for `cached`, but never specifies the encoding Cache, // §9's ctx.cache is write-only and has no identity to hash Async, // an un-awaited handle - callers await before hashing + Cyclic, // §10's cyclic Table - a Merkle fold has no bottom to start from } hash_error_message :: proc(e: Hash_Error) -> string { @@ -93,6 +94,8 @@ hash_error_message :: proc(e: Hash_Error) -> string { return "ctx.cache has no hash - it is write-only and has no identity (SPEC.md §9)" case .Async: return "an un-awaited async handle has no hash" + case .Cyclic: + return "a cyclic value has no hash yet (see LANGUAGE.md on what isn't built yet)" } return "" } @@ -101,7 +104,28 @@ hash_error_message :: proc(e: Hash_Error) -> string { // Fails (rather than inventing a digest) for the kinds §3/§15 leave open - // see Hash_Error. value_digest :: proc(v: Value) -> (Value_Digest, Hash_Error) { - switch av in v { + path := make([dynamic]rawptr, 0, 8, context.temp_allocator) + return value_digest_walk(v, &path) +} + +// The encoding above is a Merkle fold: a composite's digest is built from its +// children's. A cyclic Table (§10) has no bottom to start that fold from, and +// no amount of recursion reaches one - so it is refused rather than hung on. +// Hashing one *is* definable (decompose into strongly connected components, +// Merkle-fold the acyclic part, and give each component a digest canonical +// under bisimulation so it does not depend on which node you entered by) but +// §3 pins the encoding of a digest, so choosing one is a spec decision, not an +// implementation detail. Until that is made, `sha256` of a cyclic value fails +// the same way a directory File or a Function does. +// +// `path` is the chain of Tables currently open above this one, so the test is +// for a genuine back-edge - a Table that appears twice in different branches, +// which is ordinary sharing rather than a cycle, still hashes fine. +@(private = "file") +value_digest_walk :: proc(v: Value, path: ^[dynamic]rawptr) -> (Value_Digest, Hash_Error) { + resolved, rok := resolve_forward(v) + if !rok do return {}, .Cyclic + switch av in resolved { case Nothing_Value: return sha256_tagged(TAG_NOTHING, nil), .None @@ -148,11 +172,16 @@ value_digest :: proc(v: Value) -> (Value_Digest, Hash_Error) { // already implements (it matches entries by key, ignoring position). // Sorting by key digest gives a deterministic order without needing §6's // cross-type value ordering, which isn't built. + for open in path^ { + if open == rawptr(av) do return {}, .Cyclic + } + append(path, rawptr(av)) + defer pop(path) pairs := make([][2]Value_Digest, len(av.entries), context.temp_allocator) for entry, i in av.entries { - kd, kerr := value_digest(entry.key) + kd, kerr := value_digest_walk(entry.key, path) if kerr != .None do return {}, kerr - vd, verr := value_digest(entry.value) + vd, verr := value_digest_walk(entry.value, path) if verr != .None do return {}, verr pairs[i] = {kd, vd} } @@ -174,6 +203,11 @@ value_digest :: proc(v: Value) -> (Value_Digest, Hash_Error) { case ^Async_Handle: return {}, .Async + + case ^Forward_Ref_Value: + // Unreachable: resolve_forward above returned either a non-forward value + // or .Cyclic. Present so the switch stays exhaustive. + return {}, .Cyclic } return sha256_tagged(TAG_NOTHING, nil), .None } diff --git a/src/print_value.odin b/src/print_value.odin index c91ef90..1317c2e 100644 --- a/src/print_value.odin +++ b/src/print_value.odin @@ -6,16 +6,78 @@ import "core:strings" // A human-readable rendering of a runtime Value, for the REPL. Not meant to // be exact, re-parseable source (e.g. an empty sequence and `empty` collapse // to the same "{}") - just legible output for interactive use. +// +// A `let rec` value can reach itself (§10), so this cannot simply recurse. +// A Table that a back-edge returns to is given a label and printed as +// `#1{ ... }`; the back-edge itself prints as `#1`: +// +// #1{ name: "Alice", friends: {{ name: "Bob", friends: {#1} }} } +// +// Only Tables genuinely on a cycle are labelled, so every acyclic value - +// including one that merely shares a sub-Table between two branches - prints +// exactly as it did before any of this existed. format_value :: proc(val: Value) -> string { b: strings.Builder strings.builder_init(&b) - write_value(&b, val) + st: Print_State + defer print_state_destroy(&st) + mark_cycles(val, &st) + write_value(&b, val, &st) return strings.to_string(b) } @(private = "file") -write_value :: proc(b: ^strings.Builder, val: Value) { - #partial switch v in val { +Print_State :: struct { + cyclic: map[rawptr]bool, // Tables a back-edge returns to - these get a label + open: map[rawptr]bool, // Tables currently being written, i.e. above us + label: map[rawptr]int, // assigned when a labelled Table is first opened + seen: map[rawptr]bool, // fully explored during the marking pass + next: int, +} + +@(private = "file") +print_state_destroy :: proc(st: ^Print_State) { + delete(st.cyclic) + delete(st.open) + delete(st.label) + delete(st.seen) +} + +// Finds the Tables that a cycle returns to, by walking the value with the +// current path recorded: meeting a Table that is already on the path is a +// back-edge, and that Table is what needs a label. `seen` stops the walk from +// re-exploring a shared-but-acyclic sub-Table once per reference to it. +@(private = "file") +mark_cycles :: proc(val: Value, st: ^Print_State) { + resolved, rok := resolve_forward(val) + if !rok do return + t, is_table := resolved.(^Table_Value) + if !is_table do return + key := rawptr(t) + if st.open[key] { + st.cyclic[key] = true + return + } + if st.seen[key] do return + st.open[key] = true + for entry in t.entries { + mark_cycles(entry.key, st) + mark_cycles(entry.value, st) + } + delete_key(&st.open, key) + st.seen[key] = true +} + +@(private = "file") +write_value :: proc(b: ^strings.Builder, val: Value, st: ^Print_State) { + resolved, rok := resolve_forward(val) + if !rok { + // Only reachable from a debugger or editor peering at a `let rec` that is + // still mid-construction - a finished value never holds an unfilled one. + fmt.sbprint(b, "") + return + } + #partial switch v in resolved { case Nothing_Value: fmt.sbprint(b, "nothing") case i64: @@ -29,7 +91,7 @@ write_value :: proc(b: ^strings.Builder, val: Value) { case []u8: fmt.sbprintf(b, "<%d bytes>", len(v)) case ^Table_Value: - write_table(b, v) + write_table(b, v, st) case ^Function_Value: fmt.sbprint(b, "") case ^File_Value: @@ -61,16 +123,31 @@ write_value :: proc(b: ^strings.Builder, val: Value) { } @(private = "file") -write_table :: proc(b: ^strings.Builder, t: ^Table_Value) { +write_table :: proc(b: ^strings.Builder, t: ^Table_Value, st: ^Print_State) { + key := rawptr(t) + if st.open[key] { + // The back-edge. Its label was assigned when this Table was opened above + // us, so there is always one to write. + fmt.sbprintf(b, "#%d", st.label[key]) + return + } + if st.cyclic[key] { + st.next += 1 + st.label[key] = st.next + fmt.sbprintf(b, "#%d", st.next) + } + st.open[key] = true + defer delete_key(&st.open, key) + strings.write_byte(b, '{') seq := is_sequence_shaped(t) for entry, i in t.entries { if i > 0 do strings.write_string(b, ", ") if !seq { - write_key(b, entry.key) + write_key(b, entry.key, st) strings.write_string(b, ": ") } - write_value(b, entry.value) + write_value(b, entry.value, st) } strings.write_byte(b, '}') } @@ -87,10 +164,10 @@ is_sequence_shaped :: proc(t: ^Table_Value) -> bool { } @(private = "file") -write_key :: proc(b: ^strings.Builder, key: Value) { +write_key :: proc(b: ^strings.Builder, key: Value, st: ^Print_State) { if s, is_str := key.(string); is_str { strings.write_string(b, s) return } - write_value(b, key) + write_value(b, key, st) } diff --git a/src/rec_build.odin b/src/rec_build.odin new file mode 100644 index 0000000..ca0bc92 --- /dev/null +++ b/src/rec_build.odin @@ -0,0 +1,229 @@ +package hashedbuild + +import "core:fmt" + +// SPEC.md §10's cyclic `let rec`: how a Table can reach itself. +// +// An ordinary `let rec` evaluates its bound value in the scope the name is +// about to land in, which is enough for a *function* to recurse - a closure +// captures the scope by pointer, so the name is bound by the time anything +// calls it (see eval.odin's eval_let_bind). It is not enough for data. A Table +// entry that reads `people.bob` needs that entry's value now, during +// construction, and until this file existed there was nothing to give it: the +// name resolved to nothing and the program failed with "undefined name". +// +// The fix is to stop evaluating the entries in source order. The Table is +// created empty and bound to the name *first*, so the name always resolves to +// the object being built; entries are then evaluated **on demand**, in +// whatever order the dependencies between them actually require. Reaching +// `people.bob` while `.alice` is mid-flight simply evaluates `.bob` there and +// then. That dissolves every dependency which has a topological order at all. +// +// What survives is the residual true cycle: `.bob` reaching back into `.alice`, +// which is still in progress and so has no value to give. That, and only that, +// yields a Forward_Ref_Value (value.odin) - a cell filled in the moment +// `.alice` completes. Storing one is fine and is what makes the back-edge; +// inspecting one before it is filled is a genuinely circular definition and +// fails. Since every entry completes before the `let rec` returns, every cell +// is filled by then, and no finished value ever holds an unresolved one. +// +// The scope of all this is deliberately narrow: it applies to a Table literal +// written directly as a `let rec`'s bound value, because that is the only +// shape whose entries there are to reorder. `let rec p (build_it p);` has no +// entries and still fails exactly as it did before. + +@(private = "file") +Rec_Entry_State :: enum { + Pending, // not started + In_Progress, // being evaluated right now - reaching it yields its forward reference + Done, // its value is in place +} + +// One `let rec` Table literal, mid-construction. Lives on Interpreter.rec_builds +// for exactly as long as the binding is being evaluated. +Rec_Build :: struct { + table: ^Table_Value, + env: ^Env, // the child scope the name is bound in - entries evaluate here + name: string, // the bound name, for failure messages + nodes: []Node_Idx, // each entry's value expression + state: []Rec_Entry_State, + fwd: []^Forward_Ref_Value, // one per entry; `table.entries[i].value` until it is Done +} + +// The build for `t`, if it is currently being constructed. The stack is empty +// for every program that never writes a cyclic `let rec`, so this is a length +// check on the hot path of every field access. +rec_build_for :: proc(interp: ^Interpreter, t: ^Table_Value) -> ^Rec_Build { + #reverse for rb in interp.rec_builds { + if rb.table == t do return rb + } + return nil +} + +// `.` / `[]`, for a failure message that names the entry +// a program actually wrote rather than an internal index. +@(private = "file") +rec_entry_label :: proc(name: string, key: Value) -> string { + if s, is_str := key.(string); is_str do return fmt.tprintf("%s.%s", name, s) + return fmt.tprintf("%s[%s]", name, format_value(key)) +} + +// Reading `.` while `` is still being built - the demand that +// drives the whole scheme. Routed here by table_access (eval.odin) instead of +// the ordinary lookup, because the ordinary lookup would find the entry's +// unfilled forward reference rather than evaluating it. +rec_access :: proc(interp: ^Interpreter, rb: ^Rec_Build, key: Value) -> (Value, bool) { + for entry, i in rb.table.entries { + if !values_equal(entry.key, key) do continue + switch rb.state[i] { + case .Done: + return rb.table.entries[i].value, true + case .Pending: + // Evaluate it now, out of source order. This is the reordering that + // makes a mutual reference between two entries work without either of + // them needing a placeholder at all. + if !rec_force(interp, rb, i) do return nil, false + return rb.table.entries[i].value, true + case .In_Progress: + // The real cycle: this entry is somewhere up our own call stack. Hand + // back the cell it will be filled into. + return rb.fwd[i], true + } + } + return fail(interp, "no such key in Table") +} + +// Evaluates entry `i` to completion and fills both its slot and its forward +// reference cell. +@(private = "file") +rec_force :: proc(interp: ^Interpreter, rb: ^Rec_Build, i: int) -> bool { + rb.state[i] = .In_Progress + val, ok := eval_slot(interp, rb.nodes[i], rb.env) + if !ok do return false + // await, not concrete: an `async` entry still has to be resolved before it + // is stored, but a forward reference *stored* inside this value is the + // back-edge itself and must pass through untouched. + val, ok = await_value(interp, val) + if !ok do return false + + // A bare forward reference as the whole value means this entry is defined as + // some other entry which is in turn defined as this one - `{ .a = p.b, + // .b = p.a }`. No ordering and no cell can produce a value for that; it is + // circular in the sense that genuinely has no answer. + resolved, rok := resolve_forward(val) + if !rok { + interp.error_message = fmt.tprintf( + "circular definition: %s is defined as itself, through %s", + rec_entry_label(rb.name, rb.table.entries[i].key), + rec_entry_label(rb.name, val.(^Forward_Ref_Value).key)) + return false + } + + rb.table.entries[i].value = resolved + rb.state[i] = .Done + // Storing the resolved value (never another cell) keeps every chain at most + // one link long, so resolve_forward can never loop. + rb.fwd[i].target = resolved + rb.fwd[i].resolved = true + return true +} + +// `let rec
; ` (§10). Called by eval_let_bind for +// that one shape; everything else keeps the ordinary path. +eval_rec_table :: proc( + interp: ^Interpreter, + table_node: Node_Idx, + name: string, + child_env: ^Env, +) -> (Value, bool) { + n := interp.ast.nodes[table_node] + start := int(n.children_start) + count := int(n.children_count) + + t := new(Table_Value) + t.entries = make([dynamic]Table_Entry_Value, 0, count) + // Bound before a single entry runs - this is the whole point, and what lets + // an entry mention the Table it is part of. + env_bind(child_env, name, t) + + rb := new(Rec_Build) + rb.table = t + rb.env = child_env + rb.name = name + rb.nodes = make([]Node_Idx, count) + rb.state = make([]Rec_Entry_State, count) + rb.fwd = make([]^Forward_Ref_Value, count) + + // Keys first, all of them, in source order. They have to be known before any + // value runs, since a demand for `.bob` is a lookup by key - and it keeps the + // entries in the order they were written whatever order they end up being + // evaluated in, which §5 requires and the printer's sequence-shape test + // depends on. A computed key `[expr] =` that reads the name being bound + // finds no entry it can answer with and fails, which is the honest outcome: + // the key would have to exist before it could be looked up. + for i in 0 ..< count { + child_idx := interp.ast.extra_children[start + i] + child := interp.ast.nodes[child_idx] + key: Value + value_idx: Node_Idx + if child.kind == .Table_Entry { + key_idx := interp.ast.extra_children[child.children_start] + value_idx = interp.ast.extra_children[child.children_start + 1] + key_node := interp.ast.nodes[key_idx] + if key_node.kind == .Identifier && .Computed_Key not_in child.flags { + key = node_text(interp, key_idx) + } else { + k, kok := eval_slot(interp, key_idx, child_env) + if !kok do return nil, false + kk, kok2 := concrete_value(interp, k) + if !kok2 do return nil, false + key = kk + } + } else { + key = i64(i + 1) // 1-indexed, §5 + value_idx = child_idx + } + fr := new(Forward_Ref_Value) + fr.name = name + fr.key = key + rb.nodes[i] = value_idx + rb.fwd[i] = fr + // Every entry starts as its own unfilled cell rather than a nil, so + // anything that reaches a not-yet-evaluated entry by a route other than + // field access - a pattern match against the half-built Table, say - meets + // a forward reference and fails with the circular-definition message, + // rather than reading a hole. + append(&t.entries, Table_Entry_Value{key = key, value = fr}) + } + + append(&interp.rec_builds, rb) + defer pop(&interp.rec_builds) + + for i in 0 ..< count { + if rb.state[i] == .Pending { + if !rec_force(interp, rb, i) do return nil, false + } + } + return t, true +} + +// Resolves `v` into something that can actually be looked at: awaits an +// `async` handle (eval_async.odin) and follows a §10 forward reference to the +// value it stands for. The two are the same idea at different scales - a value +// that is not ready yet - which is why they resolve at the same places, the +// small set of points that genuinely need a concrete value. +// +// The difference is what "not ready" means. An async handle always becomes +// ready if you wait. A forward reference is ready only once the `let rec` entry +// it points at has finished, and reaching one that has not is a program that +// asked for a value before there was one to give. +concrete_value :: proc(interp: ^Interpreter, v: Value) -> (Value, bool) { + awaited, aok := await_value(interp, v) + if !aok do return nil, false + resolved, rok := resolve_forward(awaited) + if rok do return resolved, true + fr := resolved.(^Forward_Ref_Value) + return fail(interp, fmt.tprintf( + "circular definition: %s is needed before it has a value", + rec_entry_label(fr.name, fr.key))) +} diff --git a/src/rec_build_test.odin b/src/rec_build_test.odin new file mode 100644 index 0000000..301a521 --- /dev/null +++ b/src/rec_build_test.odin @@ -0,0 +1,274 @@ +// Tests run natively, never in a WASI build: core:testing pulls in +// core:log and core:terminal, neither of which compiles for wasm32. +#+build linux, windows +package hashedbuild + +import "core:testing" + +// SPEC.md §10's cyclic `let rec` (rec_build.odin): demand-driven entry +// evaluation, the forward reference that closes a true cycle, and the two +// things that then have to cope with a graph rather than a tree - equality +// (value.odin) and printing (print_value.odin). + +@(private = "file") +run :: proc(src: string) -> (val: Value, ok: bool, err: string) { + ast := parse(source_t{name = "test", n_bytes = u64(len(src)), data = raw_data(src)}, ast_t{}) + interp := Interpreter{ast = &ast, src = src} + env := env_make_child(nil) + val, ok = eval(&interp, ast.root, env) + return val, ok, interp.error_message +} + +@(private = "file") +expect_prints :: proc(t: ^testing.T, src: string, want: string) { + val, ok, err := run(src) + testing.expect(t, ok, err) + if !ok do return + testing.expect_value(t, format_value(val), want) +} + +@(private = "file") +expect_fails_with :: proc(t: ^testing.T, src: string, want_err: string) { + _, ok, err := run(src) + testing.expect(t, !ok, "expected this to fail") + if ok do return + testing.expect_value(t, err, want_err) +} + +// ---- the cycle itself -------------------------------------------------------- + +@(test) +test_rec_table_reaches_itself :: proc(t: ^testing.T) { + // The simplest cycle: one entry whose value is the Table it belongs to. + // Walking `.self` any number of times has to arrive back at the same Table. + expect_prints(t, "let rec p { .n = 1, .self = p }; p.self.self.self.n", "1") +} + +@(test) +test_rec_sequence_reaches_itself :: proc(t: ^testing.T) { + expect_prints(t, "let rec ones {1, ones}; ones[2][2][2][1]", "1") +} + +@(test) +test_rec_mutual_entries_close_the_cycle :: proc(t: ^testing.T) { + // Alice's friends hold Bob and Bob's hold Alice, so following the edge twice + // must land on the very Table we started from. Evaluating `.alice` demands + // `.bob` out of order; `.bob` then reaches back into `.alice`, which is still + // in progress - the one place a forward reference is created. + src := `let rec people { + .alice = { .name = "Alice", .friends = { people.bob } }, + .bob = { .name = "Bob", .friends = { people.alice } }, + }; people.alice.friends[1].friends[1].name` + expect_prints(t, src, `"Alice"`) +} + +@(test) +test_rec_demand_reorders_without_any_cycle :: proc(t: ^testing.T) { + // `.a` needs `.b`, which is written after it. No cycle is involved at all - + // this is purely the reordering, and it is what removes the need for a + // placeholder in every case that has a topological order. + expect_prints(t, "let rec p { .a = p.b + 1, .b = 2 }; p.a", "3") +} + +@(test) +test_rec_entries_keep_source_order :: proc(t: ^testing.T) { + // `.b` is evaluated first (demanded by `.a`) but must still be printed + // second: §5 preserves the order entries were written in, whatever order + // they ended up running in. + expect_prints(t, "let rec p { .a = p.b, .b = 2 }; p", "{a: 2, b: 2}") +} + +@(test) +test_rec_over_a_function_is_unchanged :: proc(t: ^testing.T) { + // The pre-existing `let rec` path: a closure captures the scope, so this + // never goes near rec_build.odin. Guards against the new Table path + // changing how ordinary recursion behaves. + expect_prints(t, "let rec fact (let n; (n == 0) then 1 else n * (fact (n - 1))); fact 5", "120") +} + +// ---- what is still circular -------------------------------------------------- + +@(test) +test_rec_entry_defined_as_itself_fails :: proc(t: ^testing.T) { + // `.a` is `.b` and `.b` is `.a`: no ordering and no forward reference can + // produce a value, because there is no value. + expect_fails_with(t, "let rec p { .a = p.b, .b = p.a }; p.a", + "circular definition: p.b is defined as itself, through p.a") +} + +@(test) +test_rec_inspecting_an_entry_in_progress_fails :: proc(t: ^testing.T) { + // A forward reference may be stored, never inspected - `+` inspects. + expect_fails_with(t, "let rec p { .a = p.b + 1, .b = p.a + 1 }; p.a", + "circular definition: p.a is needed before it has a value") +} + +@(test) +test_rec_non_table_still_fails_as_before :: proc(t: ^testing.T) { + // Nothing but a Table literal gets the new path, so a scalar reading its own + // name still hits the window before the bind, exactly as it did. + expect_fails_with(t, "let rec x x + 1; x", "undefined name: x") +} + +@(test) +test_rec_missing_key_still_fails :: proc(t: ^testing.T) { + expect_fails_with(t, "let rec p { .a = 1 }; p.zz", "no such key in Table") +} + +// ---- equality over a graph --------------------------------------------------- + +@(test) +test_cyclic_equality_is_bisimulation :: proc(t: ^testing.T) { + // Two 2-cycles of the same shape, built by separate bindings that share no + // node at all. §6 makes equality a question about content, so these are the + // same value - and answering it at all needs the assumption-based walk, + // since a naive structural compare would never terminate. + src := `let rec g { .a = { .tag = "a", .next = g.b }, .b = { .tag = "b", .next = g.a } }; + let rec h { .a = { .tag = "a", .next = h.b }, .b = { .tag = "b", .next = h.a } }; + g.a == h.a` + expect_prints(t, src, "true") +} + +@(test) +test_cyclic_equality_still_sees_a_difference :: proc(t: ^testing.T) { + // Same shape, one differing payload deep inside the cycle. The optimistic + // assumption must not swallow a real mismatch. + src := `let rec g { .a = { .tag = "a", .next = g.b }, .b = { .tag = "b", .next = g.a } }; + let rec h { .a = { .tag = "a", .next = h.b }, .b = { .tag = "ZZ", .next = h.a } }; + g.a == h.a` + expect_prints(t, src, "false") +} + +@(test) +test_cyclic_equality_of_different_period :: proc(t: ^testing.T) { + // A 1-cycle against a 2-cycle whose two nodes are identical. Every entry + // matches whichever way you unroll them, so bisimulation says equal - which + // is the coinductive answer, and the one that keeps equality about content. + src := `let rec g { .n = 1, .next = g }; + let rec h { .a = { .n = 1, .next = h.b }, .b = { .n = 1, .next = h.a } }; + g == h.a` + expect_prints(t, src, "true") +} + +@(test) +test_self_referential_table_equals_itself :: proc(t: ^testing.T) { + expect_prints(t, "let rec p { .n = 1, .self = p }; p == p.self", "true") +} + +@(test) +test_cyclic_equality_of_different_period_can_still_differ :: proc(t: ^testing.T) { + // The mirror of the test above: same 1-against-2 shape, but the 2-cycle + // alternates payloads, so unrolling the two against each other diverges on + // the second step. + src := `let rec g { .n = 1, .next = g }; + let rec h { .a = { .n = 1, .next = h.b }, .b = { .n = 2, .next = h.a } }; + g == h.a` + expect_prints(t, src, "false") +} + +@(test) +test_a_cycle_is_not_equal_to_a_finite_unrolling :: proc(t: ^testing.T) { + // Any finite unrolling has to bottom out in something the cycle does not, + // so no depth of it is ever the same value. + expect_prints(t, "let rec g { .n = 1, .self = g }; g == { .n = 1, .self = { .n = 1, .self = 1 } }", "false") +} + +@(test) +test_cyclic_equality_is_symmetric :: proc(t: ^testing.T) { + // Comparing entries walks the left operand and looks each key up in the + // right, so the two operands are not handled identically. The answer must + // not depend on which side is which. + src := `let rec g { .a = { .t = "a", .next = g.b }, .b = { .t = "b", .next = g.a } }; + let rec h { .a = { .t = "a", .next = h.b }, .b = { .t = "ZZ", .next = h.a } }; + { (g.a == h.a), (h.a == g.a) }` + expect_prints(t, src, "{false, false}") +} + +@(test) +test_a_cyclic_value_works_as_a_key :: proc(t: ^testing.T) { + // Nothing stops a cyclic Table being a key, and matching one is the same + // bisimulation question as matching a value. + src := `let rec p { .n = 1, .self = p }; + let rec q { .n = 1, .self = q }; + { [p] = "x" } == { [q] = "x" }` + expect_prints(t, src, "true") +} + +@(test) +test_a_failed_key_match_does_not_taint_a_later_comparison :: proc(t: ^testing.T) { + // Regression. Matching `g.a` against the candidate key `h.b` fails, but the + // optimistic walk had already recorded "assume g.a and h.b are equal" on its + // way down. When that state was shared across candidates, the later `.z` + // comparison of exactly that pair short-circuited to true on the discredited + // assumption, and two unequal Tables compared equal. `.z` is the whole point + // of the case: everything else about X and Y genuinely does match. + base :: `let rec g { .a = { .tag = "a", .next = g.b }, .b = { .tag = "b", .next = g.a } }; + let rec h { .a = { .tag = "a", .next = h.b }, .b = { .tag = "b", .next = h.a } }; + let X { [g.a] = 1, [g.b] = 2, .z = g.a };` + expect_prints(t, base + ` let Y { [h.b] = 2, [h.a] = 1, .z = h.b }; X == Y`, "false") + // The same shape where `.z` really does match, so the false above is the + // mismatch being found and not the comparison having become useless. + expect_prints(t, base + ` let Y { [h.b] = 2, [h.a] = 1, .z = h.a }; X == Y`, "true") + // ...and the pair on its own, which is what the tainted state got wrong. + expect_prints(t, base + ` g.a == h.b`, "false") +} + +// ---- printing a graph -------------------------------------------------------- + +@(test) +test_printing_labels_a_cycle :: proc(t: ^testing.T) { + expect_prints(t, "let rec p { .n = 1, .self = p }; p", "#1{n: 1, self: #1}") +} + +@(test) +test_printing_labels_a_sequence_cycle :: proc(t: ^testing.T) { + expect_prints(t, "let rec ones {1, ones}; ones", "#1{1, #1}") +} + +@(test) +test_printing_labels_the_node_the_back_edge_returns_to :: proc(t: ^testing.T) { + // The label belongs on Alice, not on the outer Table: she is what the + // back-edge inside Bob points at. + src := `let rec people { + .alice = { .name = "Alice", .friends = { people.bob } }, + .bob = { .name = "Bob", .friends = { people.alice } }, + }; people.alice` + expect_prints(t, src, `#1{name: "Alice", friends: {{name: "Bob", friends: {#1}}}}`) +} + +@(test) +test_printing_leaves_acyclic_sharing_alone :: proc(t: ^testing.T) { + // The same sub-Table reached twice down different branches is ordinary + // sharing, not a cycle - it must print in full both times, with no label. + expect_prints(t, "let s { .x = 1 }; { .p = s, .q = s }", "{p: {x: 1}, q: {x: 1}}") +} + +// ---- hashing ----------------------------------------------------------------- + +@(test) +test_hashing_a_cyclic_value_is_refused :: proc(t: ^testing.T) { + // §3 pins what a digest encodes, so a cyclic one is a spec decision rather + // than an implementation detail - until it is made, this must fail cleanly + // rather than recurse forever. See hash.odin. + val, ok, err := run("let rec p { .n = 1, .self = p }; p") + testing.expect(t, ok, err) + if !ok do return + _, herr := value_digest(val) + testing.expect_value(t, herr, Hash_Error.Cyclic) +} + +@(test) +test_hashing_acyclic_values_is_unaffected :: proc(t: ^testing.T) { + // The cycle check walks the open path only, so a shared-but-acyclic Table + // still hashes, and two equal values still hash alike. + a, aok, aerr := run("let s { .x = 1 }; { .p = s, .q = s }") + testing.expect(t, aok, aerr) + b, bok, berr := run("{ .p = { .x = 1 }, .q = { .x = 1 } }") + testing.expect(t, bok, berr) + if !aok || !bok do return + da, ea := value_digest(a) + db, eb := value_digest(b) + testing.expect_value(t, ea, Hash_Error.None) + testing.expect_value(t, eb, Hash_Error.None) + testing.expect(t, da == db, "equal values must hash alike") +} diff --git a/src/value.odin b/src/value.odin index 63443d7..2ada6e9 100644 --- a/src/value.odin +++ b/src/value.odin @@ -66,6 +66,27 @@ Cache_Value :: struct { opened: bool, } +// SPEC.md §10's forward reference: a stand-in for a `let rec` Table entry that +// is still being evaluated. Demand-driven evaluation reorders away every +// dependency that has a topological order (see eval.odin's Rec_Build); one of +// these is created only for the residual *true* cycle - an entry projecting an +// entry already in progress, which no ordering can fix. +// +// It may be **stored**: dropped into a Table, a Variant, a closure's captured +// environment. The moment anything **inspects** it before it is filled - a +// field access, a call, arithmetic, a comparison - that is a genuinely +// circular definition and fails (see eval.odin's concrete_value). By the time +// the enclosing `let rec` returns, every reference it made is filled, so a +// program can never receive a value containing an unresolved one. That is why +// this is not one of §3's types: it exists only during construction, and is +// transparent to everything afterwards. +Forward_Ref_Value :: struct { + target: Value, // valid only once `resolved` + resolved: bool, + name: string, // the `let rec` binding this points into, for the message + key: Value, // ...and which of its entries +} + Value :: union { Nothing_Value, i64, // Integer @@ -78,6 +99,7 @@ Value :: union { ^File_Value, ^Cache_Value, ^Async_Handle, // SPEC.md §2 - a fired-but-not-yet-awaited `async` expression; see eval_async.odin + ^Forward_Ref_Value, // SPEC.md §10 - a `let rec` cycle's back-edge, only ever unresolved mid-construction } Env :: struct { @@ -105,6 +127,31 @@ env_bind :: proc(env: ^Env, name: string, val: Value) { env.names[name] = val } +// Follows a chain of forward references (§10) to the value one finally stands +// for. `ok` is false if the chain ends at one that is not filled in yet, which +// only happens while its own `let rec` is still building - see +// Forward_Ref_Value and eval.odin's concrete_value, which turns that into the +// user-facing "circular definition" failure. +resolve_forward :: proc(v: Value) -> (Value, bool) { + cur := v + for { + fr, is_fwd := cur.(^Forward_Ref_Value) + if !is_fwd do return cur, true + if !fr.resolved do return cur, false + cur = fr.target + } +} + +// Each candidate is compared with its own assumption state (values_equal makes +// a fresh one), which matters and is not just tidiness: this loop is the one +// place in the comparison machinery that *keeps going* after a comparison +// returns false. The optimistic algorithm below records "assume these two are +// equal" as it descends, and those assumptions are only sound if the descent +// succeeds - a failed one leaves claims that were never justified. Sharing +// state across candidates would let a later candidate short-circuit to `true` +// on the wreckage of an earlier failure. Asking whether two keys match is a +// self-contained question about their own two subgraphs, so answering it in +// isolation is both sound and enough. table_find :: proc(t: ^Table_Value, key: Value) -> (Value, bool) { for entry in t.entries { if values_equal(entry.key, key) do return entry.value, true @@ -112,41 +159,124 @@ table_find :: proc(t: ^Table_Value, key: Value) -> (Value, bool) { return nil, false } +// ---- equality over possibly-cyclic values (SPEC.md §6/§10) ------------------- + +// `let rec` can build a Table that reaches itself (§10), so the structural walk +// below has to terminate on a graph rather than a tree. The algorithm is the +// standard optimistic one (Downey-Sethi-Tarjan congruence closure): on first +// meeting a pair of Tables, *assume* they are equal, record that assumption, +// and compare their entries under it. A back-edge then arrives at a pair +// already assumed equal and stops, instead of recursing forever. If any +// entry actually mismatches, the whole comparison returns false, and it does so +// all the way out: every caller down the value spine propagates a false rather +// than trying something else, so the discredited assumptions die with the walk +// that made them and nothing has to be rolled back. That argument holds only +// because of it - table_find, the one loop that does try something else, is +// therefore kept out of this state entirely (see its comment). +// +// What that computes is bisimulation: two separately built cycles of the same +// shape are equal, which is what §6's "equality is about content" requires - +// a pointer comparison would call them different for no reason a program can +// see. Assumptions live in a union-find with path compression, so the cost is +// near-linear (O(n·α(n))) rather than the O(n²) a visited-pair set would give. +// +// The map is created only when two *distinct* Tables are first assumed equal, +// so every acyclic comparison - and every scalar key lookup through +// table_find, which is on the hot path of field access - allocates nothing. +@(private = "file") +Bisim :: struct { + parent: map[rawptr]rawptr, +} + +@(private = "file") +bisim_destroy :: proc(bs: ^Bisim) { + if bs.parent != nil do delete(bs.parent) +} + +@(private = "file") +bisim_find :: proc(bs: ^Bisim, x: rawptr) -> rawptr { + // A nil map reads as "every node is its own root", which is exactly the + // state before any assumption has been made - so this is safe to call + // before `parent` exists. + root := x + for { + p, ok := bs.parent[root] + if !ok || p == root do break + root = p + } + cur := x + for cur != root { + p := bs.parent[cur] + bs.parent[cur] = root + cur = p + } + return root +} + +@(private = "file") +bisim_assume_equal :: proc(bs: ^Bisim, a: rawptr, b: rawptr) { + if bs.parent == nil do bs.parent = make(map[rawptr]rawptr) + ra := bisim_find(bs, a) + rb := bisim_find(bs, b) + if ra == rb do return + bs.parent[ra] = rb + bs.parent[rb] = rb +} + // No implicit coercion between kinds (Integer 5 and Float 5.0 are not equal) - // not addressed by the spec, kept simple and predictable for this pass. values_equal :: proc(a: Value, b: Value) -> bool { - switch av in a { + bs: Bisim + defer bisim_destroy(&bs) + return values_equal_bisim(a, b, &bs) +} + +@(private = "file") +values_equal_bisim :: proc(a: Value, b: Value, bs: ^Bisim) -> bool { + // Comparing a value is inspecting it, so an unresolved forward reference + // cannot be compared - but it also cannot be *reached* by a program, since + // the only code running while one exists is the `let rec` building it, and + // that fails at the inspection itself (concrete_value). Returning false + // keeps this total rather than relying on that argument. + av, aok := resolve_forward(a) + bv, bok := resolve_forward(b) + if !aok || !bok do return false + + switch x in av { case Nothing_Value: - _, ok := b.(Nothing_Value) + _, ok := bv.(Nothing_Value) return ok case i64: - bv, ok := b.(i64) - return ok && av == bv + y, ok := bv.(i64) + return ok && x == y case f64: - bv, ok := b.(f64) - return ok && av == bv + y, ok := bv.(f64) + return ok && x == y case string: - bv, ok := b.(string) - return ok && av == bv + y, ok := bv.(string) + return ok && x == y case bool: - bv, ok := b.(bool) - return ok && av == bv + y, ok := bv.(bool) + return ok && x == y case []u8: - bv, ok := b.([]u8) - return ok && slice.equal(av, bv) + y, ok := bv.([]u8) + return ok && slice.equal(x, y) case ^Table_Value: - bv, ok := b.(^Table_Value) - if !ok || len(av.entries) != len(bv.entries) do return false - for entry in av.entries { - other_val, found := table_find(bv, entry.key) - if !found || !values_equal(entry.value, other_val) do return false + y, ok := bv.(^Table_Value) + if !ok || len(x.entries) != len(y.entries) do return false + if x == y do return true // the same node - bisimilar to itself, no walk needed + if bisim_find(bs, x) == bisim_find(bs, y) do return true // already assumed + bisim_assume_equal(bs, x, y) + for entry in x.entries { + other_val, found := table_find(y, entry.key) + if !found || !values_equal_bisim(entry.value, other_val, bs) do return false } return true case ^Function_Value: - bv, ok := b.(^Function_Value) - return ok && av == bv // reference equality - functions aren't otherwise comparable + y, ok := bv.(^Function_Value) + return ok && x == y // reference equality - functions aren't otherwise comparable case ^File_Value: - bv, ok := b.(^File_Value) + y, ok := bv.(^File_Value) if !ok do return false // SPEC.md §3: a File's identity is pure content, independent of path - // two Files built from different paths are equal whenever their content @@ -155,18 +285,22 @@ values_equal :: proc(a: Value, b: Value) -> bool { // including each file's executable bit, which only the Linux target can // report - WASI's filestat has no permission bits at all, and Windows has // no POSIX exec bit - so that half isn't built (see LANGUAGE.md). - if av.kind == .Directory || bv.kind == .Directory do return av == bv - return values_hash_equal(av, bv) + if x.kind == .Directory || y.kind == .Directory do return x == y + return values_hash_equal(x, y) case ^Cache_Value: - bv, ok := b.(^Cache_Value) - return ok && av == bv // reference equality - there's only ever one per context anyway + y, ok := bv.(^Cache_Value) + return ok && x == y // reference equality - there's only ever one per context anyway case ^Async_Handle: // Every real call site awaits an operand before comparing it (see // eval_async.odin) - an un-awaited handle reaching here would be a bug // elsewhere, not a case real programs should hit. Reference equality // just keeps this switch exhaustive without pretending to be meaningful. - bv, ok := b.(^Async_Handle) - return ok && av == bv + y, ok := bv.(^Async_Handle) + return ok && x == y + case ^Forward_Ref_Value: + // Unreachable: resolve_forward above returns either a non-forward value + // or ok == false. Present so the switch stays exhaustive. + return false } return false }