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:
@@ -12,6 +12,7 @@ import {
|
||||
} from '../macros/MacroBrowser.js';
|
||||
import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
|
||||
import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
|
||||
import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
|
||||
|
||||
/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
|
||||
|
||||
@@ -34,6 +35,18 @@ import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
|
||||
* @property {number} separatorCount - Number of '::' separators found.
|
||||
* @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
|
||||
* @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
|
||||
* @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
|
||||
* @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
|
||||
* @property {string} variableName - The variable name being typed (after the prefix).
|
||||
* @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
|
||||
* @property {string} variableValue - The value after the operator (for = and +=).
|
||||
* @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
|
||||
* @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
|
||||
* @property {boolean} isTypingValue - Whether cursor is after an operator that requires a value.
|
||||
* @property {boolean} [hasInvalidTrailingChars] - Whether there are invalid characters after the variable name.
|
||||
* @property {string} [invalidTrailingChars] - The invalid trailing characters (for error display).
|
||||
* @property {string} [partialOperator] - Partial operator prefix being typed ('+' or '-').
|
||||
* @property {boolean} [isOperatorComplete] - Whether a complete operator (++ or --) was typed that doesn't need a value.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -444,6 +457,448 @@ export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum of variable shorthand prefix types.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
export const VariableShorthandType = Object.freeze({
|
||||
/** Local variable prefix (`.`) */
|
||||
LOCAL: '.',
|
||||
/** Global variable prefix (`$`) */
|
||||
GLOBAL: '$',
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {Object} VariableShorthandDefinition
|
||||
* @property {VariableShorthandType} type - The prefix symbol.
|
||||
* @property {string} name - Human-readable name.
|
||||
* @property {string} description - Description of what this prefix does.
|
||||
* @property {string[]} operations - List of supported operations.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Definitions for variable shorthand prefixes.
|
||||
* @type {Map<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.
|
||||
* Suggests {{/macroName}} to close an unclosed scoped macro.
|
||||
@@ -608,6 +1063,129 @@ export function parseMacroContext(macroText, cursorOffset) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for variable shorthand prefix (. or $)
|
||||
// These trigger variable expression mode instead of regular macro parsing
|
||||
/** @type {'.'|'$'|null} */
|
||||
let variablePrefix = null;
|
||||
let variableName = '';
|
||||
/** @type {string|null} */
|
||||
let variableOperator = null;
|
||||
let variableValue = '';
|
||||
let isVariableShorthand = false;
|
||||
let isTypingVariableName = false;
|
||||
let isTypingOperator = false;
|
||||
let isTypingValue = false;
|
||||
let variableNameEnd = i;
|
||||
|
||||
const remainingAfterFlags = macroText.slice(i);
|
||||
if (remainingAfterFlags.startsWith('.') || remainingAfterFlags.startsWith('$')) {
|
||||
isVariableShorthand = true;
|
||||
variablePrefix = /** @type {'.'|'$'} */ (remainingAfterFlags[0]);
|
||||
i++; // Move past the prefix
|
||||
|
||||
// Variable names: start with letter, can have hyphens inside, must not end with hyphen
|
||||
const varNameMatch = macroText.slice(i).match(VARIABLE_SHORTHAND_NAME_PATTERN);
|
||||
if (varNameMatch) {
|
||||
variableName = varNameMatch[0];
|
||||
i += variableName.length;
|
||||
}
|
||||
variableNameEnd = i;
|
||||
|
||||
// Skip whitespace before operator
|
||||
while (i < macroText.length && /\s/.test(macroText[i])) {
|
||||
i++;
|
||||
}
|
||||
|
||||
// Check for operators: ++, --, +=, =
|
||||
// Also track partial operator prefixes for autocomplete
|
||||
const operatorText = macroText.slice(i);
|
||||
let hasInvalidTrailingChars = false;
|
||||
let invalidTrailingChars = '';
|
||||
let partialOperator = '';
|
||||
if (operatorText.startsWith('++')) {
|
||||
variableOperator = '++';
|
||||
i += 2;
|
||||
} else if (operatorText.startsWith('--')) {
|
||||
variableOperator = '--';
|
||||
i += 2;
|
||||
} else if (operatorText.startsWith('+=')) {
|
||||
variableOperator = '+=';
|
||||
i += 2;
|
||||
} else if (operatorText.startsWith('=')) {
|
||||
variableOperator = '=';
|
||||
i += 1;
|
||||
} else if (operatorText.startsWith('+') || operatorText.startsWith('-')) {
|
||||
// Partial operator prefix - user is typing an operator
|
||||
partialOperator = operatorText[0];
|
||||
} else if (operatorText.length > 0 && !/^\s/.test(operatorText)) {
|
||||
// There's non-whitespace after the variable name that isn't a valid operator
|
||||
// This is an invalid trailing character (e.g., $my$ or .var@test)
|
||||
hasInvalidTrailingChars = true;
|
||||
invalidTrailingChars = operatorText.trim();
|
||||
}
|
||||
|
||||
// If operator requires a value (= or +=), parse the value
|
||||
if (variableOperator === '=' || variableOperator === '+=') {
|
||||
// Skip whitespace after operator
|
||||
while (i < macroText.length && /\s/.test(macroText[i])) {
|
||||
i++;
|
||||
}
|
||||
variableValue = macroText.slice(i).trimEnd();
|
||||
}
|
||||
|
||||
// Determine cursor position context for autocomplete
|
||||
const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;
|
||||
if (cursorOffset < prefixEnd) {
|
||||
// Cursor is before the prefix - still in flags area conceptually
|
||||
isTypingVariableName = false;
|
||||
} else if (cursorOffset <= variableNameEnd) {
|
||||
// Cursor is in the variable name
|
||||
isTypingVariableName = true;
|
||||
} else if (!variableOperator && !hasInvalidTrailingChars) {
|
||||
// Cursor is after variable name but no operator yet (and no invalid chars)
|
||||
// This includes partial operator prefixes like '+' or '-'
|
||||
isTypingOperator = true;
|
||||
} else if (variableOperator === '=' || variableOperator === '+=') {
|
||||
// Operator that requires value - cursor is in value area
|
||||
isTypingValue = true;
|
||||
}
|
||||
// For ++ and --, the operator is complete (no value needed)
|
||||
// For invalid trailing chars, none of the typing flags will be true
|
||||
const isOperatorComplete = (variableOperator === '++' || variableOperator === '--');
|
||||
|
||||
// Return early for variable shorthand - different structure than regular macros
|
||||
return {
|
||||
fullText: macroText,
|
||||
cursorOffset,
|
||||
paddingBefore: macroText.match(/^\s+/)?.[0] ?? '',
|
||||
identifier: '', // No macro identifier for variable shorthand
|
||||
identifierStart: -1,
|
||||
isInFlagsArea: false,
|
||||
flags,
|
||||
currentFlag,
|
||||
args: [],
|
||||
currentArgIndex: -1,
|
||||
isTypingSeparator: false,
|
||||
hasSpaceAfterIdentifier: false,
|
||||
hasSpaceArgContent: false,
|
||||
separatorCount: 0,
|
||||
// Variable shorthand specific properties
|
||||
isVariableShorthand,
|
||||
variablePrefix,
|
||||
variableName,
|
||||
variableOperator,
|
||||
variableValue,
|
||||
isTypingVariableName,
|
||||
isTypingOperator,
|
||||
isTypingValue,
|
||||
isOperatorComplete,
|
||||
hasInvalidTrailingChars,
|
||||
invalidTrailingChars,
|
||||
partialOperator,
|
||||
};
|
||||
}
|
||||
|
||||
// Regular macro parsing (not variable shorthand)
|
||||
// Now parse the identifier and arguments starting from position i
|
||||
const remainingText = macroText.slice(i);
|
||||
const parts = [];
|
||||
@@ -725,5 +1303,123 @@ export function parseMacroContext(macroText, cursorOffset) {
|
||||
hasSpaceAfterIdentifier,
|
||||
hasSpaceArgContent: spaceArgText.length > 0,
|
||||
separatorCount: separatorPositions.length,
|
||||
// Default variable shorthand properties (not a variable shorthand)
|
||||
isVariableShorthand: false,
|
||||
variablePrefix: null,
|
||||
variableName: '',
|
||||
variableOperator: null,
|
||||
variableValue: '',
|
||||
isTypingVariableName: false,
|
||||
isTypingOperator: false,
|
||||
isTypingValue: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple, generic autocomplete option for displaying basic items with name, symbol, and description.
|
||||
* Useful for simple options like inversion markers, prefixes, etc. without needing a full custom class.
|
||||
*
|
||||
* @extends AutoCompleteOption
|
||||
*/
|
||||
export class SimpleAutoCompleteOption extends AutoCompleteOption {
|
||||
/** @type {string} */
|
||||
#description;
|
||||
|
||||
/** @type {string|null} */
|
||||
#detailedDescription;
|
||||
|
||||
/**
|
||||
* @param {Object} config - Configuration for the option.
|
||||
* @param {string} config.name - The option name/key (used for matching).
|
||||
* @param {string} [config.symbol=' '] - Icon/symbol shown in the type column.
|
||||
* @param {string} [config.description=''] - Short description shown inline.
|
||||
* @param {string} [config.detailedDescription] - Longer description for details panel (supports HTML). Falls back to description if not provided.
|
||||
* @param {string} [config.type='simple'] - Type identifier for CSS/data attributes.
|
||||
*/
|
||||
constructor({ name, symbol = ' ', description = '', detailedDescription = null, type = 'simple' }) {
|
||||
super(name, symbol, type);
|
||||
this.#description = description;
|
||||
this.#detailedDescription = detailedDescription;
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
get description() {
|
||||
return this.#description;
|
||||
}
|
||||
|
||||
/** @returns {string} */
|
||||
get detailedDescription() {
|
||||
return this.#detailedDescription ?? this.#description;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
renderItem() {
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('item');
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', this.type);
|
||||
|
||||
// Type icon
|
||||
const typeSpan = document.createElement('span');
|
||||
typeSpan.classList.add('type', 'monospace');
|
||||
typeSpan.textContent = this.typeIcon;
|
||||
li.append(typeSpan);
|
||||
|
||||
// Name
|
||||
const specs = document.createElement('span');
|
||||
specs.classList.add('specs');
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.classList.add('name', 'monospace');
|
||||
this.name.split('').forEach(char => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = char;
|
||||
nameSpan.append(span);
|
||||
});
|
||||
specs.append(nameSpan);
|
||||
li.append(specs);
|
||||
|
||||
// Stopgap
|
||||
const stopgap = document.createElement('span');
|
||||
stopgap.classList.add('stopgap');
|
||||
li.append(stopgap);
|
||||
|
||||
// Help/description
|
||||
const help = document.createElement('span');
|
||||
help.classList.add('help');
|
||||
const content = document.createElement('span');
|
||||
content.classList.add('helpContent');
|
||||
content.textContent = this.#description;
|
||||
help.append(content);
|
||||
li.append(help);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {DocumentFragment}
|
||||
*/
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
|
||||
// Header with name
|
||||
const specs = document.createElement('div');
|
||||
specs.classList.add('specs');
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.classList.add('name', 'monospace');
|
||||
nameDiv.textContent = this.name;
|
||||
specs.append(nameDiv);
|
||||
frag.append(specs);
|
||||
|
||||
// Description
|
||||
if (this.detailedDescription) {
|
||||
const helpDiv = document.createElement('div');
|
||||
helpDiv.classList.add('help');
|
||||
helpDiv.innerHTML = this.detailedDescription;
|
||||
frag.append(helpDiv);
|
||||
}
|
||||
|
||||
return frag;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { textgenerationwebui_banned_in_macros } from '../../textgen-settings.js'
|
||||
import { inject_ids } from '../../constants.js';
|
||||
import { MacroRegistry, MacroCategory, MacroValueType } from '../engine/MacroRegistry.js';
|
||||
import { MacroEngine } from '../engine/MacroEngine.js';
|
||||
import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../engine/MacroLexer.js';
|
||||
|
||||
/**
|
||||
* Marker used by {{else}} to split content in {{if}} blocks.
|
||||
@@ -94,14 +95,14 @@ export function registerCoreMacros() {
|
||||
// {{if condition}}content{{/if}} -> conditional content
|
||||
// {{if condition}}then-content{{else}}else-content{{/if}} -> conditional with else branch
|
||||
// {{if !condition}}content{{/if}} -> inverted conditional (negated)
|
||||
// Condition can be a macro name (resolved automatically) or any value
|
||||
// Condition can be a macro name (resolved automatically), variable shorthand (.var or $var), or any value
|
||||
MacroRegistry.registerMacro('if', {
|
||||
category: MacroCategory.UTILITY,
|
||||
description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first.',
|
||||
description: 'Conditional macro. Returns the content if the condition is truthy, otherwise returns nothing (or the else branch if present). Prefix the condition with ! to invert. If the condition is a registered macro name (without braces), it will be resolved first. Variable shorthands (.varname for local, $varname for global) are also supported.',
|
||||
unnamedArgs: [
|
||||
{
|
||||
name: 'condition',
|
||||
description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved) or a value. Falsy: empty string, "false", "off", "0".',
|
||||
description: 'The condition to evaluate. Prefix with ! to invert. Can be a macro name (auto-resolved), variable shorthand (.var or $var), or a value. Falsy: empty string, "false", "off", "0".',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
@@ -114,6 +115,8 @@ export function registerCoreMacros() {
|
||||
'{{if charVersion}}{{charVersion}}{{else}}No version{{/if}}',
|
||||
'{{if !personality}}No personality defined{{/if}}',
|
||||
'{{if {{getvar::showHeader}}}}# Header{{/if}}',
|
||||
'{{if .myvar}}Local var exists{{/if}}',
|
||||
'{{if $globalFlag}}Global flag is set{{/if}}',
|
||||
],
|
||||
returns: 'The content if condition is truthy, else branch or empty string otherwise.',
|
||||
handler: ({ unnamedArgs: [condition, content], rawArgs: [rawCondition], flags, env, trimContent }) => {
|
||||
@@ -123,16 +126,27 @@ export function registerCoreMacros() {
|
||||
if (/^\s*!/.test(rawCondition)) {
|
||||
inverted = true;
|
||||
// Strip the ! from the resolved condition if it was the prefix
|
||||
condition = condition.replace(/^!/, '');
|
||||
condition = condition.replace(/^!\s*/, '');
|
||||
}
|
||||
|
||||
// Check if condition is a registered macro name (without braces)
|
||||
// If so, resolve it first (only for macros that accept 0 required args)
|
||||
const macroDef = MacroRegistry.getPrimaryMacro(condition);
|
||||
if (macroDef && macroDef.minArgs === 0) {
|
||||
// Use MacroEngine.evaluate to properly resolve the macro with full context
|
||||
// This ensures all handler args (cst, normalize, list, etc.) are correctly provided
|
||||
condition = MacroEngine.evaluate(`{{${condition}}}`, env);
|
||||
// Check if condition is a variable shorthand (.varname or $varname)
|
||||
// If so, resolve it using the appropriate variable macro
|
||||
const varShorthandRegex = new RegExp(`^([.$])(${MACRO_VARIABLE_SHORTHAND_PATTERN.source})$`);
|
||||
const varShorthandMatch = condition.match(varShorthandRegex);
|
||||
if (varShorthandMatch) {
|
||||
const [, prefix, varName] = varShorthandMatch;
|
||||
const varMacro = prefix === '.' ? 'getvar' : 'getglobalvar';
|
||||
// Resolve the variable using MacroEngine.evaluate
|
||||
condition = MacroEngine.evaluate(`{{${varMacro}::${varName}}}`, env);
|
||||
} else {
|
||||
// Check if condition is a registered macro name (without braces)
|
||||
// If so, resolve it first (only for macros that accept 0 required args)
|
||||
const macroDef = MacroRegistry.getPrimaryMacro(condition);
|
||||
if (macroDef && macroDef.minArgs === 0) {
|
||||
// Use MacroEngine.evaluate to properly resolve the macro with full context
|
||||
// This ensures all handler args (cst, normalize, list, etc.) are correctly provided
|
||||
condition = MacroEngine.evaluate(`{{${condition}}}`, env);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if condition is falsy: empty string or isFalseBoolean
|
||||
|
||||
@@ -13,6 +13,7 @@ import { MacroRegistry } from './MacroRegistry.js';
|
||||
* @property {string[]} args
|
||||
* @property {MacroFlags} flags - Parsed macro execution flags.
|
||||
* @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).
|
||||
* @property {boolean} [isVariableShorthand] - Whether this call originated from variable shorthand syntax.
|
||||
* @property {MacroEnv} env
|
||||
* @property {string} rawInner
|
||||
* @property {string} rawWithBraces
|
||||
@@ -21,6 +22,14 @@ import { MacroRegistry } from './MacroRegistry.js';
|
||||
* @property {CstNode} cstNode
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} VariableExprInfo
|
||||
* @property {'local' | 'global'} scope - Whether this is a local (.) or global ($) variable.
|
||||
* @property {string} varName - The variable name.
|
||||
* @property {'get' | 'set' | 'inc' | 'dec' | 'add'} operation - The operation to perform.
|
||||
* @property {string | null} value - The value for set/add operations, null for get/inc/dec.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} EvaluationContext
|
||||
* @property {string} text
|
||||
@@ -240,11 +249,21 @@ class MacroCstWalker {
|
||||
const { text, env, resolveMacro, trimContent } = context;
|
||||
|
||||
const children = macroNode.children || {};
|
||||
const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
|
||||
|
||||
// Check if this is a variable expression (has variableExpr child)
|
||||
const variableExprNode = /** @type {CstNode?} */ ((children.variableExpr || [])[0]);
|
||||
if (variableExprNode) {
|
||||
return this.#evaluateVariableExpr(macroNode, variableExprNode, context);
|
||||
}
|
||||
|
||||
// Regular macro - get identifier from macroBody
|
||||
const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]);
|
||||
const bodyChildren = macroBodyNode?.children || {};
|
||||
const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []);
|
||||
const name = identifierTokens[0]?.image || '';
|
||||
|
||||
// Extract flag tokens and parse them into a MacroFlags object
|
||||
const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);
|
||||
// Extract flag tokens and parse them into a MacroFlags object (now inside macroBody)
|
||||
const flagTokens = /** @type {IToken[]} */ (children.flags || []);
|
||||
const flagSymbols = flagTokens.map(token => token.image);
|
||||
const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();
|
||||
|
||||
@@ -255,8 +274,8 @@ class MacroCstWalker {
|
||||
const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset;
|
||||
const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset;
|
||||
|
||||
// Extract argument nodes from the "arguments" rule (if present)
|
||||
const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);
|
||||
// Extract argument nodes from the "arguments" rule (if present, inside macroBody)
|
||||
const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
|
||||
const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
|
||||
|
||||
/** @type {string[]} */
|
||||
@@ -348,6 +367,167 @@ class MacroCstWalker {
|
||||
return stringValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates a variable expression node and routes it to the appropriate variable macro.
|
||||
*
|
||||
* @param {CstNode} macroNode - The parent macro node.
|
||||
* @param {CstNode} variableExprNode - The variableExpr CST node.
|
||||
* @param {EvaluationContext} context - The evaluation context.
|
||||
* @returns {string}
|
||||
*/
|
||||
#evaluateVariableExpr(macroNode, variableExprNode, context) {
|
||||
const { text, env, resolveMacro } = context;
|
||||
|
||||
const children = macroNode.children || {};
|
||||
const varChildren = variableExprNode.children || {};
|
||||
|
||||
// Extract scope (. for local, $ for global)
|
||||
const localPrefixToken = /** @type {IToken?} */ ((varChildren['Var.scope'] || []).find(t => /** @type {IToken} */(t).tokenType?.name === 'Var.LocalPrefix'));
|
||||
const isGlobal = !localPrefixToken;
|
||||
|
||||
// Extract variable name
|
||||
const varIdentifierToken = /** @type {IToken?} */ ((varChildren['Var.identifier'] || [])[0]);
|
||||
const varName = varIdentifierToken?.image || '';
|
||||
|
||||
// Extract operator (if any)
|
||||
const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]);
|
||||
const operatorChildren = operatorNode?.children || {};
|
||||
|
||||
// Determine operation and value
|
||||
let operation = 'get';
|
||||
let value = null;
|
||||
|
||||
if (operatorNode) {
|
||||
const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []);
|
||||
const operatorToken = operatorTokens[0];
|
||||
|
||||
if (operatorToken) {
|
||||
const operatorImage = operatorToken.image;
|
||||
if (operatorImage === '++') {
|
||||
operation = 'inc';
|
||||
} else if (operatorImage === '--') {
|
||||
operation = 'dec';
|
||||
} else if (operatorImage === '=') {
|
||||
operation = 'set';
|
||||
value = this.#evaluateVariableValue(operatorChildren, context);
|
||||
} else if (operatorImage === '+=') {
|
||||
operation = 'add';
|
||||
value = this.#evaluateVariableValue(operatorChildren, context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map operation to macro name
|
||||
const macroNameMap = {
|
||||
get: isGlobal ? 'getglobalvar' : 'getvar',
|
||||
set: isGlobal ? 'setglobalvar' : 'setvar',
|
||||
inc: isGlobal ? 'incglobalvar' : 'incvar',
|
||||
dec: isGlobal ? 'decglobalvar' : 'decvar',
|
||||
add: isGlobal ? 'addglobalvar' : 'addvar',
|
||||
};
|
||||
|
||||
const targetMacroName = macroNameMap[operation];
|
||||
|
||||
// Build args array based on operation
|
||||
const args = [varName];
|
||||
if (value !== null) {
|
||||
args.push(value);
|
||||
}
|
||||
|
||||
const range = this.#getMacroRange(macroNode);
|
||||
|
||||
/** @type {MacroCall} */
|
||||
const call = {
|
||||
name: targetMacroName,
|
||||
args,
|
||||
flags: createEmptyFlags(),
|
||||
isScoped: false,
|
||||
isVariableShorthand: true,
|
||||
rawInner: text.slice(
|
||||
(/** @type {IToken|undefined} */ (children['Macro.Start']?.[0])?.endOffset ?? range.startOffset) + 1,
|
||||
(/** @type {IToken|undefined} */ (children['Macro.End']?.[0])?.startOffset ?? range.endOffset + 1) - 1,
|
||||
),
|
||||
rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
|
||||
rawArgs: args,
|
||||
range,
|
||||
cstNode: macroNode,
|
||||
env,
|
||||
};
|
||||
|
||||
const result = resolveMacro(call);
|
||||
return typeof result === 'string' ? result : String(result ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the value part of a variable expression (after = or +=).
|
||||
* Resolves any nested macros in the value.
|
||||
*
|
||||
* @param {Record<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
|
||||
* the original argument text.
|
||||
@@ -759,13 +939,24 @@ class MacroCstWalker {
|
||||
*/
|
||||
#extractMacroInfo(macroNode) {
|
||||
const children = macroNode.children || {};
|
||||
const identifierTokens = /** @type {IToken[]} */ (children['Macro.identifier'] || []);
|
||||
|
||||
// Check if this is a variable expression - they can't be scoped
|
||||
const variableExprNode = (children.variableExpr || [])[0];
|
||||
if (variableExprNode) {
|
||||
return null; // Variable expressions don't support scoped content
|
||||
}
|
||||
|
||||
// Regular macro - get info from macroBody
|
||||
const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]);
|
||||
const bodyChildren = macroBodyNode?.children || {};
|
||||
|
||||
const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []);
|
||||
const name = identifierTokens[0]?.image || '';
|
||||
|
||||
if (!name) return null;
|
||||
|
||||
// Check for closing block flag
|
||||
const flagTokens = /** @type {IToken[]} */ (children['flags'] || []);
|
||||
// Check for closing block flag (inside macroBody)
|
||||
const flagTokens = /** @type {IToken[]} */ (children.flags || []);
|
||||
const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
|
||||
|
||||
return { name, isClosing };
|
||||
@@ -786,9 +977,11 @@ class MacroCstWalker {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Count current arguments in the macro
|
||||
// Count current arguments in the macro (now inside macroBody)
|
||||
const children = macroNode.children || {};
|
||||
const argumentsNode = /** @type {CstNode?} */ ((children.arguments || [])[0]);
|
||||
const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]);
|
||||
const bodyChildren = macroBodyNode?.children || {};
|
||||
const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
|
||||
const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
|
||||
const currentArgCount = argumentNodes.length;
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
* @property {boolean} filter - Whether the filter (`>`) flag is set.
|
||||
* @property {boolean} closingBlock - Whether the closing block (`/`) flag is set.
|
||||
* @property {boolean} preserveWhitespace - Whether the preserve whitespace (`#`) flag is set.
|
||||
* @property {boolean} varDot - Whether the variable dot (`.`) flag is set.
|
||||
* @property {boolean} varDollar - Whether the variable dollar (`$`) flag is set.
|
||||
* @property {string[]} raw - The raw flag symbols in order of appearance.
|
||||
*/
|
||||
|
||||
@@ -74,19 +72,8 @@ export const MacroFlagType = Object.freeze({
|
||||
*/
|
||||
PRESERVE_WHITESPACE: '#',
|
||||
|
||||
/**
|
||||
* Variable shorthand flag (`.`).
|
||||
* Shorthand for variable access: `{{.myvar}}` equivalent to `{{getvar::myvar}}`.
|
||||
* @status TBD - Not implemented in v1
|
||||
*/
|
||||
VAR_DOT: '.',
|
||||
|
||||
/**
|
||||
* Variable shorthand flag (`$`).
|
||||
* Alternative shorthand for variable access: `{{$myvar}}`.
|
||||
* @status TBD - Not implemented in v1
|
||||
*/
|
||||
VAR_DOLLAR: '$',
|
||||
// Note: Variable shorthand (. and $) are NOT flags - they are special prefixes
|
||||
// that trigger the variable expression parsing branch. See MacroLexer.js Var tokens.
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -146,20 +133,6 @@ export const MacroFlagDefinitions = new Map([
|
||||
implemented: true,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.VAR_DOT, {
|
||||
type: MacroFlagType.VAR_DOT,
|
||||
name: 'Variable (dot)',
|
||||
description: 'Shorthand for variable access using dot notation.',
|
||||
implemented: false,
|
||||
affectsParser: false,
|
||||
}],
|
||||
[MacroFlagType.VAR_DOLLAR, {
|
||||
type: MacroFlagType.VAR_DOLLAR,
|
||||
name: 'Variable (dollar)',
|
||||
description: 'Shorthand for variable access using dollar notation.',
|
||||
implemented: false,
|
||||
affectsParser: false,
|
||||
}],
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -182,8 +155,6 @@ export function createEmptyFlags() {
|
||||
filter: false,
|
||||
closingBlock: false,
|
||||
preserveWhitespace: false,
|
||||
varDot: false,
|
||||
varDollar: false,
|
||||
raw: [],
|
||||
};
|
||||
}
|
||||
@@ -217,12 +188,6 @@ export function parseFlags(flagSymbols) {
|
||||
case MacroFlagType.PRESERVE_WHITESPACE:
|
||||
flags.preserveWhitespace = true;
|
||||
break;
|
||||
case MacroFlagType.VAR_DOT:
|
||||
flags.varDot = true;
|
||||
break;
|
||||
case MacroFlagType.VAR_DOLLAR:
|
||||
flags.varDollar = true;
|
||||
break;
|
||||
default:
|
||||
console.warn(`Can't parse unknown macro flag: ${symbol}`);
|
||||
}
|
||||
|
||||
@@ -16,28 +16,42 @@ const IDENTIFIER_LEXER_PATTERN = /[a-zA-Z][\w-_]*/;
|
||||
*/
|
||||
export const MACRO_IDENTIFIER_PATTERN = /^[a-zA-Z][\w-_]*$/;
|
||||
|
||||
/**
|
||||
* Pattern for valid variable shorthand identifiers.
|
||||
* Must start with a letter, followed by word chars (letters, digits, underscore) or hyphens,
|
||||
* but must end with a word character (not a hyphen).
|
||||
*
|
||||
* Used for variable shorthand syntax like .varName or $varName.
|
||||
*/
|
||||
export const MACRO_VARIABLE_SHORTHAND_PATTERN = /[a-zA-Z](?:[\w\-_]*[\w])?/;
|
||||
|
||||
/** @enum {string} */
|
||||
const modes = {
|
||||
const modes = Object.freeze({
|
||||
plaintext: 'plaintext_mode',
|
||||
macro_def: 'macro_def_mode',
|
||||
macro_identifier_end: 'macro_identifier_end_mode',
|
||||
macro_args: 'macro_args_mode',
|
||||
macro_filter_modifer: 'macro_filter_modifer_mode',
|
||||
macro_filter_modifier_end: 'macro_filter_modifier_end_mode',
|
||||
};
|
||||
// Variable shorthand modes
|
||||
var_identifier: 'var_identifier_mode',
|
||||
var_after_identifier: 'var_after_identifier_mode',
|
||||
var_value: 'var_value_mode',
|
||||
});
|
||||
|
||||
/** @readonly */
|
||||
const Tokens = {
|
||||
// General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'.
|
||||
/**
|
||||
* All lexer tokens used by the macro parser.
|
||||
* @readonly
|
||||
*/
|
||||
const Tokens = Object.freeze({
|
||||
/** General capture-all plaintext without macros. Consumes any character that is not the first '{' of a macro opener '{{'. */
|
||||
Plaintext: createToken({ name: 'Plaintext', pattern: /(?:[^{]|\{(?!\{))+/u, line_breaks: true }),
|
||||
// Single literal '{' that appears immediately before a macro opener '{{'.
|
||||
/** Single literal '{' that appears immediately before a macro opener '{{' */
|
||||
PlaintextOpenBrace: createToken({ name: 'Plaintext.OpenBrace', pattern: /\{(?=\{\{)/ }),
|
||||
|
||||
// General macro capture
|
||||
/** General macro capture */
|
||||
Macro: {
|
||||
Start: createToken({ name: 'Macro.Start', pattern: /\{\{/ }),
|
||||
// Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
|
||||
// We need this, because this token is going to switch lexer mode, while the general identifier does not.
|
||||
/**
|
||||
* Macro execution flags - special symbols that modify macro resolution behavior.
|
||||
* - `!` = immediate resolve (TBD)
|
||||
@@ -45,24 +59,26 @@ const Tokens = {
|
||||
* - `~` = re-evaluate (TBD)
|
||||
* - `/` = closing block marker for scoped macros
|
||||
* - `#` = preserve whitespace (don't auto-trim scoped content), also legacy handlebars compatibility
|
||||
* - `.` = variable shorthand (TBD)
|
||||
* - `$` = variable shorthand alternative (TBD)
|
||||
*/
|
||||
Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/.$]/ }),
|
||||
Flags: createToken({ name: 'Macro.Flag', pattern: /[!?~#/]/ }),
|
||||
/**
|
||||
* Filter flag (`>`) - separate token because it changes parsing behavior.
|
||||
* When present, `|` characters inside the macro are treated as filter/pipe operators.
|
||||
*/
|
||||
FilterFlag: createToken({ name: 'Macro.FilterFlag', pattern: />/ }),
|
||||
DoubleSlash: createToken({ name: 'Macro.DoubleSlash', pattern: /\/\// }),
|
||||
/**
|
||||
* Separate macro identifier needed, that is similar to the global indentifier, but captures the actual macro "name"
|
||||
* We need this, because this token is going to switch lexer mode, while the general identifier does not.
|
||||
*/
|
||||
Identifier: createToken({ name: 'Macro.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
|
||||
// At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
|
||||
/** At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces */
|
||||
EndOfIdentifier: createToken({ name: 'Macro.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
|
||||
BeforeEnd: createToken({ name: 'Macro.BeforeEnd', pattern: /(?=\}\})/, group: Lexer.SKIPPED }),
|
||||
End: createToken({ name: 'Macro.End', pattern: /\}\}/ }),
|
||||
},
|
||||
|
||||
// Captures that only appear inside arguments
|
||||
/** Captures that only appear inside arguments */
|
||||
Args: {
|
||||
DoubleColon: createToken({ name: 'Args.DoubleColon', pattern: /::/ }),
|
||||
Colon: createToken({ name: 'Args.Colon', pattern: /:/ }),
|
||||
@@ -74,7 +90,7 @@ const Tokens = {
|
||||
EscapedPipe: createToken({ name: 'Filter.EscapedPipe', pattern: /\\\|/ }),
|
||||
Pipe: createToken({ name: 'Filter.Pipe', pattern: /\|/ }),
|
||||
Identifier: createToken({ name: 'Filter.Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
|
||||
// At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces
|
||||
/** At the end of an identifier, there has to be whitspace, or must be directly followed by colon/double-colon separator, output modifier or closing braces */
|
||||
EndOfIdentifier: createToken({ name: 'Filter.EndOfIdentifier', pattern: /(?:\s+|(?=:{1,2})|(?=[|}]))/, group: Lexer.SKIPPED }),
|
||||
},
|
||||
|
||||
@@ -82,22 +98,54 @@ const Tokens = {
|
||||
Identifier: createToken({ name: 'Identifier', pattern: IDENTIFIER_LEXER_PATTERN }),
|
||||
WhiteSpace: createToken({ name: 'WhiteSpace', pattern: /\s+/, group: Lexer.SKIPPED }),
|
||||
|
||||
// Capture unknown characters one by one, to still allow other tokens being matched once they are there.
|
||||
// This includes any possible braces that is not the double closing braces as MacroEnd.
|
||||
/** Variable shorthand tokens */
|
||||
Var: {
|
||||
/** Local variable prefix (`.`) - triggers variable shorthand for local variables */
|
||||
LocalPrefix: createToken({ name: 'Var.LocalPrefix', pattern: /\./ }),
|
||||
/** Global variable prefix (`$`) - triggers variable shorthand for global variables */
|
||||
GlobalPrefix: createToken({ name: 'Var.GlobalPrefix', pattern: /\$/ }),
|
||||
/**
|
||||
* Variable identifier - allows hyphens inside but not at the end to avoid conflict with -- operator.
|
||||
* Pattern: starts with letter, optionally followed by word chars/hyphens, but must end with word char.
|
||||
* Examples: myVar, my-var, my_var, myVar123, my-long-var-name
|
||||
* Invalid: my-, my--, -var
|
||||
*/
|
||||
Identifier: createToken({ name: 'Var.Identifier', pattern: MACRO_VARIABLE_SHORTHAND_PATTERN }),
|
||||
/** Increment operator (`++`) */
|
||||
Increment: createToken({ name: 'Var.Increment', pattern: /\+\+/ }),
|
||||
/** Decrement operator (`--`) */
|
||||
Decrement: createToken({ name: 'Var.Decrement', pattern: /--/ }),
|
||||
/** Add/append operator (`+=`) - must come before Equals to avoid conflict */
|
||||
PlusEquals: createToken({ name: 'Var.PlusEquals', pattern: /\+=/ }),
|
||||
/** Set operator (`=`) */
|
||||
Equals: createToken({ name: 'Var.Equals', pattern: /=/ }),
|
||||
},
|
||||
|
||||
/**
|
||||
* Capture unknown characters one by one, to still allow other tokens being matched once they are there.
|
||||
* This includes any possible braces that is not the double closing braces as MacroEnd.
|
||||
*/
|
||||
Unknown: createToken({ name: 'Unknown', pattern: /([^}]|\}(?!\}))/ }),
|
||||
|
||||
// TODO: Capture-all rest for now, that is not the macro end or opening of a new macro. Might be replaced later down the line.
|
||||
/** TODO: Capture-all rest for now, that is not the macro end or opening of a new macro. Might be replaced later down the line. */
|
||||
Text: createToken({ name: 'Text', pattern: /.+(?=\}\}|\{\{)/, line_breaks: true }),
|
||||
|
||||
// DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches.
|
||||
// Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc.
|
||||
// Has to ALWAYS be the last token.
|
||||
/**
|
||||
* DANGER ZONE: Careful with this token. This is used as a way to pop the current mode, if no other token matches.
|
||||
* Can be used in modes that don't have a "defined" end really, like when capturing a single argument, argument list, etc.
|
||||
* Has to ALWAYS be the last token.
|
||||
*/
|
||||
ModePopper: createToken({ name: 'ModePopper', pattern: () => [''], line_breaks: false, group: Lexer.SKIPPED }),
|
||||
};
|
||||
});
|
||||
|
||||
/** @type {Map<string,string>} Saves all token definitions that are marked as entering modes */
|
||||
const enterModesMap = new Map();
|
||||
|
||||
/**
|
||||
* Lexer definition object that maps states/modes to their token rules.
|
||||
* Each mode defines which tokens are valid in that context and how to transition between modes.
|
||||
* @readonly
|
||||
*/
|
||||
const Def = {
|
||||
modes: {
|
||||
[modes.plaintext]: [
|
||||
@@ -111,6 +159,11 @@ const Def = {
|
||||
// An explicit double-slash will be treated above flags to consume, as it'll introduce a comment macro. Directly following is the args then.
|
||||
enter(Tokens.Macro.DoubleSlash, modes.macro_args),
|
||||
|
||||
// Variable shorthand prefixes - must come before flags to take precedence
|
||||
// These enter the variable identifier mode to parse variable expressions
|
||||
enter(Tokens.Var.LocalPrefix, modes.var_identifier),
|
||||
enter(Tokens.Var.GlobalPrefix, modes.var_identifier),
|
||||
|
||||
using(Tokens.Macro.Flags),
|
||||
// Filter flag is separate because it affects parsing behavior for pipes
|
||||
using(Tokens.Macro.FilterFlag),
|
||||
@@ -165,6 +218,40 @@ const Def = {
|
||||
exits(Tokens.Macro.BeforeEnd, modes.macro_identifier_end),
|
||||
exits(Tokens.Filter.EndOfIdentifier, modes.macro_filter_modifer),
|
||||
],
|
||||
|
||||
// After seeing `.` or `$`, expect a variable identifier
|
||||
[modes.var_identifier]: [
|
||||
using(Tokens.WhiteSpace),
|
||||
// Consume the variable identifier and move to operator detection
|
||||
enter(Tokens.Var.Identifier, modes.var_after_identifier, { andExits: modes.var_identifier }),
|
||||
// If no valid identifier found, exit back (will result in parser error)
|
||||
exits(Tokens.ModePopper, modes.var_identifier),
|
||||
],
|
||||
// After the variable identifier, look for operators or end
|
||||
[modes.var_after_identifier]: [
|
||||
using(Tokens.WhiteSpace),
|
||||
// Check for operators
|
||||
using(Tokens.Var.Increment),
|
||||
using(Tokens.Var.Decrement),
|
||||
enter(Tokens.Var.PlusEquals, modes.var_value, { andExits: modes.var_after_identifier }),
|
||||
enter(Tokens.Var.Equals, modes.var_value, { andExits: modes.var_after_identifier }),
|
||||
// If we see the end, exit
|
||||
exits(Tokens.Macro.BeforeEnd, modes.var_after_identifier),
|
||||
// Fallback exit
|
||||
exits(Tokens.ModePopper, modes.var_after_identifier),
|
||||
],
|
||||
// After `=` or `+=`, capture the value (can contain nested macros)
|
||||
[modes.var_value]: [
|
||||
// Nested macros in value
|
||||
enter(Tokens.Macro.Start, modes.macro_def),
|
||||
|
||||
using(Tokens.Identifier),
|
||||
using(Tokens.WhiteSpace),
|
||||
using(Tokens.Unknown),
|
||||
|
||||
// Exit when we're about to see the end
|
||||
exits(Tokens.ModePopper, modes.var_value),
|
||||
],
|
||||
},
|
||||
defaultMode: modes.plaintext,
|
||||
};
|
||||
|
||||
@@ -43,7 +43,7 @@ class MacroParser extends CstParser {
|
||||
});
|
||||
});
|
||||
|
||||
// Basic Macro Structure
|
||||
// Basic Macro Structure - can be either a regular macro or a variable expression
|
||||
$.macro = $.RULE('macro', () => {
|
||||
$.CONSUME(Tokens.Macro.Start);
|
||||
|
||||
@@ -56,13 +56,72 @@ class MacroParser extends CstParser {
|
||||
]);
|
||||
});
|
||||
|
||||
// Branch: either a variable expression (starts with . or $) or a regular macro
|
||||
$.OR([
|
||||
// Variable expression branch
|
||||
{ ALT: () => $.SUBRULE($.variableExpr) },
|
||||
// Regular macro branch
|
||||
{ ALT: () => $.SUBRULE($.macroBody) },
|
||||
]);
|
||||
|
||||
$.CONSUME(Tokens.Macro.End);
|
||||
});
|
||||
|
||||
// Regular macro body (flags + identifier + optional arguments)
|
||||
$.macroBody = $.RULE('macroBody', () => {
|
||||
// Macro identifier (name)
|
||||
$.OR2([
|
||||
{ ALT: () => $.CONSUME(Tokens.Macro.DoubleSlash, { LABEL: 'Macro.identifier' }) },
|
||||
{ ALT: () => $.CONSUME(Tokens.Macro.Identifier, { LABEL: 'Macro.identifier' }) },
|
||||
]);
|
||||
$.OPTION(() => $.SUBRULE($.arguments));
|
||||
$.CONSUME(Tokens.Macro.End);
|
||||
});
|
||||
|
||||
// Variable expression: .varName or $varName with optional operator
|
||||
$.variableExpr = $.RULE('variableExpr', () => {
|
||||
// Variable scope prefix
|
||||
$.OR3([
|
||||
{ ALT: () => $.CONSUME(Tokens.Var.LocalPrefix, { LABEL: 'Var.scope' }) },
|
||||
{ ALT: () => $.CONSUME(Tokens.Var.GlobalPrefix, { LABEL: 'Var.scope' }) },
|
||||
]);
|
||||
|
||||
// Variable identifier (name)
|
||||
$.CONSUME(Tokens.Var.Identifier, { LABEL: 'Var.identifier' });
|
||||
|
||||
// Optional operator
|
||||
$.OPTION2(() => $.SUBRULE($.variableOperator));
|
||||
});
|
||||
|
||||
// Variable operator: ++, --, = value, += value
|
||||
$.variableOperator = $.RULE('variableOperator', () => {
|
||||
$.OR4([
|
||||
{ ALT: () => $.CONSUME(Tokens.Var.Increment, { LABEL: 'Var.operator' }) },
|
||||
{ ALT: () => $.CONSUME(Tokens.Var.Decrement, { LABEL: 'Var.operator' }) },
|
||||
{
|
||||
ALT: () => {
|
||||
$.CONSUME(Tokens.Var.Equals, { LABEL: 'Var.operator' });
|
||||
$.SUBRULE($.variableValue, { LABEL: 'Var.value' });
|
||||
},
|
||||
},
|
||||
{
|
||||
ALT: () => {
|
||||
$.CONSUME(Tokens.Var.PlusEquals, { LABEL: 'Var.operator' });
|
||||
$.SUBRULE2($.variableValue, { LABEL: 'Var.value' });
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
// Variable value: everything after = or += until the end
|
||||
// Can contain nested macros and any other tokens
|
||||
$.variableValue = $.RULE('variableValue', () => {
|
||||
$.MANY2(() => {
|
||||
$.OR5([
|
||||
{ ALT: () => $.SUBRULE($.macro) }, // Nested macros
|
||||
{ ALT: () => $.CONSUME(Tokens.Identifier) },
|
||||
{ ALT: () => $.CONSUME(Tokens.Unknown) },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Arguments Parsing
|
||||
|
||||
@@ -15,7 +15,19 @@ import { SlashCommandAbortController } from './SlashCommandAbortController.js';
|
||||
import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
|
||||
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
|
||||
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
import { EnhancedMacroAutoCompleteOption, MacroFlagAutoCompleteOption, MacroClosingTagAutoCompleteOption, parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
|
||||
import {
|
||||
EnhancedMacroAutoCompleteOption,
|
||||
MacroFlagAutoCompleteOption,
|
||||
MacroClosingTagAutoCompleteOption,
|
||||
VariableShorthandAutoCompleteOption,
|
||||
VariableShorthandDefinitions,
|
||||
VariableNameAutoCompleteOption,
|
||||
VariableOperatorAutoCompleteOption,
|
||||
VariableOperatorDefinitions,
|
||||
isValidVariableShorthandName,
|
||||
parseMacroContext,
|
||||
SimpleAutoCompleteOption,
|
||||
} from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
|
||||
import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
|
||||
import { MacroParser } from '../macros/engine/MacroParser.js';
|
||||
import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
|
||||
@@ -25,6 +37,8 @@ import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
|
||||
import { SlashCommandBreak } from './SlashCommandBreak.js';
|
||||
import { macros as macroSystem } from '../macros/macro-system.js';
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
import { chat_metadata } from '/script.js';
|
||||
import { extension_settings } from '../extensions.js';
|
||||
|
||||
/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
|
||||
/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
|
||||
@@ -566,29 +580,138 @@ export class SlashCommandParser {
|
||||
} else {
|
||||
conditionStartOffset = context.identifierStart + identifier.length;
|
||||
}
|
||||
const conditionStartInText = macro.start + 2 + conditionStartOffset;
|
||||
let conditionStartInText = macro.start + 2 + conditionStartOffset;
|
||||
|
||||
// Build if-condition options using macroContent for padding calculation
|
||||
const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
|
||||
const options = this.#buildIfConditionOptions(context, allMacros, macroContent);
|
||||
|
||||
// For variable shorthand in {{if}} condition, adjust identifier and start position
|
||||
// Same fix as for regular variable shorthands - identifier must be just the var name
|
||||
// Also handle ! inversion prefix: !.var or !$var or !macroName
|
||||
const trimmedCondition = conditionText.trim();
|
||||
const hasInversion = trimmedCondition.startsWith('!');
|
||||
// Trim whitespace after ! to handle "! $myvar" syntax
|
||||
const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition;
|
||||
const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
|
||||
let resultIdentifier = conditionText;
|
||||
let resultStart = conditionStartInText;
|
||||
|
||||
if (isTypingVarShorthand) {
|
||||
// Identifier = just the variable name part (without prefix and without !)
|
||||
resultIdentifier = conditionAfterInversion.slice(1);
|
||||
// Start = after the ! (if any) and the prefix
|
||||
const prefixChar = conditionAfterInversion[0];
|
||||
const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0);
|
||||
resultStart = conditionStartInText + prefixPosInCondition + 1;
|
||||
} else if (hasInversion && conditionAfterInversion.length === 0) {
|
||||
// Just ! (possibly with whitespace) typed - identifier should be empty so other options can match
|
||||
resultIdentifier = '';
|
||||
// Start at end of actual condition text (including any whitespace after !)
|
||||
// This ensures cursor is within the name range for filtering
|
||||
resultStart = conditionStartInText + conditionText.length;
|
||||
} else if (hasInversion && conditionAfterInversion.length > 0) {
|
||||
// Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name
|
||||
resultIdentifier = conditionAfterInversion;
|
||||
// Start = after the ! and any whitespace, at the beginning of the macro name
|
||||
const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion);
|
||||
resultStart = conditionStartInText + macroNameStart;
|
||||
}
|
||||
|
||||
const result = new AutoCompleteNameResult(
|
||||
conditionText,
|
||||
conditionStartInText,
|
||||
resultIdentifier,
|
||||
resultStart,
|
||||
options,
|
||||
false,
|
||||
() => 'Use {{macro}} syntax for dynamic conditions',
|
||||
() => 'Enter a macro name or {{macro}} for the condition',
|
||||
() => isTypingVarShorthand
|
||||
? 'Enter a variable name for the condition'
|
||||
: 'Use {{macro}} syntax for dynamic conditions',
|
||||
() => isTypingVarShorthand
|
||||
? 'Enter a variable name or select from the list'
|
||||
: 'Enter a macro name or {{macro}} for the condition',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @type {()=>string|undefined} */
|
||||
let makeNoMatchText = undefined;
|
||||
/** @type {()=>string|undefined} */
|
||||
let makeNoOptionsText = undefined;
|
||||
|
||||
const options = this.#buildEnhancedMacroOptions(context, textUpToCursor);
|
||||
|
||||
// For variable shorthands, calculate the correct identifier and start position
|
||||
// based on what the user is currently typing (variable name, operator, or value)
|
||||
let resultIdentifier = identifier;
|
||||
let resultStart = identifierStartInText;
|
||||
if (context.isVariableShorthand && context.variablePrefix) {
|
||||
// Find where the prefix is in the macro content
|
||||
const prefixIndex = macroContent.indexOf(context.variablePrefix);
|
||||
|
||||
if (context.isTypingVariableName) {
|
||||
// Typing variable name: identifier = variableName, start = after prefix
|
||||
resultIdentifier = context.variableName;
|
||||
if (prefixIndex >= 0) {
|
||||
resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
|
||||
}
|
||||
} else if (context.isTypingOperator) {
|
||||
// Typing operator: identifier = partial operator text (if any), start = after variable name
|
||||
// Using partial operator as identifier ensures cursor is within name range for filtering
|
||||
resultIdentifier = context.partialOperator || '';
|
||||
if (prefixIndex >= 0) {
|
||||
// Start after prefix + variable name length
|
||||
resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length;
|
||||
// If no partial operator (just whitespace after var name), set start to cursor
|
||||
// This ensures cursor is in the name range for filtering
|
||||
if (!context.partialOperator) {
|
||||
resultStart = index;
|
||||
}
|
||||
}
|
||||
} else if (context.isOperatorComplete) {
|
||||
// Operator complete (++ or --) - show context but no value input needed
|
||||
resultIdentifier = '';
|
||||
resultStart = index; // Cursor at end
|
||||
} else if (context.hasInvalidTrailingChars) {
|
||||
// Invalid chars after variable name: show the invalid chars for warning
|
||||
resultIdentifier = context.invalidTrailingChars || '';
|
||||
if (prefixIndex >= 0) {
|
||||
resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length;
|
||||
}
|
||||
} else if (context.isTypingValue) {
|
||||
// Typing value: identifier = value being typed, start = after operator
|
||||
resultIdentifier = context.variableValue;
|
||||
if (prefixIndex >= 0) {
|
||||
const operatorLen = context.variableOperator?.length ?? 0;
|
||||
resultStart = macro.start + 2 + prefixIndex + 1 + context.variableName.length + operatorLen;
|
||||
// Skip any whitespace between operator and value
|
||||
while (resultStart < index && /\s/.test(text[resultStart])) {
|
||||
resultStart++;
|
||||
}
|
||||
|
||||
makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
|
||||
makeNoOptionsText = () => 'Enter a variable value';
|
||||
}
|
||||
} else {
|
||||
// Fallback: use variable name
|
||||
resultIdentifier = context.variableName;
|
||||
if (prefixIndex >= 0) {
|
||||
resultStart = macro.start + 2 + prefixIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!makeNoMatchText && !makeNoOptionsText) {
|
||||
makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.';
|
||||
makeNoOptionsText = () => 'Enter a variable name to create or use a new variable';
|
||||
}
|
||||
}
|
||||
|
||||
const result = new AutoCompleteNameResult(
|
||||
identifier,
|
||||
identifierStartInText,
|
||||
resultIdentifier,
|
||||
resultStart,
|
||||
options,
|
||||
false,
|
||||
makeNoMatchText,
|
||||
makeNoOptionsText,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
@@ -669,12 +792,17 @@ export class SlashCommandParser {
|
||||
* When typing arguments (after ::), prioritizes the exact macro match.
|
||||
* @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
|
||||
* @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
|
||||
* @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]}
|
||||
* @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
|
||||
*/
|
||||
#buildEnhancedMacroOptions(context, textUpToCursor = '') {
|
||||
/** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption)[]} */
|
||||
/** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
|
||||
const options = [];
|
||||
|
||||
// Handle variable shorthand mode
|
||||
if (context.isVariableShorthand) {
|
||||
return this.#buildVariableShorthandOptions(context);
|
||||
}
|
||||
|
||||
// Check for unclosed scoped macros and suggest closing tags first
|
||||
const unclosedScopes = this.#findUnclosedScopes(textUpToCursor);
|
||||
if (unclosedScopes.length > 0) {
|
||||
@@ -685,11 +813,9 @@ export class SlashCommandParser {
|
||||
|
||||
// If inside a scoped {{if}}, also suggest {{else}}
|
||||
if (innermostScope.name === 'if') {
|
||||
// TODO: TEsting
|
||||
const macroDef = macroSystem.registry.getPrimaryMacro('else');
|
||||
const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
|
||||
elseOption.sortPriority = 2;
|
||||
// const elseOption = new MacroElseAutoCompleteOption();
|
||||
options.push(elseOption);
|
||||
}
|
||||
}
|
||||
@@ -732,6 +858,14 @@ export class SlashCommandParser {
|
||||
flagOption.sortPriority = isSelectable ? 10 : 12;
|
||||
options.push(flagOption);
|
||||
}
|
||||
|
||||
// Add variable shorthand prefix options (. for local, $ for global)
|
||||
// These allow users to type variable shorthands instead of macro names
|
||||
for (const [, varShorthandDef] of VariableShorthandDefinitions) {
|
||||
const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef);
|
||||
varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12)
|
||||
options.push(varOption);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all macros from the registry (excluding hidden aliases)
|
||||
@@ -780,6 +914,187 @@ export class SlashCommandParser {
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds autocomplete options for variable shorthand syntax (.varName or $varName).
|
||||
* @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
|
||||
* @param {Object} [opts] - Optional configuration.
|
||||
* @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).
|
||||
* @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.
|
||||
* @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}
|
||||
*/
|
||||
#buildVariableShorthandOptions(context, opts = {}) {
|
||||
const { forIfCondition = false, paddingAfter = '' } = opts;
|
||||
/** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */
|
||||
const options = [];
|
||||
|
||||
const isLocal = context.variablePrefix === '.';
|
||||
const scope = isLocal ? 'local' : 'global';
|
||||
|
||||
// Always show the typed variable prefix as a non-completable option (like flags do)
|
||||
// This allows the details panel to show information about the prefix
|
||||
const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix);
|
||||
if (prefixDef) {
|
||||
const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
|
||||
prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
|
||||
prefixOption.makeSelectable = false;
|
||||
prefixOption.sortPriority = 1; // Show at top
|
||||
options.push(prefixOption);
|
||||
}
|
||||
|
||||
// If typing the variable name, suggest existing variables
|
||||
if (context.isTypingVariableName) {
|
||||
// Get existing variable names from the appropriate scope
|
||||
// Filter to only include names that are valid for shorthand syntax
|
||||
const existingVariables = this.#getVariableNames(scope)
|
||||
.filter(name => isValidVariableShorthandName(name));
|
||||
|
||||
// Add existing variables that match the typed name
|
||||
for (const varName of existingVariables) {
|
||||
const option = new VariableNameAutoCompleteOption(varName, scope, false);
|
||||
// For {{if}} condition, provide full value with closing braces
|
||||
if (forIfCondition) {
|
||||
option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already.
|
||||
option.makeSelectable = true;
|
||||
}
|
||||
// Variables matching the typed prefix get higher priority
|
||||
if (varName.startsWith(context.variableName)) {
|
||||
option.sortPriority = 3;
|
||||
} else {
|
||||
option.sortPriority = 10;
|
||||
}
|
||||
options.push(option);
|
||||
}
|
||||
|
||||
// If typing a name that doesn't exist, offer to create a new variable
|
||||
// But if the name is invalid for shorthand syntax, show a warning instead
|
||||
if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) {
|
||||
const isInvalid = !isValidVariableShorthandName(context.variableName);
|
||||
const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid);
|
||||
newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning
|
||||
if (isInvalid) {
|
||||
// Make it non-selectable since it can't be used
|
||||
newVarOption.valueProvider = () => '';
|
||||
newVarOption.makeSelectable = false;
|
||||
} else if (forIfCondition) {
|
||||
// For {{if}} condition, provide full value with closing braces
|
||||
newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`;
|
||||
}
|
||||
options.push(newVarOption);
|
||||
}
|
||||
}
|
||||
|
||||
// If there are invalid trailing characters after the variable name, show a warning
|
||||
if (context.hasInvalidTrailingChars) {
|
||||
// Show the full invalid name (variableName + invalidTrailingChars) with a warning
|
||||
const fullInvalidName = context.variableName + (context.invalidTrailingChars || '');
|
||||
const invalidOption = new VariableNameAutoCompleteOption(
|
||||
fullInvalidName,
|
||||
scope,
|
||||
false,
|
||||
true, // isInvalidName - triggers warning display
|
||||
);
|
||||
invalidOption.valueProvider = () => ''; // Don't insert anything
|
||||
invalidOption.makeSelectable = false;
|
||||
invalidOption.sortPriority = 2;
|
||||
invalidOption.matchProvider = () => true; // Always show
|
||||
options.push(invalidOption);
|
||||
// Return early - don't show operators when syntax is invalid
|
||||
return options;
|
||||
}
|
||||
|
||||
// If ready for operator (after variable name), suggest operators
|
||||
if (context.isTypingOperator) {
|
||||
// Show the current variable name as context (already typed)
|
||||
const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
|
||||
varNameOption.valueProvider = () => ''; // Already typed, don't re-insert
|
||||
varNameOption.sortPriority = 2;
|
||||
varNameOption.matchProvider = () => true; // Always show
|
||||
options.push(varNameOption);
|
||||
|
||||
// Then show available operators, filtered by partial prefix if any
|
||||
const partialOp = context.partialOperator || '';
|
||||
for (const [, operatorDef] of VariableOperatorDefinitions) {
|
||||
// Filter by partial operator prefix if user is typing one
|
||||
if (partialOp && !operatorDef.symbol.startsWith(partialOp)) {
|
||||
continue;
|
||||
}
|
||||
const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
|
||||
opOption.sortPriority = 5;
|
||||
// Always match operators when showing operator suggestions
|
||||
opOption.matchProvider = () => true;
|
||||
options.push(opOption);
|
||||
}
|
||||
}
|
||||
|
||||
// If typing value (after = or +=), no autocomplete needed - freeform text
|
||||
// But we can show the current context for reference
|
||||
if (context.isTypingValue) {
|
||||
// Show the current variable name as context
|
||||
const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
|
||||
varNameOption.valueProvider = () => ''; // Context only
|
||||
varNameOption.sortPriority = 2;
|
||||
varNameOption.matchProvider = () => true; // Always show
|
||||
options.push(varNameOption);
|
||||
|
||||
// Show the operator that was used
|
||||
if (context.variableOperator) {
|
||||
const opDef = VariableOperatorDefinitions.get(context.variableOperator);
|
||||
if (opDef) {
|
||||
const opOption = new VariableOperatorAutoCompleteOption(opDef);
|
||||
opOption.valueProvider = () => ''; // Already typed
|
||||
opOption.sortPriority = 3;
|
||||
opOption.matchProvider = () => true; // Always show
|
||||
options.push(opOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If operator is complete (++ or --), show context without value input
|
||||
if (context.isOperatorComplete) {
|
||||
// Show the current variable name as context
|
||||
const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
|
||||
varNameOption.valueProvider = () => ''; // Context only
|
||||
varNameOption.sortPriority = 2;
|
||||
varNameOption.matchProvider = () => true; // Always show
|
||||
options.push(varNameOption);
|
||||
|
||||
// Show the operator that was used
|
||||
if (context.variableOperator) {
|
||||
const opDef = VariableOperatorDefinitions.get(context.variableOperator);
|
||||
if (opDef) {
|
||||
const opOption = new VariableOperatorAutoCompleteOption(opDef);
|
||||
opOption.valueProvider = () => ''; // Already typed
|
||||
opOption.sortPriority = 3;
|
||||
opOption.matchProvider = () => true; // Always show
|
||||
options.push(opOption);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets variable names from the specified scope.
|
||||
* @param {'local'|'global'} scope - The variable scope.
|
||||
* @returns {string[]} Array of variable names.
|
||||
*/
|
||||
#getVariableNames(scope) {
|
||||
try {
|
||||
// Import chat_metadata and extension_settings dynamically to avoid circular deps
|
||||
// These are the same sources used by commonEnumProviders.variables
|
||||
if (scope === 'local') {
|
||||
// Local variables are in chat_metadata.variables
|
||||
return Object.keys(chat_metadata?.variables ?? {});
|
||||
} else {
|
||||
// Global variables are in extension_settings.variables.global
|
||||
return Object.keys(extension_settings?.variables?.global ?? {});
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
|
||||
* @param {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} context
|
||||
@@ -796,6 +1111,87 @@ export class SlashCommandParser {
|
||||
const leadingMatch = macroInnerText.match(/^(\s*)/);
|
||||
const paddingAfter = leadingMatch ? leadingMatch[1] : '';
|
||||
|
||||
// Get the condition text being typed (trimmed for detection)
|
||||
const conditionText = (context.args[0] || '').trim();
|
||||
|
||||
// Check for inversion prefix (!) - also trim whitespace after !
|
||||
const hasInversionPrefix = conditionText.startsWith('!');
|
||||
const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText;
|
||||
|
||||
const inversionOption = new SimpleAutoCompleteOption({
|
||||
name: '!',
|
||||
symbol: '🔁',
|
||||
description: 'Invert condition (NOT)',
|
||||
detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<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
|
||||
for (const macro of allMacros) {
|
||||
// Only include macros that require zero arguments (can be auto-resolved)
|
||||
|
||||
@@ -25,6 +25,7 @@ module.exports = {
|
||||
'node_modules/**/*',
|
||||
],
|
||||
globals: {
|
||||
SillyTavern: 'readonly',
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { args: 'none' }],
|
||||
|
||||
@@ -1765,6 +1765,165 @@ test.describe('MacroEngine', () => {
|
||||
expect(output).toBe('Message (by EnvChar)');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Variable Shorthand Syntax', () => {
|
||||
// {{.myvar}} - get local variable
|
||||
test('should get local variable with . shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.myvar}}', { local: { myvar: 'hello' } });
|
||||
expect(output).toBe('hello');
|
||||
});
|
||||
|
||||
// {{$myvar}} - get global variable
|
||||
test('should get global variable with $ shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{$myvar}}', { global: { myvar: 'world' } });
|
||||
expect(output).toBe('world');
|
||||
});
|
||||
|
||||
// {{.myvar = value}} - set local variable (setvar returns empty string)
|
||||
test('should set local variable with = shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.myvar = test}}Value: {{.myvar}}', { local: {} });
|
||||
// setvar returns '', then "Value: ", then getvar returns "test"
|
||||
expect(output).toBe('Value: test');
|
||||
});
|
||||
|
||||
// {{.counter++}} - increment local variable (incvar returns new value)
|
||||
test('should increment local variable with ++ shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.counter++}}', { local: { counter: '5' } });
|
||||
expect(output).toBe('6');
|
||||
});
|
||||
|
||||
// {{$counter--}} - decrement global variable (decvar returns new value)
|
||||
test('should decrement global variable with -- shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{$counter--}}', { global: { counter: '10' } });
|
||||
expect(output).toBe('9');
|
||||
});
|
||||
|
||||
// {{.myvar += 5}} - add to local variable (addvar returns empty string)
|
||||
test('should add to local variable with += shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.myvar += 3}}Then: {{.myvar}}', { local: { myvar: '7' } });
|
||||
// addvar returns '', then "Then: ", then getvar returns "10"
|
||||
expect(output).toBe('Then: 10');
|
||||
});
|
||||
|
||||
// Nested macro in value: {{.myvar = {{user}}}}
|
||||
test('should support nested macro in variable value', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.greeting = Hello {{user}}}}{{.greeting}}', { local: {} });
|
||||
// setvar returns '', then getvar returns "Hello User"
|
||||
expect(output).toBe('Hello User');
|
||||
});
|
||||
|
||||
// Whitespace handling: {{ .myvar = value }}
|
||||
test('should handle whitespace in variable shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{ .myvar = spaced }}{{.myvar}}', { local: {} });
|
||||
// setvar returns '', then getvar returns "spaced"
|
||||
expect(output).toBe('spaced');
|
||||
});
|
||||
|
||||
// Variable with hyphen in name: {{.my-var}}
|
||||
test('should handle variable name with hyphens', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.my-var}}', { local: { 'my-var': 'hyphenated' } });
|
||||
expect(output).toBe('hyphenated');
|
||||
});
|
||||
|
||||
// Variable with underscore: {{.my_var}}
|
||||
test('should handle variable name with underscores', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.my_var}}', { local: { 'my_var': 'underscored' } });
|
||||
expect(output).toBe('underscored');
|
||||
});
|
||||
|
||||
// Non-existent variable returns empty string
|
||||
test('should return empty string for non-existent variable', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, 'Value:[{{.nonexistent}}]', { local: {} });
|
||||
expect(output).toBe('Value:[]');
|
||||
});
|
||||
|
||||
// Increment non-existent variable (should start from 0)
|
||||
test('should increment non-existent variable starting from 0', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.newcounter++}}', { local: {} });
|
||||
expect(output).toBe('1');
|
||||
});
|
||||
|
||||
// Chain multiple operations
|
||||
test('should handle multiple variable operations in sequence', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.x = 5}}{{.x++}}{{.x += 10}}{{.x}}', { local: {} });
|
||||
// setvar returns '', incvar returns '6', addvar returns '', getvar returns '16'
|
||||
expect(output).toBe('616');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Variable Shorthand in {{if}} Macro', () => {
|
||||
// {{if .myvar}}...{{/if}} - truthy local variable
|
||||
test('should evaluate truthy local variable in if condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '1' } });
|
||||
expect(output).toBe('Yes');
|
||||
});
|
||||
|
||||
// {{if .myvar}}...{{/if}} - falsy local variable
|
||||
test('should evaluate falsy local variable in if condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if .flag}}Yes{{/if}}', { local: { flag: '' } });
|
||||
expect(output).toBe('');
|
||||
});
|
||||
|
||||
// {{if $globalvar}}...{{/if}} - truthy global variable
|
||||
test('should evaluate truthy global variable in if condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if $enabled}}Active{{/if}}', { global: { enabled: 'true' } });
|
||||
expect(output).toBe('Active');
|
||||
});
|
||||
|
||||
// {{if !.myvar}}...{{/if}} - inverted condition
|
||||
test('should evaluate inverted variable condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if !.flag}}Not set{{/if}}', { local: { flag: '' } });
|
||||
expect(output).toBe('Not set');
|
||||
});
|
||||
|
||||
// {{if !$globalvar}}...{{/if}} - inverted global
|
||||
test('should evaluate inverted global variable condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if !$disabled}}Enabled{{/if}}', { global: { disabled: '' } });
|
||||
expect(output).toBe('Enabled');
|
||||
});
|
||||
|
||||
// {{if ! .myvar}}...{{/if}} - inverted with whitespace
|
||||
test('should evaluate inverted condition with whitespace after !', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if ! .empty}}Empty{{/if}}', { local: { empty: '' } });
|
||||
expect(output).toBe('Empty');
|
||||
});
|
||||
|
||||
// Non-existent variable is falsy
|
||||
test('should treat non-existent variable as falsy in if condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if .nonexistent}}Yes{{else}}No{{/if}}', { local: {} });
|
||||
expect(output).toBe('No');
|
||||
});
|
||||
|
||||
// {{if .myvar}}...{{else}}...{{/if}} - with else branch
|
||||
test('should handle else branch with variable shorthand', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if .active}}On{{else}}Off{{/if}}', { local: { active: 'yes' } });
|
||||
expect(output).toBe('On');
|
||||
});
|
||||
|
||||
// Variable with hyphen in if condition
|
||||
test('should handle variable with hyphen in if condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if .is-valid}}Valid{{/if}}', { local: { 'is-valid': '1' } });
|
||||
expect(output).toBe('Valid');
|
||||
});
|
||||
|
||||
// Combine set and if
|
||||
test('should work with variable set before if check', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{.ready = yes}}{{if .ready}}Ready!{{/if}}', { local: {} });
|
||||
expect(output).toBe('Ready!');
|
||||
});
|
||||
|
||||
// Zero is falsy
|
||||
test('should treat zero as falsy in if condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Has count{{else}}No count{{/if}}', { local: { count: '0' } });
|
||||
expect(output).toBe('No count');
|
||||
});
|
||||
|
||||
// Non-zero number is truthy
|
||||
test('should treat non-zero number as truthy in if condition', async ({ page }) => {
|
||||
const output = await evaluateWithEngineAndVariables(page, '{{if .count}}Count: {{.count}}{{/if}}', { local: { count: '42' } });
|
||||
expect(output).toBe('Count: 42');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1833,3 +1992,51 @@ async function evaluateWithEngineAndCaptureMacroLogs(page, input) {
|
||||
page.off('console', handler);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates the given input string with pre-set variables.
|
||||
* Variables are set via SillyTavern.getContext().variables which is where
|
||||
* the variable macros read/write their data.
|
||||
*
|
||||
* @param {import('@playwright/test').Page} page
|
||||
* @param {string} input
|
||||
* @param {{ local?: Record<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;
|
||||
}
|
||||
|
||||
@@ -567,34 +567,6 @@ test.describe('MacroLexer', () => {
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
// {{.variable}}
|
||||
test('should support . flag', async ({ page }) => {
|
||||
const input = '{{.variable}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Flag', text: '.' },
|
||||
{ type: 'Macro.Identifier', text: 'variable' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
// {{$variable}}
|
||||
test('should support alias $ flag', async ({ page }) => {
|
||||
const input = '{{$variable}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Flag', text: '$' },
|
||||
{ type: 'Macro.Identifier', text: 'variable' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
// {{#legacy}}
|
||||
test('should support legacy # flag', async ({ page }) => {
|
||||
const input = '{{#legacy}}';
|
||||
@@ -640,13 +612,13 @@ test.describe('MacroLexer', () => {
|
||||
});
|
||||
// {{ ! .importantvariable }}
|
||||
test('should support multiple flags with whitespace', async ({ page }) => {
|
||||
const input = '{{ !.importantvariable }}';
|
||||
const input = '{{ !#importantvariable }}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Flag', text: '!' },
|
||||
{ type: 'Macro.Flag', text: '.' },
|
||||
{ type: 'Macro.Flag', text: '#' },
|
||||
{ type: 'Macro.Identifier', text: 'importantvariable' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
@@ -733,6 +705,232 @@ test.describe('MacroLexer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Variable Shorthand Syntax', () => {
|
||||
// {{.variable}} - Local variable get
|
||||
test('should tokenize local variable shorthand', async ({ page }) => {
|
||||
const input = '{{.myvar}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'myvar' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{$variable}} - Global variable get
|
||||
test('should tokenize global variable shorthand', async ({ page }) => {
|
||||
const input = '{{$myvar}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.GlobalPrefix', text: '$' },
|
||||
{ type: 'Var.Identifier', text: 'myvar' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.my-var}} - Variable with hyphen in name
|
||||
test('should tokenize variable with hyphen in name', async ({ page }) => {
|
||||
const input = '{{.my-var}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'my-var' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.counter++}} - Increment operator
|
||||
test('should tokenize increment operator', async ({ page }) => {
|
||||
const input = '{{.counter++}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'counter' },
|
||||
{ type: 'Var.Increment', text: '++' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{$counter--}} - Decrement operator
|
||||
test('should tokenize decrement operator', async ({ page }) => {
|
||||
const input = '{{$counter--}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.GlobalPrefix', text: '$' },
|
||||
{ type: 'Var.Identifier', text: 'counter' },
|
||||
{ type: 'Var.Decrement', text: '--' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.myvar = value}} - Set operator
|
||||
test('should tokenize set operator with value', async ({ page }) => {
|
||||
const input = '{{.myvar = hello}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'myvar' },
|
||||
{ type: 'Var.Equals', text: '=' },
|
||||
{ type: 'Identifier', text: 'hello' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.myvar += 5}} - Add operator
|
||||
test('should tokenize add operator with value', async ({ page }) => {
|
||||
const input = '{{.myvar += 5}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'myvar' },
|
||||
{ type: 'Var.PlusEquals', text: '+=' },
|
||||
{ type: 'Unknown', text: '5' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{ !.importantvariable }} - Variable prefix after flags
|
||||
test('should tokenize variable prefix after flags', async ({ page }) => {
|
||||
const input = '{{ !.importantvariable }}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
// When . is encountered, it triggers variable mode regardless of previous flags
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Flag', text: '!' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'importantvariable' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.my-long-var-name}} - Variable with multiple hyphens
|
||||
test('should tokenize variable with multiple hyphens', async ({ page }) => {
|
||||
const input = '{{.my-long-var-name}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'my-long-var-name' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.myvar = Hello {{user}}}} - Nested macro in value
|
||||
test('should tokenize nested macro in variable value', async ({ page }) => {
|
||||
const input = '{{.myvar = Hello {{user}}}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'myvar' },
|
||||
{ type: 'Var.Equals', text: '=' },
|
||||
{ type: 'Identifier', text: 'Hello' },
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Macro.Identifier', text: 'user' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{ .myvar }} - Whitespace around variable shorthand
|
||||
test('should tokenize variable shorthand with surrounding whitespace', async ({ page }) => {
|
||||
const input = '{{ .myvar }}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'myvar' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{$myVar123}} - Variable with numbers
|
||||
test('should tokenize variable with numbers in name', async ({ page }) => {
|
||||
const input = '{{$myVar123}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.GlobalPrefix', text: '$' },
|
||||
{ type: 'Var.Identifier', text: 'myVar123' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.my_var}} - Variable with underscore
|
||||
test('should tokenize variable with underscore in name', async ({ page }) => {
|
||||
const input = '{{.my_var}}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'my_var' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
|
||||
// {{.counter ++ }} - Increment with whitespace
|
||||
test('should tokenize increment operator with surrounding whitespace', async ({ page }) => {
|
||||
const input = '{{.counter ++ }}';
|
||||
const tokens = await runLexerGetTokens(page, input);
|
||||
|
||||
const expectedTokens = [
|
||||
{ type: 'Macro.Start', text: '{{' },
|
||||
{ type: 'Var.LocalPrefix', text: '.' },
|
||||
{ type: 'Var.Identifier', text: 'counter' },
|
||||
{ type: 'Var.Increment', text: '++' },
|
||||
{ type: 'Macro.End', text: '}}' },
|
||||
];
|
||||
|
||||
expect(tokens).toEqual(expectedTokens);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Macro Output Modifiers', () => {
|
||||
// {{macro | outputModifier}}
|
||||
test('should support output modifier without arguments', async ({ page }) => {
|
||||
|
||||
@@ -667,29 +667,175 @@ This is the second line
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// {{.myvar}} - variable shorthand
|
||||
test('should parse macro with variable dot shorthand flag', async ({ page }) => {
|
||||
test.describe('Variable Shorthand Syntax', () => {
|
||||
// {{.myvar}} - local variable get
|
||||
test('should parse local variable shorthand', async ({ page }) => {
|
||||
const input = '{{.myvar}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '.',
|
||||
'Macro.identifier': 'myvar',
|
||||
'variableExpr': {
|
||||
'Var.scope': '.',
|
||||
'Var.identifier': 'myvar',
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{$myvar}} - variable shorthand
|
||||
test('should parse macro with variable dollar shorthand flag', async ({ page }) => {
|
||||
// {{$myvar}} - global variable get
|
||||
test('should parse global variable shorthand', async ({ page }) => {
|
||||
const input = '{{$myvar}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'flags': '$',
|
||||
'Macro.identifier': 'myvar',
|
||||
'variableExpr': {
|
||||
'Var.scope': '$',
|
||||
'Var.identifier': 'myvar',
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{.my-var}} - variable with hyphen in name
|
||||
test('should parse variable with hyphen in name', async ({ page }) => {
|
||||
const input = '{{.my-var}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'variableExpr': {
|
||||
'Var.scope': '.',
|
||||
'Var.identifier': 'my-var',
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{.myvar = value}} - set operator
|
||||
test('should parse variable set shorthand', async ({ page }) => {
|
||||
const input = '{{.myvar = hello}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'variableExpr': {
|
||||
'Var.scope': '.',
|
||||
'Var.identifier': 'myvar',
|
||||
'variableOperator': {
|
||||
'Var.operator': '=',
|
||||
'Var.value': {
|
||||
'Identifier': 'hello',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{.counter++}} - increment operator
|
||||
test('should parse variable increment shorthand', async ({ page }) => {
|
||||
const input = '{{.counter++}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'variableExpr': {
|
||||
'Var.scope': '.',
|
||||
'Var.identifier': 'counter',
|
||||
'variableOperator': {
|
||||
'Var.operator': '++',
|
||||
},
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{$counter--}} - decrement operator
|
||||
test('should parse global variable decrement shorthand', async ({ page }) => {
|
||||
const input = '{{$counter--}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'variableExpr': {
|
||||
'Var.scope': '$',
|
||||
'Var.identifier': 'counter',
|
||||
'variableOperator': {
|
||||
'Var.operator': '--',
|
||||
},
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{.myvar += 5}} - add operator
|
||||
test('should parse variable add shorthand', async ({ page }) => {
|
||||
const input = '{{.myvar += 5}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'variableExpr': {
|
||||
'Var.scope': '.',
|
||||
'Var.identifier': 'myvar',
|
||||
'variableOperator': {
|
||||
'Var.operator': '+=',
|
||||
'Var.value': {
|
||||
'Unknown': '5',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{.myvar = Hello {{user}}}} - nested macro in value
|
||||
test('should parse nested macro in variable value', async ({ page }) => {
|
||||
const input = '{{.myvar = Hello {{user}}}}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'variableExpr': {
|
||||
'Var.scope': '.',
|
||||
'Var.identifier': 'myvar',
|
||||
'variableOperator': {
|
||||
'Var.operator': '=',
|
||||
'Var.value': {
|
||||
'Identifier': 'Hello',
|
||||
'macro': {
|
||||
'Macro.Start': '{{',
|
||||
'Macro.identifier': 'user',
|
||||
'Macro.End': '}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
|
||||
// {{ .myvar = spaced }} - whitespace handling
|
||||
test('should parse variable shorthand with whitespace', async ({ page }) => {
|
||||
const input = '{{ .myvar = spaced }}';
|
||||
const macroCst = await runParser(page, input);
|
||||
|
||||
expect(macroCst).toEqual({
|
||||
'Macro.Start': '{{',
|
||||
'variableExpr': {
|
||||
'Var.scope': '.',
|
||||
'Var.identifier': 'myvar',
|
||||
'variableOperator': {
|
||||
'Var.operator': '=',
|
||||
'Var.value': {
|
||||
'Identifier': 'spaced',
|
||||
},
|
||||
},
|
||||
},
|
||||
'Macro.End': '}}',
|
||||
});
|
||||
});
|
||||
@@ -767,6 +913,19 @@ function simplifyCstNode(cst, input, { flattenKeys = [], ignoreKeys = [], ignore
|
||||
}
|
||||
if (node.children) {
|
||||
const simplifiedChildren = {};
|
||||
|
||||
// Special handling: merge macroBody children into parent (flatten the structure)
|
||||
// This preserves backward compatibility with existing tests after parser refactor
|
||||
if (node.children.macroBody && Array.isArray(node.children.macroBody) && node.children.macroBody.length === 1) {
|
||||
const macroBody = node.children.macroBody[0];
|
||||
if (macroBody.children) {
|
||||
for (const bodyKey in macroBody.children) {
|
||||
node.children[bodyKey] = macroBody.children[bodyKey];
|
||||
}
|
||||
}
|
||||
delete node.children.macroBody;
|
||||
}
|
||||
|
||||
for (const key in node.children) {
|
||||
function simplifyChildNode(childNode, path) {
|
||||
if (Array.isArray(childNode)) {
|
||||
|
||||
Reference in New Issue
Block a user