import { AbstractState, ConcreteState, OrState, PseudoState, Statechart, Transition } from "./abstract_syntax"; import { findNearestArrow, findNearestSide, findRountangle, Rountangle, VisualEditorState } from "./concrete_syntax"; import { isEntirelyWithin } from "../VisualEditor/geometry"; import { Action, EventTrigger, Expression, ParsedText } from "./label_ast"; import { parse as parseLabel, SyntaxError } from "./label_parser"; export type TraceableError = { shapeUid: string; message: string; data?: any; } function addEvent(events: EventTrigger[], e: EventTrigger, textUid: string) { const haveEvent = events.find(({event}) => event === e.event); if (haveEvent) { if (haveEvent.paramName !== e.paramName === undefined) { return [{ shapeUid: textUid, message: "inconsistent event parameter", }]; } return []; } else { events.push(e); events.sort((a,b) => a.event.localeCompare(b.event)); return []; } } export function parseStatechart(state: VisualEditorState): [Statechart, TraceableError[]] { const errors: TraceableError[] = []; // implicitly, the root is always an Or-state const root: OrState = { kind: "or", uid: "root", children: [], initial: [], comments: [], entryActions: [], exitActions: [], depth: 0, timers: [], } const uid2State = new Map([["root", root]]); // we will always look for the smallest parent rountangle const parentCandidates: Rountangle[] = [{ kind: "or", uid: root.uid, topLeft: {x: -Infinity, y: -Infinity}, size: {x: Infinity, y: Infinity}, }]; const parentLinks = new Map(); // step 1: figure out state hierarchy // IMPORTANT ASSUMPTION: state.rountangles is sorted from big to small surface area: for (const rt of state.rountangles) { const common = { kind: rt.kind, uid: rt.uid, comments: [], entryActions: [], exitActions: [], }; let state; if (rt.kind === "or") { state = { ...common, initial: [], children: [], timers: [], }; } else if (rt.kind === "and") { state = { ...common, children: [], timers: [], }; } // iterate in reverse: for (let i=parentCandidates.length-1; i>=0; i--) { const candidate = parentCandidates[i]; if (candidate.uid === "root" || isEntirelyWithin(rt, candidate)) { // found our parent const parentState = uid2State.get(candidate.uid)! as ConcreteState; parentState.children.push(state as unknown as ConcreteState); parentCandidates.push(rt); parentLinks.set(rt.uid, candidate.uid); state = { ...state, parent: parentState, depth: parentState.depth + 1, } break; } } uid2State.set(rt.uid, state as ConcreteState); } for (const d of state.diamonds) { uid2State.set(d.uid, { kind: "pseudo", uid: d.uid, comments: [], }); } // step 2: figure out transitions const transitions = new Map(); const uid2Transition = new Map(); for (const arr of state.arrows) { const sides = [...state.rountangles, ...state.diamonds]; const srcUID = findNearestSide(arr, "start", sides)?.uid; const tgtUID = findNearestSide(arr, "end", sides)?.uid; if (!srcUID) { if (!tgtUID) { // dangling edge errors.push({shapeUid: arr.uid, message: "dangling"}); } else { // target but no source, so we treat is as an 'initial' marking const tgtState = uid2State.get(tgtUID)!; if (tgtState.kind === "pseudo") { // maybe allow this in the future? errors.push({ shapeUid: arr.uid, message: "pseudo-state cannot be initial state", }); } else { const ofState = uid2State.get(parentLinks.get(tgtUID)!)!; if (ofState.kind === "or") { ofState.initial.push([arr.uid, tgtState]); } else { // and states do not have an 'initial' state errors.push({ shapeUid: arr.uid, message: "AND-state cannot have an initial state", }); } } } } else { if (!tgtUID) { errors.push({ shapeUid: arr.uid, message: "no target", }); } else { // add transition const transition: Transition = { uid: arr.uid, src: uid2State.get(srcUID)!, tgt: uid2State.get(tgtUID)!, label: [], }; const existingTransitions = transitions.get(srcUID) || []; existingTransitions.push(transition); transitions.set(srcUID, existingTransitions); uid2Transition.set(arr.uid, transition); } } } for (const state of uid2State.values()) { if (state.kind === "or") { if (state.initial.length > 1) { errors.push(...state.initial.map(([uid,childState]) => ({ shapeUid: uid, message: "multiple initial states", }))); } else if (state.initial.length === 0) { errors.push({ shapeUid: state.uid, message: "no initial state", }); } } } let variables = new Set(); const inputEvents: EventTrigger[] = []; const internalEvents: EventTrigger[] = []; const outputEvents = new Set(); // step 3: figure out labels const textsSorted = state.texts.toSorted((a,b) => a.topLeft.y - b.topLeft.y); for (const text of textsSorted) { let parsed: ParsedText; try { parsed = parseLabel(text.text); // may throw parsed.uid = text.uid; } catch (e) { if (e instanceof SyntaxError) { errors.push({ shapeUid: text.uid, message: e.message, data: e, }); parsed = { kind: "parserError", uid: text.uid, } } else { throw e; } } const belongsToArrow = findNearestArrow(text.topLeft, state.arrows); if (belongsToArrow) { const belongsToTransition = uid2Transition.get(belongsToArrow.uid); if (belongsToTransition) { const {src} = belongsToTransition; belongsToTransition.label.push(parsed); if (parsed.kind === "transitionLabel") { // collect events // triggers if (parsed.trigger.kind === "event") { if (src.kind === "pseudo") { errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have event trigger"}); } else { const {event} = parsed.trigger; if (event.startsWith("_")) { errors.push(...addEvent(internalEvents, parsed.trigger, parsed.uid)); } else { errors.push(...addEvent(inputEvents, parsed.trigger, parsed.uid)); } } } else if (parsed.trigger.kind === "after") { if (src.kind === "pseudo") { errors.push({shapeUid: text.uid, message: "pseudo state outgoing transition must not have after-trigger"}); } else { src.timers.push(parsed.trigger.durationMs); src.timers.sort(); } } else if (["entry", "exit"].includes(parsed.trigger.kind)) { errors.push({shapeUid: text.uid, message: "entry/exit trigger not allowed on transitions"}); } else if (parsed.trigger.kind === "triggerless") { if (src.kind !== "pseudo") { errors.push({shapeUid: text.uid, message: "triggerless transitions only allowed on pseudo-states"}); } } // // raise-actions // for (const action of parsed.actions) { // if (action.kind === "raise") { // const {event} = action; // if (event.startsWith("_")) { // internalEvents.add(event); // } // else { // outputEvents.add(event); // } // } // } // collect variables variables = variables.union(findVariables(parsed.guard)); for (const action of parsed.actions) { variables = variables.union(findVariablesAction(action)); } } } } else { // text does not belong to transition... // so it belongs to a rountangle (a state) const rountangle = findRountangle(text.topLeft, state.rountangles); const belongsToState = rountangle ? uid2State.get(rountangle.uid)! as ConcreteState : root; if (parsed.kind === "transitionLabel") { // labels belonging to a rountangle (= a state) must by entry/exit actions // if we cannot find a containing state, then it belong to the root if (parsed.trigger.kind === "entry") { belongsToState.entryActions.push(...parsed.actions); } else if(parsed.trigger.kind === "exit") { belongsToState.exitActions.push(...parsed.actions); } else { errors.push({ shapeUid: text.uid, message: "states can only have entry/exit triggers", data: {start: {offset: 0}, end: {offset: text.text.length}}, }); } } else if (parsed.kind === "comment") { // just append comments to their respective states belongsToState.comments.push([text.uid, parsed.text]); } } } for (const transition of uid2Transition.values()) { if (transition.label.length === 0) { errors.push({ shapeUid: transition.uid, message: "no label", }); } else if (transition.label.length > 1) { errors.push({ shapeUid: transition.uid, message: "multiple labels", }); } } return [{ root, transitions, variables, inputEvents, internalEvents, outputEvents, uid2State, }, errors]; } function findVariables(expr: Expression): Set { if (expr.kind === "ref") { return new Set([expr.variable]); } else if (expr.kind === "unaryExpr") { return findVariables(expr.expr); } else if (expr.kind === "binaryExpr") { return findVariables(expr.lhs).union(findVariables(expr.rhs)); } return new Set(); } function findVariablesAction(action: Action): Set { if (action.kind === "assignment") { return new Set([action.lhs, ...findVariables(action.rhs)]); } return new Set(); }