Blame Raw
Cohee · e3f41666 · · 1364 lines (56.2 KB)
1 contributor
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
6import { logMacroInternalError, logMacroRuntimeWarning } from './MacroDiagnostics.js';
7import { MacroEngine } from './MacroEngine.js';
8import { parseFlags, createEmptyFlags, MacroFlagType } from './MacroFlags.js';
9import { MacroParser } from './MacroParser.js';
10import { MacroRegistry } from './MacroRegistry.js';
11import { 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 */
78let instance;
79export { instance as MacroCstWalker };
80
81class 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
1364instance = MacroCstWalker.instance;