Macros 2.0 (v0.7.3) - Variable Shorthand: Comparison Operators & Autocomplete Improvements (#5050) * feat: Add numeric comparison operators (>, >=, <, <=) to variable shorthand syntax - Added four new comparison operators for local and global variables - Implemented greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual operations in MacroCstWalker - Updated lexer to recognize >=, >, <=, < operators (longer patterns before shorter to avoid conflicts) - Simplified parser by consolidating operator alternatives into single OR block - Enhanced autocomplete with operator definitions, examples, and usage * Improve autocomplete for variable shorthand operators with cursor-aware filtering - Track `variableNameEnd` and `variableOperatorEnd` positions in parsed macro context for accurate cursor position checks - Add `isShortOperatorPrefix()` helper to detect operators that could be prefixes of longer ones (e.g., `>` → `>=`) - Fix operator autocomplete to show longer variants when typing short operators (typing `>` now shows both `>` and `>=`) - Show operator suggestions immediately when variable
Signed| @@ -39,7 +39,9 @@ import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnos | ||
| 39 | 39 | * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $). |
| 40 | 40 | * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null. |
| 41 | 41 | * @property {string} variableName - The variable name being typed (after the prefix). |
| 42 | + * @property {number} variableNameEnd - The end of the variable name (for partial matches). | |
| 42 | 43 | * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null. |
| 44 | + * @property {number} variableOperatorEnd - The end of the variable operator (for partial matches). | |
| 43 | 45 | * @property {string} variableValue - The value after the operator (for = and +=). |
| 44 | 46 | * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area. |
| 45 | 47 | * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator. |
| @@ -499,13 +501,13 @@ export const VariableShorthandDefinitions = new Map([ | ||
| 499 | 501 | type: VariableShorthandType.LOCAL, |
| 500 | 502 | name: 'Local Variable', |
| 501 | 503 | description: 'Access or modify a local variable (scoped to current chat).', |
| 502 | 504 | operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'], |
| 503 | 505 | }], |
| 504 | 506 | [VariableShorthandType.GLOBAL, { |
| 505 | 507 | type: VariableShorthandType.GLOBAL, |
| 506 | 508 | name: 'Global Variable', |
| 507 | 509 | description: 'Access or modify a global variable (shared across all chats).', |
| 508 | 510 | operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'], |
| 509 | 511 | }], |
| 510 | 512 | ]); |
| 511 | 513 | |
| @@ -633,6 +635,10 @@ export class VariableShorthandAutoCompleteOption extends AutoCompleteOption { | ||
| 633 | 635 | `{{${prefix}myvar ??= value}} - Set if undefined, get value`, |
| 634 | 636 | `{{${prefix}myvar == test}} - Compare (returns true/false)`, |
| 635 | 637 | `{{${prefix}myvar != test}} - Compare not equal (returns true/false)`, |
| 638 | + `{{${prefix}score > 10}} - Greater than (numeric, returns true/false)`, | |
| 639 | + `{{${prefix}score >= 10}} - Greater than or equal (numeric)`, | |
| 640 | + `{{${prefix}score < 10}} - Less than (numeric, returns true/false)`, | |
| 641 | + `{{${prefix}score <= 10}} - Less than or equal (numeric)`, | |
| 636 | 642 | ]; |
| 637 | 643 | for (const ex of examples) { |
| 638 | 644 | const li = document.createElement('li'); |
| @@ -810,6 +816,10 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption { | ||
| 810 | 816 | `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`, |
| 811 | 817 | `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`, |
| 812 | 818 | `{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`, |
| 819 | + `{{${prefix}${this.#varName} > 10}} - Greater than (numeric)`, | |
| 820 | + `{{${prefix}${this.#varName} >= 10}} - Greater than or equal (numeric)`, | |
| 821 | + `{{${prefix}${this.#varName} < 10}} - Less than (numeric)`, | |
| 822 | + `{{${prefix}${this.#varName} <= 10}} - Less than or equal (numeric)`, | |
| 813 | 823 | ]; |
| 814 | 824 | for (const ex of examples) { |
| 815 | 825 | const li = document.createElement('li'); |
| @@ -824,6 +834,18 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption { | ||
| 824 | 834 | } |
| 825 | 835 | |
| 826 | 836 | /** |
| 837 | + * Checks if an operator is a short one that could be a prefix of a longer operator. | |
| 838 | + * For example, '>' is a prefix of '>=', '<' is a prefix of '<='. | |
| 839 | + * @param {string} op - The operator to check. | |
| 840 | + * @returns {boolean} True if the operator could be a prefix of a longer operator. | |
| 841 | + */ | |
| 842 | +function isShortOperatorPrefix(op) { | |
| 843 | + // These operators could have longer variants typed after them | |
| 844 | + const shortPrefixes = ['>', '<', '=', '|', '?', '+', '-', '!']; | |
| 845 | + return shortPrefixes.includes(op); | |
| 846 | +} | |
| 847 | + | |
| 848 | +/** | |
| 827 | 849 | * Variable shorthand operators with metadata. |
| 828 | 850 | * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>} |
| 829 | 851 | */ |
| @@ -894,6 +916,30 @@ export const VariableOperatorDefinitions = new Map([ | ||
| 894 | 916 | description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.', |
| 895 | 917 | needsValue: true, |
| 896 | 918 | }], |
| 919 | + ['>', { | |
| 920 | + symbol: '>', | |
| 921 | + name: 'Greater Than', | |
| 922 | + description: 'Numeric comparison. Returns "true" if variable is greater than value, "false" otherwise.', | |
| 923 | + needsValue: true, | |
| 924 | + }], | |
| 925 | + ['>=', { | |
| 926 | + symbol: '>=', | |
| 927 | + name: 'Greater Than or Equal', | |
| 928 | + description: 'Numeric comparison. Returns "true" if variable is greater than or equal to value, "false" otherwise.', | |
| 929 | + needsValue: true, | |
| 930 | + }], | |
| 931 | + ['<', { | |
| 932 | + symbol: '<', | |
| 933 | + name: 'Less Than', | |
| 934 | + description: 'Numeric comparison. Returns "true" if variable is less than value, "false" otherwise.', | |
| 935 | + needsValue: true, | |
| 936 | + }], | |
| 937 | + ['<=', { | |
| 938 | + symbol: '<=', | |
| 939 | + name: 'Less Than or Equal', | |
| 940 | + description: 'Numeric comparison. Returns "true" if variable is less than or equal to value, "false" otherwise.', | |
| 941 | + needsValue: true, | |
| 942 | + }], | |
| 897 | 943 | ]); |
| 898 | 944 | |
| 899 | 945 | /** |
| @@ -1224,19 +1270,35 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1224 | 1270 | } else if (operatorText.startsWith('!=')) { |
| 1225 | 1271 | variableOperator = '!='; |
| 1226 | 1272 | i += 2; |
| 1273 | + } else if (operatorText.startsWith('>=')) { | |
| 1274 | + variableOperator = '>='; | |
| 1275 | + i += 2; | |
| 1276 | + } else if (operatorText.startsWith('>')) { | |
| 1277 | + variableOperator = '>'; | |
| 1278 | + i += 1; | |
| 1279 | + } else if (operatorText.startsWith('<=')) { | |
| 1280 | + variableOperator = '<='; | |
| 1281 | + i += 2; | |
| 1282 | + } else if (operatorText.startsWith('<')) { | |
| 1283 | + variableOperator = '<'; | |
| 1284 | + i += 1; | |
| 1227 | 1285 | } else if (operatorText.startsWith('=')) { |
| 1228 | 1286 | variableOperator = '='; |
| 1229 | 1287 | i += 1; |
| 1230 | 1288 | } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!') || operatorText.startsWith('>') || operatorText.startsWith('<')) { |
| 1231 | 1289 | // Partial operator prefix - user is typing an operator |
| 1232 | 1290 | partialOperator = operatorText[0]; |
| 1233 | 1291 | } else if (operatorText.length > 0 && !/^\s/.test(operatorText) && !operatorText.startsWith('}')) { |
| 1234 | 1292 | // There's non-whitespace after the variable name that isn't a valid operator |
| 1235 | 1293 | // This is an invalid trailing character (e.g., $my$ or .var@test) |
| 1294 | + // Exception: } is the closing brace, not an invalid char | |
| 1236 | 1295 | hasInvalidTrailingChars = true; |
| 1237 | 1296 | invalidTrailingChars = operatorText.trim(); |
| 1238 | 1297 | } |
| 1239 | 1298 | |
| 1299 | + // Track where the operator ends (for cursor position checks) | |
| 1300 | + const variableOperatorEnd = i; | |
| 1301 | + | |
| 1240 | 1302 | // Check if operator requires a value |
| 1241 | 1303 | const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null; |
| 1242 | 1304 | const operatorNeedsValue = operatorDef?.needsValue ?? false; |
| @@ -1256,11 +1318,15 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1256 | 1318 | // Cursor is before the prefix - still in flags area conceptually |
| 1257 | 1319 | isTypingVariableName = false; |
| 1258 | 1320 | } else if (cursorOffset <= variableNameEnd) { |
| 1259 | 1321 | // Cursor is in the variable name area (including at the end) |
| 1260 | 1322 | isTypingVariableName = true; |
| 1261 | 1323 | } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars) { |
| 1262 | 1324 | // Cursor is after variable name but no operator yet (and no invalid chars) |
| 1263 | 1325 | // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<' |
| 1326 | + isTypingOperator = true; | |
| 1327 | + } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) { | |
| 1328 | + // Short operator that could be prefix of longer one (e.g., > could become >=) | |
| 1329 | + // But ONLY if cursor is still in the operator area, not past it into value | |
| 1264 | 1330 | isTypingOperator = true; |
| 1265 | 1331 | } else if (operatorNeedsValue) { |
| 1266 | 1332 | // Operator that requires value - cursor is in value area |
| @@ -1292,7 +1358,9 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1292 | 1358 | isVariableShorthand, |
| 1293 | 1359 | variablePrefix, |
| 1294 | 1360 | variableName, |
| 1361 | + variableNameEnd, | |
| 1295 | 1362 | variableOperator, |
| 1363 | + variableOperatorEnd, | |
| 1296 | 1364 | variableValue, |
| 1297 | 1365 | isTypingVariableName, |
| 1298 | 1366 | isTypingOperator, |
| @@ -574,6 +574,22 @@ class MacroCstWalker { | ||
| 574 | 574 | operation = 'notEquals'; |
| 575 | 575 | hasValueExpr = true; |
| 576 | 576 | break; |
| 577 | + case '>': | |
| 578 | + operation = 'greaterThan'; | |
| 579 | + hasValueExpr = true; | |
| 580 | + break; | |
| 581 | + case '>=': | |
| 582 | + operation = 'greaterThanOrEqual'; | |
| 583 | + hasValueExpr = true; | |
| 584 | + break; | |
| 585 | + case '<': | |
| 586 | + operation = 'lessThan'; | |
| 587 | + hasValueExpr = true; | |
| 588 | + break; | |
| 589 | + case '<=': | |
| 590 | + operation = 'lessThanOrEqual'; | |
| 591 | + hasValueExpr = true; | |
| 592 | + break; | |
| 577 | 593 | default: |
| 578 | 594 | logMacroInternalError({ message: `Lexer found macro operator that is not implemented for variable shorthand expressions in macro node '${macroNode.name}'.` }); |
| 579 | 595 | break; |
| @@ -714,6 +730,50 @@ class MacroCstWalker { | ||
| 714 | 730 | return currentValue !== compareValue ? 'true' : 'false'; |
| 715 | 731 | } |
| 716 | 732 | |
| 733 | + case 'greaterThan': { | |
| 734 | + // Numeric greater than comparison | |
| 735 | + const currentNum = Number(vars.get(varName)); | |
| 736 | + const compareNum = Number(lazyValue()); | |
| 737 | + if (isNaN(currentNum) || isNaN(compareNum)) { | |
| 738 | + logMacroRuntimeWarning({ message: `Variable shorthand ">" operator requires numeric values. Got: "${vars.get(varName)}" > "${lazyValue()}"` }); | |
| 739 | + return 'false'; | |
| 740 | + } | |
| 741 | + return currentNum > compareNum ? 'true' : 'false'; | |
| 742 | + } | |
| 743 | + | |
| 744 | + case 'greaterThanOrEqual': { | |
| 745 | + // Numeric greater than or equal comparison | |
| 746 | + const currentNum = Number(vars.get(varName)); | |
| 747 | + const compareNum = Number(lazyValue()); | |
| 748 | + if (isNaN(currentNum) || isNaN(compareNum)) { | |
| 749 | + logMacroRuntimeWarning({ message: `Variable shorthand ">=" operator requires numeric values. Got: "${vars.get(varName)}" >= "${lazyValue()}"` }); | |
| 750 | + return 'false'; | |
| 751 | + } | |
| 752 | + return currentNum >= compareNum ? 'true' : 'false'; | |
| 753 | + } | |
| 754 | + | |
| 755 | + case 'lessThan': { | |
| 756 | + // Numeric less than comparison | |
| 757 | + const currentNum = Number(vars.get(varName)); | |
| 758 | + const compareNum = Number(lazyValue()); | |
| 759 | + if (isNaN(currentNum) || isNaN(compareNum)) { | |
| 760 | + logMacroRuntimeWarning({ message: `Variable shorthand "<" operator requires numeric values. Got: "${vars.get(varName)}" < "${lazyValue()}"` }); | |
| 761 | + return 'false'; | |
| 762 | + } | |
| 763 | + return currentNum < compareNum ? 'true' : 'false'; | |
| 764 | + } | |
| 765 | + | |
| 766 | + case 'lessThanOrEqual': { | |
| 767 | + // Numeric less than or equal comparison | |
| 768 | + const currentNum = Number(vars.get(varName)); | |
| 769 | + const compareNum = Number(lazyValue()); | |
| 770 | + if (isNaN(currentNum) || isNaN(compareNum)) { | |
| 771 | + logMacroRuntimeWarning({ message: `Variable shorthand "<=" operator requires numeric values. Got: "${vars.get(varName)}" <= "${lazyValue()}"` }); | |
| 772 | + return 'false'; | |
| 773 | + } | |
| 774 | + return currentNum <= compareNum ? 'true' : 'false'; | |
| 775 | + } | |
| 776 | + | |
| 717 | 777 | default: |
| 718 | 778 | logMacroRuntimeWarning({ message: `Unknown variable shorthand operation: "${operation}"` }); |
| 719 | 779 | return ''; |
| @@ -132,6 +132,14 @@ const Tokens = Object.freeze({ | ||
| 132 | 132 | DoubleEquals: createToken({ name: 'Var.DoubleEquals', pattern: /==/ }), |
| 133 | 133 | /** Not equals comparison operator (`!=`) - compares variable to value, returns inverted result */ |
| 134 | 134 | NotEquals: createToken({ name: 'Var.NotEquals', pattern: /!=/ }), |
| 135 | + /** Greater than or equal comparison operator (`>=`) - must come before GreaterThan */ | |
| 136 | + GreaterThanOrEqual: createToken({ name: 'Var.GreaterThanOrEqual', pattern: />=/ }), | |
| 137 | + /** Greater than comparison operator (`>`) */ | |
| 138 | + GreaterThan: createToken({ name: 'Var.GreaterThan', pattern: />/ }), | |
| 139 | + /** Less than or equal comparison operator (`<=`) - must come before LessThan */ | |
| 140 | + LessThanOrEqual: createToken({ name: 'Var.LessThanOrEqual', pattern: /<=/ }), | |
| 141 | + /** Less than comparison operator (`<`) */ | |
| 142 | + LessThan: createToken({ name: 'Var.LessThan', pattern: /</ }), | |
| 135 | 143 | /** Add/append operator (`+=`) - must come before Equals to avoid conflict */ |
| 136 | 144 | PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }), |
| 137 | 145 | /** Set operator (`=`) */ |
| @@ -258,6 +266,10 @@ const Def = { | ||
| 258 | 266 | enter(Tokens.Var.Operators.MinusEquals, modes.var_value, { andExits: modes.var_after_identifier }), |
| 259 | 267 | enter(Tokens.Var.Operators.DoubleEquals, modes.var_value, { andExits: modes.var_after_identifier }), |
| 260 | 268 | enter(Tokens.Var.Operators.NotEquals, modes.var_value, { andExits: modes.var_after_identifier }), |
| 269 | + enter(Tokens.Var.Operators.GreaterThanOrEqual, modes.var_value, { andExits: modes.var_after_identifier }), | |
| 270 | + enter(Tokens.Var.Operators.GreaterThan, modes.var_value, { andExits: modes.var_after_identifier }), | |
| 271 | + enter(Tokens.Var.Operators.LessThanOrEqual, modes.var_value, { andExits: modes.var_after_identifier }), | |
| 272 | + enter(Tokens.Var.Operators.LessThan, modes.var_value, { andExits: modes.var_after_identifier }), | |
| 261 | 273 | enter(Tokens.Var.Operators.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }), |
| 262 | 274 | enter(Tokens.Var.Operators.Equals, modes.var_value, { andExits: modes.var_after_identifier }), |
| 263 | 275 | // If we see the end, exit |
| @@ -92,65 +92,31 @@ class MacroParser extends CstParser { | ||
| 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 | - $.CONSUME(Tokens.Var.Operators.NullishCoalescingEquals, { LABEL: 'Var.operator' }); | |
| 102 | + $.OR5([ | |
| 103 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.NullishCoalescingEquals, { LABEL: 'Var.operator' }) }, | |
| 104 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.NullishCoalescing, { LABEL: 'Var.operator' }) }, | |
| 105 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.LogicalOrEquals, { LABEL: 'Var.operator' }) }, | |
| 106 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.LogicalOr, { LABEL: 'Var.operator' }) }, | |
| 107 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.MinusEquals, { LABEL: 'Var.operator' }) }, | |
| 108 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.DoubleEquals, { LABEL: 'Var.operator' }) }, | |
| 109 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.NotEquals, { LABEL: 'Var.operator' }) }, | |
| 110 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.GreaterThanOrEqual, { LABEL: 'Var.operator' }) }, | |
| 111 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.GreaterThan, { LABEL: 'Var.operator' }) }, | |
| 112 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.LessThanOrEqual, { LABEL: 'Var.operator' }) }, | |
| 113 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.LessThan, { LABEL: 'Var.operator' }) }, | |
| 114 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.PlusEquals, { LABEL: 'Var.operator' }) }, | |
| 115 | + { ALT: () => $.CONSUME(Tokens.Var.Operators.Equals, { LABEL: 'Var.operator' }) }, | |
| 116 | + ]); | |
| 103 | 117 | $.SUBRULE($.variableValue, { LABEL: 'Var.value' }); |
| 104 | 118 | }, |
| 105 | 119 | }, |
| 106 | - { | |
| 107 | - ALT: () => { | |
| 108 | - $.CONSUME(Tokens.Var.Operators.NullishCoalescing, { LABEL: 'Var.operator' }); | |
| 109 | - $.SUBRULE2($.variableValue, { LABEL: 'Var.value' }); | |
| 110 | - }, | |
| 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 | - }, | |
| 154 | 120 | ]); |
| 155 | 121 | }); |
| 156 | 122 | |
| @@ -658,17 +658,13 @@ export class SlashCommandParser { | ||
| 658 | 658 | resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix |
| 659 | 659 | } |
| 660 | 660 | } else if (context.isTypingOperator) { |
| 661 | 661 | // Typing operator: identifier = partial operator textor (ifcurrent any)operator, start = after variable name |
| 662 | - // Using partial operator as identifier ensures cursor is within name range for filtering | |
| 662 | + resultIdentifier = context.partialOperator || context.variableOperator || ''; | |
| 663 | - resultIdentifier = context.partialOperator || ''; | |
| 663 | + // Use actual variableNameEnd position from parsing (accounts for whitespace) | |
| 664 | - if (prefixIndex >= 0) { | |
| 664 | + resultStart = macro.start + 2 + context.variableNameEnd; | |
| 665 | 665 | // Start afterSkip prefixwhitespace +between variable name lengthand operator |
| 666 | - resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length; | |
| 666 | + while (resultStart < index && /\s/.test(text[resultStart])) { | |
| 667 | - // If no partial operator (just whitespace after var name), set start to cursor | |
| 667 | + resultStart++; | |
| 668 | - // This ensures cursor is in the name range for filtering | |
| 669 | - if (!context.partialOperator) { | |
| 670 | - resultStart = index; | |
| 671 | - } | |
| 672 | 668 | } |
| 673 | 669 | } else if (context.isOperatorComplete) { |
| 674 | 670 | // Operator complete (++ or --) - show context but no value input needed |
| @@ -677,15 +673,13 @@ export class SlashCommandParser { | ||
| 677 | 673 | } else if (context.hasInvalidTrailingChars) { |
| 678 | 674 | // Invalid chars after variable name: show the invalid chars for warning |
| 679 | 675 | resultIdentifier = context.invalidTrailingChars || ''; |
| 680 | - if (prefixIndex >= 0) { | |
| 676 | + // Use actual variableNameEnd position from parsing | |
| 681 | 677 | resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.lengthvariableNameEnd; |
| 682 | - } | |
| 683 | 678 | } else if (context.isTypingValue) { |
| 684 | 679 | // Typing value: identifier = value being typed, start = after operator |
| 685 | 680 | resultIdentifier = context.variableValue; |
| 686 | - if (prefixIndex >= 0) { | |
| 681 | + // Use actual operatorEnd position from parsing (accounts for whitespace) | |
| 687 | - const operatorLen = context.variableOperator?.length ?? 0; | |
| 682 | + resultStart = macro.start + 2 + context.variableOperatorEnd; | |
| 688 | - resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length + operatorLen; | |
| 689 | 683 | // Skip any whitespace between operator and value |
| 690 | 684 | while (resultStart < index && /\s/.test(text[resultStart])) { |
| 691 | 685 | resultStart++; |
| @@ -693,7 +687,6 @@ export class SlashCommandParser { | ||
| 693 | 687 | |
| 694 | 688 | makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`; |
| 695 | 689 | makeNoOptionsText = () => 'Enter a variable value'; |
| 696 | - } | |
| 697 | 690 | } else { |
| 698 | 691 | // Fallback: use variable name |
| 699 | 692 | resultIdentifier = context.variableName; |
| @@ -952,16 +945,20 @@ export class SlashCommandParser { | ||
| 952 | 945 | prefixOption.valueProvider = () => ''; // Already typed, don't re-insert |
| 953 | 946 | prefixOption.makeSelectable = false; |
| 954 | 947 | prefixOption.sortPriority = 1; // Show at top |
| 948 | + prefixOption.matchProvider = () => true; // Always show regardless of filtering | |
| 955 | 949 | options.push(prefixOption); |
| 956 | 950 | } |
| 957 | 951 | |
| 958 | 952 | // If typing the variable name, suggest existing variables |
| 959 | - if (context.isTypingVariableName) { | |
| 960 | 953 | // Get existing variable names from the appropriate scope |
| 961 | 954 | // Filter to only include names that are valid for shorthand syntax |
| 962 | 955 | const existingVariables = this.#getVariableNames(scope) |
| 963 | 956 | .filter(name => isValidVariableShorthandName(name)); |
| 964 | 957 | |
| 958 | + // Check if the typed variable name exactly matches an existing variable | |
| 959 | + const variableNameMatchesExisting = context.variableName.length > 0 && existingVariables.includes(context.variableName); | |
| 960 | + | |
| 961 | + if (context.isTypingVariableName) { | |
| 965 | 962 | // Add existing variables that match the typed name |
| 966 | 963 | for (const varName of existingVariables) { |
| 967 | 964 | const option = new VariableNameAutoCompleteOption(varName, scope, false); |
| @@ -995,6 +992,20 @@ export class SlashCommandParser { | ||
| 995 | 992 | } |
| 996 | 993 | options.push(newVarOption); |
| 997 | 994 | } |
| 995 | + | |
| 996 | + // If the typed variable name exactly matches an existing variable, also show operators | |
| 997 | + // This allows users to see available operators without having to type a space first | |
| 998 | + if (variableNameMatchesExisting) { | |
| 999 | + for (const [, operatorDef] of VariableOperatorDefinitions) { | |
| 1000 | + const opOption = new VariableOperatorAutoCompleteOption(operatorDef); | |
| 1001 | + opOption.sortPriority = 6; // Lower priority than variable suggestions | |
| 1002 | + opOption.matchProvider = () => true; // Always show | |
| 1003 | + // IMPORTANT: Operators should INSERT after variable name, not replace it | |
| 1004 | + // Use replacementStartOffset to shift insertion point past the variable name | |
| 1005 | + opOption.replacementStartOffset = context.variableName.length; | |
| 1006 | + options.push(opOption); | |
| 1007 | + } | |
| 1008 | + } | |
| 998 | 1009 | } |
| 999 | 1010 | |
| 1000 | 1011 | // If there are invalid trailing characters after the variable name, show a warning |
| @@ -1026,14 +1037,23 @@ export class SlashCommandParser { | ||
| 1026 | 1037 | options.push(varNameOption); |
| 1027 | 1038 | |
| 1028 | 1039 | // Then show available operators, filtered by partial prefix if any |
| 1040 | + // Also filter by current complete operator to show longer variants (e.g., > shows >=) | |
| 1029 | 1041 | const partialOp = context.partialOperator || ''; |
| 1042 | + const currentOp = context.variableOperator || ''; | |
| 1043 | + const filterPrefix = partialOp || currentOp; | |
| 1030 | 1044 | for (const [, operatorDef] of VariableOperatorDefinitions) { |
| 1031 | 1045 | // Filter by partial operator prefix if user is typing one |
| 1032 | - if (partialOp && !operatorDef.symbol.startsWith(partialOp)) { | |
| 1046 | + // This allows typing ">" to show both ">" and ">=" | |
| 1047 | + if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) { | |
| 1033 | 1048 | continue; |
| 1034 | 1049 | } |
| 1035 | 1050 | const opOption = new VariableOperatorAutoCompleteOption(operatorDef); |
| 1036 | - opOption.sortPriority = 5; | |
| 1051 | + // Exact match gets higher priority | |
| 1052 | + opOption.sortPriority = operatorDef.symbol === currentOp ? 4 : 5; | |
| 1053 | + // Already-typed operator is non-selectable | |
| 1054 | + if (operatorDef.symbol === currentOp) { | |
| 1055 | + opOption.valueProvider = () => ''; | |
| 1056 | + } | |
| 1037 | 1057 | // Always match operators when showing operator suggestions |
| 1038 | 1058 | opOption.matchProvider = () => true; |
| 1039 | 1059 | options.push(opOption); |
| @@ -1041,21 +1061,23 @@ export class SlashCommandParser { | ||
| 1041 | 1061 | } |
| 1042 | 1062 | |
| 1043 | 1063 | // If typing value (after = or +=), no autocomplete needed - freeform text |
| 1044 | 1064 | // But we can show the current context for reference (greyed out, non-selectable) |
| 1045 | 1065 | if (context.isTypingValue && !context.isTypingOperator) { |
| 1046 | 1066 | // Show the current variable name as context (non-selectable) |
| 1047 | 1067 | const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); |
| 1048 | 1068 | varNameOption.valueProvider = () => ''; // Context only |
| 1069 | + varNameOption.makeSelectable = false; | |
| 1049 | 1070 | varNameOption.sortPriority = 2; |
| 1050 | 1071 | varNameOption.matchProvider = () => true; // Always show |
| 1051 | 1072 | options.push(varNameOption); |
| 1052 | 1073 | |
| 1053 | 1074 | // Show the operator that was used (non-selectable) |
| 1054 | 1075 | if (context.variableOperator) { |
| 1055 | 1076 | const opDef = VariableOperatorDefinitions.get(context.variableOperator); |
| 1056 | 1077 | if (opDef) { |
| 1057 | 1078 | const opOption = new VariableOperatorAutoCompleteOption(opDef); |
| 1058 | 1079 | opOption.valueProvider = () => ''; // Already typed |
| 1080 | + opOption.makeSelectable = false; | |
| 1059 | 1081 | opOption.sortPriority = 3; |
| 1060 | 1082 | opOption.matchProvider = () => true; // Always show |
| 1061 | 1083 | options.push(opOption); |
| @@ -1063,21 +1085,23 @@ export class SlashCommandParser { | ||
| 1063 | 1085 | } |
| 1064 | 1086 | } |
| 1065 | 1087 | |
| 1066 | 1088 | // If operator is complete (++ or --), show context without value input (non-selectable) |
| 1067 | 1089 | if (context.isOperatorComplete && !context.isTypingOperator) { |
| 1068 | 1090 | // Show the current variable name as context (non-selectable) |
| 1069 | 1091 | const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); |
| 1070 | 1092 | varNameOption.valueProvider = () => ''; // Context only |
| 1093 | + varNameOption.makeSelectable = false; | |
| 1071 | 1094 | varNameOption.sortPriority = 2; |
| 1072 | 1095 | varNameOption.matchProvider = () => true; // Always show |
| 1073 | 1096 | options.push(varNameOption); |
| 1074 | 1097 | |
| 1075 | 1098 | // Show the operator that was used (non-selectable) |
| 1076 | 1099 | if (context.variableOperator) { |
| 1077 | 1100 | const opDef = VariableOperatorDefinitions.get(context.variableOperator); |
| 1078 | 1101 | if (opDef) { |
| 1079 | 1102 | const opOption = new VariableOperatorAutoCompleteOption(opDef); |
| 1080 | 1103 | opOption.valueProvider = () => ''; // Already typed |
| 1104 | + opOption.makeSelectable = false; | |
| 1081 | 1105 | opOption.sortPriority = 3; |
| 1082 | 1106 | opOption.matchProvider = () => true; // Always show |
| 1083 | 1107 | options.push(opOption); |
| @@ -2699,6 +2699,102 @@ test.describe('MacroEngine', () => { | ||
| 2699 | 2699 | expect(output).toBe('true'); |
| 2700 | 2700 | }); |
| 2701 | 2701 | |
| 2702 | + // {{.myvar > value}} - greater than comparison (numeric) | |
| 2703 | + test('should return true when variable is greater than value with >', async ({ page }) => { | |
| 2704 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: '10' } }); | |
| 2705 | + expect(output).toBe('true'); | |
| 2706 | + }); | |
| 2707 | + | |
| 2708 | + test('should return false when variable is not greater than value with >', async ({ page }) => { | |
| 2709 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '5' } }); | |
| 2710 | + expect(output).toBe('false'); | |
| 2711 | + }); | |
| 2712 | + | |
| 2713 | + test('should return false when variable equals value with >', async ({ page }) => { | |
| 2714 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 10}}', { local: { myvar: '10' } }); | |
| 2715 | + expect(output).toBe('false'); | |
| 2716 | + }); | |
| 2717 | + | |
| 2718 | + test('should return false for non-numeric values with >', async ({ page }) => { | |
| 2719 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar > 5}}', { local: { myvar: 'abc' } }); | |
| 2720 | + expect(output).toBe('false'); | |
| 2721 | + }); | |
| 2722 | + | |
| 2723 | + // {{.myvar >= value}} - greater than or equal comparison (numeric) | |
| 2724 | + test('should return true when variable is greater than value with >=', async ({ page }) => { | |
| 2725 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 5}}', { local: { myvar: '10' } }); | |
| 2726 | + expect(output).toBe('true'); | |
| 2727 | + }); | |
| 2728 | + | |
| 2729 | + test('should return true when variable equals value with >=', async ({ page }) => { | |
| 2730 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '10' } }); | |
| 2731 | + expect(output).toBe('true'); | |
| 2732 | + }); | |
| 2733 | + | |
| 2734 | + test('should return false when variable is less than value with >=', async ({ page }) => { | |
| 2735 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 10}}', { local: { myvar: '5' } }); | |
| 2736 | + expect(output).toBe('false'); | |
| 2737 | + }); | |
| 2738 | + | |
| 2739 | + // {{.myvar < value}} - less than comparison (numeric) | |
| 2740 | + test('should return true when variable is less than value with <', async ({ page }) => { | |
| 2741 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '5' } }); | |
| 2742 | + expect(output).toBe('true'); | |
| 2743 | + }); | |
| 2744 | + | |
| 2745 | + test('should return false when variable is not less than value with <', async ({ page }) => { | |
| 2746 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: '10' } }); | |
| 2747 | + expect(output).toBe('false'); | |
| 2748 | + }); | |
| 2749 | + | |
| 2750 | + test('should return false when variable equals value with <', async ({ page }) => { | |
| 2751 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 10}}', { local: { myvar: '10' } }); | |
| 2752 | + expect(output).toBe('false'); | |
| 2753 | + }); | |
| 2754 | + | |
| 2755 | + test('should return false for non-numeric values with <', async ({ page }) => { | |
| 2756 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 5}}', { local: { myvar: 'abc' } }); | |
| 2757 | + expect(output).toBe('false'); | |
| 2758 | + }); | |
| 2759 | + | |
| 2760 | + // {{.myvar <= value}} - less than or equal comparison (numeric) | |
| 2761 | + test('should return true when variable is less than value with <=', async ({ page }) => { | |
| 2762 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '5' } }); | |
| 2763 | + expect(output).toBe('true'); | |
| 2764 | + }); | |
| 2765 | + | |
| 2766 | + test('should return true when variable equals value with <=', async ({ page }) => { | |
| 2767 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 10}}', { local: { myvar: '10' } }); | |
| 2768 | + expect(output).toBe('true'); | |
| 2769 | + }); | |
| 2770 | + | |
| 2771 | + test('should return false when variable is greater than value with <=', async ({ page }) => { | |
| 2772 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 5}}', { local: { myvar: '10' } }); | |
| 2773 | + expect(output).toBe('false'); | |
| 2774 | + }); | |
| 2775 | + | |
| 2776 | + // Negative numbers with comparison operators | |
| 2777 | + test('should handle negative numbers with > operator', async ({ page }) => { | |
| 2778 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar > -5}}', { local: { myvar: '0' } }); | |
| 2779 | + expect(output).toBe('true'); | |
| 2780 | + }); | |
| 2781 | + | |
| 2782 | + test('should handle negative numbers with < operator', async ({ page }) => { | |
| 2783 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar < 0}}', { local: { myvar: '-5' } }); | |
| 2784 | + expect(output).toBe('true'); | |
| 2785 | + }); | |
| 2786 | + | |
| 2787 | + // Decimal numbers with comparison operators | |
| 2788 | + test('should handle decimal numbers with >= operator', async ({ page }) => { | |
| 2789 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar >= 3.14}}', { local: { myvar: '3.14' } }); | |
| 2790 | + expect(output).toBe('true'); | |
| 2791 | + }); | |
| 2792 | + | |
| 2793 | + test('should handle decimal numbers with <= operator', async ({ page }) => { | |
| 2794 | + const output = await evaluateWithEngineAndVariables(page, '{{.myvar <= 2.5}}', { local: { myvar: '2.49' } }); | |
| 2795 | + expect(output).toBe('true'); | |
| 2796 | + }); | |
| 2797 | + | |
| 2702 | 2798 | // Global variable versions of new operators |
| 2703 | 2799 | test('should use || with global variable', async ({ page }) => { |
| 2704 | 2800 | const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } }); |