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