Blame Raw
· · · 829 lines (38.4 KB)
0 contributors
1/** @typedef {import('chevrotain').CstNode} CstNode */
2/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
3/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
4/** @typedef {import('./MacroFlags.js').MacroFlags} MacroFlags */
5
6import { MACRO_IDENTIFIER_PATTERN } from './MacroLexer.js';
7
8import { isFalseBoolean, isTrueBoolean } from '../../utils.js';
9import { MacroEngine } from './MacroEngine.js';
10import { createMacroRuntimeError, logMacroRegisterError, logMacroRegisterWarning, logMacroRuntimeWarning } from './MacroDiagnostics.js';
11
12/**
13 * Enum of standard macro categories for grouping in documentation and autocomplete.
14 * Extensions may use these or define custom category strings.
15 *
16 * @readonly
17 * @enum {string}
18 */
19export const MacroCategory = Object.freeze({
20 /** Basic utilities and text manipulation (newline, noop, trim, reverse, comment) */
21 UTILITY: 'utility',
22 /** Randomization and dice rolling (random, pick, roll) */
23 RANDOM: 'random',
24 /** Participant names and name lists (user, char, group, notChar) */
25 NAMES: 'names',
26 /** Character card fields and persona (description, personality, scenario, mesExamples, persona) */
27 CHARACTER: 'character',
28 /** Chat history, messages, and swipes */
29 CHAT: 'chat',
30 /** Date, time, and duration macros */
31 TIME: 'time',
32 /** Local and global variable operations */
33 VARIABLE: 'variable',
34 /** Prompt templates for text completion (instruct sequences, system prompts, author's notes, context templates) */
35 PROMPTS: 'prompts',
36 /** Runtime application state (model, API, lastGenerationType, isMobile) */
37 STATE: 'state',
38 /** Macros that don't fit in any of the other categories, but don't really need/deserve their own */
39 MISC: 'misc',
40 /** Macros that are registered but not assigned to a category (any macro should have a category, so let the extension author know...) */
41 UNCATEGORIZED: 'uncategorized',
42});
43
44/**
45 * Enum of standard macro value types for type checking and documentation.
46 * Used for both argument types and return types.
47 *
48 * @readonly
49 * @enum {string}
50 */
51export const MacroValueType = Object.freeze({
52 /** String value of any kind */
53 STRING: 'string',
54 /** Integer value (natural number, no decimal spaces) */
55 INTEGER: 'integer',
56 /** Number value (decimal spaces allowed, includes integers values) */
57 NUMBER: 'number',
58 /** Boolean value (true/false, 1/0, yes/no, on/off) */
59 BOOLEAN: 'boolean',
60});
61
62/**
63 * @typedef {Object} MacroDefinitionOptions
64 * @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.
65 * @property {MacroCategory|string} [category=MacroCategory.UNCATEGORIZED] - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
66 * @property {number|MacroUnnamedArgDef[]} [unnamedArgs=0] - Specifies the macro's unnamed positional arguments. Can be a number (all required) or an array of definitions (supports optional args). Optional args must be a suffix.
67 * @property {boolean|MacroListSpec} [list] - Whether the macro allows a list of arguments (optional min and max values can be set). These arguments will be added AFTER the unnamed args.
68 * @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.
69 * @property {string} [description=''] - Add a description of what the macro does.
70 * @property {string} [returns] - Add a specific description of what the macro returns, if it is not obvious from the description.
71 * @property {MacroValueType|MacroValueType[]} [returnType=MacroValueType.STRING] - The type(s) this macro returns. Defaults to string.
72 * @property {string} [displayOverride] - Override the auto-generated macro signature for display (must include curly braces, e.g. "{{macro::arg}}").
73 * @property {string|string[]} [exampleUsage] - Example usage(s) shown in documentation (must include curly braces).
74 * @property {boolean} [delayArgResolution=false] - If true, nested macros in arguments or scope are NOT resolved before calling the handler. The handler receives raw argument text and must call resolve() manually. Use sparingly - only for control-flow macros like {{if}}.
75 * @property {MacroHandler} handler - The handler function for the macro.
76 */
77
78/**
79 * @typedef {Object} MacroAliasDef
80 * @property {string} alias - The alias name.
81 * @property {boolean} [visible=true] - Whether this alias appears in documentation/autocomplete. Defaults to true.
82 */
83
84/**
85 * @typedef {Object} MacroUnnamedArgDef
86 * @property {string} name
87 * @property {boolean} [optional=false] - Whether this argument is optional. Optional args must form a contiguous suffix (no required args after an optional).
88 * @property {string} [defaultValue] - Default value for optional args. ONLY meaningful when optional is true. Shown in docs/autocomplete.
89 * @property {MacroValueType|MacroValueType[]} [type=MacroValueType.STRING] - Single type or array of accepted types.
90 * @property {string} [sampleValue]
91 * @property {string} [description]
92 */
93
94/**
95 * @typedef {Object} MacroListSpec
96 * @property {number} [min]
97 * @property {number} [max]
98 */
99
100/**
101 * @typedef {(context: MacroExecutionContext) => string} MacroHandler
102 */
103
104/**
105 * @typedef {Object} MacroExecutionContext
106 * @property {string} name
107 * @property {string[]} args - All unnamed arguments passed to the macro. If delayArgResolution is true, these contain raw (unresolved) text.
108 * @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).
109 * @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.
110 * @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.
111 * @property {MacroFlags} flags - Macro execution flags that were applied to this macro invocation.
112 * @property {boolean} isScoped - Whether this macro was invoked using scoped syntax (opening + closing tags).
113 * @property {string} raw - The inner macro content with nested macros resolved.
114 * @property {string} rawOriginal - The original full macro text including braces, before any resolution.
115 * @property {string[]} rawArgs - The original arguments passed to the macro (always unresolved).
116 * @property {MacroEnv} env
117 * @property {CstNode} cstNode
118 * @property {{ startOffset: number, endOffset: number }} range - Range relative to the current evaluation context's text.
119 * @property {number} globalOffset - The offset of this macro in the original top-level document.
120 * This combines the context's base offset with the local range. Use this for deterministic
121 * seeding (e.g., in {{pick}}) to ensure identical macros at different positions produce different results.
122 * @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
123 * @property {(content: string, options?: { trimIndent?: boolean }) => string} trimContent - Trims scoped content with optional indentation dedent. Defaults to trimming indentation.
124 * @property {(text: string, options?: { offsetDelta?: number }) => string} resolve - Evaluates macros in the given text using the same environment.
125 * Use when delayArgResolution is true. By default, preserves the caller's globalOffset so nested
126 * macros like {{pick}} maintain deterministic position-based behavior. Pass offsetDelta to add
127 * an additional offset for uniqueness (e.g., to differentiate between multiple resolve calls).
128 * @property {(message: string, error?: any) => void} warn - Logs a runtime warning with automatic macro call context.
129 * Use this to report issues in how the macro was invoked (e.g., invalid argument values, edge cases).
130 */
131
132/**
133 * @typedef {Object} MacroDefinition
134 * @property {string} name - Primary macro name.
135 * @property {MacroResolvedAlias[]} aliases - Parsed alias definitions for this macro.
136 * @property {MacroCategory|string} category
137 * @property {number} minArgs - Minimum number of unnamed args required (excludes optional args).
138 * @property {number} maxArgs - Maximum number of unnamed args accepted (includes optional args).
139 * @property {MacroUnnamedArgDef[]} unnamedArgDefs - Definitions for all unnamed positional arguments (required + optional).
140 * @property {{ min: number, max: (number|null) }|null} list
141 * @property {boolean} strictArgs
142 * @property {string} description
143 * @property {string|null} returns
144 * @property {MacroValueType|MacroValueType[]} returnType - The type(s) this macro returns.
145 * @property {string|null} displayOverride - Override for the auto-generated macro signature display.
146 * @property {string[]} exampleUsage - Example usage strings for documentation.
147 * @property {boolean} delayArgResolution - If true, nested macros in arguments are NOT resolved before calling the handler. The handler receives raw argument text and must call resolve() manually. Use sparingly - only for control-flow macros like {{if}}.
148 * @property {MacroHandler} handler
149 * @property {MacroSource} source
150 * @property {string|null} aliasOf - If this is an alias, the primary macro name this is an alias of. Can also be used to check if this is an alias macro.
151 * @property {boolean|null} aliasVisible - If this is an alias, whether this alias is visible in docs/autocomplete.
152 */
153
154/**
155 * @typedef {Object} MacroResolvedAlias
156 * @property {string} alias - The alias name.
157 * @property {boolean} visible - Whether this alias is visible in documentation/autocomplete.
158 */
159
160/**
161 * @typedef {Object} MacroSource
162 * @property {string} name - Source identifier (extension name or script path)
163 * @property {boolean} isExtension - True if registered from an extension
164 * @property {boolean} isThirdParty - True if registered from a third-party extension
165 */
166
167/**
168 * The singleton instance of the MacroRegistry.
169 *
170 * @type {MacroRegistry}
171 */
172let instance;
173export { instance as MacroRegistry };
174
175class MacroRegistry {
176 /** @type {MacroRegistry} */ static #instance;
177 /** @type {MacroRegistry} */ static get instance() { return MacroRegistry.#instance ?? (MacroRegistry.#instance = new MacroRegistry()); }
178
179 /** @type {Map<string, MacroDefinition>} */
180 #macros;
181
182 /**
183 * @private
184 */
185 constructor() {
186 /** @type {Map<string, MacroDefinition>} */
187 this.#macros = new Map();
188 }
189
190 /**
191 * Registers a macro with the registry.
192 * Errors during registration are caught and logged, the macro will not be registered, and the function returns null.
193 *
194 * @param {string} name - Macro name (identifier).
195 * @param {MacroDefinitionOptions} options - Macro registration options including handler and metadata.
196 * @returns {MacroDefinition|null} The registered definition, or null if registration failed.
197 */
198 registerMacro(name, options) {
199 // Extract name early for error logging
200 name = typeof name === 'string' ? name.trim() : String(name);
201
202 try {
203 // Detect extension/third-party status from call stack
204 const { isExtension, isThirdParty, source } = detectMacroSource();
205
206 // Build the definition using the shared helper
207 const definition = this.buildMacroDefFromOptions(name, options, {
208 source: { name: source, isExtension, isThirdParty },
209 });
210
211 // Register the primary macro
212 this.#registerMacroEntry(name, definition);
213
214 // Register alias entries pointing to the same definition
215 for (const { alias, visible } of definition.aliases) {
216 this.#registerMacroEntry(alias, definition, { primaryMacroName: name, aliasVisible: visible });
217 }
218
219 return definition;
220 } catch (error) {
221 logMacroRegisterError({
222 message: `Failed to register macro "${name}". The macro will not be available.`,
223 macroName: name,
224 error,
225 });
226 return null;
227 }
228 }
229
230 /**
231 * Registers an alias for an existing macro.
232 * The alias will point to the same handler and metadata as the original macro.
233 * Errors during registration are caught and logged, the alias will not be registered, and the function returns false.
234 *
235 * @param {string} targetMacroName - The name of the existing macro to create an alias for.
236 * @param {string} aliasName - The alias name (identifier).
237 * @param {Object} [options] - Alias registration options.
238 * @param {boolean} [options.visible=true] - Whether this alias appears in documentation/autocomplete.
239 * @returns {boolean} True if the alias was registered successfully, false if registration failed.
240 */
241 registerMacroAlias(targetMacroName, aliasName, { visible = true } = {}) {
242 // Extract names early for error logging
243 targetMacroName = typeof targetMacroName === 'string' ? targetMacroName.trim() : String(targetMacroName);
244 aliasName = typeof aliasName === 'string' ? aliasName.trim() : String(aliasName);
245
246 try {
247 // Validate alias name
248 if (!isIdentifierValid(aliasName)) {
249 throw new Error(`Alias name "${aliasName}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
250 }
251
252 // Check that alias is not the same as target (case insensitive)
253 if (aliasName.toLowerCase() === targetMacroName.toLowerCase()) {
254 throw new Error(`Alias name "${aliasName}" cannot be the same as the target macro name (case insensitive).`);
255 }
256
257 // Check that target macro exists
258 const targetDefinition = this.getMacro(targetMacroName);
259 if (!targetDefinition) {
260 throw new Error(`Target macro "${targetMacroName}" is not registered.`);
261 }
262
263 // Get the primary definition (in case target is itself an alias)
264 const primaryDefinition = targetDefinition.aliasOf ? this.getMacro(targetDefinition.aliasOf) : targetDefinition;
265 if (!primaryDefinition) {
266 throw new Error(`Could not resolve primary definition for target macro "${targetMacroName}".`);
267 }
268
269 // Detect extension/third-party status from call stack
270 const { isExtension, isThirdParty, source } = detectMacroSource();
271
272 // Create alias definition with source detection
273 const aliasDefinition = {
274 ...primaryDefinition,
275 source: { name: source, isExtension, isThirdParty },
276 };
277
278 // Register the alias using the shared utility
279 this.#registerMacroEntry(aliasName, aliasDefinition, { primaryMacroName: primaryDefinition.name, aliasVisible: visible });
280
281 return true;
282 } catch (error) {
283 logMacroRegisterError({
284 message: `Failed to register alias "${aliasName}" for macro "${targetMacroName}". The alias will not be available.`,
285 macroName: aliasName,
286 error,
287 });
288 return false;
289 }
290 }
291
292 /**
293 * Shared utility for registering macro entries (primary or alias).
294 *
295 * @param {string} name - The registration name (primary macro or alias).
296 * @param {MacroDefinition} definition - The definition to register.
297 * @param {Object} [options={}] - Options for alias registration.
298 * @param {string} [options.primaryMacroName=null] - For aliases, the primary macro name.
299 * @param {boolean} [options.aliasVisible=null] - For aliases, visibility flag.
300 */
301 #registerMacroEntry(name, definition, { primaryMacroName = null, aliasVisible = null } = {}) {
302 const nameKey = name.toLowerCase();
303
304 if (this.#macros.has(nameKey)) {
305 const warningType = primaryMacroName ? `Alias "${name}" for macro "${primaryMacroName}"` : `Macro "${name}"`;
306 const warningMessage = primaryMacroName ? 'overwrites an existing macro.' : 'is already registered and will be overwritten.';
307 logMacroRegisterWarning({ macroName: primaryMacroName || name, message: `${warningType} ${warningMessage}` });
308 }
309
310 /** @type {MacroDefinition} */
311 const entry = primaryMacroName ? {
312 ...definition,
313 name: name, // The lookup name is the alias (preserves original casing for display)
314 aliasOf: primaryMacroName,
315 aliasVisible: aliasVisible,
316 } : definition;
317
318 this.#macros.set(nameKey, entry);
319 }
320
321 /**
322 * Unregisters a macro.
323 *
324 * @param {string} name - Macro name (identifier).
325 * @returns {boolean} True if a macro was removed.
326 */
327 unregisterMacro(name) {
328 if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
329 name = name.trim();
330 return this.#macros.delete(name.toLowerCase());
331 }
332
333 /**
334 * Checks whether a macro with the given name is registered.
335 *
336 * @param {string} name - Macro name (identifier).
337 * @returns {boolean}
338 */
339 hasMacro(name) {
340 if (typeof name !== 'string' || !name.trim()) return false;
341 name = name.trim();
342 return this.#macros.has(name.toLowerCase());
343 }
344
345 /**
346 * Returns the macro definition for a given name.
347 *
348 * @param {string} name - Macro name (identifier).
349 * @returns {MacroDefinition|undefined}
350 */
351 getMacro(name) {
352 if (typeof name !== 'string' || !name.trim()) return undefined;
353 name = name.trim();
354 return this.#macros.get(name.toLowerCase());
355 }
356
357 /**
358 * Returns the primary (non-alias) definition for a macro.
359 * If given an alias name, returns the primary definition it points to.
360 *
361 * @param {string} name - Macro name or alias.
362 * @returns {MacroDefinition|undefined}
363 */
364 getPrimaryMacro(name) {
365 const def = this.getMacro(name);
366 if (!def) return undefined;
367 return def.aliasOf ? this.getMacro(def.aliasOf) : def;
368 }
369
370 /**
371 * Returns an array of all registered macros.
372 *
373 * @param {Object} [options] - Filter options.
374 * @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
375 * @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
376 * @returns {MacroDefinition[]}
377 */
378 getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
379 let macros = Array.from(this.#macros.values());
380 if (excludeAliases) {
381 macros = macros.filter(m => !m.aliasOf);
382 } else if (excludeHiddenAliases) {
383 macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
384 }
385 return macros;
386 }
387
388 /**
389 * Executes a macro for a given call.
390 *
391 * @param {MacroCall} call - Macro call information.
392 * @param {Object} [options] - Additional options.
393 * @param {MacroDefinition} [options.defOverride] - Override the macro definition.
394 * @returns {string}
395 */
396 executeMacro(call, { defOverride } = {}) {
397 const name = call.name;
398 const def = defOverride || this.getMacro(name);
399 if (!def) {
400 throw new Error(`Macro "${name}" is not registered`);
401 }
402
403 const args = Array.isArray(call.args) ? call.args : [];
404
405 if (!isArgsValid(def, args)) {
406 const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
407 const expectedMax = def.list && def.list.max !== null
408 ? def.maxArgs + def.list.max
409 : (def.list ? null : def.maxArgs);
410
411 const expectation = (() => {
412 if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
413 if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
414 return `at least ${expectedMin}`;
415 })();
416
417 const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
418 if (def.strictArgs) {
419 throw createMacroRuntimeError({ message, call, def });
420 }
421 logMacroRuntimeWarning({ message, call, def });
422 }
423
424 // Compute unnamed args (required + optional, up to maxArgs)
425 const unnamedArgsCount = Math.min(args.length, def.maxArgs);
426 const unnamedArgsValues = args.slice(0, unnamedArgsCount);
427 const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
428
429 // Perform best-effort type validation for documented positional arguments.
430 // This can throw an error if the arguments are invalid.
431 validateArgTypes(call, def, unnamedArgsValues);
432
433 const namedArgs = null;
434
435 /** @type {MacroExecutionContext} */
436 const executionContext = {
437 name: def.name,
438 args,
439 unnamedArgs: unnamedArgsValues,
440 list: listValues,
441 namedArgs,
442 flags: call.flags,
443 isScoped: call.isScoped,
444 raw: call.rawInner,
445 rawOriginal: call.rawWithBraces,
446 rawArgs: call.rawArgs,
447 env: call.env,
448 cstNode: call.cstNode,
449 range: call.range,
450 globalOffset: call.globalOffset,
451 normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
452 trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
453 resolve: (text, { offsetDelta = 0 } = {}) => MacroEngine.evaluate(text, call.env, {
454 contextOffset: call.globalOffset + offsetDelta,
455 }),
456 warn: (message, error = undefined) => logMacroRuntimeWarning({ message, call, def, error }),
457 };
458
459 const result = def.handler(executionContext);
460 return executionContext.normalize(result);
461 }
462
463 /**
464 * Builds a MacroDefinition from MacroDefinitionOptions.
465 *
466 * This is the core logic building the actual registered macro from an options object
467 * that has nearly everything as optional args.
468 *
469 * The options object is highly flexible and allows defining all aspects of a macro
470 * through optional properties. This method processes and validates the options to
471 * create a proper MacroDefinition that can be registered with the engine.
472 *
473 * Validation includes checking for required fields, validating argument definitions,
474 * and ensuring the handler function is callable.
475 * It throws errors for invalid configurations.
476 *
477 * @param {string} name - Macro name (identifier).
478 * @param {MacroDefinitionOptions} options - Macro definition options.
479 * @param {Object} [buildOptions] - Additional options for building.
480 * @param {MacroSource} [buildOptions.source] - Source information. Defaults to dynamic source.
481 * @returns {MacroDefinition} The built macro definition.
482 * @throws {Error} If validation fails.
483 */
484 buildMacroDefFromOptions(name, options, { source } = {}) {
485 name = typeof name === 'string' ? name.trim() : String(name);
486
487 if (!isIdentifierValid(name)) throw new Error(`Macro name "${name}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
488 if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
489
490 const {
491 aliases: rawAliases,
492 category: rawCategory,
493 unnamedArgs: rawUnnamedArgs,
494 list: rawList,
495 strictArgs: rawStrictArgs,
496 description: rawDescription,
497 returns: rawReturns,
498 returnType: rawReturnType,
499 displayOverride: rawDisplayOverride,
500 exampleUsage: rawExampleUsage,
501 delayArgResolution: rawDelayArgResolution,
502 handler,
503 } = options;
504
505 if (typeof handler !== 'function') throw new Error(`Macro "${name}" options.handler must be a function.`);
506
507 /** @type {MacroResolvedAlias[]} */
508 const aliases = [];
509 if (rawAliases !== undefined && rawAliases !== null) {
510 if (!Array.isArray(rawAliases)) throw new Error(`Macro "${name}" options.aliases must be an array.`);
511 for (const [i, aliasDef] of rawAliases.entries()) {
512 if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
513 if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
514 const aliasName = aliasDef.alias.trim();
515 if (!isIdentifierValid(aliasName)) throw new Error(`Macro "${name}" options.aliases[${i}].alias "${aliasName}" is invalid. Must start with a letter, followed by word chars or hyphens.`);
516 if (aliasName.toLowerCase() === name.toLowerCase()) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name (insensitive).`);
517 const visible = aliasDef.visible !== false;
518 aliases.push({ alias: aliasName, visible });
519 }
520 }
521
522 /** @type {MacroCategory|string} */
523 let category = MacroCategory.UNCATEGORIZED;
524 if (typeof rawCategory === 'string' && rawCategory.trim()) {
525 category = rawCategory.trim();
526 }
527
528 let minArgs = 0;
529 let maxArgs = 0;
530 /** @type {MacroUnnamedArgDef[]} */
531 let unnamedArgDefs = [];
532 if (rawUnnamedArgs !== undefined) {
533 if (Array.isArray(rawUnnamedArgs)) {
534 let foundOptional = false;
535 unnamedArgDefs = rawUnnamedArgs.map((def, index) => {
536 if (!def || typeof def !== 'object') throw new Error(`Macro "${name}" options.unnamedArgs[${index}] must be an object when using argument definitions.`);
537 if (typeof def.name !== 'string' || !def.name.trim()) throw new Error(`Macro "${name}" options.unnamedArgs[${index}].name must be a non-empty string when using argument definitions.`);
538
539 if (foundOptional && !def.optional) {
540 throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
541 }
542 if (def.optional) foundOptional = true;
543
544 /** @type {MacroUnnamedArgDef} */
545 const normalized = {
546 name: def.name.trim(),
547 optional: def.optional || false,
548 defaultValue: def.defaultValue?.trim(),
549 type: Array.isArray(def.type) && def.type.length === 0 ? 'string' : def.type ?? 'string',
550 sampleValue: def.sampleValue?.trim(),
551 description: typeof def.description === 'string' ? def.description : undefined,
552 };
553
554 const validTypes = ['string', 'integer', 'number', 'boolean'];
555 const type = Array.isArray(normalized.type) ? normalized.type : [normalized.type];
556 if (type.some(t => !validTypes.includes(t))) {
557 throw new Error(`Macro "${name}" options.unnamedArgs[${index}].type must be one of "string", "integer", "number", or "boolean" when provided.`);
558 }
559
560 return normalized;
561 });
562
563 maxArgs = unnamedArgDefs.length;
564 minArgs = unnamedArgDefs.findIndex(d => d.optional);
565 if (minArgs === -1) minArgs = maxArgs;
566 } else if (typeof rawUnnamedArgs === 'number') {
567 if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
568 throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
569 }
570 minArgs = rawUnnamedArgs;
571 maxArgs = rawUnnamedArgs;
572 unnamedArgDefs = Array.from({ length: rawUnnamedArgs }, (_, i) => ({
573 name: `arg${i + 1}`,
574 optional: false,
575 type: 'string',
576 sampleValue: `arg${i + 1}`,
577 }));
578 } else {
579 throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer or an array of argument definitions when provided.`);
580 }
581 }
582
583 /** @type {{ min: number, max: (number|null) }|null} */
584 let list = null;
585 if (rawList !== undefined) {
586 if (typeof rawList === 'boolean') {
587 list = rawList ? { min: 0, max: null } : null;
588 } else if (typeof rawList === 'object' && rawList !== null) {
589 if (typeof rawList.min !== 'number' || rawList.min < 0) throw new Error(`Macro "${name}" options.list.min must be a non-negative integer when provided.`);
590 if (rawList.max !== undefined && typeof rawList.max !== 'number') throw new Error(`Macro "${name}" options.list.max must be a number when provided.`);
591 if (rawList.max !== undefined && rawList.max < rawList.min) throw new Error(`Macro "${name}" options.list.max must be greater than or equal to options.list.min.`);
592 list = { min: rawList.min, max: rawList.max ?? null };
593 } else {
594 throw new Error(`Macro "${name}" options.list must be a boolean or an object with numeric min/max when provided.`);
595 }
596 }
597
598 let strictArgs = true;
599 if (rawStrictArgs !== undefined) {
600 if (typeof rawStrictArgs !== 'boolean') throw new Error(`Macro "${name}" options.strictArgs must be a boolean when provided.`);
601 strictArgs = rawStrictArgs;
602 }
603
604 let description = '<no description>';
605 if (rawDescription !== undefined) {
606 if (typeof rawDescription !== 'string') throw new Error(`Macro "${name}" options.description must be a string when provided.`);
607 description = rawDescription;
608 }
609
610 let returns = null;
611 if (rawReturns !== undefined && rawReturns !== null) {
612 if (typeof rawReturns !== 'string') throw new Error(`Macro "${name}" options.returns must be a string when provided.`);
613 returns = rawReturns || '<empty string>';
614 }
615
616 const validTypes = ['string', 'integer', 'number', 'boolean'];
617 let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
618 if (rawReturnType !== undefined && rawReturnType !== null) {
619 returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
620 const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
621 if (typesToValidate.some(t => !validTypes.includes(t))) {
622 throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
623 }
624 }
625
626 let displayOverride = null;
627 if (rawDisplayOverride !== undefined && rawDisplayOverride !== null) {
628 if (typeof rawDisplayOverride !== 'string') throw new Error(`Macro "${name}" options.displayOverride must be a string when provided.`);
629 displayOverride = rawDisplayOverride.trim();
630 if (displayOverride && !displayOverride.startsWith('{{')) {
631 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.displayOverride should include curly braces. Auto-wrapping.` });
632 displayOverride = `{{${displayOverride}}}`;
633 }
634 }
635
636 /** @type {string[]} */
637 let exampleUsage = [];
638 if (rawExampleUsage !== undefined && rawExampleUsage !== null) {
639 const examples = Array.isArray(rawExampleUsage) ? rawExampleUsage : [rawExampleUsage];
640 for (const [i, ex] of examples.entries()) {
641 if (typeof ex !== 'string') throw new Error(`Macro "${name}" options.exampleUsage[${i}] must be a string.`);
642 let trimmed = ex.trim();
643 if (trimmed && !trimmed.startsWith('{{')) {
644 logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.exampleUsage[${i}] should include curly braces. Auto-wrapping.` });
645 trimmed = `{{${trimmed}}}`;
646 }
647 if (trimmed) exampleUsage.push(trimmed);
648 }
649 }
650
651 let delayArgResolution = false;
652 if (rawDelayArgResolution !== undefined) {
653 if (typeof rawDelayArgResolution !== 'boolean') throw new Error(`Macro "${name}" options.delayArgResolution must be a boolean when provided.`);
654 delayArgResolution = rawDelayArgResolution;
655 }
656
657 /** @type {MacroDefinition} */
658 const definition = {
659 name: name,
660 aliases,
661 category,
662 minArgs,
663 maxArgs,
664 unnamedArgDefs,
665 list,
666 strictArgs,
667 description,
668 returns,
669 returnType,
670 displayOverride,
671 exampleUsage,
672 delayArgResolution,
673 handler,
674 source: source ?? { name: 'dynamic', isExtension: false, isThirdParty: false },
675 aliasOf: null,
676 aliasVisible: null,
677 };
678
679 return definition;
680 }
681}
682
683instance = MacroRegistry.instance;
684
685/**
686 * Validates a macro identifier.
687 *
688 * @param {string} name - The macro identifier to validate.
689 * @param {Object} [options] - Validation options.
690 * @param {boolean} [options.allowComment = true] - Whether return that the comment identifier '//' is valid.
691 * @returns {boolean} True if the identifier is valid, false otherwise.
692 */
693function isIdentifierValid(name, { allowComment = true } = {}) {
694 if (typeof name !== 'string' || !name.trim()) return false;
695 if (allowComment && name === '//') return true;
696 return MACRO_IDENTIFIER_PATTERN.test(name);
697}
698
699/**
700 * Validates the arguments for a macro definition.
701 * Supports required args (minArgs), optional args (up to maxArgs), and list tail.
702 *
703 * @param {MacroDefinition} def - Macro definition.
704 * @param {any[]} args - Arguments to validate.
705 * @returns {boolean} True if the arguments are valid, false otherwise.
706 */
707function isArgsValid(def, args) {
708 const hasListArgs = def.list !== null;
709
710 // Without list: args must be between minArgs and maxArgs (inclusive)
711 if (!hasListArgs) {
712 return args.length >= def.minArgs && args.length <= def.maxArgs;
713 }
714
715 // With list: args must be at least minArgs + list.min
716 const minRequired = def.minArgs + def.list.min;
717 if (args.length < minRequired) return false;
718
719 // List items are everything after maxArgs positional slots
720 const listCount = Math.max(0, args.length - def.maxArgs);
721 if (def.list.max !== null && listCount > def.list.max) return false;
722
723 return true;
724}
725
726/**
727 * Performs type validation for unnamed positional arguments using the metadata
728 * defined on the macro definition. When strictArgs is true, invalid argument
729 * types cause an error to be thrown. When strictArgs is false, only warnings
730 * are logged and execution continues.
731 *
732 * @param {MacroCall} call
733 * @param {MacroDefinition} def
734 * @param {string[]} unnamedArgs
735 */
736function validateArgTypes(call, def, unnamedArgs) {
737 if (def.unnamedArgDefs.length === 0) return;
738
739 const defs = def.unnamedArgDefs;
740 const count = Math.min(defs.length, unnamedArgs.length);
741 for (let i = 0; i < count; i++) {
742 const argDef = defs[i];
743 const value = unnamedArgs[i];
744 if (!argDef || !argDef.type || typeof value !== 'string') {
745 // Misconfigured macro definition: always surface as an error.
746 throw new Error(`Macro "${call.name}" (position ${i + 1}) has invalid definition or type.`);
747 }
748
749 const types = Array.isArray(argDef.type) ? argDef.type : [argDef.type];
750 if (!types.some(type => isValueOfType(value, type))) {
751 const argName = argDef.name || `Argument ${i + 1}`;
752 const optionalLabel = argDef.optional ? ' (optional)' : '';
753 const message = `Macro "${call.name}" (position ${i + 1}${optionalLabel}) argument "${argName}" expected type ${argDef.type} but got value "${value}".`;
754 if (def.strictArgs) {
755 throw createMacroRuntimeError({ message, call, def: def });
756 }
757 logMacroRuntimeWarning({ message, call, def: def });
758 }
759 }
760}
761
762/**
763 * Checks whether a string value conforms to the given macro argument type.
764 *
765 * @param {string} value
766 * @param {MacroValueType} type
767 * @returns {boolean}
768 */
769function isValueOfType(value, type) {
770 const trimmed = value.trim();
771
772 if (type === 'string') {
773 return true;
774 }
775 if (type === 'integer') {
776 return /^-?\d+$/.test(trimmed);
777 }
778 if (type === 'number') {
779 const n = Number(trimmed);
780 return Number.isFinite(n);
781 }
782 if (type === 'boolean') {
783 return isTrueBoolean(trimmed) || isFalseBoolean(trimmed);
784 }
785
786 // Unknown type: treat it as invalid.
787 return false;
788}
789
790/**
791 * Detects the source of a macro registration from the call stack.
792 * Similar to how SlashCommandParser detects command sources.
793 *
794 * @returns {{ isExtension: boolean, isThirdParty: boolean, source: string }}
795 */
796function detectMacroSource() {
797 const stack = new Error().stack?.split('\n').map(line => line.trim()) ?? [];
798
799 const isExtension = stack.some(line => line.includes('/scripts/extensions/'));
800 const isThirdParty = stack.some(line => line.includes('/scripts/extensions/third-party/'));
801
802 let source = 'unknown';
803 if (isThirdParty) {
804 const match = stack.find(line => line.includes('/scripts/extensions/third-party/'));
805 if (match) {
806 source = match.replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1');
807 }
808 } else if (isExtension) {
809 const match = stack.find(line => line.includes('/scripts/extensions/'));
810 if (match) {
811 source = match.replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1');
812 }
813 } else {
814 // Find the first meaningful caller outside MacroRegistry
815 const callerIdx = stack.findIndex(line =>
816 line.includes('registerMacro') && line.includes('MacroRegistry'),
817 );
818 if (callerIdx >= 0 && callerIdx + 1 < stack.length) {
819 const callerLine = stack[callerIdx + 1];
820 // Extract script path from stack frame
821 const scriptMatch = callerLine.match(/\/((?:scripts\/)?(?:macros\/)?[^/]+\.js)/);
822 if (scriptMatch) {
823 source = scriptMatch[1];
824 }
825 }
826 }
827
828 return { isExtension, isThirdParty, source };
829}