Macros 2.0 (v0.5) - Add variable shorthand macros and variable support to `{{if}}` macro (#4933) * Add variable shorthand syntax support for local and global variables - Add VariableShorthandType enum and VariableShorthandDefinitions for `.` (local) and `$` (global) prefixes - Add VariableShorthandAutoCompleteOption class for autocomplete of variable shorthand syntax - Implement variable expression parsing in MacroCstWalker to handle `{{.varName}}` and `{{$varName}}` syntax - Add support for variable operations: get, set (=), increment (++), decrement (--), and add (+=) - Route variable expressions to appropriate macro via handler * Add variable shorthand autocomplete support for variable names and operators - Add `isValidVariableShorthandName()` helper to validate variable names against shorthand pattern - Add `VariableNameAutoCompleteOption` class for suggesting existing and new variable names - Add `VariableOperatorAutoCompleteOption` class for suggesting operators (=, ++, --, +=) - Add `VariableOperatorDefinitions` map with operator metadata (symbol, name, description, needsValue) * Add variable shorthand support to {{if}} macro condition autocomplete - Add variable shorthand (.var, $var) support to {{if}} condition evaluation in core-macros.js - Detect and resolve variable shorthands using getvar/getglobalvar macros before condition check - Update {{if}} description and examples to document variable shorthand syntax - Add variable shorthand autocomplete options when typing {{if}} condition - Show variable prefix options (. and $) when no condition is typed yet - Reuse #buildVariableShorthandOptions * refactor: Add Object.freeze to lexer constants and improve JSDoc documentation - Freeze `modes` and `Tokens` objects to prevent accidental mutations - Convert inline comments to proper JSDoc format for better documentation - Add JSDoc block for `Def` lexer definition object - Improve comment clarity and formatting consistency throughout MacroLexer.js - Remove redundant section separator comments in variable shorthand modes * Add inversion prefix (!) autocomplete support to {{if}} macro condition - Add SimpleAutoCompleteOption class for basic autocomplete items with name, symbol, and description - Add ! inversion prefix as autocomplete option in {{if}} condition with 🔁 icon - Show ! as selectable option when nothing typed, non-selectable when already present - Fix condition parsing to handle ! prefix with whitespace (e.g., "! $myvar") - Update identifier extraction to strip ! and whitespace before detecting variable * fix lint * Fix variable shorthand regex in {{if}} macro to properly capture prefix and variable name * Expand comprehensive e2e tests for variable shorthand syntax in lexer and macro engine - Add MacroLexer tests for variable shorthand edge cases (whitespace, numbers, underscores, operators) - Add MacroEngine tests for variable shorthand operations (hyphens, underscores, non-existent vars, chaining) - Add MacroEngine tests for variable shorthand in {{if}} conditions (truthy/falsy, inversion, else branches) - Test variable names with hyphens, underscores, and numbers in both get/set and conditional contexts * Fix macro flags not being allowed inside variable shorthand macros - Move MANY(flags) block from macroBody to macro rule to parse flags before branching - Fix MacroCstWalker to extract flags from children.flags instead of bodyChildren.flags - Ensures flags are available for both variable expressions and regular macros - Fixes flag extraction in visitMacro and visitBlockMacroClose methods * Add SillyTavern global to tests eslintrc --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

0dcd9906bfd9c8366acf03dc00975a57901ccd27

Wolfsblvt <wolfsblvt@gmail.com>

Signed
11 files changed, +2100 -125Showing whitespace changes
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+696 -0
@@ -12,6 +12,7 @@ import {
12} from '../macros/MacroBrowser.js';12} from '../macros/MacroBrowser.js';
13import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';13import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
14import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';14import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
15import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
1516
16/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */17/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
1718
@@ -34,6 +35,18 @@ import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
34 * @property {number} separatorCount - Number of '::' separators found.35 * @property {number} separatorCount - Number of '::' separators found.
35 * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).36 * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
36 * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.37 * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
38 * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
39 * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
40 * @property {string} variableName - The variable name being typed (after the prefix).
41 * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
42 * @property {string} variableValue - The value after the operator (for = and +=).
43 * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
44 * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
45 * @property {boolean} isTypingValue - Whether cursor is after an operator that requires a value.
46 * @property {boolean} [hasInvalidTrailingChars] - Whether there are invalid characters after the variable name.
47 * @property {string} [invalidTrailingChars] - The invalid trailing characters (for error display).
48 * @property {string} [partialOperator] - Partial operator prefix being typed ('+' or '-').
49 * @property {boolean} [isOperatorComplete] - Whether a complete operator (++ or --) was typed that doesn't need a value.
37 */50 */
3851
39/**52/**
@@ -445,6 +458,448 @@ export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
445}458}
446459
447/**460/**
461 * Enum of variable shorthand prefix types.
462 * @readonly
463 * @enum {string}
464 */
465export const VariableShorthandType = Object.freeze({
466 /** Local variable prefix (`.`) */
467 LOCAL: '.',
468 /** Global variable prefix (`$`) */
469 GLOBAL: '$',
470});
471
472/**
473 * @typedef {Object} VariableShorthandDefinition
474 * @property {VariableShorthandType} type - The prefix symbol.
475 * @property {string} name - Human-readable name.
476 * @property {string} description - Description of what this prefix does.
477 * @property {string[]} operations - List of supported operations.
478 */
479
480/**
481 * Definitions for variable shorthand prefixes.
482 * @type {Map<string, VariableShorthandDefinition>}
483 */
484export const VariableShorthandDefinitions = new Map([
485 [VariableShorthandType.LOCAL, {
486 type: VariableShorthandType.LOCAL,
487 name: 'Local Variable',
488 description: 'Access or modify a local variable (scoped to current chat).',
489 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)'],
490 }],
491 [VariableShorthandType.GLOBAL, {
492 type: VariableShorthandType.GLOBAL,
493 name: 'Global Variable',
494 description: 'Access or modify a global variable (shared across all chats).',
495 operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)'],
496 }],
497]);
498
499/**
500 * Set of valid variable shorthand prefix symbols.
501 * @type {Set<string>}
502 */
503export const ValidVariableShorthandSymbols = new Set(Object.values(VariableShorthandType));
504
505/**
506 * Regex pattern for valid variable shorthand names.
507 * Must start with a letter, can contain word chars, underscores and hyphens, but must not end with an underscore or hyphen.
508 * Examples: myVar, my-var, my_var, myVar123, my-long-var-name
509 * Invalid: my-, my--, -var, 123var
510 * @type {RegExp}
511 */
512const VARIABLE_SHORTHAND_NAME_PATTERN = new RegExp(`^${MACRO_VARIABLE_SHORTHAND_PATTERN.source}`);
513
514/**
515 * Checks if a variable name is valid for use with variable shorthand syntax.
516 * @param {string} name - The variable name to validate.
517 * @returns {boolean} True if the name is valid for shorthand syntax.
518 */
519export function isValidVariableShorthandName(name) {
520 if (!name || typeof name !== 'string') return false;
521 return VARIABLE_SHORTHAND_NAME_PATTERN.test(name);
522}
523
524/**
525 * Autocomplete option for variable shorthand prefixes.
526 * Shows prefix symbol, name, and description.
527 * This provides entry into the variable shorthand syntax ({{.varName}} or {{$varName}}).
528 */
529export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
530 /** @type {VariableShorthandDefinition} */
531 #varDef;
532
533 /**
534 * @param {VariableShorthandDefinition} varDef - The variable shorthand definition.
535 */
536 constructor(varDef) {
537 // Use the prefix symbol as the name, with a variable icon
538 super(varDef.type, '📦');
539 this.#varDef = varDef;
540 }
541
542 /** @returns {VariableShorthandDefinition} */
543 get variableDefinition() {
544 return this.#varDef;
545 }
546
547 /**
548 * Renders the autocomplete list item for this variable shorthand.
549 * @returns {HTMLElement}
550 */
551 renderItem() {
552 const li = this.makeItem(
553 `${this.#varDef.type} ${this.#varDef.name}`,
554 '📦',
555 true, // noSlash
556 [], // namedArguments
557 [], // unnamedArguments
558 'any', // returnType
559 this.#varDef.description,
560 );
561 li.setAttribute('data-name', this.name);
562 li.setAttribute('data-option-type', 'variable-shorthand');
563 return li;
564 }
565
566 /**
567 * Renders the details panel for this variable shorthand.
568 * @returns {DocumentFragment}
569 */
570 renderDetails() {
571 const frag = document.createDocumentFragment();
572
573 const details = document.createElement('div');
574 details.classList.add('macro-variable-details');
575
576 // Header with prefix symbol and name
577 const header = document.createElement('h3');
578 header.classList.add('macro-variable-details-header');
579 header.innerHTML = `<code>${this.#varDef.type}</code> ${this.#varDef.name}`;
580 details.append(header);
581
582 // Description
583 const desc = document.createElement('p');
584 desc.classList.add('macro-variable-details-desc');
585 desc.textContent = this.#varDef.description;
586 details.append(desc);
587
588 // Supported operations
589 const opsHeader = document.createElement('p');
590 opsHeader.innerHTML = '<strong>Supported Operations:</strong>';
591 details.append(opsHeader);
592
593 const opsList = document.createElement('ul');
594 opsList.classList.add('macro-variable-details-ops');
595 for (const op of this.#varDef.operations) {
596 const li = document.createElement('li');
597 li.textContent = op;
598 opsList.append(li);
599 }
600 details.append(opsList);
601
602 // Examples
603 const exampleHeader = document.createElement('p');
604 exampleHeader.innerHTML = '<strong>Examples:</strong>';
605 details.append(exampleHeader);
606
607 const exampleList = document.createElement('ul');
608 exampleList.classList.add('macro-variable-details-examples');
609 const prefix = this.#varDef.type;
610 const examples = [
611 `{{${prefix}myvar}} - Get variable value`,
612 `{{${prefix}myvar = value}} - Set variable`,
613 `{{${prefix}counter++}} - Increment`,
614 `{{${prefix}counter--}} - Decrement`,
615 `{{${prefix}myvar += text}} - Append/add`,
616 ];
617 for (const ex of examples) {
618 const li = document.createElement('li');
619 li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
620 exampleList.append(li);
621 }
622 details.append(exampleList);
623
624 frag.append(details);
625 return frag;
626 }
627}
628
629/**
630 * Autocomplete option for a specific variable name.
631 * Shows variable name with scope indicator (local/global).
632 */
633export class VariableNameAutoCompleteOption extends AutoCompleteOption {
634 /** @type {string} */
635 #varName;
636
637 /** @type {'local'|'global'} */
638 #scope;
639
640 /** @type {boolean} */
641 #isNewVariable;
642
643 /** @type {boolean} */
644 #isInvalidName;
645
646 /**
647 * @param {string} varName - The variable name.
648 * @param {'local'|'global'} scope - Whether this is a local or global variable.
649 * @param {boolean} [isNewVariable=false] - Whether this is a "create new variable" option.
650 * @param {boolean} [isInvalidName=false] - Whether this name is invalid for shorthand syntax.
651 */
652 constructor(varName, scope, isNewVariable = false, isInvalidName = false) {
653 const icon = scope === 'local' ? 'L' : 'G';
654 super(varName, icon);
655 this.#varName = varName;
656 this.#scope = scope;
657 this.#isNewVariable = isNewVariable;
658 this.#isInvalidName = isInvalidName;
659 }
660
661 /** @returns {string} */
662 get variableName() {
663 return this.#varName;
664 }
665
666 /** @returns {'local'|'global'} */
667 get scope() {
668 return this.#scope;
669 }
670
671 /** @returns {boolean} */
672 get isNewVariable() {
673 return this.#isNewVariable;
674 }
675
676 /** @returns {boolean} */
677 get isInvalidName() {
678 return this.#isInvalidName;
679 }
680
681 /**
682 * Renders the autocomplete list item for this variable.
683 * @returns {HTMLElement}
684 */
685 renderItem() {
686 const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
687 let description;
688 if (this.#isInvalidName) {
689 description = '⚠️ Invalid variable name for shorthand';
690 } else if (this.#isNewVariable) {
691 description = `Define new ${scopeLabel.toLowerCase()} variable`;
692 } else {
693 description = `${scopeLabel} variable`;
694 }
695
696 const li = this.makeItem(
697 this.#varName,
698 this.typeIcon,
699 true, // noSlash
700 [], // namedArguments
701 [], // unnamedArguments
702 'any', // returnType
703 description,
704 );
705 li.setAttribute('data-name', this.name);
706 li.setAttribute('data-option-type', 'variable-name');
707 if (this.#isNewVariable) {
708 li.classList.add('variable-new');
709 }
710 if (this.#isInvalidName) {
711 li.classList.add('variable-invalid');
712 }
713 return li;
714 }
715
716 /**
717 * Renders the details panel for this variable.
718 * @returns {DocumentFragment}
719 */
720 renderDetails() {
721 const frag = document.createDocumentFragment();
722
723 const details = document.createElement('div');
724 details.classList.add('macro-variable-name-details');
725
726 const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
727 const prefix = this.#scope === 'local' ? '.' : '$';
728
729 // Show big warning for invalid names
730 if (this.#isInvalidName) {
731 const warningBox = document.createElement('div');
732 warningBox.classList.add('variable-invalid-warning');
733 warningBox.style.cssText = 'background: #ff000033; border: 2px solid #ff0000; border-radius: 4px; padding: 10px; margin-bottom: 10px;';
734
735 const warningHeader = document.createElement('h3');
736 warningHeader.style.cssText = 'color: #ff6b6b; margin: 0 0 8px 0;';
737 warningHeader.textContent = '⚠️ Invalid Variable Name';
738 warningBox.append(warningHeader);
739
740 const warningText = document.createElement('p');
741 warningText.style.cssText = 'margin: 0 0 8px 0;';
742 warningText.innerHTML = `The name <code>${this.#varName}</code> cannot be used with variable shorthand syntax.`;
743 warningBox.append(warningText);
744
745 const rulesText = document.createElement('p');
746 rulesText.style.cssText = 'margin: 0; font-size: 0.9em;';
747 rulesText.innerHTML = '<strong>Valid names must:</strong><br>• Start with a letter (a-z, A-Z)<br>• Contain only letters, numbers, underscores, or hyphens<br>• Not end with an underscore or hyphen';
748 warningBox.append(rulesText);
749
750 details.append(warningBox);
751 frag.append(details);
752 return frag;
753 }
754
755 // Header
756 const header = document.createElement('h3');
757 header.innerHTML = this.#isNewVariable
758 ? `<code>${prefix}${this.#varName}</code> (New ${scopeLabel} Variable)`
759 : `<code>${prefix}${this.#varName}</code> ${scopeLabel} Variable`;
760 details.append(header);
761
762 // Description
763 const desc = document.createElement('p');
764 const variableSuggestion = this.#scope === 'local'
765 ? 'Local variables are scoped to the current chat.'
766 : 'Global variables are shared across all chats.';
767 if (this.#isNewVariable) {
768 desc.textContent = `Creates a new ${scopeLabel.toLowerCase()} variable named "${this.#varName}". ${variableSuggestion}`;
769 } else {
770 desc.textContent = `Access or modify the ${scopeLabel.toLowerCase()} variable "${this.#varName}". ${variableSuggestion}`;
771 }
772 details.append(desc);
773
774 // Usage examples
775 const usageHeader = document.createElement('p');
776 usageHeader.innerHTML = '<strong>Usage:</strong>';
777 details.append(usageHeader);
778
779 const usageList = document.createElement('ul');
780 const examples = [
781 `{{${prefix}${this.#varName}}} - Get value`,
782 `{{${prefix}${this.#varName} = value}} - Set value`,
783 `{{${prefix}${this.#varName}++}} - Increment`,
784 `{{${prefix}${this.#varName}--}} - Decrement`,
785 `{{${prefix}${this.#varName} += text}} - Append/add`,
786 ];
787 for (const ex of examples) {
788 const li = document.createElement('li');
789 li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
790 usageList.append(li);
791 }
792 details.append(usageList);
793
794 frag.append(details);
795 return frag;
796 }
797}
798
799/**
800 * Variable shorthand operators with metadata.
801 * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}
802 */
803export const VariableOperatorDefinitions = new Map([
804 ['=', {
805 symbol: '=',
806 name: 'Set',
807 description: 'Set the variable to a new value.',
808 needsValue: true,
809 }],
810 ['++', {
811 symbol: '++',
812 name: 'Increment',
813 description: 'Increment the variable by 1 (numeric).',
814 needsValue: false,
815 }],
816 ['--', {
817 symbol: '--',
818 name: 'Decrement',
819 description: 'Decrement the variable by 1 (numeric).',
820 needsValue: false,
821 }],
822 ['+=', {
823 symbol: '+=',
824 name: 'Add',
825 description: 'Add to the variable (numeric addition or string concatenation).',
826 needsValue: true,
827 }],
828]);
829
830/**
831 * Autocomplete option for a variable operator.
832 * Shows operator symbol, name, and description.
833 */
834export class VariableOperatorAutoCompleteOption extends AutoCompleteOption {
835 /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
836 #operatorDef;
837
838 /**
839 * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
840 */
841 constructor(operatorDef) {
842 super(operatorDef.symbol, '⚡');
843 this.#operatorDef = operatorDef;
844 }
845
846 /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
847 get operatorDefinition() {
848 return this.#operatorDef;
849 }
850
851 /**
852 * Renders the autocomplete list item for this operator.
853 * @returns {HTMLElement}
854 */
855 renderItem() {
856 const li = this.makeItem(
857 `${this.#operatorDef.symbol} ${this.#operatorDef.name}`,
858 '⚡',
859 true, // noSlash
860 [], // namedArguments
861 [], // unnamedArguments
862 'void', // returnType
863 this.#operatorDef.description,
864 );
865 li.setAttribute('data-name', this.name);
866 li.setAttribute('data-option-type', 'variable-operator');
867 return li;
868 }
869
870 /**
871 * Renders the details panel for this operator.
872 * @returns {DocumentFragment}
873 */
874 renderDetails() {
875 const frag = document.createDocumentFragment();
876
877 const details = document.createElement('div');
878 details.classList.add('macro-variable-operator-details');
879
880 // Header
881 const header = document.createElement('h3');
882 header.innerHTML = `<code>${this.#operatorDef.symbol}</code> ${this.#operatorDef.name}`;
883 details.append(header);
884
885 // Description
886 const desc = document.createElement('p');
887 desc.textContent = this.#operatorDef.description;
888 details.append(desc);
889
890 // Value note
891 const valueNote = document.createElement('p');
892 valueNote.innerHTML = this.#operatorDef.needsValue
893 ? '<em>This operator requires a value after it.</em>'
894 : '<em>This operator does not take a value.</em>';
895 details.append(valueNote);
896
897 frag.append(details);
898 return frag;
899 }
900}
901
902/**
448 * Autocomplete option for closing a scoped macro.903 * Autocomplete option for closing a scoped macro.
449 * Suggests {{/macroName}} to close an unclosed scoped macro.904 * Suggests {{/macroName}} to close an unclosed scoped macro.
450 */905 */
@@ -608,6 +1063,129 @@ export function parseMacroContext(macroText, cursorOffset) {
608 }1063 }
609 }1064 }
6101065
1066 // Check for variable shorthand prefix (. or $)
1067 // These trigger variable expression mode instead of regular macro parsing
1068 /** @type {'.'|'$'|null} */
1069 let variablePrefix = null;
1070 let variableName = '';
1071 /** @type {string|null} */
1072 let variableOperator = null;
1073 let variableValue = '';
1074 let isVariableShorthand = false;
1075 let isTypingVariableName = false;
1076 let isTypingOperator = false;
1077 let isTypingValue = false;
1078 let variableNameEnd = i;
1079
1080 const remainingAfterFlags = macroText.slice(i);
1081 if (remainingAfterFlags.startsWith('.') || remainingAfterFlags.startsWith('$')) {
1082 isVariableShorthand = true;
1083 variablePrefix = /** @type {'.'|'$'} */ (remainingAfterFlags[0]);
1084 i++; // Move past the prefix
1085
1086 // Variable names: start with letter, can have hyphens inside, must not end with hyphen
1087 const varNameMatch = macroText.slice(i).match(VARIABLE_SHORTHAND_NAME_PATTERN);
1088 if (varNameMatch) {
1089 variableName = varNameMatch[0];
1090 i += variableName.length;
1091 }
1092 variableNameEnd = i;
1093
1094 // Skip whitespace before operator
1095 while (i < macroText.length && /\s/.test(macroText[i])) {
1096 i++;
1097 }
1098
1099 // Check for operators: ++, --, +=, =
1100 // Also track partial operator prefixes for autocomplete
1101 const operatorText = macroText.slice(i);
1102 let hasInvalidTrailingChars = false;
1103 let invalidTrailingChars = '';
1104 let partialOperator = '';
1105 if (operatorText.startsWith('++')) {
1106 variableOperator = '++';
1107 i += 2;
1108 } else if (operatorText.startsWith('--')) {
1109 variableOperator = '--';
1110 i += 2;
1111 } else if (operatorText.startsWith('+=')) {
1112 variableOperator = '+=';
1113 i += 2;
1114 } else if (operatorText.startsWith('=')) {
1115 variableOperator = '=';
1116 i += 1;
1117 } else if (operatorText.startsWith('+') || operatorText.startsWith('-')) {
1118 // Partial operator prefix - user is typing an operator
1119 partialOperator = operatorText[0];
1120 } else if (operatorText.length > 0 && !/^\s/.test(operatorText)) {
1121 // There's non-whitespace after the variable name that isn't a valid operator
1122 // This is an invalid trailing character (e.g., $my$ or .var@test)
1123 hasInvalidTrailingChars = true;
1124 invalidTrailingChars = operatorText.trim();
1125 }
1126
1127 // If operator requires a value (= or +=), parse the value
1128 if (variableOperator === '=' || variableOperator === '+=') {
1129 // Skip whitespace after operator
1130 while (i < macroText.length && /\s/.test(macroText[i])) {
1131 i++;
1132 }
1133 variableValue = macroText.slice(i).trimEnd();
1134 }
1135
1136 // Determine cursor position context for autocomplete
1137 const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;
1138 if (cursorOffset < prefixEnd) {
1139 // Cursor is before the prefix - still in flags area conceptually
1140 isTypingVariableName = false;
1141 } else if (cursorOffset <= variableNameEnd) {
1142 // Cursor is in the variable name
1143 isTypingVariableName = true;
1144 } else if (!variableOperator && !hasInvalidTrailingChars) {
1145 // Cursor is after variable name but no operator yet (and no invalid chars)
1146 // This includes partial operator prefixes like '+' or '-'
1147 isTypingOperator = true;
1148 } else if (variableOperator === '=' || variableOperator === '+=') {
1149 // Operator that requires value - cursor is in value area
1150 isTypingValue = true;
1151 }
1152 // For ++ and --, the operator is complete (no value needed)
1153 // For invalid trailing chars, none of the typing flags will be true
1154 const isOperatorComplete = (variableOperator === '++' || variableOperator === '--');
1155
1156 // Return early for variable shorthand - different structure than regular macros
1157 return {
1158 fullText: macroText,
1159 cursorOffset,
1160 paddingBefore: macroText.match(/^\s+/)?.[0] ?? '',
1161 identifier: '', // No macro identifier for variable shorthand
1162 identifierStart: -1,
1163 isInFlagsArea: false,
1164 flags,
1165 currentFlag,
1166 args: [],
1167 currentArgIndex: -1,
1168 isTypingSeparator: false,
1169 hasSpaceAfterIdentifier: false,
1170 hasSpaceArgContent: false,
1171 separatorCount: 0,
1172 // Variable shorthand specific properties
1173 isVariableShorthand,
1174 variablePrefix,
1175 variableName,
1176 variableOperator,
1177 variableValue,
1178 isTypingVariableName,
1179 isTypingOperator,
1180 isTypingValue,
1181 isOperatorComplete,
1182 hasInvalidTrailingChars,
1183 invalidTrailingChars,
1184 partialOperator,
1185 };
1186 }
1187
1188 // Regular macro parsing (not variable shorthand)
611 // Now parse the identifier and arguments starting from position i1189 // Now parse the identifier and arguments starting from position i
612 const remainingText = macroText.slice(i);1190 const remainingText = macroText.slice(i);
613 const parts = [];1191 const parts = [];
@@ -725,5 +1303,123 @@ export function parseMacroContext(macroText, cursorOffset) {
725 hasSpaceAfterIdentifier,1303 hasSpaceAfterIdentifier,
726 hasSpaceArgContent: spaceArgText.length > 0,1304 hasSpaceArgContent: spaceArgText.length > 0,
727 separatorCount: separatorPositions.length,1305 separatorCount: separatorPositions.length,
1306 // Default variable shorthand properties (not a variable shorthand)
1307 isVariableShorthand: false,
1308 variablePrefix: null,
1309 variableName: '',
1310 variableOperator: null,
1311 variableValue: '',
1312 isTypingVariableName: false,
1313 isTypingOperator: false,
1314 isTypingValue: false,
728 };1315 };
729}1316}
1317
1318/**
1319 * A simple, generic autocomplete option for displaying basic items with name, symbol, and description.
1320 * Useful for simple options like inversion markers, prefixes, etc. without needing a full custom class.
1321 *
1322 * @extends AutoCompleteOption
1323 */
1324export class SimpleAutoCompleteOption extends AutoCompleteOption {
1325 /** @type {string} */
1326 #description;
1327
1328 /** @type {string|null} */
1329 #detailedDescription;
1330
1331 /**
1332 * @param {Object} config - Configuration for the option.
1333 * @param {string} config.name - The option name/key (used for matching).
1334 * @param {string} [config.symbol=' '] - Icon/symbol shown in the type column.
1335 * @param {string} [config.description=''] - Short description shown inline.
1336 * @param {string} [config.detailedDescription] - Longer description for details panel (supports HTML). Falls back to description if not provided.
1337 * @param {string} [config.type='simple'] - Type identifier for CSS/data attributes.
1338 */
1339 constructor({ name, symbol = ' ', description = '', detailedDescription = null, type = 'simple' }) {
1340 super(name, symbol, type);
1341 this.#description = description;
1342 this.#detailedDescription = detailedDescription;
1343 }
1344
1345 /** @returns {string} */
1346 get description() {
1347 return this.#description;
1348 }
1349
1350 /** @returns {string} */
1351 get detailedDescription() {
1352 return this.#detailedDescription ?? this.#description;
1353 }
1354
1355 /**
1356 * @returns {HTMLElement}
1357 */
1358 renderItem() {
1359 const li = document.createElement('li');
1360 li.classList.add('item');
1361 li.setAttribute('data-name', this.name);
1362 li.setAttribute('data-option-type', this.type);
1363
1364 // Type icon
1365 const typeSpan = document.createElement('span');
1366 typeSpan.classList.add('type', 'monospace');
1367 typeSpan.textContent = this.typeIcon;
1368 li.append(typeSpan);
1369
1370 // Name
1371 const specs = document.createElement('span');
1372 specs.classList.add('specs');
1373 const nameSpan = document.createElement('span');
1374 nameSpan.classList.add('name', 'monospace');
1375 this.name.split('').forEach(char => {
1376 const span = document.createElement('span');
1377 span.textContent = char;
1378 nameSpan.append(span);
1379 });
1380 specs.append(nameSpan);
1381 li.append(specs);
1382
1383 // Stopgap
1384 const stopgap = document.createElement('span');
1385 stopgap.classList.add('stopgap');
1386 li.append(stopgap);
1387
1388 // Help/description
1389 const help = document.createElement('span');
1390 help.classList.add('help');
1391 const content = document.createElement('span');
1392 content.classList.add('helpContent');
1393 content.textContent = this.#description;
1394 help.append(content);
1395 li.append(help);
1396
1397 return li;
1398 }
1399
1400 /**
1401 * @returns {DocumentFragment}
1402 */
1403 renderDetails() {
1404 const frag = document.createDocumentFragment();
1405
1406 // Header with name
1407 const specs = document.createElement('div');
1408 specs.classList.add('specs');
1409 const nameDiv = document.createElement('div');
1410 nameDiv.classList.add('name', 'monospace');
1411 nameDiv.textContent = this.name;
1412 specs.append(nameDiv);
1413 frag.append(specs);
1414
1415 // Description
1416 if (this.detailedDescription) {
1417 const helpDiv = document.createElement('div');
1418 helpDiv.classList.add('help');
1419 helpDiv.innerHTML = this.detailedDescription;
1420 frag.append(helpDiv);
1421 }
1422
1423 return frag;
1424 }
1425}
public/scripts/macros/definitions/core-macros.js+18 -4
@@ -5,6 +5,7 @@ import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js'
5import { inject_ids } from '../../constants.js';5import { inject_ids } from '../../constants.js';
6import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';6import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
7import { MacroEngine } from '../engine/MacroEngine.js';7import { MacroEngine } from '../engine/MacroEngine.js';
8import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../engine/MacroLexer.js';
89
9/**10/**
10 * Marker used by {{else}} to split content in {{if}} blocks.11 * Marker used by {{else}} to split content in {{if}} blocks.
@@ -94,14 +95,14 @@ export function registerCoreMacros() {
94 // {{if condition}}content{{/if}} -> conditional content95 // {{if condition}}content{{/if}} -> conditional content
95 // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch96 // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
96 // {{if !condition}}content{{/if}} -> inverted conditional (negated)97 // {{if !condition}}content{{/if}} -> inverted conditional (negated)
97 // Condition can be a macro name (resolved automatically) or any value98 // Condition can be a macro name (resolved automatically), variable shorthand (.var or $var), or any value
98 MacroRegistry.registerMacro('if', {99 MacroRegistry.registerMacro('if', {
99 category: MacroCategory.UTILITY,100 category: MacroCategory.UTILITY,
100 description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first.',101 description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first. Variable shorthands (.varname for local, $varname for global) are also supported.',
101 unnamedArgs: [102 unnamedArgs: [
102 {103 {
103 name: 'condition',104 name: 'condition',
104 description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved) or a value. Falsy: empty string, "false", "off", "0".',105 description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved), variable shorthand (.var or $var), or a value. Falsy: empty string, "false", "off", "0".',
105 },106 },
106 {107 {
107 name: 'content',108 name: 'content',
@@ -114,6 +115,8 @@ export function registerCoreMacros() {
114 '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',115 '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
115 '{{if !personality}}No personality defined{{/if}}',116 '{{if !personality}}No personality defined{{/if}}',
116 '{{if {{getvar::showHeader}}}}# Header{{/if}}',117 '{{if {{getvar::showHeader}}}}# Header{{/if}}',
118 '{{if .myvar}}Local var exists{{/if}}',
119 '{{if $globalFlag}}Global flag is set{{/if}}',
117 ],120 ],
118 returns: 'The content if condition is truthy, else branch or empty string otherwise.',121 returns: 'The content if condition is truthy, else branch or empty string otherwise.',
119 handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => {122 handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => {
@@ -123,9 +126,19 @@ export function registerCoreMacros() {
123 if (/^\s*!/.test(rawCondition)) {126 if (/^\s*!/.test(rawCondition)) {
124 inverted = true;127 inverted = true;
125 // Strip the ! from the resolved condition if it was the prefix128 // Strip the ! from the resolved condition if it was the prefix
126 condition = condition.replace(/^!/, '');129 condition = condition.replace(/^!\s*/, '');
127 }130 }
128131
132 // Check if condition is a variable shorthand (.varname or $varname)
133 // If so, resolve it using the appropriate variable macro
134 const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`);
135 const varShorthandMatch = condition.match(varShorthandRegex);
136 if (varShorthandMatch) {
137 const [, prefix, varName] = varShorthandMatch;
138 const varMacro = prefix === '.' ? 'getvar' : 'getglobalvar';
139 // Resolve the variable using MacroEngine.evaluate
140 condition = MacroEngine.evaluate(`{{${varMacro}::${varName}}}`, env);
141 } else {
129 // Check if condition is a registered macro name (without braces)142 // Check if condition is a registered macro name (without braces)
130 // If so, resolve it first (only for macros that accept 0 required args)143 // If so, resolve it first (only for macros that accept 0 required args)
131 const macroDef = MacroRegistry.getPrimaryMacro(condition);144 const macroDef = MacroRegistry.getPrimaryMacro(condition);
@@ -134,6 +147,7 @@ export function registerCoreMacros() {
134 // This ensures all handler args (cst, normalize, list, etc.) are correctly provided147 // This ensures all handler args (cst, normalize, list, etc.) are correctly provided
135 condition = MacroEngine.evaluate(`{{${condition}}}`, env);148 condition = MacroEngine.evaluate(`{{${condition}}}`, env);
136 }149 }
150 }
137151
138 // Check if condition is falsy: empty string or isFalseBoolean152 // Check if condition is falsy: empty string or isFalseBoolean
139 let isFalsy = condition === '' || isFalseBoolean(condition);153 let isFalsy = condition === '' || isFalseBoolean(condition);
public/scripts/macros/engine/MacroCstWalker.js+203 -10
@@ -13,6 +13,7 @@ import { MacroRegistry } from './MacroRegistry.js';
13 * @property {string[]} args13 * @property {string[]} args
14 * @property {MacroFlags} flags - Parsed macro execution flags.14 * @property {MacroFlags} flags - Parsed macro execution flags.
15 * @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).15 * @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).
16 * @property {boolean} [isVariableShorthand] - Whether this call originated from variable shorthand syntax.
16 * @property {MacroEnv} env17 * @property {MacroEnv} env
17 * @property {string} rawInner18 * @property {string} rawInner
18 * @property {string} rawWithBraces19 * @property {string} rawWithBraces
@@ -22,6 +23,14 @@ import { MacroRegistry } from './MacroRegistry.js';
22 */23 */
2324
24/**25/**
26 * @typedef {Object} VariableExprInfo
27 * @property {'local' | 'global'} scope - Whether this is a local (.) or global ($) variable.
28 * @property {string} varName - The variable name.
29 * @property {'get' | 'set' | 'inc' | 'dec' | 'add'} operation - The operation to perform.
30 * @property {string | null} value - The value for set/add operations, null for get/inc/dec.
31 */
32
33/**
25 * @typedef {Object} EvaluationContext34 * @typedef {Object} EvaluationContext
26 * @property {string} text35 * @property {string} text
27 * @property {MacroEnv} env36 * @property {MacroEnv} env
@@ -240,11 +249,21 @@ class MacroCstWalker {
240 const { text, env, resolveMacro, trimContent } = context;249 const { text, env, resolveMacro, trimContent } = context;
241250
242 const children = macroNode.children || {};251 const children = macroNode.children || {};
243 const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);252
253 // Check if this is a variable expression (has variableExpr child)
254 const variableExprNode = /** @type {CstNode?} */ ((children.variableExpr || [])[0]);
255 if (variableExprNode) {
256 return this.#evaluateVariableExpr(macroNode, variableExprNode, context);
257 }
258
259 // Regular macro - get identifier from macroBody
260 const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]);
261 const bodyChildren = macroBodyNode?.children || {};
262 const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []);
244 const name = identifierTokens[0]?.image || '';263 const name = identifierTokens[0]?.image || '';
245264
246 // Extract flag tokens and parse them into a MacroFlags object265 // Extract flag tokens and parse them into a MacroFlags object (now inside macroBody)
247 const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);266 const flagTokens = /** @type {IToken[]} */ (children.flags || []);
248 const flagSymbols = flagTokens.map(token => token.image);267 const flagSymbols = flagTokens.map(token => token.image);
249 const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();268 const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();
250269
@@ -255,8 +274,8 @@ class MacroCstWalker {
255 const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset;274 const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset;
256 const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset;275 const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset;
257276
258 // Extract argument nodes from the "arguments" rule (if present)277 // Extract argument nodes from the "arguments" rule (if present, inside macroBody)
259 const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);278 const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
260 const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);279 const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
261280
262 /** @type {string[]} */281 /** @type {string[]} */
@@ -349,6 +368,167 @@ class MacroCstWalker {
349 }368 }
350369
351 /**370 /**
371 * Evaluates a variable expression node and routes it to the appropriate variable macro.
372 *
373 * @param {CstNode} macroNode - The parent macro node.
374 * @param {CstNode} variableExprNode - The variableExpr CST node.
375 * @param {EvaluationContext} context - The evaluation context.
376 * @returns {string}
377 */
378 #evaluateVariableExpr(macroNode, variableExprNode, context) {
379 const { text, env, resolveMacro } = context;
380
381 const children = macroNode.children || {};
382 const varChildren = variableExprNode.children || {};
383
384 // Extract scope (. for local, $ for global)
385 const localPrefixToken = /** @type {IToken?} */ ((varChildren['Var.scope'] || []).find(t => /** @type {IToken} */(t).tokenType?.name === 'Var.LocalPrefix'));
386 const isGlobal = !localPrefixToken;
387
388 // Extract variable name
389 const varIdentifierToken = /** @type {IToken?} */ ((varChildren['Var.identifier'] || [])[0]);
390 const varName = varIdentifierToken?.image || '';
391
392 // Extract operator (if any)
393 const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]);
394 const operatorChildren = operatorNode?.children || {};
395
396 // Determine operation and value
397 let operation = 'get';
398 let value = null;
399
400 if (operatorNode) {
401 const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []);
402 const operatorToken = operatorTokens[0];
403
404 if (operatorToken) {
405 const operatorImage = operatorToken.image;
406 if (operatorImage === '++') {
407 operation = 'inc';
408 } else if (operatorImage === '--') {
409 operation = 'dec';
410 } else if (operatorImage === '=') {
411 operation = 'set';
412 value = this.#evaluateVariableValue(operatorChildren, context);
413 } else if (operatorImage === '+=') {
414 operation = 'add';
415 value = this.#evaluateVariableValue(operatorChildren, context);
416 }
417 }
418 }
419
420 // Map operation to macro name
421 const macroNameMap = {
422 get: isGlobal ? 'getglobalvar' : 'getvar',
423 set: isGlobal ? 'setglobalvar' : 'setvar',
424 inc: isGlobal ? 'incglobalvar' : 'incvar',
425 dec: isGlobal ? 'decglobalvar' : 'decvar',
426 add: isGlobal ? 'addglobalvar' : 'addvar',
427 };
428
429 const targetMacroName = macroNameMap[operation];
430
431 // Build args array based on operation
432 const args = [varName];
433 if (value !== null) {
434 args.push(value);
435 }
436
437 const range = this.#getMacroRange(macroNode);
438
439 /** @type {MacroCall} */
440 const call = {
441 name: targetMacroName,
442 args,
443 flags: createEmptyFlags(),
444 isScoped: false,
445 isVariableShorthand: true,
446 rawInner: text.slice(
447 (/** @type {IToken|undefined} */ (children['Macro.Start']?.[0])?.endOffset ?? range.startOffset) + 1,
448 (/** @type {IToken|undefined} */ (children['Macro.End']?.[0])?.startOffset ?? range.endOffset + 1) - 1,
449 ),
450 rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
451 rawArgs: args,
452 range,
453 cstNode: macroNode,
454 env,
455 };
456
457 const result = resolveMacro(call);
458 return typeof result === 'string' ? result : String(result ?? '');
459 }
460
461 /**
462 * Evaluates the value part of a variable expression (after = or +=).
463 * Resolves any nested macros in the value.
464 *
465 * @param {Record<string, any>} operatorChildren - The children of the variableOperator node.
466 * @param {EvaluationContext} context - The evaluation context.
467 * @returns {string}
468 */
469 #evaluateVariableValue(operatorChildren, context) {
470 const { text } = context;
471
472 const valueNodes = /** @type {CstNode[]} */ (operatorChildren['Var.value'] || []);
473 const valueNode = valueNodes[0];
474
475 if (!valueNode) {
476 return '';
477 }
478
479 const valueChildren = valueNode.children || {};
480
481 // Get all tokens and nested macros from the value
482 const identifierTokens = /** @type {IToken[]} */ (valueChildren.Identifier || []);
483 const unknownTokens = /** @type {IToken[]} */ (valueChildren.Unknown || []);
484 const nestedMacros = /** @type {CstNode[]} */ (valueChildren.macro || []);
485
486 // Get the range of the value
487 const allTokens = [...identifierTokens, ...unknownTokens];
488 const allRanges = [
489 ...allTokens.map(t => ({ startOffset: t.startOffset, endOffset: t.endOffset })),
490 ...nestedMacros.map(m => this.#getMacroRange(m)),
491 ];
492
493 if (allRanges.length === 0) {
494 return '';
495 }
496
497 const startOffset = Math.min(...allRanges.map(r => r.startOffset));
498 const endOffset = Math.max(...allRanges.map(r => r.endOffset));
499
500 // If no nested macros, return the raw text (trimmed)
501 if (nestedMacros.length === 0) {
502 return text.slice(startOffset, endOffset + 1).trim();
503 }
504
505 // Evaluate nested macros
506 const nestedWithRange = nestedMacros.map(node => ({
507 node,
508 range: this.#getMacroRange(node),
509 }));
510
511 nestedWithRange.sort((a, b) => a.range.startOffset - b.range.startOffset);
512
513 let result = '';
514 let cursor = startOffset;
515
516 for (const entry of nestedWithRange) {
517 if (entry.range.startOffset > cursor) {
518 result += text.slice(cursor, entry.range.startOffset);
519 }
520 result += this.#evaluateMacroNode(entry.node, context);
521 cursor = entry.range.endOffset + 1;
522 }
523
524 if (cursor <= endOffset) {
525 result += text.slice(cursor, endOffset + 1);
526 }
527
528 return result.trim();
529 }
530
531 /**
352 * Evaluates a single argument node by resolving nested macros and reconstructing532 * Evaluates a single argument node by resolving nested macros and reconstructing
353 * the original argument text.533 * the original argument text.
354 *534 *
@@ -759,13 +939,24 @@ class MacroCstWalker {
759 */939 */
760 #extractMacroInfo(macroNode) {940 #extractMacroInfo(macroNode) {
761 const children = macroNode.children || {};941 const children = macroNode.children || {};
762 const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);942
943 // Check if this is a variable expression - they can't be scoped
944 const variableExprNode = (children.variableExpr || [])[0];
945 if (variableExprNode) {
946 return null; // Variable expressions don't support scoped content
947 }
948
949 // Regular macro - get info from macroBody
950 const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]);
951 const bodyChildren = macroBodyNode?.children || {};
952
953 const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []);
763 const name = identifierTokens[0]?.image || '';954 const name = identifierTokens[0]?.image || '';
764955
765 if (!name) return null;956 if (!name) return null;
766957
767 // Check for closing block flag958 // Check for closing block flag (inside macroBody)
768 const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);959 const flagTokens = /** @type {IToken[]} */ (children.flags || []);
769 const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);960 const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
770961
771 return { name, isClosing };962 return { name, isClosing };
@@ -786,9 +977,11 @@ class MacroCstWalker {
786 return true;977 return true;
787 }978 }
788979
789 // Count current arguments in the macro980 // Count current arguments in the macro (now inside macroBody)
790 const children = macroNode.children || {};981 const children = macroNode.children || {};
791 const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);982 const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]);
983 const bodyChildren = macroBodyNode?.children || {};
984 const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
792 const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);985 const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
793 const currentArgCount = argumentNodes.length;986 const currentArgCount = argumentNodes.length;
794987
public/scripts/macros/engine/MacroFlags.js+2 -37
@@ -15,8 +15,6 @@
15 * @property {boolean} filter - Whether the filter (`>`) flag is set.15 * @property {boolean} filter - Whether the filter (`>`) flag is set.
16 * @property {boolean} closingBlock - Whether the closing block (`/`) flag is set.16 * @property {boolean} closingBlock - Whether the closing block (`/`) flag is set.
17 * @property {boolean} preserveWhitespace - Whether the preserve whitespace (`#`) flag is set.17 * @property {boolean} preserveWhitespace - Whether the preserve whitespace (`#`) flag is set.
18 * @property {boolean} varDot - Whether the variable dot (`.`) flag is set.
19 * @property {boolean} varDollar - Whether the variable dollar (`$`) flag is set.
20 * @property {string[]} raw - The raw flag symbols in order of appearance.18 * @property {string[]} raw - The raw flag symbols in order of appearance.
21 */19 */
2220
@@ -74,19 +72,8 @@ export const MacroFlagType = Object.freeze({
74 */72 */
75 PRESERVE_WHITESPACE: '#',73 PRESERVE_WHITESPACE: '#',
7674
77 /**75 // Note: Variable shorthand (. and $) are NOT flags - they are special prefixes
78 * Variable shorthand flag (`.`).76 // that trigger the variable expression parsing branch. See MacroLexer.js Var tokens.
79 * Shorthand for variable access: `{{.myvar}}` equivalent to `{{getvar::myvar}}`.
80 * @status TBD - Not implemented in v1
81 */
82 VAR_DOT: '.',
83
84 /**
85 * Variable shorthand flag (`$`).
86 * Alternative shorthand for variable access: `{{$myvar}}`.
87 * @status TBD - Not implemented in v1
88 */
89 VAR_DOLLAR: '$',
90});77});
9178
92/**79/**
@@ -146,20 +133,6 @@ export const MacroFlagDefinitions = new Map([
146 implemented: true,133 implemented: true,
147 affectsParser: false,134 affectsParser: false,
148 }],135 }],
149 [MacroFlagType.VAR_DOT, {
150 type: MacroFlagType.VAR_DOT,
151 name: 'Variable (dot)',
152 description: 'Shorthand for variable access using dot notation.',
153 implemented: false,
154 affectsParser: false,
155 }],
156 [MacroFlagType.VAR_DOLLAR, {
157 type: MacroFlagType.VAR_DOLLAR,
158 name: 'Variable (dollar)',
159 description: 'Shorthand for variable access using dollar notation.',
160 implemented: false,
161 affectsParser: false,
162 }],
163]);136]);
164137
165/**138/**
@@ -182,8 +155,6 @@ export function createEmptyFlags() {
182 filter: false,155 filter: false,
183 closingBlock: false,156 closingBlock: false,
184 preserveWhitespace: false,157 preserveWhitespace: false,
185 varDot: false,
186 varDollar: false,
187 raw: [],158 raw: [],
188 };159 };
189}160}
@@ -217,12 +188,6 @@ export function parseFlags(flagSymbols) {
217 case MacroFlagType.PRESERVE_WHITESPACE:188 case MacroFlagType.PRESERVE_WHITESPACE:
218 flags.preserveWhitespace = true;189 flags.preserveWhitespace = true;
219 break;190 break;
220 case MacroFlagType.VAR_DOT:
221 flags.varDot = true;
222 break;
223 case MacroFlagType.VAR_DOLLAR:
224 flags.varDollar = true;
225 break;
226 default:191 default:
227 console.warn(`Can't parse unknown macro flag: ${symbol}`);192 console.warn(`Can't parse unknown macro flag: ${symbol}`);
228 }193 }
public/scripts/macros/engine/MacroLexer.js+109 -22
@@ -16,28 +16,42 @@ const IDENTIFIER_LEXER_PATTERN = /[a-zA-Z][\w-_]*/;
16 */16 */
17export const MACRO_IDENTIFIER_PATTERN = /^[a-zA-Z][\w-_]*$/;17export const MACRO_IDENTIFIER_PATTERN = /^[a-zA-Z][\w-_]*$/;
1818
19/**
20 * Pattern for valid variable shorthand identifiers.
21 * Must start with a letter, followed by word chars (letters, digits, underscore) or hyphens,
22 * but must end with a word character (not a hyphen).
23 *
24 * Used for variable shorthand syntax like .varName or $varName.
25 */
26export const MACRO_VARIABLE_SHORTHAND_PATTERN = /[a-zA-Z](?:[\w\-_]*[\w])?/;
27
19/** @enum {string} */28/** @enum {string} */
20const modes = {29const modes = Object.freeze({
21 plaintext: 'plaintext_mode',30 plaintext: 'plaintext_mode',
22 macro_def: 'macro_def_mode',31 macro_def: 'macro_def_mode',
23 macro_identifier_end: 'macro_identifier_end_mode',32 macro_identifier_end: 'macro_identifier_end_mode',
24 macro_args: 'macro_args_mode',33 macro_args: 'macro_args_mode',
25 macro_filter_modifer: 'macro_filter_modifer_mode',34 macro_filter_modifer: 'macro_filter_modifer_mode',
26 macro_filter_modifier_end: 'macro_filter_modifier_end_mode',35 macro_filter_modifier_end: 'macro_filter_modifier_end_mode',
27};36 // Variable shorthand modes
37 var_identifier: 'var_identifier_mode',
38 var_after_identifier: 'var_after_identifier_mode',
39 var_value: 'var_value_mode',
40});
2841
29/** @readonly */42/**
30const Tokens = {43 * All lexer tokens used by the macro parser.
31 // General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'.44 * @readonly
45 */
46const Tokens = Object.freeze({
47/** General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'. */
32 Plaintext: createToken({ name: 'Plaintext', pattern: /(?:[^{]|\{(?!\{))+/u, line_breaks: true }),48 Plaintext: createToken({ name: 'Plaintext', pattern: /(?:[^{]|\{(?!\{))+/u, line_breaks: true }),
33 // Single literal '{' that appears immediately before a macro opener '{{'.49 /** Single literal '{' that appears immediately before a macro opener '{{' */
34 PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }),50 PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }),
3551
36 // General macro capture52 /** General macro capture */
37 Macro: {53 Macro: {
38 Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),54 Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),
39 // Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
40 // We need this, because this token is going to switch lexer mode, while the general identifier does not.
41 /**55 /**
42 * Macro execution flags - special symbols that modify macro resolution behavior.56 * Macro execution flags - special symbols that modify macro resolution behavior.
43 * - `!` = immediate resolve (TBD)57 * - `!` = immediate resolve (TBD)
@@ -45,24 +59,26 @@ const Tokens = {
45 * - `~` = re-evaluate (TBD)59 * - `~` = re-evaluate (TBD)
46 * - `/` = closing block marker for scoped macros60 * - `/` = closing block marker for scoped macros
47 * - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility61 * - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility
48 * - `.` = variable shorthand (TBD)
49 * - `$` = variable shorthand alternative (TBD)
50 */62 */
51 Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/.$]/ }),63 Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/]/ }),
52 /**64 /**
53 * Filter flag (`>`) - separate token because it changes parsing behavior.65 * Filter flag (`>`) - separate token because it changes parsing behavior.
54 * When present, `|` characters inside the macro are treated as filter/pipe operators.66 * When present, `|` characters inside the macro are treated as filter/pipe operators.
55 */67 */
56 FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }),68 FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }),
57 DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),69 DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),
70 /**
71 * Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
72 * We need this, because this token is going to switch lexer mode, while the general identifier does not.
73 */
58 Identifier: createToken({ name: 'Macro.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),74 Identifier: createToken({ name: 'Macro.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
59 // At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces75 /** At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces */
60 EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),76 EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
61 BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),77 BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
62 End: createToken({ name: 'Macro.End', pattern: /\}\}/ }),78 End: createToken({ name: 'Macro.End', pattern: /\}\}/ }),
63 },79 },
6480
65 // Captures that only appear inside arguments81 /** Captures that only appear inside arguments */
66 Args: {82 Args: {
67 DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }),83 DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }),
68 Colon: createToken({ name: 'Args.Colon', pattern: /:/ }),84 Colon: createToken({ name: 'Args.Colon', pattern: /:/ }),
@@ -74,7 +90,7 @@ const Tokens = {
74 EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),90 EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
75 Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),91 Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
76 Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),92 Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
77 // At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces93 /** At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces */
78 EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),94 EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
79 },95 },
8096
@@ -82,22 +98,54 @@ const Tokens = {
82 Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),98 Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
83 WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),99 WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),
84100
85 // Capture unknown characters one by one, to still allow other tokens being matched once they are there.101 /** Variable shorthand tokens */
86 // This includes any possible braces that is not the double closing braces as MacroEnd.102 Var: {
103 /** Local variable prefix (`.`) - triggers variable shorthand for local variables */
104 LocalPrefix: createToken({ name: 'Var.LocalPrefix', pattern: /\./ }),
105 /** Global variable prefix (`$`) - triggers variable shorthand for global variables */
106 GlobalPrefix: createToken({ name: 'Var.GlobalPrefix', pattern: /\$/ }),
107 /**
108 * Variable identifier - allows hyphens inside but not at the end to avoid conflict with -- operator.
109 * Pattern: starts with letter, optionally followed by word chars/hyphens, but must end with word char.
110 * Examples: myVar, my-var, my_var, myVar123, my-long-var-name
111 * Invalid: my-, my--, -var
112 */
113 Identifier: createToken({ name: 'Var.Identifier', pattern: MACRO_VARIABLE_SHORTHAND_PATTERN }),
114 /** Increment operator (`++`) */
115 Increment: createToken({ name: 'Var.Increment', pattern: /\+\+/ }),
116 /** Decrement operator (`--`) */
117 Decrement: createToken({ name: 'Var.Decrement', pattern: /--/ }),
118 /** Add/append operator (`+=`) - must come before Equals to avoid conflict */
119 PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),
120 /** Set operator (`=`) */
121 Equals: createToken({ name: 'Var.Equals', pattern: /=/ }),
122 },
123
124 /**
125 * Capture unknown characters one by one, to still allow other tokens being matched once they are there.
126 * This includes any possible braces that is not the double closing braces as MacroEnd.
127 */
87 Unknown: createToken({ name: 'Unknown', pattern: /([^}]|\}(?!\}))/ }),128 Unknown: createToken({ name: 'Unknown', pattern: /([^}]|\}(?!\}))/ }),
88129
89 // TODO: Capture-all rest for now, that is not the macro end or opening of a new macro. Might be replaced later down the line.130 /** TODO: Capture-all rest for now, that is not the macro end or opening of a new macro. Might be replaced later down the line. */
90 Text: createToken({ name: 'Text', pattern: /.+(?=\}\}|\{\{)/, line_breaks: true }),131 Text: createToken({ name: 'Text', pattern: /.+(?=\}\}|\{\{)/, line_breaks: true }),
91132
92 // DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches.133 /**
93 // Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc.134 * DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches.
94 // Has to ALWAYS be the last token.135 * Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc.
136 * Has to ALWAYS be the last token.
137 */
95 ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }),138 ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }),
96};139});
97140
98/** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */141/** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */
99const enterModesMap = new Map();142const enterModesMap = new Map();
100143
144/**
145 * Lexer definition object that maps states/modes to their token rules.
146 * Each mode defines which tokens are valid in that context and how to transition between modes.
147 * @readonly
148 */
101const Def = {149const Def = {
102 modes: {150 modes: {
103 [modes.plaintext]: [151 [modes.plaintext]: [
@@ -111,6 +159,11 @@ const Def = {
111 // An explicit double-slash will be treated above flags to consume, as it'll introduce a comment macro. Directly following is the args then.159 // An explicit double-slash will be treated above flags to consume, as it'll introduce a comment macro. Directly following is the args then.
112 enter(Tokens.Macro.DoubleSlash, modes.macro_args),160 enter(Tokens.Macro.DoubleSlash, modes.macro_args),
113161
162 // Variable shorthand prefixes - must come before flags to take precedence
163 // These enter the variable identifier mode to parse variable expressions
164 enter(Tokens.Var.LocalPrefix, modes.var_identifier),
165 enter(Tokens.Var.GlobalPrefix, modes.var_identifier),
166
114 using(Tokens.Macro.Flags),167 using(Tokens.Macro.Flags),
115 // Filter flag is separate because it affects parsing behavior for pipes168 // Filter flag is separate because it affects parsing behavior for pipes
116 using(Tokens.Macro.FilterFlag),169 using(Tokens.Macro.FilterFlag),
@@ -165,6 +218,40 @@ const Def = {
165 exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),218 exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
166 exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer),219 exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer),
167 ],220 ],
221
222 // After seeing `.` or `$`, expect a variable identifier
223 [modes.var_identifier]: [
224 using(Tokens.WhiteSpace),
225 // Consume the variable identifier and move to operator detection
226 enter(Tokens.Var.Identifier, modes.var_after_identifier, { andExits: modes.var_identifier }),
227 // If no valid identifier found, exit back (will result in parser error)
228 exits(Tokens.ModePopper, modes.var_identifier),
229 ],
230 // After the variable identifier, look for operators or end
231 [modes.var_after_identifier]: [
232 using(Tokens.WhiteSpace),
233 // Check for operators
234 using(Tokens.Var.Increment),
235 using(Tokens.Var.Decrement),
236 enter(Tokens.Var.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }),
237 enter(Tokens.Var.Equals, modes.var_value, { andExits: modes.var_after_identifier }),
238 // If we see the end, exit
239 exits(Tokens.Macro.BeforeEnd, modes.var_after_identifier),
240 // Fallback exit
241 exits(Tokens.ModePopper, modes.var_after_identifier),
242 ],
243 // After `=` or `+=`, capture the value (can contain nested macros)
244 [modes.var_value]: [
245 // Nested macros in value
246 enter(Tokens.Macro.Start, modes.macro_def),
247
248 using(Tokens.Identifier),
249 using(Tokens.WhiteSpace),
250 using(Tokens.Unknown),
251
252 // Exit when we're about to see the end
253 exits(Tokens.ModePopper, modes.var_value),
254 ],
168 },255 },
169 defaultMode: modes.plaintext,256 defaultMode: modes.plaintext,
170};257};
public/scripts/macros/engine/MacroParser.js+61 -2
@@ -43,7 +43,7 @@ class MacroParser extends CstParser {
43 });43 });
44 });44 });
4545
46 // Basic Macro Structure46 // Basic Macro Structure - can be either a regular macro or a variable expression
47 $.macro = $.RULE('macro', () => {47 $.macro = $.RULE('macro', () => {
48 $.CONSUME(Tokens.Macro.Start);48 $.CONSUME(Tokens.Macro.Start);
4949
@@ -56,13 +56,72 @@ class MacroParser extends CstParser {
56 ]);56 ]);
57 });57 });
5858
59 // Branch: either a variable expression (starts with . or $) or a regular macro
60 $.OR([
61 // Variable expression branch
62 { ALT: () => $.SUBRULE($.variableExpr) },
63 // Regular macro branch
64 { ALT: () => $.SUBRULE($.macroBody) },
65 ]);
66
67 $.CONSUME(Tokens.Macro.End);
68 });
69
70 // Regular macro body (flags + identifier + optional arguments)
71 $.macroBody = $.RULE('macroBody', () => {
59 // Macro identifier (name)72 // Macro identifier (name)
60 $.OR2([73 $.OR2([
61 { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },74 { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
62 { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },75 { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
63 ]);76 ]);
64 $.OPTION(() => $.SUBRULE($.arguments));77 $.OPTION(() => $.SUBRULE($.arguments));
65 $.CONSUME(Tokens.Macro.End);78 });
79
80 // Variable expression: .varName or $varName with optional operator
81 $.variableExpr = $.RULE('variableExpr', () => {
82 // Variable scope prefix
83 $.OR3([
84 { ALT: () => $.CONSUME(Tokens.Var.LocalPrefix, { LABEL: 'Var.scope' }) },
85 { ALT: () => $.CONSUME(Tokens.Var.GlobalPrefix, { LABEL: 'Var.scope' }) },
86 ]);
87
88 // Variable identifier (name)
89 $.CONSUME(Tokens.Var.Identifier, { LABEL: 'Var.identifier' });
90
91 // Optional operator
92 $.OPTION2(() => $.SUBRULE($.variableOperator));
93 });
94
95 // Variable operator: ++, --, = value, += value
96 $.variableOperator = $.RULE('variableOperator', () => {
97 $.OR4([
98 { ALT: () => $.CONSUME(Tokens.Var.Increment, { LABEL: 'Var.operator' }) },
99 { ALT: () => $.CONSUME(Tokens.Var.Decrement, { LABEL: 'Var.operator' }) },
100 {
101 ALT: () => {
102 $.CONSUME(Tokens.Var.Equals, { LABEL: 'Var.operator' });
103 $.SUBRULE($.variableValue, { LABEL: 'Var.value' });
104 },
105 },
106 {
107 ALT: () => {
108 $.CONSUME(Tokens.Var.PlusEquals, { LABEL: 'Var.operator' });
109 $.SUBRULE2($.variableValue, { LABEL: 'Var.value' });
110 },
111 },
112 ]);
113 });
114
115 // Variable value: everything after = or += until the end
116 // Can contain nested macros and any other tokens
117 $.variableValue = $.RULE('variableValue', () => {
118 $.MANY2(() => {
119 $.OR5([
120 { ALT: () => $.SUBRULE($.macro) }, // Nested macros
121 { ALT: () => $.CONSUME(Tokens.Identifier) },
122 { ALT: () => $.CONSUME(Tokens.Unknown) },
123 ]);
124 });
66 });125 });
67126
68 // Arguments Parsing127 // Arguments Parsing
public/scripts/slash-commands/SlashCommandParser.js+408 -12
@@ -15,7 +15,19 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
15import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';15import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
18import { EnhancedMacroAutoCompleteOption, MacroFlagAutoCompleteOption, MacroClosingTagAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';18import {
19 EnhancedMacroAutoCompleteOption,
20 MacroFlagAutoCompleteOption,
21 MacroClosingTagAutoCompleteOption,
22 VariableShorthandAutoCompleteOption,
23 VariableShorthandDefinitions,
24 VariableNameAutoCompleteOption,
25 VariableOperatorAutoCompleteOption,
26 VariableOperatorDefinitions,
27 isValidVariableShorthandName,
28 parseMacroContext,
29 SimpleAutoCompleteOption,
30} from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
19import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';31import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
20import { MacroParser } from '../macros/engine/MacroParser.js';32import { MacroParser } from '../macros/engine/MacroParser.js';
21import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';33import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
@@ -25,6 +37,8 @@ import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
25import { SlashCommandBreak } from './SlashCommandBreak.js';37import { SlashCommandBreak } from './SlashCommandBreak.js';
26import { macros as macroSystem } from '../macros/macro-system.js';38import { macros as macroSystem } from '../macros/macro-system.js';
27import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';39import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
40import { chat_metadata } from '/script.js';
41import { extension_settings } from '../extensions.js';
2842
29/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */43/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
30/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */44/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
@@ -566,29 +580,138 @@ export class SlashCommandParser {
566 } else {580 } else {
567 conditionStartOffset = context.identifierStart + identifier.length;581 conditionStartOffset = context.identifierStart + identifier.length;
568 }582 }
569 const conditionStartInText = macro.start + 2 + conditionStartOffset;583 let conditionStartInText = macro.start + 2 + conditionStartOffset;
570584
571 // Build if-condition options using macroContent for padding calculation585 // Build if-condition options using macroContent for padding calculation
572 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });586 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
573 const options = this.#buildIfConditionOptions(context, allMacros, macroContent);587 const options = this.#buildIfConditionOptions(context, allMacros, macroContent);
574588
589 // For variable shorthand in {{if}} condition, adjust identifier and start position
590 // Same fix as for regular variable shorthands - identifier must be just the var name
591 // Also handle ! inversion prefix: !.var or !$var or !macroName
592 const trimmedCondition = conditionText.trim();
593 const hasInversion = trimmedCondition.startsWith('!');
594 // Trim whitespace after ! to handle "! $myvar" syntax
595 const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition;
596 const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
597 let resultIdentifier = conditionText;
598 let resultStart = conditionStartInText;
599
600 if (isTypingVarShorthand) {
601 // Identifier = just the variable name part (without prefix and without !)
602 resultIdentifier = conditionAfterInversion.slice(1);
603 // Start = after the ! (if any) and the prefix
604 const prefixChar = conditionAfterInversion[0];
605 const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0);
606 resultStart = conditionStartInText + prefixPosInCondition + 1;
607 } else if (hasInversion && conditionAfterInversion.length === 0) {
608 // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match
609 resultIdentifier = '';
610 // Start at end of actual condition text (including any whitespace after !)
611 // This ensures cursor is within the name range for filtering
612 resultStart = conditionStartInText + conditionText.length;
613 } else if (hasInversion && conditionAfterInversion.length > 0) {
614 // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name
615 resultIdentifier = conditionAfterInversion;
616 // Start = after the ! and any whitespace, at the beginning of the macro name
617 const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion);
618 resultStart = conditionStartInText + macroNameStart;
619 }
620
575 const result = new AutoCompleteNameResult(621 const result = new AutoCompleteNameResult(
576 conditionText,622 resultIdentifier,
577 conditionStartInText,623 resultStart,
578 options,624 options,
579 false,625 false,
580 () => 'Use {{macro}} syntax for dynamic conditions',626 () => isTypingVarShorthand
581 () => 'Enter a macro name or {{macro}} for the condition',627 ? 'Enter a variable name for the condition'
628 : 'Use {{macro}} syntax for dynamic conditions',
629 () => isTypingVarShorthand
630 ? 'Enter a variable name or select from the list'
631 : 'Enter a macro name or {{macro}} for the condition',
582 );632 );
583 return result;633 return result;
584 }634 }
585635
636 /** @type {()=>string|undefined} */
637 let makeNoMatchText = undefined;
638 /** @type {()=>string|undefined} */
639 let makeNoOptionsText = undefined;
640
586 const options = this.#buildEnhancedMacroOptions(context, textUpToCursor);641 const options = this.#buildEnhancedMacroOptions(context, textUpToCursor);
642
643 // For variable shorthands, calculate the correct identifier and start position
644 // based on what the user is currently typing (variable name, operator, or value)
645 let resultIdentifier = identifier;
646 let resultStart = identifierStartInText;
647 if (context.isVariableShorthand && context.variablePrefix) {
648 // Find where the prefix is in the macro content
649 const prefixIndex = macroContent.indexOf(context.variablePrefix);
650
651 if (context.isTypingVariableName) {
652 // Typing variable name: identifier = variableName, start = after prefix
653 resultIdentifier = context.variableName;
654 if (prefixIndex >= 0) {
655 resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
656 }
657 } else if (context.isTypingOperator) {
658 // Typing operator: identifier = partial operator text (if any), start = after variable name
659 // Using partial operator as identifier ensures cursor is within name range for filtering
660 resultIdentifier = context.partialOperator || '';
661 if (prefixIndex >= 0) {
662 // Start after prefix + variable name length
663 resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length;
664 // If no partial operator (just whitespace after var name), set start to cursor
665 // This ensures cursor is in the name range for filtering
666 if (!context.partialOperator) {
667 resultStart = index;
668 }
669 }
670 } else if (context.isOperatorComplete) {
671 // Operator complete (++ or --) - show context but no value input needed
672 resultIdentifier = '';
673 resultStart = index; // Cursor at end
674 } else if (context.hasInvalidTrailingChars) {
675 // Invalid chars after variable name: show the invalid chars for warning
676 resultIdentifier = context.invalidTrailingChars || '';
677 if (prefixIndex >= 0) {
678 resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length;
679 }
680 } else if (context.isTypingValue) {
681 // Typing value: identifier = value being typed, start = after operator
682 resultIdentifier = context.variableValue;
683 if (prefixIndex >= 0) {
684 const operatorLen = context.variableOperator?.length ?? 0;
685 resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length + operatorLen;
686 // Skip any whitespace between operator and value
687 while (resultStart < index && /\s/.test(text[resultStart])) {
688 resultStart++;
689 }
690
691 makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
692 makeNoOptionsText = () => 'Enter a variable value';
693 }
694 } else {
695 // Fallback: use variable name
696 resultIdentifier = context.variableName;
697 if (prefixIndex >= 0) {
698 resultStart = macro.start + 2 + prefixIndex + 1;
699 }
700 }
701
702 if (!makeNoMatchText && !makeNoOptionsText) {
703 makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.';
704 makeNoOptionsText = () => 'Enter a variable name to create or use a new variable';
705 }
706 }
707
587 const result = new AutoCompleteNameResult(708 const result = new AutoCompleteNameResult(
588 identifier,709 resultIdentifier,
589 identifierStartInText,710 resultStart,
590 options,711 options,
591 false,712 false,
713 makeNoMatchText,
714 makeNoOptionsText,
592 );715 );
593 return result;716 return result;
594 }717 }
@@ -669,12 +792,17 @@ export class SlashCommandParser {
669 * When typing arguments (after ::), prioritizes the exact macro match.792 * When typing arguments (after ::), prioritizes the exact macro match.
670 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context793 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
671 * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.794 * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
672 * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]}795 * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
673 */796 */
674 #buildEnhancedMacroOptions(context, textUpToCursor = '') {797 #buildEnhancedMacroOptions(context, textUpToCursor = '') {
675 /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} */798 /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
676 const options = [];799 const options = [];
677800
801 // Handle variable shorthand mode
802 if (context.isVariableShorthand) {
803 return this.#buildVariableShorthandOptions(context);
804 }
805
678 // Check for unclosed scoped macros and suggest closing tags first806 // Check for unclosed scoped macros and suggest closing tags first
679 const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);807 const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
680 if (unclosedScopes.length > 0) {808 if (unclosedScopes.length > 0) {
@@ -685,11 +813,9 @@ export class SlashCommandParser {
685813
686 // If inside a scoped {{if}}, also suggest {{else}}814 // If inside a scoped {{if}}, also suggest {{else}}
687 if (innermostScope.name === 'if') {815 if (innermostScope.name === 'if') {
688 // TODO: TEsting
689 const macroDef = macroSystem.registry.getPrimaryMacro('else');816 const macroDef = macroSystem.registry.getPrimaryMacro('else');
690 const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);817 const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
691 elseOption.sortPriority = 2;818 elseOption.sortPriority = 2;
692 // const elseOption = new MacroElseAutoCompleteOption();
693 options.push(elseOption);819 options.push(elseOption);
694 }820 }
695 }821 }
@@ -732,6 +858,14 @@ export class SlashCommandParser {
732 flagOption.sortPriority = isSelectable ? 10 : 12;858 flagOption.sortPriority = isSelectable ? 10 : 12;
733 options.push(flagOption);859 options.push(flagOption);
734 }860 }
861
862 // Add variable shorthand prefix options (. for local, $ for global)
863 // These allow users to type variable shorthands instead of macro names
864 for (const [, varShorthandDef] of VariableShorthandDefinitions) {
865 const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef);
866 varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12)
867 options.push(varOption);
868 }
735 }869 }
736870
737 // Get all macros from the registry (excluding hidden aliases)871 // Get all macros from the registry (excluding hidden aliases)
@@ -781,6 +915,187 @@ export class SlashCommandParser {
781 }915 }
782916
783 /**917 /**
918 * Builds autocomplete options for variable shorthand syntax (.varName or $varName).
919 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
920 * @param {Object} [opts] - Optional configuration.
921 * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).
922 * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.
923 * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
924 */
925 #buildVariableShorthandOptions(context, opts = {}) {
926 const { forIfCondition = false, paddingAfter = '' } = opts;
927 /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
928 const options = [];
929
930 const isLocal = context.variablePrefix === '.';
931 const scope = isLocal ? 'local' : 'global';
932
933 // Always show the typed variable prefix as a non-completable option (like flags do)
934 // This allows the details panel to show information about the prefix
935 const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix);
936 if (prefixDef) {
937 const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
938 prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
939 prefixOption.makeSelectable = false;
940 prefixOption.sortPriority = 1; // Show at top
941 options.push(prefixOption);
942 }
943
944 // If typing the variable name, suggest existing variables
945 if (context.isTypingVariableName) {
946 // Get existing variable names from the appropriate scope
947 // Filter to only include names that are valid for shorthand syntax
948 const existingVariables = this.#getVariableNames(scope)
949 .filter(name => isValidVariableShorthandName(name));
950
951 // Add existing variables that match the typed name
952 for (const varName of existingVariables) {
953 const option = new VariableNameAutoCompleteOption(varName, scope, false);
954 // For {{if}} condition, provide full value with closing braces
955 if (forIfCondition) {
956 option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already.
957 option.makeSelectable = true;
958 }
959 // Variables matching the typed prefix get higher priority
960 if (varName.startsWith(context.variableName)) {
961 option.sortPriority = 3;
962 } else {
963 option.sortPriority = 10;
964 }
965 options.push(option);
966 }
967
968 // If typing a name that doesn't exist, offer to create a new variable
969 // But if the name is invalid for shorthand syntax, show a warning instead
970 if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) {
971 const isInvalid = !isValidVariableShorthandName(context.variableName);
972 const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid);
973 newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning
974 if (isInvalid) {
975 // Make it non-selectable since it can't be used
976 newVarOption.valueProvider = () => '';
977 newVarOption.makeSelectable = false;
978 } else if (forIfCondition) {
979 // For {{if}} condition, provide full value with closing braces
980 newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`;
981 }
982 options.push(newVarOption);
983 }
984 }
985
986 // If there are invalid trailing characters after the variable name, show a warning
987 if (context.hasInvalidTrailingChars) {
988 // Show the full invalid name (variableName + invalidTrailingChars) with a warning
989 const fullInvalidName = context.variableName + (context.invalidTrailingChars || '');
990 const invalidOption = new VariableNameAutoCompleteOption(
991 fullInvalidName,
992 scope,
993 false,
994 true, // isInvalidName - triggers warning display
995 );
996 invalidOption.valueProvider = () => ''; // Don't insert anything
997 invalidOption.makeSelectable = false;
998 invalidOption.sortPriority = 2;
999 invalidOption.matchProvider = () => true; // Always show
1000 options.push(invalidOption);
1001 // Return early - don't show operators when syntax is invalid
1002 return options;
1003 }
1004
1005 // If ready for operator (after variable name), suggest operators
1006 if (context.isTypingOperator) {
1007 // Show the current variable name as context (already typed)
1008 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1009 varNameOption.valueProvider = () => ''; // Already typed, don't re-insert
1010 varNameOption.sortPriority = 2;
1011 varNameOption.matchProvider = () => true; // Always show
1012 options.push(varNameOption);
1013
1014 // Then show available operators, filtered by partial prefix if any
1015 const partialOp = context.partialOperator || '';
1016 for (const [, operatorDef] of VariableOperatorDefinitions) {
1017 // Filter by partial operator prefix if user is typing one
1018 if (partialOp && !operatorDef.symbol.startsWith(partialOp)) {
1019 continue;
1020 }
1021 const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
1022 opOption.sortPriority = 5;
1023 // Always match operators when showing operator suggestions
1024 opOption.matchProvider = () => true;
1025 options.push(opOption);
1026 }
1027 }
1028
1029 // If typing value (after = or +=), no autocomplete needed - freeform text
1030 // But we can show the current context for reference
1031 if (context.isTypingValue) {
1032 // Show the current variable name as context
1033 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1034 varNameOption.valueProvider = () => ''; // Context only
1035 varNameOption.sortPriority = 2;
1036 varNameOption.matchProvider = () => true; // Always show
1037 options.push(varNameOption);
1038
1039 // Show the operator that was used
1040 if (context.variableOperator) {
1041 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
1042 if (opDef) {
1043 const opOption = new VariableOperatorAutoCompleteOption(opDef);
1044 opOption.valueProvider = () => ''; // Already typed
1045 opOption.sortPriority = 3;
1046 opOption.matchProvider = () => true; // Always show
1047 options.push(opOption);
1048 }
1049 }
1050 }
1051
1052 // If operator is complete (++ or --), show context without value input
1053 if (context.isOperatorComplete) {
1054 // Show the current variable name as context
1055 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
1056 varNameOption.valueProvider = () => ''; // Context only
1057 varNameOption.sortPriority = 2;
1058 varNameOption.matchProvider = () => true; // Always show
1059 options.push(varNameOption);
1060
1061 // Show the operator that was used
1062 if (context.variableOperator) {
1063 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
1064 if (opDef) {
1065 const opOption = new VariableOperatorAutoCompleteOption(opDef);
1066 opOption.valueProvider = () => ''; // Already typed
1067 opOption.sortPriority = 3;
1068 opOption.matchProvider = () => true; // Always show
1069 options.push(opOption);
1070 }
1071 }
1072 }
1073
1074 return options;
1075 }
1076
1077 /**
1078 * Gets variable names from the specified scope.
1079 * @param {'local'|'global'} scope - The variable scope.
1080 * @returns {string[]} Array of variable names.
1081 */
1082 #getVariableNames(scope) {
1083 try {
1084 // Import chat_metadata and extension_settings dynamically to avoid circular deps
1085 // These are the same sources used by commonEnumProviders.variables
1086 if (scope === 'local') {
1087 // Local variables are in chat_metadata.variables
1088 return Object.keys(chat_metadata?.variables ?? {});
1089 } else {
1090 // Global variables are in extension_settings.variables.global
1091 return Object.keys(extension_settings?.variables?.global ?? {});
1092 }
1093 } catch {
1094 return [];
1095 }
1096 }
1097
1098 /**
784 * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.1099 * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
785 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context1100 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
786 * @param {import('../macros/engine/MacroRegistry.js').MacroDefinition[]} allMacros1101 * @param {import('../macros/engine/MacroRegistry.js').MacroDefinition[]} allMacros
@@ -796,6 +1111,87 @@ export class SlashCommandParser {
796 const leadingMatch = macroInnerText.match(/^(\s*)/);1111 const leadingMatch = macroInnerText.match(/^(\s*)/);
797 const paddingAfter = leadingMatch ? leadingMatch[1] : '';1112 const paddingAfter = leadingMatch ? leadingMatch[1] : '';
7981113
1114 // Get the condition text being typed (trimmed for detection)
1115 const conditionText = (context.args[0] || '').trim();
1116
1117 // Check for inversion prefix (!) - also trim whitespace after !
1118 const hasInversionPrefix = conditionText.startsWith('!');
1119 const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText;
1120
1121 const inversionOption = new SimpleAutoCompleteOption({
1122 name: '!',
1123 symbol: '🔁',
1124 description: 'Invert condition (NOT)',
1125 detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> is empty or zero.',
1126 type: 'inverse',
1127 });
1128
1129 // Check if condition starts with a variable shorthand prefix (with or without !)
1130 const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
1131
1132 if (isTypingVariableShorthand) {
1133 // User is typing a variable shorthand - reuse #buildVariableShorthandOptions
1134 const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]);
1135 const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix
1136
1137 // If inverted, show the ! as non-selectable context
1138 if (hasInversionPrefix) {
1139 inversionOption.valueProvider = () => ''; // Already typed
1140 inversionOption.makeSelectable = false;
1141 inversionOption.sortPriority = 0;
1142 options.push(inversionOption);
1143 }
1144
1145 // Create a synthetic context for #buildVariableShorthandOptions
1146 /** @type {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} */
1147 const varContext = {
1148 ...context,
1149 isVariableShorthand: true,
1150 variablePrefix: prefix,
1151 variableName: varNameTyped,
1152 isTypingVariableName: true,
1153 isTypingOperator: false,
1154 isTypingValue: false,
1155 isOperatorComplete: false,
1156 hasInvalidTrailingChars: false,
1157 variableOperator: null,
1158 variableValue: '',
1159 };
1160
1161 const varOptions = this.#buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter });
1162 options.push(...varOptions);
1163 return options;
1164 }
1165
1166 // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion
1167
1168 // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed
1169 if (conditionText.length === 0) {
1170 // Nothing typed - offer ! as selectable option
1171 inversionOption.valueProvider = () => '!';
1172 inversionOption.makeSelectable = true;
1173 inversionOption.sortPriority = -1; // Show at very top
1174 options.push(inversionOption);
1175 } else if (hasInversionPrefix && conditionAfterInversion.length === 0) {
1176 // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes
1177 inversionOption.valueProvider = () => ''; // Already typed
1178 inversionOption.makeSelectable = false;
1179 inversionOption.sortPriority = -1;
1180 options.push(inversionOption);
1181 }
1182
1183 // Add variable shorthand prefix options when no content typed yet (or just ! typed)
1184 if (conditionAfterInversion.length === 0) {
1185 for (const [, prefixDef] of VariableShorthandDefinitions) {
1186 const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
1187 // Complete with just the prefix symbol
1188 prefixOption.valueProvider = () => prefixDef.type;
1189 prefixOption.makeSelectable = true;
1190 prefixOption.sortPriority = 0; // Show at top
1191 options.push(prefixOption);
1192 }
1193 }
1194
799 // Add zero-arg macros as condition shorthand options1195 // Add zero-arg macros as condition shorthand options
800 for (const macro of allMacros) {1196 for (const macro of allMacros) {
801 // Only include macros that require zero arguments (can be auto-resolved)1197 // Only include macros that require zero arguments (can be auto-resolved)
tests/.eslintrc.cjs+1 -0
@@ -25,6 +25,7 @@ module.exports = {
25 'node_modules/**/*',25 'node_modules/**/*',
26 ],26 ],
27 globals: {27 globals: {
28 SillyTavern: 'readonly',
28 },29 },
29 rules: {30 rules: {
30 'no-unused-vars': ['error', { args: 'none' }],31 'no-unused-vars': ['error', { args: 'none' }],
tests/frontend/MacroEngine.e2e.js+207 -0
@@ -1765,6 +1765,165 @@ test.describe('MacroEngine', () => {
1765 expect(output).toBe('Message (by EnvChar)');1765 expect(output).toBe('Message (by EnvChar)');
1766 });1766 });
1767 });1767 });
1768
1769 test.describe('Variable Shorthand Syntax', () => {
1770 // {{.myvar}} - get local variable
1771 test('should get local variable with . shorthand', async ({ page }) => {
1772 const output = await evaluateWithEngineAndVariables(page, '{{.myvar}}', { local: { myvar: 'hello' } });
1773 expect(output).toBe('hello');
1774 });
1775
1776 // {{$myvar}} - get global variable
1777 test('should get global variable with $ shorthand', async ({ page }) => {
1778 const output = await evaluateWithEngineAndVariables(page, '{{$myvar}}', { global: { myvar: 'world' } });
1779 expect(output).toBe('world');
1780 });
1781
1782 // {{.myvar = value}} - set local variable (setvar returns empty string)
1783 test('should set local variable with = shorthand', async ({ page }) => {
1784 const output = await evaluateWithEngineAndVariables(page, '{{.myvar = test}}Value: {{.myvar}}', { local: {} });
1785 // setvar returns '', then "Value: ", then getvar returns "test"
1786 expect(output).toBe('Value: test');
1787 });
1788
1789 // {{.counter++}} - increment local variable (incvar returns new value)
1790 test('should increment local variable with ++ shorthand', async ({ page }) => {
1791 const output = await evaluateWithEngineAndVariables(page, '{{.counter++}}', { local: { counter: '5' } });
1792 expect(output).toBe('6');
1793 });
1794
1795 // {{$counter--}} - decrement global variable (decvar returns new value)
1796 test('should decrement global variable with -- shorthand', async ({ page }) => {
1797 const output = await evaluateWithEngineAndVariables(page, '{{$counter--}}', { global: { counter: '10' } });
1798 expect(output).toBe('9');
1799 });
1800
1801 // {{.myvar += 5}} - add to local variable (addvar returns empty string)
1802 test('should add to local variable with += shorthand', async ({ page }) => {
1803 const output = await evaluateWithEngineAndVariables(page, '{{.myvar += 3}}Then: {{.myvar}}', { local: { myvar: '7' } });
1804 // addvar returns '', then "Then: ", then getvar returns "10"
1805 expect(output).toBe('Then: 10');
1806 });
1807
1808 // Nested macro in value: {{.myvar = {{user}}}}
1809 test('should support nested macro in variable value', async ({ page }) => {
1810 const output = await evaluateWithEngineAndVariables(page, '{{.greeting = Hello {{user}}}}{{.greeting}}', { local: {} });
1811 // setvar returns '', then getvar returns "Hello User"
1812 expect(output).toBe('Hello User');
1813 });
1814
1815 // Whitespace handling: {{ .myvar = value }}
1816 test('should handle whitespace in variable shorthand', async ({ page }) => {
1817 const output = await evaluateWithEngineAndVariables(page, '{{ .myvar = spaced }}{{.myvar}}', { local: {} });
1818 // setvar returns '', then getvar returns "spaced"
1819 expect(output).toBe('spaced');
1820 });
1821
1822 // Variable with hyphen in name: {{.my-var}}
1823 test('should handle variable name with hyphens', async ({ page }) => {
1824 const output = await evaluateWithEngineAndVariables(page, '{{.my-var}}', { local: { 'my-var': 'hyphenated' } });
1825 expect(output).toBe('hyphenated');
1826 });
1827
1828 // Variable with underscore: {{.my_var}}
1829 test('should handle variable name with underscores', async ({ page }) => {
1830 const output = await evaluateWithEngineAndVariables(page, '{{.my_var}}', { local: { 'my_var': 'underscored' } });
1831 expect(output).toBe('underscored');
1832 });
1833
1834 // Non-existent variable returns empty string
1835 test('should return empty string for non-existent variable', async ({ page }) => {
1836 const output = await evaluateWithEngineAndVariables(page, 'Value:[{{.nonexistent}}]', { local: {} });
1837 expect(output).toBe('Value:[]');
1838 });
1839
1840 // Increment non-existent variable (should start from 0)
1841 test('should increment non-existent variable starting from 0', async ({ page }) => {
1842 const output = await evaluateWithEngineAndVariables(page, '{{.newcounter++}}', { local: {} });
1843 expect(output).toBe('1');
1844 });
1845
1846 // Chain multiple operations
1847 test('should handle multiple variable operations in sequence', async ({ page }) => {
1848 const output = await evaluateWithEngineAndVariables(page, '{{.x = 5}}{{.x++}}{{.x += 10}}{{.x}}', { local: {} });
1849 // setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'
1850 expect(output).toBe('616');
1851 });
1852 });
1853
1854 test.describe('Variable Shorthand in {{if}} Macro', () => {
1855 // {{if .myvar}}...{{/if}} - truthy local variable
1856 test('should evaluate truthy local variable in if condition', async ({ page }) => {
1857 const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '1' } });
1858 expect(output).toBe('Yes');
1859 });
1860
1861 // {{if .myvar}}...{{/if}} - falsy local variable
1862 test('should evaluate falsy local variable in if condition', async ({ page }) => {
1863 const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '' } });
1864 expect(output).toBe('');
1865 });
1866
1867 // {{if $globalvar}}...{{/if}} - truthy global variable
1868 test('should evaluate truthy global variable in if condition', async ({ page }) => {
1869 const output = await evaluateWithEngineAndVariables(page, '{{if $enabled}}Active{{/if}}', { global: { enabled: 'true' } });
1870 expect(output).toBe('Active');
1871 });
1872
1873 // {{if !.myvar}}...{{/if}} - inverted condition
1874 test('should evaluate inverted variable condition', async ({ page }) => {
1875 const output = await evaluateWithEngineAndVariables(page, '{{if !.flag}}Not set{{/if}}', { local: { flag: '' } });
1876 expect(output).toBe('Not set');
1877 });
1878
1879 // {{if !$globalvar}}...{{/if}} - inverted global
1880 test('should evaluate inverted global variable condition', async ({ page }) => {
1881 const output = await evaluateWithEngineAndVariables(page, '{{if !$disabled}}Enabled{{/if}}', { global: { disabled: '' } });
1882 expect(output).toBe('Enabled');
1883 });
1884
1885 // {{if ! .myvar}}...{{/if}} - inverted with whitespace
1886 test('should evaluate inverted condition with whitespace after !', async ({ page }) => {
1887 const output = await evaluateWithEngineAndVariables(page, '{{if ! .empty}}Empty{{/if}}', { local: { empty: '' } });
1888 expect(output).toBe('Empty');
1889 });
1890
1891 // Non-existent variable is falsy
1892 test('should treat non-existent variable as falsy in if condition', async ({ page }) => {
1893 const output = await evaluateWithEngineAndVariables(page, '{{if .nonexistent}}Yes{{else}}No{{/if}}', { local: {} });
1894 expect(output).toBe('No');
1895 });
1896
1897 // {{if .myvar}}...{{else}}...{{/if}} - with else branch
1898 test('should handle else branch with variable shorthand', async ({ page }) => {
1899 const output = await evaluateWithEngineAndVariables(page, '{{if .active}}On{{else}}Off{{/if}}', { local: { active: 'yes' } });
1900 expect(output).toBe('On');
1901 });
1902
1903 // Variable with hyphen in if condition
1904 test('should handle variable with hyphen in if condition', async ({ page }) => {
1905 const output = await evaluateWithEngineAndVariables(page, '{{if .is-valid}}Valid{{/if}}', { local: { 'is-valid': '1' } });
1906 expect(output).toBe('Valid');
1907 });
1908
1909 // Combine set and if
1910 test('should work with variable set before if check', async ({ page }) => {
1911 const output = await evaluateWithEngineAndVariables(page, '{{.ready = yes}}{{if .ready}}Ready!{{/if}}', { local: {} });
1912 expect(output).toBe('Ready!');
1913 });
1914
1915 // Zero is falsy
1916 test('should treat zero as falsy in if condition', async ({ page }) => {
1917 const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Has count{{else}}No count{{/if}}', { local: { count: '0' } });
1918 expect(output).toBe('No count');
1919 });
1920
1921 // Non-zero number is truthy
1922 test('should treat non-zero number as truthy in if condition', async ({ page }) => {
1923 const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Count: {{.count}}{{/if}}', { local: { count: '42' } });
1924 expect(output).toBe('Count: 42');
1925 });
1926 });
1768});1927});
17691928
1770/**1929/**
@@ -1833,3 +1992,51 @@ async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
1833 page.off('console', handler);1992 page.off('console', handler);
1834 }1993 }
1835}1994}
1995
1996/**
1997 * Evaluates the given input string with pre-set variables.
1998 * Variables are set via SillyTavern.getContext().variables which is where
1999 * the variable macros read/write their data.
2000 *
2001 * @param {import('@playwright/test').Page} page
2002 * @param {string} input
2003 * @param {{ local?: Record<string, string>, global?: Record<string, string> }} variables
2004 * @returns {Promise<string>}
2005 */
2006async function evaluateWithEngineAndVariables(page, input, variables) {
2007 const result = await page.evaluate(async ({ input, variables }) => {
2008 /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
2009 const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
2010 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
2011 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
2012
2013 // Get the SillyTavern context for variable access
2014 const ctx = SillyTavern.getContext();
2015
2016 // Pre-set local variables
2017 if (variables.local) {
2018 for (const [key, value] of Object.entries(variables.local)) {
2019 ctx.variables.local.set(key, value);
2020 }
2021 }
2022 // Pre-set global variables
2023 if (variables.global) {
2024 for (const [key, value] of Object.entries(variables.global)) {
2025 ctx.variables.global.set(key, value);
2026 }
2027 }
2028
2029 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
2030 const rawEnv = {
2031 content: input,
2032 name1Override: 'User',
2033 name2Override: 'Character',
2034 };
2035 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
2036
2037 const output = await MacroEngine.evaluate(input, env);
2038 return output;
2039 }, { input, variables });
2040
2041 return result;
2042}
tests/frontend/MacroLexer.e2e.js+228 -30
@@ -567,34 +567,6 @@ test.describe('MacroLexer', () => {
567567
568 expect(tokens).toEqual(expectedTokens);568 expect(tokens).toEqual(expectedTokens);
569 });569 });
570 // {{.variable}}
571 test('should support . flag', async ({ page }) => {
572 const input = '{{.variable}}';
573 const tokens = await runLexerGetTokens(page, input);
574
575 const expectedTokens = [
576 { type: 'Macro.Start', text: '{{' },
577 { type: 'Macro.Flag', text: '.' },
578 { type: 'Macro.Identifier', text: 'variable' },
579 { type: 'Macro.End', text: '}}' },
580 ];
581
582 expect(tokens).toEqual(expectedTokens);
583 });
584 // {{$variable}}
585 test('should support alias $ flag', async ({ page }) => {
586 const input = '{{$variable}}';
587 const tokens = await runLexerGetTokens(page, input);
588
589 const expectedTokens = [
590 { type: 'Macro.Start', text: '{{' },
591 { type: 'Macro.Flag', text: '$' },
592 { type: 'Macro.Identifier', text: 'variable' },
593 { type: 'Macro.End', text: '}}' },
594 ];
595
596 expect(tokens).toEqual(expectedTokens);
597 });
598 // {{#legacy}}570 // {{#legacy}}
599 test('should support legacy # flag', async ({ page }) => {571 test('should support legacy # flag', async ({ page }) => {
600 const input = '{{#legacy}}';572 const input = '{{#legacy}}';
@@ -640,13 +612,13 @@ test.describe('MacroLexer', () => {
640 });612 });
641 // {{ ! .importantvariable }}613 // {{ ! .importantvariable }}
642 test('should support multiple flags with whitespace', async ({ page }) => {614 test('should support multiple flags with whitespace', async ({ page }) => {
643 const input = '{{ !.importantvariable }}';615 const input = '{{ !#importantvariable }}';
644 const tokens = await runLexerGetTokens(page, input);616 const tokens = await runLexerGetTokens(page, input);
645617
646 const expectedTokens = [618 const expectedTokens = [
647 { type: 'Macro.Start', text: '{{' },619 { type: 'Macro.Start', text: '{{' },
648 { type: 'Macro.Flag', text: '!' },620 { type: 'Macro.Flag', text: '!' },
649 { type: 'Macro.Flag', text: '.' },621 { type: 'Macro.Flag', text: '#' },
650 { type: 'Macro.Identifier', text: 'importantvariable' },622 { type: 'Macro.Identifier', text: 'importantvariable' },
651 { type: 'Macro.End', text: '}}' },623 { type: 'Macro.End', text: '}}' },
652 ];624 ];
@@ -733,6 +705,232 @@ test.describe('MacroLexer', () => {
733 });705 });
734 });706 });
735707
708 test.describe('Variable Shorthand Syntax', () => {
709 // {{.variable}} - Local variable get
710 test('should tokenize local variable shorthand', async ({ page }) => {
711 const input = '{{.myvar}}';
712 const tokens = await runLexerGetTokens(page, input);
713
714 const expectedTokens = [
715 { type: 'Macro.Start', text: '{{' },
716 { type: 'Var.LocalPrefix', text: '.' },
717 { type: 'Var.Identifier', text: 'myvar' },
718 { type: 'Macro.End', text: '}}' },
719 ];
720
721 expect(tokens).toEqual(expectedTokens);
722 });
723
724 // {{$variable}} - Global variable get
725 test('should tokenize global variable shorthand', async ({ page }) => {
726 const input = '{{$myvar}}';
727 const tokens = await runLexerGetTokens(page, input);
728
729 const expectedTokens = [
730 { type: 'Macro.Start', text: '{{' },
731 { type: 'Var.GlobalPrefix', text: '$' },
732 { type: 'Var.Identifier', text: 'myvar' },
733 { type: 'Macro.End', text: '}}' },
734 ];
735
736 expect(tokens).toEqual(expectedTokens);
737 });
738
739 // {{.my-var}} - Variable with hyphen in name
740 test('should tokenize variable with hyphen in name', async ({ page }) => {
741 const input = '{{.my-var}}';
742 const tokens = await runLexerGetTokens(page, input);
743
744 const expectedTokens = [
745 { type: 'Macro.Start', text: '{{' },
746 { type: 'Var.LocalPrefix', text: '.' },
747 { type: 'Var.Identifier', text: 'my-var' },
748 { type: 'Macro.End', text: '}}' },
749 ];
750
751 expect(tokens).toEqual(expectedTokens);
752 });
753
754 // {{.counter++}} - Increment operator
755 test('should tokenize increment operator', async ({ page }) => {
756 const input = '{{.counter++}}';
757 const tokens = await runLexerGetTokens(page, input);
758
759 const expectedTokens = [
760 { type: 'Macro.Start', text: '{{' },
761 { type: 'Var.LocalPrefix', text: '.' },
762 { type: 'Var.Identifier', text: 'counter' },
763 { type: 'Var.Increment', text: '++' },
764 { type: 'Macro.End', text: '}}' },
765 ];
766
767 expect(tokens).toEqual(expectedTokens);
768 });
769
770 // {{$counter--}} - Decrement operator
771 test('should tokenize decrement operator', async ({ page }) => {
772 const input = '{{$counter--}}';
773 const tokens = await runLexerGetTokens(page, input);
774
775 const expectedTokens = [
776 { type: 'Macro.Start', text: '{{' },
777 { type: 'Var.GlobalPrefix', text: '$' },
778 { type: 'Var.Identifier', text: 'counter' },
779 { type: 'Var.Decrement', text: '--' },
780 { type: 'Macro.End', text: '}}' },
781 ];
782
783 expect(tokens).toEqual(expectedTokens);
784 });
785
786 // {{.myvar = value}} - Set operator
787 test('should tokenize set operator with value', async ({ page }) => {
788 const input = '{{.myvar = hello}}';
789 const tokens = await runLexerGetTokens(page, input);
790
791 const expectedTokens = [
792 { type: 'Macro.Start', text: '{{' },
793 { type: 'Var.LocalPrefix', text: '.' },
794 { type: 'Var.Identifier', text: 'myvar' },
795 { type: 'Var.Equals', text: '=' },
796 { type: 'Identifier', text: 'hello' },
797 { type: 'Macro.End', text: '}}' },
798 ];
799
800 expect(tokens).toEqual(expectedTokens);
801 });
802
803 // {{.myvar += 5}} - Add operator
804 test('should tokenize add operator with value', async ({ page }) => {
805 const input = '{{.myvar += 5}}';
806 const tokens = await runLexerGetTokens(page, input);
807
808 const expectedTokens = [
809 { type: 'Macro.Start', text: '{{' },
810 { type: 'Var.LocalPrefix', text: '.' },
811 { type: 'Var.Identifier', text: 'myvar' },
812 { type: 'Var.PlusEquals', text: '+=' },
813 { type: 'Unknown', text: '5' },
814 { type: 'Macro.End', text: '}}' },
815 ];
816
817 expect(tokens).toEqual(expectedTokens);
818 });
819
820 // {{ !.importantvariable }} - Variable prefix after flags
821 test('should tokenize variable prefix after flags', async ({ page }) => {
822 const input = '{{ !.importantvariable }}';
823 const tokens = await runLexerGetTokens(page, input);
824
825 // When . is encountered, it triggers variable mode regardless of previous flags
826 const expectedTokens = [
827 { type: 'Macro.Start', text: '{{' },
828 { type: 'Macro.Flag', text: '!' },
829 { type: 'Var.LocalPrefix', text: '.' },
830 { type: 'Var.Identifier', text: 'importantvariable' },
831 { type: 'Macro.End', text: '}}' },
832 ];
833
834 expect(tokens).toEqual(expectedTokens);
835 });
836
837 // {{.my-long-var-name}} - Variable with multiple hyphens
838 test('should tokenize variable with multiple hyphens', async ({ page }) => {
839 const input = '{{.my-long-var-name}}';
840 const tokens = await runLexerGetTokens(page, input);
841
842 const expectedTokens = [
843 { type: 'Macro.Start', text: '{{' },
844 { type: 'Var.LocalPrefix', text: '.' },
845 { type: 'Var.Identifier', text: 'my-long-var-name' },
846 { type: 'Macro.End', text: '}}' },
847 ];
848
849 expect(tokens).toEqual(expectedTokens);
850 });
851
852 // {{.myvar = Hello {{user}}}} - Nested macro in value
853 test('should tokenize nested macro in variable value', async ({ page }) => {
854 const input = '{{.myvar = Hello {{user}}}}';
855 const tokens = await runLexerGetTokens(page, input);
856
857 const expectedTokens = [
858 { type: 'Macro.Start', text: '{{' },
859 { type: 'Var.LocalPrefix', text: '.' },
860 { type: 'Var.Identifier', text: 'myvar' },
861 { type: 'Var.Equals', text: '=' },
862 { type: 'Identifier', text: 'Hello' },
863 { type: 'Macro.Start', text: '{{' },
864 { type: 'Macro.Identifier', text: 'user' },
865 { type: 'Macro.End', text: '}}' },
866 { type: 'Macro.End', text: '}}' },
867 ];
868
869 expect(tokens).toEqual(expectedTokens);
870 });
871
872 // {{ .myvar }} - Whitespace around variable shorthand
873 test('should tokenize variable shorthand with surrounding whitespace', async ({ page }) => {
874 const input = '{{ .myvar }}';
875 const tokens = await runLexerGetTokens(page, input);
876
877 const expectedTokens = [
878 { type: 'Macro.Start', text: '{{' },
879 { type: 'Var.LocalPrefix', text: '.' },
880 { type: 'Var.Identifier', text: 'myvar' },
881 { type: 'Macro.End', text: '}}' },
882 ];
883
884 expect(tokens).toEqual(expectedTokens);
885 });
886
887 // {{$myVar123}} - Variable with numbers
888 test('should tokenize variable with numbers in name', async ({ page }) => {
889 const input = '{{$myVar123}}';
890 const tokens = await runLexerGetTokens(page, input);
891
892 const expectedTokens = [
893 { type: 'Macro.Start', text: '{{' },
894 { type: 'Var.GlobalPrefix', text: '$' },
895 { type: 'Var.Identifier', text: 'myVar123' },
896 { type: 'Macro.End', text: '}}' },
897 ];
898
899 expect(tokens).toEqual(expectedTokens);
900 });
901
902 // {{.my_var}} - Variable with underscore
903 test('should tokenize variable with underscore in name', async ({ page }) => {
904 const input = '{{.my_var}}';
905 const tokens = await runLexerGetTokens(page, input);
906
907 const expectedTokens = [
908 { type: 'Macro.Start', text: '{{' },
909 { type: 'Var.LocalPrefix', text: '.' },
910 { type: 'Var.Identifier', text: 'my_var' },
911 { type: 'Macro.End', text: '}}' },
912 ];
913
914 expect(tokens).toEqual(expectedTokens);
915 });
916
917 // {{.counter ++ }} - Increment with whitespace
918 test('should tokenize increment operator with surrounding whitespace', async ({ page }) => {
919 const input = '{{.counter ++ }}';
920 const tokens = await runLexerGetTokens(page, input);
921
922 const expectedTokens = [
923 { type: 'Macro.Start', text: '{{' },
924 { type: 'Var.LocalPrefix', text: '.' },
925 { type: 'Var.Identifier', text: 'counter' },
926 { type: 'Var.Increment', text: '++' },
927 { type: 'Macro.End', text: '}}' },
928 ];
929
930 expect(tokens).toEqual(expectedTokens);
931 });
932 });
933
736 test.describe('Macro Output Modifiers', () => {934 test.describe('Macro Output Modifiers', () => {
737 // {{macro | outputModifier}}935 // {{macro | outputModifier}}
738 test('should support output modifier without arguments', async ({ page }) => {936 test('should support output modifier without arguments', async ({ page }) => {
tests/frontend/MacroParser.e2e.js+167 -8
@@ -667,29 +667,175 @@ This is the second line
667 'Macro.End': '}}',667 'Macro.End': '}}',
668 });668 });
669 });669 });
670 });
670671
671 // {{.myvar}} - variable shorthand672 test.describe('Variable Shorthand Syntax', () => {
672 test('should parse macro with variable dot shorthand flag', async ({ page }) => {673 // {{.myvar}} - local variable get
674 test('should parse local variable shorthand', async ({ page }) => {
673 const input = '{{.myvar}}';675 const input = '{{.myvar}}';
674 const macroCst = await runParser(page, input);676 const macroCst = await runParser(page, input);
675677
676 expect(macroCst).toEqual({678 expect(macroCst).toEqual({
677 'Macro.Start': '{{',679 'Macro.Start': '{{',
678 'flags': '.',680 'variableExpr': {
679 'Macro.identifier': 'myvar',681 'Var.scope': '.',
682 'Var.identifier': 'myvar',
683 },
680 'Macro.End': '}}',684 'Macro.End': '}}',
681 });685 });
682 });686 });
683687
684 // {{$myvar}} - variable shorthand688 // {{$myvar}} - global variable get
685 test('should parse macro with variable dollar shorthand flag', async ({ page }) => {689 test('should parse global variable shorthand', async ({ page }) => {
686 const input = '{{$myvar}}';690 const input = '{{$myvar}}';
687 const macroCst = await runParser(page, input);691 const macroCst = await runParser(page, input);
688692
689 expect(macroCst).toEqual({693 expect(macroCst).toEqual({
690 'Macro.Start': '{{',694 'Macro.Start': '{{',
691 'flags': '$',695 'variableExpr': {
692 'Macro.identifier': 'myvar',696 'Var.scope': '$',
697 'Var.identifier': 'myvar',
698 },
699 'Macro.End': '}}',
700 });
701 });
702
703 // {{.my-var}} - variable with hyphen in name
704 test('should parse variable with hyphen in name', async ({ page }) => {
705 const input = '{{.my-var}}';
706 const macroCst = await runParser(page, input);
707
708 expect(macroCst).toEqual({
709 'Macro.Start': '{{',
710 'variableExpr': {
711 'Var.scope': '.',
712 'Var.identifier': 'my-var',
713 },
714 'Macro.End': '}}',
715 });
716 });
717
718 // {{.myvar = value}} - set operator
719 test('should parse variable set shorthand', async ({ page }) => {
720 const input = '{{.myvar = hello}}';
721 const macroCst = await runParser(page, input);
722
723 expect(macroCst).toEqual({
724 'Macro.Start': '{{',
725 'variableExpr': {
726 'Var.scope': '.',
727 'Var.identifier': 'myvar',
728 'variableOperator': {
729 'Var.operator': '=',
730 'Var.value': {
731 'Identifier': 'hello',
732 },
733 },
734 },
735 'Macro.End': '}}',
736 });
737 });
738
739 // {{.counter++}} - increment operator
740 test('should parse variable increment shorthand', async ({ page }) => {
741 const input = '{{.counter++}}';
742 const macroCst = await runParser(page, input);
743
744 expect(macroCst).toEqual({
745 'Macro.Start': '{{',
746 'variableExpr': {
747 'Var.scope': '.',
748 'Var.identifier': 'counter',
749 'variableOperator': {
750 'Var.operator': '++',
751 },
752 },
753 'Macro.End': '}}',
754 });
755 });
756
757 // {{$counter--}} - decrement operator
758 test('should parse global variable decrement shorthand', async ({ page }) => {
759 const input = '{{$counter--}}';
760 const macroCst = await runParser(page, input);
761
762 expect(macroCst).toEqual({
763 'Macro.Start': '{{',
764 'variableExpr': {
765 'Var.scope': '$',
766 'Var.identifier': 'counter',
767 'variableOperator': {
768 'Var.operator': '--',
769 },
770 },
771 'Macro.End': '}}',
772 });
773 });
774
775 // {{.myvar += 5}} - add operator
776 test('should parse variable add shorthand', async ({ page }) => {
777 const input = '{{.myvar += 5}}';
778 const macroCst = await runParser(page, input);
779
780 expect(macroCst).toEqual({
781 'Macro.Start': '{{',
782 'variableExpr': {
783 'Var.scope': '.',
784 'Var.identifier': 'myvar',
785 'variableOperator': {
786 'Var.operator': '+=',
787 'Var.value': {
788 'Unknown': '5',
789 },
790 },
791 },
792 'Macro.End': '}}',
793 });
794 });
795
796 // {{.myvar = Hello {{user}}}} - nested macro in value
797 test('should parse nested macro in variable value', async ({ page }) => {
798 const input = '{{.myvar = Hello {{user}}}}';
799 const macroCst = await runParser(page, input);
800
801 expect(macroCst).toEqual({
802 'Macro.Start': '{{',
803 'variableExpr': {
804 'Var.scope': '.',
805 'Var.identifier': 'myvar',
806 'variableOperator': {
807 'Var.operator': '=',
808 'Var.value': {
809 'Identifier': 'Hello',
810 'macro': {
811 'Macro.Start': '{{',
812 'Macro.identifier': 'user',
813 'Macro.End': '}}',
814 },
815 },
816 },
817 },
818 'Macro.End': '}}',
819 });
820 });
821
822 // {{ .myvar = spaced }} - whitespace handling
823 test('should parse variable shorthand with whitespace', async ({ page }) => {
824 const input = '{{ .myvar = spaced }}';
825 const macroCst = await runParser(page, input);
826
827 expect(macroCst).toEqual({
828 'Macro.Start': '{{',
829 'variableExpr': {
830 'Var.scope': '.',
831 'Var.identifier': 'myvar',
832 'variableOperator': {
833 'Var.operator': '=',
834 'Var.value': {
835 'Identifier': 'spaced',
836 },
837 },
838 },
693 'Macro.End': '}}',839 'Macro.End': '}}',
694 });840 });
695 });841 });
@@ -767,6 +913,19 @@ function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignore
767 }913 }
768 if (node.children) {914 if (node.children) {
769 const simplifiedChildren = {};915 const simplifiedChildren = {};
916
917 // Special handling: merge macroBody children into parent (flatten the structure)
918 // This preserves backward compatibility with existing tests after parser refactor
919 if (node.children.macroBody && Array.isArray(node.children.macroBody) && node.children.macroBody.length === 1) {
920 const macroBody = node.children.macroBody[0];
921 if (macroBody.children) {
922 for (const bodyKey in macroBody.children) {
923 node.children[bodyKey] = macroBody.children[bodyKey];
924 }
925 }
926 delete node.children.macroBody;
927 }
928
770 for (const key in node.children) {929 for (const key in node.children) {
771 function simplifyChildNode(childNode, path) {930 function simplifyChildNode(childNode, path) {
772 if (Array.isArray(childNode)) {931 if (Array.isArray(childNode)) {