476 lines
13 KiB
TypeScript
476 lines
13 KiB
TypeScript
|
|
/**
|
||
|
|
* Variable reactivity engine — tracks variable declarations and tag
|
||
|
|
* modifiers, then cascades changes when variables are set.
|
||
|
|
*
|
||
|
|
* Runs client-side (in the sender's tab, via command-dispatcher).
|
||
|
|
* When a variable changes, it:
|
||
|
|
* 1. Sends the base var message
|
||
|
|
* 2. Detects tag activation/deactivation diff
|
||
|
|
* 3. Re-evaluates declared variables whose dependencies changed
|
||
|
|
* 4. Applies/removes tag modifiers on affected targets
|
||
|
|
* 5. Sends var messages for every derived change
|
||
|
|
*
|
||
|
|
* Circular dependency detection happens at registration time
|
||
|
|
* (topological sort). At runtime, we guard against re-entrant
|
||
|
|
* evaluation with an in-flight set.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { VarDeclaration, TagModifier } from "./declare-parser";
|
||
|
|
import { evaluateExpression } from "./variable-expression";
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Types
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
export interface VarReactivityState {
|
||
|
|
/** All variable declarations (from role=declare blocks) */
|
||
|
|
declarations: VarDeclaration[];
|
||
|
|
/** All tag modifiers (from role=declare blocks) */
|
||
|
|
tagModifiers: TagModifier[];
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The runtime variable store (read from stream state). */
|
||
|
|
export type VariableStore = Record<string, string>;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Internal state
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/** Dependency graph: $dep → Set<$declaredVar> */
|
||
|
|
let depGraph: Map<string, Set<string>> | null = null;
|
||
|
|
|
||
|
|
/** Reverse: $declaredVar → its expression */
|
||
|
|
let declExprs: Map<string, string> | null = null;
|
||
|
|
|
||
|
|
/** Tag modifiers: #tag → [{target, expression}] */
|
||
|
|
let tagModMap: Map<string, Array<{ target: string; expression: string }>> | null = null;
|
||
|
|
|
||
|
|
/** Set of $vars currently being re-evaluated (cycle guard) */
|
||
|
|
const inFlight = new Set<string>();
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Public API
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Initialize (or re-initialize) the reactivity engine from declarations
|
||
|
|
* and tag modifiers parsed from role=declare blocks.
|
||
|
|
*
|
||
|
|
* Throws if a circular dependency is detected.
|
||
|
|
*/
|
||
|
|
export function initReactivity(state: VarReactivityState): void {
|
||
|
|
depGraph = new Map();
|
||
|
|
declExprs = new Map();
|
||
|
|
tagModMap = new Map();
|
||
|
|
|
||
|
|
// Index tag modifiers
|
||
|
|
for (const tm of state.tagModifiers) {
|
||
|
|
let list = tagModMap.get(tm.tag);
|
||
|
|
if (!list) {
|
||
|
|
list = [];
|
||
|
|
tagModMap.set(tm.tag, list);
|
||
|
|
}
|
||
|
|
list.push({ target: tm.target, expression: tm.expression });
|
||
|
|
}
|
||
|
|
|
||
|
|
// Index declarations and build dependency graph
|
||
|
|
for (const decl of state.declarations) {
|
||
|
|
declExprs.set(decl.key, decl.expression);
|
||
|
|
const deps = extractDependencies(decl.expression);
|
||
|
|
for (const dep of deps) {
|
||
|
|
let dependents = depGraph.get(dep);
|
||
|
|
if (!dependents) {
|
||
|
|
dependents = new Set();
|
||
|
|
depGraph.set(dep, dependents);
|
||
|
|
}
|
||
|
|
dependents.add(decl.key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Circular dependency check
|
||
|
|
checkCircular();
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Extract $var names from an expression string. */
|
||
|
|
export function extractDependencies(expr: string): string[] {
|
||
|
|
const vars: string[] = [];
|
||
|
|
// Match $identifier patterns (not inside function names, just bare $var)
|
||
|
|
const re = /\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
||
|
|
let m: RegExpExecArray | null;
|
||
|
|
while ((m = re.exec(expr)) !== null) {
|
||
|
|
const name = "$" + m[1];
|
||
|
|
if (!vars.includes(name)) vars.push(name);
|
||
|
|
}
|
||
|
|
return vars;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute which declared variables need re-evaluation after
|
||
|
|
* a set of base variables changed, and what tag modifier effects
|
||
|
|
* to apply.
|
||
|
|
*
|
||
|
|
* Returns the list of { key, value } pairs to publish as var messages.
|
||
|
|
* The caller is responsible for sending these via sendMessage.
|
||
|
|
*
|
||
|
|
* `changedVar` is the variable that was just set (e.g. "$con").
|
||
|
|
* `oldValue` is its previous value (for detecting tag transitions).
|
||
|
|
* `currentVars` is the full variable store.
|
||
|
|
*/
|
||
|
|
export function computeCascade(
|
||
|
|
changedVar: string,
|
||
|
|
oldValue: string | undefined,
|
||
|
|
currentVars: VariableStore,
|
||
|
|
): Array<{ key: string; value: string }> {
|
||
|
|
if (!depGraph || !declExprs || !tagModMap) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
const results: Array<{ key: string; value: string }> = [];
|
||
|
|
|
||
|
|
// ---- Tag activation/deactivation ----
|
||
|
|
const newTag = isTagValue(currentVars[changedVar]);
|
||
|
|
const oldTag = isTagValue(oldValue);
|
||
|
|
|
||
|
|
if (newTag !== oldTag) {
|
||
|
|
// Deactivate old tag
|
||
|
|
if (oldTag) {
|
||
|
|
const removed = computeTagDeactivation(
|
||
|
|
oldTag,
|
||
|
|
currentVars,
|
||
|
|
);
|
||
|
|
for (const r of removed) {
|
||
|
|
results.push(r);
|
||
|
|
// Update currentVars in-place so subsequent steps see new values
|
||
|
|
currentVars = { ...currentVars, [r.key]: r.value };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Activate new tag
|
||
|
|
if (newTag) {
|
||
|
|
const added = computeTagActivation(
|
||
|
|
newTag,
|
||
|
|
currentVars,
|
||
|
|
);
|
||
|
|
for (const r of added) {
|
||
|
|
results.push(r);
|
||
|
|
currentVars = { ...currentVars, [r.key]: r.value };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- Declaration re-evaluation ----
|
||
|
|
const reevaluated = reevaluateDependents(
|
||
|
|
changedVar,
|
||
|
|
currentVars,
|
||
|
|
);
|
||
|
|
for (const r of reevaluated) {
|
||
|
|
// Avoid re-sending the same key if it was already in tag results
|
||
|
|
if (!results.some((x) => x.key === r.key)) {
|
||
|
|
results.push(r);
|
||
|
|
currentVars = { ...currentVars, [r.key]: r.value };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute which tags are active given the current variable store.
|
||
|
|
* A tag is active if any variable has that tag as its value.
|
||
|
|
*/
|
||
|
|
export function computeActiveTags(vars: VariableStore): string[] {
|
||
|
|
const active: string[] = [];
|
||
|
|
for (const value of Object.values(vars)) {
|
||
|
|
if (isTagValue(value) && !active.includes(value)) {
|
||
|
|
active.push(value);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return active;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Internal helpers
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
function isTagValue(value: string | undefined): string | null {
|
||
|
|
if (!value) return null;
|
||
|
|
const trimmed = value.trim();
|
||
|
|
return trimmed.startsWith("#") ? trimmed : null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Walk the dependency graph from `changedVar` and re-evaluate all
|
||
|
|
* affected declarations. Uses topological order.
|
||
|
|
*/
|
||
|
|
function reevaluateDependents(
|
||
|
|
changedVar: string,
|
||
|
|
vars: VariableStore,
|
||
|
|
): Array<{ key: string; value: string }> {
|
||
|
|
if (!depGraph || !declExprs) return [];
|
||
|
|
|
||
|
|
// Collect all dependents reachable from changedVar (BFS)
|
||
|
|
const affected = new Set<string>();
|
||
|
|
const queue = [changedVar];
|
||
|
|
while (queue.length > 0) {
|
||
|
|
const dep = queue.shift()!;
|
||
|
|
const dependents = depGraph.get(dep);
|
||
|
|
if (!dependents) continue;
|
||
|
|
for (const d of dependents) {
|
||
|
|
if (!affected.has(d)) {
|
||
|
|
affected.add(d);
|
||
|
|
queue.push(d); // transitive: if C depends on B which depends on A...
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Topological sort: declarations that depend only on base vars go first
|
||
|
|
const sorted = topoSortAffected(affected);
|
||
|
|
|
||
|
|
const results: Array<{ key: string; value: string }> = [];
|
||
|
|
const localVars = { ...vars }; // working copy
|
||
|
|
|
||
|
|
for (const key of sorted) {
|
||
|
|
if (inFlight.has(key)) {
|
||
|
|
// Shouldn't happen (caught at init), but guard anyway
|
||
|
|
throw new Error(`Circular dependency detected during evaluation of ${key}`);
|
||
|
|
}
|
||
|
|
inFlight.add(key);
|
||
|
|
|
||
|
|
try {
|
||
|
|
const expr = declExprs.get(key);
|
||
|
|
if (!expr) continue;
|
||
|
|
|
||
|
|
// Evaluate with current localVars (which includes previously
|
||
|
|
// re-evaluated dependents)
|
||
|
|
const result = evaluateExpression(expr, {
|
||
|
|
lookup: (name: string) => {
|
||
|
|
const k = "$" + name;
|
||
|
|
return localVars[k] ?? undefined;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const newValue = String(result.value);
|
||
|
|
|
||
|
|
// Apply any active tag modifiers to this key
|
||
|
|
const finalValue = applyTagModifiersTo(key, newValue, localVars);
|
||
|
|
|
||
|
|
if (finalValue !== localVars[key]) {
|
||
|
|
localVars[key] = finalValue;
|
||
|
|
results.push({ key, value: finalValue });
|
||
|
|
}
|
||
|
|
} finally {
|
||
|
|
inFlight.delete(key);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Apply all active tag modifiers that target `key`.
|
||
|
|
* Returns the modified value (base + sum of active modifiers).
|
||
|
|
*/
|
||
|
|
function applyTagModifiersTo(
|
||
|
|
key: string,
|
||
|
|
baseValue: string,
|
||
|
|
vars: VariableStore,
|
||
|
|
): string {
|
||
|
|
if (!tagModMap) return baseValue;
|
||
|
|
|
||
|
|
const activeTags = computeActiveTags(vars);
|
||
|
|
const baseNum = parseFloat(baseValue);
|
||
|
|
if (isNaN(baseNum)) return baseValue; // non-numeric, can't apply modifiers
|
||
|
|
|
||
|
|
let modifierSum = 0;
|
||
|
|
for (const tag of activeTags) {
|
||
|
|
const mods = tagModMap.get(tag);
|
||
|
|
if (!mods) continue;
|
||
|
|
for (const mod of mods) {
|
||
|
|
if (mod.target === key) {
|
||
|
|
try {
|
||
|
|
const result = evaluateExpression(mod.expression, {
|
||
|
|
lookup: (name: string) => {
|
||
|
|
const k = "$" + name;
|
||
|
|
return vars[k] ?? undefined;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
modifierSum += result.value;
|
||
|
|
} catch {
|
||
|
|
// If modifier expression fails, skip it
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return String(baseNum + modifierSum);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute the effects of activating a tag: for each modifier,
|
||
|
|
* evaluate and add to the target variable's current value.
|
||
|
|
*/
|
||
|
|
function computeTagActivation(
|
||
|
|
tag: string,
|
||
|
|
vars: VariableStore,
|
||
|
|
): Array<{ key: string; value: string }> {
|
||
|
|
if (!tagModMap) return [];
|
||
|
|
|
||
|
|
const mods = tagModMap.get(tag);
|
||
|
|
if (!mods) return [];
|
||
|
|
|
||
|
|
const results: Array<{ key: string; value: string }> = [];
|
||
|
|
const localVars = { ...vars };
|
||
|
|
|
||
|
|
for (const mod of mods) {
|
||
|
|
try {
|
||
|
|
const result = evaluateExpression(mod.expression, {
|
||
|
|
lookup: (name: string) => {
|
||
|
|
const k = "$" + name;
|
||
|
|
return localVars[k] ?? undefined;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const currentBase = parseFloat(localVars[mod.target] ?? "0");
|
||
|
|
if (!isNaN(currentBase)) {
|
||
|
|
const newValue = String(currentBase + result.value);
|
||
|
|
localVars[mod.target] = newValue;
|
||
|
|
results.push({ key: mod.target, value: newValue });
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// skip failed modifier
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Compute the effects of deactivating a tag: for each modifier,
|
||
|
|
* subtract from the target variable's current value.
|
||
|
|
*/
|
||
|
|
function computeTagDeactivation(
|
||
|
|
tag: string,
|
||
|
|
vars: VariableStore,
|
||
|
|
): Array<{ key: string; value: string }> {
|
||
|
|
if (!tagModMap) return [];
|
||
|
|
|
||
|
|
const mods = tagModMap.get(tag);
|
||
|
|
if (!mods) return [];
|
||
|
|
|
||
|
|
const results: Array<{ key: string; value: string }> = [];
|
||
|
|
const localVars = { ...vars };
|
||
|
|
|
||
|
|
for (const mod of mods) {
|
||
|
|
try {
|
||
|
|
const result = evaluateExpression(mod.expression, {
|
||
|
|
lookup: (name: string) => {
|
||
|
|
const k = "$" + name;
|
||
|
|
return localVars[k] ?? undefined;
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const currentValue = parseFloat(localVars[mod.target] ?? "0");
|
||
|
|
if (!isNaN(currentValue)) {
|
||
|
|
const newValue = String(currentValue - result.value);
|
||
|
|
localVars[mod.target] = newValue;
|
||
|
|
results.push({ key: mod.target, value: newValue });
|
||
|
|
}
|
||
|
|
} catch {
|
||
|
|
// skip failed modifier
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return results;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Topological sort & circular check
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Topologically sort a set of affected declaration keys so that
|
||
|
|
* dependents are evaluated after their dependencies.
|
||
|
|
*/
|
||
|
|
function topoSortAffected(affected: Set<string>): string[] {
|
||
|
|
if (!depGraph || !declExprs) return [...affected];
|
||
|
|
|
||
|
|
const result: string[] = [];
|
||
|
|
const visited = new Set<string>();
|
||
|
|
const temp = new Set<string>();
|
||
|
|
|
||
|
|
function visit(key: string): void {
|
||
|
|
if (visited.has(key)) return;
|
||
|
|
if (temp.has(key)) {
|
||
|
|
// This shouldn't happen if checkCircular passed, but guard
|
||
|
|
throw new Error(`Circular dependency involving ${key}`);
|
||
|
|
}
|
||
|
|
temp.add(key);
|
||
|
|
|
||
|
|
// Visit dependencies first
|
||
|
|
const expr = declExprs!.get(key);
|
||
|
|
if (expr) {
|
||
|
|
const deps = extractDependencies(expr);
|
||
|
|
for (const dep of deps) {
|
||
|
|
if (affected.has(dep) || declExprs!.has(dep)) {
|
||
|
|
visit(dep);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
temp.delete(key);
|
||
|
|
visited.add(key);
|
||
|
|
result.push(key);
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const key of affected) {
|
||
|
|
visit(key);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check for circular dependencies in the full declaration graph.
|
||
|
|
* Throws with a descriptive message if a cycle is found.
|
||
|
|
*/
|
||
|
|
function checkCircular(): void {
|
||
|
|
if (!declExprs) return;
|
||
|
|
|
||
|
|
const allKeys = [...declExprs.keys()];
|
||
|
|
const state = new Map<string, "unvisited" | "visiting" | "visited">();
|
||
|
|
for (const k of allKeys) state.set(k, "unvisited");
|
||
|
|
|
||
|
|
const path: string[] = [];
|
||
|
|
|
||
|
|
function dfs(key: string): void {
|
||
|
|
const s = state.get(key);
|
||
|
|
if (s === "visited") return;
|
||
|
|
if (s === "visiting") {
|
||
|
|
const cycleStart = path.indexOf(key);
|
||
|
|
const cycle = path.slice(cycleStart).concat(key);
|
||
|
|
throw new Error(
|
||
|
|
`Circular dependency detected: ${cycle.join(" → ")}`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
state.set(key, "visiting");
|
||
|
|
path.push(key);
|
||
|
|
|
||
|
|
const expr = declExprs!.get(key);
|
||
|
|
if (expr) {
|
||
|
|
const deps = extractDependencies(expr);
|
||
|
|
for (const dep of deps) {
|
||
|
|
if (declExprs!.has(dep)) {
|
||
|
|
dfs(dep);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
path.pop();
|
||
|
|
state.set(key, "visited");
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const key of allKeys) {
|
||
|
|
dfs(key);
|
||
|
|
}
|
||
|
|
}
|