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

42155ecebf064d1a3476927fe63f9d78594d7d9d

Wolfsblvt <wolfsblvt@gmail.com>

Signed
6 files changed, +313 -87Showing whitespace changes
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+75 -7
@@ -39,7 +39,9 @@ import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnos
39 * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).39 * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
40 * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.40 * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
41 * @property {string} variableName - The variable name being typed (after the prefix).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 * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.43 * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
44 * @property {number} variableOperatorEnd - The end of the variable operator (for partial matches).
43 * @property {string} variableValue - The value after the operator (for = and +=).45 * @property {string} variableValue - The value after the operator (for = and +=).
44 * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.46 * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
45 * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.47 * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
@@ -499,13 +501,13 @@ export const VariableShorthandDefinitions = new Map([
499 type: VariableShorthandType.LOCAL,501 type: VariableShorthandType.LOCAL,
500 name: 'Local Variable',502 name: 'Local Variable',
501 description: 'Access or modify a local variable (scoped to current chat).',503 description: 'Access or modify a local variable (scoped to current chat).',
502 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)'],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 [VariableShorthandType.GLOBAL, {506 [VariableShorthandType.GLOBAL, {
505 type: VariableShorthandType.GLOBAL,507 type: VariableShorthandType.GLOBAL,
506 name: 'Global Variable',508 name: 'Global Variable',
507 description: 'Access or modify a global variable (shared across all chats).',509 description: 'Access or modify a global variable (shared across all chats).',
508 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)'],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]);
511513
@@ -633,6 +635,10 @@ export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
633 `{{${prefix}myvar ??= value}} - Set if undefined, get value`,635 `{{${prefix}myvar ??= value}} - Set if undefined, get value`,
634 `{{${prefix}myvar == test}} - Compare (returns true/false)`,636 `{{${prefix}myvar == test}} - Compare (returns true/false)`,
635 `{{${prefix}myvar != test}} - Compare not equal (returns true/false)`,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 for (const ex of examples) {643 for (const ex of examples) {
638 const li = document.createElement('li');644 const li = document.createElement('li');
@@ -810,6 +816,10 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption {
810 `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,816 `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,
811 `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,817 `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,
812 `{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`,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 for (const ex of examples) {824 for (const ex of examples) {
815 const li = document.createElement('li');825 const li = document.createElement('li');
@@ -824,6 +834,18 @@ export class VariableNameAutoCompleteOption extends AutoCompleteOption {
824}834}
825835
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 */
842function isShortOperatorPrefix(op) {
843 // These operators could have longer variants typed after them
844 const shortPrefixes = ['>', '<', '=', '|', '?', '+', '-', '!'];
845 return shortPrefixes.includes(op);
846}
847
848/**
827 * Variable shorthand operators with metadata.849 * Variable shorthand operators with metadata.
828 * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}850 * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}
829 */851 */
@@ -894,6 +916,30 @@ export const VariableOperatorDefinitions = new Map([
894 description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',916 description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',
895 needsValue: true,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]);
898944
899/**945/**
@@ -1224,19 +1270,35 @@ export function parseMacroContext(macroText, cursorOffset) {
1224 } else if (operatorText.startsWith('!=')) {1270 } else if (operatorText.startsWith('!=')) {
1225 variableOperator = '!=';1271 variableOperator = '!=';
1226 i += 2;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 } else if (operatorText.startsWith('=')) {1285 } else if (operatorText.startsWith('=')) {
1228 variableOperator = '=';1286 variableOperator = '=';
1229 i += 1;1287 i += 1;
1230 } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!')) {1288 } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!') || operatorText.startsWith('>') || operatorText.startsWith('<')) {
1231 // Partial operator prefix - user is typing an operator1289 // Partial operator prefix - user is typing an operator
1232 partialOperator = operatorText[0];1290 partialOperator = operatorText[0];
1233 } else if (operatorText.length > 0 && !/^\s/.test(operatorText)) {1291 } else if (operatorText.length > 0 && !/^\s/.test(operatorText) && !operatorText.startsWith('}')) {
1234 // There's non-whitespace after the variable name that isn't a valid operator1292 // There's non-whitespace after the variable name that isn't a valid operator
1235 // This is an invalid trailing character (e.g., $my$ or .var@test)1293 // This is an invalid trailing character (e.g., $my$ or .var@test)
1294 // Exception: } is the closing brace, not an invalid char
1236 hasInvalidTrailingChars = true;1295 hasInvalidTrailingChars = true;
1237 invalidTrailingChars = operatorText.trim();1296 invalidTrailingChars = operatorText.trim();
1238 }1297 }
12391298
1299 // Track where the operator ends (for cursor position checks)
1300 const variableOperatorEnd = i;
1301
1240 // Check if operator requires a value1302 // Check if operator requires a value
1241 const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;1303 const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;
1242 const operatorNeedsValue = operatorDef?.needsValue ?? false;1304 const operatorNeedsValue = operatorDef?.needsValue ?? false;
@@ -1256,11 +1318,15 @@ export function parseMacroContext(macroText, cursorOffset) {
1256 // Cursor is before the prefix - still in flags area conceptually1318 // Cursor is before the prefix - still in flags area conceptually
1257 isTypingVariableName = false;1319 isTypingVariableName = false;
1258 } else if (cursorOffset <= variableNameEnd) {1320 } else if (cursorOffset <= variableNameEnd) {
1259 // Cursor is in the variable name1321 // Cursor is in the variable name area (including at the end)
1260 isTypingVariableName = true;1322 isTypingVariableName = true;
1261 } else if (!variableOperator && !hasInvalidTrailingChars) {1323 } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars) {
1262 // Cursor is after variable name but no operator yet (and no invalid chars)1324 // Cursor is after variable name but no operator yet (and no invalid chars)
1263 // This includes partial operator prefixes like '+', '-', '|', '?'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 isTypingOperator = true;1330 isTypingOperator = true;
1265 } else if (operatorNeedsValue) {1331 } else if (operatorNeedsValue) {
1266 // Operator that requires value - cursor is in value area1332 // Operator that requires value - cursor is in value area
@@ -1292,7 +1358,9 @@ export function parseMacroContext(macroText, cursorOffset) {
1292 isVariableShorthand,1358 isVariableShorthand,
1293 variablePrefix,1359 variablePrefix,
1294 variableName,1360 variableName,
1361 variableNameEnd,
1295 variableOperator,1362 variableOperator,
1363 variableOperatorEnd,
1296 variableValue,1364 variableValue,
1297 isTypingVariableName,1365 isTypingVariableName,
1298 isTypingOperator,1366 isTypingOperator,
public/scripts/macros/engine/MacroCstWalker.js+60 -0
@@ -574,6 +574,22 @@ class MacroCstWalker {
574 operation = 'notEquals';574 operation = 'notEquals';
575 hasValueExpr = true;575 hasValueExpr = true;
576 break;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 default:593 default:
578 logMacroInternalError({ message: `Lexer found macro operator that is not implemented for variable shorthand expressions in macro node '${macroNode.name}'.` });594 logMacroInternalError({ message: `Lexer found macro operator that is not implemented for variable shorthand expressions in macro node '${macroNode.name}'.` });
579 break;595 break;
@@ -714,6 +730,50 @@ class MacroCstWalker {
714 return currentValue !== compareValue ? 'true' : 'false';730 return currentValue !== compareValue ? 'true' : 'false';
715 }731 }
716732
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 default:777 default:
718 logMacroRuntimeWarning({ message: `Unknown variable shorthand operation: "${operation}"` });778 logMacroRuntimeWarning({ message: `Unknown variable shorthand operation: "${operation}"` });
719 return '';779 return '';
public/scripts/macros/engine/MacroLexer.js+12 -0
@@ -132,6 +132,14 @@ const Tokens = Object.freeze({
132 DoubleEquals: createToken({ name: 'Var.DoubleEquals', pattern: /==/ }),132 DoubleEquals: createToken({ name: 'Var.DoubleEquals', pattern: /==/ }),
133 /** Not equals comparison operator (`!=`) - compares variable to value, returns inverted result */133 /** Not equals comparison operator (`!=`) - compares variable to value, returns inverted result */
134 NotEquals: createToken({ name: 'Var.NotEquals', pattern: /!=/ }),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 /** Add/append operator (`+=`) - must come before Equals to avoid conflict */143 /** Add/append operator (`+=`) - must come before Equals to avoid conflict */
136 PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),144 PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),
137 /** Set operator (`=`) */145 /** Set operator (`=`) */
@@ -258,6 +266,10 @@ const Def = {
258 enter(Tokens.Var.Operators.MinusEquals, modes.var_value, { andExits: modes.var_after_identifier }),266 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 }),267 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 }),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 enter(Tokens.Var.Operators.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }),273 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 }),274 enter(Tokens.Var.Operators.Equals, modes.var_value, { andExits: modes.var_after_identifier }),
263 // If we see the end, exit275 // If we see the end, exit
public/scripts/macros/engine/MacroParser.js+16 -50
@@ -92,65 +92,31 @@ class MacroParser extends CstParser {
92 $.OPTION2(() => $.SUBRULE($.variableOperator));92 $.OPTION2(() => $.SUBRULE($.variableOperator));
93 });93 });
9494
95 // Variable operator: ++, --, = value, += 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.Operators.Increment, { LABEL: 'Var.operator' }) },98 { ALT: () => $.CONSUME(Tokens.Var.Operators.Increment, { LABEL: 'Var.operator' }) },
99 { ALT: () => $.CONSUME(Tokens.Var.Operators.Decrement, { LABEL: 'Var.operator' }) },99 { ALT: () => $.CONSUME(Tokens.Var.Operators.Decrement, { LABEL: 'Var.operator' }) },
100 {100 {
101 ALT: () => {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 $.SUBRULE($.variableValue, { LABEL: 'Var.value' });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 });
156122
public/scripts/slash-commands/SlashCommandParser.js+54 -30
@@ -658,17 +658,13 @@ export class SlashCommandParser {
658 resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix658 resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
659 }659 }
660 } else if (context.isTypingOperator) {660 } else if (context.isTypingOperator) {
661 // Typing operator: identifier = partial operator text (if any), start = after variable name661 // Typing operator: identifier = partial operator or current operator, start = after variable name
662 // Using partial operator as identifier ensures cursor is within name range for filtering662 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 // Start after prefix + variable name length665 // Skip whitespace between variable name and 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 cursor667 resultStart++;
668 // This ensures cursor is in the name range for filtering
669 if (!context.partialOperator) {
670 resultStart = index;
671 }
672 }668 }
673 } else if (context.isOperatorComplete) {669 } else if (context.isOperatorComplete) {
674 // Operator complete (++ or --) - show context but no value input needed670 // Operator complete (++ or --) - show context but no value input needed
@@ -677,15 +673,13 @@ export class SlashCommandParser {
677 } else if (context.hasInvalidTrailingChars) {673 } else if (context.hasInvalidTrailingChars) {
678 // Invalid chars after variable name: show the invalid chars for warning674 // Invalid chars after variable name: show the invalid chars for warning
679 resultIdentifier = context.invalidTrailingChars || '';675 resultIdentifier = context.invalidTrailingChars || '';
680 if (prefixIndex >= 0) {676 // Use actual variableNameEnd position from parsing
681 resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length;677 resultStart = macro.start + 2 + context.variableNameEnd;
682 }
683 } else if (context.isTypingValue) {678 } else if (context.isTypingValue) {
684 // Typing value: identifier = value being typed, start = after operator679 // Typing value: identifier = value being typed, start = after operator
685 resultIdentifier = context.variableValue;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 // Skip any whitespace between operator and value683 // Skip any whitespace between operator and value
690 while (resultStart < index && /\s/.test(text[resultStart])) {684 while (resultStart < index && /\s/.test(text[resultStart])) {
691 resultStart++;685 resultStart++;
@@ -693,7 +687,6 @@ export class SlashCommandParser {
693687
694 makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;688 makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
695 makeNoOptionsText = () => 'Enter a variable value';689 makeNoOptionsText = () => 'Enter a variable value';
696 }
697 } else {690 } else {
698 // Fallback: use variable name691 // Fallback: use variable name
699 resultIdentifier = context.variableName;692 resultIdentifier = context.variableName;
@@ -952,16 +945,20 @@ export class SlashCommandParser {
952 prefixOption.valueProvider = () => ''; // Already typed, don't re-insert945 prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
953 prefixOption.makeSelectable = false;946 prefixOption.makeSelectable = false;
954 prefixOption.sortPriority = 1; // Show at top947 prefixOption.sortPriority = 1; // Show at top
948 prefixOption.matchProvider = () => true; // Always show regardless of filtering
955 options.push(prefixOption);949 options.push(prefixOption);
956 }950 }
957951
958 // If typing the variable name, suggest existing variables952 // If typing the variable name, suggest existing variables
959 if (context.isTypingVariableName) {
960 // Get existing variable names from the appropriate scope953 // Get existing variable names from the appropriate scope
961 // Filter to only include names that are valid for shorthand syntax954 // Filter to only include names that are valid for shorthand syntax
962 const existingVariables = this.#getVariableNames(scope)955 const existingVariables = this.#getVariableNames(scope)
963 .filter(name => isValidVariableShorthandName(name));956 .filter(name => isValidVariableShorthandName(name));
964957
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 // Add existing variables that match the typed name962 // Add existing variables that match the typed name
966 for (const varName of existingVariables) {963 for (const varName of existingVariables) {
967 const option = new VariableNameAutoCompleteOption(varName, scope, false);964 const option = new VariableNameAutoCompleteOption(varName, scope, false);
@@ -995,6 +992,20 @@ export class SlashCommandParser {
995 }992 }
996 options.push(newVarOption);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 }
9991010
1000 // If there are invalid trailing characters after the variable name, show a warning1011 // If there are invalid trailing characters after the variable name, show a warning
@@ -1026,14 +1037,23 @@ export class SlashCommandParser {
1026 options.push(varNameOption);1037 options.push(varNameOption);
10271038
1028 // Then show available operators, filtered by partial prefix if any1039 // 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 const partialOp = context.partialOperator || '';1041 const partialOp = context.partialOperator || '';
1042 const currentOp = context.variableOperator || '';
1043 const filterPrefix = partialOp || currentOp;
1030 for (const [, operatorDef] of VariableOperatorDefinitions) {1044 for (const [, operatorDef] of VariableOperatorDefinitions) {
1031 // Filter by partial operator prefix if user is typing one1045 // Filter by 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 continue;1048 continue;
1034 }1049 }
1035 const opOption = new VariableOperatorAutoCompleteOption(operatorDef);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 // Always match operators when showing operator suggestions1057 // Always match operators when showing operator suggestions
1038 opOption.matchProvider = () => true;1058 opOption.matchProvider = () => true;
1039 options.push(opOption);1059 options.push(opOption);
@@ -1041,21 +1061,23 @@ export class SlashCommandParser {
1041 }1061 }
10421062
1043 // If typing value (after = or +=), no autocomplete needed - freeform text1063 // If typing value (after = or +=), no autocomplete needed - freeform text
1044 // But we can show the current context for reference1064 // But we show the current context for reference (greyed out, non-selectable)
1045 if (context.isTypingValue) {1065 if (context.isTypingValue && !context.isTypingOperator) {
1046 // Show the current variable name as context1066 // Show the current variable name as context (non-selectable)
1047 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);1067 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1048 varNameOption.valueProvider = () => ''; // Context only1068 varNameOption.valueProvider = () => ''; // Context only
1069 varNameOption.makeSelectable = false;
1049 varNameOption.sortPriority = 2;1070 varNameOption.sortPriority = 2;
1050 varNameOption.matchProvider = () => true; // Always show1071 varNameOption.matchProvider = () => true; // Always show
1051 options.push(varNameOption);1072 options.push(varNameOption);
10521073
1053 // Show the operator that was used1074 // Show the operator that was used (non-selectable)
1054 if (context.variableOperator) {1075 if (context.variableOperator) {
1055 const opDef = VariableOperatorDefinitions.get(context.variableOperator);1076 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
1056 if (opDef) {1077 if (opDef) {
1057 const opOption = new VariableOperatorAutoCompleteOption(opDef);1078 const opOption = new VariableOperatorAutoCompleteOption(opDef);
1058 opOption.valueProvider = () => ''; // Already typed1079 opOption.valueProvider = () => ''; // Already typed
1080 opOption.makeSelectable = false;
1059 opOption.sortPriority = 3;1081 opOption.sortPriority = 3;
1060 opOption.matchProvider = () => true; // Always show1082 opOption.matchProvider = () => true; // Always show
1061 options.push(opOption);1083 options.push(opOption);
@@ -1063,21 +1085,23 @@ export class SlashCommandParser {
1063 }1085 }
1064 }1086 }
10651087
1066 // If operator is complete (++ or --), show context without value input1088 // If operator is complete (++ or --), show context without value input (non-selectable)
1067 if (context.isOperatorComplete) {1089 if (context.isOperatorComplete && !context.isTypingOperator) {
1068 // Show the current variable name as context1090 // Show the current variable name as context (non-selectable)
1069 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);1091 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1070 varNameOption.valueProvider = () => ''; // Context only1092 varNameOption.valueProvider = () => ''; // Context only
1093 varNameOption.makeSelectable = false;
1071 varNameOption.sortPriority = 2;1094 varNameOption.sortPriority = 2;
1072 varNameOption.matchProvider = () => true; // Always show1095 varNameOption.matchProvider = () => true; // Always show
1073 options.push(varNameOption);1096 options.push(varNameOption);
10741097
1075 // Show the operator that was used1098 // Show the operator that was used (non-selectable)
1076 if (context.variableOperator) {1099 if (context.variableOperator) {
1077 const opDef = VariableOperatorDefinitions.get(context.variableOperator);1100 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
1078 if (opDef) {1101 if (opDef) {
1079 const opOption = new VariableOperatorAutoCompleteOption(opDef);1102 const opOption = new VariableOperatorAutoCompleteOption(opDef);
1080 opOption.valueProvider = () => ''; // Already typed1103 opOption.valueProvider = () => ''; // Already typed
1104 opOption.makeSelectable = false;
1081 opOption.sortPriority = 3;1105 opOption.sortPriority = 3;
1082 opOption.matchProvider = () => true; // Always show1106 opOption.matchProvider = () => true; // Always show
1083 options.push(opOption);1107 options.push(opOption);
tests/frontend/MacroEngine.e2e.js+96 -0
@@ -2699,6 +2699,102 @@ test.describe('MacroEngine', () => {
2699 expect(output).toBe('true');2699 expect(output).toBe('true');
2700 });2700 });
27012701
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 // Global variable versions of new operators2798 // Global variable versions of new operators
2703 test('should use || with global variable', async ({ page }) => {2799 test('should use || with global variable', async ({ page }) => {
2704 const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } });2800 const output = await evaluateWithEngineAndVariables(page, '{{$myvar || globaldefault}}', { global: { myvar: '' } });