Signal let-expressions

This commit is contained in:
Yura Dupyn 2026-02-13 15:48:01 +01:00
parent 1ad1c8c442
commit 0014f75a22
7 changed files with 185 additions and 12 deletions

View file

@ -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.

View file

@ -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",

View file

@ -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<Value> {
export function eval_signal_expression(program: Program, env: Env, e: SignalExpr): Signal<Value> {
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<Value>,
}
// 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<Value>, e: Expr): Value {
const value = eval_expr(program, Env.nil(), e);
@ -107,6 +162,19 @@ function pair<A, B>(X: Signal<A>, Y: Signal<B>): Signal<[A, B]> {
return Z;
}
function tupleThen<A, B>(Xs: Signal<A>[], f: (values: A[]) => B): Signal<B> {
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 ===

View file

@ -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);
}

View file

@ -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 {

View file

@ -342,7 +342,6 @@ export function isNextTokenExprStart(cursor: Cursor): boolean {
case "signal":
case "fn-signal":
case "=":
case "=":
case "|":
case "!":
return false;

View file

@ -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<void> {
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<void> {
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);
}
}