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.
Files changed (38) hide show
  1. package/README.md +44 -16
  2. package/dist/compile/compiler.js +2 -2
  3. package/dist/compile/compiler.js.map +1 -1
  4. package/dist/compile/ir.d.ts +1 -0
  5. package/dist/markup/parser.js +0 -3
  6. package/dist/markup/parser.js.map +1 -1
  7. package/dist/model/ast.d.ts +1 -0
  8. package/dist/parse/lexer.js +5 -5
  9. package/dist/parse/lexer.js.map +1 -1
  10. package/dist/parse/parser.js +12 -2
  11. package/dist/parse/parser.js.map +1 -1
  12. package/dist/react/DialogueExample.js +4 -3
  13. package/dist/react/DialogueExample.js.map +1 -1
  14. package/dist/runtime/evaluator.d.ts +3 -0
  15. package/dist/runtime/evaluator.js +165 -3
  16. package/dist/runtime/evaluator.js.map +1 -1
  17. package/dist/runtime/runner.d.ts +2 -0
  18. package/dist/runtime/runner.js +66 -10
  19. package/dist/runtime/runner.js.map +1 -1
  20. package/dist/tests/markup.test.js +1 -1
  21. package/dist/tests/markup.test.js.map +1 -1
  22. package/dist/tests/options.test.js +164 -9
  23. package/dist/tests/options.test.js.map +1 -1
  24. package/dist/tests/variables_flow_cmds.test.js +117 -10
  25. package/dist/tests/variables_flow_cmds.test.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/compile/compiler.ts +2 -2
  28. package/src/compile/ir.ts +1 -1
  29. package/src/markup/parser.ts +0 -3
  30. package/src/model/ast.ts +1 -0
  31. package/src/parse/lexer.ts +18 -18
  32. package/src/parse/parser.ts +33 -22
  33. package/src/react/DialogueExample.tsx +11 -10
  34. package/src/runtime/evaluator.ts +224 -47
  35. package/src/runtime/runner.ts +102 -37
  36. package/src/tests/markup.test.ts +1 -1
  37. package/src/tests/options.test.ts +206 -36
  38. 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.4-c",
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",
@@ -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
 
@@ -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
@@ -73,6 +73,7 @@ export interface Option {
73
73
  tags?: string[];
74
74
  css?: string; // Custom CSS style for option
75
75
  markup?: MarkupParseResult;
76
+ condition?: string;
76
77
  }
77
78
 
78
79
  export interface IfBlock {
@@ -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
- // Manage indentation tokens only within node bodies
38
- if (!inHeaders) {
39
- const prev = indentStack[indentStack.length - 1];
40
- if (indent.length > prev) {
41
- indentStack.push(indent.length);
42
- push("INDENT", "", lineNum, 1);
43
- } else if (indent.length < prev) {
44
- while (indentStack.length && indent.length < indentStack[indentStack.length - 1]) {
45
- indentStack.pop();
46
- push("DEDENT", "", lineNum, 1);
47
- }
48
- }
49
- }
50
-
51
- if (content.trim() === "") {
52
- push("EMPTY", "", lineNum, 1);
53
- continue;
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;
@@ -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: textWithoutCss, css } = this.extractCss(textWithAttrs);
220
- const markup = parseMarkup(textWithoutCss);
221
- let body: Statement[] = [];
222
- if (this.at("INDENT")) {
223
- this.take("INDENT");
224
- body = this.parseStatementsUntil("DEDENT");
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
- Narrator: Welcome to [b]yarn-spinner-ts[/b], {$playerName}!
12
- Narrator: Current street cred: {$reputation}
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}
@@ -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 (trimmed.includes("(") && trimmed.includes(")")) {
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
- // Simple variable or literal
55
- return this.resolveValue(trimmed);
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*(===|!==|<=|>=|<|>)\s*(.+)$/);
114
- if (!match) throw new Error(`Invalid comparison: ${expr}`);
115
-
116
- const [, left, op, right] = match;
117
- const leftVal = this.evaluateExpression(left.trim());
118
- const rightVal = this.evaluateExpression(right.trim());
119
-
120
- switch (op) {
121
- case "===":
122
- case "==":
123
- return this.deepEquals(leftVal, rightVal);
124
- case "!==":
125
- case "!=":
126
- return !this.deepEquals(leftVal, rightVal);
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 ">":