yarn-spinner-runner-ts 0.1.4-c → 0.1.5-a
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.
- package/README.md +44 -16
- package/dist/compile/compiler.js +2 -2
- package/dist/compile/compiler.js.map +1 -1
- package/dist/compile/ir.d.ts +1 -0
- package/dist/markup/parser.js +0 -3
- package/dist/markup/parser.js.map +1 -1
- package/dist/model/ast.d.ts +1 -0
- package/dist/parse/lexer.js +5 -5
- package/dist/parse/lexer.js.map +1 -1
- package/dist/parse/parser.js +12 -2
- package/dist/parse/parser.js.map +1 -1
- package/dist/react/DialogueExample.js +4 -3
- package/dist/react/DialogueExample.js.map +1 -1
- package/dist/runtime/evaluator.d.ts +3 -0
- package/dist/runtime/evaluator.js +165 -3
- package/dist/runtime/evaluator.js.map +1 -1
- package/dist/runtime/runner.d.ts +2 -0
- package/dist/runtime/runner.js +66 -10
- package/dist/runtime/runner.js.map +1 -1
- package/dist/tests/markup.test.js +1 -1
- package/dist/tests/markup.test.js.map +1 -1
- package/dist/tests/options.test.js +164 -9
- package/dist/tests/options.test.js.map +1 -1
- package/dist/tests/variables_flow_cmds.test.js +117 -10
- package/dist/tests/variables_flow_cmds.test.js.map +1 -1
- package/package.json +1 -1
- package/src/compile/compiler.ts +2 -2
- package/src/compile/ir.ts +1 -1
- package/src/markup/parser.ts +0 -3
- package/src/model/ast.ts +1 -0
- package/src/parse/lexer.ts +18 -18
- package/src/parse/parser.ts +33 -22
- package/src/react/DialogueExample.tsx +11 -10
- package/src/runtime/evaluator.ts +224 -47
- package/src/runtime/runner.ts +102 -37
- package/src/tests/markup.test.ts +1 -1
- package/src/tests/options.test.ts +206 -36
- package/src/tests/variables_flow_cmds.test.ts +139 -28
|
@@ -2,16 +2,16 @@ import { test } from "node:test";
|
|
|
2
2
|
import { strictEqual } from "node:assert";
|
|
3
3
|
import { parseYarn, compile, YarnRunner } from "../index.js";
|
|
4
4
|
test("variables, flow control, and commands", () => {
|
|
5
|
-
const script = `
|
|
6
|
-
title: Start
|
|
7
|
-
---
|
|
8
|
-
<<set $score to 10>>
|
|
9
|
-
<<if $score >= 10>>
|
|
10
|
-
Narrator: High
|
|
11
|
-
<<else>>
|
|
12
|
-
Narrator: Low
|
|
13
|
-
<<endif>>
|
|
14
|
-
===
|
|
5
|
+
const script = `
|
|
6
|
+
title: Start
|
|
7
|
+
---
|
|
8
|
+
<<set $score to 10>>
|
|
9
|
+
<<if $score >= 10>>
|
|
10
|
+
Narrator: High
|
|
11
|
+
<<else>>
|
|
12
|
+
Narrator: Low
|
|
13
|
+
<<endif>>
|
|
14
|
+
===
|
|
15
15
|
`;
|
|
16
16
|
const doc = parseYarn(script);
|
|
17
17
|
const ir = compile(doc);
|
|
@@ -27,4 +27,111 @@ title: Start
|
|
|
27
27
|
strictEqual(/High/.test(b.text), true, "Expected High branch");
|
|
28
28
|
strictEqual(runner.getVariable("score"), 10, "Variable should be set");
|
|
29
29
|
});
|
|
30
|
+
test("equality operators support ==, !=, and single =", () => {
|
|
31
|
+
const script = `
|
|
32
|
+
title: Start
|
|
33
|
+
---
|
|
34
|
+
<<set $doorOpen to true>>
|
|
35
|
+
<<if $doorOpen = true>>
|
|
36
|
+
Narrator: Single equals ok
|
|
37
|
+
<<endif>>
|
|
38
|
+
<<if $doorOpen == true>>
|
|
39
|
+
Narrator: Double equals ok
|
|
40
|
+
<<endif>>
|
|
41
|
+
<<if $doorOpen != false>>
|
|
42
|
+
Narrator: Not equals ok
|
|
43
|
+
<<endif>>
|
|
44
|
+
===
|
|
45
|
+
`;
|
|
46
|
+
const doc = parseYarn(script);
|
|
47
|
+
const ir = compile(doc);
|
|
48
|
+
const runner = new YarnRunner(ir, { startAt: "Start" });
|
|
49
|
+
const seen = [];
|
|
50
|
+
let guard = 25;
|
|
51
|
+
while (guard-- > 0) {
|
|
52
|
+
const result = runner.currentResult;
|
|
53
|
+
if (!result)
|
|
54
|
+
break;
|
|
55
|
+
if (result.type === "text" && result.text.trim()) {
|
|
56
|
+
seen.push(result.text.trim());
|
|
57
|
+
}
|
|
58
|
+
if (result.isDialogueEnd) {
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (result.type === "options") {
|
|
62
|
+
runner.advance(0);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
runner.advance();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
strictEqual(seen.includes("Single equals ok"), true, "Single equals comparison should succeed");
|
|
69
|
+
strictEqual(seen.includes("Double equals ok"), true, "Double equals comparison should succeed");
|
|
70
|
+
strictEqual(seen.includes("Not equals ok"), true, "Not equals comparison should succeed");
|
|
71
|
+
});
|
|
72
|
+
test("set command supports equals syntax with arithmetic reassignment", () => {
|
|
73
|
+
const script = `
|
|
74
|
+
title: StreetCred
|
|
75
|
+
---
|
|
76
|
+
<<set $reputation = 100>>
|
|
77
|
+
<<set $reputation = $reputation - 25 >>
|
|
78
|
+
Narrator: Current street cred: {$reputation}
|
|
79
|
+
===
|
|
80
|
+
`;
|
|
81
|
+
const doc = parseYarn(script);
|
|
82
|
+
const ir = compile(doc);
|
|
83
|
+
const runner = new YarnRunner(ir, { startAt: "StreetCred" });
|
|
84
|
+
const seen = [];
|
|
85
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
86
|
+
const result = runner.currentResult;
|
|
87
|
+
if (!result)
|
|
88
|
+
break;
|
|
89
|
+
if (result.type === "text" && result.text.trim()) {
|
|
90
|
+
seen.push(result.text.trim());
|
|
91
|
+
}
|
|
92
|
+
if (result.isDialogueEnd)
|
|
93
|
+
break;
|
|
94
|
+
if (result.type === "options") {
|
|
95
|
+
runner.advance(0);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
runner.advance();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
strictEqual(seen.includes("Current street cred: 75"), true, "Should reflect arithmetic subtraction");
|
|
102
|
+
strictEqual(runner.getVariable("reputation"), 75, "Variable should store updated numeric value");
|
|
103
|
+
});
|
|
104
|
+
test("set command respects arithmetic precedence and parentheses", () => {
|
|
105
|
+
const script = `
|
|
106
|
+
title: MathChecks
|
|
107
|
+
---
|
|
108
|
+
<<set $score = 10>>
|
|
109
|
+
<<set $score = $score + 10 * 2>>
|
|
110
|
+
<<set $score = ($score + 10) / 2>>
|
|
111
|
+
Narrator: Score now {$score}
|
|
112
|
+
===
|
|
113
|
+
`;
|
|
114
|
+
const doc = parseYarn(script);
|
|
115
|
+
const ir = compile(doc);
|
|
116
|
+
const runner = new YarnRunner(ir, { startAt: "MathChecks" });
|
|
117
|
+
const lines = [];
|
|
118
|
+
for (let guard = 0; guard < 20; guard++) {
|
|
119
|
+
const result = runner.currentResult;
|
|
120
|
+
if (!result)
|
|
121
|
+
break;
|
|
122
|
+
if (result.type === "text" && result.text.trim()) {
|
|
123
|
+
lines.push(result.text.trim());
|
|
124
|
+
}
|
|
125
|
+
if (result.isDialogueEnd)
|
|
126
|
+
break;
|
|
127
|
+
if (result.type === "options") {
|
|
128
|
+
runner.advance(0);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
runner.advance();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
strictEqual(lines.includes("Score now 20"), true, "Should honor operator precedence and parentheses");
|
|
135
|
+
strictEqual(runner.getVariable("score"), 20, "Final numeric value should be 20");
|
|
136
|
+
});
|
|
30
137
|
//# sourceMappingURL=variables_flow_cmds.test.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"variables_flow_cmds.test.js","sourceRoot":"","sources":["../../src/tests/variables_flow_cmds.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,SAAS,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE7D,IAAI,CAAC,uCAAuC,EAAE,GAAG,EAAE;IACjD,MAAM,MAAM,GAAG;;;;;;;;;;CAUhB,CAAC;IAEA,MAAM,GAAG,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;IAC9B,MAAM,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IACxB,MAAM,MAAM,GAAG,IAAI,UAAU,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;IAExD,yCAAyC;IACzC,0CAA0C;IAC1C,MAAM,CAAC,GAAG,MAAM,CAAC,aAAc,CAAC;IAChC,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,SAAS,EAAE,gCAAgC,CAAC,CAAC;IACjE,MAAM,CAAC,OAAO,EAAE,CAAC;IACjB,MAAM,CAAC,GAAG,MAAM,CAAC,aAAc,CAAC;IAChC,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,8BAA8B,CAAC,CAAC;IAC5D,IAAI,CAAC,CAAC,IAAI,KAAK,MAAM;QAAE,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,sBAAsB,CAAC,CAAC;IACtF,WAAW,CAAC,MAAM,CAAC,WAAW,CAAC,OAAO,CAAC,EAAE,EAAE,EAAE,wBAAwB,CAAC,CAAC;AACzE,CAAC,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"variables_flow_cmds.test.js","sourceRoot":"","sources":["../../src/tests/variables_flow_cmds.test.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yarn-spinner-runner-ts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5-a",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "TypeScript parser, compiler, and runtime for Yarn Spinner 3.x with React adapter [NPM package](https://www.npmjs.com/package/yarn-spinner-runner-ts)",
|
|
6
6
|
"license": "MIT",
|
package/src/compile/compiler.ts
CHANGED
|
@@ -72,7 +72,7 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram
|
|
|
72
72
|
}
|
|
73
73
|
block.push({
|
|
74
74
|
op: "options",
|
|
75
|
-
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, block: emitBlock(o.body) })),
|
|
75
|
+
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body) })),
|
|
76
76
|
});
|
|
77
77
|
break;
|
|
78
78
|
}
|
|
@@ -141,7 +141,7 @@ export function compile(doc: YarnDocument, opts: CompileOptions = {}): IRProgram
|
|
|
141
141
|
}
|
|
142
142
|
block.push({
|
|
143
143
|
op: "options",
|
|
144
|
-
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, block: emitBlock(o.body) })),
|
|
144
|
+
options: s.options.map((o: Option) => ({ text: o.text, tags: ensureLineId(o.tags), css: (o as any).css, markup: o.markup, condition: o.condition, block: emitBlock(o.body) })),
|
|
145
145
|
});
|
|
146
146
|
break;
|
|
147
147
|
}
|
package/src/compile/ir.ts
CHANGED
|
@@ -22,7 +22,7 @@ export type IRInstruction =
|
|
|
22
22
|
| { op: "command"; content: string }
|
|
23
23
|
| { op: "jump"; target: string }
|
|
24
24
|
| { op: "detour"; target: string }
|
|
25
|
-
| { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; block: IRInstruction[] }> }
|
|
25
|
+
| { op: "options"; options: Array<{ text: string; tags?: string[]; css?: string; markup?: MarkupParseResult; condition?: string; block: IRInstruction[] }> }
|
|
26
26
|
| { op: "if"; branches: Array<{ condition: string | null; block: IRInstruction[] }> }
|
|
27
27
|
| { op: "once"; id: string; block: IRInstruction[] };
|
|
28
28
|
|
package/src/markup/parser.ts
CHANGED
|
@@ -145,9 +145,6 @@ export function parseMarkup(input: string): MarkupParseResult {
|
|
|
145
145
|
};
|
|
146
146
|
|
|
147
147
|
const handleSelfClosing = (tag: ParsedTag) => {
|
|
148
|
-
if (tag.name === "br") {
|
|
149
|
-
appendChar("\n");
|
|
150
|
-
}
|
|
151
148
|
const wrapper: MarkupWrapper = {
|
|
152
149
|
name: tag.name,
|
|
153
150
|
type: DEFAULT_HTML_TAGS.has(tag.name) ? "default" : "custom",
|
package/src/model/ast.ts
CHANGED
package/src/parse/lexer.ts
CHANGED
|
@@ -34,24 +34,24 @@ export function lex(input: string): Token[] {
|
|
|
34
34
|
const indent = raw.match(/^[ \t]*/)?.[0] ?? "";
|
|
35
35
|
const content = raw.slice(indent.length);
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
37
|
+
if (content.trim() === "") {
|
|
38
|
+
push("EMPTY", "", lineNum, 1);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Manage indentation tokens only within node bodies and on non-empty lines
|
|
43
|
+
if (!inHeaders) {
|
|
44
|
+
const prev = indentStack[indentStack.length - 1];
|
|
45
|
+
if (indent.length > prev) {
|
|
46
|
+
indentStack.push(indent.length);
|
|
47
|
+
push("INDENT", "", lineNum, 1);
|
|
48
|
+
} else if (indent.length < prev) {
|
|
49
|
+
while (indentStack.length && indent.length < indentStack[indentStack.length - 1]) {
|
|
50
|
+
indentStack.pop();
|
|
51
|
+
push("DEDENT", "", lineNum, 1);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
55
|
|
|
56
56
|
if (content === "---") {
|
|
57
57
|
inHeaders = false;
|
package/src/parse/parser.ts
CHANGED
|
@@ -215,24 +215,26 @@ class Parser {
|
|
|
215
215
|
// One or more OPTION lines, with bodies under INDENT
|
|
216
216
|
while (this.at("OPTION")) {
|
|
217
217
|
const raw = this.take("OPTION").text;
|
|
218
|
-
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
|
|
219
|
-
const { text:
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
218
|
+
const { cleanText: textWithAttrs, tags } = this.extractTags(raw);
|
|
219
|
+
const { text: textWithCondition, css } = this.extractCss(textWithAttrs);
|
|
220
|
+
const { text: optionText, condition } = this.extractOptionCondition(textWithCondition);
|
|
221
|
+
const markup = parseMarkup(optionText);
|
|
222
|
+
let body: Statement[] = [];
|
|
223
|
+
if (this.at("INDENT")) {
|
|
224
|
+
this.take("INDENT");
|
|
225
|
+
body = this.parseStatementsUntil("DEDENT");
|
|
225
226
|
this.take("DEDENT");
|
|
226
227
|
while (this.at("EMPTY")) this.i++;
|
|
227
228
|
}
|
|
228
|
-
options.push({
|
|
229
|
-
type: "Option",
|
|
230
|
-
text: markup.text,
|
|
231
|
-
body,
|
|
232
|
-
tags,
|
|
233
|
-
css,
|
|
234
|
-
markup: this.normalizeMarkup(markup),
|
|
235
|
-
|
|
229
|
+
options.push({
|
|
230
|
+
type: "Option",
|
|
231
|
+
text: markup.text,
|
|
232
|
+
body,
|
|
233
|
+
tags,
|
|
234
|
+
css,
|
|
235
|
+
markup: this.normalizeMarkup(markup),
|
|
236
|
+
condition,
|
|
237
|
+
});
|
|
236
238
|
// Consecutive options belong to the same group; break on non-OPTION
|
|
237
239
|
while (this.at("EMPTY")) this.i++;
|
|
238
240
|
}
|
|
@@ -283,15 +285,24 @@ class Parser {
|
|
|
283
285
|
return { cleanText: input };
|
|
284
286
|
}
|
|
285
287
|
|
|
286
|
-
private extractCss(input: string): { text: string; css?: string } {
|
|
287
|
-
const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
|
|
288
|
-
if (cssMatch) {
|
|
289
|
-
const css = cssMatch[1].trim();
|
|
290
|
-
const text = input.replace(cssMatch[0], "").trimEnd();
|
|
288
|
+
private extractCss(input: string): { text: string; css?: string } {
|
|
289
|
+
const cssMatch = input.match(/\s*&css\{([^}]*)\}\s*$/);
|
|
290
|
+
if (cssMatch) {
|
|
291
|
+
const css = cssMatch[1].trim();
|
|
292
|
+
const text = input.replace(cssMatch[0], "").trimEnd();
|
|
291
293
|
return { text, css };
|
|
292
294
|
}
|
|
293
|
-
return { text: input };
|
|
294
|
-
}
|
|
295
|
+
return { text: input };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private extractOptionCondition(input: string): { text: string; condition?: string } {
|
|
299
|
+
const match = input.match(/\s\[\s*if\s+([^\]]+)\]\s*$/i);
|
|
300
|
+
if (match) {
|
|
301
|
+
const text = input.slice(0, match.index).trimEnd();
|
|
302
|
+
return { text, condition: match[1].trim() };
|
|
303
|
+
}
|
|
304
|
+
return { text: input };
|
|
305
|
+
}
|
|
295
306
|
|
|
296
307
|
private parseStatementsUntilStop(shouldStop: () => boolean): Statement[] {
|
|
297
308
|
const out: Statement[] = [];
|
|
@@ -8,11 +8,12 @@ import type { SceneCollection } from "../scene/types.js";
|
|
|
8
8
|
const DEFAULT_YARN = `title: Start
|
|
9
9
|
scene: scene1
|
|
10
10
|
---
|
|
11
|
-
|
|
12
|
-
Narrator:
|
|
11
|
+
<< declare $hasBadge = false >>
|
|
12
|
+
Narrator: Welcome to [b]yarn-spinner-ts[/b], {$playerName}!
|
|
13
|
+
Narrator: Current street cred: {$reputation}
|
|
13
14
|
npc: This is a dialogue system powered by Yarn Spinner.
|
|
14
15
|
Narrator: Click anywhere to continue, or choose an option below.
|
|
15
|
-
-> Start the adventure &css{backgroundColor: #4a9eff; color: white;}
|
|
16
|
+
-> Start the adventure &css{backgroundColor: #4a9eff; color: white;} [if $hasBadge]
|
|
16
17
|
Narrator: Great! Let's begin your journey.
|
|
17
18
|
<<jump NextScene>>
|
|
18
19
|
-> Learn more &css{backgroundColor: #2ecc71; color: red;}
|
|
@@ -101,13 +102,13 @@ export function DialogueExample() {
|
|
|
101
102
|
</div>
|
|
102
103
|
)}
|
|
103
104
|
|
|
104
|
-
<DialogueView
|
|
105
|
-
program={program || { nodes: {}, enums: {} }}
|
|
106
|
-
startNode="Start"
|
|
107
|
-
scenes={scenes}
|
|
108
|
-
variables={{ playerName: "V", reputation: 3 }}
|
|
109
|
-
enableTypingAnimation={enableTypingAnimation}
|
|
110
|
-
showTypingCursor={true}
|
|
105
|
+
<DialogueView
|
|
106
|
+
program={program || { nodes: {}, enums: {} }}
|
|
107
|
+
startNode="Start"
|
|
108
|
+
scenes={scenes}
|
|
109
|
+
variables={{ playerName: "V", reputation: 3 }}
|
|
110
|
+
enableTypingAnimation={enableTypingAnimation}
|
|
111
|
+
showTypingCursor={true}
|
|
111
112
|
typingSpeed={20}
|
|
112
113
|
cursorCharacter="$"
|
|
113
114
|
autoAdvanceAfterTyping={true}
|
package/src/runtime/evaluator.ts
CHANGED
|
@@ -27,33 +27,38 @@ export class ExpressionEvaluator {
|
|
|
27
27
|
/**
|
|
28
28
|
* Evaluate an expression that can return any value (not just boolean).
|
|
29
29
|
*/
|
|
30
|
-
evaluateExpression(expr: string): unknown {
|
|
31
|
-
const trimmed = this.preprocess(expr.trim());
|
|
32
|
-
if (!trimmed) return false;
|
|
33
|
-
|
|
34
|
-
// Handle function calls like `functionName(arg1, arg2)`
|
|
35
|
-
if (
|
|
36
|
-
return this.evaluateFunctionCall(trimmed);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// Handle comparisons
|
|
40
|
-
if (this.containsComparison(trimmed)) {
|
|
41
|
-
return this.evaluateComparison(trimmed);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Handle logical operators
|
|
45
|
-
if (trimmed.includes("&&") || trimmed.includes("||")) {
|
|
46
|
-
return this.evaluateLogical(trimmed);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Handle negation
|
|
50
|
-
if (trimmed.startsWith("!")) {
|
|
51
|
-
return !this.evaluateExpression(trimmed.slice(1).trim());
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
30
|
+
evaluateExpression(expr: string): unknown {
|
|
31
|
+
const trimmed = this.preprocess(expr.trim());
|
|
32
|
+
if (!trimmed) return false;
|
|
33
|
+
|
|
34
|
+
// Handle function calls like `functionName(arg1, arg2)`
|
|
35
|
+
if (this.looksLikeFunctionCall(trimmed)) {
|
|
36
|
+
return this.evaluateFunctionCall(trimmed);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle comparisons
|
|
40
|
+
if (this.containsComparison(trimmed)) {
|
|
41
|
+
return this.evaluateComparison(trimmed);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Handle logical operators
|
|
45
|
+
if (trimmed.includes("&&") || trimmed.includes("||")) {
|
|
46
|
+
return this.evaluateLogical(trimmed);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Handle negation
|
|
50
|
+
if (trimmed.startsWith("!")) {
|
|
51
|
+
return !this.evaluateExpression(trimmed.slice(1).trim());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Handle arithmetic expressions (+, -, *, /, %)
|
|
55
|
+
if (this.containsArithmetic(trimmed)) {
|
|
56
|
+
return this.evaluateArithmetic(trimmed);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Simple variable or literal
|
|
60
|
+
return this.resolveValue(trimmed);
|
|
61
|
+
}
|
|
57
62
|
|
|
58
63
|
private preprocess(expr: string): string {
|
|
59
64
|
// Normalize operator word aliases to JS-like symbols
|
|
@@ -71,9 +76,9 @@ export class ExpressionEvaluator {
|
|
|
71
76
|
.replace(/\blt\b/gi, "<");
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
private evaluateFunctionCall(expr: string): unknown {
|
|
75
|
-
const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
|
|
76
|
-
if (!match) throw new Error(`Invalid function call: ${expr}`);
|
|
79
|
+
private evaluateFunctionCall(expr: string): unknown {
|
|
80
|
+
const match = expr.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*\((.*)\)$/);
|
|
81
|
+
if (!match) throw new Error(`Invalid function call: ${expr}`);
|
|
77
82
|
|
|
78
83
|
const [, name, argsStr] = match;
|
|
79
84
|
const func = this.functions[name];
|
|
@@ -104,26 +109,198 @@ export class ExpressionEvaluator {
|
|
|
104
109
|
return args;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
|
-
private containsComparison(expr: string): boolean {
|
|
108
|
-
return /[<>=!]/.test(expr);
|
|
109
|
-
}
|
|
112
|
+
private containsComparison(expr: string): boolean {
|
|
113
|
+
return /[<>=!]/.test(expr);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private looksLikeFunctionCall(expr: string): boolean {
|
|
117
|
+
return /^([a-zA-Z_][a-zA-Z0-9_]*)\s*\(.*\)$/.test(expr);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private containsArithmetic(expr: string): boolean {
|
|
121
|
+
// Remove quoted strings to avoid false positives on "-" or "+" inside literals
|
|
122
|
+
const unquoted = expr.replace(/"[^"]*"|'[^']*'/g, "");
|
|
123
|
+
return /[+\-*/%]/.test(unquoted);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private evaluateArithmetic(expr: string): number {
|
|
127
|
+
const input = expr;
|
|
128
|
+
let index = 0;
|
|
129
|
+
|
|
130
|
+
const skipWhitespace = () => {
|
|
131
|
+
while (index < input.length && /\s/.test(input[index])) {
|
|
132
|
+
index++;
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const toNumber = (value: unknown): number => {
|
|
137
|
+
if (typeof value === "number") return value;
|
|
138
|
+
if (typeof value === "boolean") return value ? 1 : 0;
|
|
139
|
+
if (value == null || value === "") return 0;
|
|
140
|
+
const num = Number(value);
|
|
141
|
+
if (Number.isNaN(num)) {
|
|
142
|
+
throw new Error(`Cannot convert ${String(value)} to number`);
|
|
143
|
+
}
|
|
144
|
+
return num;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const readToken = (): string => {
|
|
148
|
+
skipWhitespace();
|
|
149
|
+
const start = index;
|
|
150
|
+
let depth = 0;
|
|
151
|
+
let inQuotes = false;
|
|
152
|
+
let quoteChar = "";
|
|
153
|
+
|
|
154
|
+
while (index < input.length) {
|
|
155
|
+
const char = input[index];
|
|
156
|
+
if (inQuotes) {
|
|
157
|
+
if (char === quoteChar) {
|
|
158
|
+
inQuotes = false;
|
|
159
|
+
quoteChar = "";
|
|
160
|
+
}
|
|
161
|
+
index++;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (char === '"' || char === "'") {
|
|
166
|
+
inQuotes = true;
|
|
167
|
+
quoteChar = char;
|
|
168
|
+
index++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (char === "(") {
|
|
173
|
+
depth++;
|
|
174
|
+
index++;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (char === ")") {
|
|
179
|
+
if (depth === 0) break;
|
|
180
|
+
depth--;
|
|
181
|
+
index++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (depth === 0 && "+-*/%".includes(char)) {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (depth === 0 && /\s/.test(char)) {
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
index++;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return input.slice(start, index).trim();
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const parsePrimary = (): unknown => {
|
|
200
|
+
skipWhitespace();
|
|
201
|
+
if (index >= input.length) {
|
|
202
|
+
throw new Error("Unexpected end of expression");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const char = input[index];
|
|
206
|
+
if (char === "(") {
|
|
207
|
+
index++;
|
|
208
|
+
const value = parseAddSub();
|
|
209
|
+
skipWhitespace();
|
|
210
|
+
if (input[index] !== ")") {
|
|
211
|
+
throw new Error("Unmatched parenthesis in expression");
|
|
212
|
+
}
|
|
213
|
+
index++;
|
|
214
|
+
return value;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const token = readToken();
|
|
218
|
+
if (!token) {
|
|
219
|
+
throw new Error("Invalid expression token");
|
|
220
|
+
}
|
|
221
|
+
return this.evaluateExpression(token);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const parseUnary = (): number => {
|
|
225
|
+
skipWhitespace();
|
|
226
|
+
if (input[index] === "+") {
|
|
227
|
+
index++;
|
|
228
|
+
return parseUnary();
|
|
229
|
+
}
|
|
230
|
+
if (input[index] === "-") {
|
|
231
|
+
index++;
|
|
232
|
+
return -parseUnary();
|
|
233
|
+
}
|
|
234
|
+
return toNumber(parsePrimary());
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const parseMulDiv = (): number => {
|
|
238
|
+
let value = parseUnary();
|
|
239
|
+
while (true) {
|
|
240
|
+
skipWhitespace();
|
|
241
|
+
const char = input[index];
|
|
242
|
+
if (char === "*" || char === "/" || char === "%") {
|
|
243
|
+
index++;
|
|
244
|
+
const right = parseUnary();
|
|
245
|
+
if (char === "*") {
|
|
246
|
+
value = value * right;
|
|
247
|
+
} else if (char === "/") {
|
|
248
|
+
value = value / right;
|
|
249
|
+
} else {
|
|
250
|
+
value = value % right;
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
return value;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const parseAddSub = (): number => {
|
|
260
|
+
let value = parseMulDiv();
|
|
261
|
+
while (true) {
|
|
262
|
+
skipWhitespace();
|
|
263
|
+
const char = input[index];
|
|
264
|
+
if (char === "+" || char === "-") {
|
|
265
|
+
index++;
|
|
266
|
+
const right = parseMulDiv();
|
|
267
|
+
if (char === "+") {
|
|
268
|
+
value = value + right;
|
|
269
|
+
} else {
|
|
270
|
+
value = value - right;
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const result = parseAddSub();
|
|
280
|
+
skipWhitespace();
|
|
281
|
+
if (index < input.length) {
|
|
282
|
+
throw new Error(`Unexpected token "${input.slice(index)}" in expression`);
|
|
283
|
+
}
|
|
284
|
+
return result;
|
|
285
|
+
}
|
|
110
286
|
|
|
111
287
|
private evaluateComparison(expr: string): boolean {
|
|
112
288
|
// Match comparison operators (avoid matching !=, <=, >=)
|
|
113
|
-
const match = expr.match(/^(.+?)\s*(
|
|
114
|
-
if (!match) throw new Error(`Invalid comparison: ${expr}`);
|
|
115
|
-
|
|
116
|
-
const [, left,
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
case "
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
case "
|
|
126
|
-
|
|
289
|
+
const match = expr.match(/^(.+?)\s*(===|==|!==|!=|=|<=|>=|<|>)\s*(.+)$/);
|
|
290
|
+
if (!match) throw new Error(`Invalid comparison: ${expr}`);
|
|
291
|
+
|
|
292
|
+
const [, left, rawOp, right] = match;
|
|
293
|
+
const op = rawOp === "=" ? "==" : rawOp;
|
|
294
|
+
const leftVal = this.evaluateExpression(left.trim());
|
|
295
|
+
const rightVal = this.evaluateExpression(right.trim());
|
|
296
|
+
|
|
297
|
+
switch (op) {
|
|
298
|
+
case "===":
|
|
299
|
+
case "==":
|
|
300
|
+
return this.deepEquals(leftVal, rightVal);
|
|
301
|
+
case "!==":
|
|
302
|
+
case "!=":
|
|
303
|
+
return !this.deepEquals(leftVal, rightVal);
|
|
127
304
|
case "<":
|
|
128
305
|
return Number(leftVal) < Number(rightVal);
|
|
129
306
|
case ">":
|