Macros 2.0 (v0.7.0) -Variable Shorthand: New Operators & Lazy Evaluation (#4997) * Add new variable shorthand operators and new variable macros - Add `{{hasvar}}` and `{{deletevar}}` macros for local variables - Add `{{hasglobalvar}}` and `{{deleteglobalvar}}` macros for global variables - Implement new variable shorthand operators: `-=`, `||`, `??`, `||=`, `??=`, `==` - Refactor variable expression evaluation to use direct variable API calls instead of routing through macro registry - Add comprehensive operator support in lexer with proper token ordering for multi-character operators * Implement lazy evaluation for variable shorthand value expressions - Replace eager value evaluation with lazy evaluation pattern for variable operators - Add `#createLazyValue()` method that caches value expression result on first call - Change `#executeVariableOperation()` to accept `lazyValue` function instead of pre-evaluated value - Only evaluate value expressions when actually needed (e.g., when variable is falsy for `||`, when variable doesn't exist for `??`) - Add comprehensive e2e tests * Update variable shorthand autocomplete with new operators and add edge case tests - Add new operators to autocomplete definitions: `-=`, `||`, `??`, `||=`, `??=`, `==` - Update example lists in `VariableShorthandAutoCompleteOption` and `VariableNameAutoCompleteOption` - Add operator definitions with descriptions and `needsValue` flags to `VariableOperatorDefinitions` - Update `parseMacroContext()` to recognize new operators with proper ordering (longer operators checked first) * Add not equals (!=) operator to variable shorthand expressions - Add `!=` operator to variable shorthand definitions for local and global variables - Implement `notEquals` operation in `MacroCstWalker` that returns inverted equality comparison - Add `NotEquals` token to lexer with pattern `/!=` - Update parser to handle `!=` operator with value expression - Add `!=` operator to autocomplete examples and operator definitions - Update `parseMacroContext()` to recognize `!=` operator and... * Tiny code review fixes - Fix typo: "insie" → "inside" in variable shorthand operators comment - Remove extra space in `{{getglobalvar}}` example usage * Fix missing closing braces in hasglobalvar macro example usage * Fix isFalsy to normalize value before checking boolean state in variable shorthand operators - Apply `normalize()` to value before passing to `isFalseBoolean()` in `isFalsy` helper - Ensures consistent falsy evaluation for variable shorthand operators like `||`, `??`, `||=`, `??=` * Fix pipe character inside macro braces being treated as command separator - Add `isInsideMacroBraces()` method to track unclosed `{{}}` macro braces - Modify `testCommandEnd()` to only treat `|` as command end when not inside macro braces - Scan text behind current position to calculate macro brace depth - Skip second character when detecting `{{` or `}}` pairs - Ensure `depth` never goes below 0 using `Math.max(0, depth - 1)` * Add aliases for variable existence and deletion macros - Add `varexists` alias for `hasvar` macro - Add `flushvar` alias for `deletevar` macro - Add `globalvarexists` alias for `hasglobalvar` macro - Add `flushglobalvar` alias for `deleteglobalvar` macro * Update variable shorthand operator descriptions to clarify return behavior - Add "returns nothing" clarification to `=`, `+=`, `-=` operators in examples and definitions - Add "returns the new value" clarification to `++`, `--` operators in examples and definitions - Update `VariableOperatorDefinitions` descriptions to match example text - Ensures users understand which operators return values vs perform silent mutations

f8c373f55a8cdcba93c43d8a88b760ecb76ac600

Wolfsblvt <wolfsblvt@gmail.com>

Signed
9 files changed, +870 -94Ignore whitespace
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+98 -16
@@ -499,13 +499,13 @@ export const VariableShorthandDefinitions = new Map([
499 type: VariableShorthandType.LOCAL,499 type: VariableShorthandType.LOCAL,
500 name: 'Local Variable',500 name: 'Local Variable',
501 description: 'Access or modify a local variable (scoped to current chat).',501 description: 'Access or modify a local variable (scoped to current chat).',
502 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)'],502 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)'],
503 }],503 }],
504 [VariableShorthandType.GLOBAL, {504 [VariableShorthandType.GLOBAL, {
505 type: VariableShorthandType.GLOBAL,505 type: VariableShorthandType.GLOBAL,
506 name: 'Global Variable',506 name: 'Global Variable',
507 description: 'Access or modify a global variable (shared across all chats).',507 description: 'Access or modify a global variable (shared across all chats).',
508 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)'],508 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)'],
509 }],509 }],
510]);510]);
511511
@@ -622,10 +622,17 @@ export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
622 const prefix = this.#varDef.type;622 const prefix = this.#varDef.type;
623 const examples = [623 const examples = [
624 `{{${prefix}myvar}} - Get variable value`,624 `{{${prefix}myvar}} - Get variable value`,
625 `{{${prefix}myvar = value}} - Set variable`,625 `{{${prefix}myvar = value}} - Set variable (returns nothing)`,
626 `{{${prefix}counter++}} - Increment`,626 `{{${prefix}counter++}} - Increment and get value`,
627 `{{${prefix}counter--}} - Decrement`,627 `{{${prefix}counter--}} - Decrement and get value`,
628 `{{${prefix}myvar += text}} - Append/add`,628 `{{${prefix}myvar += text}} - Append/add (returns nothing)`,
629 `{{${prefix}score -= 5}} - Subtract (returns nothing)`,
630 `{{${prefix}myvar || default}} - Get with fallback if falsy`,
631 `{{${prefix}myvar ?? default}} - Get with fallback if undefined`,
632 `{{${prefix}myvar ||= value}} - Set if falsy, get value`,
633 `{{${prefix}myvar ??= value}} - Set if undefined, get value`,
634 `{{${prefix}myvar == test}} - Compare (returns true/false)`,
635 `{{${prefix}myvar != test}} - Compare not equal (returns true/false)`,
629 ];636 ];
630 for (const ex of examples) {637 for (const ex of examples) {
631 const li = document.createElement('li');638 const li = document.createElement('li');
@@ -796,6 +803,13 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption {
796 `{{${prefix}${this.#varName}++}} - Increment`,803 `{{${prefix}${this.#varName}++}} - Increment`,
797 `{{${prefix}${this.#varName}--}} - Decrement`,804 `{{${prefix}${this.#varName}--}} - Decrement`,
798 `{{${prefix}${this.#varName} += text}} - Append/add`,805 `{{${prefix}${this.#varName} += text}} - Append/add`,
806 `{{${prefix}${this.#varName} -= 5}} - Subtract`,
807 `{{${prefix}${this.#varName} || default}} - Get with fallback if falsy`,
808 `{{${prefix}${this.#varName} ?? default}} - Get with fallback if undefined`,
809 `{{${prefix}${this.#varName} ||= value}} - Set if falsy, get value`,
810 `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,
811 `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,
812 `{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`,
799 ];813 ];
800 for (const ex of examples) {814 for (const ex of examples) {
801 const li = document.createElement('li');815 const li = document.createElement('li');
@@ -817,25 +831,67 @@ export const VariableOperatorDefinitions = new Map([
817 ['=', {831 ['=', {
818 symbol: '=',832 symbol: '=',
819 name: 'Set',833 name: 'Set',
820 description: 'Set the variable to a new value.',834 description: 'Set the variable to a new value. Returns nothing.',
821 needsValue: true,835 needsValue: true,
822 }],836 }],
823 ['++', {837 ['++', {
824 symbol: '++',838 symbol: '++',
825 name: 'Increment',839 name: 'Increment',
826 description: 'Increment the variable by 1 (numeric).',840 description: 'Increment the variable by 1 (numeric). Returns the new value.',
827 needsValue: false,841 needsValue: false,
828 }],842 }],
829 ['--', {843 ['--', {
830 symbol: '--',844 symbol: '--',
831 name: 'Decrement',845 name: 'Decrement',
832 description: 'Decrement the variable by 1 (numeric).',846 description: 'Decrement the variable by 1 (numeric). Returns the new value.',
833 needsValue: false,847 needsValue: false,
834 }],848 }],
835 ['+=', {849 ['+=', {
836 symbol: '+=',850 symbol: '+=',
837 name: 'Add',851 name: 'Add',
838 description: 'Add to the variable (numeric addition or string concatenation).',852 description: 'Add to the variable (numeric addition or string concatenation). Returns nothing.',
853 needsValue: true,
854 }],
855 ['-=', {
856 symbol: '-=',
857 name: 'Subtract',
858 description: 'Subtract a numeric value from the variable. Returns nothing.',
859 needsValue: true,
860 }],
861 ['||', {
862 symbol: '||',
863 name: 'Logical Or',
864 description: 'Return the fallback value if the variable is falsy, otherwise return the variable value.',
865 needsValue: true,
866 }],
867 ['??', {
868 symbol: '??',
869 name: 'Nullish Coalescing',
870 description: 'Return the fallback value only if the variable does not exist, otherwise return the variable value (even if falsy).',
871 needsValue: true,
872 }],
873 ['||=', {
874 symbol: '||=',
875 name: 'Logical Or Assign',
876 description: 'If the variable is falsy, set it to the value and return it; otherwise return the current value.',
877 needsValue: true,
878 }],
879 ['??=', {
880 symbol: '??=',
881 name: 'Nullish Coalescing Assign',
882 description: 'If the variable does not exist, set it to the value and return it; otherwise return the current value.',
883 needsValue: true,
884 }],
885 ['==', {
886 symbol: '==',
887 name: 'Equals',
888 description: 'Compare the variable value to another value. Returns "true" or "false".',
889 needsValue: true,
890 }],
891 ['!=', {
892 symbol: '!=',
893 name: 'Not Equals',
894 description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',
839 needsValue: true,895 needsValue: true,
840 }],896 }],
841]);897]);
@@ -1131,7 +1187,8 @@ export function parseMacroContext(macroText, cursorOffset) {
1131 i++;1187 i++;
1132 }1188 }
11331189
1134 // Check for operators: ++, --, +=, =1190 // Check for operators: ++, --, +=, -=, ||=, ??=, ||, ??, ==, =
1191 // Order matters: longer operators must be checked before shorter ones
1135 // Also track partial operator prefixes for autocomplete1192 // Also track partial operator prefixes for autocomplete
1136 const operatorText = macroText.slice(i);1193 const operatorText = macroText.slice(i);
1137 let hasInvalidTrailingChars = false;1194 let hasInvalidTrailingChars = false;
@@ -1143,13 +1200,34 @@ export function parseMacroContext(macroText, cursorOffset) {
1143 } else if (operatorText.startsWith('--')) {1200 } else if (operatorText.startsWith('--')) {
1144 variableOperator = '--';1201 variableOperator = '--';
1145 i += 2;1202 i += 2;
1203 } else if (operatorText.startsWith('||=')) {
1204 variableOperator = '||=';
1205 i += 3;
1206 } else if (operatorText.startsWith('??=')) {
1207 variableOperator = '??=';
1208 i += 3;
1209 } else if (operatorText.startsWith('||')) {
1210 variableOperator = '||';
1211 i += 2;
1212 } else if (operatorText.startsWith('??')) {
1213 variableOperator = '??';
1214 i += 2;
1146 } else if (operatorText.startsWith('+=')) {1215 } else if (operatorText.startsWith('+=')) {
1147 variableOperator = '+=';1216 variableOperator = '+=';
1148 i += 2;1217 i += 2;
1218 } else if (operatorText.startsWith('-=')) {
1219 variableOperator = '-=';
1220 i += 2;
1221 } else if (operatorText.startsWith('==')) {
1222 variableOperator = '==';
1223 i += 2;
1224 } else if (operatorText.startsWith('!=')) {
1225 variableOperator = '!=';
1226 i += 2;
1149 } else if (operatorText.startsWith('=')) {1227 } else if (operatorText.startsWith('=')) {
1150 variableOperator = '=';1228 variableOperator = '=';
1151 i += 1;1229 i += 1;
1152 } else if (operatorText.startsWith('+') || operatorText.startsWith('-')) {1230 } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!')) {
1153 // Partial operator prefix - user is typing an operator1231 // Partial operator prefix - user is typing an operator
1154 partialOperator = operatorText[0];1232 partialOperator = operatorText[0];
1155 } else if (operatorText.length > 0 && !/^\s/.test(operatorText)) {1233 } else if (operatorText.length > 0 && !/^\s/.test(operatorText)) {
@@ -1159,8 +1237,12 @@ export function parseMacroContext(macroText, cursorOffset) {
1159 invalidTrailingChars = operatorText.trim();1237 invalidTrailingChars = operatorText.trim();
1160 }1238 }
11611239
1162 // If operator requires a value (= or +=), parse the value1240 // Check if operator requires a value
1163 if (variableOperator === '=' || variableOperator === '+=') {1241 const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;
1242 const operatorNeedsValue = operatorDef?.needsValue ?? false;
1243
1244 // If operator requires a value, parse the value
1245 if (operatorNeedsValue) {
1164 // Skip whitespace after operator1246 // Skip whitespace after operator
1165 while (i < macroText.length && /\s/.test(macroText[i])) {1247 while (i < macroText.length && /\s/.test(macroText[i])) {
1166 i++;1248 i++;
@@ -1178,9 +1260,9 @@ export function parseMacroContext(macroText, cursorOffset) {
1178 isTypingVariableName = true;1260 isTypingVariableName = true;
1179 } else if (!variableOperator && !hasInvalidTrailingChars) {1261 } else if (!variableOperator && !hasInvalidTrailingChars) {
1180 // Cursor is after variable name but no operator yet (and no invalid chars)1262 // Cursor is after variable name but no operator yet (and no invalid chars)
1181 // This includes partial operator prefixes like '+' or '-'1263 // This includes partial operator prefixes like '+', '-', '|', '?'
1182 isTypingOperator = true;1264 isTypingOperator = true;
1183 } else if (variableOperator === '=' || variableOperator === '+=') {1265 } else if (operatorNeedsValue) {
1184 // Operator that requires value - cursor is in value area1266 // Operator that requires value - cursor is in value area
1185 isTypingValue = true;1267 isTypingValue = true;
1186 }1268 }
public/scripts/macros/definitions/variable-macros.js+86 -5
@@ -68,7 +68,7 @@ export function registerVariableMacros() {
68 description: 'Increments a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',68 description: 'Increments a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
69 returns: 'The new value of the local variable.',69 returns: 'The new value of the local variable.',
70 returnType: MacroValueType.NUMBER,70 returnType: MacroValueType.NUMBER,
71 exampleUsage: ['{{incvar::myintvar}}'],71 exampleUsage: ['{{incvar::myintvar}}', '{{incvar some-local-int-var}}'],
72 handler: ({ unnamedArgs: [name], normalize }) => {72 handler: ({ unnamedArgs: [name], normalize }) => {
73 const result = ctx.variables.local.inc(name);73 const result = ctx.variables.local.inc(name);
74 return normalize(result);74 return normalize(result);
@@ -88,7 +88,7 @@ export function registerVariableMacros() {
88 description: 'Decrements a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',88 description: 'Decrements a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
89 returns: 'The new value of the local variable.',89 returns: 'The new value of the local variable.',
90 returnType: MacroValueType.NUMBER,90 returnType: MacroValueType.NUMBER,
91 exampleUsage: ['{{decvar::myintvar}}'],91 exampleUsage: ['{{decvar::myintvar}}', '{{decvar some-local-int-var}}'],
92 handler: ({ unnamedArgs: [name], normalize }) => {92 handler: ({ unnamedArgs: [name], normalize }) => {
93 const result = ctx.variables.local.dec(name);93 const result = ctx.variables.local.dec(name);
94 return normalize(result);94 return normalize(result);
@@ -108,13 +108,53 @@ export function registerVariableMacros() {
108 description: 'Gets the value of a local variable.',108 description: 'Gets the value of a local variable.',
109 returns: 'The value of the local variable.',109 returns: 'The value of the local variable.',
110 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],110 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
111 exampleUsage: ['{{getvar::myvar}}', '{{getvar::myintvar}}'],111 exampleUsage: ['{{getvar::myvar}}', '{{getvar myintvar}}'],
112 handler: ({ unnamedArgs: [name], normalize }) => {112 handler: ({ unnamedArgs: [name], normalize }) => {
113 const result = ctx.variables.local.get(name);113 const result = ctx.variables.local.get(name);
114 return normalize(result);114 return normalize(result);
115 },115 },
116 });116 });
117117
118 // {{hasvar::name}} -> returns 'true' or 'false'
119 MacroRegistry.registerMacro('hasvar', {
120 aliases: [{ alias: 'varexists' }],
121 category: MacroCategory.VARIABLE,
122 unnamedArgs: [
123 {
124 name: 'name',
125 type: MacroValueType.STRING,
126 description: 'The name of the local variable to check.',
127 },
128 ],
129 description: 'Checks if a local variable exists.',
130 returns: '"true" if the variable exists, "false" otherwise.',
131 returnType: MacroValueType.STRING,
132 exampleUsage: ['{{hasvar::myvar}}', '{{hasvar some-local-var}}'],
133 handler: ({ unnamedArgs: [name] }) => {
134 return ctx.variables.local.has(name) ? 'true' : 'false';
135 },
136 });
137
138 // {{deletevar::name}} -> returns ''
139 MacroRegistry.registerMacro('deletevar', {
140 aliases: [{ alias: 'flushvar' }],
141 category: MacroCategory.VARIABLE,
142 unnamedArgs: [
143 {
144 name: 'name',
145 type: MacroValueType.STRING,
146 description: 'The name of the local variable to delete.',
147 },
148 ],
149 description: 'Deletes a local variable.',
150 returns: '',
151 exampleUsage: ['{{deletevar::myvar}}', '{{deletevar some-local-var}}'],
152 handler: ({ unnamedArgs: [name] }) => {
153 ctx.variables.local.del(name);
154 return '';
155 },
156 });
157
118 // {{setglobalvar::name::value}} -> ''158 // {{setglobalvar::name::value}} -> ''
119 MacroRegistry.registerMacro('setglobalvar', {159 MacroRegistry.registerMacro('setglobalvar', {
120 category: MacroCategory.VARIABLE,160 category: MacroCategory.VARIABLE,
@@ -176,6 +216,7 @@ export function registerVariableMacros() {
176 description: 'Increments a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',216 description: 'Increments a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
177 returns: 'The new value of the global variable.',217 returns: 'The new value of the global variable.',
178 returnType: MacroValueType.NUMBER,218 returnType: MacroValueType.NUMBER,
219 exampleUsage: ['{{incglobalvar::myintvar}}', '{{incglobalvar some-global-int-var}}'],
179 handler: ({ unnamedArgs: [name], normalize }) => {220 handler: ({ unnamedArgs: [name], normalize }) => {
180 const result = ctx.variables.global.inc(name);221 const result = ctx.variables.global.inc(name);
181 return normalize(result);222 return normalize(result);
@@ -195,7 +236,7 @@ export function registerVariableMacros() {
195 description: 'Decrements a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',236 description: 'Decrements a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
196 returns: 'The new value of the global variable.',237 returns: 'The new value of the global variable.',
197 returnType: MacroValueType.NUMBER,238 returnType: MacroValueType.NUMBER,
198 exampleUsage: ['{{decglobalvar::myintvar}}'],239 exampleUsage: ['{{decglobalvar::myintvar}}', '{{decglobalvar some-global-int-var}}'],
199 handler: ({ unnamedArgs: [name], normalize }) => {240 handler: ({ unnamedArgs: [name], normalize }) => {
200 const result = ctx.variables.global.dec(name);241 const result = ctx.variables.global.dec(name);
201 return normalize(result);242 return normalize(result);
@@ -215,10 +256,50 @@ export function registerVariableMacros() {
215 description: 'Gets the value of a global variable.',256 description: 'Gets the value of a global variable.',
216 returns: 'The value of the global variable.',257 returns: 'The value of the global variable.',
217 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],258 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
218 exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar::myintvar}}'],259 exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar myintvar}}'],
219 handler: ({ unnamedArgs: [name], normalize }) => {260 handler: ({ unnamedArgs: [name], normalize }) => {
220 const result = ctx.variables.global.get(name);261 const result = ctx.variables.global.get(name);
221 return normalize(result);262 return normalize(result);
222 },263 },
223 });264 });
265
266 // {{hasglobalvar::name}} -> returns 'true' or 'false'
267 MacroRegistry.registerMacro('hasglobalvar', {
268 aliases: [{ alias: 'globalvarexists' }],
269 category: MacroCategory.VARIABLE,
270 unnamedArgs: [
271 {
272 name: 'name',
273 type: MacroValueType.STRING,
274 description: 'The name of the global variable to check.',
275 },
276 ],
277 description: 'Checks if a global variable exists.',
278 returns: '"true" if the variable exists, "false" otherwise.',
279 returnType: MacroValueType.STRING,
280 exampleUsage: ['{{hasglobalvar::myvar}}', '{{hasglobalvar some-global-var}}'],
281 handler: ({ unnamedArgs: [name] }) => {
282 return ctx.variables.global.has(name) ? 'true' : 'false';
283 },
284 });
285
286 // {{deleteglobalvar::name}} -> returns ''
287 MacroRegistry.registerMacro('deleteglobalvar', {
288 aliases: [{ alias: 'flushglobalvar' }],
289 category: MacroCategory.VARIABLE,
290 unnamedArgs: [
291 {
292 name: 'name',
293 type: MacroValueType.STRING,
294 description: 'The name of the global variable to delete.',
295 },
296 ],
297 description: 'Deletes a global variable.',
298 returns: '',
299 exampleUsage: ['{{deleteglobalvar::myvar}}', '{{deleteglobalvar some-global-var}}'],
300 handler: ({ unnamedArgs: [name] }) => {
301 ctx.variables.global.del(name);
302 return '';
303 },
304 });
224}305}
public/scripts/macros/engine/MacroCstWalker.js+186 -50
@@ -3,9 +3,12 @@
3/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */3/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
4/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */4/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
55
6import { logMacroInternalError, logMacroRuntimeWarning } from './MacroDiagnostics.js';
7import { MacroEngine } from './MacroEngine.js';
6import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';8import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';
7import { MacroParser } from './MacroParser.js';9import { MacroParser } from './MacroParser.js';
8import { MacroRegistry } from './MacroRegistry.js';10import { MacroRegistry } from './MacroRegistry.js';
11import { isFalseBoolean } from '/scripts/utils.js';
912
10/**13/**
11 * @typedef {Object} MacroCall14 * @typedef {Object} MacroCall
@@ -493,7 +496,10 @@ class MacroCstWalker {
493 }496 }
494497
495 /**498 /**
496 * Evaluates a variable expression node and routes it to the appropriate variable macro.499 * Evaluates a variable expression node using direct variable API calls.
500 * Supports operators: get, set (=), add (+=), sub (-=), inc (++), dec (--),
501 * logical or (||), nullish coalescing (??), logical or assign (||=),
502 * nullish coalescing assign (??=), and equality comparison (==).
497 *503 *
498 * @param {CstNode} macroNode - The parent macro node.504 * @param {CstNode} macroNode - The parent macro node.
499 * @param {CstNode} variableExprNode - The variableExpr CST node.505 * @param {CstNode} variableExprNode - The variableExpr CST node.
@@ -501,9 +507,6 @@ class MacroCstWalker {
501 * @returns {string}507 * @returns {string}
502 */508 */
503 #evaluateVariableExpr(macroNode, variableExprNode, context) {509 #evaluateVariableExpr(macroNode, variableExprNode, context) {
504 const { text, contextOffset, env, resolveMacro } = context;
505
506 const children = macroNode.children || {};
507 const varChildren = variableExprNode.children || {};510 const varChildren = variableExprNode.children || {};
508511
509 // Extract scope (. for local, $ for global)512 // Extract scope (. for local, $ for global)
@@ -518,9 +521,9 @@ class MacroCstWalker {
518 const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]);521 const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]);
519 const operatorChildren = operatorNode?.children || {};522 const operatorChildren = operatorNode?.children || {};
520523
521 // Determine operation and value524 // Determine operation and whether a value expression is expected
522 let operation = 'get';525 let operation = 'get';
523 let value = null;526 let hasValueExpr = false;
524527
525 if (operatorNode) {528 if (operatorNode) {
526 const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []);529 const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []);
@@ -528,60 +531,193 @@ class MacroCstWalker {
528531
529 if (operatorToken) {532 if (operatorToken) {
530 const operatorImage = operatorToken.image;533 const operatorImage = operatorToken.image;
531 if (operatorImage === '++') {534 switch (operatorImage) {
532 operation = 'inc';535 case '++':
533 } else if (operatorImage === '--') {536 operation = 'inc';
534 operation = 'dec';537 break;
535 } else if (operatorImage === '=') {538 case '--':
536 operation = 'set';539 operation = 'dec';
537 value = this.#evaluateVariableValue(operatorChildren, context);540 break;
538 } else if (operatorImage === '+=') {541 case '=':
539 operation = 'add';542 operation = 'set';
540 value = this.#evaluateVariableValue(operatorChildren, context);543 hasValueExpr = true;
544 break;
545 case '+=':
546 operation = 'add';
547 hasValueExpr = true;
548 break;
549 case '-=':
550 operation = 'sub';
551 hasValueExpr = true;
552 break;
553 case '||':
554 operation = 'logicalOr';
555 hasValueExpr = true;
556 break;
557 case '??':
558 operation = 'nullishCoalescing';
559 hasValueExpr = true;
560 break;
561 case '||=':
562 operation = 'logicalOrAssign';
563 hasValueExpr = true;
564 break;
565 case '??=':
566 operation = 'nullishCoalescingAssign';
567 hasValueExpr = true;
568 break;
569 case '==':
570 operation = 'equals';
571 hasValueExpr = true;
572 break;
573 case '!=':
574 operation = 'notEquals';
575 hasValueExpr = true;
576 break;
577 default:
578 logMacroInternalError({ message: `Lexer found macro operator that is not implemented for variable shorthand expressions in macro node '${macroNode.name}'.` });
579 break;
541 }580 }
542 }581 }
543 }582 }
544583
545 // Map operation to macro name584 // Create a lazy value resolver that caches its result on first call.
546 const macroNameMap = {585 // This ensures the value expression is only evaluated when actually needed,
547 get: isGlobal ? 'getglobalvar' : 'getvar',586 // which is important for performance and because some macros are stateful.
548 set: isGlobal ? 'setglobalvar' : 'setvar',587 const lazyValue = hasValueExpr ? this.#createLazyValue(operatorChildren, context) : () => '';
549 inc: isGlobal ? 'incglobalvar' : 'incvar',588
550 dec: isGlobal ? 'decglobalvar' : 'decvar',589 // Execute the operation using direct variable API calls
551 add: isGlobal ? 'addglobalvar' : 'addvar',590 return this.#executeVariableOperation(varName, isGlobal, operation, lazyValue);
591 }
592
593 /**
594 * Creates a lazy value resolver that caches its result on first call.
595 * This ensures the value expression is only evaluated when actually needed.
596 *
597 * @param {Record<string, any>} operatorChildren - The children of the variableOperator node.
598 * @param {EvaluationContext} context - The evaluation context.
599 * @returns {() => string} A function that returns the evaluated value, caching the result.
600 */
601 #createLazyValue(operatorChildren, context) {
602 let cached = null;
603 let resolved = false;
604
605 return () => {
606 if (!resolved) {
607 cached = this.#evaluateVariableValue(operatorChildren, context);
608 resolved = true;
609 }
610 return cached;
552 };611 };
612 }
553613
554 const targetMacroName = macroNameMap[operation];614 /**
615 * Executes a variable operation using the SillyTavern context API.
616 *
617 * @param {string} varName - The variable name.
618 * @param {boolean} isGlobal - Whether this is a global ($) or local (.) variable.
619 * @param {string} operation - The operation to perform.
620 * @param {() => string} lazyValue - A lazy function that returns the value when called. Only evaluated when needed.
621 * @returns {string} The result of the operation.
622 */
623 #executeVariableOperation(varName, isGlobal, operation, lazyValue) {
624 const ctx = SillyTavern.getContext();
625 const vars = isGlobal ? ctx.variables.global : ctx.variables.local;
626
627 /**
628 * Normalizes macro results into a string.
629 * @param {any} value
630 * @returns {string}
631 */
632 const normalize = MacroEngine.normalizeMacroResult.bind(MacroEngine);
633
634 /**
635 * Checks if a value is falsy (empty string, 0, '0', false, 'false', null, undefined).
636 * @param {any} val
637 * @returns {boolean}
638 */
639 const isFalsy = (val) => !val || isFalseBoolean(normalize(val));
640
641 switch (operation) {
642 case 'get':
643 return normalize(vars.get(varName));
644
645 case 'set':
646 vars.set(varName, lazyValue());
647 return '';
648
649 case 'inc':
650 return normalize(vars.inc(varName));
651
652 case 'dec':
653 return normalize(vars.dec(varName));
654
655 case 'add':
656 vars.add(varName, lazyValue());
657 return '';
658
659 case 'sub': {
660 // Subtract by adding the negative value
661 const numValue = Number(lazyValue());
662 if (!isNaN(numValue)) vars.add(varName, -numValue);
663 else logMacroRuntimeWarning({ message: `Variable shorthand "-=" operator requires a numeric value, got: "${lazyValue()}"` });
664 return '';
665 }
555666
556 // Build args array based on operation667 case 'logicalOr': {
557 const args = [varName];668 // Returns default value if variable is falsy, otherwise returns variable value
558 if (value !== null) {669 // Value is only resolved if needed (when variable is falsy)
559 args.push(value);670 const currentValue = vars.get(varName);
560 }671 return isFalsy(currentValue) ? normalize(lazyValue()) : normalize(currentValue);
672 }
561673
562 const range = this.#getMacroRange(macroNode);674 case 'nullishCoalescing': {
675 // Returns default value only if variable doesn't exist, otherwise returns variable value (even if falsy)
676 // Value is only resolved if needed (when variable doesn't exist)
677 const exists = vars.has(varName);
678 return exists ? normalize(vars.get(varName)) : normalize(lazyValue());
679 }
563680
564 /** @type {MacroCall} */681 case 'logicalOrAssign': {
565 const call = {682 // If variable is falsy, set it to value and return value; otherwise return current value
566 name: targetMacroName,683 // Value is only resolved if needed (when variable is falsy)
567 args,684 const currentValue = vars.get(varName);
568 flags: createEmptyFlags(),685 if (isFalsy(currentValue)) {
569 isScoped: false,686 vars.set(varName, lazyValue());
570 isVariableShorthand: true,687 return normalize(lazyValue());
571 rawInner: text.slice(688 }
572 (/** @type {IToken|undefined} */ (children['Macro.Start']?.[0])?.endOffset ?? range.startOffset) + 1,689 return normalize(currentValue);
573 (/** @type {IToken|undefined} */ (children['Macro.End']?.[0])?.startOffset ?? range.endOffset + 1) - 1,690 }
574 ),
575 rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
576 rawArgs: args,
577 range,
578 globalOffset: contextOffset + range.startOffset,
579 cstNode: macroNode,
580 env,
581 };
582691
583 const result = resolveMacro(call);692 case 'nullishCoalescingAssign': {
584 return typeof result === 'string' ? result : String(result ?? '');693 // If variable doesn't exist, set it to value and return value; otherwise return current value
694 // Value is only resolved if needed (when variable doesn't exist)
695 const exists = vars.has(varName);
696 if (!exists) {
697 vars.set(varName, lazyValue());
698 return normalize(lazyValue());
699 }
700 return normalize(vars.get(varName));
701 }
702
703 case 'equals': {
704 // String equality comparison - value is always needed
705 const currentValue = normalize(vars.get(varName));
706 const compareValue = normalize(lazyValue());
707 return currentValue === compareValue ? 'true' : 'false';
708 }
709
710 case 'notEquals': {
711 // String inequality comparison - value is always needed
712 const currentValue = normalize(vars.get(varName));
713 const compareValue = normalize(lazyValue());
714 return currentValue !== compareValue ? 'true' : 'false';
715 }
716
717 default:
718 logMacroRuntimeWarning({ message: `Unknown variable shorthand operation: "${operation}"` });
719 return '';
720 }
585 }721 }
586722
587 /**723 /**
public/scripts/macros/engine/MacroLexer.js+38 -13
@@ -111,14 +111,32 @@ const Tokens = Object.freeze({
111 * Invalid: my-, my--, -var111 * Invalid: my-, my--, -var
112 */112 */
113 Identifier: createToken({ name: 'Var.Identifier', pattern: MACRO_VARIABLE_SHORTHAND_PATTERN }),113 Identifier: createToken({ name: 'Var.Identifier', pattern: MACRO_VARIABLE_SHORTHAND_PATTERN }),
114 /** Increment operator (`++`) */114
115 Increment: createToken({ name: 'Var.Increment', pattern: /\+\+/ }),115 /** All tokens that are valid operators inside a variable shorthand expression */
116 /** Decrement operator (`--`) */116 Operators: {
117 Decrement: createToken({ name: 'Var.Decrement', pattern: /--/ }),117 /** Increment operator (`++`) */
118 /** Add/append operator (`+=`) - must come before Equals to avoid conflict */118 Increment: createToken({ name: 'Var.Increment', pattern: /\+\+/ }),
119 PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),119 /** Decrement operator (`--`) */
120 /** Set operator (`=`) */120 Decrement: createToken({ name: 'Var.Decrement', pattern: /--/ }),
121 Equals: createToken({ name: 'Var.Equals', pattern: /=/ }),121 /** Nullish coalescing assignment operator (`??=`) - sets var if undefined, must come before NullishCoalescing */
122 NullishCoalescingEquals: createToken({ name: 'Var.NullishCoalescingEquals', pattern: /\?\?=/ }),
123 /** Nullish coalescing operator (`??`) - returns default if var undefined */
124 NullishCoalescing: createToken({ name: 'Var.NullishCoalescing', pattern: /\?\?/ }),
125 /** Logical OR assignment operator (`||=`) - sets var if falsy, must come before LogicalOr */
126 LogicalOrEquals: createToken({ name: 'Var.LogicalOrEquals', pattern: /\|\|=/ }),
127 /** Logical OR operator (`||`) - returns default if var falsy */
128 LogicalOr: createToken({ name: 'Var.LogicalOr', pattern: /\|\|/ }),
129 /** Subtract operator (`-=`) - subtracts value from variable */
130 MinusEquals: createToken({ name: 'Var.MinusEquals', pattern: /-=/ }),
131 /** Equality comparison operator (`==`) - compares variable to value */
132 DoubleEquals: createToken({ name: 'Var.DoubleEquals', pattern: /==/ }),
133 /** Not equals comparison operator (`!=`) - compares variable to value, returns inverted result */
134 NotEquals: createToken({ name: 'Var.NotEquals', pattern: /!=/ }),
135 /** Add/append operator (`+=`) - must come before Equals to avoid conflict */
136 PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),
137 /** Set operator (`=`) */
138 Equals: createToken({ name: 'Var.Equals', pattern: /=/ }),
139 },
122 },140 },
123141
124 /**142 /**
@@ -230,11 +248,18 @@ const Def = {
230 // After the variable identifier, look for operators or end248 // After the variable identifier, look for operators or end
231 [modes.var_after_identifier]: [249 [modes.var_after_identifier]: [
232 using(Tokens.WhiteSpace),250 using(Tokens.WhiteSpace),
233 // Check for operators251 // Check for operators - order matters: longer patterns first
234 using(Tokens.Var.Increment),252 using(Tokens.Var.Operators.Increment),
235 using(Tokens.Var.Decrement),253 using(Tokens.Var.Operators.Decrement),
236 enter(Tokens.Var.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }),254 enter(Tokens.Var.Operators.NullishCoalescingEquals, modes.var_value, { andExits: modes.var_after_identifier }),
237 enter(Tokens.Var.Equals, modes.var_value, { andExits: modes.var_after_identifier }),255 enter(Tokens.Var.Operators.NullishCoalescing, modes.var_value, { andExits: modes.var_after_identifier }),
256 enter(Tokens.Var.Operators.LogicalOrEquals, modes.var_value, { andExits: modes.var_after_identifier }),
257 enter(Tokens.Var.Operators.LogicalOr, modes.var_value, { andExits: modes.var_after_identifier }),
258 enter(Tokens.Var.Operators.MinusEquals, modes.var_value, { andExits: modes.var_after_identifier }),
259 enter(Tokens.Var.Operators.DoubleEquals, modes.var_value, { andExits: modes.var_after_identifier }),
260 enter(Tokens.Var.Operators.NotEquals, modes.var_value, { andExits: modes.var_after_identifier }),
261 enter(Tokens.Var.Operators.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }),
262 enter(Tokens.Var.Operators.Equals, modes.var_value, { andExits: modes.var_after_identifier }),
238 // If we see the end, exit263 // If we see the end, exit
239 exits(Tokens.Macro.BeforeEnd, modes.var_after_identifier),264 exits(Tokens.Macro.BeforeEnd, modes.var_after_identifier),
240 // Fallback exit265 // Fallback exit
public/scripts/macros/engine/MacroParser.js+48 -6
@@ -88,27 +88,69 @@ class MacroParser extends CstParser {
88 // Variable identifier (name)88 // Variable identifier (name)
89 $.CONSUME(Tokens.Var.Identifier, { LABEL: 'Var.identifier' });89 $.CONSUME(Tokens.Var.Identifier, { LABEL: 'Var.identifier' });
9090
91 // Optional operator91 // Optional operator (and expression, if operator requires one)
92 $.OPTION2(() => $.SUBRULE($.variableOperator));92 $.OPTION2(() => $.SUBRULE($.variableOperator));
93 });93 });
9494
95 // Variable operator: ++, --, = value, += value95 // Variable operator: ++, --, = value, += value, -= value, ||, ??, ||=, ??=, ==, !=
96 $.variableOperator = $.RULE('variableOperator', () => {96 $.variableOperator = $.RULE('variableOperator', () => {
97 $.OR4([97 $.OR4([
98 { ALT: () => $.CONSUME(Tokens.Var.Increment, { LABEL: 'Var.operator' }) },98 { ALT: () => $.CONSUME(Tokens.Var.Operators.Increment, { LABEL: 'Var.operator' }) },
99 { ALT: () => $.CONSUME(Tokens.Var.Decrement, { LABEL: 'Var.operator' }) },99 { ALT: () => $.CONSUME(Tokens.Var.Operators.Decrement, { LABEL: 'Var.operator' }) },
100 {100 {
101 ALT: () => {101 ALT: () => {
102 $.CONSUME(Tokens.Var.Equals, { LABEL: 'Var.operator' });102 $.CONSUME(Tokens.Var.Operators.NullishCoalescingEquals, { LABEL: 'Var.operator' });
103 $.SUBRULE($.variableValue, { LABEL: 'Var.value' });103 $.SUBRULE($.variableValue, { LABEL: 'Var.value' });
104 },104 },
105 },105 },
106 {106 {
107 ALT: () => {107 ALT: () => {
108 $.CONSUME(Tokens.Var.PlusEquals, { LABEL: 'Var.operator' });108 $.CONSUME(Tokens.Var.Operators.NullishCoalescing, { LABEL: 'Var.operator' });
109 $.SUBRULE2($.variableValue, { LABEL: 'Var.value' });109 $.SUBRULE2($.variableValue, { LABEL: 'Var.value' });
110 },110 },
111 },111 },
112 {
113 ALT: () => {
114 $.CONSUME(Tokens.Var.Operators.LogicalOrEquals, { LABEL: 'Var.operator' });
115 $.SUBRULE3($.variableValue, { LABEL: 'Var.value' });
116 },
117 },
118 {
119 ALT: () => {
120 $.CONSUME(Tokens.Var.Operators.LogicalOr, { LABEL: 'Var.operator' });
121 $.SUBRULE4($.variableValue, { LABEL: 'Var.value' });
122 },
123 },
124 {
125 ALT: () => {
126 $.CONSUME(Tokens.Var.Operators.MinusEquals, { LABEL: 'Var.operator' });
127 $.SUBRULE5($.variableValue, { LABEL: 'Var.value' });
128 },
129 },
130 {
131 ALT: () => {
132 $.CONSUME(Tokens.Var.Operators.DoubleEquals, { LABEL: 'Var.operator' });
133 $.SUBRULE6($.variableValue, { LABEL: 'Var.value' });
134 },
135 },
136 {
137 ALT: () => {
138 $.CONSUME(Tokens.Var.Operators.NotEquals, { LABEL: 'Var.operator' });
139 $.SUBRULE7($.variableValue, { LABEL: 'Var.value' });
140 },
141 },
142 {
143 ALT: () => {
144 $.CONSUME(Tokens.Var.Operators.PlusEquals, { LABEL: 'Var.operator' });
145 $.SUBRULE8($.variableValue, { LABEL: 'Var.value' });
146 },
147 },
148 {
149 ALT: () => {
150 $.CONSUME(Tokens.Var.Operators.Equals, { LABEL: 'Var.operator' });
151 $.SUBRULE9($.variableValue, { LABEL: 'Var.value' });
152 },
153 },
112 ]);154 ]);
113 });155 });
114156
public/scripts/slash-commands/SlashCommandParser.js+27 -1
@@ -1714,7 +1714,33 @@ export class SlashCommandParser {
1714 return this.testSymbol('/');1714 return this.testSymbol('/');
1715 }1715 }
1716 testCommandEnd() {1716 testCommandEnd() {
1717 return this.testClosureEnd() || this.testSymbol('|');1717 if (this.testClosureEnd()) return true;
1718 // Only treat | as command end if we're not inside macro braces {{}}
1719 if (this.testSymbol('|') && !this.isInsideMacroBraces()) return true;
1720 return false;
1721 }
1722
1723 /**
1724 * Checks if the current position is inside unclosed macro braces {{...}}.
1725 * This prevents pipes inside macros from being treated as command separators.
1726 * @returns {boolean} True if inside unclosed macro braces.
1727 */
1728 isInsideMacroBraces() {
1729 const textBehind = this.behind;
1730 let depth = 0;
1731
1732 // Scan through the text to track macro brace depth
1733 for (let i = 0; i < textBehind.length; i++) {
1734 if (textBehind[i] === '{' && textBehind[i + 1] === '{') {
1735 depth++;
1736 i++; // Skip the second {
1737 } else if (textBehind[i] === '}' && textBehind[i + 1] === '}') {
1738 depth = Math.max(0, depth - 1);
1739 i++; // Skip the second }
1740 }
1741 }
1742
1743 return depth > 0;
1718 }1744 }
1719 parseCommand() {1745 parseCommand() {
1720 const start = this.index + 1;1746 const start = this.index + 1;
public/scripts/st-context.js+3 -1
@@ -95,7 +95,7 @@ import { tokenizers, getTextTokens, getTokenCount, getTokenCountAsync, getTokeni
95import { ToolManager } from './tool-calling.js';95import { ToolManager } from './tool-calling.js';
96import { accountStorage } from './util/AccountStorage.js';96import { accountStorage } from './util/AccountStorage.js';
97import { timestampToMoment, uuidv4 } from './utils.js';97import { timestampToMoment, uuidv4 } from './utils.js';
98import { addGlobalVariable, addLocalVariable, decrementGlobalVariable, decrementLocalVariable, deleteGlobalVariable, deleteLocalVariable, getGlobalVariable, getLocalVariable, incrementGlobalVariable, incrementLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';98import { addGlobalVariable, addLocalVariable, decrementGlobalVariable, decrementLocalVariable, deleteGlobalVariable, deleteLocalVariable, existsGlobalVariable, existsLocalVariable, getGlobalVariable, getLocalVariable, incrementGlobalVariable, incrementLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';
99import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList } from './world-info.js';99import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList } from './world-info.js';
100import { ChatCompletionService, TextCompletionService } from './custom-request.js';100import { ChatCompletionService, TextCompletionService } from './custom-request.js';
101import { ConnectionManagerRequestService } from './extensions/shared.js';101import { ConnectionManagerRequestService } from './extensions/shared.js';
@@ -243,6 +243,7 @@ export function getContext() {
243 add: addLocalVariable,243 add: addLocalVariable,
244 inc: incrementLocalVariable,244 inc: incrementLocalVariable,
245 dec: decrementLocalVariable,245 dec: decrementLocalVariable,
246 has: existsLocalVariable,
246 },247 },
247 global: {248 global: {
248 get: getGlobalVariable,249 get: getGlobalVariable,
@@ -251,6 +252,7 @@ export function getContext() {
251 add: addGlobalVariable,252 add: addGlobalVariable,
252 inc: incrementGlobalVariable,253 inc: incrementGlobalVariable,
253 dec: decrementGlobalVariable,254 dec: decrementGlobalVariable,
255 has: existsGlobalVariable,
254 },256 },
255 },257 },
256 loadWorldInfo,258 loadWorldInfo,
public/scripts/variables.js+2 -2
@@ -438,7 +438,7 @@ async function ifCallback(args, value) {
438 * @param {string} name Local variable name438 * @param {string} name Local variable name
439 * @returns {boolean} True if the local variable exists, false otherwise439 * @returns {boolean} True if the local variable exists, false otherwise
440 */440 */
441function existsLocalVariable(name) {441export function existsLocalVariable(name) {
442 return chat_metadata.variables && chat_metadata.variables[name] !== undefined;442 return chat_metadata.variables && chat_metadata.variables[name] !== undefined;
443}443}
444444
@@ -447,7 +447,7 @@ function existsLocalVariable(name) {
447 * @param {string} name Global variable name447 * @param {string} name Global variable name
448 * @returns {boolean} True if the global variable exists, false otherwise448 * @returns {boolean} True if the global variable exists, false otherwise
449 */449 */
450function existsGlobalVariable(name) {450export function existsGlobalVariable(name) {
451 return extension_settings.variables.global && extension_settings.variables.global[name] !== undefined;451 return extension_settings.variables.global && extension_settings.variables.global[name] !== undefined;
452}452}
453453
tests/frontend/MacroEngine.e2e.js+382 -0
@@ -2559,6 +2559,341 @@ test.describe('MacroEngine', () => {
2559 // setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'2559 // setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'
2560 expect(output).toBe('616');2560 expect(output).toBe('616');
2561 });2561 });
2562
2563 // {{.myvar -= 5}} - subtract from local variable
2564 test('should subtract from local variable with -= shorthand', async ({ page }) => {
2565 const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= 3}}Then: {{.myvar}}', { local: { myvar: '10' } });
2566 // subvar returns '', then "Then: ", then getvar returns "7"
2567 expect(output).toBe('Then: 7');
2568 });
2569
2570 // {{$myvar -= 5}} - subtract from global variable
2571 test('should subtract from global variable with -= shorthand', async ({ page }) => {
2572 const output = await evaluateWithEngineAndVariables(page, '{{$myvar -= 5}}{{$myvar}}', { global: { myvar: '20' } });
2573 expect(output).toBe('15');
2574 });
2575
2576 // {{.myvar || default}} - returns default when falsy
2577 test('should return default value with || when variable is falsy (empty)', async ({ page }) => {
2578 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '' } });
2579 expect(output).toBe('fallback');
2580 });
2581
2582 test('should return default value with || when variable is falsy (zero)', async ({ page }) => {
2583 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: '0' } });
2584 expect(output).toBe('fallback');
2585 });
2586
2587 test('should return variable value with || when truthy', async ({ page }) => {
2588 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || fallback}}', { local: { myvar: 'existing' } });
2589 expect(output).toBe('existing');
2590 });
2591
2592 test('should return default value with || when variable does not exist', async ({ page }) => {
2593 const output = await evaluateWithEngineAndVariables(page, '{{.nonexistent || default}}', { local: {} });
2594 expect(output).toBe('default');
2595 });
2596
2597 // {{.myvar ?? default}} - returns default only when undefined
2598 test('should return default value with ?? when variable does not exist', async ({ page }) => {
2599 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: {} });
2600 expect(output).toBe('fallback');
2601 });
2602
2603 test('should return empty string with ?? when variable exists but is empty', async ({ page }) => {
2604 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? fallback}}]', { local: { myvar: '' } });
2605 expect(output).toBe('[]');
2606 });
2607
2608 test('should return zero with ?? when variable exists and is zero', async ({ page }) => {
2609 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: '0' } });
2610 expect(output).toBe('0');
2611 });
2612
2613 test('should return variable value with ?? when it exists', async ({ page }) => {
2614 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? fallback}}', { local: { myvar: 'value' } });
2615 expect(output).toBe('value');
2616 });
2617
2618 // {{.myvar ||= default}} - sets and returns default when falsy
2619 test('should set and return default with ||= when variable is falsy', async ({ page }) => {
2620 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: '' } });
2621 // ||= returns 'newval', then getvar also returns 'newval'
2622 expect(output).toBe('newvalnewval');
2623 });
2624
2625 test('should not set and return current with ||= when variable is truthy', async ({ page }) => {
2626 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= newval}}{{.myvar}}', { local: { myvar: 'existing' } });
2627 // ||= returns 'existing', then getvar returns 'existing'
2628 expect(output).toBe('existingexisting');
2629 });
2630
2631 test('should set and return default with ||= when variable does not exist', async ({ page }) => {
2632 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= created}}{{.myvar}}', { local: {} });
2633 expect(output).toBe('createdcreated');
2634 });
2635
2636 // {{.myvar ??= default}} - sets and returns default only when undefined
2637 test('should set and return default with ??= when variable does not exist', async ({ page }) => {
2638 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= created}}{{.myvar}}', { local: {} });
2639 expect(output).toBe('createdcreated');
2640 });
2641
2642 test('should not set and return current with ??= when variable exists but is empty', async ({ page }) => {
2643 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ??= newval}}][{{.myvar}}]', { local: { myvar: '' } });
2644 // ??= returns '' (current value), then getvar returns '' (unchanged)
2645 expect(output).toBe('[][]');
2646 });
2647
2648 test('should not set and return current with ??= when variable exists and is zero', async ({ page }) => {
2649 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= newval}}{{.myvar}}', { local: { myvar: '0' } });
2650 // ??= returns '0', then getvar returns '0'
2651 expect(output).toBe('00');
2652 });
2653
2654 // {{.myvar == value}} - equality comparison
2655 test('should return true when variable equals value with ==', async ({ page }) => {
2656 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == hello}}', { local: { myvar: 'hello' } });
2657 expect(output).toBe('true');
2658 });
2659
2660 test('should return false when variable does not equal value with ==', async ({ page }) => {
2661 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == world}}', { local: { myvar: 'hello' } });
2662 expect(output).toBe('false');
2663 });
2664
2665 test('should compare empty variable correctly with ==', async ({ page }) => {
2666 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ==}}', { local: { myvar: '' } });
2667 expect(output).toBe('true');
2668 });
2669
2670 test('should compare numeric value correctly with ==', async ({ page }) => {
2671 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == 42}}', { local: { myvar: '42' } });
2672 expect(output).toBe('true');
2673 });
2674
2675 // {{.myvar != value}} - inequality comparison
2676 test('should return true when variable does not equal value with !=', async ({ page }) => {
2677 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != world}}', { local: { myvar: 'hello' } });
2678 expect(output).toBe('true');
2679 });
2680
2681 test('should return false when variable equals value with !=', async ({ page }) => {
2682 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != hello}}', { local: { myvar: 'hello' } });
2683 expect(output).toBe('false');
2684 });
2685
2686 test('should compare empty variable correctly with !=', async ({ page }) => {
2687 const output = await evaluateWithEngineAndVariables(page, '{{.myvar !=}}', { local: { myvar: '' } });
2688 expect(output).toBe('false');
2689 });
2690
2691 test('should compare non-empty to empty with !=', async ({ page }) => {
2692 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != }}', { local: { myvar: 'value' } });
2693 expect(output).toBe('true');
2694 });
2695
2696 test('should compare numeric value correctly with !=', async ({ page }) => {
2697 const output = await evaluateWithEngineAndVariables(page, '{{.myvar != 99}}', { local: { myvar: '42' } });
2698 expect(output).toBe('true');
2699 });
2700
2701 // Global variable versions of new operators
2702 test('should use || with global variable', async ({ page }) => {
2703 const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } });
2704 expect(output).toBe('globaldefault');
2705 });
2706
2707 test('should use ?? with global variable', async ({ page }) => {
2708 const output = await evaluateWithEngineAndVariables(page, '{{$myvar ?? globaldefault}}', { global: {} });
2709 expect(output).toBe('globaldefault');
2710 });
2711
2712 test('should use ||= with global variable', async ({ page }) => {
2713 const output = await evaluateWithEngineAndVariables(page, '{{$myvar ||= gset}}{{$myvar}}', { global: { myvar: '' } });
2714 expect(output).toBe('gsetgset');
2715 });
2716
2717 test('should use ??= with global variable', async ({ page }) => {
2718 const output = await evaluateWithEngineAndVariables(page, '{{$myvar ??= gcreated}}{{$myvar}}', { global: {} });
2719 expect(output).toBe('gcreatedgcreated');
2720 });
2721
2722 test('should use == with global variable', async ({ page }) => {
2723 const output = await evaluateWithEngineAndVariables(page, '{{$myvar == test}}', { global: { myvar: 'test' } });
2724 expect(output).toBe('true');
2725 });
2726
2727 // Nested macro in fallback value
2728 test('should support nested macro in || fallback value', async ({ page }) => {
2729 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || Hello {{user}}}}', { local: {} });
2730 expect(output).toBe('Hello User');
2731 });
2732
2733 test('should support nested macro in ?? fallback value', async ({ page }) => {
2734 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? Hello {{user}}}}', { local: {} });
2735 expect(output).toBe('Hello User');
2736 });
2737
2738 // Whitespace handling with new operators
2739 test('should handle whitespace with || operator', async ({ page }) => {
2740 const output = await evaluateWithEngineAndVariables(page, '{{ .myvar || spaced }}', { local: {} });
2741 expect(output).toBe('spaced');
2742 });
2743
2744 test('should handle whitespace with ?? operator', async ({ page }) => {
2745 const output = await evaluateWithEngineAndVariables(page, '{{ .myvar ?? spaced }}', { local: {} });
2746 expect(output).toBe('spaced');
2747 });
2748 });
2749
2750 test.describe('Variable Shorthand Lazy Evaluation', () => {
2751 // Tests to verify that fallback value expressions are only evaluated when needed.
2752 // This is important for performance and because some macros are stateful.
2753
2754 // ?? should NOT evaluate fallback when variable exists
2755 test('should NOT evaluate ?? fallback when variable exists', async ({ page }) => {
2756 // Use setvar in the fallback - if lazy evaluation works, tracker should remain unset
2757 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'exists' } });
2758 // myvar exists, so ?? returns 'exists' and the fallback (which would set tracker) is NOT evaluated
2759 expect(output).toBe('exists[]');
2760 });
2761
2762 test('should evaluate ?? fallback when variable does not exist', async ({ page }) => {
2763 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ?? {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: {} });
2764 // myvar doesn't exist, so ?? evaluates and returns the fallback, setting tracker
2765 expect(output).toBe('fallback[evaluated]');
2766 });
2767
2768 // || should NOT evaluate fallback when variable is truthy
2769 test('should NOT evaluate || fallback when variable is truthy', async ({ page }) => {
2770 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
2771 // myvar is truthy, so || returns 'truthy' and the fallback is NOT evaluated
2772 expect(output).toBe('truthy[]');
2773 });
2774
2775 test('should evaluate || fallback when variable is falsy', async ({ page }) => {
2776 const output = await evaluateWithEngineAndVariables(page, '{{.myvar || {{.tracker = evaluated}}fallback}}[{{.tracker}}]', { local: { myvar: '' } });
2777 // myvar is falsy, so || evaluates and returns the fallback, setting tracker
2778 expect(output).toBe('fallback[evaluated]');
2779 });
2780
2781 // ??= should NOT evaluate value when variable exists
2782 test('should NOT evaluate ??= value when variable exists', async ({ page }) => {
2783 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'exists' } });
2784 // myvar exists, so ??= returns current value and the value expression is NOT evaluated
2785 expect(output).toBe('exists[]');
2786 });
2787
2788 test('should evaluate ??= value when variable does not exist', async ({ page }) => {
2789 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ??= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: {} });
2790 // myvar doesn't exist, so ??= evaluates value, sets myvar, and returns it
2791 expect(output).toBe('newval[evaluated]');
2792 });
2793
2794 // ||= should NOT evaluate value when variable is truthy
2795 test('should NOT evaluate ||= value when variable is truthy', async ({ page }) => {
2796 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: 'truthy' } });
2797 // myvar is truthy, so ||= returns current value and the value expression is NOT evaluated
2798 expect(output).toBe('truthy[]');
2799 });
2800
2801 test('should evaluate ||= value when variable is falsy', async ({ page }) => {
2802 const output = await evaluateWithEngineAndVariables(page, '{{.myvar ||= {{.tracker = evaluated}}newval}}[{{.tracker}}]', { local: { myvar: '' } });
2803 // myvar is falsy, so ||= evaluates value, sets myvar, and returns it
2804 expect(output).toBe('newval[evaluated]');
2805 });
2806
2807 // Operators that ALWAYS evaluate value should still work
2808 test('should always evaluate = value expression', async ({ page }) => {
2809 const output = await evaluateWithEngineAndVariables(page, '{{.myvar = {{.tracker = evaluated}}value}}[{{.tracker}}]', { local: {} });
2810 expect(output).toBe('[evaluated]');
2811 });
2812
2813 test('should always evaluate += value expression', async ({ page }) => {
2814 const output = await evaluateWithEngineAndVariables(page, '{{.myvar += {{.tracker = evaluated}}5}}[{{.tracker}}]', { local: { myvar: '10' } });
2815 expect(output).toBe('[evaluated]');
2816 });
2817
2818 test('should always evaluate == value expression', async ({ page }) => {
2819 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == {{.tracker = evaluated}}test}}[{{.tracker}}]', { local: { myvar: 'test' } });
2820 expect(output).toBe('true[evaluated]');
2821 });
2822
2823 // Value should only be evaluated once (caching test)
2824 test('should only evaluate value expression once when needed', async ({ page }) => {
2825 // Use addvar to track how many times the value is evaluated (addvar returns empty string)
2826 const output = await evaluateWithEngineAndVariables(page, '{{.counter = 0}}{{.myvar ??= {{.counter += 1}}value}}{{.counter}}', { local: {} });
2827 // counter should be 1 (value evaluated exactly once)
2828 expect(output).toBe('value1');
2829 });
2830 });
2831
2832 test.describe('Variable Shorthand Edge Cases', () => {
2833 // Operators requiring a value but value is empty
2834 test('should handle = operator with empty value', async ({ page }) => {
2835 const output = await evaluateWithEngineAndVariables(page, '{{.myvar = }}[{{.myvar}}]', { local: {} });
2836 // Empty value after = should set the variable to empty string
2837 expect(output).toBe('[]');
2838 });
2839
2840 test('should handle += operator with empty value', async ({ page }) => {
2841 const output = await evaluateWithEngineAndVariables(page, '{{.myvar += }}[{{.myvar}}]', { local: { myvar: 'existing' } });
2842 // Empty value after += should add nothing
2843 expect(output).toBe('[existing]');
2844 });
2845
2846 test('should handle -= operator with empty value (non-numeric)', async ({ page }) => {
2847 const output = await evaluateWithEngineAndVariables(page, '{{.myvar -= }}[{{.myvar}}]', { local: { myvar: '10' } });
2848 // Empty value is NaN, so subtraction fails silently and returns empty
2849 expect(output).toBe('[10]');
2850 });
2851
2852 test('should handle || operator with empty fallback', async ({ page }) => {
2853 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar || }}]', { local: { myvar: '' } });
2854 // Falsy myvar, empty fallback - returns empty string
2855 expect(output).toBe('[]');
2856 });
2857
2858 test('should handle ?? operator with empty fallback', async ({ page }) => {
2859 const output = await evaluateWithEngineAndVariables(page, '[{{.myvar ?? }}]', { local: {} });
2860 // Undefined myvar, empty fallback - returns empty string
2861 expect(output).toBe('[]');
2862 });
2863
2864 test('should handle == operator with empty comparison value', async ({ page }) => {
2865 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: '' } });
2866 // Empty var equals empty value - should be true
2867 expect(output).toBe('true');
2868 });
2869
2870 test('should handle == operator comparing non-empty to empty', async ({ page }) => {
2871 const output = await evaluateWithEngineAndVariables(page, '{{.myvar == }}', { local: { myvar: 'value' } });
2872 // Non-empty var vs empty value - should be false
2873 expect(output).toBe('false');
2874 });
2875
2876 // Operators that don't take values - should return raw if invalid
2877 test('should return raw with trailing content after ++ operator', async ({ page }) => {
2878 const output = await evaluateWithEngineAndVariables(page, '{{.myvar++5}}', { local: { myvar: '5' } });
2879 expect(output).toBe('{{.myvar++5}}');
2880 });
2881
2882 test('should return empty with trailing content after -- operator', async ({ page }) => {
2883 // This is a weird case. The "--" operator does not accept value expression, but writing it like this,
2884 // makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
2885 // This is intended, so this resolving to null, as the variable does not exist, is also intended.
2886 const output = await evaluateWithEngineAndVariables(page, '{{.myvar--5}}', { local: { myvar: '10' } });
2887 expect(output).toBe('');
2888 });
2889
2890 test('should return raw with trailing content after -- operator separated by spaces', async ({ page }) => {
2891 // This is a weird case. The "--" operator does not accept value expression, but writing it like this,
2892 // makes the parser treat "myvar--5" as the variable identifier, as dashes and numbers are allowed.
2893 // This is intended, so this resolving to null, as the variable does not exist, is also intended.
2894 const output = await evaluateWithEngineAndVariables(page, '{{.myvar -- 5}}', { local: { myvar: '10' } });
2895 expect(output).toBe('{{.myvar -- 5}}');
2896 });
2562 });2897 });
25632898
2564 test.describe('Variable Shorthand in {{if}} Macro', () => {2899 test.describe('Variable Shorthand in {{if}} Macro', () => {
@@ -2814,6 +3149,53 @@ test.describe('MacroEngine', () => {
2814 expect(output).toBe('found');3149 expect(output).toBe('found');
2815 });3150 });
2816 });3151 });
3152
3153 test.describe('Variable Macros (hasvar, deletevar)', () => {
3154 // {{hasvar::name}} - check if local variable exists
3155 test('should return true when local variable exists', async ({ page }) => {
3156 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: 'value' } });
3157 expect(output).toBe('true');
3158 });
3159
3160 test('should return false when local variable does not exist', async ({ page }) => {
3161 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::nonexistent}}', { local: {} });
3162 expect(output).toBe('false');
3163 });
3164
3165 test('should return true when local variable exists but is empty', async ({ page }) => {
3166 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}', { local: { myvar: '' } });
3167 expect(output).toBe('true');
3168 });
3169
3170 // {{hasglobalvar::name}} - check if global variable exists
3171 test('should return true when global variable exists', async ({ page }) => {
3172 const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
3173 expect(output).toBe('true');
3174 });
3175
3176 test('should return false when global variable does not exist', async ({ page }) => {
3177 const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::nonexistent}}', { global: {} });
3178 expect(output).toBe('false');
3179 });
3180
3181 // {{deletevar::name}} - delete local variable
3182 test('should delete local variable', async ({ page }) => {
3183 const output = await evaluateWithEngineAndVariables(page, '{{hasvar::myvar}}{{deletevar::myvar}}{{hasvar::myvar}}', { local: { myvar: 'value' } });
3184 expect(output).toBe('truefalse');
3185 });
3186
3187 // {{deleteglobalvar::name}} - delete global variable
3188 test('should delete global variable', async ({ page }) => {
3189 const output = await evaluateWithEngineAndVariables(page, '{{hasglobalvar::myvar}}{{deleteglobalvar::myvar}}{{hasglobalvar::myvar}}', { global: { myvar: 'value' } });
3190 expect(output).toBe('truefalse');
3191 });
3192
3193 // Combining hasvar with if
3194 test('should use hasvar in if condition', async ({ page }) => {
3195 const output = await evaluateWithEngineAndVariables(page, '{{if {{hasvar::myvar}} == true}}exists{{else}}missing{{/if}}', { local: { myvar: '' } });
3196 expect(output).toBe('exists');
3197 });
3198 });
2817});3199});
28183200
2819/**3201/**