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, +2107 -132Ignore whitespace
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+696 -0
@@ -12,6 +12,7 @@ import {
1212} from '../macros/MacroBrowser.js';
1313import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
1414import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
15+import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
1516
1617/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
1718
@@ -34,6 +35,18 @@ import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
3435 * @property {number} separatorCount - Number of '::' separators found.
3536 * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
3637 * @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.
3750 */
3851
3952/**
@@ -445,6 +458,448 @@ export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
445458}
446459
447460/**
461+ * Enum of variable shorthand prefix types.
462+ * @readonly
463+ * @enum {string}
464+ */
465+export 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+ */
484+export 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+ */
503+export 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+ */
512+const 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+ */
519+export 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+ */
529+export 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+ */
633+export 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+ */
803+export 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+ */
834+export 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+/**
448903 * Autocomplete option for closing a scoped macro.
449904 * Suggests {{/macroName}} to close an unclosed scoped macro.
450905 */
@@ -608,6 +1063,129 @@ export function parseMacroContext(macroText, cursorOffset) {
6081063 }
6091064 }
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)
6111189 // Now parse the identifier and arguments starting from position i
6121190 const remainingText = macroText.slice(i);
6131191 const parts = [];
@@ -725,5 +1303,123 @@ export function parseMacroContext(macroText, cursorOffset) {
7251303 hasSpaceAfterIdentifier,
7261304 hasSpaceArgContent: spaceArgText.length > 0,
7271305 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,
7281315 };
7291316}
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+ */
1324+export 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+25 -11
@@ -5,6 +5,7 @@ import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js'
55import { inject_ids } from '../../constants.js';
66import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
77import { MacroEngine } from '../engine/MacroEngine.js';
8+import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../engine/MacroLexer.js';
89
910/**
1011 * Marker used by {{else}} to split content in {{if}} blocks.
@@ -94,14 +95,14 @@ export function registerCoreMacros() {
9495 // {{if condition}}content{{/if}} -> conditional content
9596 // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
9697 // {{if !condition}}content{{/if}} -> inverted conditional (negated)
9798 // Condition can be a macro name (resolved automatically), variable shorthand (.var or $var), or any value
9899 MacroRegistry.registerMacro('if', {
99100 category: MacroCategory.UTILITY,
100101 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.',
101102 unnamedArgs: [
102103 {
103104 name: 'condition',
104105 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".',
105106 },
106107 {
107108 name: 'content',
@@ -114,6 +115,8 @@ export function registerCoreMacros() {
114115 '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
115116 '{{if !personality}}No personality defined{{/if}}',
116117 '{{if {{getvar::showHeader}}}}# Header{{/if}}',
118+ '{{if .myvar}}Local var exists{{/if}}',
119+ '{{if $globalFlag}}Global flag is set{{/if}}',
117120 ],
118121 returns: 'The content if condition is truthy, else branch or empty string otherwise.',
119122 handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => {
@@ -123,16 +126,27 @@ export function registerCoreMacros() {
123126 if (/^\s*!/.test(rawCondition)) {
124127 inverted = true;
125128 // Strip the ! from the resolved condition if it was the prefix
126129 condition = condition.replace(/^!\s*/, '');
127130 }
128131
129132 // Check if condition is a registered macrovariable nameshorthand (without.varname bracesor $varname)
130133 // If so, resolve it first (only for macros thatusing acceptthe 0appropriate requiredvariable args)macro
131- const macroDef = MacroRegistry.getPrimaryMacro(condition);
134+ const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`);
132- if (macroDef && macroDef.minArgs === 0) {
135+ const varShorthandMatch = condition.match(varShorthandRegex);
133- // Use MacroEngine.evaluate to properly resolve the macro with full context
136+ if (varShorthandMatch) {
134- // This ensures all handler args (cst, normalize, list, etc.) are correctly provided
137+ const [, prefix, varName] = varShorthandMatch;
135- condition = MacroEngine.evaluate(`{{${condition}}}`, env);
138+ const varMacro = prefix === '.' ? 'getvar' : 'getglobalvar';
139+ // Resolve the variable using MacroEngine.evaluate
140+ condition = MacroEngine.evaluate(`{{${varMacro}::${varName}}}`, env);
141+ } else {
142+ // Check if condition is a registered macro name (without braces)
143+ // If so, resolve it first (only for macros that accept 0 required args)
144+ const macroDef = MacroRegistry.getPrimaryMacro(condition);
145+ if (macroDef && macroDef.minArgs === 0) {
146+ // Use MacroEngine.evaluate to properly resolve the macro with full context
147+ // This ensures all handler args (cst, normalize, list, etc.) are correctly provided
148+ condition = MacroEngine.evaluate(`{{${condition}}}`, env);
149+ }
136150 }
137151
138152 // Check if condition is falsy: empty string or isFalseBoolean
public/scripts/macros/engine/MacroCstWalker.js+203 -10
@@ -13,6 +13,7 @@ import { MacroRegistry } from './MacroRegistry.js';
1313 * @property {string[]} args
1414 * @property {MacroFlags} flags - Parsed macro execution flags.
1515 * @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.
1617 * @property {MacroEnv} env
1718 * @property {string} rawInner
1819 * @property {string} rawWithBraces
@@ -22,6 +23,14 @@ import { MacroRegistry } from './MacroRegistry.js';
2223 */
2324
2425/**
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+/**
2534 * @typedef {Object} EvaluationContext
2635 * @property {string} text
2736 * @property {MacroEnv} env
@@ -240,11 +249,21 @@ class MacroCstWalker {
240249 const { text, env, resolveMacro, trimContent } = context;
241250
242251 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'] || []);
244263 const name = identifierTokens[0]?.image || '';
245264
246265 // Extract flag tokens and parse them into a MacroFlags object (now inside macroBody)
247266 const flagTokens = /** @type {IToken[]} */ (children['.flags'] || []);
248267 const flagSymbols = flagTokens.map(token => token.image);
249268 const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();
250269
@@ -255,8 +274,8 @@ class MacroCstWalker {
255274 const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset;
256275 const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset;
257276
258277 // Extract argument nodes from the "arguments" rule (if present, inside macroBody)
259278 const argumentsNode = /** @type {CstNode?} */ ((childrenbodyChildren.arguments || [])[0]);
260279 const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
261280
262281 /** @type {string[]} */
@@ -349,6 +368,167 @@ class MacroCstWalker {
349368 }
350369
351370 /**
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+ /**
352532 * Evaluates a single argument node by resolving nested macros and reconstructing
353533 * the original argument text.
354534 *
@@ -759,13 +939,24 @@ class MacroCstWalker {
759939 */
760940 #extractMacroInfo(macroNode) {
761941 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'] || []);
763954 const name = identifierTokens[0]?.image || '';
764955
765956 if (!name) return null;
766957
767958 // Check for closing block flag (inside macroBody)
768959 const flagTokens = /** @type {IToken[]} */ (children['.flags'] || []);
769960 const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
770961
771962 return { name, isClosing };
@@ -786,9 +977,11 @@ class MacroCstWalker {
786977 return true;
787978 }
788979
789980 // Count current arguments in the macro (now inside macroBody)
790981 const children = macroNode.children || {};
791982 const argumentsNodemacroBodyNode = /** @type {CstNode?} */ ((children.argumentsmacroBody || [])[0]);
983+ const bodyChildren = macroBodyNode?.children || {};
984+ const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
792985 const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
793986 const currentArgCount = argumentNodes.length;
794987
public/scripts/macros/engine/MacroFlags.js+2 -37
@@ -15,8 +15,6 @@
1515 * @property {boolean} filter - Whether the filter (`>`) flag is set.
1616 * @property {boolean} closingBlock - Whether the closing block (`/`) flag is set.
1717 * @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.
2018 * @property {string[]} raw - The raw flag symbols in order of appearance.
2119 */
2220
@@ -74,19 +72,8 @@ export const MacroFlagType = Object.freeze({
7472 */
7573 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: '$',
9077});
9178
9279/**
@@ -146,20 +133,6 @@ export const MacroFlagDefinitions = new Map([
146133 implemented: true,
147134 affectsParser: false,
148135 }],
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- }],
163136]);
164137
165138/**
@@ -182,8 +155,6 @@ export function createEmptyFlags() {
182155 filter: false,
183156 closingBlock: false,
184157 preserveWhitespace: false,
185- varDot: false,
186- varDollar: false,
187158 raw: [],
188159 };
189160}
@@ -217,12 +188,6 @@ export function parseFlags(flagSymbols) {
217188 case MacroFlagType.PRESERVE_WHITESPACE:
218189 flags.preserveWhitespace = true;
219190 break;
220- case MacroFlagType.VAR_DOT:
221- flags.varDot = true;
222- break;
223- case MacroFlagType.VAR_DOLLAR:
224- flags.varDollar = true;
225- break;
226191 default:
227192 console.warn(`Can't parse unknown macro flag: ${symbol}`);
228193 }
public/scripts/macros/engine/MacroLexer.js+109 -22
@@ -16,28 +16,42 @@ const IDENTIFIER_LEXER_PATTERN = /[a-zA-Z][\w-_]*/;
1616 */
1717export 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+ */
26+export const MACRO_VARIABLE_SHORTHAND_PATTERN = /[a-zA-Z](?:[\w\-_]*[\w])?/;
27+
1928/** @enum {string} */
2029const modes = Object.freeze({
2130 plaintext: 'plaintext_mode',
2231 macro_def: 'macro_def_mode',
2332 macro_identifier_end: 'macro_identifier_end_mode',
2433 macro_args: 'macro_args_mode',
2534 macro_filter_modifer: 'macro_filter_modifer_mode',
2635 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
2942/** @readonly */
30-const 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+ */
46+const Tokens = Object.freeze({
47+/** General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'. */
3248 Plaintext: createToken({ name: 'Plaintext', pattern: /(?:[^{]|\{(?!\{))+/u, line_breaks: true }),
3349 //** Single literal '{' that appears immediately before a macro opener '{{'. */
3450 PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }),
3551
3652 //** General macro capture */
3753 Macro: {
3854 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.
4155 /**
4256 * Macro execution flags - special symbols that modify macro resolution behavior.
4357 * - `!` = immediate resolve (TBD)
@@ -45,24 +59,26 @@ const Tokens = {
4559 * - `~` = re-evaluate (TBD)
4660 * - `/` = closing block marker for scoped macros
4761 * - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility
48- * - `.` = variable shorthand (TBD)
49- * - `$` = variable shorthand alternative (TBD)
5062 */
5163 Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/.$]/ }),
5264 /**
5365 * Filter flag (`>`) - separate token because it changes parsing behavior.
5466 * When present, `|` characters inside the macro are treated as filter/pipe operators.
5567 */
5668 FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }),
5769 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+ */
5874 Identifier: createToken({ name: 'Macro.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
5975 //** 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 */
6076 EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
6177 BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
6278 End: createToken({ name: 'Macro.End', pattern: /\}\}/ }),
6379 },
6480
6581 //** Captures that only appear inside arguments */
6682 Args: {
6783 DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }),
6884 Colon: createToken({ name: 'Args.Colon', pattern: /:/ }),
@@ -74,7 +90,7 @@ const Tokens = {
7490 EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
7591 Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
7692 Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
7793 //** 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 */
7894 EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
7995 },
8096
@@ -82,22 +98,54 @@ const Tokens = {
8298 Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
8399 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+ */
87128 Unknown: createToken({ name: 'Unknown', pattern: /([^}]|\}(?!\}))/ }),
88129
89130 //** 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. */
90131 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+ */
95138 ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }),
96139});
97140
98141/** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */
99142const 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+ */
101149const Def = {
102150 modes: {
103151 [modes.plaintext]: [
@@ -111,6 +159,11 @@ const Def = {
111159 // An explicit double-slash will be treated above flags to consume, as it'll introduce a comment macro. Directly following is the args then.
112160 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+
114167 using(Tokens.Macro.Flags),
115168 // Filter flag is separate because it affects parsing behavior for pipes
116169 using(Tokens.Macro.FilterFlag),
@@ -165,6 +218,40 @@ const Def = {
165218 exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
166219 exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer),
167220 ],
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+ ],
168255 },
169256 defaultMode: modes.plaintext,
170257};
public/scripts/macros/engine/MacroParser.js+61 -2
@@ -43,7 +43,7 @@ class MacroParser extends CstParser {
4343 });
4444 });
4545
46- // Basic Macro Structure
46+ // Basic Macro Structure - can be either a regular macro or a variable expression
4747 $.macro = $.RULE('macro', () => {
4848 $.CONSUME(Tokens.Macro.Start);
4949
@@ -56,13 +56,72 @@ class MacroParser extends CstParser {
5656 ]);
5757 });
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', () => {
5972 // Macro identifier (name)
6073 $.OR2([
6174 { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
6275 { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
6376 ]);
6477 $.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+ });
66125 });
67126
68127 // Arguments Parsing
public/scripts/slash-commands/SlashCommandParser.js+408 -12
@@ -15,7 +15,19 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
1515import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
1616import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
1717import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
18-import { EnhancedMacroAutoCompleteOption, MacroFlagAutoCompleteOption, MacroClosingTagAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
18+import {
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';
1931import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
2032import { MacroParser } from '../macros/engine/MacroParser.js';
2133import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
@@ -25,6 +37,8 @@ import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
2537import { SlashCommandBreak } from './SlashCommandBreak.js';
2638import { macros as macroSystem } from '../macros/macro-system.js';
2739import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
40+import { chat_metadata } from '/script.js';
41+import { extension_settings } from '../extensions.js';
2842
2943/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
3044/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
@@ -566,29 +580,138 @@ export class SlashCommandParser {
566580 } else {
567581 conditionStartOffset = context.identifierStart + identifier.length;
568582 }
569583 constlet conditionStartInText = macro.start + 2 + conditionStartOffset;
570584
571585 // Build if-condition options using macroContent for padding calculation
572586 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
573587 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+
575621 const result = new AutoCompleteNameResult(
576622 conditionTextresultIdentifier,
577623 conditionStartInTextresultStart,
578624 options,
579625 false,
580- () => 'Use {{macro}} syntax for dynamic conditions',
626+ () => isTypingVarShorthand
581627 () => ? 'Enter a macrovariable name or {{macro}} 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',
582632 );
583633 return result;
584634 }
585635
636+ /** @type {()=>string|undefined} */
637+ let makeNoMatchText = undefined;
638+ /** @type {()=>string|undefined} */
639+ let makeNoOptionsText = undefined;
640+
586641 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+
587708 const result = new AutoCompleteNameResult(
588709 identifierresultIdentifier,
589710 identifierStartInTextresultStart,
590711 options,
591712 false,
713+ makeNoMatchText,
714+ makeNoOptionsText,
592715 );
593716 return result;
594717 }
@@ -669,12 +792,17 @@ export class SlashCommandParser {
669792 * When typing arguments (after ::), prioritizes the exact macro match.
670793 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
671794 * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
672795 * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
673796 */
674797 #buildEnhancedMacroOptions(context, textUpToCursor = '') {
675798 /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
676799 const options = [];
677800
801+ // Handle variable shorthand mode
802+ if (context.isVariableShorthand) {
803+ return this.#buildVariableShorthandOptions(context);
804+ }
805+
678806 // Check for unclosed scoped macros and suggest closing tags first
679807 const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
680808 if (unclosedScopes.length > 0) {
@@ -685,11 +813,9 @@ export class SlashCommandParser {
685813
686814 // If inside a scoped {{if}}, also suggest {{else}}
687815 if (innermostScope.name === 'if') {
688- // TODO: TEsting
689816 const macroDef = macroSystem.registry.getPrimaryMacro('else');
690817 const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
691818 elseOption.sortPriority = 2;
692- // const elseOption = new MacroElseAutoCompleteOption();
693819 options.push(elseOption);
694820 }
695821 }
@@ -732,6 +858,14 @@ export class SlashCommandParser {
732858 flagOption.sortPriority = isSelectable ? 10 : 12;
733859 options.push(flagOption);
734860 }
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+ }
735869 }
736870
737871 // Get all macros from the registry (excluding hidden aliases)
@@ -781,6 +915,187 @@ export class SlashCommandParser {
781915 }
782916
783917 /**
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+ /**
7841099 * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
7851100 * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
7861101 * @param {import('../macros/engine/MacroRegistry.js').MacroDefinition[]} allMacros
@@ -796,6 +1111,87 @@ export class SlashCommandParser {
7961111 const leadingMatch = macroInnerText.match(/^(\s*)/);
7971112 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+
7991195 // Add zero-arg macros as condition shorthand options
8001196 for (const macro of allMacros) {
8011197 // Only include macros that require zero arguments (can be auto-resolved)
tests/.eslintrc.cjs+1 -0
@@ -25,6 +25,7 @@ module.exports = {
2525 'node_modules/**/*',
2626 ],
2727 globals: {
28+ SillyTavern: 'readonly',
2829 },
2930 rules: {
3031 'no-unused-vars': ['error', { args: 'none' }],
tests/frontend/MacroEngine.e2e.js+207 -0
@@ -1765,6 +1765,165 @@ test.describe('MacroEngine', () => {
17651765 expect(output).toBe('Message (by EnvChar)');
17661766 });
17671767 });
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+ });
17681927});
17691928
17701929/**
@@ -1833,3 +1992,51 @@ async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
18331992 page.off('console', handler);
18341993 }
18351994}
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+ */
2006+async 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
568568 expect(tokens).toEqual(expectedTokens);
569569 });
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- });
598570 // {{#legacy}}
599571 test('should support legacy # flag', async ({ page }) => {
600572 const input = '{{#legacy}}';
@@ -640,13 +612,13 @@ test.describe('MacroLexer', () => {
640612 });
641613 // {{ ! .importantvariable }}
642614 test('should support multiple flags with whitespace', async ({ page }) => {
643615 const input = '{{ !.#importantvariable }}';
644616 const tokens = await runLexerGetTokens(page, input);
645617
646618 const expectedTokens = [
647619 { type: 'Macro.Start', text: '{{' },
648620 { type: 'Macro.Flag', text: '!' },
649621 { type: 'Macro.Flag', text: '.#' },
650622 { type: 'Macro.Identifier', text: 'importantvariable' },
651623 { type: 'Macro.End', text: '}}' },
652624 ];
@@ -733,6 +705,232 @@ test.describe('MacroLexer', () => {
733705 });
734706 });
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+
736934 test.describe('Macro Output Modifiers', () => {
737935 // {{macro | outputModifier}}
738936 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
667667 'Macro.End': '}}',
668668 });
669669 });
670+ });
670671
671- // {{.myvar}} - variable shorthand
672+ 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 }) => {
673675 const input = '{{.myvar}}';
674676 const macroCst = await runParser(page, input);
675677
676678 expect(macroCst).toEqual({
677679 'Macro.Start': '{{',
678680 'flagsvariableExpr': '.',{
679681 'MacroVar.identifierscope': 'myvar.',
682+ 'Var.identifier': 'myvar',
683+ },
680684 'Macro.End': '}}',
681685 });
682686 });
683687
684688 // {{$myvar}} - global variable shorthandget
685689 test('should parse macro withglobal variable dollar shorthand flag', async ({ page }) => {
686690 const input = '{{$myvar}}';
687691 const macroCst = await runParser(page, input);
688692
689693 expect(macroCst).toEqual({
690694 'Macro.Start': '{{',
691695 'flagsvariableExpr': '$',{
692696 'MacroVar.identifierscope': 'myvar$',
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+ },
693839 'Macro.End': '}}',
694840 });
695841 });
@@ -767,6 +913,19 @@ function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignore
767913 }
768914 if (node.children) {
769915 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+
770929 for (const key in node.children) {
771930 function simplifyChildNode(childNode, path) {
772931 if (Array.isArray(childNode)) {