/** * 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; // --------------------------------------------------------------------------- // Internal state // --------------------------------------------------------------------------- /** Dependency graph: $dep → Set<$declaredVar> */ let depGraph: Map> | null = null; /** Reverse: $declaredVar → its expression */ let declExprs: Map | null = null; /** Tag modifiers: #tag → [{target, expression}] */ let tagModMap: Map> | null = null; /** Set of $vars currently being re-evaluated (cycle guard) */ const inFlight = new Set(); // --------------------------------------------------------------------------- // 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 initial values for all declared variables. * Called once after initReactivity() to seed the store. * Unset dependencies default to 0. */ export function computeInitialValues( currentVars: VariableStore, ): Array<{ key: string; value: string }> { if (!declExprs) return []; const allKeys = [...declExprs.keys()]; const sorted = topoSortAffected(new Set(allKeys)); const results: Array<{ key: string; value: string }> = []; const localVars = { ...currentVars }; for (const key of sorted) { const expr = declExprs.get(key); if (!expr) continue; try { const result = evaluateExpression(expr, { lookup: (name: string) => { const k = "$" + name; return localVars[k] ?? undefined; }, }); const newValue = String(result.value); const finalValue = applyTagModifiersTo(key, newValue, localVars); if (finalValue !== (localVars[key] ?? "")) { localVars[key] = finalValue; results.push({ key, value: finalValue }); } } catch { // skip failed evaluations at init time } } 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(); 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[] { if (!depGraph || !declExprs) return [...affected]; const result: string[] = []; const visited = new Set(); const temp = new Set(); 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(); 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); } }