/** @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} */ #collectDocumentItems(cst) { const plaintextTokens = /** @type {IToken[]} */ (cst.children.plaintext || []); const macroNodes = /** @type {CstNode[]} */ (cst.children.macro || []); /** @type {Array} */ 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} 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} 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} 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} items - The collected document items. * @param {string} text - The original document text. * @returns {Array} - 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} */ 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} */ 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;