iobroker.statechart 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,106 +0,0 @@
1
- "use strict";
2
-
3
- /*
4
- * The structure of a chart (hierarchy, transition order, kind of transitions) as the runtime executes it.
5
- * Shared by the runtime and the code generator, so the generated code always shows what is executed.
6
- */
7
-
8
- /**
9
- * @typedef {import("./chart-model").Chart} Chart
10
- * @typedef {import("./chart-model").ChartState} ChartState
11
- * @typedef {import("./chart-model").ChartTransition} ChartTransition
12
- */
13
-
14
- class ChartStructure {
15
- /**
16
- * @param {Chart} chart
17
- */
18
- constructor(chart) {
19
- this.chart = chart;
20
- /** @type {Map<string, ChartState>} */
21
- this.statesById = new Map(chart.states.map(s => [s.id, s]));
22
- /** @type {Map<string, ChartState[]>} children by parent ID, "" for the top level */
23
- this.childrenByParent = new Map();
24
- for (const state of chart.states) {
25
- // states with an unknown parent are treated like top level states
26
- const parent = state.parent && this.statesById.has(state.parent) ? state.parent : "";
27
- this.childrenByParent.set(parent, [...(this.childrenByParent.get(parent) || []), state]);
28
- }
29
- /** @type {Map<string, ChartTransition[]>} transitions by source, sorted by priority */
30
- this.transitionsBySource = new Map();
31
- for (const transition of chart.transitions) {
32
- const list = this.transitionsBySource.get(transition.source) || [];
33
- this.transitionsBySource.set(transition.source, [...list, transition]);
34
- }
35
- for (const list of this.transitionsBySource.values()) {
36
- // the sort is stable: transitions without priority keep their order, after the others
37
- list.sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));
38
- }
39
- }
40
-
41
- /**
42
- * @param {string | null} parentId null for the top level
43
- * @returns {ChartState[]}
44
- */
45
- getChildren(parentId) {
46
- return this.childrenByParent.get(parentId || "") || [];
47
- }
48
-
49
- /**
50
- * The initial state of a level: the configured one if it is a child of this level, otherwise the first child
51
- *
52
- * @param {string | null} parentId null for the top level
53
- * @returns {string | undefined} undefined if the level has no states
54
- */
55
- getInitialChild(parentId) {
56
- const children = this.getChildren(parentId);
57
- const configured = parentId ? this.statesById.get(parentId)?.initial : this.chart.initial;
58
- return children.some(s => s.id === configured) ? configured : children[0]?.id;
59
- }
60
-
61
- /**
62
- * @param {string} stateId
63
- * @returns {ChartTransition[]} the transitions starting at the state, sorted by priority
64
- */
65
- getTransitions(stateId) {
66
- return this.transitionsBySource.get(stateId) || [];
67
- }
68
-
69
- /**
70
- * @param {string} ancestorId
71
- * @param {string} id
72
- * @returns {boolean} true if the state "id" is a (direct or indirect) child of "ancestorId"
73
- */
74
- isDescendant(ancestorId, id) {
75
- let parent = this.statesById.get(id)?.parent;
76
- // the counter protects against cycles in invalid charts
77
- for (let i = 0; parent && i < this.statesById.size; i++) {
78
- if (parent === ancestorId) {
79
- return true;
80
- }
81
- parent = this.statesById.get(parent)?.parent;
82
- }
83
- return false;
84
- }
85
-
86
- /**
87
- * How a transition is executed
88
- *
89
- * @param {ChartTransition} transition
90
- * @returns {{ inner: boolean; reenter: boolean }} inner: checked after the during action of the source,
91
- * the source stays active. reenter: the source state is left and entered again (XState "reenter").
92
- */
93
- getTransitionKind(transition) {
94
- const { source, target } = transition;
95
- const toChild = this.isDescendant(source, target);
96
- // an inner transition stays in the source state, only its active child is left
97
- const inner = toChild && !transition.reenterParent;
98
- // Like Stateflow, the source state is left and entered again by self transitions, by transitions
99
- // to an own child which should re-enter the parent, and by transitions from a child to its parent.
100
- // Without "reenter", XState does not leave the source state if the target is a child of it.
101
- const reenter = source === target || (toChild && !inner) || this.isDescendant(target, source);
102
- return { inner, reenter };
103
- }
104
- }
105
-
106
- module.exports = { ChartStructure };
@@ -1,328 +0,0 @@
1
- "use strict";
2
-
3
- /*
4
- * Checks conditions and actions of a chart: JavaScript syntax, unknown variables and events.
5
- * Used by the editor (to underline errors) and by the runtime (to log them when a chart starts).
6
- */
7
-
8
- const acorn = require("acorn");
9
- const walk = require("acorn-walk");
10
- const { HELPERS } = require("./chart-helpers");
11
- const { LabelSyntaxError, formatMessage, parseTransitionLabelWithRanges } = require("./chart-model");
12
-
13
- /**
14
- * @typedef {import("./chart-model").Chart} Chart
15
- * @typedef {import("./chart-model").ChartSymbol} ChartSymbol
16
- */
17
-
18
- /** Names which can be used in conditions and actions besides the chart variables */
19
- const GLOBALS = new Set([
20
- // the helper functions, e.g. log() and setState()
21
- ...Object.keys(HELPERS),
22
- "undefined",
23
- "NaN",
24
- "Infinity",
25
- "Math",
26
- "Date",
27
- "JSON",
28
- "Number",
29
- "String",
30
- "Boolean",
31
- "Array",
32
- "Object",
33
- "RegExp",
34
- "Map",
35
- "Set",
36
- "Error",
37
- "parseInt",
38
- "parseFloat",
39
- "isNaN",
40
- "isFinite",
41
- "encodeURIComponent",
42
- "decodeURIComponent",
43
- ]);
44
-
45
- /** Fields of a state which contain actions */
46
- const STATE_ACTIONS = /** @type {const} */ (["entry", "during", "exit"]);
47
-
48
- /**
49
- * @typedef {object} Diagnostic
50
- * @property {number} from Position in the checked text
51
- * @property {number} to
52
- * @property {"error" | "warning"} severity
53
- * @property {string} message English message
54
- * @property {string} key Message with %s placeholders, used to translate it
55
- * @property {string[]} args
56
- */
57
-
58
- /**
59
- * @param {number} from
60
- * @param {number} to
61
- * @param {"error" | "warning"} severity
62
- * @param {string} key
63
- * @param {string[]} [args]
64
- * @returns {Diagnostic}
65
- */
66
- function createDiagnostic(from, to, severity, key, args = []) {
67
- return { from, to, severity, key, args, message: formatMessage(key, args) };
68
- }
69
-
70
- /**
71
- * Parses JavaScript and converts syntax errors to diagnostics
72
- *
73
- * @param {string} code
74
- * @param {"expression" | "statements"} kind
75
- * @returns {{ ast: acorn.Node } | { diagnostic: Diagnostic }}
76
- */
77
- function parseCode(code, kind) {
78
- try {
79
- if (kind === "statements") {
80
- return { ast: acorn.parse(code, { ecmaVersion: "latest", sourceType: "script" }) };
81
- }
82
- const ast = acorn.parseExpressionAt(code, 0, { ecmaVersion: "latest" });
83
- const rest = code.substring(ast.end);
84
- if (rest.trim()) {
85
- const from = ast.end + rest.length - rest.trimStart().length;
86
- return { diagnostic: createDiagnostic(from, code.length, "error", 'Unexpected "%s"', [rest.trim()]) };
87
- }
88
- return { ast };
89
- } catch (err) {
90
- const pos = typeof err.pos === "number" ? Math.min(err.pos, code.length) : 0;
91
- // acorn adds the position "(line:column)" to its messages, it is shown by the underline anyway
92
- const message = String(err.message).replace(/\s*\(\d+:\d+\)$/, "");
93
- // underline the token at the error position, or the last character if the code ends unexpectedly
94
- const from = pos < code.length ? pos : Math.max(0, code.length - 1);
95
- const token = /^(\w+|\S)/.exec(code.substring(from));
96
- const to = Math.min(code.length, from + (token ? token[0].length : 1));
97
- return { diagnostic: createDiagnostic(from, to, "error", "Syntax error: %s", [message]) };
98
- }
99
- }
100
-
101
- /**
102
- * Checks a condition or action
103
- *
104
- * @param {string | undefined} code
105
- * @param {"expression" | "statements"} kind Condition (expression) or action (statements)
106
- * @param {ChartSymbol[]} symbols
107
- * @param {number} [offset] Position of the code in the text, added to all positions
108
- * @param {string[]} [functionNames] Names declared in the function blocks of the chart
109
- * @returns {Diagnostic[]}
110
- */
111
- function validateCode(code, kind, symbols, offset = 0, functionNames = []) {
112
- if (!code?.trim()) {
113
- return [];
114
- }
115
- const parsed = parseCode(code, kind);
116
- if ("diagnostic" in parsed) {
117
- const d = parsed.diagnostic;
118
- return [{ ...d, from: d.from + offset, to: d.to + offset }];
119
- }
120
-
121
- /** names declared in the code itself (let, const, function parameters, ...) */
122
- const declared = new Set();
123
- /** @type {{ node: any; write: boolean }[]} */
124
- const references = [];
125
- walk.fullAncestor(parsed.ast, (node, _state, ancestors, type) => {
126
- if (node.type !== "Identifier") {
127
- return;
128
- }
129
- /** @type {any} */
130
- const parent = ancestors[ancestors.length - 2];
131
- if (type === "VariablePattern") {
132
- // acorn-walk uses "VariablePattern" for declarations and for assignment targets
133
- if (parent?.type === "AssignmentExpression" && parent.left === node) {
134
- references.push({ node, write: true });
135
- } else {
136
- declared.add(node.name);
137
- }
138
- } else {
139
- references.push({ node, write: parent?.type === "UpdateExpression" });
140
- }
141
- });
142
-
143
- const inputs = new Set(symbols.filter(s => s.scope === "input").map(s => s.name));
144
- const known = new Set([...symbols.map(s => s.name), ...functionNames]);
145
- /** @type {Diagnostic[]} */
146
- const diagnostics = [];
147
- for (const { node, write } of references) {
148
- const from = node.start + offset;
149
- const to = node.end + offset;
150
- if (!known.has(node.name) && !declared.has(node.name) && !GLOBALS.has(node.name)) {
151
- diagnostics.push(createDiagnostic(from, to, "error", 'Unknown variable "%s"', [node.name]));
152
- } else if (write && inputs.has(node.name) && !declared.has(node.name)) {
153
- diagnostics.push(
154
- createDiagnostic(from, to, "warning", 'Input "%s" is overwritten on its next change', [node.name]),
155
- );
156
- }
157
- }
158
- return diagnostics;
159
- }
160
-
161
- /**
162
- * Checks a transition label: its structure, the event and the JavaScript of condition and action
163
- *
164
- * @param {string | undefined} label
165
- * @param {ChartSymbol[]} symbols
166
- * @param {string[]} [functionNames] Names declared in the function blocks of the chart
167
- * @returns {Diagnostic[]}
168
- */
169
- function validateTransitionLabel(label, symbols, functionNames = []) {
170
- let parsed;
171
- try {
172
- parsed = parseTransitionLabelWithRanges(label);
173
- } catch (err) {
174
- if (err instanceof LabelSyntaxError) {
175
- return [createDiagnostic(err.from, err.to, "error", err.key, err.args)];
176
- }
177
- throw err;
178
- }
179
- const { label: parts, ranges } = parsed;
180
-
181
- /** @type {Diagnostic[]} */
182
- const diagnostics = [];
183
- if (parts.event && ranges.event && !symbols.some(s => s.scope === "input" && s.name === parts.event)) {
184
- diagnostics.push(
185
- createDiagnostic(
186
- ranges.event.from,
187
- ranges.event.to,
188
- "error",
189
- 'Unknown event "%s", use the name of an input',
190
- [parts.event],
191
- ),
192
- );
193
- }
194
- if (parts.condition && ranges.condition) {
195
- diagnostics.push(...validateCode(parts.condition, "expression", symbols, ranges.condition.from, functionNames));
196
- }
197
- if (parts.action && ranges.action) {
198
- diagnostics.push(...validateCode(parts.action, "statements", symbols, ranges.action.from, functionNames));
199
- }
200
- return diagnostics;
201
- }
202
-
203
- /**
204
- * @typedef {object} Declaration A name declared at the top level of a function block
205
- * @property {string} name
206
- * @property {string | null} signature e.g. "clamp(x, lo, hi)", null if it is no function
207
- * @property {number} from Position of the name in the code
208
- * @property {number} to
209
- */
210
-
211
- /**
212
- * Finds the names declared at the top level of a function block: functions, but also constants etc.
213
- *
214
- * @param {string | undefined} code
215
- * @returns {Declaration[]} empty if the code has syntax errors
216
- */
217
- function getDeclarations(code) {
218
- if (!code?.trim()) {
219
- return [];
220
- }
221
- const parsed = parseCode(code, "statements");
222
- if ("diagnostic" in parsed) {
223
- return [];
224
- }
225
- /**
226
- * @param {any} fn Function node
227
- * @param {string} name
228
- */
229
- const signature = (fn, name) => `${name}(${fn.params.map(p => code.substring(p.start, p.end)).join(", ")})`;
230
-
231
- /** @type {Declaration[]} */
232
- const declarations = [];
233
- for (const node of /** @type {any} */ (parsed.ast).body) {
234
- if (node.type === "FunctionDeclaration" && node.id) {
235
- const { name, start, end } = node.id;
236
- declarations.push({ name, signature: signature(node, name), from: start, to: end });
237
- } else if (node.type === "VariableDeclaration") {
238
- for (const declarator of node.declarations) {
239
- if (declarator.id.type !== "Identifier") {
240
- continue;
241
- }
242
- const { name, start, end } = declarator.id;
243
- const init = declarator.init;
244
- const isFunction = init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression";
245
- declarations.push({ name, signature: isFunction ? signature(init, name) : null, from: start, to: end });
246
- }
247
- }
248
- }
249
- return declarations;
250
- }
251
-
252
- /**
253
- * Names declared in all function blocks of a chart
254
- *
255
- * @param {Chart} chart
256
- * @returns {string[]}
257
- */
258
- function getFunctionNames(chart) {
259
- return (chart.functions || []).flatMap(fn => getDeclarations(fn.code).map(d => d.name));
260
- }
261
-
262
- /**
263
- * @typedef {object} ChartProblem
264
- * @property {"state" | "transition" | "function"} kind
265
- * @property {string} id ID of the state, transition or function block
266
- * @property {string} field "entry", "during", "exit", "label" or "code"
267
- * @property {Diagnostic} diagnostic
268
- */
269
-
270
- /**
271
- * Checks all actions, transition labels and function blocks of a chart
272
- *
273
- * @param {Chart} chart
274
- * @returns {ChartProblem[]}
275
- */
276
- function validateChart(chart) {
277
- const functionNames = getFunctionNames(chart);
278
- /** @type {ChartProblem[]} */
279
- const problems = [];
280
- for (const state of chart.states) {
281
- for (const field of STATE_ACTIONS) {
282
- for (const diagnostic of validateCode(state[field], "statements", chart.symbols, 0, functionNames)) {
283
- problems.push({ kind: "state", id: state.id, field, diagnostic });
284
- }
285
- }
286
- }
287
- for (const transition of chart.transitions) {
288
- for (const diagnostic of validateTransitionLabel(transition.label, chart.symbols, functionNames)) {
289
- problems.push({ kind: "transition", id: transition.id, field: "label", diagnostic });
290
- }
291
- }
292
-
293
- // a name may only be declared once in all function blocks, and not hide a variable
294
- const symbolNames = new Set(chart.symbols.map(s => s.name));
295
- const seen = new Set();
296
- for (const fn of chart.functions || []) {
297
- for (const diagnostic of validateCode(fn.code, "statements", chart.symbols, 0, functionNames)) {
298
- problems.push({ kind: "function", id: fn.id, field: "code", diagnostic });
299
- }
300
- for (const { name, from, to } of getDeclarations(fn.code)) {
301
- let key = null;
302
- if (seen.has(name)) {
303
- key = '"%s" is declared in more than one function block';
304
- } else if (symbolNames.has(name) || GLOBALS.has(name)) {
305
- key = '"%s" is already the name of a variable';
306
- }
307
- if (key) {
308
- problems.push({
309
- kind: "function",
310
- id: fn.id,
311
- field: "code",
312
- diagnostic: createDiagnostic(from, to, "error", key, [name]),
313
- });
314
- }
315
- seen.add(name);
316
- }
317
- }
318
- return problems;
319
- }
320
-
321
- module.exports = {
322
- GLOBALS,
323
- getDeclarations,
324
- getFunctionNames,
325
- validateChart,
326
- validateCode,
327
- validateTransitionLabel,
328
- };
@@ -1,149 +0,0 @@
1
- "use strict";
2
-
3
- const { expect } = require("chai");
4
- const { getDeclarations, validateChart, validateCode, validateTransitionLabel } = require("./chart-validation");
5
-
6
- /** @type {import("./chart-model").ChartSymbol[]} */
7
- const symbols = [
8
- { name: "motion", scope: "input", stateId: "test.0.motion" },
9
- { name: "light", scope: "output", stateId: "test.0.light" },
10
- { name: "count", scope: "local", initial: 0 },
11
- ];
12
-
13
- /**
14
- * @param {import("./chart-validation").Diagnostic[]} diagnostics
15
- * @param {string} text
16
- */
17
- const summarize = (diagnostics, text) =>
18
- diagnostics.map(d => `${d.severity}: ${d.message} @${text.substring(d.from, d.to)}`);
19
-
20
- describe("chart-validation => validateCode", () => {
21
- it("accepts valid code", () => {
22
- expect(validateCode("light = motion && count > 1; log(Math.max(1, 2));", "statements", symbols)).to.deep.equal(
23
- [],
24
- );
25
- expect(validateCode("", "statements", symbols)).to.deep.equal([]);
26
- });
27
-
28
- it("reports unknown variables with their position", () => {
29
- const code = "light = motoin;";
30
- expect(summarize(validateCode(code, "statements", symbols), code)).to.deep.equal([
31
- 'error: Unknown variable "motoin" @motoin',
32
- ]);
33
- });
34
-
35
- it("knows variables declared in the code", () => {
36
- const code = "let x = 1; const f = (a) => a + x; for (const i of [1]) { light = f(i); }";
37
- expect(validateCode(code, "statements", symbols)).to.deep.equal([]);
38
- });
39
-
40
- it("ignores property names", () => {
41
- expect(validateCode("light = { on: true }.on && JSON.parse('1').foo;", "statements", symbols)).to.deep.equal(
42
- [],
43
- );
44
- });
45
-
46
- it("warns when an input is assigned", () => {
47
- const code = "motion = false; count++;";
48
- expect(summarize(validateCode(code, "statements", symbols), code)).to.deep.equal([
49
- 'warning: Input "motion" is overwritten on its next change @motion',
50
- ]);
51
- });
52
-
53
- it("reports syntax errors", () => {
54
- const code = "light = = true;";
55
- const diagnostics = validateCode(code, "statements", symbols);
56
- expect(diagnostics).to.have.length(1);
57
- expect(diagnostics[0].message).to.match(/^Syntax error: Unexpected token/);
58
- expect(code.substring(diagnostics[0].from, diagnostics[0].to)).to.equal("=");
59
- });
60
-
61
- it("rejects statements in conditions", () => {
62
- const code = "motion; light = 1";
63
- expect(summarize(validateCode(code, "expression", symbols), code)).to.deep.equal([
64
- 'error: Unexpected "; light = 1" @; light = 1',
65
- ]);
66
- });
67
- });
68
-
69
- describe("chart-validation => validateTransitionLabel", () => {
70
- it("accepts valid labels", () => {
71
- expect(validateTransitionLabel("motion[motion && count < 3]{count++;}", symbols)).to.deep.equal([]);
72
- expect(validateTransitionLabel("after(5, sec)", symbols)).to.deep.equal([]);
73
- });
74
-
75
- it("reports problems at their position in the label", () => {
76
- const label = "motoin[motion && lightt]{light = tru;}";
77
- expect(summarize(validateTransitionLabel(label, symbols), label)).to.deep.equal([
78
- 'error: Unknown event "motoin", use the name of an input @motoin',
79
- 'error: Unknown variable "lightt" @lightt',
80
- 'error: Unknown variable "tru" @tru',
81
- ]);
82
- });
83
-
84
- it("finds problems in multi line labels", () => {
85
- const label = "[motion]\n{\n light = true;\n cnt++;\n}";
86
- expect(summarize(validateTransitionLabel(label, symbols), label)).to.deep.equal([
87
- 'error: Unknown variable "cnt" @cnt',
88
- ]);
89
- });
90
-
91
- it("reports structural errors with key and arguments for translation", () => {
92
- const [diagnostic] = validateTransitionLabel("[motion", symbols);
93
- expect(diagnostic).to.include({ key: 'Missing "%s"', message: 'Missing "]"', from: 0, to: 7 });
94
- expect(diagnostic.args).to.deep.equal(["]"]);
95
- });
96
- });
97
-
98
- describe("chart-validation => validateChart", () => {
99
- it("reports problems of states and transitions", () => {
100
- const problems = validateChart({
101
- version: 1,
102
- name: "Test",
103
- enabled: true,
104
- symbols,
105
- states: [{ id: "s1", name: "S1", entry: "lihgt = true;", x: 0, y: 0 }],
106
- transitions: [{ id: "t1", source: "s1", target: "s1", label: "[x]" }],
107
- });
108
- expect(problems.map(p => `${p.kind} ${p.id} ${p.field}: ${p.diagnostic.message}`)).to.deep.equal([
109
- 'state s1 entry: Unknown variable "lihgt"',
110
- 'transition t1 label: Unknown variable "x"',
111
- ]);
112
- });
113
-
114
- it("knows the functions of function blocks and checks the blocks", () => {
115
- const problems = validateChart({
116
- version: 1,
117
- name: "Test",
118
- enabled: true,
119
- symbols,
120
- states: [{ id: "s1", name: "S1", entry: "light = isOn(count) && LIMIT > 1;", x: 0, y: 0 }],
121
- transitions: [{ id: "t1", source: "s1", target: "s1", label: "[clamp(count, 0, 5) > 1]" }],
122
- functions: [
123
- { id: "f1", code: "function clamp(x, lo, hi) { return Math.min(hi, Math.max(lo, x)); }", x: 0, y: 0 },
124
- { id: "f2", code: "const LIMIT = 3;\nconst isOn = c => c > LIMIT && motoin;", x: 0, y: 0 },
125
- { id: "f3", code: "function clamp() {}\nfunction count() {}", x: 0, y: 0 },
126
- ],
127
- });
128
- expect(problems.map(p => `${p.kind} ${p.id} ${p.field}: ${p.diagnostic.message}`)).to.deep.equal([
129
- 'function f2 code: Unknown variable "motoin"',
130
- 'function f3 code: "clamp" is declared in more than one function block',
131
- 'function f3 code: "count" is already the name of a variable',
132
- ]);
133
- });
134
- });
135
-
136
- describe("chart-validation => getDeclarations", () => {
137
- it("returns functions with their signature and other declarations", () => {
138
- const code = "function clamp(x, lo = 0, ...rest) {}\nconst f = (a, b) => a + b;\nlet counter = 0;";
139
- expect(getDeclarations(code).map(d => [d.name, d.signature, code.substring(d.from, d.to)])).to.deep.equal([
140
- ["clamp", "clamp(x, lo = 0, ...rest)", "clamp"],
141
- ["f", "f(a, b)", "f"],
142
- ["counter", null, "counter"],
143
- ]);
144
- });
145
-
146
- it("returns nothing for code with syntax errors", () => {
147
- expect(getDeclarations("function (")).to.deep.equal([]);
148
- });
149
- });