Files
SillyTavern/public/scripts/macros/engine/MacroCstWalker.js
T
Cohee 4d1619ba47 Chore: enable brace-style eslint check (#5159)
* eslint: enable brace-style check

* Fix jsdoc and color

* fix: correct CSS color syntax in CreateZenSliders function
2026-02-15 01:46:32 +02:00

1365 lines
56 KiB
JavaScript

/** @typedef {import('chevrotain').CstNode} CstNode */
/** @typedef {import('chevrotain').IToken} IToken */
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
import { logMacroInternalError, logMacroRuntimeWarning } from './MacroDiagnostics.js';
import { MacroEngine } from './MacroEngine.js';
import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';
import { MacroParser } from './MacroParser.js';
import { MacroRegistry } from './MacroRegistry.js';
import { isFalseBoolean } from '/scripts/utils.js';
/**
* @typedef {Object} MacroCall
* @property {string} name
* @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
* @property {string[]} rawArgs
* @property {{ startOffset: number, endOffset: number }} range - Range relative to the current evaluation context's text.
* @property {number} globalOffset - The offset of this macro in the original top-level document.
* This combines the context's base offset with the local range. Use this for deterministic
* seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results.
* @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.
*/
/**
* Context passed through the CST evaluation process.
*
* @typedef {Object} EvaluationContext
* @property {string} text - The text being evaluated at the current level. This is NOT the same as env.content.
* At the top level, this is the full document text. When evaluating nested content (arguments or scoped
* content), this is the substring being evaluated. CST node positions are always relative to this text.
*
* - Careful, this also means when resolving macros inside macro arguments, this will NOT be the text of
* the argument currently being resolved, but the full macro text with identifier and all macros.
* @property {number} contextOffset - Base offset from the original top-level document. At the top level this is 0.
* When re-parsing nested content (arguments/scoped), this is set to the substring's start position in
* the original document. Used to calculate globalOffset for macros that need deterministic positioning.
* @property {MacroEnv} env - The macro environment containing context like user/char names, variables, and the
* original full content (env.content). This remains constant throughout the evaluation.
* @property {(call: MacroCall) => string} resolveMacro - Callback to resolve a macro call to its result string.
* @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Shared utility function that trims scoped content with optional indentation dedent.
*/
/**
* @typedef {Object} TokenRange
* @property {number} startOffset
* @property {number} endOffset
*/
/**
* @typedef {Object} MacroNodeInfo
* @property {string} name - The macro identifier name.
* @property {boolean} isClosing - Whether this macro has the closing block flag (/).
* @property {number} startOffset - Start position in the source text.
* @property {number} endOffset - End position in the source text (inclusive).
* @property {number} argCount - Number of arguments provided to the macro.
*/
/**
* The singleton instance of the MacroCstWalker.
*
* @type {MacroCstWalker}
*/
let instance;
export { instance as MacroCstWalker };
class MacroCstWalker {
/** @type {MacroCstWalker} */ static #instance;
/** @type {MacroCstWalker} */ static get instance() { return MacroCstWalker.#instance ?? (MacroCstWalker.#instance = new MacroCstWalker()); }
constructor() { }
/**
* Evaluates a full document CST into a resolved string.
*
* @param {EvaluationContext & { cst: CstNode }} options
* @returns {string}
*/
evaluateDocument(options) {
const { text, cst, contextOffset, env, resolveMacro, trimContent } = options;
if (typeof text !== 'string') {
throw new Error('MacroCstWalker.evaluateDocument: text must be a string');
}
if (!cst || typeof cst !== 'object' || !cst.children) {
throw new Error('MacroCstWalker.evaluateDocument: cst must be a CstNode');
}
if (typeof resolveMacro !== 'function') {
throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function');
}
if (typeof trimContent !== 'function') {
throw new Error('MacroCstWalker.evaluateDocument: trimContent must be a function');
}
/** @type {EvaluationContext} */
const context = { text, contextOffset, env, resolveMacro, trimContent };
let items = this.#collectDocumentItems(cst);
// Process scoped macros: find opening/closing pairs and merge them
items = this.#processScopedMacros(items, text);
if (items.length === 0) {
return text;
}
let result = '';
let cursor = 0;
// Iterate over all items in the document. Evaluate any macro being found, and keep them in the exact same place.
for (const item of items) {
if (item.startOffset > cursor) {
result += text.slice(cursor, item.startOffset);
}
// Items can be either plaintext or macro nodes
if (item.type === 'plaintext') {
result += text.slice(item.startOffset, item.endOffset + 1);
cursor = item.endOffset + 1;
} else if (item.keepRaw) {
// Unmatched closing macros stay as raw text
result += text.slice(item.startOffset, item.endOffset + 1);
cursor = item.endOffset + 1;
} else {
result += this.#evaluateMacroNode(item.node, context, item.scopedContent);
// If this macro has scoped content, skip past the closing macro
if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) {
cursor = item.scopedContent.closingEndOffset + 1;
} else {
cursor = item.endOffset + 1;
}
}
}
if (cursor < text.length) {
result += text.slice(cursor);
}
return result;
}
/**
* Extracts basic info from a macro CST node: name, closing flag, position, and argument count.
* Returns null for variable expressions or nodes without valid identifiers.
*
* @param {CstNode} macroNode - A macro CST node from the parser.
* @returns {MacroNodeInfo | null}
*/
extractMacroInfo(macroNode) {
const children = macroNode?.children || {};
// Variable expressions don't have standard macro identifiers
if ((children.variableExpr || [])[0]) {
return null;
}
// Get start/end tokens for position
const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
if (!startToken || !endToken) {
return null;
}
// Get identifier and arguments 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;
// Count arguments (arguments rule contains argument nodes)
const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
const argCount = argumentNodes.length;
// Check for closing block flag
const flagTokens = /** @type {IToken[]} */ (children.flags || []);
const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
return {
name,
isClosing,
startOffset: startToken.startOffset,
endOffset: endToken.endOffset,
argCount,
};
}
/**
* Finds unclosed scoped macros in a document CST.
* Used by autocomplete to suggest closing tags.
*
* @param {Object} options
* @param {string} options.text - The document text.
* @param {CstNode} options.cst - The parsed CST.
* @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} - Array of unclosed macro info, innermost last.
*/
findUnclosedScopes(options) {
const { text, cst } = options;
if (typeof text !== 'string' || !cst?.children) {
return [];
}
let items = this.#collectDocumentItems(cst);
// Don't process scoped macros - we want to find the raw opening/closing pairs
// Just extract macro info and find unmatched openers
/** @type {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} */
const unclosedStack = [];
// Extract macro names and closing status
for (const item of items) {
if (item.type !== 'macro') continue;
const info = this.#extractMacroInfo(item.node);
if (!info) continue;
if (info.isClosing) {
// Find matching opener in stack (case-insensitive)
// When closing an outer scope, all inner unclosed scopes are implicitly closed
const matchIndex = unclosedStack.findLastIndex(s => s.name.toLowerCase() === info.name.toLowerCase());
if (matchIndex !== -1) {
// Pop everything from matchIndex to end (inclusive) - closes the matched scope and all nested ones
unclosedStack.splice(matchIndex);
}
// If no matching opener, ignore (orphan closing tag)
} else {
// Opening tag - check if this macro can accept scoped content
if (this.#canAcceptScopedContent(item.node, info.name)) {
// Extract whitespace padding from the macro
const { paddingBefore, paddingAfter } = this.#extractMacroPadding(item.node, text);
unclosedStack.push({
name: info.name,
startOffset: item.startOffset,
endOffset: item.endOffset,
paddingBefore,
paddingAfter,
});
}
}
}
return unclosedStack;
}
/**
* Extracts the whitespace padding from a macro node.
* Returns the whitespace after {{ and before }}.
*
* @param {CstNode} macroNode - The macro CST node.
* @param {string} text - The source text.
* @returns {{ paddingBefore: string, paddingAfter: string }}
*/
#extractMacroPadding(macroNode, text) {
const children = macroNode.children || {};
const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
if (!startToken || !endToken) {
return { paddingBefore: '', paddingAfter: '' };
}
// Get the raw text inside the macro (between {{ and }})
const innerStart = startToken.endOffset + 1;
const innerEnd = endToken.startOffset;
const innerText = text.slice(innerStart, innerEnd);
// Extract leading whitespace (paddingBefore)
const leadingMatch = innerText.match(/^(\s*)/);
const paddingBefore = leadingMatch ? leadingMatch[1] : '';
// Extract trailing whitespace (paddingAfter)
const trailingMatch = innerText.match(/(\s*)$/);
const paddingAfter = trailingMatch ? trailingMatch[1] : '';
return { paddingBefore, paddingAfter };
}
/** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */
/** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode, scopedContent?: { startOffset: number, endOffset: number, closingEndOffset: number }, keepRaw?: boolean }} DocumentItemMacro */
/** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */
/**
* Collects top-level plaintext tokens and macro nodes from the document CST.
*
* @param {CstNode} cst
* @returns {Array<DocumentItem>}
*/
#collectDocumentItems(cst) {
const plaintextTokens = /** @type {IToken[]} */ (cst.children.plaintext || []);
const macroNodes = /** @type {CstNode[]} */ (cst.children.macro || []);
/** @type {Array<DocumentItem>} */
const items = [];
for (const token of plaintextTokens) {
if (typeof token.startOffset !== 'number' || typeof token.endOffset !== 'number') {
continue;
}
items.push({
type: 'plaintext',
startOffset: token.startOffset,
endOffset: token.endOffset,
token,
});
}
for (const macroNode of macroNodes) {
const children = macroNode.children || {};
const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
// If the end token was inserted during error recovery, treat this macro as plaintext
if (this.#isRecoveryToken(endToken)) {
// Flatten the incomplete macro: collect its tokens as plaintext but keep nested macros
this.#flattenIncompleteMacro(macroNode, endToken, items);
continue;
}
const range = this.#getMacroRange(macroNode);
items.push({
type: 'macro',
startOffset: range.startOffset,
endOffset: range.endOffset,
node: macroNode,
});
}
items.sort((a, b) => {
if (a.startOffset !== b.startOffset) {
return a.startOffset - b.startOffset;
}
return a.endOffset - b.endOffset;
});
return items;
}
/**
* Evaluates a single macro CST node, resolving any nested macros first.
*
* @param {CstNode} macroNode
* @param {EvaluationContext} context
* @param {{ startOffset: number, endOffset: number, closingEndOffset: number }} [scopedContent] - Optional scoped content range for block macros.
* @returns {string}
*/
#evaluateMacroNode(macroNode, context, scopedContent) {
const { text, contextOffset, env, resolveMacro, trimContent } = context;
const children = macroNode.children || {};
// 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 (now inside macroBody)
const flagTokens = /** @type {IToken[]} */ (children.flags || []);
const flagSymbols = flagTokens.map(token => token.image);
const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags();
const range = this.#getMacroRange(macroNode);
const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]);
const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]);
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, inside macroBody)
const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]);
const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []);
// Check if this macro has delayArgResolution flag - if so, skip nested macro evaluation
const macroDef = MacroRegistry.getMacro(name);
const delayArgResolution = macroDef?.delayArgResolution === true;
/** @type {string[]} */
const args = [];
/** @type {({ value: string } & TokenRange)[]} */
const evaluatedArguments = [];
/** @type {string[]} */
const rawArgs = [];
for (const argNode of argumentNodes) {
const location = this.#getArgumentLocation(argNode);
const rawArgText = location ? text.slice(location.startOffset, location.endOffset + 1) : '';
rawArgs.push(rawArgText);
// If delayArgResolution is true, use raw text; otherwise evaluate nested macros
const argValue = delayArgResolution ? rawArgText : this.#evaluateArgumentNode(argNode, context);
args.push(argValue);
if (location) {
evaluatedArguments.push({
value: argValue,
...location,
});
}
}
// If this macro has scoped content, evaluate it and append as the last argument
if (scopedContent) {
// Handle empty scoped content (when opening and closing are adjacent)
if (scopedContent.startOffset > scopedContent.endOffset) {
args.push('');
rawArgs.push('');
} else {
const rawScopedText = text.slice(scopedContent.startOffset, scopedContent.endOffset + 1);
rawArgs.push(rawScopedText);
// If delayArgResolution is true, use raw text; otherwise evaluate nested macros
let scopedValue;
if (delayArgResolution) {
scopedValue = rawScopedText;
} else {
scopedValue = this.#evaluateScopedContent(scopedContent, context);
// Auto-trim scoped content unless the '#' (preserveWhitespace) flag is set
if (!flags.preserveWhitespace) {
scopedValue = trimContent(scopedValue);
}
}
args.push(scopedValue);
// Add to evaluated arguments for rawInner reconstruction
evaluatedArguments.push({
value: scopedValue,
startOffset: scopedContent.startOffset,
endOffset: scopedContent.endOffset,
});
}
}
evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset);
// Build the inner raw string between the braces, with nested macros resolved.
// This uses the already evaluated argument strings and preserves any text
// between arguments (such as separators or whitespace).
let rawInner = '';
if (innerStart <= innerEnd) {
let cursor = innerStart;
for (const entry of evaluatedArguments) {
if (entry.startOffset > cursor) {
rawInner += text.slice(cursor, entry.startOffset);
}
rawInner += entry.value;
cursor = entry.endOffset + 1;
}
if (cursor <= innerEnd) {
rawInner += text.slice(cursor, innerEnd + 1);
}
}
/** @type {MacroCall} */
const call = {
name,
args,
flags,
isScoped: scopedContent != null,
rawInner,
rawWithBraces: text.slice(range.startOffset, range.endOffset + 1),
rawArgs,
range,
globalOffset: contextOffset + range.startOffset,
cstNode: macroNode,
env,
};
const value = resolveMacro(call);
const stringValue = typeof value === 'string' ? value : String(value ?? '');
return stringValue;
}
/**
* Evaluates a variable expression node using direct variable API calls.
* Supports operators: get, set (=), add (+=), sub (-=), inc (++), dec (--),
* logical or (||), nullish coalescing (??), logical or assign (||=),
* nullish coalescing assign (??=), and equality comparison (==).
*
* @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 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 whether a value expression is expected
let operation = 'get';
let hasValueExpr = false;
if (operatorNode) {
const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []);
const operatorToken = operatorTokens[0];
if (operatorToken) {
const operatorImage = operatorToken.image;
switch (operatorImage) {
case '++':
operation = 'inc';
break;
case '--':
operation = 'dec';
break;
case '=':
operation = 'set';
hasValueExpr = true;
break;
case '+=':
operation = 'add';
hasValueExpr = true;
break;
case '-=':
operation = 'sub';
hasValueExpr = true;
break;
case '||':
operation = 'logicalOr';
hasValueExpr = true;
break;
case '??':
operation = 'nullishCoalescing';
hasValueExpr = true;
break;
case '||=':
operation = 'logicalOrAssign';
hasValueExpr = true;
break;
case '??=':
operation = 'nullishCoalescingAssign';
hasValueExpr = true;
break;
case '==':
operation = 'equals';
hasValueExpr = true;
break;
case '!=':
operation = 'notEquals';
hasValueExpr = true;
break;
case '>':
operation = 'greaterThan';
hasValueExpr = true;
break;
case '>=':
operation = 'greaterThanOrEqual';
hasValueExpr = true;
break;
case '<':
operation = 'lessThan';
hasValueExpr = true;
break;
case '<=':
operation = 'lessThanOrEqual';
hasValueExpr = true;
break;
default:
logMacroInternalError({ message: `Lexer found macro operator that is not implemented for variable shorthand expressions in macro node '${macroNode.name}'.` });
break;
}
}
}
// Create a lazy value resolver that caches its result on first call.
// This ensures the value expression is only evaluated when actually needed,
// which is important for performance and because some macros are stateful.
const lazyValue = hasValueExpr ? this.#createLazyValue(operatorChildren, context) : () => '';
// Execute the operation using direct variable API calls
return this.#executeVariableOperation(varName, isGlobal, operation, lazyValue);
}
/**
* Creates a lazy value resolver that caches its result on first call.
* This ensures the value expression is only evaluated when actually needed.
*
* @param {Record<string, any>} operatorChildren - The children of the variableOperator node.
* @param {EvaluationContext} context - The evaluation context.
* @returns {() => string} A function that returns the evaluated value, caching the result.
*/
#createLazyValue(operatorChildren, context) {
let cached = null;
let resolved = false;
return () => {
if (!resolved) {
cached = this.#evaluateVariableValue(operatorChildren, context);
resolved = true;
}
return cached;
};
}
/**
* Executes a variable operation using the SillyTavern context API.
*
* @param {string} varName - The variable name.
* @param {boolean} isGlobal - Whether this is a global ($) or local (.) variable.
* @param {string} operation - The operation to perform.
* @param {() => string} lazyValue - A lazy function that returns the value when called. Only evaluated when needed.
* @returns {string} The result of the operation.
*/
#executeVariableOperation(varName, isGlobal, operation, lazyValue) {
const ctx = SillyTavern.getContext();
const vars = isGlobal ? ctx.variables.global : ctx.variables.local;
/**
* Normalizes macro results into a string.
* @param {any} value
* @returns {string}
*/
const normalize = MacroEngine.normalizeMacroResult.bind(MacroEngine);
/**
* Checks if a value is falsy (empty string, 0, '0', false, 'false', null, undefined).
* @param {any} val
* @returns {boolean}
*/
const isFalsy = (val) => !val || isFalseBoolean(normalize(val));
switch (operation) {
case 'get':
return normalize(vars.get(varName));
case 'set':
vars.set(varName, lazyValue());
return '';
case 'inc':
return normalize(vars.inc(varName));
case 'dec':
return normalize(vars.dec(varName));
case 'add':
vars.add(varName, lazyValue());
return '';
case 'sub': {
// Subtract by adding the negative value
const numValue = Number(lazyValue());
if (!isNaN(numValue)) vars.add(varName, -numValue);
else logMacroRuntimeWarning({ message: `Variable shorthand "-=" operator requires a numeric value, got: "${lazyValue()}"` });
return '';
}
case 'logicalOr': {
// Returns default value if variable is falsy, otherwise returns variable value
// Value is only resolved if needed (when variable is falsy)
const currentValue = vars.get(varName);
return isFalsy(currentValue) ? normalize(lazyValue()) : normalize(currentValue);
}
case 'nullishCoalescing': {
// Returns default value only if variable doesn't exist, otherwise returns variable value (even if falsy)
// Value is only resolved if needed (when variable doesn't exist)
const exists = vars.has(varName);
return exists ? normalize(vars.get(varName)) : normalize(lazyValue());
}
case 'logicalOrAssign': {
// If variable is falsy, set it to value and return value; otherwise return current value
// Value is only resolved if needed (when variable is falsy)
const currentValue = vars.get(varName);
if (isFalsy(currentValue)) {
vars.set(varName, lazyValue());
return normalize(lazyValue());
}
return normalize(currentValue);
}
case 'nullishCoalescingAssign': {
// If variable doesn't exist, set it to value and return value; otherwise return current value
// Value is only resolved if needed (when variable doesn't exist)
const exists = vars.has(varName);
if (!exists) {
vars.set(varName, lazyValue());
return normalize(lazyValue());
}
return normalize(vars.get(varName));
}
case 'equals': {
// String equality comparison - value is always needed
const currentValue = normalize(vars.get(varName));
const compareValue = normalize(lazyValue());
return currentValue === compareValue ? 'true' : 'false';
}
case 'notEquals': {
// String inequality comparison - value is always needed
const currentValue = normalize(vars.get(varName));
const compareValue = normalize(lazyValue());
return currentValue !== compareValue ? 'true' : 'false';
}
case 'greaterThan': {
// Numeric greater than comparison
const currentNum = Number(vars.get(varName));
const compareNum = Number(lazyValue());
if (isNaN(currentNum) || isNaN(compareNum)) {
logMacroRuntimeWarning({ message: `Variable shorthand ">" operator requires numeric values. Got: "${vars.get(varName)}" > "${lazyValue()}"` });
return 'false';
}
return currentNum > compareNum ? 'true' : 'false';
}
case 'greaterThanOrEqual': {
// Numeric greater than or equal comparison
const currentNum = Number(vars.get(varName));
const compareNum = Number(lazyValue());
if (isNaN(currentNum) || isNaN(compareNum)) {
logMacroRuntimeWarning({ message: `Variable shorthand ">=" operator requires numeric values. Got: "${vars.get(varName)}" >= "${lazyValue()}"` });
return 'false';
}
return currentNum >= compareNum ? 'true' : 'false';
}
case 'lessThan': {
// Numeric less than comparison
const currentNum = Number(vars.get(varName));
const compareNum = Number(lazyValue());
if (isNaN(currentNum) || isNaN(compareNum)) {
logMacroRuntimeWarning({ message: `Variable shorthand "<" operator requires numeric values. Got: "${vars.get(varName)}" < "${lazyValue()}"` });
return 'false';
}
return currentNum < compareNum ? 'true' : 'false';
}
case 'lessThanOrEqual': {
// Numeric less than or equal comparison
const currentNum = Number(vars.get(varName));
const compareNum = Number(lazyValue());
if (isNaN(currentNum) || isNaN(compareNum)) {
logMacroRuntimeWarning({ message: `Variable shorthand "<=" operator requires numeric values. Got: "${vars.get(varName)}" <= "${lazyValue()}"` });
return 'false';
}
return currentNum <= compareNum ? 'true' : 'false';
}
default:
logMacroRuntimeWarning({ message: `Unknown variable shorthand operation: "${operation}"` });
return '';
}
}
/**
* 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.
*
* This method extracts the argument's raw text and re-parses it to properly
* handle scoped macros (opening/closing tag pairs) that may appear within
* the argument content.
*
* @param {CstNode} argNode - The argument CST node to evaluate.
* @param {EvaluationContext} context - The evaluation context containing the parent document's text and environment.
* @returns {string} The evaluated argument with all nested macros (including scoped ones) resolved.
*/
#evaluateArgumentNode(argNode, context) {
const location = this.#getArgumentLocation(argNode);
if (!location) {
return '';
}
const { text, contextOffset } = context;
const rawContent = text.slice(location.startOffset, location.endOffset + 1);
// Calculate the new base offset: parent's contextOffset + this argument's start position
const newContextOffset = contextOffset + location.startOffset;
// Use the shared helper to evaluate the content, which handles scoped macros
return this.#evaluateRawContent(rawContent, newContextOffset, context);
}
/**
* Evaluates a text content string by parsing it and resolving all macros,
* including scoped macro pairs (opening/closing tags).
*
* This is the core helper used by both argument evaluation and scoped content
* evaluation to ensure consistent handling of nested and scoped macros.
*
* @param {string} rawContent - The raw text content to evaluate.
* @param {number} newContextOffset - The offset of rawContent's start position in the original top-level document.
* @param {EvaluationContext} context - The parent evaluation context (used for env, resolveMacro, trimContent).
* @returns {string} The evaluated content with all macros resolved.
*/
#evaluateRawContent(rawContent, newContextOffset, context) {
// If empty, return as-is
if (!rawContent) {
return '';
}
// Re-evaluate the content to find all nested macros including scoped pairs
// We need to parse and evaluate this content as if it were a standalone document
const { cst } = MacroParser.parseDocument(rawContent);
// If parsing fails, return the raw content
if (!cst || typeof cst !== 'object' || !cst.children) {
return rawContent;
}
// Create a new context with the content as the text and updated contextOffset
// This is important: positions in the parsed CST are relative to rawContent,
// but contextOffset tracks the absolute position in the original document
/** @type {EvaluationContext} */
const contentContext = { ...context, text: rawContent, contextOffset: newContextOffset };
// Collect items and process scoped macros
let items = this.#collectDocumentItems(cst);
items = this.#processScopedMacros(items, rawContent);
// If no items, return raw content
if (items.length === 0) {
return rawContent;
}
// Evaluate items in order
let result = '';
let cursor = 0;
for (const item of items) {
if (item.startOffset > cursor) {
result += rawContent.slice(cursor, item.startOffset);
}
if (item.type === 'plaintext') {
result += rawContent.slice(item.startOffset, item.endOffset + 1);
cursor = item.endOffset + 1;
} else if (item.keepRaw) {
// Unmatched closing macros stay as raw text
result += rawContent.slice(item.startOffset, item.endOffset + 1);
cursor = item.endOffset + 1;
} else {
result += this.#evaluateMacroNode(item.node, contentContext, item.scopedContent);
// If this macro has scoped content, skip past the closing macro
if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) {
cursor = item.scopedContent.closingEndOffset + 1;
} else {
cursor = item.endOffset + 1;
}
}
}
if (cursor < rawContent.length) {
result += rawContent.slice(cursor);
}
return result;
}
/**
* Computes the character range of a macro node based on its start/end tokens
* or its own location if those are not available.
*
* @param {CstNode} macroNode
* @returns {TokenRange}
*/
#getMacroRange(macroNode) {
const startToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.Start'] || [])[0]);
const endToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.End'] || [])[0]);
if (startToken && endToken) {
return { startOffset: startToken.startOffset, endOffset: endToken.endOffset };
}
if (macroNode.location) {
return { startOffset: macroNode.location.startOffset, endOffset: macroNode.location.endOffset };
}
return { startOffset: 0, endOffset: 0 };
}
/**
* Flattens an incomplete macro node into document items.
* Tokens from the incomplete macro become plaintext, but nested complete macros are preserved.
*
* @param {CstNode} macroNode
* @param {IToken} excludeToken - The recovery-inserted token to exclude
* @param {Array<DocumentItem>} items - The items array to add to
*/
#flattenIncompleteMacro(macroNode, excludeToken, items) {
const children = macroNode.children || {};
for (const key of Object.keys(children)) {
for (const element of children[key] || []) {
// Skip the recovery-inserted token
if (element === excludeToken) continue;
// Handle IToken - add as plaintext
if ('startOffset' in element && typeof element.startOffset === 'number') {
items.push({
type: 'plaintext',
startOffset: element.startOffset,
endOffset: element.endOffset ?? element.startOffset,
token: element,
});
} else if ('children' in element) {
// Handle nested CstNode (macro or argument)
const nestedChildren = element.children || {};
const nestedEnd = /** @type {IToken?} */ ((nestedChildren['Macro.End'] || [])[0]);
const nestedStart = /** @type {IToken?} */ ((nestedChildren['Macro.Start'] || [])[0]);
// Check if this is a complete macro node
if (nestedStart && nestedEnd) {
if (!this.#isRecoveryToken(nestedEnd)) {
// Complete nested macro - add as macro item
const range = this.#getMacroRange(element);
items.push({
type: 'macro',
startOffset: range.startOffset,
endOffset: range.endOffset,
node: element,
});
} else {
// Another incomplete nested macro - recurse
this.#flattenIncompleteMacro(element, nestedEnd, items);
}
} else {
// Not a macro node (e.g., arguments, argument) - recurse into it
this.#flattenIncompleteMacro(element, excludeToken, items);
}
}
}
}
}
/**
* Checks if a token was inserted during Chevrotain's error recovery.
* Recovery tokens have `isInsertedInRecovery=true` or invalid offset values.
*
* @param {IToken|null|undefined} token
* @returns {boolean}
*/
#isRecoveryToken(token) {
return token?.isInsertedInRecovery === true
|| typeof token?.startOffset !== 'number'
|| Number.isNaN(token?.startOffset);
}
/**
* Computes the character range of an argument node based on all its child
* tokens and nested macros.
*
* @param {CstNode} argNode
* @returns {TokenRange|null}
*/
#getArgumentLocation(argNode) {
const children = argNode.children || {};
let startOffset = Number.POSITIVE_INFINITY;
let endOffset = Number.NEGATIVE_INFINITY;
for (const key of Object.keys(children)) {
for (const element of children[key] || []) {
if (this.#isCstNode(element)) {
const location = element.location;
if (!location) {
continue;
}
if (location.startOffset < startOffset) {
startOffset = location.startOffset;
}
if (location.endOffset > endOffset) {
endOffset = location.endOffset;
}
} else if (element) {
if (element.startOffset < startOffset) {
startOffset = element.startOffset;
}
if (element.endOffset > endOffset) {
endOffset = element.endOffset;
}
}
}
}
if (!Number.isFinite(startOffset) || !Number.isFinite(endOffset)) {
return null;
}
return { startOffset, endOffset };
}
/**
* Determines whether the given value is a CST node.
*
* @param {any} value
* @returns {value is CstNode}
*/
#isCstNode(value) {
return !!value && typeof value === 'object' && 'name' in value && 'children' in value;
}
/**
* Evaluates scoped content between an opening and closing macro tag.
* This resolves any nested macros within the scoped content.
*
* @param {{ startOffset: number, endOffset: number }} scopedContent - The range of the scoped content.
* @param {EvaluationContext} context - The evaluation context. The `text` property contains the parent
* document text, and offsets in scopedContent are relative to that parent text.
* @returns {string} - The evaluated scoped content with nested macros resolved.
*/
#evaluateScopedContent(scopedContent, context) {
const { text, contextOffset } = context;
const { startOffset, endOffset } = scopedContent;
// Extract the raw content between opening and closing tags
const rawContent = text.slice(startOffset, endOffset + 1);
// Calculate the new base offset: parent's contextOffset + this scoped content's start position
const newContextOffset = contextOffset + startOffset;
// Use the shared helper to evaluate the content
return this.#evaluateRawContent(rawContent, newContextOffset, context);
}
// ========================================================================
// Scoped Macro Processing
// ========================================================================
/**
* Processes document items to find and merge scoped macro pairs.
* A scoped macro is an opening macro followed by content and a closing macro.
* Example: `{{setvar::myvar}}content{{/setvar}}` becomes `{{setvar::myvar::content}}`
*
* The closing macro has the `closingBlock` flag (`/`) and the same identifier.
* Everything between the opening and closing macros becomes the last unnamed argument.
*
* @param {Array<DocumentItem>} items - The collected document items.
* @param {string} text - The original document text.
* @returns {Array<DocumentItem>} - The processed items with scoped macros merged.
*/
#processScopedMacros(items, text) {
// Build a list of scoped macro info for each macro item
/** @type {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} */
const macroInfos = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.type !== 'macro') continue;
const info = this.#extractMacroInfo(item.node);
if (!info) continue;
macroInfos.push({
index: i,
item,
name: info.name,
isClosing: info.isClosing,
matched: false,
});
}
// Find matching pairs - only process OUTERMOST scopes at this level
// Nested scopes will be discovered when parent's scoped content is re-parsed
/** @type {Array<{ openingIndex: number, closingIndex: number }>} */
const pairs = [];
// Track ranges that are inside a scope (to skip nested openers)
/** @type {Set<number>} */
const insideScope = new Set();
for (let i = 0; i < macroInfos.length; i++) {
const openInfo = macroInfos[i];
// Skip closing macros, already matched macros, or macros inside another scope
if (openInfo.isClosing || openInfo.matched || insideScope.has(openInfo.index)) continue;
// Find the matching closing macro for this opening macro
const closingIdx = this.#findMatchingClosingMacro(macroInfos, i);
if (closingIdx === -1) continue;
// Check if the macro can accept scoped content (arity validation)
if (!this.#canAcceptScopedContent(openInfo.item.node, openInfo.name)) {
// Macro cannot accept scoped content - mark both as keepRaw
openInfo.item.keepRaw = true;
macroInfos[closingIdx].item.keepRaw = true;
// Mark as matched so they won't be processed again
openInfo.matched = true;
macroInfos[closingIdx].matched = true;
continue;
}
// Mark both as matched
openInfo.matched = true;
macroInfos[closingIdx].matched = true;
const closingIndex = macroInfos[closingIdx].index;
pairs.push({
openingIndex: openInfo.index,
closingIndex: closingIndex,
});
// Mark all items between this pair as inside a scope
// They will be processed when the scoped content is re-parsed
for (let j = openInfo.index + 1; j < closingIndex; j++) {
insideScope.add(j);
}
}
// Mark unmatched closing macros as keepRaw so they stay as raw text
for (const info of macroInfos) {
if (info.isClosing && !info.matched) {
info.item.keepRaw = true;
}
}
// If no pairs found, return items (with unmatched closings marked as raw)
if (pairs.length === 0) {
return items;
}
// Process pairs: merge content into opening macro's scopedContent field
// Track which items to remove (closing macros and intermediate content items)
/** @type {Set<number>} */
const itemsToRemove = new Set();
for (const pair of pairs) {
const openingItem = /** @type {DocumentItemMacro} */ (items[pair.openingIndex]);
const closingItem = /** @type {DocumentItemMacro} */ (items[pair.closingIndex]);
// Collect content between opening and closing (exclusive)
const contentStart = openingItem.endOffset + 1;
const contentEnd = closingItem.startOffset - 1;
// Store the scoped content range on the opening macro item
// This will be used during macro evaluation to append the content as the last argument
openingItem.scopedContent = {
startOffset: contentStart,
endOffset: contentEnd,
closingEndOffset: closingItem.endOffset,
};
// Mark closing macro for removal
itemsToRemove.add(pair.closingIndex);
// Mark ALL intermediate items between opening and closing for removal
// They will be captured as raw scoped content and re-parsed during evaluation
for (let j = pair.openingIndex + 1; j < pair.closingIndex; j++) {
itemsToRemove.add(j);
}
}
// Filter out removed items
return items.filter((_, index) => !itemsToRemove.has(index));
}
/**
* Extracts macro name and closing flag status from a macro node.
*
* @param {CstNode} macroNode
* @returns {{ name: string, isClosing: boolean } | null}
*/
#extractMacroInfo(macroNode) {
const children = macroNode.children || {};
// 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 (inside macroBody)
const flagTokens = /** @type {IToken[]} */ (children.flags || []);
const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK);
return { name, isClosing };
}
/**
* Checks if a macro can accept scoped content as an additional argument.
* Returns true if adding one more argument would result in valid arity.
*
* @param {CstNode} macroNode - The macro CST node.
* @param {string} macroName - The macro name.
* @returns {boolean} - True if scoped content is allowed.
*/
#canAcceptScopedContent(macroNode, macroName) {
const def = MacroRegistry.getPrimaryMacro(macroName);
if (!def) {
// Unknown macro - allow scoped content (will be handled as unknown macro later)
return true;
}
// Count current arguments in the macro (now inside macroBody)
const children = macroNode.children || {};
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;
// List-arg macros don't support scoped content - they accept arbitrary inline args instead
if (def.list) {
return false;
}
// Check if adding 1 more argument (scoped content) would be valid
const newArgCount = currentArgCount + 1;
// Without list: newArgCount must be between minArgs and maxArgs
return newArgCount >= def.minArgs && newArgCount <= def.maxArgs;
}
/**
* Finds the matching closing macro for an opening macro at the given index.
* Handles nested scopes by tracking depth. Only counts opening macros that
* can accept scoped content (inline macros with all args filled don't count).
*
* @param {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} macroInfos
* @param {number} openingIdx - Index in macroInfos array of the opening macro.
* @returns {number} - Index in macroInfos array of the matching closing macro, or -1 if not found.
*/
#findMatchingClosingMacro(macroInfos, openingIdx) {
const openInfo = macroInfos[openingIdx];
const targetName = openInfo.name;
let depth = 1;
for (let i = openingIdx + 1; i < macroInfos.length; i++) {
const info = macroInfos[i];
// Only consider macros with the same name (case-insensitive)
if (info.name.toLowerCase() !== targetName.toLowerCase()) continue;
// Skip already matched macros
if (info.matched) continue;
if (info.isClosing) {
depth--;
if (depth === 0) {
return i;
}
} else {
// Only increment depth for opening macros that can accept scoped content
// Inline macros (e.g., {{if condition::content}}) don't need closing tags
if (this.#canAcceptScopedContent(info.item.node, info.name)) {
depth++;
}
}
}
return -1; // No matching closing macro found
}
}
instance = MacroCstWalker.instance;