import type { TriggerEventBinding, TriggerExpression, TriggerFunctionDeclaration, TriggerProgramV2,
TriggerStatement, TriggerVariableDeclarationV2 } from '../types';
export type TriggerProgramNode = { category: 'event' | 'declaration' | 'function' | 'statement';
value: TriggerEventBinding | TriggerVariableDeclarationV2 | TriggerFunctionDeclaration | TriggerStatement };
export type TriggerPasteTarget = { parentId: string; branch: 'statements' | 'elseStatements' | 'cases'; caseId?: string };
const marker = 'xfesm-trigger-node-v2';
const maxClipboardCharacters = 1_048_576;
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const text = (value: unknown): value is string => typeof value === 'string';
// Clipboard JSON is untrusted. Check its entire AST before it can reach the recursive editor.
function checker(maxNodes = 4096, maxDepth = 32) {
let nodes = 0;
const node = (value: unknown): value is Record<string, unknown> => object(value)
&& text(value.nodeId) && !!value.nodeId && ++nodes <= maxNodes;
const array = (value: unknown, check: (entry: unknown) => boolean) => Array.isArray(value) && value.every(check);
const expression = (value: unknown, depth = 0): boolean => node(value) && depth <= maxDepth && text(value.name)
&& text(value.valueType) && ['LITERAL', 'REFERENCE', 'UNARY', 'BINARY', 'FUNCTION', 'INDEX', 'COALESCE', 'CONVERT'].includes(String(value.kind))
&& array(value.arguments, (entry) => expression(entry, depth + 1));
const declaration = (value: unknown): boolean => node(value) && text(value.name) && text(value.valueType)
&& ['trigger', 'global'].includes(String(value.visibility)) && text(value.storage)
&& ['SESSION', 'TTL', 'PERSISTENT'].includes(String(value.lifetime))
&& (value.ttlSeconds == null || typeof value.ttlSeconds === 'number' && Number.isFinite(value.ttlSeconds))
&& (value.initialValue == null || expression(value.initialValue));
const statement = (value: unknown, depth = 0): boolean => node(value) && depth <= maxDepth && text(value.name)
&& ['ACTION', 'SET', 'IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'BREAK', 'CONTINUE', 'CALL', 'RETURN', 'TRY'].includes(String(value.kind))
&& object(value.inputs) && Object.values(value.inputs).every((entry) => expression(entry))
&& (value.expression == null || expression(value.expression))
&& array(value.statements, (entry) => statement(entry, depth + 1))
&& array(value.elseStatements, (entry) => statement(entry, depth + 1))
&& array(value.cases, (entry) => node(entry) && expression(entry.match)
&& array(entry.statements, (child) => statement(child, depth + 1)));
const event = (value: unknown): boolean => node(value) && text(value.type) && object(value.configuration)
&& Object.values(value.configuration).every(text);
const fn = (value: unknown): boolean => node(value) && text(value.name) && text(value.returnType)
&& array(value.parameters, (entry) => object(entry) && text(entry.name) && text(entry.valueType)
&& ['IN', 'OUT', 'INOUT'].includes(String(entry.mode)))
&& array(value.locals, declaration) && array(value.statements, (entry) => statement(entry));
return { event, declaration, function: fn, statement };
}
export function serializeTriggerNode(node: TriggerProgramNode): string {
return JSON.stringify({ marker, ...node });
}
export function parseTriggerNode(source: string): TriggerProgramNode | undefined {
if (!source || source.length > maxClipboardCharacters) return undefined;
try {
const value: unknown = JSON.parse(source);
if (!object(value) || value.marker !== marker || !['event', 'declaration', 'function', 'statement'].includes(String(value.category))) return undefined;
const category = value.category as TriggerProgramNode['category'];
return checker()[category](value.value) ? { category, value: value.value as TriggerProgramNode['value'] } : undefined;
} catch { return undefined; }
}
export function triggerProgramFitsLimits(program: TriggerProgramV2, maxNodes = 4096, maxDepth = 32): boolean {
const check = checker(maxNodes, maxDepth);
return program.events.length > 0 && program.events.length <= 32 && program.events.every(check.event)
&& program.declarations.every(check.declaration) && program.functions.every(check.function)
&& program.statements.every((entry) => check.statement(entry));
}
function freshId(): string {
return globalThis.crypto?.randomUUID?.() ?? 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
const value = Math.floor(Math.random() * 16); return (character === 'x' ? value : value & 3 | 8).toString(16);
});
}
function cloneNode(node: TriggerProgramNode): TriggerProgramNode {
const clone = structuredClone(node);
const expression = (value?: TriggerExpression) => {
if (!value) return;
value.nodeId = freshId(); value.arguments.forEach(expression);
// Literal records may contain an ordinary field named nodeId; that is user data, not an AST ID.
};
const declaration = (value: TriggerVariableDeclarationV2) => { value.nodeId = freshId(); expression(value.initialValue); };
const statement = (value: TriggerStatement) => {
value.nodeId = freshId(); expression(value.expression); Object.values(value.inputs).forEach(expression);
value.statements.forEach(statement); value.elseStatements.forEach(statement);
value.cases.forEach((branch) => { branch.nodeId = freshId(); expression(branch.match); branch.statements.forEach(statement); });
};
if (clone.category === 'statement') statement(clone.value as TriggerStatement);
else if (clone.category === 'declaration') declaration(clone.value as TriggerVariableDeclarationV2);
else if (clone.category === 'function') {
const fn = clone.value as TriggerFunctionDeclaration;
fn.nodeId = freshId(); fn.locals.forEach(declaration); fn.statements.forEach(statement);
} else clone.value.nodeId = freshId();
return clone;
}
function insertAfter<T extends { nodeId: string }>(items: T[], selectedId: string, value: T): T[] {
const index = items.findIndex((entry) => entry.nodeId === selectedId);
return index < 0 ? [...items, value] : [...items.slice(0, index + 1), value, ...items.slice(index + 1)];
}
function availableName(name: string, existing: string[], maxLength: number): string {
if (!existing.includes(name)) return name;
const stem = name.slice(0, maxLength - 12);
let next = `${stem}_copy`; let index = 2;
while (existing.includes(next)) next = `${stem}_copy${index++}`;
return next;
}
export function insertTriggerNode(program: TriggerProgramV2, source: TriggerProgramNode, selectedId: string,
target?: TriggerPasteTarget): { program: TriggerProgramV2; nodeId: string } {
const copy = cloneNode(source);
let next = program;
if (copy.category === 'event') next = { ...program, events: insertAfter(program.events, selectedId, copy.value as TriggerEventBinding) };
if (copy.category === 'declaration') {
const value = copy.value as TriggerVariableDeclarationV2;
value.name = availableName(value.name, program.declarations.map((entry) => entry.name), 48);
next = { ...program, declarations: insertAfter(program.declarations, selectedId, value) };
}
if (copy.category === 'function') {
const value = copy.value as TriggerFunctionDeclaration;
const previousName = value.name;
value.name = availableName(value.name, program.functions.map((entry) => entry.name), 128);
const renameSelfCalls = (statements: TriggerStatement[]) => statements.forEach((statement) => {
if (statement.kind === 'CALL' && statement.name === previousName) statement.name = value.name;
renameSelfCalls(statement.statements); renameSelfCalls(statement.elseStatements);
statement.cases.forEach((branch) => renameSelfCalls(branch.statements));
});
renameSelfCalls(value.statements);
next = { ...program, functions: insertAfter(program.functions, selectedId, value) };
}
if (copy.category === 'statement') {
const value = copy.value as TriggerStatement;
let inserted = false;
const insert = (statements: TriggerStatement[]): TriggerStatement[] => statements.flatMap((entry) => {
if (!target && entry.nodeId === selectedId) { inserted = true; return [entry, value]; }
const parent = target?.parentId === entry.nodeId;
const branch = (key: 'statements' | 'elseStatements') => {
if (parent && target.branch === key) { inserted = true; return [...entry[key], value]; }
return insert(entry[key]);
};
return [{ ...entry, statements: branch('statements'), elseStatements: branch('elseStatements'),
cases: entry.cases.map((caseBranch) => {
if (parent && target.branch === 'cases' && target.caseId === caseBranch.nodeId) {
inserted = true; return { ...caseBranch, statements: [...caseBranch.statements, value] };
}
return { ...caseBranch, statements: insert(caseBranch.statements) };
}) }];
});
const statements = insert(program.statements);
const functions = program.functions.map((fn) => {
if (!target && fn.nodeId === selectedId) { inserted = true; return { ...fn, statements: [...fn.statements, value] }; }
return { ...fn, statements: insert(fn.statements) };
});
next = { ...program, statements: inserted ? statements : [...statements, value], functions };
}
return { program: next, nodeId: copy.value.nodeId };
}
import type { TriggerEventBinding, TriggerExpression, TriggerFunctionDeclaration, TriggerProgramV2,
TriggerStatement, TriggerVariableDeclarationV2 } from '../types';
export type TriggerProgramNode = { category: 'event' | 'declaration' | 'function' | 'statement';
value: TriggerEventBinding | TriggerVariableDeclarationV2 | TriggerFunctionDeclaration | TriggerStatement };
export type TriggerPasteTarget = { parentId: string; branch: 'statements' | 'elseStatements' | 'cases'; caseId?: string };
const marker = 'xfesm-trigger-node-v2';
const maxClipboardCharacters = 1_048_576;
const object = (value: unknown): value is Record<string, unknown> => !!value && typeof value === 'object' && !Array.isArray(value);
const text = (value: unknown): value is string => typeof value === 'string';
// Clipboard JSON is untrusted. Check its entire AST before it can reach the recursive editor.
function checker(maxNodes = 4096, maxDepth = 32) {
let nodes = 0;
const node = (value: unknown): value is Record<string, unknown> => object(value)
&& text(value.nodeId) && !!value.nodeId && ++nodes <= maxNodes;
const array = (value: unknown, check: (entry: unknown) => boolean) => Array.isArray(value) && value.every(check);
const expression = (value: unknown, depth = 0): boolean => node(value) && depth <= maxDepth && text(value.name)
&& text(value.valueType) && ['LITERAL', 'REFERENCE', 'UNARY', 'BINARY', 'FUNCTION', 'INDEX', 'COALESCE', 'CONVERT'].includes(String(value.kind))
&& array(value.arguments, (entry) => expression(entry, depth + 1));
const declaration = (value: unknown): boolean => node(value) && text(value.name) && text(value.valueType)
&& ['trigger', 'global'].includes(String(value.visibility)) && text(value.storage)
&& ['SESSION', 'TTL', 'PERSISTENT'].includes(String(value.lifetime))
&& (value.ttlSeconds == null || typeof value.ttlSeconds === 'number' && Number.isFinite(value.ttlSeconds))
&& (value.initialValue == null || expression(value.initialValue));
const statement = (value: unknown, depth = 0): boolean => node(value) && depth <= maxDepth && text(value.name)
&& ['ACTION', 'SET', 'IF', 'SWITCH', 'REPEAT', 'WHILE', 'FOREACH', 'BREAK', 'CONTINUE', 'CALL', 'RETURN', 'TRY'].includes(String(value.kind))
&& object(value.inputs) && Object.values(value.inputs).every((entry) => expression(entry))
&& (value.expression == null || expression(value.expression))
&& array(value.statements, (entry) => statement(entry, depth + 1))
&& array(value.elseStatements, (entry) => statement(entry, depth + 1))
&& array(value.cases, (entry) => node(entry) && expression(entry.match)
&& array(entry.statements, (child) => statement(child, depth + 1)));
const event = (value: unknown): boolean => node(value) && text(value.type) && object(value.configuration)
&& Object.values(value.configuration).every(text);
const fn = (value: unknown): boolean => node(value) && text(value.name) && text(value.returnType)
&& array(value.parameters, (entry) => object(entry) && text(entry.name) && text(entry.valueType)
&& ['IN', 'OUT', 'INOUT'].includes(String(entry.mode)))
&& array(value.locals, declaration) && array(value.statements, (entry) => statement(entry));
return { event, declaration, function: fn, statement };
}
export function serializeTriggerNode(node: TriggerProgramNode): string {
return JSON.stringify({ marker, ...node });
}
export function parseTriggerNode(source: string): TriggerProgramNode | undefined {
if (!source || source.length > maxClipboardCharacters) return undefined;
try {
const value: unknown = JSON.parse(source);
if (!object(value) || value.marker !== marker || !['event', 'declaration', 'function', 'statement'].includes(String(value.category))) return undefined;
const category = value.category as TriggerProgramNode['category'];
return checker()[category](value.value) ? { category, value: value.value as TriggerProgramNode['value'] } : undefined;
} catch { return undefined; }
}
export function triggerProgramFitsLimits(program: TriggerProgramV2, maxNodes = 4096, maxDepth = 32): boolean {
const check = checker(maxNodes, maxDepth);
return program.events.length > 0 && program.events.length <= 32 && program.events.every(check.event)
&& program.declarations.every(check.declaration) && program.functions.every(check.function)
&& program.statements.every((entry) => check.statement(entry));
}
function freshId(): string {
return globalThis.crypto?.randomUUID?.() ?? 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
const value = Math.floor(Math.random() * 16); return (character === 'x' ? value : value & 3 | 8).toString(16);
});
}
function cloneNode(node: TriggerProgramNode): TriggerProgramNode {
const clone = structuredClone(node);
const expression = (value?: TriggerExpression) => {
if (!value) return;
value.nodeId = freshId(); value.arguments.forEach(expression);
// Literal records may contain an ordinary field named nodeId; that is user data, not an AST ID.
};
const declaration = (value: TriggerVariableDeclarationV2) => { value.nodeId = freshId(); expression(value.initialValue); };
const statement = (value: TriggerStatement) => {
value.nodeId = freshId(); expression(value.expression); Object.values(value.inputs).forEach(expression);
value.statements.forEach(statement); value.elseStatements.forEach(statement);
value.cases.forEach((branch) => { branch.nodeId = freshId(); expression(branch.match); branch.statements.forEach(statement); });
};
if (clone.category === 'statement') statement(clone.value as TriggerStatement);
else if (clone.category === 'declaration') declaration(clone.value as TriggerVariableDeclarationV2);
else if (clone.category === 'function') {
const fn = clone.value as TriggerFunctionDeclaration;
fn.nodeId = freshId(); fn.locals.forEach(declaration); fn.statements.forEach(statement);
} else clone.value.nodeId = freshId();
return clone;
}
function insertAfter<T extends { nodeId: string }>(items: T[], selectedId: string, value: T): T[] {
const index = items.findIndex((entry) => entry.nodeId === selectedId);
return index < 0 ? [...items, value] : [...items.slice(0, index + 1), value, ...items.slice(index + 1)];
}
function availableName(name: string, existing: string[], maxLength: number): string {
if (!existing.includes(name)) return name;
const stem = name.slice(0, maxLength - 12);
let next = `${stem}_copy`; let index = 2;
while (existing.includes(next)) next = `${stem}_copy${index++}`;
return next;
}
export function insertTriggerNode(program: TriggerProgramV2, source: TriggerProgramNode, selectedId: string,
target?: TriggerPasteTarget): { program: TriggerProgramV2; nodeId: string } {
const copy = cloneNode(source);
let next = program;
if (copy.category === 'event') next = { ...program, events: insertAfter(program.events, selectedId, copy.value as TriggerEventBinding) };
if (copy.category === 'declaration') {
const value = copy.value as TriggerVariableDeclarationV2;
value.name = availableName(value.name, program.declarations.map((entry) => entry.name), 48);
next = { ...program, declarations: insertAfter(program.declarations, selectedId, value) };
}
if (copy.category === 'function') {
const value = copy.value as TriggerFunctionDeclaration;
const previousName = value.name;
value.name = availableName(value.name, program.functions.map((entry) => entry.name), 128);
const renameSelfCalls = (statements: TriggerStatement[]) => statements.forEach((statement) => {
if (statement.kind === 'CALL' && statement.name === previousName) statement.name = value.name;
renameSelfCalls(statement.statements); renameSelfCalls(statement.elseStatements);
statement.cases.forEach((branch) => renameSelfCalls(branch.statements));
});
renameSelfCalls(value.statements);
next = { ...program, functions: insertAfter(program.functions, selectedId, value) };
}
if (copy.category === 'statement') {
const value = copy.value as TriggerStatement;
let inserted = false;
const insert = (statements: TriggerStatement[]): TriggerStatement[] => statements.flatMap((entry) => {
if (!target && entry.nodeId === selectedId) { inserted = true; return [entry, value]; }
const parent = target?.parentId === entry.nodeId;
const branch = (key: 'statements' | 'elseStatements') => {
if (parent && target.branch === key) { inserted = true; return [...entry[key], value]; }
return insert(entry[key]);
};
return [{ ...entry, statements: branch('statements'), elseStatements: branch('elseStatements'),
cases: entry.cases.map((caseBranch) => {
if (parent && target.branch === 'cases' && target.caseId === caseBranch.nodeId) {
inserted = true; return { ...caseBranch, statements: [...caseBranch.statements, value] };
}
return { ...caseBranch, statements: insert(caseBranch.statements) };
}) }];
});
const statements = insert(program.statements);
const functions = program.functions.map((fn) => {
if (!target && fn.nodeId === selectedId) { inserted = true; return { ...fn, statements: [...fn.statements, value] }; }
return { ...fn, statements: insert(fn.statements) };
});
next = { ...program, statements: inserted ? statements : [...statements, value], functions };
}
return { program: next, nodeId: copy.value.nodeId };
}