From 0dcd9906bfd9c8366acf03dc00975a57901ccd27 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Wed, 31 Dec 2025 18:38:50 +0100 Subject: [PATCH] Macros 2.0 (v0.5) - Add variable shorthand macros and variable support to `{{if}}` macro (#4933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- .../EnhancedMacroAutoCompleteOption.js | 696 ++++++++++++++++++ .../scripts/macros/definitions/core-macros.js | 36 +- .../scripts/macros/engine/MacroCstWalker.js | 213 +++++- public/scripts/macros/engine/MacroFlags.js | 39 +- public/scripts/macros/engine/MacroLexer.js | 131 +++- public/scripts/macros/engine/MacroParser.js | 63 +- .../slash-commands/SlashCommandParser.js | 420 ++++++++++- tests/.eslintrc.cjs | 1 + tests/frontend/MacroEngine.e2e.js | 207 ++++++ tests/frontend/MacroLexer.e2e.js | 258 ++++++- tests/frontend/MacroParser.e2e.js | 175 ++++- 11 files changed, 2107 insertions(+), 132 deletions(-) diff --git a/public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js b/public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js index e3c65a152..da9024269 100644 --- a/public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js +++ b/public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js @@ -12,6 +12,7 @@ import { } from '../macros/MacroBrowser.js'; import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js'; import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js'; +import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js'; /** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */ @@ -34,6 +35,18 @@ import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js'; * @property {number} separatorCount - Number of '::' separators found. * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag). * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content. + * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $). + * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null. + * @property {string} variableName - The variable name being typed (after the prefix). + * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null. + * @property {string} variableValue - The value after the operator (for = and +=). + * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area. + * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator. + * @property {boolean} isTypingValue - Whether cursor is after an operator that requires a value. + * @property {boolean} [hasInvalidTrailingChars] - Whether there are invalid characters after the variable name. + * @property {string} [invalidTrailingChars] - The invalid trailing characters (for error display). + * @property {string} [partialOperator] - Partial operator prefix being typed ('+' or '-'). + * @property {boolean} [isOperatorComplete] - Whether a complete operator (++ or --) was typed that doesn't need a value. */ /** @@ -444,6 +457,448 @@ export class MacroFlagAutoCompleteOption extends AutoCompleteOption { } } +/** + * Enum of variable shorthand prefix types. + * @readonly + * @enum {string} + */ +export const VariableShorthandType = Object.freeze({ + /** Local variable prefix (`.`) */ + LOCAL: '.', + /** Global variable prefix (`$`) */ + GLOBAL: '$', +}); + +/** + * @typedef {Object} VariableShorthandDefinition + * @property {VariableShorthandType} type - The prefix symbol. + * @property {string} name - Human-readable name. + * @property {string} description - Description of what this prefix does. + * @property {string[]} operations - List of supported operations. + */ + +/** + * Definitions for variable shorthand prefixes. + * @type {Map} + */ +export const VariableShorthandDefinitions = new Map([ + [VariableShorthandType.LOCAL, { + type: VariableShorthandType.LOCAL, + name: 'Local Variable', + description: 'Access or modify a local variable (scoped to current chat).', + operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)'], + }], + [VariableShorthandType.GLOBAL, { + type: VariableShorthandType.GLOBAL, + name: 'Global Variable', + description: 'Access or modify a global variable (shared across all chats).', + operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)'], + }], +]); + +/** + * Set of valid variable shorthand prefix symbols. + * @type {Set} + */ +export const ValidVariableShorthandSymbols = new Set(Object.values(VariableShorthandType)); + +/** + * Regex pattern for valid variable shorthand names. + * Must start with a letter, can contain word chars, underscores and hyphens, but must not end with an underscore or hyphen. + * Examples: myVar, my-var, my_var, myVar123, my-long-var-name + * Invalid: my-, my--, -var, 123var + * @type {RegExp} + */ +const VARIABLE_SHORTHAND_NAME_PATTERN = new RegExp(`^${MACRO_VARIABLE_SHORTHAND_PATTERN.source}`); + +/** + * Checks if a variable name is valid for use with variable shorthand syntax. + * @param {string} name - The variable name to validate. + * @returns {boolean} True if the name is valid for shorthand syntax. + */ +export function isValidVariableShorthandName(name) { + if (!name || typeof name !== 'string') return false; + return VARIABLE_SHORTHAND_NAME_PATTERN.test(name); +} + +/** + * Autocomplete option for variable shorthand prefixes. + * Shows prefix symbol, name, and description. + * This provides entry into the variable shorthand syntax ({{.varName}} or {{$varName}}). + */ +export class VariableShorthandAutoCompleteOption extends AutoCompleteOption { + /** @type {VariableShorthandDefinition} */ + #varDef; + + /** + * @param {VariableShorthandDefinition} varDef - The variable shorthand definition. + */ + constructor(varDef) { + // Use the prefix symbol as the name, with a variable icon + super(varDef.type, '📦'); + this.#varDef = varDef; + } + + /** @returns {VariableShorthandDefinition} */ + get variableDefinition() { + return this.#varDef; + } + + /** + * Renders the autocomplete list item for this variable shorthand. + * @returns {HTMLElement} + */ + renderItem() { + const li = this.makeItem( + `${this.#varDef.type} ${this.#varDef.name}`, + '📦', + true, // noSlash + [], // namedArguments + [], // unnamedArguments + 'any', // returnType + this.#varDef.description, + ); + li.setAttribute('data-name', this.name); + li.setAttribute('data-option-type', 'variable-shorthand'); + return li; + } + + /** + * Renders the details panel for this variable shorthand. + * @returns {DocumentFragment} + */ + renderDetails() { + const frag = document.createDocumentFragment(); + + const details = document.createElement('div'); + details.classList.add('macro-variable-details'); + + // Header with prefix symbol and name + const header = document.createElement('h3'); + header.classList.add('macro-variable-details-header'); + header.innerHTML = `${this.#varDef.type} ${this.#varDef.name}`; + details.append(header); + + // Description + const desc = document.createElement('p'); + desc.classList.add('macro-variable-details-desc'); + desc.textContent = this.#varDef.description; + details.append(desc); + + // Supported operations + const opsHeader = document.createElement('p'); + opsHeader.innerHTML = 'Supported Operations:'; + details.append(opsHeader); + + const opsList = document.createElement('ul'); + opsList.classList.add('macro-variable-details-ops'); + for (const op of this.#varDef.operations) { + const li = document.createElement('li'); + li.textContent = op; + opsList.append(li); + } + details.append(opsList); + + // Examples + const exampleHeader = document.createElement('p'); + exampleHeader.innerHTML = 'Examples:'; + details.append(exampleHeader); + + const exampleList = document.createElement('ul'); + exampleList.classList.add('macro-variable-details-examples'); + const prefix = this.#varDef.type; + const examples = [ + `{{${prefix}myvar}} - Get variable value`, + `{{${prefix}myvar = value}} - Set variable`, + `{{${prefix}counter++}} - Increment`, + `{{${prefix}counter--}} - Decrement`, + `{{${prefix}myvar += text}} - Append/add`, + ]; + for (const ex of examples) { + const li = document.createElement('li'); + li.innerHTML = `${ex.split(' - ')[0]} - ${ex.split(' - ')[1]}`; + exampleList.append(li); + } + details.append(exampleList); + + frag.append(details); + return frag; + } +} + +/** + * Autocomplete option for a specific variable name. + * Shows variable name with scope indicator (local/global). + */ +export class VariableNameAutoCompleteOption extends AutoCompleteOption { + /** @type {string} */ + #varName; + + /** @type {'local'|'global'} */ + #scope; + + /** @type {boolean} */ + #isNewVariable; + + /** @type {boolean} */ + #isInvalidName; + + /** + * @param {string} varName - The variable name. + * @param {'local'|'global'} scope - Whether this is a local or global variable. + * @param {boolean} [isNewVariable=false] - Whether this is a "create new variable" option. + * @param {boolean} [isInvalidName=false] - Whether this name is invalid for shorthand syntax. + */ + constructor(varName, scope, isNewVariable = false, isInvalidName = false) { + const icon = scope === 'local' ? 'L' : 'G'; + super(varName, icon); + this.#varName = varName; + this.#scope = scope; + this.#isNewVariable = isNewVariable; + this.#isInvalidName = isInvalidName; + } + + /** @returns {string} */ + get variableName() { + return this.#varName; + } + + /** @returns {'local'|'global'} */ + get scope() { + return this.#scope; + } + + /** @returns {boolean} */ + get isNewVariable() { + return this.#isNewVariable; + } + + /** @returns {boolean} */ + get isInvalidName() { + return this.#isInvalidName; + } + + /** + * Renders the autocomplete list item for this variable. + * @returns {HTMLElement} + */ + renderItem() { + const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global'; + let description; + if (this.#isInvalidName) { + description = '⚠️ Invalid variable name for shorthand'; + } else if (this.#isNewVariable) { + description = `Define new ${scopeLabel.toLowerCase()} variable`; + } else { + description = `${scopeLabel} variable`; + } + + const li = this.makeItem( + this.#varName, + this.typeIcon, + true, // noSlash + [], // namedArguments + [], // unnamedArguments + 'any', // returnType + description, + ); + li.setAttribute('data-name', this.name); + li.setAttribute('data-option-type', 'variable-name'); + if (this.#isNewVariable) { + li.classList.add('variable-new'); + } + if (this.#isInvalidName) { + li.classList.add('variable-invalid'); + } + return li; + } + + /** + * Renders the details panel for this variable. + * @returns {DocumentFragment} + */ + renderDetails() { + const frag = document.createDocumentFragment(); + + const details = document.createElement('div'); + details.classList.add('macro-variable-name-details'); + + const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global'; + const prefix = this.#scope === 'local' ? '.' : '$'; + + // Show big warning for invalid names + if (this.#isInvalidName) { + const warningBox = document.createElement('div'); + warningBox.classList.add('variable-invalid-warning'); + warningBox.style.cssText = 'background: #ff000033; border: 2px solid #ff0000; border-radius: 4px; padding: 10px; margin-bottom: 10px;'; + + const warningHeader = document.createElement('h3'); + warningHeader.style.cssText = 'color: #ff6b6b; margin: 0 0 8px 0;'; + warningHeader.textContent = '⚠️ Invalid Variable Name'; + warningBox.append(warningHeader); + + const warningText = document.createElement('p'); + warningText.style.cssText = 'margin: 0 0 8px 0;'; + warningText.innerHTML = `The name ${this.#varName} cannot be used with variable shorthand syntax.`; + warningBox.append(warningText); + + const rulesText = document.createElement('p'); + rulesText.style.cssText = 'margin: 0; font-size: 0.9em;'; + rulesText.innerHTML = 'Valid names must:
• Start with a letter (a-z, A-Z)
• Contain only letters, numbers, underscores, or hyphens
• Not end with an underscore or hyphen'; + warningBox.append(rulesText); + + details.append(warningBox); + frag.append(details); + return frag; + } + + // Header + const header = document.createElement('h3'); + header.innerHTML = this.#isNewVariable + ? `${prefix}${this.#varName} (New ${scopeLabel} Variable)` + : `${prefix}${this.#varName} ${scopeLabel} Variable`; + details.append(header); + + // Description + const desc = document.createElement('p'); + const variableSuggestion = this.#scope === 'local' + ? 'Local variables are scoped to the current chat.' + : 'Global variables are shared across all chats.'; + if (this.#isNewVariable) { + desc.textContent = `Creates a new ${scopeLabel.toLowerCase()} variable named "${this.#varName}". ${variableSuggestion}`; + } else { + desc.textContent = `Access or modify the ${scopeLabel.toLowerCase()} variable "${this.#varName}". ${variableSuggestion}`; + } + details.append(desc); + + // Usage examples + const usageHeader = document.createElement('p'); + usageHeader.innerHTML = 'Usage:'; + details.append(usageHeader); + + const usageList = document.createElement('ul'); + const examples = [ + `{{${prefix}${this.#varName}}} - Get value`, + `{{${prefix}${this.#varName} = value}} - Set value`, + `{{${prefix}${this.#varName}++}} - Increment`, + `{{${prefix}${this.#varName}--}} - Decrement`, + `{{${prefix}${this.#varName} += text}} - Append/add`, + ]; + for (const ex of examples) { + const li = document.createElement('li'); + li.innerHTML = `${ex.split(' - ')[0]} - ${ex.split(' - ')[1]}`; + usageList.append(li); + } + details.append(usageList); + + frag.append(details); + return frag; + } +} + +/** + * Variable shorthand operators with metadata. + * @type {Map} + */ +export const VariableOperatorDefinitions = new Map([ + ['=', { + symbol: '=', + name: 'Set', + description: 'Set the variable to a new value.', + needsValue: true, + }], + ['++', { + symbol: '++', + name: 'Increment', + description: 'Increment the variable by 1 (numeric).', + needsValue: false, + }], + ['--', { + symbol: '--', + name: 'Decrement', + description: 'Decrement the variable by 1 (numeric).', + needsValue: false, + }], + ['+=', { + symbol: '+=', + name: 'Add', + description: 'Add to the variable (numeric addition or string concatenation).', + needsValue: true, + }], +]); + +/** + * Autocomplete option for a variable operator. + * Shows operator symbol, name, and description. + */ +export class VariableOperatorAutoCompleteOption extends AutoCompleteOption { + /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */ + #operatorDef; + + /** + * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition. + */ + constructor(operatorDef) { + super(operatorDef.symbol, '⚡'); + this.#operatorDef = operatorDef; + } + + /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */ + get operatorDefinition() { + return this.#operatorDef; + } + + /** + * Renders the autocomplete list item for this operator. + * @returns {HTMLElement} + */ + renderItem() { + const li = this.makeItem( + `${this.#operatorDef.symbol} ${this.#operatorDef.name}`, + '⚡', + true, // noSlash + [], // namedArguments + [], // unnamedArguments + 'void', // returnType + this.#operatorDef.description, + ); + li.setAttribute('data-name', this.name); + li.setAttribute('data-option-type', 'variable-operator'); + return li; + } + + /** + * Renders the details panel for this operator. + * @returns {DocumentFragment} + */ + renderDetails() { + const frag = document.createDocumentFragment(); + + const details = document.createElement('div'); + details.classList.add('macro-variable-operator-details'); + + // Header + const header = document.createElement('h3'); + header.innerHTML = `${this.#operatorDef.symbol} ${this.#operatorDef.name}`; + details.append(header); + + // Description + const desc = document.createElement('p'); + desc.textContent = this.#operatorDef.description; + details.append(desc); + + // Value note + const valueNote = document.createElement('p'); + valueNote.innerHTML = this.#operatorDef.needsValue + ? 'This operator requires a value after it.' + : 'This operator does not take a value.'; + details.append(valueNote); + + frag.append(details); + return frag; + } +} + /** * Autocomplete option for closing a scoped macro. * Suggests {{/macroName}} to close an unclosed scoped macro. @@ -608,6 +1063,129 @@ export function parseMacroContext(macroText, cursorOffset) { } } + // Check for variable shorthand prefix (. or $) + // These trigger variable expression mode instead of regular macro parsing + /** @type {'.'|'$'|null} */ + let variablePrefix = null; + let variableName = ''; + /** @type {string|null} */ + let variableOperator = null; + let variableValue = ''; + let isVariableShorthand = false; + let isTypingVariableName = false; + let isTypingOperator = false; + let isTypingValue = false; + let variableNameEnd = i; + + const remainingAfterFlags = macroText.slice(i); + if (remainingAfterFlags.startsWith('.') || remainingAfterFlags.startsWith('$')) { + isVariableShorthand = true; + variablePrefix = /** @type {'.'|'$'} */ (remainingAfterFlags[0]); + i++; // Move past the prefix + + // Variable names: start with letter, can have hyphens inside, must not end with hyphen + const varNameMatch = macroText.slice(i).match(VARIABLE_SHORTHAND_NAME_PATTERN); + if (varNameMatch) { + variableName = varNameMatch[0]; + i += variableName.length; + } + variableNameEnd = i; + + // Skip whitespace before operator + while (i < macroText.length && /\s/.test(macroText[i])) { + i++; + } + + // Check for operators: ++, --, +=, = + // Also track partial operator prefixes for autocomplete + const operatorText = macroText.slice(i); + let hasInvalidTrailingChars = false; + let invalidTrailingChars = ''; + let partialOperator = ''; + if (operatorText.startsWith('++')) { + variableOperator = '++'; + i += 2; + } else if (operatorText.startsWith('--')) { + variableOperator = '--'; + i += 2; + } else if (operatorText.startsWith('+=')) { + variableOperator = '+='; + i += 2; + } else if (operatorText.startsWith('=')) { + variableOperator = '='; + i += 1; + } else if (operatorText.startsWith('+') || operatorText.startsWith('-')) { + // Partial operator prefix - user is typing an operator + partialOperator = operatorText[0]; + } else if (operatorText.length > 0 && !/^\s/.test(operatorText)) { + // There's non-whitespace after the variable name that isn't a valid operator + // This is an invalid trailing character (e.g., $my$ or .var@test) + hasInvalidTrailingChars = true; + invalidTrailingChars = operatorText.trim(); + } + + // If operator requires a value (= or +=), parse the value + if (variableOperator === '=' || variableOperator === '+=') { + // Skip whitespace after operator + while (i < macroText.length && /\s/.test(macroText[i])) { + i++; + } + variableValue = macroText.slice(i).trimEnd(); + } + + // Determine cursor position context for autocomplete + const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1; + if (cursorOffset < prefixEnd) { + // Cursor is before the prefix - still in flags area conceptually + isTypingVariableName = false; + } else if (cursorOffset <= variableNameEnd) { + // Cursor is in the variable name + isTypingVariableName = true; + } else if (!variableOperator && !hasInvalidTrailingChars) { + // Cursor is after variable name but no operator yet (and no invalid chars) + // This includes partial operator prefixes like '+' or '-' + isTypingOperator = true; + } else if (variableOperator === '=' || variableOperator === '+=') { + // Operator that requires value - cursor is in value area + isTypingValue = true; + } + // For ++ and --, the operator is complete (no value needed) + // For invalid trailing chars, none of the typing flags will be true + const isOperatorComplete = (variableOperator === '++' || variableOperator === '--'); + + // Return early for variable shorthand - different structure than regular macros + return { + fullText: macroText, + cursorOffset, + paddingBefore: macroText.match(/^\s+/)?.[0] ?? '', + identifier: '', // No macro identifier for variable shorthand + identifierStart: -1, + isInFlagsArea: false, + flags, + currentFlag, + args: [], + currentArgIndex: -1, + isTypingSeparator: false, + hasSpaceAfterIdentifier: false, + hasSpaceArgContent: false, + separatorCount: 0, + // Variable shorthand specific properties + isVariableShorthand, + variablePrefix, + variableName, + variableOperator, + variableValue, + isTypingVariableName, + isTypingOperator, + isTypingValue, + isOperatorComplete, + hasInvalidTrailingChars, + invalidTrailingChars, + partialOperator, + }; + } + + // Regular macro parsing (not variable shorthand) // Now parse the identifier and arguments starting from position i const remainingText = macroText.slice(i); const parts = []; @@ -725,5 +1303,123 @@ export function parseMacroContext(macroText, cursorOffset) { hasSpaceAfterIdentifier, hasSpaceArgContent: spaceArgText.length > 0, separatorCount: separatorPositions.length, + // Default variable shorthand properties (not a variable shorthand) + isVariableShorthand: false, + variablePrefix: null, + variableName: '', + variableOperator: null, + variableValue: '', + isTypingVariableName: false, + isTypingOperator: false, + isTypingValue: false, }; } + +/** + * A simple, generic autocomplete option for displaying basic items with name, symbol, and description. + * Useful for simple options like inversion markers, prefixes, etc. without needing a full custom class. + * + * @extends AutoCompleteOption + */ +export class SimpleAutoCompleteOption extends AutoCompleteOption { + /** @type {string} */ + #description; + + /** @type {string|null} */ + #detailedDescription; + + /** + * @param {Object} config - Configuration for the option. + * @param {string} config.name - The option name/key (used for matching). + * @param {string} [config.symbol=' '] - Icon/symbol shown in the type column. + * @param {string} [config.description=''] - Short description shown inline. + * @param {string} [config.detailedDescription] - Longer description for details panel (supports HTML). Falls back to description if not provided. + * @param {string} [config.type='simple'] - Type identifier for CSS/data attributes. + */ + constructor({ name, symbol = ' ', description = '', detailedDescription = null, type = 'simple' }) { + super(name, symbol, type); + this.#description = description; + this.#detailedDescription = detailedDescription; + } + + /** @returns {string} */ + get description() { + return this.#description; + } + + /** @returns {string} */ + get detailedDescription() { + return this.#detailedDescription ?? this.#description; + } + + /** + * @returns {HTMLElement} + */ + renderItem() { + const li = document.createElement('li'); + li.classList.add('item'); + li.setAttribute('data-name', this.name); + li.setAttribute('data-option-type', this.type); + + // Type icon + const typeSpan = document.createElement('span'); + typeSpan.classList.add('type', 'monospace'); + typeSpan.textContent = this.typeIcon; + li.append(typeSpan); + + // Name + const specs = document.createElement('span'); + specs.classList.add('specs'); + const nameSpan = document.createElement('span'); + nameSpan.classList.add('name', 'monospace'); + this.name.split('').forEach(char => { + const span = document.createElement('span'); + span.textContent = char; + nameSpan.append(span); + }); + specs.append(nameSpan); + li.append(specs); + + // Stopgap + const stopgap = document.createElement('span'); + stopgap.classList.add('stopgap'); + li.append(stopgap); + + // Help/description + const help = document.createElement('span'); + help.classList.add('help'); + const content = document.createElement('span'); + content.classList.add('helpContent'); + content.textContent = this.#description; + help.append(content); + li.append(help); + + return li; + } + + /** + * @returns {DocumentFragment} + */ + renderDetails() { + const frag = document.createDocumentFragment(); + + // Header with name + const specs = document.createElement('div'); + specs.classList.add('specs'); + const nameDiv = document.createElement('div'); + nameDiv.classList.add('name', 'monospace'); + nameDiv.textContent = this.name; + specs.append(nameDiv); + frag.append(specs); + + // Description + if (this.detailedDescription) { + const helpDiv = document.createElement('div'); + helpDiv.classList.add('help'); + helpDiv.innerHTML = this.detailedDescription; + frag.append(helpDiv); + } + + return frag; + } +} diff --git a/public/scripts/macros/definitions/core-macros.js b/public/scripts/macros/definitions/core-macros.js index 9d5fae321..7661e35b9 100644 --- a/public/scripts/macros/definitions/core-macros.js +++ b/public/scripts/macros/definitions/core-macros.js @@ -5,6 +5,7 @@ import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js' import { inject_ids } from '../../constants.js'; import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js'; import { MacroEngine } from '../engine/MacroEngine.js'; +import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../engine/MacroLexer.js'; /** * Marker used by {{else}} to split content in {{if}} blocks. @@ -94,14 +95,14 @@ export function registerCoreMacros() { // {{if condition}}content{{/if}} -> conditional content // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch // {{if !condition}}content{{/if}} -> inverted conditional (negated) - // Condition can be a macro name (resolved automatically) or any value + // Condition can be a macro name (resolved automatically), variable shorthand (.var or $var), or any value MacroRegistry.registerMacro('if', { category: MacroCategory.UTILITY, - 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.', + 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.', unnamedArgs: [ { name: 'condition', - description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved) or a value. Falsy: empty string, "false", "off", "0".', + 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".', }, { name: 'content', @@ -114,6 +115,8 @@ export function registerCoreMacros() { '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}', '{{if !personality}}No personality defined{{/if}}', '{{if {{getvar::showHeader}}}}# Header{{/if}}', + '{{if .myvar}}Local var exists{{/if}}', + '{{if $globalFlag}}Global flag is set{{/if}}', ], returns: 'The content if condition is truthy, else branch or empty string otherwise.', handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => { @@ -123,16 +126,27 @@ export function registerCoreMacros() { if (/^\s*!/.test(rawCondition)) { inverted = true; // Strip the ! from the resolved condition if it was the prefix - condition = condition.replace(/^!/, ''); + condition = condition.replace(/^!\s*/, ''); } - // Check if condition is a registered macro name (without braces) - // If so, resolve it first (only for macros that accept 0 required args) - const macroDef = MacroRegistry.getPrimaryMacro(condition); - if (macroDef && macroDef.minArgs === 0) { - // Use MacroEngine.evaluate to properly resolve the macro with full context - // This ensures all handler args (cst, normalize, list, etc.) are correctly provided - condition = MacroEngine.evaluate(`{{${condition}}}`, env); + // Check if condition is a variable shorthand (.varname or $varname) + // If so, resolve it using the appropriate variable macro + const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`); + const varShorthandMatch = condition.match(varShorthandRegex); + if (varShorthandMatch) { + const [, prefix, varName] = varShorthandMatch; + const varMacro = prefix === '.' ? 'getvar' : 'getglobalvar'; + // Resolve the variable using MacroEngine.evaluate + condition = MacroEngine.evaluate(`{{${varMacro}::${varName}}}`, env); + } else { + // Check if condition is a registered macro name (without braces) + // If so, resolve it first (only for macros that accept 0 required args) + const macroDef = MacroRegistry.getPrimaryMacro(condition); + if (macroDef && macroDef.minArgs === 0) { + // Use MacroEngine.evaluate to properly resolve the macro with full context + // This ensures all handler args (cst, normalize, list, etc.) are correctly provided + condition = MacroEngine.evaluate(`{{${condition}}}`, env); + } } // Check if condition is falsy: empty string or isFalseBoolean diff --git a/public/scripts/macros/engine/MacroCstWalker.js b/public/scripts/macros/engine/MacroCstWalker.js index aba7f5400..101be2940 100644 --- a/public/scripts/macros/engine/MacroCstWalker.js +++ b/public/scripts/macros/engine/MacroCstWalker.js @@ -13,6 +13,7 @@ import { MacroRegistry } from './MacroRegistry.js'; * @property {string[]} args * @property {MacroFlags} flags - Parsed macro execution flags. * @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags). + * @property {boolean} [isVariableShorthand] - Whether this call originated from variable shorthand syntax. * @property {MacroEnv} env * @property {string} rawInner * @property {string} rawWithBraces @@ -21,6 +22,14 @@ import { MacroRegistry } from './MacroRegistry.js'; * @property {CstNode} cstNode */ +/** + * @typedef {Object} VariableExprInfo + * @property {'local' | 'global'} scope - Whether this is a local (.) or global ($) variable. + * @property {string} varName - The variable name. + * @property {'get' | 'set' | 'inc' | 'dec' | 'add'} operation - The operation to perform. + * @property {string | null} value - The value for set/add operations, null for get/inc/dec. + */ + /** * @typedef {Object} EvaluationContext * @property {string} text @@ -240,11 +249,21 @@ class MacroCstWalker { const { text, env, resolveMacro, trimContent } = context; const children = macroNode.children || {}; - const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []); + + // Check if this is a variable expression (has variableExpr child) + const variableExprNode = /** @type {CstNode?} */ ((children.variableExpr || [])[0]); + if (variableExprNode) { + return this.#evaluateVariableExpr(macroNode, variableExprNode, context); + } + + // Regular macro - get identifier from macroBody + const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]); + const bodyChildren = macroBodyNode?.children || {}; + const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []); const name = identifierTokens[0]?.image || ''; - // Extract flag tokens and parse them into a MacroFlags object - const flagTokens = /** @type {IToken[]} */ (children['flags'] || []); + // Extract flag tokens and parse them into a MacroFlags object (now inside macroBody) + const flagTokens = /** @type {IToken[]} */ (children.flags || []); const flagSymbols = flagTokens.map(token => token.image); const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags(); @@ -255,8 +274,8 @@ class MacroCstWalker { const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset; const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset; - // Extract argument nodes from the "arguments" rule (if present) - const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]); + // Extract argument nodes from the "arguments" rule (if present, inside macroBody) + const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]); const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []); /** @type {string[]} */ @@ -348,6 +367,167 @@ class MacroCstWalker { return stringValue; } + /** + * Evaluates a variable expression node and routes it to the appropriate variable macro. + * + * @param {CstNode} macroNode - The parent macro node. + * @param {CstNode} variableExprNode - The variableExpr CST node. + * @param {EvaluationContext} context - The evaluation context. + * @returns {string} + */ + #evaluateVariableExpr(macroNode, variableExprNode, context) { + const { text, env, resolveMacro } = context; + + const children = macroNode.children || {}; + const varChildren = variableExprNode.children || {}; + + // Extract scope (. for local, $ for global) + const localPrefixToken = /** @type {IToken?} */ ((varChildren['Var.scope'] || []).find(t => /** @type {IToken} */(t).tokenType?.name === 'Var.LocalPrefix')); + const isGlobal = !localPrefixToken; + + // Extract variable name + const varIdentifierToken = /** @type {IToken?} */ ((varChildren['Var.identifier'] || [])[0]); + const varName = varIdentifierToken?.image || ''; + + // Extract operator (if any) + const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]); + const operatorChildren = operatorNode?.children || {}; + + // Determine operation and value + let operation = 'get'; + let value = null; + + if (operatorNode) { + const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []); + const operatorToken = operatorTokens[0]; + + if (operatorToken) { + const operatorImage = operatorToken.image; + if (operatorImage === '++') { + operation = 'inc'; + } else if (operatorImage === '--') { + operation = 'dec'; + } else if (operatorImage === '=') { + operation = 'set'; + value = this.#evaluateVariableValue(operatorChildren, context); + } else if (operatorImage === '+=') { + operation = 'add'; + value = this.#evaluateVariableValue(operatorChildren, context); + } + } + } + + // Map operation to macro name + const macroNameMap = { + get: isGlobal ? 'getglobalvar' : 'getvar', + set: isGlobal ? 'setglobalvar' : 'setvar', + inc: isGlobal ? 'incglobalvar' : 'incvar', + dec: isGlobal ? 'decglobalvar' : 'decvar', + add: isGlobal ? 'addglobalvar' : 'addvar', + }; + + const targetMacroName = macroNameMap[operation]; + + // Build args array based on operation + const args = [varName]; + if (value !== null) { + args.push(value); + } + + const range = this.#getMacroRange(macroNode); + + /** @type {MacroCall} */ + const call = { + name: targetMacroName, + args, + flags: createEmptyFlags(), + isScoped: false, + isVariableShorthand: true, + rawInner: text.slice( + (/** @type {IToken|undefined} */ (children['Macro.Start']?.[0])?.endOffset ?? range.startOffset) + 1, + (/** @type {IToken|undefined} */ (children['Macro.End']?.[0])?.startOffset ?? range.endOffset + 1) - 1, + ), + rawWithBraces: text.slice(range.startOffset, range.endOffset + 1), + rawArgs: args, + range, + cstNode: macroNode, + env, + }; + + const result = resolveMacro(call); + return typeof result === 'string' ? result : String(result ?? ''); + } + + /** + * Evaluates the value part of a variable expression (after = or +=). + * Resolves any nested macros in the value. + * + * @param {Record} operatorChildren - The children of the variableOperator node. + * @param {EvaluationContext} context - The evaluation context. + * @returns {string} + */ + #evaluateVariableValue(operatorChildren, context) { + const { text } = context; + + const valueNodes = /** @type {CstNode[]} */ (operatorChildren['Var.value'] || []); + const valueNode = valueNodes[0]; + + if (!valueNode) { + return ''; + } + + const valueChildren = valueNode.children || {}; + + // Get all tokens and nested macros from the value + const identifierTokens = /** @type {IToken[]} */ (valueChildren.Identifier || []); + const unknownTokens = /** @type {IToken[]} */ (valueChildren.Unknown || []); + const nestedMacros = /** @type {CstNode[]} */ (valueChildren.macro || []); + + // Get the range of the value + const allTokens = [...identifierTokens, ...unknownTokens]; + const allRanges = [ + ...allTokens.map(t => ({ startOffset: t.startOffset, endOffset: t.endOffset })), + ...nestedMacros.map(m => this.#getMacroRange(m)), + ]; + + if (allRanges.length === 0) { + return ''; + } + + const startOffset = Math.min(...allRanges.map(r => r.startOffset)); + const endOffset = Math.max(...allRanges.map(r => r.endOffset)); + + // If no nested macros, return the raw text (trimmed) + if (nestedMacros.length === 0) { + return text.slice(startOffset, endOffset + 1).trim(); + } + + // Evaluate nested macros + const nestedWithRange = nestedMacros.map(node => ({ + node, + range: this.#getMacroRange(node), + })); + + nestedWithRange.sort((a, b) => a.range.startOffset - b.range.startOffset); + + let result = ''; + let cursor = startOffset; + + for (const entry of nestedWithRange) { + if (entry.range.startOffset > cursor) { + result += text.slice(cursor, entry.range.startOffset); + } + result += this.#evaluateMacroNode(entry.node, context); + cursor = entry.range.endOffset + 1; + } + + if (cursor <= endOffset) { + result += text.slice(cursor, endOffset + 1); + } + + return result.trim(); + } + /** * Evaluates a single argument node by resolving nested macros and reconstructing * the original argument text. @@ -759,13 +939,24 @@ class MacroCstWalker { */ #extractMacroInfo(macroNode) { const children = macroNode.children || {}; - const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []); + + // Check if this is a variable expression - they can't be scoped + const variableExprNode = (children.variableExpr || [])[0]; + if (variableExprNode) { + return null; // Variable expressions don't support scoped content + } + + // Regular macro - get info from macroBody + const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]); + const bodyChildren = macroBodyNode?.children || {}; + + const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []); const name = identifierTokens[0]?.image || ''; if (!name) return null; - // Check for closing block flag - const flagTokens = /** @type {IToken[]} */ (children['flags'] || []); + // Check for closing block flag (inside macroBody) + const flagTokens = /** @type {IToken[]} */ (children.flags || []); const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK); return { name, isClosing }; @@ -786,9 +977,11 @@ class MacroCstWalker { return true; } - // Count current arguments in the macro + // Count current arguments in the macro (now inside macroBody) const children = macroNode.children || {}; - const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]); + const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]); + const bodyChildren = macroBodyNode?.children || {}; + const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]); const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []); const currentArgCount = argumentNodes.length; diff --git a/public/scripts/macros/engine/MacroFlags.js b/public/scripts/macros/engine/MacroFlags.js index 6630e2ff7..436a6b860 100644 --- a/public/scripts/macros/engine/MacroFlags.js +++ b/public/scripts/macros/engine/MacroFlags.js @@ -15,8 +15,6 @@ * @property {boolean} filter - Whether the filter (`>`) flag is set. * @property {boolean} closingBlock - Whether the closing block (`/`) flag is set. * @property {boolean} preserveWhitespace - Whether the preserve whitespace (`#`) flag is set. - * @property {boolean} varDot - Whether the variable dot (`.`) flag is set. - * @property {boolean} varDollar - Whether the variable dollar (`$`) flag is set. * @property {string[]} raw - The raw flag symbols in order of appearance. */ @@ -74,19 +72,8 @@ export const MacroFlagType = Object.freeze({ */ PRESERVE_WHITESPACE: '#', - /** - * Variable shorthand flag (`.`). - * Shorthand for variable access: `{{.myvar}}` equivalent to `{{getvar::myvar}}`. - * @status TBD - Not implemented in v1 - */ - VAR_DOT: '.', - - /** - * Variable shorthand flag (`$`). - * Alternative shorthand for variable access: `{{$myvar}}`. - * @status TBD - Not implemented in v1 - */ - VAR_DOLLAR: '$', + // Note: Variable shorthand (. and $) are NOT flags - they are special prefixes + // that trigger the variable expression parsing branch. See MacroLexer.js Var tokens. }); /** @@ -146,20 +133,6 @@ export const MacroFlagDefinitions = new Map([ implemented: true, affectsParser: false, }], - [MacroFlagType.VAR_DOT, { - type: MacroFlagType.VAR_DOT, - name: 'Variable (dot)', - description: 'Shorthand for variable access using dot notation.', - implemented: false, - affectsParser: false, - }], - [MacroFlagType.VAR_DOLLAR, { - type: MacroFlagType.VAR_DOLLAR, - name: 'Variable (dollar)', - description: 'Shorthand for variable access using dollar notation.', - implemented: false, - affectsParser: false, - }], ]); /** @@ -182,8 +155,6 @@ export function createEmptyFlags() { filter: false, closingBlock: false, preserveWhitespace: false, - varDot: false, - varDollar: false, raw: [], }; } @@ -217,12 +188,6 @@ export function parseFlags(flagSymbols) { case MacroFlagType.PRESERVE_WHITESPACE: flags.preserveWhitespace = true; break; - case MacroFlagType.VAR_DOT: - flags.varDot = true; - break; - case MacroFlagType.VAR_DOLLAR: - flags.varDollar = true; - break; default: console.warn(`Can't parse unknown macro flag: ${symbol}`); } diff --git a/public/scripts/macros/engine/MacroLexer.js b/public/scripts/macros/engine/MacroLexer.js index becd25525..b9e41ffba 100644 --- a/public/scripts/macros/engine/MacroLexer.js +++ b/public/scripts/macros/engine/MacroLexer.js @@ -16,28 +16,42 @@ const IDENTIFIER_LEXER_PATTERN = /[a-zA-Z][\w-_]*/; */ export const MACRO_IDENTIFIER_PATTERN = /^[a-zA-Z][\w-_]*$/; +/** + * Pattern for valid variable shorthand identifiers. + * Must start with a letter, followed by word chars (letters, digits, underscore) or hyphens, + * but must end with a word character (not a hyphen). + * + * Used for variable shorthand syntax like .varName or $varName. + */ +export const MACRO_VARIABLE_SHORTHAND_PATTERN = /[a-zA-Z](?:[\w\-_]*[\w])?/; + /** @enum {string} */ -const modes = { +const modes = Object.freeze({ plaintext: 'plaintext_mode', macro_def: 'macro_def_mode', macro_identifier_end: 'macro_identifier_end_mode', macro_args: 'macro_args_mode', macro_filter_modifer: 'macro_filter_modifer_mode', macro_filter_modifier_end: 'macro_filter_modifier_end_mode', -}; + // Variable shorthand modes + var_identifier: 'var_identifier_mode', + var_after_identifier: 'var_after_identifier_mode', + var_value: 'var_value_mode', +}); -/** @readonly */ -const Tokens = { - // General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'. +/** + * All lexer tokens used by the macro parser. + * @readonly + */ +const Tokens = Object.freeze({ +/** General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'. */ Plaintext: createToken({ name: 'Plaintext', pattern: /(?:[^{]|\{(?!\{))+/u, line_breaks: true }), - // Single literal '{' that appears immediately before a macro opener '{{'. + /** Single literal '{' that appears immediately before a macro opener '{{' */ PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }), - // General macro capture + /** General macro capture */ Macro: { Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }), - // Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name" - // We need this, because this token is going to switch lexer mode, while the general identifier does not. /** * Macro execution flags - special symbols that modify macro resolution behavior. * - `!` = immediate resolve (TBD) @@ -45,24 +59,26 @@ const Tokens = { * - `~` = re-evaluate (TBD) * - `/` = closing block marker for scoped macros * - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility - * - `.` = variable shorthand (TBD) - * - `$` = variable shorthand alternative (TBD) */ - Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/.$]/ }), + Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/]/ }), /** * Filter flag (`>`) - separate token because it changes parsing behavior. * When present, `|` characters inside the macro are treated as filter/pipe operators. */ FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }), DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }), + /** + * Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name" + * We need this, because this token is going to switch lexer mode, while the general identifier does not. + */ Identifier: createToken({ name: 'Macro.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }), - // 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 + /** 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 */ EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }), BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }), End: createToken({ name: 'Macro.End', pattern: /\}\}/ }), }, - // Captures that only appear inside arguments + /** Captures that only appear inside arguments */ Args: { DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }), Colon: createToken({ name: 'Args.Colon', pattern: /:/ }), @@ -74,7 +90,7 @@ const Tokens = { EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }), Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }), Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }), - // 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 + /** 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 */ EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }), }, @@ -82,22 +98,54 @@ const Tokens = { Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }), WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }), - // Capture unknown characters one by one, to still allow other tokens being matched once they are there. - // This includes any possible braces that is not the double closing braces as MacroEnd. + /** Variable shorthand tokens */ + Var: { + /** Local variable prefix (`.`) - triggers variable shorthand for local variables */ + LocalPrefix: createToken({ name: 'Var.LocalPrefix', pattern: /\./ }), + /** Global variable prefix (`$`) - triggers variable shorthand for global variables */ + GlobalPrefix: createToken({ name: 'Var.GlobalPrefix', pattern: /\$/ }), + /** + * Variable identifier - allows hyphens inside but not at the end to avoid conflict with -- operator. + * Pattern: starts with letter, optionally followed by word chars/hyphens, but must end with word char. + * Examples: myVar, my-var, my_var, myVar123, my-long-var-name + * Invalid: my-, my--, -var + */ + Identifier: createToken({ name: 'Var.Identifier', pattern: MACRO_VARIABLE_SHORTHAND_PATTERN }), + /** Increment operator (`++`) */ + Increment: createToken({ name: 'Var.Increment', pattern: /\+\+/ }), + /** Decrement operator (`--`) */ + Decrement: createToken({ name: 'Var.Decrement', pattern: /--/ }), + /** Add/append operator (`+=`) - must come before Equals to avoid conflict */ + PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }), + /** Set operator (`=`) */ + Equals: createToken({ name: 'Var.Equals', pattern: /=/ }), + }, + + /** + * Capture unknown characters one by one, to still allow other tokens being matched once they are there. + * This includes any possible braces that is not the double closing braces as MacroEnd. + */ Unknown: createToken({ name: 'Unknown', pattern: /([^}]|\}(?!\}))/ }), - // 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. + /** 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. */ Text: createToken({ name: 'Text', pattern: /.+(?=\}\}|\{\{)/, line_breaks: true }), - // DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches. - // Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc. - // Has to ALWAYS be the last token. + /** + * DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches. + * Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc. + * Has to ALWAYS be the last token. + */ ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }), -}; +}); /** @type {Map} Saves all token definitions that are marked as entering modes */ const enterModesMap = new Map(); +/** + * Lexer definition object that maps states/modes to their token rules. + * Each mode defines which tokens are valid in that context and how to transition between modes. + * @readonly + */ const Def = { modes: { [modes.plaintext]: [ @@ -111,6 +159,11 @@ const Def = { // An explicit double-slash will be treated above flags to consume, as it'll introduce a comment macro. Directly following is the args then. enter(Tokens.Macro.DoubleSlash, modes.macro_args), + // Variable shorthand prefixes - must come before flags to take precedence + // These enter the variable identifier mode to parse variable expressions + enter(Tokens.Var.LocalPrefix, modes.var_identifier), + enter(Tokens.Var.GlobalPrefix, modes.var_identifier), + using(Tokens.Macro.Flags), // Filter flag is separate because it affects parsing behavior for pipes using(Tokens.Macro.FilterFlag), @@ -165,6 +218,40 @@ const Def = { exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end), exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer), ], + + // After seeing `.` or `$`, expect a variable identifier + [modes.var_identifier]: [ + using(Tokens.WhiteSpace), + // Consume the variable identifier and move to operator detection + enter(Tokens.Var.Identifier, modes.var_after_identifier, { andExits: modes.var_identifier }), + // If no valid identifier found, exit back (will result in parser error) + exits(Tokens.ModePopper, modes.var_identifier), + ], + // After the variable identifier, look for operators or end + [modes.var_after_identifier]: [ + using(Tokens.WhiteSpace), + // Check for operators + using(Tokens.Var.Increment), + using(Tokens.Var.Decrement), + enter(Tokens.Var.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }), + enter(Tokens.Var.Equals, modes.var_value, { andExits: modes.var_after_identifier }), + // If we see the end, exit + exits(Tokens.Macro.BeforeEnd, modes.var_after_identifier), + // Fallback exit + exits(Tokens.ModePopper, modes.var_after_identifier), + ], + // After `=` or `+=`, capture the value (can contain nested macros) + [modes.var_value]: [ + // Nested macros in value + enter(Tokens.Macro.Start, modes.macro_def), + + using(Tokens.Identifier), + using(Tokens.WhiteSpace), + using(Tokens.Unknown), + + // Exit when we're about to see the end + exits(Tokens.ModePopper, modes.var_value), + ], }, defaultMode: modes.plaintext, }; diff --git a/public/scripts/macros/engine/MacroParser.js b/public/scripts/macros/engine/MacroParser.js index 4b1fa94dc..7653da322 100644 --- a/public/scripts/macros/engine/MacroParser.js +++ b/public/scripts/macros/engine/MacroParser.js @@ -43,7 +43,7 @@ class MacroParser extends CstParser { }); }); - // Basic Macro Structure + // Basic Macro Structure - can be either a regular macro or a variable expression $.macro = $.RULE('macro', () => { $.CONSUME(Tokens.Macro.Start); @@ -56,13 +56,72 @@ class MacroParser extends CstParser { ]); }); + // Branch: either a variable expression (starts with . or $) or a regular macro + $.OR([ + // Variable expression branch + { ALT: () => $.SUBRULE($.variableExpr) }, + // Regular macro branch + { ALT: () => $.SUBRULE($.macroBody) }, + ]); + + $.CONSUME(Tokens.Macro.End); + }); + + // Regular macro body (flags + identifier + optional arguments) + $.macroBody = $.RULE('macroBody', () => { // Macro identifier (name) $.OR2([ { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) }, { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) }, ]); $.OPTION(() => $.SUBRULE($.arguments)); - $.CONSUME(Tokens.Macro.End); + }); + + // Variable expression: .varName or $varName with optional operator + $.variableExpr = $.RULE('variableExpr', () => { + // Variable scope prefix + $.OR3([ + { ALT: () => $.CONSUME(Tokens.Var.LocalPrefix, { LABEL: 'Var.scope' }) }, + { ALT: () => $.CONSUME(Tokens.Var.GlobalPrefix, { LABEL: 'Var.scope' }) }, + ]); + + // Variable identifier (name) + $.CONSUME(Tokens.Var.Identifier, { LABEL: 'Var.identifier' }); + + // Optional operator + $.OPTION2(() => $.SUBRULE($.variableOperator)); + }); + + // Variable operator: ++, --, = value, += value + $.variableOperator = $.RULE('variableOperator', () => { + $.OR4([ + { ALT: () => $.CONSUME(Tokens.Var.Increment, { LABEL: 'Var.operator' }) }, + { ALT: () => $.CONSUME(Tokens.Var.Decrement, { LABEL: 'Var.operator' }) }, + { + ALT: () => { + $.CONSUME(Tokens.Var.Equals, { LABEL: 'Var.operator' }); + $.SUBRULE($.variableValue, { LABEL: 'Var.value' }); + }, + }, + { + ALT: () => { + $.CONSUME(Tokens.Var.PlusEquals, { LABEL: 'Var.operator' }); + $.SUBRULE2($.variableValue, { LABEL: 'Var.value' }); + }, + }, + ]); + }); + + // Variable value: everything after = or += until the end + // Can contain nested macros and any other tokens + $.variableValue = $.RULE('variableValue', () => { + $.MANY2(() => { + $.OR5([ + { ALT: () => $.SUBRULE($.macro) }, // Nested macros + { ALT: () => $.CONSUME(Tokens.Identifier) }, + { ALT: () => $.CONSUME(Tokens.Unknown) }, + ]); + }); }); // Arguments Parsing diff --git a/public/scripts/slash-commands/SlashCommandParser.js b/public/scripts/slash-commands/SlashCommandParser.js index dbf786eae..c637f057a 100644 --- a/public/scripts/slash-commands/SlashCommandParser.js +++ b/public/scripts/slash-commands/SlashCommandParser.js @@ -15,7 +15,19 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js'; import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js'; import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js'; import { SlashCommandEnumValue } from './SlashCommandEnumValue.js'; -import { EnhancedMacroAutoCompleteOption, MacroFlagAutoCompleteOption, MacroClosingTagAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js'; +import { + EnhancedMacroAutoCompleteOption, + MacroFlagAutoCompleteOption, + MacroClosingTagAutoCompleteOption, + VariableShorthandAutoCompleteOption, + VariableShorthandDefinitions, + VariableNameAutoCompleteOption, + VariableOperatorAutoCompleteOption, + VariableOperatorDefinitions, + isValidVariableShorthandName, + parseMacroContext, + SimpleAutoCompleteOption, +} from '../autocomplete/EnhancedMacroAutoCompleteOption.js'; import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js'; import { MacroParser } from '../macros/engine/MacroParser.js'; import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js'; @@ -25,6 +37,8 @@ import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js'; import { SlashCommandBreak } from './SlashCommandBreak.js'; import { macros as macroSystem } from '../macros/macro-system.js'; import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js'; +import { chat_metadata } from '/script.js'; +import { extension_settings } from '../extensions.js'; /** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */ /** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */ @@ -566,29 +580,138 @@ export class SlashCommandParser { } else { conditionStartOffset = context.identifierStart + identifier.length; } - const conditionStartInText = macro.start + 2 + conditionStartOffset; + let conditionStartInText = macro.start + 2 + conditionStartOffset; // Build if-condition options using macroContent for padding calculation const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true }); const options = this.#buildIfConditionOptions(context, allMacros, macroContent); + // For variable shorthand in {{if}} condition, adjust identifier and start position + // Same fix as for regular variable shorthands - identifier must be just the var name + // Also handle ! inversion prefix: !.var or !$var or !macroName + const trimmedCondition = conditionText.trim(); + const hasInversion = trimmedCondition.startsWith('!'); + // Trim whitespace after ! to handle "! $myvar" syntax + const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition; + const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$'); + let resultIdentifier = conditionText; + let resultStart = conditionStartInText; + + if (isTypingVarShorthand) { + // Identifier = just the variable name part (without prefix and without !) + resultIdentifier = conditionAfterInversion.slice(1); + // Start = after the ! (if any) and the prefix + const prefixChar = conditionAfterInversion[0]; + const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0); + resultStart = conditionStartInText + prefixPosInCondition + 1; + } else if (hasInversion && conditionAfterInversion.length === 0) { + // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match + resultIdentifier = ''; + // Start at end of actual condition text (including any whitespace after !) + // This ensures cursor is within the name range for filtering + resultStart = conditionStartInText + conditionText.length; + } else if (hasInversion && conditionAfterInversion.length > 0) { + // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name + resultIdentifier = conditionAfterInversion; + // Start = after the ! and any whitespace, at the beginning of the macro name + const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion); + resultStart = conditionStartInText + macroNameStart; + } + const result = new AutoCompleteNameResult( - conditionText, - conditionStartInText, + resultIdentifier, + resultStart, options, false, - () => 'Use {{macro}} syntax for dynamic conditions', - () => 'Enter a macro name or {{macro}} for the condition', + () => isTypingVarShorthand + ? 'Enter a variable name for the condition' + : 'Use {{macro}} syntax for dynamic conditions', + () => isTypingVarShorthand + ? 'Enter a variable name or select from the list' + : 'Enter a macro name or {{macro}} for the condition', ); return result; } + /** @type {()=>string|undefined} */ + let makeNoMatchText = undefined; + /** @type {()=>string|undefined} */ + let makeNoOptionsText = undefined; + const options = this.#buildEnhancedMacroOptions(context, textUpToCursor); + + // For variable shorthands, calculate the correct identifier and start position + // based on what the user is currently typing (variable name, operator, or value) + let resultIdentifier = identifier; + let resultStart = identifierStartInText; + if (context.isVariableShorthand && context.variablePrefix) { + // Find where the prefix is in the macro content + const prefixIndex = macroContent.indexOf(context.variablePrefix); + + if (context.isTypingVariableName) { + // Typing variable name: identifier = variableName, start = after prefix + resultIdentifier = context.variableName; + if (prefixIndex >= 0) { + resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix + } + } else if (context.isTypingOperator) { + // Typing operator: identifier = partial operator text (if any), start = after variable name + // Using partial operator as identifier ensures cursor is within name range for filtering + resultIdentifier = context.partialOperator || ''; + if (prefixIndex >= 0) { + // Start after prefix + variable name length + resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length; + // If no partial operator (just whitespace after var name), set start to cursor + // This ensures cursor is in the name range for filtering + if (!context.partialOperator) { + resultStart = index; + } + } + } else if (context.isOperatorComplete) { + // Operator complete (++ or --) - show context but no value input needed + resultIdentifier = ''; + resultStart = index; // Cursor at end + } else if (context.hasInvalidTrailingChars) { + // Invalid chars after variable name: show the invalid chars for warning + resultIdentifier = context.invalidTrailingChars || ''; + if (prefixIndex >= 0) { + resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length; + } + } else if (context.isTypingValue) { + // Typing value: identifier = value being typed, start = after operator + resultIdentifier = context.variableValue; + if (prefixIndex >= 0) { + const operatorLen = context.variableOperator?.length ?? 0; + resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length + operatorLen; + // Skip any whitespace between operator and value + while (resultStart < index && /\s/.test(text[resultStart])) { + resultStart++; + } + + makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`; + makeNoOptionsText = () => 'Enter a variable value'; + } + } else { + // Fallback: use variable name + resultIdentifier = context.variableName; + if (prefixIndex >= 0) { + resultStart = macro.start + 2 + prefixIndex + 1; + } + } + + if (!makeNoMatchText && !makeNoOptionsText) { + makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.'; + makeNoOptionsText = () => 'Enter a variable name to create or use a new variable'; + } + } + const result = new AutoCompleteNameResult( - identifier, - identifierStartInText, + resultIdentifier, + resultStart, options, false, + makeNoMatchText, + makeNoOptionsText, ); return result; } @@ -669,12 +792,17 @@ export class SlashCommandParser { * When typing arguments (after ::), prioritizes the exact macro match. * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection. - * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} + * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */ #buildEnhancedMacroOptions(context, textUpToCursor = '') { - /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} */ + /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */ const options = []; + // Handle variable shorthand mode + if (context.isVariableShorthand) { + return this.#buildVariableShorthandOptions(context); + } + // Check for unclosed scoped macros and suggest closing tags first const unclosedScopes = this.#findUnclosedScopes(textUpToCursor); if (unclosedScopes.length > 0) { @@ -685,11 +813,9 @@ export class SlashCommandParser { // If inside a scoped {{if}}, also suggest {{else}} if (innermostScope.name === 'if') { - // TODO: TEsting const macroDef = macroSystem.registry.getPrimaryMacro('else'); const elseOption = new EnhancedMacroAutoCompleteOption(macroDef); elseOption.sortPriority = 2; - // const elseOption = new MacroElseAutoCompleteOption(); options.push(elseOption); } } @@ -732,6 +858,14 @@ export class SlashCommandParser { flagOption.sortPriority = isSelectable ? 10 : 12; options.push(flagOption); } + + // Add variable shorthand prefix options (. for local, $ for global) + // These allow users to type variable shorthands instead of macro names + for (const [, varShorthandDef] of VariableShorthandDefinitions) { + const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef); + varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12) + options.push(varOption); + } } // Get all macros from the registry (excluding hidden aliases) @@ -780,6 +914,187 @@ export class SlashCommandParser { return options; } + /** + * Builds autocomplete options for variable shorthand syntax (.varName or $varName). + * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context + * @param {Object} [opts] - Optional configuration. + * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}). + * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}. + * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} + */ + #buildVariableShorthandOptions(context, opts = {}) { + const { forIfCondition = false, paddingAfter = '' } = opts; + /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */ + const options = []; + + const isLocal = context.variablePrefix === '.'; + const scope = isLocal ? 'local' : 'global'; + + // Always show the typed variable prefix as a non-completable option (like flags do) + // This allows the details panel to show information about the prefix + const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix); + if (prefixDef) { + const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef); + prefixOption.valueProvider = () => ''; // Already typed, don't re-insert + prefixOption.makeSelectable = false; + prefixOption.sortPriority = 1; // Show at top + options.push(prefixOption); + } + + // If typing the variable name, suggest existing variables + if (context.isTypingVariableName) { + // Get existing variable names from the appropriate scope + // Filter to only include names that are valid for shorthand syntax + const existingVariables = this.#getVariableNames(scope) + .filter(name => isValidVariableShorthandName(name)); + + // Add existing variables that match the typed name + for (const varName of existingVariables) { + const option = new VariableNameAutoCompleteOption(varName, scope, false); + // For {{if}} condition, provide full value with closing braces + if (forIfCondition) { + option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already. + option.makeSelectable = true; + } + // Variables matching the typed prefix get higher priority + if (varName.startsWith(context.variableName)) { + option.sortPriority = 3; + } else { + option.sortPriority = 10; + } + options.push(option); + } + + // If typing a name that doesn't exist, offer to create a new variable + // But if the name is invalid for shorthand syntax, show a warning instead + if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) { + const isInvalid = !isValidVariableShorthandName(context.variableName); + const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid); + newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning + if (isInvalid) { + // Make it non-selectable since it can't be used + newVarOption.valueProvider = () => ''; + newVarOption.makeSelectable = false; + } else if (forIfCondition) { + // For {{if}} condition, provide full value with closing braces + newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`; + } + options.push(newVarOption); + } + } + + // If there are invalid trailing characters after the variable name, show a warning + if (context.hasInvalidTrailingChars) { + // Show the full invalid name (variableName + invalidTrailingChars) with a warning + const fullInvalidName = context.variableName + (context.invalidTrailingChars || ''); + const invalidOption = new VariableNameAutoCompleteOption( + fullInvalidName, + scope, + false, + true, // isInvalidName - triggers warning display + ); + invalidOption.valueProvider = () => ''; // Don't insert anything + invalidOption.makeSelectable = false; + invalidOption.sortPriority = 2; + invalidOption.matchProvider = () => true; // Always show + options.push(invalidOption); + // Return early - don't show operators when syntax is invalid + return options; + } + + // If ready for operator (after variable name), suggest operators + if (context.isTypingOperator) { + // Show the current variable name as context (already typed) + const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); + varNameOption.valueProvider = () => ''; // Already typed, don't re-insert + varNameOption.sortPriority = 2; + varNameOption.matchProvider = () => true; // Always show + options.push(varNameOption); + + // Then show available operators, filtered by partial prefix if any + const partialOp = context.partialOperator || ''; + for (const [, operatorDef] of VariableOperatorDefinitions) { + // Filter by partial operator prefix if user is typing one + if (partialOp && !operatorDef.symbol.startsWith(partialOp)) { + continue; + } + const opOption = new VariableOperatorAutoCompleteOption(operatorDef); + opOption.sortPriority = 5; + // Always match operators when showing operator suggestions + opOption.matchProvider = () => true; + options.push(opOption); + } + } + + // If typing value (after = or +=), no autocomplete needed - freeform text + // But we can show the current context for reference + if (context.isTypingValue) { + // Show the current variable name as context + const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); + varNameOption.valueProvider = () => ''; // Context only + varNameOption.sortPriority = 2; + varNameOption.matchProvider = () => true; // Always show + options.push(varNameOption); + + // Show the operator that was used + if (context.variableOperator) { + const opDef = VariableOperatorDefinitions.get(context.variableOperator); + if (opDef) { + const opOption = new VariableOperatorAutoCompleteOption(opDef); + opOption.valueProvider = () => ''; // Already typed + opOption.sortPriority = 3; + opOption.matchProvider = () => true; // Always show + options.push(opOption); + } + } + } + + // If operator is complete (++ or --), show context without value input + if (context.isOperatorComplete) { + // Show the current variable name as context + const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); + varNameOption.valueProvider = () => ''; // Context only + varNameOption.sortPriority = 2; + varNameOption.matchProvider = () => true; // Always show + options.push(varNameOption); + + // Show the operator that was used + if (context.variableOperator) { + const opDef = VariableOperatorDefinitions.get(context.variableOperator); + if (opDef) { + const opOption = new VariableOperatorAutoCompleteOption(opDef); + opOption.valueProvider = () => ''; // Already typed + opOption.sortPriority = 3; + opOption.matchProvider = () => true; // Always show + options.push(opOption); + } + } + } + + return options; + } + + /** + * Gets variable names from the specified scope. + * @param {'local'|'global'} scope - The variable scope. + * @returns {string[]} Array of variable names. + */ + #getVariableNames(scope) { + try { + // Import chat_metadata and extension_settings dynamically to avoid circular deps + // These are the same sources used by commonEnumProviders.variables + if (scope === 'local') { + // Local variables are in chat_metadata.variables + return Object.keys(chat_metadata?.variables ?? {}); + } else { + // Global variables are in extension_settings.variables.global + return Object.keys(extension_settings?.variables?.global ?? {}); + } + } catch { + return []; + } + } + /** * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand. * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context @@ -796,6 +1111,87 @@ export class SlashCommandParser { const leadingMatch = macroInnerText.match(/^(\s*)/); const paddingAfter = leadingMatch ? leadingMatch[1] : ''; + // Get the condition text being typed (trimmed for detection) + const conditionText = (context.args[0] || '').trim(); + + // Check for inversion prefix (!) - also trim whitespace after ! + const hasInversionPrefix = conditionText.startsWith('!'); + const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText; + + const inversionOption = new SimpleAutoCompleteOption({ + name: '!', + symbol: '🔁', + description: 'Invert condition (NOT)', + detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.

Example: {{if !myVar}} executes when myVar is empty or zero.', + type: 'inverse', + }); + + // Check if condition starts with a variable shorthand prefix (with or without !) + const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$'); + + if (isTypingVariableShorthand) { + // User is typing a variable shorthand - reuse #buildVariableShorthandOptions + const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]); + const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix + + // If inverted, show the ! as non-selectable context + if (hasInversionPrefix) { + inversionOption.valueProvider = () => ''; // Already typed + inversionOption.makeSelectable = false; + inversionOption.sortPriority = 0; + options.push(inversionOption); + } + + // Create a synthetic context for #buildVariableShorthandOptions + /** @type {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} */ + const varContext = { + ...context, + isVariableShorthand: true, + variablePrefix: prefix, + variableName: varNameTyped, + isTypingVariableName: true, + isTypingOperator: false, + isTypingValue: false, + isOperatorComplete: false, + hasInvalidTrailingChars: false, + variableOperator: null, + variableValue: '', + }; + + const varOptions = this.#buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter }); + options.push(...varOptions); + return options; + } + + // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion + + // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed + if (conditionText.length === 0) { + // Nothing typed - offer ! as selectable option + inversionOption.valueProvider = () => '!'; + inversionOption.makeSelectable = true; + inversionOption.sortPriority = -1; // Show at very top + options.push(inversionOption); + } else if (hasInversionPrefix && conditionAfterInversion.length === 0) { + // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes + inversionOption.valueProvider = () => ''; // Already typed + inversionOption.makeSelectable = false; + inversionOption.sortPriority = -1; + options.push(inversionOption); + } + + // Add variable shorthand prefix options when no content typed yet (or just ! typed) + if (conditionAfterInversion.length === 0) { + for (const [, prefixDef] of VariableShorthandDefinitions) { + const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef); + // Complete with just the prefix symbol + prefixOption.valueProvider = () => prefixDef.type; + prefixOption.makeSelectable = true; + prefixOption.sortPriority = 0; // Show at top + options.push(prefixOption); + } + } + // Add zero-arg macros as condition shorthand options for (const macro of allMacros) { // Only include macros that require zero arguments (can be auto-resolved) diff --git a/tests/.eslintrc.cjs b/tests/.eslintrc.cjs index 52d38b0a4..d391f79f6 100644 --- a/tests/.eslintrc.cjs +++ b/tests/.eslintrc.cjs @@ -25,6 +25,7 @@ module.exports = { 'node_modules/**/*', ], globals: { + SillyTavern: 'readonly', }, rules: { 'no-unused-vars': ['error', { args: 'none' }], diff --git a/tests/frontend/MacroEngine.e2e.js b/tests/frontend/MacroEngine.e2e.js index db5081a56..b4743d618 100644 --- a/tests/frontend/MacroEngine.e2e.js +++ b/tests/frontend/MacroEngine.e2e.js @@ -1765,6 +1765,165 @@ test.describe('MacroEngine', () => { expect(output).toBe('Message (by EnvChar)'); }); }); + + test.describe('Variable Shorthand Syntax', () => { + // {{.myvar}} - get local variable + test('should get local variable with . shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.myvar}}', { local: { myvar: 'hello' } }); + expect(output).toBe('hello'); + }); + + // {{$myvar}} - get global variable + test('should get global variable with $ shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{$myvar}}', { global: { myvar: 'world' } }); + expect(output).toBe('world'); + }); + + // {{.myvar = value}} - set local variable (setvar returns empty string) + test('should set local variable with = shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.myvar = test}}Value: {{.myvar}}', { local: {} }); + // setvar returns '', then "Value: ", then getvar returns "test" + expect(output).toBe('Value: test'); + }); + + // {{.counter++}} - increment local variable (incvar returns new value) + test('should increment local variable with ++ shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.counter++}}', { local: { counter: '5' } }); + expect(output).toBe('6'); + }); + + // {{$counter--}} - decrement global variable (decvar returns new value) + test('should decrement global variable with -- shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{$counter--}}', { global: { counter: '10' } }); + expect(output).toBe('9'); + }); + + // {{.myvar += 5}} - add to local variable (addvar returns empty string) + test('should add to local variable with += shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.myvar += 3}}Then: {{.myvar}}', { local: { myvar: '7' } }); + // addvar returns '', then "Then: ", then getvar returns "10" + expect(output).toBe('Then: 10'); + }); + + // Nested macro in value: {{.myvar = {{user}}}} + test('should support nested macro in variable value', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.greeting = Hello {{user}}}}{{.greeting}}', { local: {} }); + // setvar returns '', then getvar returns "Hello User" + expect(output).toBe('Hello User'); + }); + + // Whitespace handling: {{ .myvar = value }} + test('should handle whitespace in variable shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{ .myvar = spaced }}{{.myvar}}', { local: {} }); + // setvar returns '', then getvar returns "spaced" + expect(output).toBe('spaced'); + }); + + // Variable with hyphen in name: {{.my-var}} + test('should handle variable name with hyphens', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.my-var}}', { local: { 'my-var': 'hyphenated' } }); + expect(output).toBe('hyphenated'); + }); + + // Variable with underscore: {{.my_var}} + test('should handle variable name with underscores', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.my_var}}', { local: { 'my_var': 'underscored' } }); + expect(output).toBe('underscored'); + }); + + // Non-existent variable returns empty string + test('should return empty string for non-existent variable', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, 'Value:[{{.nonexistent}}]', { local: {} }); + expect(output).toBe('Value:[]'); + }); + + // Increment non-existent variable (should start from 0) + test('should increment non-existent variable starting from 0', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.newcounter++}}', { local: {} }); + expect(output).toBe('1'); + }); + + // Chain multiple operations + test('should handle multiple variable operations in sequence', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.x = 5}}{{.x++}}{{.x += 10}}{{.x}}', { local: {} }); + // setvar returns '', incvar returns '6', addvar returns '', getvar returns '16' + expect(output).toBe('616'); + }); + }); + + test.describe('Variable Shorthand in {{if}} Macro', () => { + // {{if .myvar}}...{{/if}} - truthy local variable + test('should evaluate truthy local variable in if condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '1' } }); + expect(output).toBe('Yes'); + }); + + // {{if .myvar}}...{{/if}} - falsy local variable + test('should evaluate falsy local variable in if condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '' } }); + expect(output).toBe(''); + }); + + // {{if $globalvar}}...{{/if}} - truthy global variable + test('should evaluate truthy global variable in if condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if $enabled}}Active{{/if}}', { global: { enabled: 'true' } }); + expect(output).toBe('Active'); + }); + + // {{if !.myvar}}...{{/if}} - inverted condition + test('should evaluate inverted variable condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if !.flag}}Not set{{/if}}', { local: { flag: '' } }); + expect(output).toBe('Not set'); + }); + + // {{if !$globalvar}}...{{/if}} - inverted global + test('should evaluate inverted global variable condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if !$disabled}}Enabled{{/if}}', { global: { disabled: '' } }); + expect(output).toBe('Enabled'); + }); + + // {{if ! .myvar}}...{{/if}} - inverted with whitespace + test('should evaluate inverted condition with whitespace after !', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if ! .empty}}Empty{{/if}}', { local: { empty: '' } }); + expect(output).toBe('Empty'); + }); + + // Non-existent variable is falsy + test('should treat non-existent variable as falsy in if condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if .nonexistent}}Yes{{else}}No{{/if}}', { local: {} }); + expect(output).toBe('No'); + }); + + // {{if .myvar}}...{{else}}...{{/if}} - with else branch + test('should handle else branch with variable shorthand', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if .active}}On{{else}}Off{{/if}}', { local: { active: 'yes' } }); + expect(output).toBe('On'); + }); + + // Variable with hyphen in if condition + test('should handle variable with hyphen in if condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if .is-valid}}Valid{{/if}}', { local: { 'is-valid': '1' } }); + expect(output).toBe('Valid'); + }); + + // Combine set and if + test('should work with variable set before if check', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{.ready = yes}}{{if .ready}}Ready!{{/if}}', { local: {} }); + expect(output).toBe('Ready!'); + }); + + // Zero is falsy + test('should treat zero as falsy in if condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Has count{{else}}No count{{/if}}', { local: { count: '0' } }); + expect(output).toBe('No count'); + }); + + // Non-zero number is truthy + test('should treat non-zero number as truthy in if condition', async ({ page }) => { + const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Count: {{.count}}{{/if}}', { local: { count: '42' } }); + expect(output).toBe('Count: 42'); + }); + }); }); /** @@ -1833,3 +1992,51 @@ async function evaluateWithEngineAndCaptureMacroLogs(page, input) { page.off('console', handler); } } + +/** + * Evaluates the given input string with pre-set variables. + * Variables are set via SillyTavern.getContext().variables which is where + * the variable macros read/write their data. + * + * @param {import('@playwright/test').Page} page + * @param {string} input + * @param {{ local?: Record, global?: Record }} variables + * @returns {Promise} + */ +async function evaluateWithEngineAndVariables(page, input, variables) { + const result = await page.evaluate(async ({ input, variables }) => { + /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */ + const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js'); + /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */ + const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js'); + + // Get the SillyTavern context for variable access + const ctx = SillyTavern.getContext(); + + // Pre-set local variables + if (variables.local) { + for (const [key, value] of Object.entries(variables.local)) { + ctx.variables.local.set(key, value); + } + } + // Pre-set global variables + if (variables.global) { + for (const [key, value] of Object.entries(variables.global)) { + ctx.variables.global.set(key, value); + } + } + + /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */ + const rawEnv = { + content: input, + name1Override: 'User', + name2Override: 'Character', + }; + const env = MacroEnvBuilder.buildFromRawEnv(rawEnv); + + const output = await MacroEngine.evaluate(input, env); + return output; + }, { input, variables }); + + return result; +} diff --git a/tests/frontend/MacroLexer.e2e.js b/tests/frontend/MacroLexer.e2e.js index e3004813b..e08ac8e4a 100644 --- a/tests/frontend/MacroLexer.e2e.js +++ b/tests/frontend/MacroLexer.e2e.js @@ -567,34 +567,6 @@ test.describe('MacroLexer', () => { expect(tokens).toEqual(expectedTokens); }); - // {{.variable}} - test('should support . flag', async ({ page }) => { - const input = '{{.variable}}'; - const tokens = await runLexerGetTokens(page, input); - - const expectedTokens = [ - { type: 'Macro.Start', text: '{{' }, - { type: 'Macro.Flag', text: '.' }, - { type: 'Macro.Identifier', text: 'variable' }, - { type: 'Macro.End', text: '}}' }, - ]; - - expect(tokens).toEqual(expectedTokens); - }); - // {{$variable}} - test('should support alias $ flag', async ({ page }) => { - const input = '{{$variable}}'; - const tokens = await runLexerGetTokens(page, input); - - const expectedTokens = [ - { type: 'Macro.Start', text: '{{' }, - { type: 'Macro.Flag', text: '$' }, - { type: 'Macro.Identifier', text: 'variable' }, - { type: 'Macro.End', text: '}}' }, - ]; - - expect(tokens).toEqual(expectedTokens); - }); // {{#legacy}} test('should support legacy # flag', async ({ page }) => { const input = '{{#legacy}}'; @@ -640,13 +612,13 @@ test.describe('MacroLexer', () => { }); // {{ ! .importantvariable }} test('should support multiple flags with whitespace', async ({ page }) => { - const input = '{{ !.importantvariable }}'; + const input = '{{ !#importantvariable }}'; const tokens = await runLexerGetTokens(page, input); const expectedTokens = [ { type: 'Macro.Start', text: '{{' }, { type: 'Macro.Flag', text: '!' }, - { type: 'Macro.Flag', text: '.' }, + { type: 'Macro.Flag', text: '#' }, { type: 'Macro.Identifier', text: 'importantvariable' }, { type: 'Macro.End', text: '}}' }, ]; @@ -733,6 +705,232 @@ test.describe('MacroLexer', () => { }); }); + test.describe('Variable Shorthand Syntax', () => { + // {{.variable}} - Local variable get + test('should tokenize local variable shorthand', async ({ page }) => { + const input = '{{.myvar}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'myvar' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{$variable}} - Global variable get + test('should tokenize global variable shorthand', async ({ page }) => { + const input = '{{$myvar}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.GlobalPrefix', text: '$' }, + { type: 'Var.Identifier', text: 'myvar' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.my-var}} - Variable with hyphen in name + test('should tokenize variable with hyphen in name', async ({ page }) => { + const input = '{{.my-var}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'my-var' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.counter++}} - Increment operator + test('should tokenize increment operator', async ({ page }) => { + const input = '{{.counter++}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'counter' }, + { type: 'Var.Increment', text: '++' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{$counter--}} - Decrement operator + test('should tokenize decrement operator', async ({ page }) => { + const input = '{{$counter--}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.GlobalPrefix', text: '$' }, + { type: 'Var.Identifier', text: 'counter' }, + { type: 'Var.Decrement', text: '--' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.myvar = value}} - Set operator + test('should tokenize set operator with value', async ({ page }) => { + const input = '{{.myvar = hello}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'myvar' }, + { type: 'Var.Equals', text: '=' }, + { type: 'Identifier', text: 'hello' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.myvar += 5}} - Add operator + test('should tokenize add operator with value', async ({ page }) => { + const input = '{{.myvar += 5}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'myvar' }, + { type: 'Var.PlusEquals', text: '+=' }, + { type: 'Unknown', text: '5' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{ !.importantvariable }} - Variable prefix after flags + test('should tokenize variable prefix after flags', async ({ page }) => { + const input = '{{ !.importantvariable }}'; + const tokens = await runLexerGetTokens(page, input); + + // When . is encountered, it triggers variable mode regardless of previous flags + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Macro.Flag', text: '!' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'importantvariable' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.my-long-var-name}} - Variable with multiple hyphens + test('should tokenize variable with multiple hyphens', async ({ page }) => { + const input = '{{.my-long-var-name}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'my-long-var-name' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.myvar = Hello {{user}}}} - Nested macro in value + test('should tokenize nested macro in variable value', async ({ page }) => { + const input = '{{.myvar = Hello {{user}}}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'myvar' }, + { type: 'Var.Equals', text: '=' }, + { type: 'Identifier', text: 'Hello' }, + { type: 'Macro.Start', text: '{{' }, + { type: 'Macro.Identifier', text: 'user' }, + { type: 'Macro.End', text: '}}' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{ .myvar }} - Whitespace around variable shorthand + test('should tokenize variable shorthand with surrounding whitespace', async ({ page }) => { + const input = '{{ .myvar }}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'myvar' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{$myVar123}} - Variable with numbers + test('should tokenize variable with numbers in name', async ({ page }) => { + const input = '{{$myVar123}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.GlobalPrefix', text: '$' }, + { type: 'Var.Identifier', text: 'myVar123' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.my_var}} - Variable with underscore + test('should tokenize variable with underscore in name', async ({ page }) => { + const input = '{{.my_var}}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'my_var' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + + // {{.counter ++ }} - Increment with whitespace + test('should tokenize increment operator with surrounding whitespace', async ({ page }) => { + const input = '{{.counter ++ }}'; + const tokens = await runLexerGetTokens(page, input); + + const expectedTokens = [ + { type: 'Macro.Start', text: '{{' }, + { type: 'Var.LocalPrefix', text: '.' }, + { type: 'Var.Identifier', text: 'counter' }, + { type: 'Var.Increment', text: '++' }, + { type: 'Macro.End', text: '}}' }, + ]; + + expect(tokens).toEqual(expectedTokens); + }); + }); + test.describe('Macro Output Modifiers', () => { // {{macro | outputModifier}} test('should support output modifier without arguments', async ({ page }) => { diff --git a/tests/frontend/MacroParser.e2e.js b/tests/frontend/MacroParser.e2e.js index 311eea881..b38fb1eb5 100644 --- a/tests/frontend/MacroParser.e2e.js +++ b/tests/frontend/MacroParser.e2e.js @@ -667,29 +667,175 @@ This is the second line 'Macro.End': '}}', }); }); + }); - // {{.myvar}} - variable shorthand - test('should parse macro with variable dot shorthand flag', async ({ page }) => { + test.describe('Variable Shorthand Syntax', () => { + // {{.myvar}} - local variable get + test('should parse local variable shorthand', async ({ page }) => { const input = '{{.myvar}}'; const macroCst = await runParser(page, input); expect(macroCst).toEqual({ 'Macro.Start': '{{', - 'flags': '.', - 'Macro.identifier': 'myvar', + 'variableExpr': { + 'Var.scope': '.', + 'Var.identifier': 'myvar', + }, 'Macro.End': '}}', }); }); - // {{$myvar}} - variable shorthand - test('should parse macro with variable dollar shorthand flag', async ({ page }) => { + // {{$myvar}} - global variable get + test('should parse global variable shorthand', async ({ page }) => { const input = '{{$myvar}}'; const macroCst = await runParser(page, input); expect(macroCst).toEqual({ 'Macro.Start': '{{', - 'flags': '$', - 'Macro.identifier': 'myvar', + 'variableExpr': { + 'Var.scope': '$', + 'Var.identifier': 'myvar', + }, + 'Macro.End': '}}', + }); + }); + + // {{.my-var}} - variable with hyphen in name + test('should parse variable with hyphen in name', async ({ page }) => { + const input = '{{.my-var}}'; + const macroCst = await runParser(page, input); + + expect(macroCst).toEqual({ + 'Macro.Start': '{{', + 'variableExpr': { + 'Var.scope': '.', + 'Var.identifier': 'my-var', + }, + 'Macro.End': '}}', + }); + }); + + // {{.myvar = value}} - set operator + test('should parse variable set shorthand', async ({ page }) => { + const input = '{{.myvar = hello}}'; + const macroCst = await runParser(page, input); + + expect(macroCst).toEqual({ + 'Macro.Start': '{{', + 'variableExpr': { + 'Var.scope': '.', + 'Var.identifier': 'myvar', + 'variableOperator': { + 'Var.operator': '=', + 'Var.value': { + 'Identifier': 'hello', + }, + }, + }, + 'Macro.End': '}}', + }); + }); + + // {{.counter++}} - increment operator + test('should parse variable increment shorthand', async ({ page }) => { + const input = '{{.counter++}}'; + const macroCst = await runParser(page, input); + + expect(macroCst).toEqual({ + 'Macro.Start': '{{', + 'variableExpr': { + 'Var.scope': '.', + 'Var.identifier': 'counter', + 'variableOperator': { + 'Var.operator': '++', + }, + }, + 'Macro.End': '}}', + }); + }); + + // {{$counter--}} - decrement operator + test('should parse global variable decrement shorthand', async ({ page }) => { + const input = '{{$counter--}}'; + const macroCst = await runParser(page, input); + + expect(macroCst).toEqual({ + 'Macro.Start': '{{', + 'variableExpr': { + 'Var.scope': '$', + 'Var.identifier': 'counter', + 'variableOperator': { + 'Var.operator': '--', + }, + }, + 'Macro.End': '}}', + }); + }); + + // {{.myvar += 5}} - add operator + test('should parse variable add shorthand', async ({ page }) => { + const input = '{{.myvar += 5}}'; + const macroCst = await runParser(page, input); + + expect(macroCst).toEqual({ + 'Macro.Start': '{{', + 'variableExpr': { + 'Var.scope': '.', + 'Var.identifier': 'myvar', + 'variableOperator': { + 'Var.operator': '+=', + 'Var.value': { + 'Unknown': '5', + }, + }, + }, + 'Macro.End': '}}', + }); + }); + + // {{.myvar = Hello {{user}}}} - nested macro in value + test('should parse nested macro in variable value', async ({ page }) => { + const input = '{{.myvar = Hello {{user}}}}'; + const macroCst = await runParser(page, input); + + expect(macroCst).toEqual({ + 'Macro.Start': '{{', + 'variableExpr': { + 'Var.scope': '.', + 'Var.identifier': 'myvar', + 'variableOperator': { + 'Var.operator': '=', + 'Var.value': { + 'Identifier': 'Hello', + 'macro': { + 'Macro.Start': '{{', + 'Macro.identifier': 'user', + 'Macro.End': '}}', + }, + }, + }, + }, + 'Macro.End': '}}', + }); + }); + + // {{ .myvar = spaced }} - whitespace handling + test('should parse variable shorthand with whitespace', async ({ page }) => { + const input = '{{ .myvar = spaced }}'; + const macroCst = await runParser(page, input); + + expect(macroCst).toEqual({ + 'Macro.Start': '{{', + 'variableExpr': { + 'Var.scope': '.', + 'Var.identifier': 'myvar', + 'variableOperator': { + 'Var.operator': '=', + 'Var.value': { + 'Identifier': 'spaced', + }, + }, + }, 'Macro.End': '}}', }); }); @@ -767,6 +913,19 @@ function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignore } if (node.children) { const simplifiedChildren = {}; + + // Special handling: merge macroBody children into parent (flatten the structure) + // This preserves backward compatibility with existing tests after parser refactor + if (node.children.macroBody && Array.isArray(node.children.macroBody) && node.children.macroBody.length === 1) { + const macroBody = node.children.macroBody[0]; + if (macroBody.children) { + for (const bodyKey in macroBody.children) { + node.children[bodyKey] = macroBody.children[bodyKey]; + } + } + delete node.children.macroBody; + } + for (const key in node.children) { function simplifyChildNode(childNode, path) { if (Array.isArray(childNode)) {