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, +858 -82Showing whitespace changes
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+98 -16
@@ -499,13 +499,13 @@ export const VariableShorthandDefinitions = new Map([
499499 type: VariableShorthandType.LOCAL,
500500 name: 'Local Variable',
501501 description: 'Access or modify a local variable (scoped to current chat).',
502502 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)'],
503503 }],
504504 [VariableShorthandType.GLOBAL, {
505505 type: VariableShorthandType.GLOBAL,
506506 name: 'Global Variable',
507507 description: 'Access or modify a global variable (shared across all chats).',
508508 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)'],
509509 }],
510510]);
511511
@@ -622,10 +622,17 @@ export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
622622 const prefix = this.#varDef.type;
623623 const examples = [
624624 `{{${prefix}myvar}} - Get variable value`,
625625 `{{${prefix}myvar = value}} - Set variable (returns nothing)`,
626626 `{{${prefix}counter++}} - Increment and get value`,
627627 `{{${prefix}counter--}} - Decrement and get value`,
628628 `{{${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)`,
629636 ];
630637 for (const ex of examples) {
631638 const li = document.createElement('li');
@@ -796,6 +803,13 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption {
796803 `{{${prefix}${this.#varName}++}} - Increment`,
797804 `{{${prefix}${this.#varName}--}} - Decrement`,
798805 `{{${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)`,
799813 ];
800814 for (const ex of examples) {
801815 const li = document.createElement('li');
@@ -817,25 +831,67 @@ export const VariableOperatorDefinitions = new Map([
817831 ['=', {
818832 symbol: '=',
819833 name: 'Set',
820834 description: 'Set the variable to a new value. Returns nothing.',
821835 needsValue: true,
822836 }],
823837 ['++', {
824838 symbol: '++',
825839 name: 'Increment',
826840 description: 'Increment the variable by 1 (numeric). Returns the new value.',
827841 needsValue: false,
828842 }],
829843 ['--', {
830844 symbol: '--',
831845 name: 'Decrement',
832846 description: 'Decrement the variable by 1 (numeric). Returns the new value.',
833847 needsValue: false,
834848 }],
835849 ['+=', {
836850 symbol: '+=',
837851 name: 'Add',
838852 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.',
839895 needsValue: true,
840896 }],
841897]);
@@ -1131,7 +1187,8 @@ export function parseMacroContext(macroText, cursorOffset) {
11311187 i++;
11321188 }
11331189
11341190 // Check for operators: ++, --, +=, -=, ||=, ??=, ||, ??, ==, =
1191+ // Order matters: longer operators must be checked before shorter ones
11351192 // Also track partial operator prefixes for autocomplete
11361193 const operatorText = macroText.slice(i);
11371194 let hasInvalidTrailingChars = false;
@@ -1143,13 +1200,34 @@ export function parseMacroContext(macroText, cursorOffset) {
11431200 } else if (operatorText.startsWith('--')) {
11441201 variableOperator = '--';
11451202 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;
11461215 } else if (operatorText.startsWith('+=')) {
11471216 variableOperator = '+=';
11481217 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;
11491227 } else if (operatorText.startsWith('=')) {
11501228 variableOperator = '=';
11511229 i += 1;
11521230 } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!')) {
11531231 // Partial operator prefix - user is typing an operator
11541232 partialOperator = operatorText[0];
11551233 } else if (operatorText.length > 0 && !/^\s/.test(operatorText)) {
@@ -1159,8 +1237,12 @@ export function parseMacroContext(macroText, cursorOffset) {
11591237 invalidTrailingChars = operatorText.trim();
11601238 }
11611239
11621240 // IfCheck if operator requires a value (= or +=), parse the 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) {
11641246 // Skip whitespace after operator
11651247 while (i < macroText.length && /\s/.test(macroText[i])) {
11661248 i++;
@@ -1178,9 +1260,9 @@ export function parseMacroContext(macroText, cursorOffset) {
11781260 isTypingVariableName = true;
11791261 } else if (!variableOperator && !hasInvalidTrailingChars) {
11801262 // Cursor is after variable name but no operator yet (and no invalid chars)
11811263 // This includes partial operator prefixes like '+' or, '-', '|', '?'
11821264 isTypingOperator = true;
1183- } else if (variableOperator === '=' || variableOperator === '+=') {
1265+ } else if (operatorNeedsValue) {
11841266 // Operator that requires value - cursor is in value area
11851267 isTypingValue = true;
11861268 }
public/scripts/macros/definitions/variable-macros.js+86 -5
@@ -68,7 +68,7 @@ export function registerVariableMacros() {
6868 description: 'Increments a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
6969 returns: 'The new value of the local variable.',
7070 returnType: MacroValueType.NUMBER,
7171 exampleUsage: ['{{incvar::myintvar}}', '{{incvar some-local-int-var}}'],
7272 handler: ({ unnamedArgs: [name], normalize }) => {
7373 const result = ctx.variables.local.inc(name);
7474 return normalize(result);
@@ -88,7 +88,7 @@ export function registerVariableMacros() {
8888 description: 'Decrements a local variable by 1 and returns the new value. If the variable does not exist, it will be created.',
8989 returns: 'The new value of the local variable.',
9090 returnType: MacroValueType.NUMBER,
9191 exampleUsage: ['{{decvar::myintvar}}', '{{decvar some-local-int-var}}'],
9292 handler: ({ unnamedArgs: [name], normalize }) => {
9393 const result = ctx.variables.local.dec(name);
9494 return normalize(result);
@@ -108,13 +108,53 @@ export function registerVariableMacros() {
108108 description: 'Gets the value of a local variable.',
109109 returns: 'The value of the local variable.',
110110 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
111111 exampleUsage: ['{{getvar::myvar}}', '{{getvar:: myintvar}}'],
112112 handler: ({ unnamedArgs: [name], normalize }) => {
113113 const result = ctx.variables.local.get(name);
114114 return normalize(result);
115115 },
116116 });
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+
118158 // {{setglobalvar::name::value}} -> ''
119159 MacroRegistry.registerMacro('setglobalvar', {
120160 category: MacroCategory.VARIABLE,
@@ -176,6 +216,7 @@ export function registerVariableMacros() {
176216 description: 'Increments a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
177217 returns: 'The new value of the global variable.',
178218 returnType: MacroValueType.NUMBER,
219+ exampleUsage: ['{{incglobalvar::myintvar}}', '{{incglobalvar some-global-int-var}}'],
179220 handler: ({ unnamedArgs: [name], normalize }) => {
180221 const result = ctx.variables.global.inc(name);
181222 return normalize(result);
@@ -195,7 +236,7 @@ export function registerVariableMacros() {
195236 description: 'Decrements a global variable by 1 and returns the new value. If the variable does not exist, it will be created.',
196237 returns: 'The new value of the global variable.',
197238 returnType: MacroValueType.NUMBER,
198239 exampleUsage: ['{{decglobalvar::myintvar}}', '{{decglobalvar some-global-int-var}}'],
199240 handler: ({ unnamedArgs: [name], normalize }) => {
200241 const result = ctx.variables.global.dec(name);
201242 return normalize(result);
@@ -215,10 +256,50 @@ export function registerVariableMacros() {
215256 description: 'Gets the value of a global variable.',
216257 returns: 'The value of the global variable.',
217258 returnType: [MacroValueType.STRING, MacroValueType.NUMBER],
218259 exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar:: myintvar}}'],
219260 handler: ({ unnamedArgs: [name], normalize }) => {
220261 const result = ctx.variables.global.get(name);
221262 return normalize(result);
222263 },
223264 });
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+ });
224305}
public/scripts/macros/engine/MacroCstWalker.js+182 -46
@@ -3,9 +3,12 @@
33/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
44/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
55
6+import { logMacroInternalError, logMacroRuntimeWarning } from './MacroDiagnostics.js';
7+import { MacroEngine } from './MacroEngine.js';
68import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';
79import { MacroParser } from './MacroParser.js';
810import { MacroRegistry } from './MacroRegistry.js';
11+import { isFalseBoolean } from '/scripts/utils.js';
912
1013/**
1114 * @typedef {Object} MacroCall
@@ -493,7 +496,10 @@ class MacroCstWalker {
493496 }
494497
495498 /**
496499 * Evaluates a variable expression node and routes it to theusing appropriatedirect variable macroAPI 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 (==).
497503 *
498504 * @param {CstNode} macroNode - The parent macro node.
499505 * @param {CstNode} variableExprNode - The variableExpr CST node.
@@ -501,9 +507,6 @@ class MacroCstWalker {
501507 * @returns {string}
502508 */
503509 #evaluateVariableExpr(macroNode, variableExprNode, context) {
504- const { text, contextOffset, env, resolveMacro } = context;
505-
506- const children = macroNode.children || {};
507510 const varChildren = variableExprNode.children || {};
508511
509512 // Extract scope (. for local, $ for global)
@@ -518,9 +521,9 @@ class MacroCstWalker {
518521 const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]);
519522 const operatorChildren = operatorNode?.children || {};
520523
521524 // Determine operation and whether a value expression is expected
522525 let operation = 'get';
523526 let valuehasValueExpr = nullfalse;
524527
525528 if (operatorNode) {
526529 const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []);
@@ -528,60 +531,193 @@ class MacroCstWalker {
528531
529532 if (operatorToken) {
530533 const operatorImage = operatorToken.image;
531534 ifswitch (operatorImage === '++') {
535+ case '++':
532536 operation = 'inc';
533- } else if (operatorImage === '--') {
537+ break;
538+ case '--':
534539 operation = 'dec';
535- } else if (operatorImage === '=') {
540+ break;
541+ case '=':
536542 operation = 'set';
537- value = this.#evaluateVariableValue(operatorChildren, context);
543+ hasValueExpr = true;
538- } else if (operatorImage === '+=') {
544+ break;
545+ case '+=':
539546 operation = 'add';
540- value = this.#evaluateVariableValue(operatorChildren, context);
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;
580+ }
581+ }
582+ }
583+
584+ // Create a lazy value resolver that caches its result on first call.
585+ // This ensures the value expression is only evaluated when actually needed,
586+ // which is important for performance and because some macros are stateful.
587+ const lazyValue = hasValueExpr ? this.#createLazyValue(operatorChildren, context) : () => '';
588+
589+ // Execute the operation using direct variable API calls
590+ return this.#executeVariableOperation(varName, isGlobal, operation, lazyValue);
541591 }
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;
542609 }
610+ return cached;
611+ };
543612 }
544613
545- // Map operation to macro name
614+ /**
546- const macroNameMap = {
615+ * Executes a variable operation using the SillyTavern context API.
547- get: isGlobal ? 'getglobalvar' : 'getvar',
616+ *
548- set: isGlobal ? 'setglobalvar' : 'setvar',
617+ * @param {string} varName - The variable name.
549- inc: isGlobal ? 'incglobalvar' : 'incvar',
618+ * @param {boolean} isGlobal - Whether this is a global ($) or local (.) variable.
550- dec: isGlobal ? 'decglobalvar' : 'decvar',
619+ * @param {string} operation - The operation to perform.
551- add: isGlobal ? 'addglobalvar' : 'addvar',
620+ * @param {() => string} lazyValue - A lazy function that returns the value when called. Only evaluated when needed.
552- };
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));
553651
554- const targetMacroName = macroNameMap[operation];
652+ case 'dec':
653+ return normalize(vars.dec(varName));
555654
556- // Build args array based on operation
655+ case 'add':
557- const args = [varName];
656+ vars.add(varName, lazyValue());
558- if (value !== null) {
657+ return '';
559- args.push(value);
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 '';
560665 }
561666
562- const range = this.#getMacroRange(macroNode);
667+ case 'logicalOr': {
668+ // Returns default value if variable is falsy, otherwise returns variable value
669+ // Value is only resolved if needed (when variable is falsy)
670+ const currentValue = vars.get(varName);
671+ return isFalsy(currentValue) ? normalize(lazyValue()) : normalize(currentValue);
672+ }
563673
564- /** @type {MacroCall} */
674+ case 'nullishCoalescing': {
565- const call = {
675+ // Returns default value only if variable doesn't exist, otherwise returns variable value (even if falsy)
566- name: targetMacroName,
676+ // Value is only resolved if needed (when variable doesn't exist)
567- args,
677+ const exists = vars.has(varName);
568- flags: createEmptyFlags(),
678+ return exists ? normalize(vars.get(varName)) : normalize(lazyValue());
569- isScoped: false,
679+ }
570- isVariableShorthand: true,
680+
571- rawInner: text.slice(
681+ case 'logicalOrAssign': {
572- (/** @type {IToken|undefined} */ (children['Macro.Start']?.[0])?.endOffset ?? range.startOffset) + 1,
682+ // If variable is falsy, set it to value and return value; otherwise return current value
573- (/** @type {IToken|undefined} */ (children['Macro.End']?.[0])?.startOffset ?? range.endOffset + 1) - 1,
683+ // Value is only resolved if needed (when variable is falsy)
574- ),
684+ const currentValue = vars.get(varName);
575- rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
685+ if (isFalsy(currentValue)) {
576- rawArgs: args,
686+ vars.set(varName, lazyValue());
577- range,
687+ return normalize(lazyValue());
578- globalOffset: contextOffset + range.startOffset,
688+ }
579- cstNode: macroNode,
689+ return normalize(currentValue);
580- env,
690+ }
581- };
691+
692+ case 'nullishCoalescingAssign': {
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+ }
582702
583- const result = resolveMacro(call);
703+ case 'equals': {
584- return typeof result === 'string' ? result : String(result ?? '');
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+ }
585721 }
586722
587723 /**
public/scripts/macros/engine/MacroLexer.js+30 -5
@@ -111,15 +111,33 @@ const Tokens = Object.freeze({
111111 * Invalid: my-, my--, -var
112112 */
113113 Identifier: createToken({ name: 'Var.Identifier', pattern: MACRO_VARIABLE_SHORTHAND_PATTERN }),
114+
115+ /** All tokens that are valid operators inside a variable shorthand expression */
116+ Operators: {
114117 /** Increment operator (`++`) */
115118 Increment: createToken({ name: 'Var.Increment', pattern: /\+\+/ }),
116119 /** Decrement operator (`--`) */
117120 Decrement: createToken({ name: 'Var.Decrement', 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: /!=/ }),
118135 /** Add/append operator (`+=`) - must come before Equals to avoid conflict */
119136 PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),
120137 /** Set operator (`=`) */
121138 Equals: createToken({ name: 'Var.Equals', pattern: /=/ }),
122139 },
140+ },
123141
124142 /**
125143 * Capture unknown characters one by one, to still allow other tokens being matched once they are there.
@@ -230,11 +248,18 @@ const Def = {
230248 // After the variable identifier, look for operators or end
231249 [modes.var_after_identifier]: [
232250 using(Tokens.WhiteSpace),
233251 // Check for operators - order matters: longer patterns first
234252 using(Tokens.Var.Operators.Increment),
235253 using(Tokens.Var.Operators.Decrement),
236254 enter(Tokens.Var.PlusEqualsOperators.NullishCoalescingEquals, modes.var_value, { andExits: modes.var_after_identifier }),
237255 enter(Tokens.Var.EqualsOperators.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 }),
238263 // If we see the end, exit
239264 exits(Tokens.Macro.BeforeEnd, modes.var_after_identifier),
240265 // Fallback exit
public/scripts/macros/engine/MacroParser.js+48 -6
@@ -88,27 +88,69 @@ class MacroParser extends CstParser {
8888 // Variable identifier (name)
8989 $.CONSUME(Tokens.Var.Identifier, { LABEL: 'Var.identifier' });
9090
91- // Optional operator
91+ // Optional operator (and expression, if operator requires one)
9292 $.OPTION2(() => $.SUBRULE($.variableOperator));
9393 });
9494
9595 // Variable operator: ++, --, = value, += value, -= value, ||, ??, ||=, ??=, ==, !=
9696 $.variableOperator = $.RULE('variableOperator', () => {
9797 $.OR4([
9898 { ALT: () => $.CONSUME(Tokens.Var.Operators.Increment, { LABEL: 'Var.operator' }) },
9999 { ALT: () => $.CONSUME(Tokens.Var.Operators.Decrement, { LABEL: 'Var.operator' }) },
100100 {
101101 ALT: () => {
102102 $.CONSUME(Tokens.Var.EqualsOperators.NullishCoalescingEquals, { LABEL: 'Var.operator' });
103103 $.SUBRULE($.variableValue, { LABEL: 'Var.value' });
104104 },
105105 },
106106 {
107107 ALT: () => {
108108 $.CONSUME(Tokens.Var.PlusEqualsOperators.NullishCoalescing, { LABEL: 'Var.operator' });
109109 $.SUBRULE2($.variableValue, { LABEL: 'Var.value' });
110110 },
111111 },
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+ },
112154 ]);
113155 });
114156
public/scripts/slash-commands/SlashCommandParser.js+27 -1
@@ -1714,7 +1714,33 @@ export class SlashCommandParser {
17141714 return this.testSymbol('/');
17151715 }
17161716 testCommandEnd() {
17171717 returnif (this.testClosureEnd()) ||return this.testSymbol('|')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;
17181744 }
17191745 parseCommand() {
17201746 const start = this.index + 1;
public/scripts/st-context.js+3 -1
@@ -95,7 +95,7 @@ import { tokenizers, getTextTokens, getTokenCount, getTokenCountAsync, getTokeni
9595import { ToolManager } from './tool-calling.js';
9696import { accountStorage } from './util/AccountStorage.js';
9797import { timestampToMoment, uuidv4 } from './utils.js';
9898import { addGlobalVariable, addLocalVariable, decrementGlobalVariable, decrementLocalVariable, deleteGlobalVariable, deleteLocalVariable, existsGlobalVariable, existsLocalVariable, getGlobalVariable, getLocalVariable, incrementGlobalVariable, incrementLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js';
9999import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList } from './world-info.js';
100100import { ChatCompletionService, TextCompletionService } from './custom-request.js';
101101import { ConnectionManagerRequestService } from './extensions/shared.js';
@@ -243,6 +243,7 @@ export function getContext() {
243243 add: addLocalVariable,
244244 inc: incrementLocalVariable,
245245 dec: decrementLocalVariable,
246+ has: existsLocalVariable,
246247 },
247248 global: {
248249 get: getGlobalVariable,
@@ -251,6 +252,7 @@ export function getContext() {
251252 add: addGlobalVariable,
252253 inc: incrementGlobalVariable,
253254 dec: decrementGlobalVariable,
255+ has: existsGlobalVariable,
254256 },
255257 },
256258 loadWorldInfo,
public/scripts/variables.js+2 -2
@@ -438,7 +438,7 @@ async function ifCallback(args, value) {
438438 * @param {string} name Local variable name
439439 * @returns {boolean} True if the local variable exists, false otherwise
440440 */
441441export function existsLocalVariable(name) {
442442 return chat_metadata.variables && chat_metadata.variables[name] !== undefined;
443443}
444444
@@ -447,7 +447,7 @@ function existsLocalVariable(name) {
447447 * @param {string} name Global variable name
448448 * @returns {boolean} True if the global variable exists, false otherwise
449449 */
450450export function existsGlobalVariable(name) {
451451 return extension_settings.variables.global && extension_settings.variables.global[name] !== undefined;
452452}
453453
tests/frontend/MacroEngine.e2e.js+382 -0
@@ -2559,6 +2559,341 @@ test.describe('MacroEngine', () => {
25592559 // setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'
25602560 expect(output).toBe('616');
25612561 });
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+ });
25622897 });
25632898
25642899 test.describe('Variable Shorthand in {{if}} Macro', () => {
@@ -2814,6 +3149,53 @@ test.describe('MacroEngine', () => {
28143149 expect(output).toBe('found');
28153150 });
28163151 });
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+ });
28173199});
28183200
28193201/**