| 1 | /** @typedef {import('chevrotain').CstNode} CstNode */ |
| 2 | /** @typedef {import('chevrotain').IToken} IToken */ |
| 3 | /** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */ |
| 4 | /** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */ |
| 5 | |
| 6 | import { logMacroInternalError, logMacroRuntimeWarning } from './MacroDiagnostics.js'; |
| 7 | import { MacroEngine } from './MacroEngine.js'; |
| 8 | import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js'; |
| 9 | import { MacroParser } from './MacroParser.js'; |
| 10 | import { MacroRegistry } from './MacroRegistry.js'; |
| 11 | import { isFalseBoolean } from '/scripts/utils.js'; |
| 12 | |
| 13 | /** |
| 14 | * @typedef {Object} MacroCall |
| 15 | * @property {string} name |
| 16 | * @property {string[]} args |
| 17 | * @property {MacroFlags} flags - Parsed macro execution flags. |
| 18 | * @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags). |
| 19 | * @property {boolean} [isVariableShorthand] - Whether this call originated from variable shorthand syntax. |
| 20 | * @property {MacroEnv} env |
| 21 | * @property {string} rawInner |
| 22 | * @property {string} rawWithBraces |
| 23 | * @property {string[]} rawArgs |
| 24 | * @property {{ startOffset: number, endOffset: number }} range - Range relative to the current evaluation context's text. |
| 25 | * @property {number} globalOffset - The offset of this macro in the original top-level document. |
| 26 | * This combines the context's base offset with the local range. Use this for deterministic |
| 27 | * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results. |
| 28 | * @property {CstNode} cstNode |
| 29 | */ |
| 30 | |
| 31 | /** |
| 32 | * @typedef {Object} VariableExprInfo |
| 33 | * @property {'local' | 'global'} scope - Whether this is a local (.) or global ($) variable. |
| 34 | * @property {string} varName - The variable name. |
| 35 | * @property {'get' | 'set' | 'inc' | 'dec' | 'add'} operation - The operation to perform. |
| 36 | * @property {string | null} value - The value for set/add operations, null for get/inc/dec. |
| 37 | */ |
| 38 | |
| 39 | /** |
| 40 | * Context passed through the CST evaluation process. |
| 41 | * |
| 42 | * @typedef {Object} EvaluationContext |
| 43 | * @property {string} text - The text being evaluated at the current level. This is NOT the same as env.content. |
| 44 | * At the top level, this is the full document text. When evaluating nested content (arguments or scoped |
| 45 | * content), this is the substring being evaluated. CST node positions are always relative to this text. |
| 46 | * |
| 47 | * - Careful, this also means when resolving macros inside macro arguments, this will NOT be the text of |
| 48 | * the argument currently being resolved, but the full macro text with identifier and all macros. |
| 49 | * @property {number} contextOffset - Base offset from the original top-level document. At the top level this is 0. |
| 50 | * When re-parsing nested content (arguments/scoped), this is set to the substring's start position in |
| 51 | * the original document. Used to calculate globalOffset for macros that need deterministic positioning. |
| 52 | * @property {MacroEnv} env - The macro environment containing context like user/char names, variables, and the |
| 53 | * original full content (env.content). This remains constant throughout the evaluation. |
| 54 | * @property {(call: MacroCall) => string} resolveMacro - Callback to resolve a macro call to its result string. |
| 55 | * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Shared utility function that trims scoped content with optional indentation dedent. |
| 56 | */ |
| 57 | |
| 58 | /** |
| 59 | * @typedef {Object} TokenRange |
| 60 | * @property {number} startOffset |
| 61 | * @property {number} endOffset |
| 62 | */ |
| 63 | |
| 64 | /** |
| 65 | * @typedef {Object} MacroNodeInfo |
| 66 | * @property {string} name - The macro identifier name. |
| 67 | * @property {boolean} isClosing - Whether this macro has the closing block flag (/). |
| 68 | * @property {number} startOffset - Start position in the source text. |
| 69 | * @property {number} endOffset - End position in the source text (inclusive). |
| 70 | * @property {number} argCount - Number of arguments provided to the macro. |
| 71 | */ |
| 72 | |
| 73 | /** |
| 74 | * The singleton instance of the MacroCstWalker. |
| 75 | * |
| 76 | * @type {MacroCstWalker} |
| 77 | */ |
| 78 | let instance; |
| 79 | export { instance as MacroCstWalker }; |
| 80 | |
| 81 | class MacroCstWalker { |
| 82 | /** @type {MacroCstWalker} */ static #instance; |
| 83 | /** @type {MacroCstWalker} */ static get instance() { return MacroCstWalker.#instance ?? (MacroCstWalker.#instance = new MacroCstWalker()); } |
| 84 | |
| 85 | constructor() { } |
| 86 | |
| 87 | /** |
| 88 | * Evaluates a full document CST into a resolved string. |
| 89 | * |
| 90 | * @param {EvaluationContext & { cst: CstNode }} options |
| 91 | * @returns {string} |
| 92 | */ |
| 93 | evaluateDocument(options) { |
| 94 | const { text, cst, contextOffset, env, resolveMacro, trimContent } = options; |
| 95 | |
| 96 | if (typeof text !== 'string') { |
| 97 | throw new Error('MacroCstWalker.evaluateDocument: text must be a string'); |
| 98 | } |
| 99 | if (!cst || typeof cst !== 'object' || !cst.children) { |
| 100 | throw new Error('MacroCstWalker.evaluateDocument: cst must be a CstNode'); |
| 101 | } |
| 102 | if (typeof resolveMacro !== 'function') { |
| 103 | throw new Error('MacroCstWalker.evaluateDocument: resolveMacro must be a function'); |
| 104 | } |
| 105 | if (typeof trimContent !== 'function') { |
| 106 | throw new Error('MacroCstWalker.evaluateDocument: trimContent must be a function'); |
| 107 | } |
| 108 | |
| 109 | /** @type {EvaluationContext} */ |
| 110 | const context = { text, contextOffset, env, resolveMacro, trimContent }; |
| 111 | let items = this.#collectDocumentItems(cst); |
| 112 | |
| 113 | // Process scoped macros: find opening/closing pairs and merge them |
| 114 | items = this.#processScopedMacros(items, text); |
| 115 | |
| 116 | if (items.length === 0) { |
| 117 | return text; |
| 118 | } |
| 119 | |
| 120 | let result = ''; |
| 121 | let cursor = 0; |
| 122 | |
| 123 | // Iterate over all items in the document. Evaluate any macro being found, and keep them in the exact same place. |
| 124 | for (const item of items) { |
| 125 | if (item.startOffset > cursor) { |
| 126 | result += text.slice(cursor, item.startOffset); |
| 127 | } |
| 128 | |
| 129 | // Items can be either plaintext or macro nodes |
| 130 | if (item.type === 'plaintext') { |
| 131 | result += text.slice(item.startOffset, item.endOffset + 1); |
| 132 | cursor = item.endOffset + 1; |
| 133 | } else if (item.keepRaw) { |
| 134 | // Unmatched closing macros stay as raw text |
| 135 | result += text.slice(item.startOffset, item.endOffset + 1); |
| 136 | cursor = item.endOffset + 1; |
| 137 | } else { |
| 138 | result += this.#evaluateMacroNode(item.node, context, item.scopedContent); |
| 139 | // If this macro has scoped content, skip past the closing macro |
| 140 | if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) { |
| 141 | cursor = item.scopedContent.closingEndOffset + 1; |
| 142 | } else { |
| 143 | cursor = item.endOffset + 1; |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | if (cursor < text.length) { |
| 149 | result += text.slice(cursor); |
| 150 | } |
| 151 | |
| 152 | return result; |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Extracts basic info from a macro CST node: name, closing flag, position, and argument count. |
| 157 | * Returns null for variable expressions or nodes without valid identifiers. |
| 158 | * |
| 159 | * @param {CstNode} macroNode - A macro CST node from the parser. |
| 160 | * @returns {MacroNodeInfo | null} |
| 161 | */ |
| 162 | extractMacroInfo(macroNode) { |
| 163 | const children = macroNode?.children || {}; |
| 164 | |
| 165 | // Variable expressions don't have standard macro identifiers |
| 166 | if ((children.variableExpr || [])[0]) { |
| 167 | return null; |
| 168 | } |
| 169 | |
| 170 | // Get start/end tokens for position |
| 171 | const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]); |
| 172 | const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]); |
| 173 | if (!startToken || !endToken) { |
| 174 | return null; |
| 175 | } |
| 176 | |
| 177 | // Get identifier and arguments from macroBody |
| 178 | const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]); |
| 179 | const bodyChildren = macroBodyNode?.children || {}; |
| 180 | const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []); |
| 181 | const name = identifierTokens[0]?.image || ''; |
| 182 | |
| 183 | if (!name) return null; |
| 184 | |
| 185 | // Count arguments (arguments rule contains argument nodes) |
| 186 | const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]); |
| 187 | const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []); |
| 188 | const argCount = argumentNodes.length; |
| 189 | |
| 190 | // Check for closing block flag |
| 191 | const flagTokens = /** @type {IToken[]} */ (children.flags || []); |
| 192 | const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK); |
| 193 | |
| 194 | return { |
| 195 | name, |
| 196 | isClosing, |
| 197 | startOffset: startToken.startOffset, |
| 198 | endOffset: endToken.endOffset, |
| 199 | argCount, |
| 200 | }; |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * Finds unclosed scoped macros in a document CST. |
| 205 | * Used by autocomplete to suggest closing tags. |
| 206 | * |
| 207 | * @param {Object} options |
| 208 | * @param {string} options.text - The document text. |
| 209 | * @param {CstNode} options.cst - The parsed CST. |
| 210 | * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} - Array of unclosed macro info, innermost last. |
| 211 | */ |
| 212 | findUnclosedScopes(options) { |
| 213 | const { text, cst } = options; |
| 214 | |
| 215 | if (typeof text !== 'string' || !cst?.children) { |
| 216 | return []; |
| 217 | } |
| 218 | |
| 219 | let items = this.#collectDocumentItems(cst); |
| 220 | // Don't process scoped macros - we want to find the raw opening/closing pairs |
| 221 | // Just extract macro info and find unmatched openers |
| 222 | |
| 223 | /** @type {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} */ |
| 224 | const unclosedStack = []; |
| 225 | |
| 226 | // Extract macro names and closing status |
| 227 | for (const item of items) { |
| 228 | if (item.type !== 'macro') continue; |
| 229 | |
| 230 | const info = this.#extractMacroInfo(item.node); |
| 231 | if (!info) continue; |
| 232 | |
| 233 | if (info.isClosing) { |
| 234 | // Find matching opener in stack (case-insensitive) |
| 235 | // When closing an outer scope, all inner unclosed scopes are implicitly closed |
| 236 | const matchIndex = unclosedStack.findLastIndex(s => s.name.toLowerCase() === info.name.toLowerCase()); |
| 237 | if (matchIndex !== -1) { |
| 238 | // Pop everything from matchIndex to end (inclusive) - closes the matched scope and all nested ones |
| 239 | unclosedStack.splice(matchIndex); |
| 240 | } |
| 241 | // If no matching opener, ignore (orphan closing tag) |
| 242 | } else { |
| 243 | // Opening tag - check if this macro can accept scoped content |
| 244 | if (this.#canAcceptScopedContent(item.node, info.name)) { |
| 245 | // Extract whitespace padding from the macro |
| 246 | const { paddingBefore, paddingAfter } = this.#extractMacroPadding(item.node, text); |
| 247 | |
| 248 | unclosedStack.push({ |
| 249 | name: info.name, |
| 250 | startOffset: item.startOffset, |
| 251 | endOffset: item.endOffset, |
| 252 | paddingBefore, |
| 253 | paddingAfter, |
| 254 | }); |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | return unclosedStack; |
| 260 | } |
| 261 | |
| 262 | /** |
| 263 | * Extracts the whitespace padding from a macro node. |
| 264 | * Returns the whitespace after {{ and before }}. |
| 265 | * |
| 266 | * @param {CstNode} macroNode - The macro CST node. |
| 267 | * @param {string} text - The source text. |
| 268 | * @returns {{ paddingBefore: string, paddingAfter: string }} |
| 269 | */ |
| 270 | #extractMacroPadding(macroNode, text) { |
| 271 | const children = macroNode.children || {}; |
| 272 | const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]); |
| 273 | const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]); |
| 274 | |
| 275 | if (!startToken || !endToken) { |
| 276 | return { paddingBefore: '', paddingAfter: '' }; |
| 277 | } |
| 278 | |
| 279 | // Get the raw text inside the macro (between {{ and }}) |
| 280 | const innerStart = startToken.endOffset + 1; |
| 281 | const innerEnd = endToken.startOffset; |
| 282 | const innerText = text.slice(innerStart, innerEnd); |
| 283 | |
| 284 | // Extract leading whitespace (paddingBefore) |
| 285 | const leadingMatch = innerText.match(/^(\s*)/); |
| 286 | const paddingBefore = leadingMatch ? leadingMatch[1] : ''; |
| 287 | |
| 288 | // Extract trailing whitespace (paddingAfter) |
| 289 | const trailingMatch = innerText.match(/(\s*)$/); |
| 290 | const paddingAfter = trailingMatch ? trailingMatch[1] : ''; |
| 291 | |
| 292 | return { paddingBefore, paddingAfter }; |
| 293 | } |
| 294 | |
| 295 | /** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */ |
| 296 | /** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode, scopedContent?: { startOffset: number, endOffset: number, closingEndOffset: number }, keepRaw?: boolean }} DocumentItemMacro */ |
| 297 | /** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */ |
| 298 | |
| 299 | /** |
| 300 | * Collects top-level plaintext tokens and macro nodes from the document CST. |
| 301 | * |
| 302 | * @param {CstNode} cst |
| 303 | * @returns {Array<DocumentItem>} |
| 304 | */ |
| 305 | #collectDocumentItems(cst) { |
| 306 | const plaintextTokens = /** @type {IToken[]} */ (cst.children.plaintext || []); |
| 307 | const macroNodes = /** @type {CstNode[]} */ (cst.children.macro || []); |
| 308 | |
| 309 | /** @type {Array<DocumentItem>} */ |
| 310 | const items = []; |
| 311 | |
| 312 | for (const token of plaintextTokens) { |
| 313 | if (typeof token.startOffset !== 'number' || typeof token.endOffset !== 'number') { |
| 314 | continue; |
| 315 | } |
| 316 | |
| 317 | items.push({ |
| 318 | type: 'plaintext', |
| 319 | startOffset: token.startOffset, |
| 320 | endOffset: token.endOffset, |
| 321 | token, |
| 322 | }); |
| 323 | } |
| 324 | |
| 325 | for (const macroNode of macroNodes) { |
| 326 | const children = macroNode.children || {}; |
| 327 | const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]); |
| 328 | |
| 329 | // If the end token was inserted during error recovery, treat this macro as plaintext |
| 330 | if (this.#isRecoveryToken(endToken)) { |
| 331 | // Flatten the incomplete macro: collect its tokens as plaintext but keep nested macros |
| 332 | this.#flattenIncompleteMacro(macroNode, endToken, items); |
| 333 | continue; |
| 334 | } |
| 335 | |
| 336 | const range = this.#getMacroRange(macroNode); |
| 337 | items.push({ |
| 338 | type: 'macro', |
| 339 | startOffset: range.startOffset, |
| 340 | endOffset: range.endOffset, |
| 341 | node: macroNode, |
| 342 | }); |
| 343 | } |
| 344 | |
| 345 | items.sort((a, b) => { |
| 346 | if (a.startOffset !== b.startOffset) { |
| 347 | return a.startOffset - b.startOffset; |
| 348 | } |
| 349 | return a.endOffset - b.endOffset; |
| 350 | }); |
| 351 | |
| 352 | return items; |
| 353 | } |
| 354 | |
| 355 | /** |
| 356 | * Evaluates a single macro CST node, resolving any nested macros first. |
| 357 | * |
| 358 | * @param {CstNode} macroNode |
| 359 | * @param {EvaluationContext} context |
| 360 | * @param {{ startOffset: number, endOffset: number, closingEndOffset: number }} [scopedContent] - Optional scoped content range for block macros. |
| 361 | * @returns {string} |
| 362 | */ |
| 363 | #evaluateMacroNode(macroNode, context, scopedContent) { |
| 364 | const { text, contextOffset, env, resolveMacro, trimContent } = context; |
| 365 | |
| 366 | const children = macroNode.children || {}; |
| 367 | |
| 368 | // Check if this is a variable expression (has variableExpr child) |
| 369 | const variableExprNode = /** @type {CstNode?} */ ((children.variableExpr || [])[0]); |
| 370 | if (variableExprNode) { |
| 371 | return this.#evaluateVariableExpr(macroNode, variableExprNode, context); |
| 372 | } |
| 373 | |
| 374 | // Regular macro - get identifier from macroBody |
| 375 | const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]); |
| 376 | const bodyChildren = macroBodyNode?.children || {}; |
| 377 | const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []); |
| 378 | const name = identifierTokens[0]?.image || ''; |
| 379 | |
| 380 | // Extract flag tokens and parse them into a MacroFlags object (now inside macroBody) |
| 381 | const flagTokens = /** @type {IToken[]} */ (children.flags || []); |
| 382 | const flagSymbols = flagTokens.map(token => token.image); |
| 383 | const flags = flagSymbols.length > 0 ? parseFlags(flagSymbols) : createEmptyFlags(); |
| 384 | |
| 385 | const range = this.#getMacroRange(macroNode); |
| 386 | const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]); |
| 387 | const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]); |
| 388 | |
| 389 | const innerStart = startToken ? startToken.endOffset + 1 : range.startOffset; |
| 390 | const innerEnd = endToken ? endToken.startOffset - 1 : range.endOffset; |
| 391 | |
| 392 | // Extract argument nodes from the "arguments" rule (if present, inside macroBody) |
| 393 | const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]); |
| 394 | const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []); |
| 395 | |
| 396 | // Check if this macro has delayArgResolution flag - if so, skip nested macro evaluation |
| 397 | const macroDef = MacroRegistry.getMacro(name); |
| 398 | const delayArgResolution = macroDef?.delayArgResolution === true; |
| 399 | |
| 400 | /** @type {string[]} */ |
| 401 | const args = []; |
| 402 | /** @type {({ value: string } & TokenRange)[]} */ |
| 403 | const evaluatedArguments = []; |
| 404 | /** @type {string[]} */ |
| 405 | const rawArgs = []; |
| 406 | |
| 407 | for (const argNode of argumentNodes) { |
| 408 | const location = this.#getArgumentLocation(argNode); |
| 409 | const rawArgText = location ? text.slice(location.startOffset, location.endOffset + 1) : ''; |
| 410 | rawArgs.push(rawArgText); |
| 411 | |
| 412 | // If delayArgResolution is true, use raw text; otherwise evaluate nested macros |
| 413 | const argValue = delayArgResolution ? rawArgText : this.#evaluateArgumentNode(argNode, context); |
| 414 | args.push(argValue); |
| 415 | |
| 416 | if (location) { |
| 417 | evaluatedArguments.push({ |
| 418 | value: argValue, |
| 419 | ...location, |
| 420 | }); |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | // If this macro has scoped content, evaluate it and append as the last argument |
| 425 | if (scopedContent) { |
| 426 | // Handle empty scoped content (when opening and closing are adjacent) |
| 427 | if (scopedContent.startOffset > scopedContent.endOffset) { |
| 428 | args.push(''); |
| 429 | rawArgs.push(''); |
| 430 | } else { |
| 431 | const rawScopedText = text.slice(scopedContent.startOffset, scopedContent.endOffset + 1); |
| 432 | rawArgs.push(rawScopedText); |
| 433 | |
| 434 | // If delayArgResolution is true, use raw text; otherwise evaluate nested macros |
| 435 | let scopedValue; |
| 436 | if (delayArgResolution) { |
| 437 | scopedValue = rawScopedText; |
| 438 | } else { |
| 439 | scopedValue = this.#evaluateScopedContent(scopedContent, context); |
| 440 | // Auto-trim scoped content unless the '#' (preserveWhitespace) flag is set |
| 441 | if (!flags.preserveWhitespace) { |
| 442 | scopedValue = trimContent(scopedValue); |
| 443 | } |
| 444 | } |
| 445 | |
| 446 | args.push(scopedValue); |
| 447 | |
| 448 | // Add to evaluated arguments for rawInner reconstruction |
| 449 | evaluatedArguments.push({ |
| 450 | value: scopedValue, |
| 451 | startOffset: scopedContent.startOffset, |
| 452 | endOffset: scopedContent.endOffset, |
| 453 | }); |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | evaluatedArguments.sort((a, b) => a.startOffset - b.startOffset); |
| 458 | |
| 459 | // Build the inner raw string between the braces, with nested macros resolved. |
| 460 | // This uses the already evaluated argument strings and preserves any text |
| 461 | // between arguments (such as separators or whitespace). |
| 462 | let rawInner = ''; |
| 463 | if (innerStart <= innerEnd) { |
| 464 | let cursor = innerStart; |
| 465 | |
| 466 | for (const entry of evaluatedArguments) { |
| 467 | if (entry.startOffset > cursor) { |
| 468 | rawInner += text.slice(cursor, entry.startOffset); |
| 469 | } |
| 470 | |
| 471 | rawInner += entry.value; |
| 472 | cursor = entry.endOffset + 1; |
| 473 | } |
| 474 | |
| 475 | if (cursor <= innerEnd) { |
| 476 | rawInner += text.slice(cursor, innerEnd + 1); |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | /** @type {MacroCall} */ |
| 481 | const call = { |
| 482 | name, |
| 483 | args, |
| 484 | flags, |
| 485 | isScoped: scopedContent != null, |
| 486 | rawInner, |
| 487 | rawWithBraces: text.slice(range.startOffset, range.endOffset + 1), |
| 488 | rawArgs, |
| 489 | range, |
| 490 | globalOffset: contextOffset + range.startOffset, |
| 491 | cstNode: macroNode, |
| 492 | env, |
| 493 | }; |
| 494 | |
| 495 | const value = resolveMacro(call); |
| 496 | const stringValue = typeof value === 'string' ? value : String(value ?? ''); |
| 497 | |
| 498 | return stringValue; |
| 499 | } |
| 500 | |
| 501 | /** |
| 502 | * Evaluates a variable expression node using direct variable API calls. |
| 503 | * Supports operators: get, set (=), add (+=), sub (-=), inc (++), dec (--), |
| 504 | * logical or (||), nullish coalescing (??), logical or assign (||=), |
| 505 | * nullish coalescing assign (??=), and equality comparison (==). |
| 506 | * |
| 507 | * @param {CstNode} macroNode - The parent macro node. |
| 508 | * @param {CstNode} variableExprNode - The variableExpr CST node. |
| 509 | * @param {EvaluationContext} context - The evaluation context. |
| 510 | * @returns {string} |
| 511 | */ |
| 512 | #evaluateVariableExpr(macroNode, variableExprNode, context) { |
| 513 | const varChildren = variableExprNode.children || {}; |
| 514 | |
| 515 | // Extract scope (. for local, $ for global) |
| 516 | const localPrefixToken = /** @type {IToken?} */ ((varChildren['Var.scope'] || []).find(t => /** @type {IToken} */(t).tokenType?.name === 'Var.LocalPrefix')); |
| 517 | const isGlobal = !localPrefixToken; |
| 518 | |
| 519 | // Extract variable name |
| 520 | const varIdentifierToken = /** @type {IToken?} */ ((varChildren['Var.identifier'] || [])[0]); |
| 521 | const varName = varIdentifierToken?.image || ''; |
| 522 | |
| 523 | // Extract operator (if any) |
| 524 | const operatorNode = /** @type {CstNode?} */ ((varChildren.variableOperator || [])[0]); |
| 525 | const operatorChildren = operatorNode?.children || {}; |
| 526 | |
| 527 | // Determine operation and whether a value expression is expected |
| 528 | let operation = 'get'; |
| 529 | let hasValueExpr = false; |
| 530 | |
| 531 | if (operatorNode) { |
| 532 | const operatorTokens = /** @type {IToken[]} */ (operatorChildren['Var.operator'] || []); |
| 533 | const operatorToken = operatorTokens[0]; |
| 534 | |
| 535 | if (operatorToken) { |
| 536 | const operatorImage = operatorToken.image; |
| 537 | switch (operatorImage) { |
| 538 | case '++': |
| 539 | operation = 'inc'; |
| 540 | break; |
| 541 | case '--': |
| 542 | operation = 'dec'; |
| 543 | break; |
| 544 | case '=': |
| 545 | operation = 'set'; |
| 546 | hasValueExpr = true; |
| 547 | break; |
| 548 | case '+=': |
| 549 | operation = 'add'; |
| 550 | hasValueExpr = true; |
| 551 | break; |
| 552 | case '-=': |
| 553 | operation = 'sub'; |
| 554 | hasValueExpr = true; |
| 555 | break; |
| 556 | case '||': |
| 557 | operation = 'logicalOr'; |
| 558 | hasValueExpr = true; |
| 559 | break; |
| 560 | case '??': |
| 561 | operation = 'nullishCoalescing'; |
| 562 | hasValueExpr = true; |
| 563 | break; |
| 564 | case '||=': |
| 565 | operation = 'logicalOrAssign'; |
| 566 | hasValueExpr = true; |
| 567 | break; |
| 568 | case '??=': |
| 569 | operation = 'nullishCoalescingAssign'; |
| 570 | hasValueExpr = true; |
| 571 | break; |
| 572 | case '==': |
| 573 | operation = 'equals'; |
| 574 | hasValueExpr = true; |
| 575 | break; |
| 576 | case '!=': |
| 577 | operation = 'notEquals'; |
| 578 | hasValueExpr = true; |
| 579 | break; |
| 580 | case '>': |
| 581 | operation = 'greaterThan'; |
| 582 | hasValueExpr = true; |
| 583 | break; |
| 584 | case '>=': |
| 585 | operation = 'greaterThanOrEqual'; |
| 586 | hasValueExpr = true; |
| 587 | break; |
| 588 | case '<': |
| 589 | operation = 'lessThan'; |
| 590 | hasValueExpr = true; |
| 591 | break; |
| 592 | case '<=': |
| 593 | operation = 'lessThanOrEqual'; |
| 594 | hasValueExpr = true; |
| 595 | break; |
| 596 | default: |
| 597 | logMacroInternalError({ message: `Lexer found macro operator that is not implemented for variable shorthand expressions in macro node '${macroNode.name}'.` }); |
| 598 | break; |
| 599 | } |
| 600 | } |
| 601 | } |
| 602 | |
| 603 | // Create a lazy value resolver that caches its result on first call. |
| 604 | // This ensures the value expression is only evaluated when actually needed, |
| 605 | // which is important for performance and because some macros are stateful. |
| 606 | const lazyValue = hasValueExpr ? this.#createLazyValue(operatorChildren, context) : () => ''; |
| 607 | |
| 608 | // Execute the operation using direct variable API calls |
| 609 | return this.#executeVariableOperation(varName, isGlobal, operation, lazyValue); |
| 610 | } |
| 611 | |
| 612 | /** |
| 613 | * Creates a lazy value resolver that caches its result on first call. |
| 614 | * This ensures the value expression is only evaluated when actually needed. |
| 615 | * |
| 616 | * @param {Record<string, any>} operatorChildren - The children of the variableOperator node. |
| 617 | * @param {EvaluationContext} context - The evaluation context. |
| 618 | * @returns {() => string} A function that returns the evaluated value, caching the result. |
| 619 | */ |
| 620 | #createLazyValue(operatorChildren, context) { |
| 621 | let cached = null; |
| 622 | let resolved = false; |
| 623 | |
| 624 | return () => { |
| 625 | if (!resolved) { |
| 626 | cached = this.#evaluateVariableValue(operatorChildren, context); |
| 627 | resolved = true; |
| 628 | } |
| 629 | return cached; |
| 630 | }; |
| 631 | } |
| 632 | |
| 633 | /** |
| 634 | * Executes a variable operation using the SillyTavern context API. |
| 635 | * |
| 636 | * @param {string} varName - The variable name. |
| 637 | * @param {boolean} isGlobal - Whether this is a global ($) or local (.) variable. |
| 638 | * @param {string} operation - The operation to perform. |
| 639 | * @param {() => string} lazyValue - A lazy function that returns the value when called. Only evaluated when needed. |
| 640 | * @returns {string} The result of the operation. |
| 641 | */ |
| 642 | #executeVariableOperation(varName, isGlobal, operation, lazyValue) { |
| 643 | const ctx = SillyTavern.getContext(); |
| 644 | const vars = isGlobal ? ctx.variables.global : ctx.variables.local; |
| 645 | |
| 646 | /** |
| 647 | * Normalizes macro results into a string. |
| 648 | * @param {any} value |
| 649 | * @returns {string} |
| 650 | */ |
| 651 | const normalize = MacroEngine.normalizeMacroResult.bind(MacroEngine); |
| 652 | |
| 653 | /** |
| 654 | * Checks if a value is falsy (empty string, 0, '0', false, 'false', null, undefined). |
| 655 | * @param {any} val |
| 656 | * @returns {boolean} |
| 657 | */ |
| 658 | const isFalsy = (val) => !val || isFalseBoolean(normalize(val)); |
| 659 | |
| 660 | switch (operation) { |
| 661 | case 'get': |
| 662 | return normalize(vars.get(varName)); |
| 663 | |
| 664 | case 'set': |
| 665 | vars.set(varName, lazyValue()); |
| 666 | return ''; |
| 667 | |
| 668 | case 'inc': |
| 669 | return normalize(vars.inc(varName)); |
| 670 | |
| 671 | case 'dec': |
| 672 | return normalize(vars.dec(varName)); |
| 673 | |
| 674 | case 'add': |
| 675 | vars.add(varName, lazyValue()); |
| 676 | return ''; |
| 677 | |
| 678 | case 'sub': { |
| 679 | // Subtract by adding the negative value |
| 680 | const numValue = Number(lazyValue()); |
| 681 | if (!isNaN(numValue)) vars.add(varName, -numValue); |
| 682 | else logMacroRuntimeWarning({ message: `Variable shorthand "-=" operator requires a numeric value, got: "${lazyValue()}"` }); |
| 683 | return ''; |
| 684 | } |
| 685 | |
| 686 | case 'logicalOr': { |
| 687 | // Returns default value if variable is falsy, otherwise returns variable value |
| 688 | // Value is only resolved if needed (when variable is falsy) |
| 689 | const currentValue = vars.get(varName); |
| 690 | return isFalsy(currentValue) ? normalize(lazyValue()) : normalize(currentValue); |
| 691 | } |
| 692 | |
| 693 | case 'nullishCoalescing': { |
| 694 | // Returns default value only if variable doesn't exist, otherwise returns variable value (even if falsy) |
| 695 | // Value is only resolved if needed (when variable doesn't exist) |
| 696 | const exists = vars.has(varName); |
| 697 | return exists ? normalize(vars.get(varName)) : normalize(lazyValue()); |
| 698 | } |
| 699 | |
| 700 | case 'logicalOrAssign': { |
| 701 | // If variable is falsy, set it to value and return value; otherwise return current value |
| 702 | // Value is only resolved if needed (when variable is falsy) |
| 703 | const currentValue = vars.get(varName); |
| 704 | if (isFalsy(currentValue)) { |
| 705 | vars.set(varName, lazyValue()); |
| 706 | return normalize(lazyValue()); |
| 707 | } |
| 708 | return normalize(currentValue); |
| 709 | } |
| 710 | |
| 711 | case 'nullishCoalescingAssign': { |
| 712 | // If variable doesn't exist, set it to value and return value; otherwise return current value |
| 713 | // Value is only resolved if needed (when variable doesn't exist) |
| 714 | const exists = vars.has(varName); |
| 715 | if (!exists) { |
| 716 | vars.set(varName, lazyValue()); |
| 717 | return normalize(lazyValue()); |
| 718 | } |
| 719 | return normalize(vars.get(varName)); |
| 720 | } |
| 721 | |
| 722 | case 'equals': { |
| 723 | // String equality comparison - value is always needed |
| 724 | const currentValue = normalize(vars.get(varName)); |
| 725 | const compareValue = normalize(lazyValue()); |
| 726 | return currentValue === compareValue ? 'true' : 'false'; |
| 727 | } |
| 728 | |
| 729 | case 'notEquals': { |
| 730 | // String inequality comparison - value is always needed |
| 731 | const currentValue = normalize(vars.get(varName)); |
| 732 | const compareValue = normalize(lazyValue()); |
| 733 | return currentValue !== compareValue ? 'true' : 'false'; |
| 734 | } |
| 735 | |
| 736 | case 'greaterThan': { |
| 737 | // Numeric greater than comparison |
| 738 | const currentNum = Number(vars.get(varName)); |
| 739 | const compareNum = Number(lazyValue()); |
| 740 | if (isNaN(currentNum) || isNaN(compareNum)) { |
| 741 | logMacroRuntimeWarning({ message: `Variable shorthand ">" operator requires numeric values. Got: "${vars.get(varName)}" > "${lazyValue()}"` }); |
| 742 | return 'false'; |
| 743 | } |
| 744 | return currentNum > compareNum ? 'true' : 'false'; |
| 745 | } |
| 746 | |
| 747 | case 'greaterThanOrEqual': { |
| 748 | // Numeric greater than or equal comparison |
| 749 | const currentNum = Number(vars.get(varName)); |
| 750 | const compareNum = Number(lazyValue()); |
| 751 | if (isNaN(currentNum) || isNaN(compareNum)) { |
| 752 | logMacroRuntimeWarning({ message: `Variable shorthand ">=" operator requires numeric values. Got: "${vars.get(varName)}" >= "${lazyValue()}"` }); |
| 753 | return 'false'; |
| 754 | } |
| 755 | return currentNum >= compareNum ? 'true' : 'false'; |
| 756 | } |
| 757 | |
| 758 | case 'lessThan': { |
| 759 | // Numeric less than comparison |
| 760 | const currentNum = Number(vars.get(varName)); |
| 761 | const compareNum = Number(lazyValue()); |
| 762 | if (isNaN(currentNum) || isNaN(compareNum)) { |
| 763 | logMacroRuntimeWarning({ message: `Variable shorthand "<" operator requires numeric values. Got: "${vars.get(varName)}" < "${lazyValue()}"` }); |
| 764 | return 'false'; |
| 765 | } |
| 766 | return currentNum < compareNum ? 'true' : 'false'; |
| 767 | } |
| 768 | |
| 769 | case 'lessThanOrEqual': { |
| 770 | // Numeric less than or equal comparison |
| 771 | const currentNum = Number(vars.get(varName)); |
| 772 | const compareNum = Number(lazyValue()); |
| 773 | if (isNaN(currentNum) || isNaN(compareNum)) { |
| 774 | logMacroRuntimeWarning({ message: `Variable shorthand "<=" operator requires numeric values. Got: "${vars.get(varName)}" <= "${lazyValue()}"` }); |
| 775 | return 'false'; |
| 776 | } |
| 777 | return currentNum <= compareNum ? 'true' : 'false'; |
| 778 | } |
| 779 | |
| 780 | default: |
| 781 | logMacroRuntimeWarning({ message: `Unknown variable shorthand operation: "${operation}"` }); |
| 782 | return ''; |
| 783 | } |
| 784 | } |
| 785 | |
| 786 | /** |
| 787 | * Evaluates the value part of a variable expression (after = or +=). |
| 788 | * Resolves any nested macros in the value. |
| 789 | * |
| 790 | * @param {Record<string, any>} operatorChildren - The children of the variableOperator node. |
| 791 | * @param {EvaluationContext} context - The evaluation context. |
| 792 | * @returns {string} |
| 793 | */ |
| 794 | #evaluateVariableValue(operatorChildren, context) { |
| 795 | const { text } = context; |
| 796 | |
| 797 | const valueNodes = /** @type {CstNode[]} */ (operatorChildren['Var.value'] || []); |
| 798 | const valueNode = valueNodes[0]; |
| 799 | |
| 800 | if (!valueNode) { |
| 801 | return ''; |
| 802 | } |
| 803 | |
| 804 | const valueChildren = valueNode.children || {}; |
| 805 | |
| 806 | // Get all tokens and nested macros from the value |
| 807 | const identifierTokens = /** @type {IToken[]} */ (valueChildren.Identifier || []); |
| 808 | const unknownTokens = /** @type {IToken[]} */ (valueChildren.Unknown || []); |
| 809 | const nestedMacros = /** @type {CstNode[]} */ (valueChildren.macro || []); |
| 810 | |
| 811 | // Get the range of the value |
| 812 | const allTokens = [...identifierTokens, ...unknownTokens]; |
| 813 | const allRanges = [ |
| 814 | ...allTokens.map(t => ({ startOffset: t.startOffset, endOffset: t.endOffset })), |
| 815 | ...nestedMacros.map(m => this.#getMacroRange(m)), |
| 816 | ]; |
| 817 | |
| 818 | if (allRanges.length === 0) { |
| 819 | return ''; |
| 820 | } |
| 821 | |
| 822 | const startOffset = Math.min(...allRanges.map(r => r.startOffset)); |
| 823 | const endOffset = Math.max(...allRanges.map(r => r.endOffset)); |
| 824 | |
| 825 | // If no nested macros, return the raw text (trimmed) |
| 826 | if (nestedMacros.length === 0) { |
| 827 | return text.slice(startOffset, endOffset + 1).trim(); |
| 828 | } |
| 829 | |
| 830 | // Evaluate nested macros |
| 831 | const nestedWithRange = nestedMacros.map(node => ({ |
| 832 | node, |
| 833 | range: this.#getMacroRange(node), |
| 834 | })); |
| 835 | |
| 836 | nestedWithRange.sort((a, b) => a.range.startOffset - b.range.startOffset); |
| 837 | |
| 838 | let result = ''; |
| 839 | let cursor = startOffset; |
| 840 | |
| 841 | for (const entry of nestedWithRange) { |
| 842 | if (entry.range.startOffset > cursor) { |
| 843 | result += text.slice(cursor, entry.range.startOffset); |
| 844 | } |
| 845 | result += this.#evaluateMacroNode(entry.node, context); |
| 846 | cursor = entry.range.endOffset + 1; |
| 847 | } |
| 848 | |
| 849 | if (cursor <= endOffset) { |
| 850 | result += text.slice(cursor, endOffset + 1); |
| 851 | } |
| 852 | |
| 853 | return result.trim(); |
| 854 | } |
| 855 | |
| 856 | /** |
| 857 | * Evaluates a single argument node by resolving nested macros and reconstructing |
| 858 | * the original argument text. |
| 859 | * |
| 860 | * This method extracts the argument's raw text and re-parses it to properly |
| 861 | * handle scoped macros (opening/closing tag pairs) that may appear within |
| 862 | * the argument content. |
| 863 | * |
| 864 | * @param {CstNode} argNode - The argument CST node to evaluate. |
| 865 | * @param {EvaluationContext} context - The evaluation context containing the parent document's text and environment. |
| 866 | * @returns {string} The evaluated argument with all nested macros (including scoped ones) resolved. |
| 867 | */ |
| 868 | #evaluateArgumentNode(argNode, context) { |
| 869 | const location = this.#getArgumentLocation(argNode); |
| 870 | if (!location) { |
| 871 | return ''; |
| 872 | } |
| 873 | |
| 874 | const { text, contextOffset } = context; |
| 875 | const rawContent = text.slice(location.startOffset, location.endOffset + 1); |
| 876 | |
| 877 | // Calculate the new base offset: parent's contextOffset + this argument's start position |
| 878 | const newContextOffset = contextOffset + location.startOffset; |
| 879 | |
| 880 | // Use the shared helper to evaluate the content, which handles scoped macros |
| 881 | return this.#evaluateRawContent(rawContent, newContextOffset, context); |
| 882 | } |
| 883 | |
| 884 | /** |
| 885 | * Evaluates a text content string by parsing it and resolving all macros, |
| 886 | * including scoped macro pairs (opening/closing tags). |
| 887 | * |
| 888 | * This is the core helper used by both argument evaluation and scoped content |
| 889 | * evaluation to ensure consistent handling of nested and scoped macros. |
| 890 | * |
| 891 | * @param {string} rawContent - The raw text content to evaluate. |
| 892 | * @param {number} newContextOffset - The offset of rawContent's start position in the original top-level document. |
| 893 | * @param {EvaluationContext} context - The parent evaluation context (used for env, resolveMacro, trimContent). |
| 894 | * @returns {string} The evaluated content with all macros resolved. |
| 895 | */ |
| 896 | #evaluateRawContent(rawContent, newContextOffset, context) { |
| 897 | // If empty, return as-is |
| 898 | if (!rawContent) { |
| 899 | return ''; |
| 900 | } |
| 901 | |
| 902 | // Re-evaluate the content to find all nested macros including scoped pairs |
| 903 | // We need to parse and evaluate this content as if it were a standalone document |
| 904 | const { cst } = MacroParser.parseDocument(rawContent); |
| 905 | |
| 906 | // If parsing fails, return the raw content |
| 907 | if (!cst || typeof cst !== 'object' || !cst.children) { |
| 908 | return rawContent; |
| 909 | } |
| 910 | |
| 911 | // Create a new context with the content as the text and updated contextOffset |
| 912 | // This is important: positions in the parsed CST are relative to rawContent, |
| 913 | // but contextOffset tracks the absolute position in the original document |
| 914 | /** @type {EvaluationContext} */ |
| 915 | const contentContext = { ...context, text: rawContent, contextOffset: newContextOffset }; |
| 916 | |
| 917 | // Collect items and process scoped macros |
| 918 | let items = this.#collectDocumentItems(cst); |
| 919 | items = this.#processScopedMacros(items, rawContent); |
| 920 | |
| 921 | // If no items, return raw content |
| 922 | if (items.length === 0) { |
| 923 | return rawContent; |
| 924 | } |
| 925 | |
| 926 | // Evaluate items in order |
| 927 | let result = ''; |
| 928 | let cursor = 0; |
| 929 | |
| 930 | for (const item of items) { |
| 931 | if (item.startOffset > cursor) { |
| 932 | result += rawContent.slice(cursor, item.startOffset); |
| 933 | } |
| 934 | |
| 935 | if (item.type === 'plaintext') { |
| 936 | result += rawContent.slice(item.startOffset, item.endOffset + 1); |
| 937 | cursor = item.endOffset + 1; |
| 938 | } else if (item.keepRaw) { |
| 939 | // Unmatched closing macros stay as raw text |
| 940 | result += rawContent.slice(item.startOffset, item.endOffset + 1); |
| 941 | cursor = item.endOffset + 1; |
| 942 | } else { |
| 943 | result += this.#evaluateMacroNode(item.node, contentContext, item.scopedContent); |
| 944 | // If this macro has scoped content, skip past the closing macro |
| 945 | if (item.scopedContent && item.scopedContent.closingEndOffset > item.endOffset) { |
| 946 | cursor = item.scopedContent.closingEndOffset + 1; |
| 947 | } else { |
| 948 | cursor = item.endOffset + 1; |
| 949 | } |
| 950 | } |
| 951 | } |
| 952 | |
| 953 | if (cursor < rawContent.length) { |
| 954 | result += rawContent.slice(cursor); |
| 955 | } |
| 956 | |
| 957 | return result; |
| 958 | } |
| 959 | |
| 960 | /** |
| 961 | * Computes the character range of a macro node based on its start/end tokens |
| 962 | * or its own location if those are not available. |
| 963 | * |
| 964 | * @param {CstNode} macroNode |
| 965 | * @returns {TokenRange} |
| 966 | */ |
| 967 | #getMacroRange(macroNode) { |
| 968 | const startToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.Start'] || [])[0]); |
| 969 | const endToken = /** @type {IToken?} */ (((macroNode.children || {})['Macro.End'] || [])[0]); |
| 970 | |
| 971 | if (startToken && endToken) { |
| 972 | return { startOffset: startToken.startOffset, endOffset: endToken.endOffset }; |
| 973 | } |
| 974 | if (macroNode.location) { |
| 975 | return { startOffset: macroNode.location.startOffset, endOffset: macroNode.location.endOffset }; |
| 976 | } |
| 977 | return { startOffset: 0, endOffset: 0 }; |
| 978 | } |
| 979 | |
| 980 | /** |
| 981 | * Flattens an incomplete macro node into document items. |
| 982 | * Tokens from the incomplete macro become plaintext, but nested complete macros are preserved. |
| 983 | * |
| 984 | * @param {CstNode} macroNode |
| 985 | * @param {IToken} excludeToken - The recovery-inserted token to exclude |
| 986 | * @param {Array<DocumentItem>} items - The items array to add to |
| 987 | */ |
| 988 | #flattenIncompleteMacro(macroNode, excludeToken, items) { |
| 989 | const children = macroNode.children || {}; |
| 990 | |
| 991 | for (const key of Object.keys(children)) { |
| 992 | for (const element of children[key] || []) { |
| 993 | // Skip the recovery-inserted token |
| 994 | if (element === excludeToken) continue; |
| 995 | |
| 996 | // Handle IToken - add as plaintext |
| 997 | if ('startOffset' in element && typeof element.startOffset === 'number') { |
| 998 | items.push({ |
| 999 | type: 'plaintext', |
| 1000 | startOffset: element.startOffset, |
| 1001 | endOffset: element.endOffset ?? element.startOffset, |
| 1002 | token: element, |
| 1003 | }); |
| 1004 | } else if ('children' in element) { |
| 1005 | // Handle nested CstNode (macro or argument) |
| 1006 | const nestedChildren = element.children || {}; |
| 1007 | const nestedEnd = /** @type {IToken?} */ ((nestedChildren['Macro.End'] || [])[0]); |
| 1008 | const nestedStart = /** @type {IToken?} */ ((nestedChildren['Macro.Start'] || [])[0]); |
| 1009 | |
| 1010 | // Check if this is a complete macro node |
| 1011 | if (nestedStart && nestedEnd) { |
| 1012 | if (!this.#isRecoveryToken(nestedEnd)) { |
| 1013 | // Complete nested macro - add as macro item |
| 1014 | const range = this.#getMacroRange(element); |
| 1015 | items.push({ |
| 1016 | type: 'macro', |
| 1017 | startOffset: range.startOffset, |
| 1018 | endOffset: range.endOffset, |
| 1019 | node: element, |
| 1020 | }); |
| 1021 | } else { |
| 1022 | // Another incomplete nested macro - recurse |
| 1023 | this.#flattenIncompleteMacro(element, nestedEnd, items); |
| 1024 | } |
| 1025 | } else { |
| 1026 | // Not a macro node (e.g., arguments, argument) - recurse into it |
| 1027 | this.#flattenIncompleteMacro(element, excludeToken, items); |
| 1028 | } |
| 1029 | } |
| 1030 | } |
| 1031 | } |
| 1032 | } |
| 1033 | |
| 1034 | /** |
| 1035 | * Checks if a token was inserted during Chevrotain's error recovery. |
| 1036 | * Recovery tokens have `isInsertedInRecovery=true` or invalid offset values. |
| 1037 | * |
| 1038 | * @param {IToken|null|undefined} token |
| 1039 | * @returns {boolean} |
| 1040 | */ |
| 1041 | #isRecoveryToken(token) { |
| 1042 | return token?.isInsertedInRecovery === true |
| 1043 | || typeof token?.startOffset !== 'number' |
| 1044 | || Number.isNaN(token?.startOffset); |
| 1045 | } |
| 1046 | |
| 1047 | /** |
| 1048 | * Computes the character range of an argument node based on all its child |
| 1049 | * tokens and nested macros. |
| 1050 | * |
| 1051 | * @param {CstNode} argNode |
| 1052 | * @returns {TokenRange|null} |
| 1053 | */ |
| 1054 | #getArgumentLocation(argNode) { |
| 1055 | const children = argNode.children || {}; |
| 1056 | let startOffset = Number.POSITIVE_INFINITY; |
| 1057 | let endOffset = Number.NEGATIVE_INFINITY; |
| 1058 | |
| 1059 | for (const key of Object.keys(children)) { |
| 1060 | for (const element of children[key] || []) { |
| 1061 | if (this.#isCstNode(element)) { |
| 1062 | const location = element.location; |
| 1063 | if (!location) { |
| 1064 | continue; |
| 1065 | } |
| 1066 | |
| 1067 | if (location.startOffset < startOffset) { |
| 1068 | startOffset = location.startOffset; |
| 1069 | } |
| 1070 | if (location.endOffset > endOffset) { |
| 1071 | endOffset = location.endOffset; |
| 1072 | } |
| 1073 | } else if (element) { |
| 1074 | if (element.startOffset < startOffset) { |
| 1075 | startOffset = element.startOffset; |
| 1076 | } |
| 1077 | if (element.endOffset > endOffset) { |
| 1078 | endOffset = element.endOffset; |
| 1079 | } |
| 1080 | } |
| 1081 | } |
| 1082 | } |
| 1083 | |
| 1084 | if (!Number.isFinite(startOffset) || !Number.isFinite(endOffset)) { |
| 1085 | return null; |
| 1086 | } |
| 1087 | |
| 1088 | return { startOffset, endOffset }; |
| 1089 | } |
| 1090 | |
| 1091 | /** |
| 1092 | * Determines whether the given value is a CST node. |
| 1093 | * |
| 1094 | * @param {any} value |
| 1095 | * @returns {value is CstNode} |
| 1096 | */ |
| 1097 | #isCstNode(value) { |
| 1098 | return !!value && typeof value === 'object' && 'name' in value && 'children' in value; |
| 1099 | } |
| 1100 | |
| 1101 | /** |
| 1102 | * Evaluates scoped content between an opening and closing macro tag. |
| 1103 | * This resolves any nested macros within the scoped content. |
| 1104 | * |
| 1105 | * @param {{ startOffset: number, endOffset: number }} scopedContent - The range of the scoped content. |
| 1106 | * @param {EvaluationContext} context - The evaluation context. The `text` property contains the parent |
| 1107 | * document text, and offsets in scopedContent are relative to that parent text. |
| 1108 | * @returns {string} - The evaluated scoped content with nested macros resolved. |
| 1109 | */ |
| 1110 | #evaluateScopedContent(scopedContent, context) { |
| 1111 | const { text, contextOffset } = context; |
| 1112 | const { startOffset, endOffset } = scopedContent; |
| 1113 | |
| 1114 | // Extract the raw content between opening and closing tags |
| 1115 | const rawContent = text.slice(startOffset, endOffset + 1); |
| 1116 | |
| 1117 | // Calculate the new base offset: parent's contextOffset + this scoped content's start position |
| 1118 | const newContextOffset = contextOffset + startOffset; |
| 1119 | |
| 1120 | // Use the shared helper to evaluate the content |
| 1121 | return this.#evaluateRawContent(rawContent, newContextOffset, context); |
| 1122 | } |
| 1123 | |
| 1124 | // ======================================================================== |
| 1125 | // Scoped Macro Processing |
| 1126 | // ======================================================================== |
| 1127 | |
| 1128 | /** |
| 1129 | * Processes document items to find and merge scoped macro pairs. |
| 1130 | * A scoped macro is an opening macro followed by content and a closing macro. |
| 1131 | * Example: `{{setvar::myvar}}content{{/setvar}}` becomes `{{setvar::myvar::content}}` |
| 1132 | * |
| 1133 | * The closing macro has the `closingBlock` flag (`/`) and the same identifier. |
| 1134 | * Everything between the opening and closing macros becomes the last unnamed argument. |
| 1135 | * |
| 1136 | * @param {Array<DocumentItem>} items - The collected document items. |
| 1137 | * @param {string} text - The original document text. |
| 1138 | * @returns {Array<DocumentItem>} - The processed items with scoped macros merged. |
| 1139 | */ |
| 1140 | #processScopedMacros(items, text) { |
| 1141 | // Build a list of scoped macro info for each macro item |
| 1142 | /** @type {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} */ |
| 1143 | const macroInfos = []; |
| 1144 | |
| 1145 | for (let i = 0; i < items.length; i++) { |
| 1146 | const item = items[i]; |
| 1147 | if (item.type !== 'macro') continue; |
| 1148 | |
| 1149 | const info = this.#extractMacroInfo(item.node); |
| 1150 | if (!info) continue; |
| 1151 | |
| 1152 | macroInfos.push({ |
| 1153 | index: i, |
| 1154 | item, |
| 1155 | name: info.name, |
| 1156 | isClosing: info.isClosing, |
| 1157 | matched: false, |
| 1158 | }); |
| 1159 | } |
| 1160 | |
| 1161 | // Find matching pairs - only process OUTERMOST scopes at this level |
| 1162 | // Nested scopes will be discovered when parent's scoped content is re-parsed |
| 1163 | /** @type {Array<{ openingIndex: number, closingIndex: number }>} */ |
| 1164 | const pairs = []; |
| 1165 | |
| 1166 | // Track ranges that are inside a scope (to skip nested openers) |
| 1167 | /** @type {Set<number>} */ |
| 1168 | const insideScope = new Set(); |
| 1169 | |
| 1170 | for (let i = 0; i < macroInfos.length; i++) { |
| 1171 | const openInfo = macroInfos[i]; |
| 1172 | |
| 1173 | // Skip closing macros, already matched macros, or macros inside another scope |
| 1174 | if (openInfo.isClosing || openInfo.matched || insideScope.has(openInfo.index)) continue; |
| 1175 | |
| 1176 | // Find the matching closing macro for this opening macro |
| 1177 | const closingIdx = this.#findMatchingClosingMacro(macroInfos, i); |
| 1178 | if (closingIdx === -1) continue; |
| 1179 | |
| 1180 | // Check if the macro can accept scoped content (arity validation) |
| 1181 | if (!this.#canAcceptScopedContent(openInfo.item.node, openInfo.name)) { |
| 1182 | // Macro cannot accept scoped content - mark both as keepRaw |
| 1183 | openInfo.item.keepRaw = true; |
| 1184 | macroInfos[closingIdx].item.keepRaw = true; |
| 1185 | // Mark as matched so they won't be processed again |
| 1186 | openInfo.matched = true; |
| 1187 | macroInfos[closingIdx].matched = true; |
| 1188 | continue; |
| 1189 | } |
| 1190 | |
| 1191 | // Mark both as matched |
| 1192 | openInfo.matched = true; |
| 1193 | macroInfos[closingIdx].matched = true; |
| 1194 | |
| 1195 | const closingIndex = macroInfos[closingIdx].index; |
| 1196 | |
| 1197 | pairs.push({ |
| 1198 | openingIndex: openInfo.index, |
| 1199 | closingIndex: closingIndex, |
| 1200 | }); |
| 1201 | |
| 1202 | // Mark all items between this pair as inside a scope |
| 1203 | // They will be processed when the scoped content is re-parsed |
| 1204 | for (let j = openInfo.index + 1; j < closingIndex; j++) { |
| 1205 | insideScope.add(j); |
| 1206 | } |
| 1207 | } |
| 1208 | |
| 1209 | // Mark unmatched closing macros as keepRaw so they stay as raw text |
| 1210 | for (const info of macroInfos) { |
| 1211 | if (info.isClosing && !info.matched) { |
| 1212 | info.item.keepRaw = true; |
| 1213 | } |
| 1214 | } |
| 1215 | |
| 1216 | // If no pairs found, return items (with unmatched closings marked as raw) |
| 1217 | if (pairs.length === 0) { |
| 1218 | return items; |
| 1219 | } |
| 1220 | |
| 1221 | // Process pairs: merge content into opening macro's scopedContent field |
| 1222 | |
| 1223 | // Track which items to remove (closing macros and intermediate content items) |
| 1224 | /** @type {Set<number>} */ |
| 1225 | const itemsToRemove = new Set(); |
| 1226 | |
| 1227 | for (const pair of pairs) { |
| 1228 | const openingItem = /** @type {DocumentItemMacro} */ (items[pair.openingIndex]); |
| 1229 | const closingItem = /** @type {DocumentItemMacro} */ (items[pair.closingIndex]); |
| 1230 | |
| 1231 | // Collect content between opening and closing (exclusive) |
| 1232 | const contentStart = openingItem.endOffset + 1; |
| 1233 | const contentEnd = closingItem.startOffset - 1; |
| 1234 | |
| 1235 | // Store the scoped content range on the opening macro item |
| 1236 | // This will be used during macro evaluation to append the content as the last argument |
| 1237 | openingItem.scopedContent = { |
| 1238 | startOffset: contentStart, |
| 1239 | endOffset: contentEnd, |
| 1240 | closingEndOffset: closingItem.endOffset, |
| 1241 | }; |
| 1242 | |
| 1243 | // Mark closing macro for removal |
| 1244 | itemsToRemove.add(pair.closingIndex); |
| 1245 | |
| 1246 | // Mark ALL intermediate items between opening and closing for removal |
| 1247 | // They will be captured as raw scoped content and re-parsed during evaluation |
| 1248 | for (let j = pair.openingIndex + 1; j < pair.closingIndex; j++) { |
| 1249 | itemsToRemove.add(j); |
| 1250 | } |
| 1251 | } |
| 1252 | |
| 1253 | // Filter out removed items |
| 1254 | return items.filter((_, index) => !itemsToRemove.has(index)); |
| 1255 | } |
| 1256 | |
| 1257 | /** |
| 1258 | * Extracts macro name and closing flag status from a macro node. |
| 1259 | * |
| 1260 | * @param {CstNode} macroNode |
| 1261 | * @returns {{ name: string, isClosing: boolean } | null} |
| 1262 | */ |
| 1263 | #extractMacroInfo(macroNode) { |
| 1264 | const children = macroNode.children || {}; |
| 1265 | |
| 1266 | // Check if this is a variable expression - they can't be scoped |
| 1267 | const variableExprNode = (children.variableExpr || [])[0]; |
| 1268 | if (variableExprNode) { |
| 1269 | return null; // Variable expressions don't support scoped content |
| 1270 | } |
| 1271 | |
| 1272 | // Regular macro - get info from macroBody |
| 1273 | const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]); |
| 1274 | const bodyChildren = macroBodyNode?.children || {}; |
| 1275 | |
| 1276 | const identifierTokens = /** @type {IToken[]} */ (bodyChildren['Macro.identifier'] || []); |
| 1277 | const name = identifierTokens[0]?.image || ''; |
| 1278 | |
| 1279 | if (!name) return null; |
| 1280 | |
| 1281 | // Check for closing block flag (inside macroBody) |
| 1282 | const flagTokens = /** @type {IToken[]} */ (children.flags || []); |
| 1283 | const isClosing = flagTokens.some(token => token.image === MacroFlagType.CLOSING_BLOCK); |
| 1284 | |
| 1285 | return { name, isClosing }; |
| 1286 | } |
| 1287 | |
| 1288 | /** |
| 1289 | * Checks if a macro can accept scoped content as an additional argument. |
| 1290 | * Returns true if adding one more argument would result in valid arity. |
| 1291 | * |
| 1292 | * @param {CstNode} macroNode - The macro CST node. |
| 1293 | * @param {string} macroName - The macro name. |
| 1294 | * @returns {boolean} - True if scoped content is allowed. |
| 1295 | */ |
| 1296 | #canAcceptScopedContent(macroNode, macroName) { |
| 1297 | const def = MacroRegistry.getPrimaryMacro(macroName); |
| 1298 | if (!def) { |
| 1299 | // Unknown macro - allow scoped content (will be handled as unknown macro later) |
| 1300 | return true; |
| 1301 | } |
| 1302 | |
| 1303 | // Count current arguments in the macro (now inside macroBody) |
| 1304 | const children = macroNode.children || {}; |
| 1305 | const macroBodyNode = /** @type {CstNode?} */ ((children.macroBody || [])[0]); |
| 1306 | const bodyChildren = macroBodyNode?.children || {}; |
| 1307 | const argumentsNode = /** @type {CstNode?} */ ((bodyChildren.arguments || [])[0]); |
| 1308 | const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []); |
| 1309 | const currentArgCount = argumentNodes.length; |
| 1310 | |
| 1311 | // List-arg macros don't support scoped content - they accept arbitrary inline args instead |
| 1312 | if (def.list) { |
| 1313 | return false; |
| 1314 | } |
| 1315 | |
| 1316 | // Check if adding 1 more argument (scoped content) would be valid |
| 1317 | const newArgCount = currentArgCount + 1; |
| 1318 | |
| 1319 | // Without list: newArgCount must be between minArgs and maxArgs |
| 1320 | return newArgCount >= def.minArgs && newArgCount <= def.maxArgs; |
| 1321 | } |
| 1322 | |
| 1323 | /** |
| 1324 | * Finds the matching closing macro for an opening macro at the given index. |
| 1325 | * Handles nested scopes by tracking depth. Only counts opening macros that |
| 1326 | * can accept scoped content (inline macros with all args filled don't count). |
| 1327 | * |
| 1328 | * @param {Array<{ index: number, item: DocumentItemMacro, name: string, isClosing: boolean, matched: boolean }>} macroInfos |
| 1329 | * @param {number} openingIdx - Index in macroInfos array of the opening macro. |
| 1330 | * @returns {number} - Index in macroInfos array of the matching closing macro, or -1 if not found. |
| 1331 | */ |
| 1332 | #findMatchingClosingMacro(macroInfos, openingIdx) { |
| 1333 | const openInfo = macroInfos[openingIdx]; |
| 1334 | const targetName = openInfo.name; |
| 1335 | let depth = 1; |
| 1336 | |
| 1337 | for (let i = openingIdx + 1; i < macroInfos.length; i++) { |
| 1338 | const info = macroInfos[i]; |
| 1339 | |
| 1340 | // Only consider macros with the same name (case-insensitive) |
| 1341 | if (info.name.toLowerCase() !== targetName.toLowerCase()) continue; |
| 1342 | |
| 1343 | // Skip already matched macros |
| 1344 | if (info.matched) continue; |
| 1345 | |
| 1346 | if (info.isClosing) { |
| 1347 | depth--; |
| 1348 | if (depth === 0) { |
| 1349 | return i; |
| 1350 | } |
| 1351 | } else { |
| 1352 | // Only increment depth for opening macros that can accept scoped content |
| 1353 | // Inline macros (e.g., {{if condition::content}}) don't need closing tags |
| 1354 | if (this.#canAcceptScopedContent(info.item.node, info.name)) { |
| 1355 | depth++; |
| 1356 | } |
| 1357 | } |
| 1358 | } |
| 1359 | |
| 1360 | return -1; // No matching closing macro found |
| 1361 | } |
| 1362 | } |
| 1363 | |
| 1364 | instance = MacroCstWalker.instance; |