From 0014f75a22855955e8d9d15baee633d436147852 Mon Sep 17 00:00:00 2001 From: Yura Dupyn <2153100+omedusyo@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:48:01 +0100 Subject: [PATCH] Signal let-expressions --- src/lang/SIGNAL.md | 2 +- src/lang/eval/evaluator.ts | 4 +- src/lang/eval/signal.ts | 76 +++++++++++++++++++++++++-- src/lang/eval/value.ts | 4 ++ src/lang/expr.ts | 6 +-- src/lang/parser/scanner.ts | 1 - src/lang/program.ts | 104 ++++++++++++++++++++++++++++++++++++- 7 files changed, 185 insertions(+), 12 deletions(-) diff --git a/src/lang/SIGNAL.md b/src/lang/SIGNAL.md index abbfaf6..fbf365d 100644 --- a/src/lang/SIGNAL.md +++ b/src/lang/SIGNAL.md @@ -1,7 +1,7 @@ # Language Design: Two Worlds -The language into two fragments: Normal Expressions vs Signal Expressions. +The language's expressions are divided into two fragments: Normal Expressions vs Signal Expressions. - The Normal (Values) world is the standard (call-by-value) functional programming. - The Signal (Reactive) world declares connections between time-varying signals. diff --git a/src/lang/eval/evaluator.ts b/src/lang/eval/evaluator.ts index 77f1735..93eed1c 100644 --- a/src/lang/eval/evaluator.ts +++ b/src/lang/eval/evaluator.ts @@ -77,9 +77,9 @@ export function eval_expr(program: Program, env: Env, e: Expr): Value { function eval_bindings(program: Program, env: Env, bindings: ExprBinding[]): Env { // note that `let { x = 123, y = x + 1 ... } is allowed. Ofcourse later bindings can't be referenced by earlier bindings (i.e. no recursion). let cur_env = env; - for (const { pattern: var_name, expr } of bindings) { + for (const { pattern, expr } of bindings) { const value = eval_expr(program, cur_env, expr); - const res = match_product_pattern(var_name, value); + const res = match_product_pattern(pattern, value); if (res.tag === "failure") { throw ThrownRuntimeError.error({ tag: "UnableToFindMatchingPattern", diff --git a/src/lang/eval/signal.ts b/src/lang/eval/signal.ts index a250f3d..77252f7 100644 --- a/src/lang/eval/signal.ts +++ b/src/lang/eval/signal.ts @@ -1,7 +1,9 @@ -import { Expr, SignalExpr, SignalName } from "../expr"; +import { Product } from "electron"; +import { Expr, ProductPattern, SignalExpr, SignalName } from "../expr"; import { Program } from "../program"; import { Result, RuntimeError, ThrownRuntimeError } from "./error"; import { eval_expr, eval_start } from "./evaluator"; +import { match_product_pattern } from "./pattern_match"; import { Env, Value } from "./value"; @@ -37,7 +39,7 @@ export namespace SignalRuntime { // 2. runtime, subscriptions, derived signals etc. // may throw `ThrownRuntimeError` -export function eval_signal_expression(program: Program, e: SignalExpr): Signal { +export function eval_signal_expression(program: Program, env: Env, e: SignalExpr): Signal { switch (e.tag) { case "read": return Program.get_or_create_signal(program, e.name); @@ -45,11 +47,64 @@ export function eval_signal_expression(program: Program, e: SignalExpr): Signal< const val = eval_expr(program, Env.nil(), e.arg); return signal(val); case "let": - // TODO - throw new Error("TODO: Let-signals are hard, skipping for now."); + // TODO: To change this, first look at how `tupleThen` works. It's a simpler more isolated case. Easier to get right. + // === Get/Create dependencies === + let signalBindings: SignalBinding[] = []; + for (const { pattern, expr } of e.bindings) { + // This will either get a reference to an existing signal or create a bunch of new signals + // Note that we're using `env` here and not `cur_env`. That's intentional. + const signal = eval_signal_expression(program, env, expr); + signalBindings.push({ pattern, signal }); + } + + // === Initialization === + const initValue = eval_expr_in_signal_bindings(program, env, signalBindings, e.body); + const letSignal = signal(initValue); + + // === Reactive Update === + // Setup a subscription to each dependency, when it changes, poll every signal and re-evaluate + // TODO: This is extremely inefficient. + for (const { signal } of signalBindings) { + signal.subscribe(() => { + letSignal.set(() => { + const value = eval_expr_in_signal_bindings(program, env, signalBindings, e.body); + return value; + }); + }); + } + + return letSignal; } } +type SignalBinding = { + pattern: ProductPattern, + signal: Signal, +} + +// may throw `ThrownRuntimeError` +function eval_expr_in_signal_bindings(program: Program, env: Env, signalBindings: SignalBinding[], expr: Expr): Value { + let cur_env = env; + for (const { pattern, signal } of signalBindings) { + const value = signal.read(); + const res = match_product_pattern(pattern, value); + if (res.tag === "failure") { + // TODO: idk what to do here... + // TODO: Do you actually need to cleanup the old signals? What should be done here? + // TODO: We shouldn't throw here... the individual let-signal-branches should ve independent of each other. + // not sure... + throw ThrownRuntimeError.error({ + tag: "UnableToFindMatchingPattern", + value, + }); + } else { + cur_env = Env.push_frame(cur_env, res.frame); + } + } + const value = eval_expr(program, cur_env, expr); + return value; +} + // may throw `ThrownRuntimeError` export function signal_set_value(program: Program, sig: Signal, e: Expr): Value { const value = eval_expr(program, Env.nil(), e); @@ -107,6 +162,19 @@ function pair(X: Signal, Y: Signal): Signal<[A, B]> { return Z; } +function tupleThen(Xs: Signal[], f: (values: A[]) => B): Signal { + const Z = signal(f(Xs.map(X => X.read()))); + + // TODO: This is just preliminary. Has the glitch bug. Also has insane quadratic behaviour. + Xs.forEach((X, i) => { + X.subscribe(_ => { + Z.set(() => f(Xs.map(X => X.read()))); + }); + }); + + return Z; +} + // === example === diff --git a/src/lang/eval/value.ts b/src/lang/eval/value.ts index 0168dc9..b675413 100644 --- a/src/lang/eval/value.ts +++ b/src/lang/eval/value.ts @@ -66,6 +66,10 @@ export namespace Env { }); } + export function nil_frame(): EnvFrame { + return new Map(); + } + export function frame_insert_mut(frame: EnvFrame, var_name: VariableName, value: Value) { frame.set(var_name, value); } diff --git a/src/lang/expr.ts b/src/lang/expr.ts index 174ea4d..f7cbe05 100644 --- a/src/lang/expr.ts +++ b/src/lang/expr.ts @@ -27,7 +27,7 @@ export type SignalExpr = | { tag: "read", name: SignalName } & Meta // TODO: Is `const` necesary? | { tag: "const", arg: Expr } & Meta - | { tag: "let", bindings: SignalBinding[], body: Expr } & Meta + | { tag: "let", bindings: SignalExprBinding[], body: Expr } & Meta export type Literal = | { tag: "number", value: number } @@ -38,7 +38,7 @@ export type ExprBinding = { expr: Expr, } & Meta -export type SignalBinding = { +export type SignalExprBinding = { pattern: ProductPattern, expr: SignalExpr, } & Meta @@ -87,7 +87,7 @@ export namespace Expr { export namespace SignalExpr { export const read = (name: SignalName, span: Span): SignalExpr => ({ tag: "read", name, span }); - export const signalBinding = (pattern: ProductPattern, expr: SignalExpr, span: Span): SignalBinding => ({ pattern, expr, span }); + export const signalBinding = (pattern: ProductPattern, expr: SignalExpr, span: Span): SignalExprBinding => ({ pattern, expr, span }); } export namespace ProductPattern { diff --git a/src/lang/parser/scanner.ts b/src/lang/parser/scanner.ts index efce63c..2f6a372 100644 --- a/src/lang/parser/scanner.ts +++ b/src/lang/parser/scanner.ts @@ -342,7 +342,6 @@ export function isNextTokenExprStart(cursor: Cursor): boolean { case "signal": case "fn-signal": case "=": - case "=": case "|": case "!": return false; diff --git a/src/lang/program.ts b/src/lang/program.ts index 48a203f..29f6235 100644 --- a/src/lang/program.ts +++ b/src/lang/program.ts @@ -94,6 +94,7 @@ export namespace Program { | { tag: "DuplicateSignalName", name: SignalName } | { tag: "SignalNotFound", name: SignalName } | { tag: "CannotEditPrimitiveSignal", name: SignalName } + | { tag: "CannotEditCell", name: SignalName } | { tag: "CannotDeletePrimitiveSignal", name: SignalName } | { tag: "PrimitiveSignalAlreadyExists", name: SignalName } @@ -196,7 +197,7 @@ export namespace Program { def.is_initializing = true; try { - const newSignal = eval_signal_expression(program, def.body); + const newSignal = eval_signal_expression(program, Env.nil(), def.body); const newId = attachNewSignal(program, newSignal); program.signal_runtime.store.set(newId, newSignal); def.signalId = newId; @@ -260,6 +261,18 @@ export namespace Program { return signal; } } + case "cell": { + const def = sigDef.def; + if (def.signalId === undefined) { + throw ThrownRuntimeError.error({ tag: "SignalLookupFailure", name, }); + } + const signal = SignalRuntime.getSignal(program.signal_runtime, def.signalId); + if (signal === undefined){ + throw ThrownRuntimeError.error({ tag: "SignalLookupFailure", name, }); + } else { + return signal; + } + } case "primitive": { const def = sigDef.def; const signal = SignalRuntime.getSignal(program.signal_runtime, def.signalId); @@ -413,6 +426,10 @@ export function updateSignal( return Result.error({ tag: "CannotEditPrimitiveSignal", name } as any); } + if (existingEntry.tag === "cell") { + return Result.error({ tag: "CannotEditCell", name } as any); + } + const def = existingEntry.def; def.body = body; def.raw_body = raw_body; @@ -458,5 +475,90 @@ export function updateSignal( return Result.ok(undefined); } + // === Cells === + export type CreateCell = { + name: SignalName, + body: Expr, + raw_body: string, + } + + export function registerCell( + program: Program, + { name, body, raw_body }: CreateCell + ): Result { + + if (program.signal_definitions.has(name)) { + return Result.error({ tag: "DuplicateSignalName", name }); + } + + const now: Timestamp = Date.now(); + + // TODO: MAY THROW RuntimeError. Should probably switch to `eval_start` - and extend the `Program.Error` with runtime errors. + const initialValue = eval_expr(program, Env.nil(), body); + + const sig = signal(initialValue); + const signalId = attachNewSignal(program, sig); + + const newCell: CellDefinition = { + name, + raw_body, + body, + signalId, + createdAt: now, + lastModifiedAt: now, + }; + + program.signal_definitions.set(name, { tag: "cell", def: newCell }); + program.signal_definition_order.push(name); + + return Result.ok(undefined); + } + + export type UpdateCell = { + body: Expr, + raw_body: string, + } + + export function updateCell( + program: Program, + name: SignalName, + { body, raw_body }: UpdateCell + ): Result { + const existingEntry = program.signal_definitions.get(name); + + if (!existingEntry) { + return Result.error({ tag: "SignalNotFound", name } as any); + } + + // Ensure we are editing a Cell + if (existingEntry.tag !== "cell") { + return Result.error({ tag: "CannotEditCell", name } as any); + } + + const def = existingEntry.def; + + // TODO: MAY THROW RuntimeError. Should probably switch to `eval_start` - and extend the `Program.Error` with runtime errors. + const newValue = eval_expr(program, Env.nil(), body); + + // 2. Find the existing runtime signal + if (def.signalId === undefined) { + // This should theoretically not happen for cells since we initialize them eagerly, + // but good to be safe. + return Result.error({ tag: "SignalDefinitionHasSignalIdWithoutSignal", name } as any); + } + + const sig = SignalRuntime.getSignal(program.signal_runtime, def.signalId); + if (!sig) { + return Result.error({ tag: "SignalDefinitionHasSignalIdWithoutSignal", name } as any); + } + + sig.set(() => newValue); + + def.body = body; + def.raw_body = raw_body; + def.lastModifiedAt = Date.now(); + + return Result.ok(undefined); + } }