Macros 2.0 (v0.5) - Add variable shorthand macros and variable support to {{if}} macro (#4933)

* Add variable shorthand syntax support for local and global variables

- Add VariableShorthandType enum and VariableShorthandDefinitions for `.` (local) and `$` (global) prefixes
- Add VariableShorthandAutoCompleteOption class for autocomplete of variable shorthand syntax
- Implement variable expression parsing in MacroCstWalker to handle `{{.varName}}` and `{{$varName}}` syntax
- Add support for variable operations: get, set (=), increment (++), decrement (--), and add (+=)
- Route variable expressions to appropriate macro via handler

* Add variable shorthand autocomplete support for variable names and operators

- Add `isValidVariableShorthandName()` helper to validate variable names against shorthand pattern
- Add `VariableNameAutoCompleteOption` class for suggesting existing and new variable names
- Add `VariableOperatorAutoCompleteOption` class for suggesting operators (=, ++, --, +=)
- Add `VariableOperatorDefinitions` map with operator metadata (symbol, name, description, needsValue)

* Add variable shorthand support to {{if}} macro condition autocomplete

- Add variable shorthand (.var, $var) support to {{if}} condition evaluation in core-macros.js
- Detect and resolve variable shorthands using getvar/getglobalvar macros before condition check
- Update {{if}} description and examples to document variable shorthand syntax
- Add variable shorthand autocomplete options when typing {{if}} condition
- Show variable prefix options (. and $) when no condition is typed yet
- Reuse #buildVariableShorthandOptions

* refactor: Add Object.freeze to lexer constants and improve JSDoc documentation

- Freeze `modes` and `Tokens` objects to prevent accidental mutations
- Convert inline comments to proper JSDoc format for better documentation
- Add JSDoc block for `Def` lexer definition object
- Improve comment clarity and formatting consistency throughout MacroLexer.js
- Remove redundant section separator comments in variable shorthand modes

* Add inversion prefix (!) autocomplete support to {{if}} macro condition

- Add SimpleAutoCompleteOption class for basic autocomplete items with name, symbol, and description
- Add ! inversion prefix as autocomplete option in {{if}} condition with 🔁 icon
- Show ! as selectable option when nothing typed, non-selectable when already present
- Fix condition parsing to handle ! prefix with whitespace (e.g., "! $myvar")
- Update identifier extraction to strip ! and whitespace before detecting variable

* fix lint

* Fix variable shorthand regex in {{if}} macro to properly capture prefix and variable name

* Expand comprehensive e2e tests for variable shorthand syntax in lexer and macro engine

- Add MacroLexer tests for variable shorthand edge cases (whitespace, numbers, underscores, operators)
- Add MacroEngine tests for variable shorthand operations (hyphens, underscores, non-existent vars, chaining)
- Add MacroEngine tests for variable shorthand in {{if}} conditions (truthy/falsy, inversion, else branches)
- Test variable names with hyphens, underscores, and numbers in both get/set and conditional contexts

* Fix macro flags not being allowed inside variable shorthand macros

- Move MANY(flags) block from macroBody to macro rule to parse flags before branching
- Fix MacroCstWalker to extract flags from children.flags instead of bodyChildren.flags
- Ensures flags are available for both variable expressions and regular macros
- Fixes flag extraction in visitMacro and visitBlockMacroClose methods

* Add SillyTavern global to tests eslintrc

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
Wolfsblvt
2025-12-31 18:38:50 +01:00
committed by GitHub
parent 5dee64e0bc
commit 0dcd9906bf
11 changed files with 2107 additions and 132 deletions
@@ -12,6 +12,7 @@ import {
} from '../macros/MacroBrowser.js'; } from '../macros/MacroBrowser.js';
import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js'; import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
import { ValidFlagSymbols } from '../macros/engine/MacroFlags.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 */ /** @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 {number} separatorCount - Number of '::' separators found.
* @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag). * @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 {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<string, VariableShorthandDefinition>}
*/
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<string>}
*/
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 = `<code>${this.#varDef.type}</code> ${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 = '<strong>Supported Operations:</strong>';
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 = '<strong>Examples:</strong>';
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 = `<code>${ex.split(' - ')[0]}</code> - ${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 <code>${this.#varName}</code> 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 = '<strong>Valid names must:</strong><br>• Start with a letter (a-z, A-Z)<br>• Contain only letters, numbers, underscores, or hyphens<br>• Not end with an underscore or hyphen';
warningBox.append(rulesText);
details.append(warningBox);
frag.append(details);
return frag;
}
// Header
const header = document.createElement('h3');
header.innerHTML = this.#isNewVariable
? `<code>${prefix}${this.#varName}</code> (New ${scopeLabel} Variable)`
: `<code>${prefix}${this.#varName}</code> ${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 = '<strong>Usage:</strong>';
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 = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
usageList.append(li);
}
details.append(usageList);
frag.append(details);
return frag;
}
}
/**
* Variable shorthand operators with metadata.
* @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}
*/
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 = `<code>${this.#operatorDef.symbol}</code> ${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
? '<em>This operator requires a value after it.</em>'
: '<em>This operator does not take a value.</em>';
details.append(valueNote);
frag.append(details);
return frag;
}
}
/** /**
* Autocomplete option for closing a scoped macro. * Autocomplete option for closing a scoped macro.
* Suggests {{/macroName}} to close an unclosed 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 // Now parse the identifier and arguments starting from position i
const remainingText = macroText.slice(i); const remainingText = macroText.slice(i);
const parts = []; const parts = [];
@@ -725,5 +1303,123 @@ export function parseMacroContext(macroText, cursorOffset) {
hasSpaceAfterIdentifier, hasSpaceAfterIdentifier,
hasSpaceArgContent: spaceArgText.length > 0, hasSpaceArgContent: spaceArgText.length > 0,
separatorCount: separatorPositions.length, 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;
}
}
@@ -5,6 +5,7 @@ import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js'
import { inject_ids } from '../../constants.js'; import { inject_ids } from '../../constants.js';
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js'; import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
import { MacroEngine } from '../engine/MacroEngine.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. * 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}}content{{/if}} -> conditional content
// {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch // {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
// {{if !condition}}content{{/if}} -> inverted conditional (negated) // {{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', { MacroRegistry.registerMacro('if', {
category: MacroCategory.UTILITY, 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: [ unnamedArgs: [
{ {
name: 'condition', 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', name: 'content',
@@ -114,6 +115,8 @@ export function registerCoreMacros() {
'{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}', '{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
'{{if !personality}}No personality defined{{/if}}', '{{if !personality}}No personality defined{{/if}}',
'{{if {{getvar::showHeader}}}}# Header{{/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.', returns: 'The content if condition is truthy, else branch or empty string otherwise.',
handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => { handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => {
@@ -123,16 +126,27 @@ export function registerCoreMacros() {
if (/^\s*!/.test(rawCondition)) { if (/^\s*!/.test(rawCondition)) {
inverted = true; inverted = true;
// Strip the ! from the resolved condition if it was the prefix // 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) // Check if condition is a variable shorthand (.varname or $varname)
// If so, resolve it first (only for macros that accept 0 required args) // If so, resolve it using the appropriate variable macro
const macroDef = MacroRegistry.getPrimaryMacro(condition); const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`);
if (macroDef && macroDef.minArgs === 0) { const varShorthandMatch = condition.match(varShorthandRegex);
// Use MacroEngine.evaluate to properly resolve the macro with full context if (varShorthandMatch) {
// This ensures all handler args (cst, normalize, list, etc.) are correctly provided const [, prefix, varName] = varShorthandMatch;
condition = MacroEngine.evaluate(`{{${condition}}}`, env); 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 // Check if condition is falsy: empty string or isFalseBoolean
+203 -10
View File
@@ -13,6 +13,7 @@ import { MacroRegistry } from './MacroRegistry.js';
* @property {string[]} args * @property {string[]} args
* @property {MacroFlags} flags - Parsed macro execution flags. * @property {MacroFlags} flags - Parsed macro execution flags.
* @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags). * @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 {MacroEnv} env
* @property {string} rawInner * @property {string} rawInner
* @property {string} rawWithBraces * @property {string} rawWithBraces
@@ -21,6 +22,14 @@ import { MacroRegistry } from './MacroRegistry.js';
* @property {CstNode} cstNode * @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 * @typedef {Object} EvaluationContext
* @property {string} text * @property {string} text
@@ -240,11 +249,21 @@ class MacroCstWalker {
const { text, env, resolveMacro, trimContent } = context; const { text, env, resolveMacro, trimContent } = context;
const children = macroNode.children || {}; 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 || ''; const name = identifierTokens[0]?.image || '';
// Extract flag tokens and parse them into a MacroFlags object // Extract flag tokens and parse them into a MacroFlags object (now inside macroBody)
const flagTokens = /** @type {IToken[]} */ (children['flags'] || []); const flagTokens = /** @type {IToken[]} */ (children.flags || []);
const flagSymbols = flagTokens.map(token => token.image); const flagSymbols = flagTokens.map(token => token.image);
const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags(); const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();
@@ -255,8 +274,8 @@ class MacroCstWalker {
const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset; const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset;
const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset; const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset;
// Extract argument nodes from the "arguments" rule (if present) // Extract argument nodes from the "arguments" rule (if present, inside macroBody)
const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]); const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []); const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
/** @type {string[]} */ /** @type {string[]} */
@@ -348,6 +367,167 @@ class MacroCstWalker {
return stringValue; 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<string, any>} 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 * Evaluates a single argument node by resolving nested macros and reconstructing
* the original argument text. * the original argument text.
@@ -759,13 +939,24 @@ class MacroCstWalker {
*/ */
#extractMacroInfo(macroNode) { #extractMacroInfo(macroNode) {
const children = macroNode.children || {}; 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 || ''; const name = identifierTokens[0]?.image || '';
if (!name) return null; if (!name) return null;
// Check for closing block flag // Check for closing block flag (inside macroBody)
const flagTokens = /** @type {IToken[]} */ (children['flags'] || []); const flagTokens = /** @type {IToken[]} */ (children.flags || []);
const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK); const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
return { name, isClosing }; return { name, isClosing };
@@ -786,9 +977,11 @@ class MacroCstWalker {
return true; return true;
} }
// Count current arguments in the macro // Count current arguments in the macro (now inside macroBody)
const children = macroNode.children || {}; 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 argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
const currentArgCount = argumentNodes.length; const currentArgCount = argumentNodes.length;
+2 -37
View File
@@ -15,8 +15,6 @@
* @property {boolean} filter - Whether the filter (`>`) flag is set. * @property {boolean} filter - Whether the filter (`>`) flag is set.
* @property {boolean} closingBlock - Whether the closing block (`/`) 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} 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. * @property {string[]} raw - The raw flag symbols in order of appearance.
*/ */
@@ -74,19 +72,8 @@ export const MacroFlagType = Object.freeze({
*/ */
PRESERVE_WHITESPACE: '#', PRESERVE_WHITESPACE: '#',
/** // Note: Variable shorthand (. and $) are NOT flags - they are special prefixes
* Variable shorthand flag (`.`). // that trigger the variable expression parsing branch. See MacroLexer.js Var tokens.
* 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: '$',
}); });
/** /**
@@ -146,20 +133,6 @@ export const MacroFlagDefinitions = new Map([
implemented: true, implemented: true,
affectsParser: false, 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, filter: false,
closingBlock: false, closingBlock: false,
preserveWhitespace: false, preserveWhitespace: false,
varDot: false,
varDollar: false,
raw: [], raw: [],
}; };
} }
@@ -217,12 +188,6 @@ export function parseFlags(flagSymbols) {
case MacroFlagType.PRESERVE_WHITESPACE: case MacroFlagType.PRESERVE_WHITESPACE:
flags.preserveWhitespace = true; flags.preserveWhitespace = true;
break; break;
case MacroFlagType.VAR_DOT:
flags.varDot = true;
break;
case MacroFlagType.VAR_DOLLAR:
flags.varDollar = true;
break;
default: default:
console.warn(`Can't parse unknown macro flag: ${symbol}`); console.warn(`Can't parse unknown macro flag: ${symbol}`);
} }
+109 -22
View File
@@ -16,28 +16,42 @@ const IDENTIFIER_LEXER_PATTERN = /[a-zA-Z][\w-_]*/;
*/ */
export const MACRO_IDENTIFIER_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} */ /** @enum {string} */
const modes = { const modes = Object.freeze({
plaintext: 'plaintext_mode', plaintext: 'plaintext_mode',
macro_def: 'macro_def_mode', macro_def: 'macro_def_mode',
macro_identifier_end: 'macro_identifier_end_mode', macro_identifier_end: 'macro_identifier_end_mode',
macro_args: 'macro_args_mode', macro_args: 'macro_args_mode',
macro_filter_modifer: 'macro_filter_modifer_mode', macro_filter_modifer: 'macro_filter_modifer_mode',
macro_filter_modifier_end: 'macro_filter_modifier_end_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 = { * All lexer tokens used by the macro parser.
// General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'. * @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 }), 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: /\{(?=\{\{)/ }), PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }),
// General macro capture /** General macro capture */
Macro: { Macro: {
Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }), 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. * Macro execution flags - special symbols that modify macro resolution behavior.
* - `!` = immediate resolve (TBD) * - `!` = immediate resolve (TBD)
@@ -45,24 +59,26 @@ const Tokens = {
* - `~` = re-evaluate (TBD) * - `~` = re-evaluate (TBD)
* - `/` = closing block marker for scoped macros * - `/` = closing block marker for scoped macros
* - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility * - `#` = 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. * Filter flag (`>`) - separate token because it changes parsing behavior.
* When present, `|` characters inside the macro are treated as filter/pipe operators. * When present, `|` characters inside the macro are treated as filter/pipe operators.
*/ */
FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }), FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }),
DoubleSlash: createToken({ name: 'Macro.DoubleSlash', 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 }), 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 }), EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }), BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
End: createToken({ name: 'Macro.End', pattern: /\}\}/ }), End: createToken({ name: 'Macro.End', pattern: /\}\}/ }),
}, },
// Captures that only appear inside arguments /** Captures that only appear inside arguments */
Args: { Args: {
DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }), DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }),
Colon: createToken({ name: 'Args.Colon', pattern: /:/ }), Colon: createToken({ name: 'Args.Colon', pattern: /:/ }),
@@ -74,7 +90,7 @@ const Tokens = {
EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }), EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }), Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_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 }), 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 }), Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }), 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. /** Variable shorthand tokens */
// This includes any possible braces that is not the double closing braces as MacroEnd. 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: /([^}]|\}(?!\}))/ }), 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 }), 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. * DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches.
// Has to ALWAYS be the last token. * 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 }), ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }),
}; });
/** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */ /** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */
const enterModesMap = new Map(); 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 = { const Def = {
modes: { modes: {
[modes.plaintext]: [ [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. // 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), 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), using(Tokens.Macro.Flags),
// Filter flag is separate because it affects parsing behavior for pipes // Filter flag is separate because it affects parsing behavior for pipes
using(Tokens.Macro.FilterFlag), using(Tokens.Macro.FilterFlag),
@@ -165,6 +218,40 @@ const Def = {
exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end), exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer), 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, defaultMode: modes.plaintext,
}; };
+61 -2
View File
@@ -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', () => { $.macro = $.RULE('macro', () => {
$.CONSUME(Tokens.Macro.Start); $.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) // Macro identifier (name)
$.OR2([ $.OR2([
{ ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) }, { ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
{ ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) }, { ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
]); ]);
$.OPTION(() => $.SUBRULE($.arguments)); $.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 // Arguments Parsing
@@ -15,7 +15,19 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js'; import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js'; import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
import { SlashCommandEnumValue } from './SlashCommandEnumValue.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 { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
import { MacroParser } from '../macros/engine/MacroParser.js'; import { MacroParser } from '../macros/engine/MacroParser.js';
import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js'; import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
@@ -25,6 +37,8 @@ import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
import { SlashCommandBreak } from './SlashCommandBreak.js'; import { SlashCommandBreak } from './SlashCommandBreak.js';
import { macros as macroSystem } from '../macros/macro-system.js'; import { macros as macroSystem } from '../macros/macro-system.js';
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.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').NamedArgumentsCapture} NamedArgumentsCapture */
/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */ /** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
@@ -566,29 +580,138 @@ export class SlashCommandParser {
} else { } else {
conditionStartOffset = context.identifierStart + identifier.length; 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 // Build if-condition options using macroContent for padding calculation
const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true }); const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
const options = this.#buildIfConditionOptions(context, allMacros, macroContent); 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( const result = new AutoCompleteNameResult(
conditionText, resultIdentifier,
conditionStartInText, resultStart,
options, options,
false, false,
() => 'Use {{macro}} syntax for dynamic conditions', () => isTypingVarShorthand
() => 'Enter a macro name or {{macro}} for the condition', ? '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; return result;
} }
/** @type {()=>string|undefined} */
let makeNoMatchText = undefined;
/** @type {()=>string|undefined} */
let makeNoOptionsText = undefined;
const options = this.#buildEnhancedMacroOptions(context, textUpToCursor); 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( const result = new AutoCompleteNameResult(
identifier, resultIdentifier,
identifierStartInText, resultStart,
options, options,
false, false,
makeNoMatchText,
makeNoOptionsText,
); );
return result; return result;
} }
@@ -669,12 +792,17 @@ export class SlashCommandParser {
* When typing arguments (after ::), prioritizes the exact macro match. * When typing arguments (after ::), prioritizes the exact macro match.
* @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
* @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection. * @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 = '') { #buildEnhancedMacroOptions(context, textUpToCursor = '') {
/** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} */ /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
const options = []; const options = [];
// Handle variable shorthand mode
if (context.isVariableShorthand) {
return this.#buildVariableShorthandOptions(context);
}
// Check for unclosed scoped macros and suggest closing tags first // Check for unclosed scoped macros and suggest closing tags first
const unclosedScopes = this.#findUnclosedScopes(textUpToCursor); const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
if (unclosedScopes.length > 0) { if (unclosedScopes.length > 0) {
@@ -685,11 +813,9 @@ export class SlashCommandParser {
// If inside a scoped {{if}}, also suggest {{else}} // If inside a scoped {{if}}, also suggest {{else}}
if (innermostScope.name === 'if') { if (innermostScope.name === 'if') {
// TODO: TEsting
const macroDef = macroSystem.registry.getPrimaryMacro('else'); const macroDef = macroSystem.registry.getPrimaryMacro('else');
const elseOption = new EnhancedMacroAutoCompleteOption(macroDef); const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
elseOption.sortPriority = 2; elseOption.sortPriority = 2;
// const elseOption = new MacroElseAutoCompleteOption();
options.push(elseOption); options.push(elseOption);
} }
} }
@@ -732,6 +858,14 @@ export class SlashCommandParser {
flagOption.sortPriority = isSelectable ? 10 : 12; flagOption.sortPriority = isSelectable ? 10 : 12;
options.push(flagOption); 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) // Get all macros from the registry (excluding hidden aliases)
@@ -780,6 +914,187 @@ export class SlashCommandParser {
return options; 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. * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
* @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context * @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
@@ -796,6 +1111,87 @@ export class SlashCommandParser {
const leadingMatch = macroInnerText.match(/^(\s*)/); const leadingMatch = macroInnerText.match(/^(\s*)/);
const paddingAfter = leadingMatch ? leadingMatch[1] : ''; 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.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> 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 // Add zero-arg macros as condition shorthand options
for (const macro of allMacros) { for (const macro of allMacros) {
// Only include macros that require zero arguments (can be auto-resolved) // Only include macros that require zero arguments (can be auto-resolved)
+1
View File
@@ -25,6 +25,7 @@ module.exports = {
'node_modules/**/*', 'node_modules/**/*',
], ],
globals: { globals: {
SillyTavern: 'readonly',
}, },
rules: { rules: {
'no-unused-vars': ['error', { args: 'none' }], 'no-unused-vars': ['error', { args: 'none' }],
+207
View File
@@ -1765,6 +1765,165 @@ test.describe('MacroEngine', () => {
expect(output).toBe('Message (by EnvChar)'); 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); 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<string, string>, global?: Record<string, string> }} variables
* @returns {Promise<string>}
*/
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;
}
+228 -30
View File
@@ -567,34 +567,6 @@ test.describe('MacroLexer', () => {
expect(tokens).toEqual(expectedTokens); 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}} // {{#legacy}}
test('should support legacy # flag', async ({ page }) => { test('should support legacy # flag', async ({ page }) => {
const input = '{{#legacy}}'; const input = '{{#legacy}}';
@@ -640,13 +612,13 @@ test.describe('MacroLexer', () => {
}); });
// {{ ! .importantvariable }} // {{ ! .importantvariable }}
test('should support multiple flags with whitespace', async ({ page }) => { test('should support multiple flags with whitespace', async ({ page }) => {
const input = '{{ !.importantvariable }}'; const input = '{{ !#importantvariable }}';
const tokens = await runLexerGetTokens(page, input); const tokens = await runLexerGetTokens(page, input);
const expectedTokens = [ const expectedTokens = [
{ type: 'Macro.Start', text: '{{' }, { type: 'Macro.Start', text: '{{' },
{ type: 'Macro.Flag', text: '!' }, { type: 'Macro.Flag', text: '!' },
{ type: 'Macro.Flag', text: '.' }, { type: 'Macro.Flag', text: '#' },
{ type: 'Macro.Identifier', text: 'importantvariable' }, { type: 'Macro.Identifier', text: 'importantvariable' },
{ type: 'Macro.End', text: '}}' }, { 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', () => { test.describe('Macro Output Modifiers', () => {
// {{macro | outputModifier}} // {{macro | outputModifier}}
test('should support output modifier without arguments', async ({ page }) => { test('should support output modifier without arguments', async ({ page }) => {
+167 -8
View File
@@ -667,29 +667,175 @@ This is the second line
'Macro.End': '}}', 'Macro.End': '}}',
}); });
}); });
});
// {{.myvar}} - variable shorthand test.describe('Variable Shorthand Syntax', () => {
test('should parse macro with variable dot shorthand flag', async ({ page }) => { // {{.myvar}} - local variable get
test('should parse local variable shorthand', async ({ page }) => {
const input = '{{.myvar}}'; const input = '{{.myvar}}';
const macroCst = await runParser(page, input); const macroCst = await runParser(page, input);
expect(macroCst).toEqual({ expect(macroCst).toEqual({
'Macro.Start': '{{', 'Macro.Start': '{{',
'flags': '.', 'variableExpr': {
'Macro.identifier': 'myvar', 'Var.scope': '.',
'Var.identifier': 'myvar',
},
'Macro.End': '}}', 'Macro.End': '}}',
}); });
}); });
// {{$myvar}} - variable shorthand // {{$myvar}} - global variable get
test('should parse macro with variable dollar shorthand flag', async ({ page }) => { test('should parse global variable shorthand', async ({ page }) => {
const input = '{{$myvar}}'; const input = '{{$myvar}}';
const macroCst = await runParser(page, input); const macroCst = await runParser(page, input);
expect(macroCst).toEqual({ expect(macroCst).toEqual({
'Macro.Start': '{{', 'Macro.Start': '{{',
'flags': '$', 'variableExpr': {
'Macro.identifier': 'myvar', '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': '}}', 'Macro.End': '}}',
}); });
}); });
@@ -767,6 +913,19 @@ function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignore
} }
if (node.children) { if (node.children) {
const simplifiedChildren = {}; 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) { for (const key in node.children) {
function simplifyChildNode(childNode, path) { function simplifyChildNode(childNode, path) {
if (Array.isArray(childNode)) { if (Array.isArray(childNode)) {