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
Signed| @@ -499,13 +499,13 @@ export const VariableShorthandDefinitions = new Map([ | ||
| 499 | 499 | type: VariableShorthandType.LOCAL, |
| 500 | 500 | name: 'Local Variable', |
| 501 | 501 | description: 'Access or modify a local variable (scoped to current chat).', |
| 502 | 502 | operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)'], |
| 503 | 503 | }], |
| 504 | 504 | [VariableShorthandType.GLOBAL, { |
| 505 | 505 | type: VariableShorthandType.GLOBAL, |
| 506 | 506 | name: 'Global Variable', |
| 507 | 507 | description: 'Access or modify a global variable (shared across all chats).', |
| 508 | 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 | ]); |
| 511 | 511 | |
| @@ -622,10 +622,17 @@ export class VariableShorthandAutoCompleteOption extends AutoCompleteOption { | ||
| 622 | 622 | const prefix = this.#varDef.type; |
| 623 | 623 | const examples = [ |
| 624 | 624 | `{{${prefix}myvar}} - Get variable value`, |
| 625 | 625 | `{{${prefix}myvar = value}} - Set variable (returns nothing)`, |
| 626 | 626 | `{{${prefix}counter++}} - Increment and get value`, |
| 627 | 627 | `{{${prefix}counter--}} - Decrement and get value`, |
| 628 | 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 | 637 | for (const ex of examples) { |
| 631 | 638 | const li = document.createElement('li'); |
| @@ -796,6 +803,13 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption { | ||
| 796 | 803 | `{{${prefix}${this.#varName}++}} - Increment`, |
| 797 | 804 | `{{${prefix}${this.#varName}--}} - Decrement`, |
| 798 | 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 | 814 | for (const ex of examples) { |
| 801 | 815 | const li = document.createElement('li'); |
| @@ -817,25 +831,67 @@ export const VariableOperatorDefinitions = new Map([ | ||
| 817 | 831 | ['=', { |
| 818 | 832 | symbol: '=', |
| 819 | 833 | name: 'Set', |
| 820 | 834 | description: 'Set the variable to a new value. Returns nothing.', |
| 821 | 835 | needsValue: true, |
| 822 | 836 | }], |
| 823 | 837 | ['++', { |
| 824 | 838 | symbol: '++', |
| 825 | 839 | name: 'Increment', |
| 826 | 840 | description: 'Increment the variable by 1 (numeric). Returns the new value.', |
| 827 | 841 | needsValue: false, |
| 828 | 842 | }], |
| 829 | 843 | ['--', { |
| 830 | 844 | symbol: '--', |
| 831 | 845 | name: 'Decrement', |
| 832 | 846 | description: 'Decrement the variable by 1 (numeric). Returns the new value.', |
| 833 | 847 | needsValue: false, |
| 834 | 848 | }], |
| 835 | 849 | ['+=', { |
| 836 | 850 | symbol: '+=', |
| 837 | 851 | name: 'Add', |
| 838 | 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 | 895 | needsValue: true, |
| 840 | 896 | }], |
| 841 | 897 | ]); |
| @@ -1131,7 +1187,8 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1131 | 1187 | i++; |
| 1132 | 1188 | } |
| 1133 | 1189 | |
| 1134 | 1190 | // Check for operators: ++, --, +=, -=, ||=, ??=, ||, ??, ==, = |
| 1191 | + // Order matters: longer operators must be checked before shorter ones | |
| 1135 | 1192 | // Also track partial operator prefixes for autocomplete |
| 1136 | 1193 | const operatorText = macroText.slice(i); |
| 1137 | 1194 | let hasInvalidTrailingChars = false; |
| @@ -1143,13 +1200,34 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1143 | 1200 | } else if (operatorText.startsWith('--')) { |
| 1144 | 1201 | variableOperator = '--'; |
| 1145 | 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 | 1215 | } else if (operatorText.startsWith('+=')) { |
| 1147 | 1216 | variableOperator = '+='; |
| 1148 | 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 | 1227 | } else if (operatorText.startsWith('=')) { |
| 1150 | 1228 | variableOperator = '='; |
| 1151 | 1229 | i += 1; |
| 1152 | 1230 | } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!')) { |
| 1153 | 1231 | // Partial operator prefix - user is typing an operator |
| 1154 | 1232 | partialOperator = operatorText[0]; |
| 1155 | 1233 | } else if (operatorText.length > 0 && !/^\s/.test(operatorText)) { |
| @@ -1159,8 +1237,12 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1159 | 1237 | invalidTrailingChars = operatorText.trim(); |
| 1160 | 1238 | } |
| 1161 | 1239 | |
| 1162 | 1240 | // 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) { | |
| 1164 | 1246 | // Skip whitespace after operator |
| 1165 | 1247 | while (i < macroText.length && /\s/.test(macroText[i])) { |
| 1166 | 1248 | i++; |
| @@ -1178,9 +1260,9 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1178 | 1260 | isTypingVariableName = true; |
| 1179 | 1261 | } else if (!variableOperator && !hasInvalidTrailingChars) { |
| 1180 | 1262 | // Cursor is after variable name but no operator yet (and no invalid chars) |
| 1181 | 1263 | // This includes partial operator prefixes like '+' or, '-', '|', '?' |
| 1182 | 1264 | isTypingOperator = true; |
| 1183 | - } else if (variableOperator === '=' || variableOperator === '+=') { | |
| 1265 | + } else if (operatorNeedsValue) { | |
| 1184 | 1266 | // Operator that requires value - cursor is in value area |
| 1185 | 1267 | isTypingValue = true; |
| 1186 | 1268 | } |
| @@ -68,7 +68,7 @@ export function registerVariableMacros() { | ||
| 68 | 68 | description: 'Increments a local variable by 1 and returns the new value. If the variable does not exist, it will be created.', |
| 69 | 69 | returns: 'The new value of the local variable.', |
| 70 | 70 | returnType: MacroValueType.NUMBER, |
| 71 | 71 | exampleUsage: ['{{incvar::myintvar}}', '{{incvar some-local-int-var}}'], |
| 72 | 72 | handler: ({ unnamedArgs: [name], normalize }) => { |
| 73 | 73 | const result = ctx.variables.local.inc(name); |
| 74 | 74 | return normalize(result); |
| @@ -88,7 +88,7 @@ export function registerVariableMacros() { | ||
| 88 | 88 | description: 'Decrements a local variable by 1 and returns the new value. If the variable does not exist, it will be created.', |
| 89 | 89 | returns: 'The new value of the local variable.', |
| 90 | 90 | returnType: MacroValueType.NUMBER, |
| 91 | 91 | exampleUsage: ['{{decvar::myintvar}}', '{{decvar some-local-int-var}}'], |
| 92 | 92 | handler: ({ unnamedArgs: [name], normalize }) => { |
| 93 | 93 | const result = ctx.variables.local.dec(name); |
| 94 | 94 | return normalize(result); |
| @@ -108,13 +108,53 @@ export function registerVariableMacros() { | ||
| 108 | 108 | description: 'Gets the value of a local variable.', |
| 109 | 109 | returns: 'The value of the local variable.', |
| 110 | 110 | returnType: [MacroValueType.STRING, MacroValueType.NUMBER], |
| 111 | 111 | exampleUsage: ['{{getvar::myvar}}', '{{getvar:: myintvar}}'], |
| 112 | 112 | handler: ({ unnamedArgs: [name], normalize }) => { |
| 113 | 113 | const result = ctx.variables.local.get(name); |
| 114 | 114 | return normalize(result); |
| 115 | 115 | }, |
| 116 | 116 | }); |
| 117 | 117 | |
| 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 | 158 | // {{setglobalvar::name::value}} -> '' |
| 119 | 159 | MacroRegistry.registerMacro('setglobalvar', { |
| 120 | 160 | category: MacroCategory.VARIABLE, |
| @@ -176,6 +216,7 @@ export function registerVariableMacros() { | ||
| 176 | 216 | description: 'Increments a global variable by 1 and returns the new value. If the variable does not exist, it will be created.', |
| 177 | 217 | returns: 'The new value of the global variable.', |
| 178 | 218 | returnType: MacroValueType.NUMBER, |
| 219 | + exampleUsage: ['{{incglobalvar::myintvar}}', '{{incglobalvar some-global-int-var}}'], | |
| 179 | 220 | handler: ({ unnamedArgs: [name], normalize }) => { |
| 180 | 221 | const result = ctx.variables.global.inc(name); |
| 181 | 222 | return normalize(result); |
| @@ -195,7 +236,7 @@ export function registerVariableMacros() { | ||
| 195 | 236 | description: 'Decrements a global variable by 1 and returns the new value. If the variable does not exist, it will be created.', |
| 196 | 237 | returns: 'The new value of the global variable.', |
| 197 | 238 | returnType: MacroValueType.NUMBER, |
| 198 | 239 | exampleUsage: ['{{decglobalvar::myintvar}}', '{{decglobalvar some-global-int-var}}'], |
| 199 | 240 | handler: ({ unnamedArgs: [name], normalize }) => { |
| 200 | 241 | const result = ctx.variables.global.dec(name); |
| 201 | 242 | return normalize(result); |
| @@ -215,10 +256,50 @@ export function registerVariableMacros() { | ||
| 215 | 256 | description: 'Gets the value of a global variable.', |
| 216 | 257 | returns: 'The value of the global variable.', |
| 217 | 258 | returnType: [MacroValueType.STRING, MacroValueType.NUMBER], |
| 218 | 259 | exampleUsage: ['{{getglobalvar::myvar}}', '{{getglobalvar:: myintvar}}'], |
| 219 | 260 | handler: ({ unnamedArgs: [name], normalize }) => { |
| 220 | 261 | const result = ctx.variables.global.get(name); |
| 221 | 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 | } |
| @@ -3,9 +3,12 @@ | ||
| 3 | 3 | /** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */ |
| 4 | 4 | /** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */ |
| 5 | 5 | |
| 6 | +import { logMacroInternalError, logMacroRuntimeWarning } from './MacroDiagnostics.js'; | |
| 7 | +import { MacroEngine } from './MacroEngine.js'; | |
| 6 | 8 | import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js'; |
| 7 | 9 | import { MacroParser } from './MacroParser.js'; |
| 8 | 10 | import { MacroRegistry } from './MacroRegistry.js'; |
| 11 | +import { isFalseBoolean } from '/scripts/utils.js'; | |
| 9 | 12 | |
| 10 | 13 | /** |
| 11 | 14 | * @typedef {Object} MacroCall |
| @@ -493,7 +496,10 @@ class MacroCstWalker { | ||
| 493 | 496 | } |
| 494 | 497 | |
| 495 | 498 | /** |
| 496 | 499 | * 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 (==). | |
| 497 | 503 | * |
| 498 | 504 | * @param {CstNode} macroNode - The parent macro node. |
| 499 | 505 | * @param {CstNode} variableExprNode - The variableExpr CST node. |
| @@ -501,9 +507,6 @@ class MacroCstWalker { | ||
| 501 | 507 | * @returns {string} |
| 502 | 508 | */ |
| 503 | 509 | #evaluateVariableExpr(macroNode, variableExprNode, context) { |
| 504 | - const { text, contextOffset, env, resolveMacro } = context; | |
| 505 | - | |
| 506 | - const children = macroNode.children || {}; | |
| 507 | 510 | const varChildren = variableExprNode.children || {}; |
| 508 | 511 | |
| 509 | 512 | // Extract scope (. for local, $ for global) |
| @@ -518,9 +521,9 @@ class MacroCstWalker { | ||
| 518 | 521 | const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]); |
| 519 | 522 | const operatorChildren = operatorNode?.children || {}; |
| 520 | 523 | |
| 521 | 524 | // Determine operation and whether a value expression is expected |
| 522 | 525 | let operation = 'get'; |
| 523 | 526 | let valuehasValueExpr = nullfalse; |
| 524 | 527 | |
| 525 | 528 | if (operatorNode) { |
| 526 | 529 | const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []); |
| @@ -528,60 +531,193 @@ class MacroCstWalker { | ||
| 528 | 531 | |
| 529 | 532 | if (operatorToken) { |
| 530 | 533 | const operatorImage = operatorToken.image; |
| 531 | 534 | ifswitch (operatorImage === '++') { |
| 535 | + case '++': | |
| 532 | 536 | operation = 'inc'; |
| 533 | - } else if (operatorImage === '--') { | |
| 537 | + break; | |
| 538 | + case '--': | |
| 534 | 539 | operation = 'dec'; |
| 535 | - } else if (operatorImage === '=') { | |
| 540 | + break; | |
| 541 | + case '=': | |
| 536 | 542 | operation = 'set'; |
| 537 | - value = this.#evaluateVariableValue(operatorChildren, context); | |
| 543 | + hasValueExpr = true; | |
| 538 | - } else if (operatorImage === '+=') { | |
| 544 | + break; | |
| 545 | + case '+=': | |
| 539 | 546 | 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); | |
| 541 | 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; | |
| 542 | 609 | } |
| 610 | + return cached; | |
| 611 | + }; | |
| 543 | 612 | } |
| 544 | 613 | |
| 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)); | |
| 553 | 651 | |
| 554 | - const targetMacroName = macroNameMap[operation]; | |
| 652 | + case 'dec': | |
| 653 | + return normalize(vars.dec(varName)); | |
| 555 | 654 | |
| 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 ''; | |
| 560 | 665 | } |
| 561 | 666 | |
| 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 | + } | |
| 563 | 673 | |
| 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 | + } | |
| 582 | 702 | |
| 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 | + } | |
| 585 | 721 | } |
| 586 | 722 | |
| 587 | 723 | /** |
| @@ -111,15 +111,33 @@ const Tokens = Object.freeze({ | ||
| 111 | 111 | * Invalid: my-, my--, -var |
| 112 | 112 | */ |
| 113 | 113 | 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: { | |
| 114 | 117 | /** Increment operator (`++`) */ |
| 115 | 118 | Increment: createToken({ name: 'Var.Increment', pattern: /\+\+/ }), |
| 116 | 119 | /** Decrement operator (`--`) */ |
| 117 | 120 | 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: /!=/ }), | |
| 118 | 135 | /** Add/append operator (`+=`) - must come before Equals to avoid conflict */ |
| 119 | 136 | PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }), |
| 120 | 137 | /** Set operator (`=`) */ |
| 121 | 138 | Equals: createToken({ name: 'Var.Equals', pattern: /=/ }), |
| 122 | 139 | }, |
| 140 | + }, | |
| 123 | 141 | |
| 124 | 142 | /** |
| 125 | 143 | * Capture unknown characters one by one, to still allow other tokens being matched once they are there. |
| @@ -230,11 +248,18 @@ const Def = { | ||
| 230 | 248 | // After the variable identifier, look for operators or end |
| 231 | 249 | [modes.var_after_identifier]: [ |
| 232 | 250 | using(Tokens.WhiteSpace), |
| 233 | 251 | // Check for operators - order matters: longer patterns first |
| 234 | 252 | using(Tokens.Var.Operators.Increment), |
| 235 | 253 | using(Tokens.Var.Operators.Decrement), |
| 236 | 254 | enter(Tokens.Var.PlusEqualsOperators.NullishCoalescingEquals, modes.var_value, { andExits: modes.var_after_identifier }), |
| 237 | 255 | 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 }), | |
| 238 | 263 | // If we see the end, exit |
| 239 | 264 | exits(Tokens.Macro.BeforeEnd, modes.var_after_identifier), |
| 240 | 265 | // Fallback exit |
| @@ -88,27 +88,69 @@ class MacroParser extends CstParser { | ||
| 88 | 88 | // Variable identifier (name) |
| 89 | 89 | $.CONSUME(Tokens.Var.Identifier, { LABEL: 'Var.identifier' }); |
| 90 | 90 | |
| 91 | - // Optional operator | |
| 91 | + // Optional operator (and expression, if operator requires one) | |
| 92 | 92 | $.OPTION2(() => $.SUBRULE($.variableOperator)); |
| 93 | 93 | }); |
| 94 | 94 | |
| 95 | 95 | // Variable operator: ++, --, = value, += value, -= value, ||, ??, ||=, ??=, ==, != |
| 96 | 96 | $.variableOperator = $.RULE('variableOperator', () => { |
| 97 | 97 | $.OR4([ |
| 98 | 98 | { ALT: () => $.CONSUME(Tokens.Var.Operators.Increment, { LABEL: 'Var.operator' }) }, |
| 99 | 99 | { ALT: () => $.CONSUME(Tokens.Var.Operators.Decrement, { LABEL: 'Var.operator' }) }, |
| 100 | 100 | { |
| 101 | 101 | ALT: () => { |
| 102 | 102 | $.CONSUME(Tokens.Var.EqualsOperators.NullishCoalescingEquals, { LABEL: 'Var.operator' }); |
| 103 | 103 | $.SUBRULE($.variableValue, { LABEL: 'Var.value' }); |
| 104 | 104 | }, |
| 105 | 105 | }, |
| 106 | 106 | { |
| 107 | 107 | ALT: () => { |
| 108 | 108 | $.CONSUME(Tokens.Var.PlusEqualsOperators.NullishCoalescing, { LABEL: 'Var.operator' }); |
| 109 | 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 | }); |
| 114 | 156 | |
| @@ -1714,7 +1714,33 @@ export class SlashCommandParser { | ||
| 1714 | 1714 | return this.testSymbol('/'); |
| 1715 | 1715 | } |
| 1716 | 1716 | testCommandEnd() { |
| 1717 | 1717 | 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; | |
| 1718 | 1744 | } |
| 1719 | 1745 | parseCommand() { |
| 1720 | 1746 | const start = this.index + 1; |
| @@ -95,7 +95,7 @@ import { tokenizers, getTextTokens, getTokenCount, getTokenCountAsync, getTokeni | ||
| 95 | 95 | import { ToolManager } from './tool-calling.js'; |
| 96 | 96 | import { accountStorage } from './util/AccountStorage.js'; |
| 97 | 97 | import { timestampToMoment, uuidv4 } from './utils.js'; |
| 98 | 98 | import { addGlobalVariable, addLocalVariable, decrementGlobalVariable, decrementLocalVariable, deleteGlobalVariable, deleteLocalVariable, existsGlobalVariable, existsLocalVariable, getGlobalVariable, getLocalVariable, incrementGlobalVariable, incrementLocalVariable, setGlobalVariable, setLocalVariable } from './variables.js'; |
| 99 | 99 | import { convertCharacterBook, getWorldInfoPrompt, loadWorldInfo, reloadEditor, saveWorldInfo, updateWorldInfoList } from './world-info.js'; |
| 100 | 100 | import { ChatCompletionService, TextCompletionService } from './custom-request.js'; |
| 101 | 101 | import { ConnectionManagerRequestService } from './extensions/shared.js'; |
| @@ -243,6 +243,7 @@ export function getContext() { | ||
| 243 | 243 | add: addLocalVariable, |
| 244 | 244 | inc: incrementLocalVariable, |
| 245 | 245 | dec: decrementLocalVariable, |
| 246 | + has: existsLocalVariable, | |
| 246 | 247 | }, |
| 247 | 248 | global: { |
| 248 | 249 | get: getGlobalVariable, |
| @@ -251,6 +252,7 @@ export function getContext() { | ||
| 251 | 252 | add: addGlobalVariable, |
| 252 | 253 | inc: incrementGlobalVariable, |
| 253 | 254 | dec: decrementGlobalVariable, |
| 255 | + has: existsGlobalVariable, | |
| 254 | 256 | }, |
| 255 | 257 | }, |
| 256 | 258 | loadWorldInfo, |
| @@ -438,7 +438,7 @@ async function ifCallback(args, value) { | ||
| 438 | 438 | * @param {string} name Local variable name |
| 439 | 439 | * @returns {boolean} True if the local variable exists, false otherwise |
| 440 | 440 | */ |
| 441 | 441 | export function existsLocalVariable(name) { |
| 442 | 442 | return chat_metadata.variables && chat_metadata.variables[name] !== undefined; |
| 443 | 443 | } |
| 444 | 444 | |
| @@ -447,7 +447,7 @@ function existsLocalVariable(name) { | ||
| 447 | 447 | * @param {string} name Global variable name |
| 448 | 448 | * @returns {boolean} True if the global variable exists, false otherwise |
| 449 | 449 | */ |
| 450 | 450 | export function existsGlobalVariable(name) { |
| 451 | 451 | return extension_settings.variables.global && extension_settings.variables.global[name] !== undefined; |
| 452 | 452 | } |
| 453 | 453 | |
| @@ -2559,6 +2559,341 @@ test.describe('MacroEngine', () => { | ||
| 2559 | 2559 | // setvar returns '', incvar returns '6', addvar returns '', getvar returns '16' |
| 2560 | 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 | }); |
| 2563 | 2898 | |
| 2564 | 2899 | test.describe('Variable Shorthand in {{if}} Macro', () => { |
| @@ -2814,6 +3149,53 @@ test.describe('MacroEngine', () => { | ||
| 2814 | 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 | }); |
| 2818 | 3200 | |
| 2819 | 3201 | /** |