import { MalformedRuleError, RuleTypeError, UnknownNodeError } from './errors'; import { parseChartRule } from './schema'; import { type ComparisonOperandNode, type NumericOperandNode, type RuleChannel, type RuleContext, type RuleNode, type RuleValue, } from './types'; /** * Resolve a rule to a value against `ctx`. Accepts a `{$rule}` placeholder or a * bare node. A malformed or unknown node throws a `ChartRuleError` (never a * native `TypeError`), so a caller reading an untrusted, deserialized rule can * catch every failure with one type. When the rule is a colour placeholder, the * result is checked to be a colour (`string | null`) — a rule that resolves to a * number or boolean is a `RuleTypeError`, not something the renderer paints. * * Pure and non-recursive: the only inputs are the validated flat rule and the * context. No clock, randomness, callbacks, or I/O. */ export function evaluate(rule: unknown, ctx: RuleContext): RuleValue { const parsedRule = parseChartRule(rule); if ('$rule' in parsedRule) { return coerceForChannel(parsedRule.channel, visit(parsedRule.$rule, ctx)); } return visit(parsedRule, ctx); } function visit(node: RuleNode, ctx: RuleContext): RuleValue { switch (node.kind) { case 'value': return ctx.value; case 'literal': return node.value; case 'equals': return operand(node.left, ctx) === operand(node.right, ctx); case 'isNot': return operand(node.left, ctx) !== operand(node.right, ctx); case 'lessThan': return numericOperand(node.left, ctx) < numericOperand(node.right, ctx); case 'greaterThan': return numericOperand(node.left, ctx) > numericOperand(node.right, ctx); case 'lessOrEqual': return numericOperand(node.left, ctx) <= numericOperand(node.right, ctx); case 'greaterOrEqual': return numericOperand(node.left, ctx) >= numericOperand(node.right, ctx); case 'ifElse': return visit(node.cond, ctx) ? node.then.value : node.else.value; default: return unreachable(node); } } /* -------------------------------------------------------------------------- */ /* Coercions — narrow a RuleValue, or throw a typed error. */ /* -------------------------------------------------------------------------- */ function operand(node: ComparisonOperandNode, ctx: RuleContext): RuleValue { return node.kind === 'value' ? ctx.value : node.value; } function numericOperand(node: NumericOperandNode, ctx: RuleContext): number { return node.kind === 'value' ? ctx.value : node.value; } /** A colour channel must resolve to a colour string, or null for "no override". */ function asColor(value: RuleValue): RuleValue { if (value === null || typeof value === 'string') { return value; } throw new RuleTypeError(`a colour rule must resolve to a colour string or null, got ${typeName(value)}`); } /** * Coerce a resolved value to what its channel can use. A colour rule must * resolve to a colour; a future channel would add its own case here. A * placeholder whose `channel` is missing or unrecognised (only possible from * untrusted, deserialized input — the type and schema both pin it to `'color'`) * is malformed rather than silently unchecked. */ function coerceForChannel(channel: RuleChannel, value: RuleValue): RuleValue { switch (channel) { case 'color': return asColor(value); default: throw new MalformedRuleError(`a rule placeholder has an unsupported channel: ${String(channel)}`); } } function typeName(value: RuleValue): string { return value === null ? 'null' : typeof value; } function unreachable(node: never): never { throw new UnknownNodeError((node as { kind?: string }).kind); }