Macros 2.0 (v0.6.6) - STscript compatibility (#4957) * Bypass REPLACE_GETVAR with experimental parser enabled * (wip) macro-2.0 replacer in STscript parser * Add support for MacroDefinitionOptions format in dynamic macros - Add DynamicMacroValue typedef supporting string, function, or MacroDefinitionOptions object - Extract macro definition building logic into MacroRegistry.buildMacroDefFromOptions() method - Update MacroEngine to detect and handle three dynamic macro formats: 1. string - direct value, no args allowed 2. function - handler function, no args allowed (legacy) 3. MacroDefinitionOptions object - full definition with handler, args, type * Implement dynamic macro replacers * Remove global typedef * Use unique closure boundary * Add e2e tests for MacroSlashCommands * Update public/scripts/macros/engine/MacroEngine.js Co-authored-by: Wolfsblvt <wolfsblvt@gmail.com> * Use strict args array match * Update public/scripts/macros/engine/MacroEngine.js --------- Co-authored-by: Wolfsblvt <wolfsblvt@gmail.com>

7331dba056583f4fd1118686ed2fe6fbea5785e6

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
10 files changed, +1189 -221Showing whitespace changes
public/script.js+1 -1
@@ -2839,7 +2839,7 @@ export function substituteParamsLegacy(content, _name1, _name2, _original, _grou
28392839 * @param {string} [options.original] - The original message for {{original}} substitution.
28402840 * @param {string} [options.groupOverride] - The group members list for {{group}} substitution.
28412841 * @param {boolean} [options.replaceCharacterCard=true] - Whether to replace character card macros.
28422842 * @param {Record<string,string|MacroHandler import('./scripts/macros/engine/MacroEnv.types.js').DynamicMacroValue>} [options.dynamicMacros={}] - Additional environment variables as dynamic macros for substitution. Registered as macro functions.
28432843 * @param {(x: string) => string} [options.postProcessFn=(x) => x] - Post-processing function for each substituted macro.
28442844 * @returns {string} The string with substituted parameters.
28452845 */
public/scripts/macros/engine/MacroEngine.js+34 -20
@@ -1,11 +1,12 @@
11import { MacroParser } from './MacroParser.js';
22import { MacroCstWalker } from './MacroCstWalker.js';
33import { MacroRegistry, MacroValueType } from './MacroRegistry.js';
44import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
55import { ELSE_MARKER } from '../definitions/core-macros.js';
66
77/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
88/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
9+/** @typedef {import('./MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
910/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
1011
1112/**
@@ -166,31 +167,44 @@ class MacroEngine {
166167
167168 // First check if this is a dynamic macro to use. If so, we will create a temporary macro definition for it and use that over any registered macro.
168169 // Dynamic macro keys are normalized to lowercase for case-insensitive matching.
169170 /** @type {MacroDefinition?|null} */
170171 let defOverride = null;
171172 const nameLower = name.toLowerCase();
172173 if (Object.hasOwn(env.dynamicMacros, nameLower)) {
173174 const impl = env.dynamicMacros[nameLower];
174- defOverride = {
175+
175- name,
176+ // Dynamic macros support three formats:
176- aliases: [],
177+ // 1. string - direct value, no args allowed
178+ // 2. function - handler function, no args allowed (legacy behavior)
179+ // 3. MacroDefinitionOptions object - full definition with handler, args, type validation, etc.
180+
181+ // Check if this looks like a MacroDefinitionOptions object (has handler property)
182+ const looksLikeOptions = impl && typeof impl === 'object' &&
183+ 'handler' in impl && typeof impl.handler === 'function';
184+
185+ if (looksLikeOptions) {
186+ // Case 3: MacroDefinitionOptions - use the full definition builder
187+ try {
188+ const options = /** @type {MacroDefinitionOptions} */ (impl);
189+ defOverride = MacroRegistry.buildMacroDefFromOptions(name, options);
190+ } catch (error) {
191+ // If building fails, log warning and fall through to check registered macros
192+ logMacroRuntimeWarning({ message: `Dynamic macro "${name}" has invalid options: ${error.message}`, call });
193+ }
194+ } else if (['string', 'number', 'boolean', 'function'].includes((typeof impl))) {
195+ // Case 1 & 2: string or handler function
196+ if (['number', 'boolean'].includes(typeof impl)) {
197+ logMacroRuntimeWarning({ message: `Dynamic macro "${name}" uses unsupported number/boolean format.`, call });
198+ }
199+ defOverride = MacroRegistry.buildMacroDefFromOptions(name, {
200+ handler: typeof impl === 'function' ? impl : () => String(impl ?? ''),
177201 category: 'dynamic',
178202 description: 'Dynamic macro',
179- minArgs: 0,
203+ returnType: MacroValueType.STRING,
180- maxArgs: 0,
204+ });
181- unnamedArgDefs: [],
205+ } else {
182- list: null,
206+ logMacroRuntimeWarning({ message: `Dynamic macro "${name}" is not defined correctly (must be string, a handler function, or a macro def options object with handler property).`, call });
183- strictArgs: true, // Fail dynamic macros if they are called with arguments
207+ }
184- returns: null,
185- returnType: 'string',
186- displayOverride: null,
187- exampleUsage: [],
188- source: { name: 'dynamic', isExtension: false, isThirdParty: false },
189- aliasOf: null,
190- aliasVisible: null,
191- delayArgResolution: false,
192- handler: typeof impl === 'function' ? impl : () => impl,
193- };
194208 }
195209
196210 // If not, check if the macro exists and is registered
public/scripts/macros/engine/MacroEnv.types.js+10 -1
@@ -7,6 +7,15 @@
77 */
88
99/** @typedef {import('./MacroRegistry.js').MacroHandler} MacroHandler */
10+/** @typedef {import('./MacroRegistry.js').MacroDefinitionOptions} MacroDefinitionOptions */
11+
12+/**
13+ * A dynamic macro value can be:
14+ * - A string (direct value)
15+ * - A MacroHandler function (resolved at runtime)
16+ * - A MacroDefinitionOptions object (full macro definition with handler, args, etc.)
17+ * @typedef {string | MacroHandler | MacroDefinitionOptions} DynamicMacroValue
18+ */
1019
1120/**
1221 * @typedef {Object} MacroEnvNames
@@ -50,7 +59,7 @@
5059 * @property {MacroEnvCharacter} character
5160 * @property {MacroEnvSystem} system
5261 * @property {MacroEnvFunctions} functions
5362 * @property {Object<string, string|MacroHandlerDynamicMacroValue>} dynamicMacros
5463 * @property {Record<string, unknown>} extra
5564 */
5665
public/scripts/macros/engine/MacroEnvBuilder.js+1 -1
@@ -22,7 +22,7 @@ import { getStringHash } from '/scripts/utils.js';
2222 * @property {string|null} [original]
2323 * @property {string|null} [groupOverride]
2424 * @property {boolean} [replaceCharacterCard]
2525 * @property {Record<string, anyimport('./MacroEnv.types.js').DynamicMacroValue>|null} [dynamicMacros]
2626 * @property {(value: string) => string} [postProcessFn]
2727 */
2828
public/scripts/macros/engine/MacroRegistry.js+207 -185
@@ -192,6 +192,210 @@ class MacroRegistry {
192192 name = typeof name === 'string' ? name.trim() : String(name);
193193
194194 try {
195+ const nameKey = name.toLowerCase();
196+ if (this.#macros.has(nameKey)) {
197+ logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
198+ }
199+
200+ // Detect extension/third-party status from call stack
201+ const { isExtension, isThirdParty, source } = detectMacroSource();
202+
203+ // Build the definition using the shared helper
204+ const definition = this.buildMacroDefFromOptions(name, options, {
205+ source: { name: source, isExtension, isThirdParty },
206+ });
207+
208+ this.#macros.set(nameKey, definition);
209+
210+ // Register alias entries pointing to the same definition
211+ for (const { alias, visible } of definition.aliases) {
212+ const aliasKey = alias.toLowerCase();
213+ if (this.#macros.has(aliasKey)) {
214+ logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
215+ }
216+ /** @type {MacroDefinition} */
217+ const aliasEntry = {
218+ ...definition,
219+ name: alias, // The lookup name is the alias (preserves original casing for display)
220+ aliasOf: name,
221+ aliasVisible: visible,
222+ };
223+ this.#macros.set(aliasKey, aliasEntry);
224+ }
225+
226+ return definition;
227+ } catch (error) {
228+ logMacroRegisterError({
229+ message: `Failed to register macro "${name}". The macro will not be available.`,
230+ macroName: name,
231+ error,
232+ });
233+ return null;
234+ }
235+ }
236+
237+ /**
238+ * Unregisters a macro.
239+ *
240+ * @param {string} name - Macro name (identifier).
241+ * @returns {boolean} True if a macro was removed.
242+ */
243+ unregisterMacro(name) {
244+ if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
245+ name = name.trim();
246+ return this.#macros.delete(name.toLowerCase());
247+ }
248+
249+ /**
250+ * Checks whether a macro with the given name is registered.
251+ *
252+ * @param {string} name - Macro name (identifier).
253+ * @returns {boolean}
254+ */
255+ hasMacro(name) {
256+ if (typeof name !== 'string' || !name.trim()) return false;
257+ name = name.trim();
258+ return this.#macros.has(name.toLowerCase());
259+ }
260+
261+ /**
262+ * Returns the macro definition for a given name.
263+ *
264+ * @param {string} name - Macro name (identifier).
265+ * @returns {MacroDefinition|undefined}
266+ */
267+ getMacro(name) {
268+ if (typeof name !== 'string' || !name.trim()) return undefined;
269+ name = name.trim();
270+ return this.#macros.get(name.toLowerCase());
271+ }
272+
273+ /**
274+ * Returns the primary (non-alias) definition for a macro.
275+ * If given an alias name, returns the primary definition it points to.
276+ *
277+ * @param {string} name - Macro name or alias.
278+ * @returns {MacroDefinition|undefined}
279+ */
280+ getPrimaryMacro(name) {
281+ const def = this.getMacro(name);
282+ if (!def) return undefined;
283+ return def.aliasOf ? this.getMacro(def.aliasOf) : def;
284+ }
285+
286+ /**
287+ * Returns an array of all registered macros.
288+ *
289+ * @param {Object} [options] - Filter options.
290+ * @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
291+ * @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
292+ * @returns {MacroDefinition[]}
293+ */
294+ getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
295+ let macros = Array.from(this.#macros.values());
296+ if (excludeAliases) {
297+ macros = macros.filter(m => !m.aliasOf);
298+ } else if (excludeHiddenAliases) {
299+ macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
300+ }
301+ return macros;
302+ }
303+
304+ /**
305+ * Executes a macro for a given call.
306+ *
307+ * @param {MacroCall} call - Macro call information.
308+ * @param {Object} [options] - Additional options.
309+ * @param {MacroDefinition} [options.defOverride] - Override the macro definition.
310+ * @returns {string}
311+ */
312+ executeMacro(call, { defOverride } = {}) {
313+ const name = call.name;
314+ const def = defOverride || this.getMacro(name);
315+ if (!def) {
316+ throw new Error(`Macro "${name}" is not registered`);
317+ }
318+
319+ const args = Array.isArray(call.args) ? call.args : [];
320+
321+ if (!isArgsValid(def, args)) {
322+ const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
323+ const expectedMax = def.list && def.list.max !== null
324+ ? def.maxArgs + def.list.max
325+ : (def.list ? null : def.maxArgs);
326+
327+ const expectation = (() => {
328+ if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
329+ if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
330+ return `at least ${expectedMin}`;
331+ })();
332+
333+ const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
334+ if (def.strictArgs) {
335+ throw createMacroRuntimeError({ message, call, def });
336+ }
337+ logMacroRuntimeWarning({ message, call, def });
338+ }
339+
340+ // Compute unnamed args (required + optional, up to maxArgs)
341+ const unnamedArgsCount = Math.min(args.length, def.maxArgs);
342+ const unnamedArgsValues = args.slice(0, unnamedArgsCount);
343+ const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
344+
345+ // Perform best-effort type validation for documented positional arguments.
346+ // This can throw an error if the arguments are invalid.
347+ validateArgTypes(call, def, unnamedArgsValues);
348+
349+ const namedArgs = null;
350+
351+ /** @type {MacroExecutionContext} */
352+ const executionContext = {
353+ name: def.name,
354+ args,
355+ unnamedArgs: unnamedArgsValues,
356+ list: listValues,
357+ namedArgs,
358+ flags: call.flags,
359+ isScoped: call.isScoped,
360+ raw: call.rawInner,
361+ rawOriginal: call.rawWithBraces,
362+ rawArgs: call.rawArgs,
363+ env: call.env,
364+ cstNode: call.cstNode,
365+ range: call.range,
366+ normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
367+ trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
368+ resolve: (text) => MacroEngine.evaluate(text, call.env),
369+ };
370+
371+ const result = def.handler(executionContext);
372+ return executionContext.normalize(result);
373+ }
374+
375+ /**
376+ * Builds a MacroDefinition from MacroDefinitionOptions.
377+ *
378+ * This is the core logic building the actual registered macro from an options object
379+ * that has nearly everything as optional args.
380+ *
381+ * The options object is highly flexible and allows defining all aspects of a macro
382+ * through optional properties. This method processes and validates the options to
383+ * create a proper MacroDefinition that can be registered with the engine.
384+ *
385+ * Validation includes checking for required fields, validating argument definitions,
386+ * and ensuring the handler function is callable.
387+ * It throws errors for invalid configurations.
388+ *
389+ * @param {string} name - Macro name (identifier).
390+ * @param {MacroDefinitionOptions} options - Macro definition options.
391+ * @param {Object} [buildOptions] - Additional options for building.
392+ * @param {MacroSource} [buildOptions.source] - Source information. Defaults to dynamic source.
393+ * @returns {MacroDefinition} The built macro definition.
394+ * @throws {Error} If validation fails.
395+ */
396+ buildMacroDefFromOptions(name, options, { source } = {}) {
397+ name = typeof name === 'string' ? name.trim() : String(name);
398+
195399 if (!isIdentifierValid(name)) throw new Error(`Macro name "${name}" is invalid. Must start with a letter, followed by alphanumeric characters or hyphens.`);
196400 if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
197401
@@ -222,7 +426,7 @@ class MacroRegistry {
222426 const aliasName = aliasDef.alias.trim();
223427 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.`);
224428 if (aliasName.toLowerCase() === name.toLowerCase()) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name (insensitive).`);
225429 const visible = aliasDef.visible !== false; // Default to true
226430 aliases.push({ alias: aliasName, visible });
227431 }
228432 }
@@ -239,13 +443,11 @@ class MacroRegistry {
239443 let unnamedArgDefs = [];
240444 if (rawUnnamedArgs !== undefined) {
241445 if (Array.isArray(rawUnnamedArgs)) {
242- // Parse array of argument definitions with optional support
243446 let foundOptional = false;
244447 unnamedArgDefs = rawUnnamedArgs.map((def, index) => {
245448 if (!def || typeof def !== 'object') throw new Error(`Macro "${name}" options.unnamedArgs[${index}] must be an object when using argument definitions.`);
246449 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.`);
247450
248- // Validate: no required args after optional
249451 if (foundOptional && !def.optional) {
250452 throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
251453 }
@@ -270,10 +472,9 @@ class MacroRegistry {
270472 return normalized;
271473 });
272474
273- // Compute minArgs (required count) and maxArgs (total count)
274475 maxArgs = unnamedArgDefs.length;
275476 minArgs = unnamedArgDefs.findIndex(d => d.optional);
276477 if (minArgs === -1) minArgs = maxArgs; // No optional args, all are required
277478 } else if (typeof rawUnnamedArgs === 'number') {
278479 if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
279480 throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
@@ -324,13 +525,10 @@ class MacroRegistry {
324525 returns = rawReturns || '<empty string>';
325526 }
326527
327- // Process and validate returnType (defaults to 'string')
328528 const validTypes = ['string', 'integer', 'number', 'boolean'];
329529 let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
330530 if (rawReturnType !== undefined && rawReturnType !== null) {
331- // Normalize to non-empty value or default
332531 returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
333- // Validate all types
334532 const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
335533 if (typesToValidate.some(t => !validTypes.includes(t))) {
336534 throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
@@ -368,14 +566,6 @@ class MacroRegistry {
368566 delayArgResolution = rawDelayArgResolution;
369567 }
370568
371- const nameKey = name.toLowerCase();
372- if (this.#macros.has(nameKey)) {
373- logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
374- }
375-
376- // Detect extension/third-party status from call stack
377- const { isExtension, isThirdParty, source } = detectMacroSource();
378-
379569 /** @type {MacroDefinition} */
380570 const definition = {
381571 name: name,
@@ -393,180 +583,12 @@ class MacroRegistry {
393583 exampleUsage,
394584 delayArgResolution,
395585 handler,
396- source: {
586+ source: source ?? { name: 'dynamic', isExtension: false, isThirdParty: false },
397- name: source,
398- isExtension,
399- isThirdParty,
400- },
401587 aliasOf: null,
402588 aliasVisible: null,
403589 };
404590
405- this.#macros.set(nameKey, definition);
406-
407- // Register alias entries pointing to the same definition
408- for (const { alias, visible } of aliases) {
409- const aliasKey = alias.toLowerCase();
410- if (this.#macros.has(aliasKey)) {
411- logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
412- }
413- /** @type {MacroDefinition} */
414- const aliasEntry = {
415- ...definition,
416- name: alias, // The lookup name is the alias (preserves original casing for display)
417- aliasOf: name,
418- aliasVisible: visible,
419- };
420- this.#macros.set(aliasKey, aliasEntry);
421- }
422-
423591 return definition;
424- } catch (error) {
425- logMacroRegisterError({
426- message: `Failed to register macro "${name}". The macro will not be available.`,
427- macroName: name,
428- error,
429- });
430- return null;
431- }
432- }
433-
434- /**
435- * Unregisters a macro.
436- *
437- * @param {string} name - Macro name (identifier).
438- * @returns {boolean} True if a macro was removed.
439- */
440- unregisterMacro(name) {
441- if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
442- name = name.trim();
443- return this.#macros.delete(name.toLowerCase());
444- }
445-
446- /**
447- * Checks whether a macro with the given name is registered.
448- *
449- * @param {string} name - Macro name (identifier).
450- * @returns {boolean}
451- */
452- hasMacro(name) {
453- if (typeof name !== 'string' || !name.trim()) return false;
454- name = name.trim();
455- return this.#macros.has(name.toLowerCase());
456- }
457-
458- /**
459- * Returns the macro definition for a given name.
460- *
461- * @param {string} name - Macro name (identifier).
462- * @returns {MacroDefinition|undefined}
463- */
464- getMacro(name) {
465- if (typeof name !== 'string' || !name.trim()) return undefined;
466- name = name.trim();
467- return this.#macros.get(name.toLowerCase());
468- }
469-
470- /**
471- * Returns the primary (non-alias) definition for a macro.
472- * If given an alias name, returns the primary definition it points to.
473- *
474- * @param {string} name - Macro name or alias.
475- * @returns {MacroDefinition|undefined}
476- */
477- getPrimaryMacro(name) {
478- const def = this.getMacro(name);
479- if (!def) return undefined;
480- return def.aliasOf ? this.getMacro(def.aliasOf) : def;
481- }
482-
483- /**
484- * Returns an array of all registered macros.
485- *
486- * @param {Object} [options] - Filter options.
487- * @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
488- * @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
489- * @returns {MacroDefinition[]}
490- */
491- getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
492- let macros = Array.from(this.#macros.values());
493- if (excludeAliases) {
494- macros = macros.filter(m => !m.aliasOf);
495- } else if (excludeHiddenAliases) {
496- macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
497- }
498- return macros;
499- }
500-
501- /**
502- * Executes a macro for a given call.
503- *
504- * @param {MacroCall} call - Macro call information.
505- * @param {Object} [options] - Additional options.
506- * @param {MacroDefinition} [options.defOverride] - Override the macro definition.
507- * @returns {string}
508- */
509- executeMacro(call, { defOverride } = {}) {
510- const name = call.name;
511- const def = defOverride || this.getMacro(name);
512- if (!def) {
513- throw new Error(`Macro "${name}" is not registered`);
514- }
515-
516- const args = Array.isArray(call.args) ? call.args : [];
517-
518- if (!isArgsValid(def, args)) {
519- const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
520- const expectedMax = def.list && def.list.max !== null
521- ? def.maxArgs + def.list.max
522- : (def.list ? null : def.maxArgs);
523-
524- const expectation = (() => {
525- if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
526- if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
527- return `at least ${expectedMin}`;
528- })();
529-
530- const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
531- if (def.strictArgs) {
532- throw createMacroRuntimeError({ message, call, def });
533- }
534- logMacroRuntimeWarning({ message, call, def });
535- }
536-
537- // Compute unnamed args (required + optional, up to maxArgs)
538- const unnamedArgsCount = Math.min(args.length, def.maxArgs);
539- const unnamedArgsValues = args.slice(0, unnamedArgsCount);
540- const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
541-
542- // Perform best-effort type validation for documented positional arguments.
543- // This can throw an error if the arguments are invalid.
544- validateArgTypes(call, def, unnamedArgsValues);
545-
546- const namedArgs = null;
547-
548- /** @type {MacroExecutionContext} */
549- const executionContext = {
550- name: def.name,
551- args,
552- unnamedArgs: unnamedArgsValues,
553- list: listValues,
554- namedArgs,
555- flags: call.flags,
556- isScoped: call.isScoped,
557- raw: call.rawInner,
558- rawOriginal: call.rawWithBraces,
559- rawArgs: call.rawArgs,
560- env: call.env,
561- cstNode: call.cstNode,
562- range: call.range,
563- normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
564- trimContent: MacroEngine.trimScopedContent.bind(MacroEngine),
565- resolve: (text) => MacroEngine.evaluate(text, call.env),
566- };
567-
568- const result = def.handler(executionContext);
569- return executionContext.normalize(result);
570592 }
571593}
572594
public/scripts/slash-commands/SlashCommandClosure.js+102 -0
@@ -1,4 +1,5 @@
11import { substituteParams } from '../../script.js';
2+import { power_user } from '../power-user.js';
23import { delay, escapeRegex, uuidv4 } from '../utils.js';
34import { SlashCommand } from './SlashCommand.js';
45import { SlashCommandAbortController } from './SlashCommandAbortController.js';
@@ -48,6 +49,104 @@ export class SlashCommandClosure {
4849 }
4950
5051 /**
52+ * Performs parameter substitution using the macro engine.
53+ * @param {string} text Text to substitute
54+ * @param {SlashCommandScope} scope Script scope
55+ * @param {{key:string, value:string|SlashCommandClosure}[]} macroList Custom scope macros
56+ * @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]} Substituted text or list of strings/closures
57+ */
58+ substituteWithMacroEngine(text, scope, macroList) {
59+ /** @type {Record<string, import('./../macros/engine/MacroEnv.types.js').DynamicMacroValue>} */
60+ const dynamicMacros = {
61+ 'pipe': () => scope.pipe,
62+ 'var': {
63+ strictArgs: false,
64+ list: { min: 1, max: 2 },
65+ handler: (context) => {
66+ try {
67+ // NB: Legacy replacer halted the script execution on unknown variables
68+ return scope.getVariable(context.list[0], context.list[1]);
69+ } catch (error) {
70+ console.warn('{{var}} dynamic macro execution error:', error);
71+ return '';
72+ }
73+ },
74+ },
75+ };
76+
77+ // Special marker to denote closures in the substituted text
78+ const CLOSURE_BOUNDARY = '\uFFF0~CLOSURE~\uFFF0';
79+ /** @type {Map<string, SlashCommandClosure>} */
80+ const closures = new Map();
81+ /** @type {Record<string, { args: string[], value: string|SlashCommandClosure }[]>} */
82+ const customMacros = {};
83+
84+ for (const macro of macroList) {
85+ const [name, ...rest] = macro.key.split('::');
86+ if (!Object.hasOwn(customMacros, name)) {
87+ customMacros[name] = [];
88+ }
89+ customMacros[name].push({ args: rest, value: macro.value });
90+ }
91+
92+ for (const [macroName, macroArguments] of Object.entries(customMacros)) {
93+ dynamicMacros[macroName] = {
94+ strictArgs: false,
95+ list: { min: 0, max: Number.MAX_SAFE_INTEGER },
96+ handler: (context) => {
97+ // Sort to prefer exact matches over wildcard matches
98+ const sortedMacroArgs = macroArguments.toSorted((a, b) => {
99+ const aHasWildcard = a.args.includes('*');
100+ const bHasWildcard = b.args.includes('*');
101+ if (aHasWildcard && !bHasWildcard) return 1;
102+ if (!aHasWildcard && bHasWildcard) return -1;
103+ return 0;
104+ });
105+
106+ const findMacroMatch = (/** @type {{args: string[]}} */ i) => {
107+ // Exact match
108+ if (i.args.length === context.list.length && i.args.every((arg, index) => arg === context.list[index])) {
109+ return true;
110+ }
111+ // Wildcard match - if any definition arg is '*', it matches any value at that position
112+ if (i.args.length === context.list.length) {
113+ return i.args.every((arg, index) => arg === '*' || arg === context.list[index]);
114+ }
115+ return false;
116+ };
117+
118+ const replacer = sortedMacroArgs.find(findMacroMatch)?.value;
119+ if (replacer instanceof SlashCommandClosure) {
120+ replacer.abortController = this.abortController;
121+ replacer.breakController = this.breakController;
122+ replacer.scope.parent = this.scope;
123+ if (this.debugController && !replacer.debugController) {
124+ replacer.debugController = this.debugController;
125+ }
126+
127+ const closureKey = uuidv4();
128+ closures.set(closureKey, replacer);
129+ return `${CLOSURE_BOUNDARY}${closureKey}${CLOSURE_BOUNDARY}`;
130+ }
131+
132+ return String(replacer ?? '');
133+ },
134+ };
135+ }
136+
137+ const substitutedText = substituteParams(text, { dynamicMacros });
138+
139+ // If any closures were inserted, split the text accordingly
140+ if (closures.size > 0) {
141+ const parts = substitutedText.split(CLOSURE_BOUNDARY).map(part => closures.has(part) ? closures.get(part) : part).filter(Boolean);
142+ return parts.length === 1 ? parts[0] : parts;
143+ }
144+
145+ // No closures, return substituted text as-is
146+ return substitutedText;
147+ }
148+
149+ /**
51150 *
52151 * @param {string} text
53152 * @param {SlashCommandScope} scope
@@ -72,6 +171,9 @@ export class SlashCommandClosure {
72171 if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');
73172 return 0;
74173 });
174+ if (power_user.experimental_macro_engine) {
175+ return this.substituteWithMacroEngine(text, scope, macroList);
176+ }
75177 const macros = macroList.map(it=>escapeMacro(it)).join('|');
76178 const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);
77179 let done = '';
public/scripts/slash-commands/SlashCommandParser.js+4 -0
@@ -1392,6 +1392,10 @@ export class SlashCommandParser {
13921392 }
13931393
13941394 replaceGetvar(value) {
1395+ // Not needed with the new parser.
1396+ if (power_user.experimental_macro_engine) {
1397+ return value;
1398+ }
13951399 return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (match, cmd, name, idx) => {
13961400 name = name.trim();
13971401 cmd = cmd.toLowerCase();
tests/frontend/MacroEngine.e2e.js+465 -10
@@ -674,13 +674,118 @@ test.describe('MacroEngine', () => {
674674 });
675675
676676 test.describe('Dynamic macros', () => {
677677 test.describe('should notString resolvevalue dynamic macro when called with arguments due to strict aritymacros', async ({ page }) => {
678- /** @type {string[]} */
678+ test('should resolve dynamic macro with string value', async ({ page }) => {
679+ const output = await page.evaluate(async () => {
680+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
681+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
682+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
683+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
684+
685+ const rawEnv = {
686+ content: 'Test: {{myvalue}}',
687+ dynamicMacros: {
688+ myvalue: 'hello world',
689+ },
690+ };
691+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
692+ return MacroEngine.evaluate('Test: {{myvalue}}', env);
693+ });
694+
695+ expect(output).toBe('Test: hello world');
696+ });
697+
698+ test('should resolve dynamic macro with numeric value converted to string', async ({ page }) => {
699+ const output = await page.evaluate(async () => {
700+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
701+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
702+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
703+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
704+
705+ const rawEnv = {
706+ content: '',
707+ dynamicMacros: {
708+ num: 42,
709+ },
710+ };
711+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
712+ return MacroEngine.evaluate('Value: {{num}}', env);
713+ });
714+
715+ expect(output).toBe('Value: 42');
716+ });
717+
718+ test('should not resolve string dynamic macro when called with arguments', async ({ page }) => {
679719 const warnings = [];
680720 page.on('console', msg => {
681721 if (msg.type() === 'warning') {warnings.push(msg.text());
682- warnings.push(msg.text());
722+ });
683- }
723+
724+ const input = 'Dyn: {{myvalue::extra}}';
725+ const output = await page.evaluate(async (input) => {
726+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
727+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
728+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
729+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
730+
731+ const rawEnv = {
732+ content: input,
733+ dynamicMacros: { myvalue: 'hello' },
734+ };
735+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
736+ return MacroEngine.evaluate(input, env);
737+ }, input);
738+
739+ expect(output).toBe(input);
740+ expect(warnings.some(w => w.includes('Macro "myvalue"') && w.includes('unnamed arguments'))).toBeTruthy();
741+ });
742+ });
743+
744+ test.describe('Handler function dynamic macros', () => {
745+ test('should resolve dynamic macro with handler function', async ({ page }) => {
746+ const output = await page.evaluate(async () => {
747+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
748+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
749+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
750+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
751+
752+ const rawEnv = {
753+ content: '',
754+ dynamicMacros: {
755+ dyn: () => 'handler result',
756+ },
757+ };
758+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
759+ return MacroEngine.evaluate('Result: {{dyn}}', env);
760+ });
761+
762+ expect(output).toBe('Result: handler result');
763+ });
764+
765+ test('should pass execution context to handler function', async ({ page }) => {
766+ const output = await page.evaluate(async () => {
767+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
768+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
769+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
770+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
771+
772+ const rawEnv = {
773+ content: 'full content here',
774+ dynamicMacros: {
775+ dyn: (ctx) => `name=${ctx.name}, content=${ctx.env.content}`,
776+ },
777+ };
778+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
779+ return MacroEngine.evaluate('{{dyn}}', env);
780+ });
781+
782+ expect(output).toBe('name=dyn, content=full content here');
783+ });
784+
785+ test('should not resolve handler dynamic macro when called with arguments due to strict arity', async ({ page }) => {
786+ const warnings = [];
787+ page.on('console', msg => {
788+ if (msg.type() === 'warning') warnings.push(msg.text());
684789 });
685790
686791 const input = 'Dyn: {{dyn::extra}}';
@@ -690,7 +795,6 @@ test.describe('MacroEngine', () => {
690795 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
691796 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
692797
693- /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
694798 const rawEnv = {
695799 content: input,
696800 dynamicMacros: {
@@ -698,16 +802,367 @@ test.describe('MacroEngine', () => {
698802 },
699803 };
700804 const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
805+ return MacroEngine.evaluate(input, env);
806+ }, input);
807+
808+ expect(output).toBe(input);
809+ expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
810+ });
811+ });
812+
813+ test.describe('MacroDefinitionOptions dynamic macros', () => {
814+ test('should resolve dynamic macro with MacroDefinitionOptions', async ({ page }) => {
815+ const output = await page.evaluate(async () => {
816+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
817+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
818+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
819+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
820+
821+ const rawEnv = {
822+ content: '',
823+ dynamicMacros: {
824+ greet: {
825+ description: 'A greeting macro',
826+ handler: () => 'Hello from options!',
827+ },
828+ },
829+ };
830+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
831+ return MacroEngine.evaluate('{{greet}}', env);
832+ });
833+
834+ expect(output).toBe('Hello from options!');
835+ });
836+
837+ test('should support unnamed arguments in dynamic macro with options', async ({ page }) => {
838+ const output = await page.evaluate(async () => {
839+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
840+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
841+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
842+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
843+
844+ const rawEnv = {
845+ content: '',
846+ dynamicMacros: {
847+ greet: {
848+ unnamedArgs: [{ name: 'name' }],
849+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
850+ },
851+ },
852+ };
853+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
854+ return MacroEngine.evaluate('{{greet::World}}', env);
855+ });
856+
857+ expect(output).toBe('Hello, World!');
858+ });
859+
860+ test('should support multiple unnamed arguments in dynamic macro', async ({ page }) => {
861+ const output = await page.evaluate(async () => {
862+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
863+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
864+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
865+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
866+
867+ const rawEnv = {
868+ content: '',
869+ dynamicMacros: {
870+ wrap: {
871+ unnamedArgs: [
872+ { name: 'content' },
873+ { name: 'prefix' },
874+ { name: 'suffix' },
875+ ],
876+ handler: ({ unnamedArgs: [content, prefix, suffix] }) => `${prefix}${content}${suffix}`,
877+ },
878+ },
879+ };
880+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
881+ return MacroEngine.evaluate('{{wrap::hello::[::]}}', env);
882+ });
883+
884+ expect(output).toBe('[hello]');
885+ });
886+
887+ test('should support optional arguments in dynamic macro', async ({ page }) => {
888+ const output = await page.evaluate(async () => {
889+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
890+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
891+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
892+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
893+
894+ const rawEnv = {
895+ content: '',
896+ dynamicMacros: {
897+ greet: {
898+ unnamedArgs: [
899+ { name: 'name' },
900+ { name: 'greeting', optional: true, defaultValue: 'Hello' },
901+ ],
902+ handler: ({ unnamedArgs: [name, greeting] }) => `${greeting || 'Hello'}, ${name}!`,
903+ },
904+ },
905+ };
906+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
907+
908+ const result1 = MacroEngine.evaluate('{{greet::World}}', env);
909+ const result2 = MacroEngine.evaluate('{{greet::World::Hi}}', env);
910+ return { result1, result2 };
911+ });
912+
913+ expect(output.result1).toBe('Hello, World!');
914+ expect(output.result2).toBe('Hi, World!');
915+ });
916+
917+ test('should support list arguments in dynamic macro', async ({ page }) => {
918+ const output = await page.evaluate(async () => {
919+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
920+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
921+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
922+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
923+
924+ const rawEnv = {
925+ content: '',
926+ dynamicMacros: {
927+ join: {
928+ unnamedArgs: [{ name: 'separator' }],
929+ list: true,
930+ handler: ({ unnamedArgs: [sep], list }) => list.join(sep),
931+ },
932+ },
933+ };
934+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
935+ return MacroEngine.evaluate('{{join::-::a::b::c}}', env);
936+ });
937+
938+ expect(output).toBe('a-b-c');
939+ });
940+
941+ test('should enforce type validation in dynamic macro with options', async ({ page }) => {
942+ const warnings = [];
943+ page.on('console', msg => {
944+ if (msg.type() === 'warning') warnings.push(msg.text());
945+ });
701946
947+ const input = '{{calc::abc}}';
948+ const output = await page.evaluate(async (input) => {
949+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
950+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
951+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
952+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
953+
954+ const rawEnv = {
955+ content: input,
956+ dynamicMacros: {
957+ calc: {
958+ unnamedArgs: [{ name: 'value', type: 'integer' }],
959+ strictArgs: true,
960+ handler: ({ unnamedArgs: [val] }) => `#${val}#`,
961+ },
962+ },
963+ };
964+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
702965 return MacroEngine.evaluate(input, env);
703966 }, input);
704967
705- // Dynamic macro with arguments should not resolve because the
706- // temporary definition is strictArgs: true and minArgs/maxArgs: 0.
707968 expect(output).toBe(input);
969+ expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
970+ });
708971
709- // A runtime arity warning for the dynamic macro should be logged
972+ test('should respect strictArgs: false in dynamic macro with options', async ({ page }) => {
710- expect(warnings.some(w => w.includes('Macro "dyn"') && w.includes('unnamed arguments'))).toBeTruthy();
973+ const warnings = [];
974+ page.on('console', msg => {
975+ if (msg.type() === 'warning') warnings.push(msg.text());
976+ });
977+
978+ const output = await page.evaluate(async () => {
979+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
980+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
981+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
982+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
983+
984+ const rawEnv = {
985+ content: '',
986+ dynamicMacros: {
987+ calc: {
988+ unnamedArgs: [{ name: 'value', type: 'integer' }],
989+ strictArgs: false,
990+ handler: ({ unnamedArgs: [val] }) => `#${val}#`,
991+ },
992+ },
993+ };
994+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
995+ return MacroEngine.evaluate('{{calc::abc}}', env);
996+ });
997+
998+ expect(output).toBe('#abc#');
999+ expect(warnings.some(w => w.includes('calc') && w.includes('expected type integer'))).toBeTruthy();
1000+ });
1001+
1002+ test('should fail arity check in dynamic macro with options when too few args', async ({ page }) => {
1003+ const warnings = [];
1004+ page.on('console', msg => {
1005+ if (msg.type() === 'warning') warnings.push(msg.text());
1006+ });
1007+
1008+ const input = '{{greet}}';
1009+ const output = await page.evaluate(async (input) => {
1010+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1011+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1012+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1013+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1014+
1015+ const rawEnv = {
1016+ content: input,
1017+ dynamicMacros: {
1018+ greet: {
1019+ unnamedArgs: [{ name: 'name' }],
1020+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
1021+ },
1022+ },
1023+ };
1024+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1025+ return MacroEngine.evaluate(input, env);
1026+ }, input);
1027+
1028+ expect(output).toBe(input);
1029+ expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
1030+ });
1031+
1032+ test('should fail arity check in dynamic macro with options when too many args', async ({ page }) => {
1033+ const warnings = [];
1034+ page.on('console', msg => {
1035+ if (msg.type() === 'warning') warnings.push(msg.text());
1036+ });
1037+
1038+ const input = '{{greet::one::two}}';
1039+ const output = await page.evaluate(async (input) => {
1040+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1041+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1042+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1043+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1044+
1045+ const rawEnv = {
1046+ content: input,
1047+ dynamicMacros: {
1048+ greet: {
1049+ unnamedArgs: [{ name: 'name' }],
1050+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
1051+ },
1052+ },
1053+ };
1054+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1055+ return MacroEngine.evaluate(input, env);
1056+ }, input);
1057+
1058+ expect(output).toBe(input);
1059+ expect(warnings.some(w => w.includes('greet') && w.includes('unnamed arguments'))).toBeTruthy();
1060+ });
1061+
1062+ test('should handle invalid MacroDefinitionOptions gracefully', async ({ page }) => {
1063+ const warnings = [];
1064+ page.on('console', msg => {
1065+ if (msg.type() === 'warning') warnings.push(msg.text());
1066+ });
1067+
1068+ const input = '{{bad}}';
1069+ const output = await page.evaluate(async (input) => {
1070+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1071+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1072+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1073+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1074+
1075+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
1076+ const rawEnv = {
1077+ content: input,
1078+ dynamicMacros: {
1079+ bad: {
1080+ // Missing handler - should fail validation
1081+ unnamedArgs: 1,
1082+ },
1083+ },
1084+ };
1085+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1086+ return MacroEngine.evaluate(input, env);
1087+ }, input);
1088+
1089+ // Should remain unresolved since options are invalid
1090+ expect(output).toBe(input);
1091+ expect(warnings.some(w => w.includes('bad') && w.includes('is not defined correctly'))).toBeTruthy();
1092+ });
1093+ });
1094+
1095+ test.describe('Dynamic macro priority and case sensitivity', () => {
1096+ test('should override registered macro with dynamic macro of same name', async ({ page }) => {
1097+ const output = await page.evaluate(async () => {
1098+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1099+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1100+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1101+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1102+
1103+ const rawEnv = {
1104+ content: '',
1105+ name1Override: 'User',
1106+ dynamicMacros: {
1107+ user: 'DynamicUser',
1108+ },
1109+ };
1110+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1111+ return MacroEngine.evaluate('{{user}}', env);
1112+ });
1113+
1114+ expect(output).toBe('DynamicUser');
1115+ });
1116+
1117+ test('should match dynamic macro names case-insensitively', async ({ page }) => {
1118+ const output = await page.evaluate(async () => {
1119+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1120+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1121+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1122+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1123+
1124+ const rawEnv = {
1125+ content: '',
1126+ dynamicMacros: {
1127+ MyMacro: 'value',
1128+ },
1129+ };
1130+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1131+
1132+ const r1 = MacroEngine.evaluate('{{MyMacro}}', env);
1133+ const r2 = MacroEngine.evaluate('{{mymacro}}', env);
1134+ const r3 = MacroEngine.evaluate('{{MYMACRO}}', env);
1135+ return { r1, r2, r3 };
1136+ });
1137+
1138+ expect(output.r1).toBe('value');
1139+ expect(output.r2).toBe('value');
1140+ expect(output.r3).toBe('value');
1141+ });
1142+
1143+ test('should resolve multiple different dynamic macros in same evaluation', async ({ page }) => {
1144+ const output = await page.evaluate(async () => {
1145+ /** @type {import('../../public/scripts/macros/engine/MacroEngine.js')} */
1146+ const { MacroEngine } = await import('./scripts/macros/engine/MacroEngine.js');
1147+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
1148+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
1149+
1150+ const rawEnv = {
1151+ content: '',
1152+ dynamicMacros: {
1153+ a: 'alpha',
1154+ b: () => 'beta',
1155+ c: {
1156+ handler: () => 'gamma',
1157+ },
1158+ },
1159+ };
1160+ const env = MacroEnvBuilder.buildFromRawEnv(rawEnv);
1161+ return MacroEngine.evaluate('{{a}}-{{b}}-{{c}}', env);
1162+ });
1163+
1164+ expect(output).toBe('alpha-beta-gamma');
1165+ });
7111166 });
7121167 });
7131168
tests/frontend/MacroEnvBuilder.e2e.js+188 -3
@@ -205,18 +205,17 @@ test.describe('MacroEnvBuilder', () => {
205205 });
206206 });
207207
208+ test.describe('Dynamic macros in env', () => {
208209 test('merges dynamicMacros properties into env.dynamicMacros', async ({ page }) => {
209210 const dynamicMacros = await page.evaluate(async () => {
210211 /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
211212 const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
212213
213- /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js').MacroEnvRawContext} */
214214 const ctx = {
215215 content: '',
216216 dynamicMacros: {
217217 simple: 'value',
218218 number: 42,
219- nested: { foo: 'bar' },
220219 },
221220 };
222221
@@ -226,7 +225,193 @@ test.describe('MacroEnvBuilder', () => {
226225
227226 expect(dynamicMacros.simple).toBe('value');
228227 expect(dynamicMacros.number).toBe(42);
229- expect(dynamicMacros.nested).toEqual({ foo: 'bar' });
228+ });
229+
230+ test('normalizes dynamic macro keys to lowercase', async ({ page }) => {
231+ const result = await page.evaluate(async () => {
232+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
233+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
234+
235+ const ctx = {
236+ content: '',
237+ dynamicMacros: {
238+ MyMacro: 'value1',
239+ UPPERCASE: 'value2',
240+ lowercase: 'value3',
241+ },
242+ };
243+
244+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
245+ return {
246+ hasLower: 'mymacro' in env.dynamicMacros,
247+ hasUpper: 'uppercase' in env.dynamicMacros,
248+ hasLowercase: 'lowercase' in env.dynamicMacros,
249+ value1: env.dynamicMacros['mymacro'],
250+ value2: env.dynamicMacros['uppercase'],
251+ value3: env.dynamicMacros['lowercase'],
252+ };
253+ });
254+
255+ expect(result.hasLower).toBe(true);
256+ expect(result.hasUpper).toBe(true);
257+ expect(result.hasLowercase).toBe(true);
258+ expect(result.value1).toBe('value1');
259+ expect(result.value2).toBe('value2');
260+ expect(result.value3).toBe('value3');
261+ });
262+
263+ test('accepts string values for dynamic macros', async ({ page }) => {
264+ const result = await page.evaluate(async () => {
265+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
266+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
267+
268+ const ctx = {
269+ content: '',
270+ dynamicMacros: {
271+ greeting: 'Hello, World!',
272+ },
273+ };
274+
275+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
276+ return {
277+ value: env.dynamicMacros['greeting'],
278+ type: typeof env.dynamicMacros['greeting'],
279+ };
280+ });
281+
282+ expect(result.value).toBe('Hello, World!');
283+ expect(result.type).toBe('string');
284+ });
285+
286+ test('accepts handler functions for dynamic macros', async ({ page }) => {
287+ const result = await page.evaluate(async () => {
288+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
289+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
290+
291+ const ctx = {
292+ content: '',
293+ dynamicMacros: {
294+ dyn: () => 'handler result',
295+ },
296+ };
297+
298+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
299+ const storedValue = env.dynamicMacros['dyn'];
300+ return {
301+ isFunction: typeof storedValue === 'function',
302+ callResult: typeof storedValue === 'function' ? storedValue() : null,
303+ };
304+ });
305+
306+ expect(result.isFunction).toBe(true);
307+ expect(result.callResult).toBe('handler result');
308+ });
309+
310+ test('accepts MacroDefinitionOptions objects for dynamic macros', async ({ page }) => {
311+ const result = await page.evaluate(async () => {
312+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
313+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
314+
315+ const ctx = {
316+ content: '',
317+ dynamicMacros: {
318+ greet: {
319+ description: 'A greeting macro',
320+ unnamedArgs: [{ name: 'name' }],
321+ handler: ({ unnamedArgs: [name] }) => `Hello, ${name}!`,
322+ },
323+ },
324+ };
325+
326+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
327+ const storedValue = env.dynamicMacros['greet'];
328+ return {
329+ isObject: typeof storedValue === 'object' && storedValue !== null,
330+ hasHandler: typeof storedValue?.handler === 'function',
331+ hasDescription: typeof storedValue?.description === 'string',
332+ hasUnnamedArgs: Array.isArray(storedValue?.unnamedArgs),
333+ };
334+ });
335+
336+ expect(result.isObject).toBe(true);
337+ expect(result.hasHandler).toBe(true);
338+ expect(result.hasDescription).toBe(true);
339+ expect(result.hasUnnamedArgs).toBe(true);
340+ });
341+
342+ test('supports mixed dynamic macro value types', async ({ page }) => {
343+ const result = await page.evaluate(async () => {
344+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
345+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
346+
347+ const ctx = {
348+ content: '',
349+ dynamicMacros: {
350+ stringVal: 'direct string',
351+ funcVal: () => 'from function',
352+ optionsVal: {
353+ handler: () => 'from options',
354+ unnamedArgs: 1,
355+ },
356+ },
357+ };
358+
359+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
360+ return {
361+ stringType: typeof env.dynamicMacros['stringval'],
362+ funcType: typeof env.dynamicMacros['funcval'],
363+ optionsType: typeof env.dynamicMacros['optionsval'],
364+ optionsHasHandler: typeof env.dynamicMacros['optionsval']?.handler === 'function',
365+ };
366+ });
367+
368+ expect(result.stringType).toBe('string');
369+ expect(result.funcType).toBe('function');
370+ expect(result.optionsType).toBe('object');
371+ expect(result.optionsHasHandler).toBe(true);
372+ });
373+
374+ test('handles null dynamicMacros gracefully', async ({ page }) => {
375+ const result = await page.evaluate(async () => {
376+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
377+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
378+
379+ const ctx = {
380+ content: '',
381+ dynamicMacros: null,
382+ };
383+
384+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
385+ return {
386+ hasDynamicMacros: 'dynamicMacros' in env,
387+ isEmpty: Object.keys(env.dynamicMacros).length === 0,
388+ };
389+ });
390+
391+ expect(result.hasDynamicMacros).toBe(true);
392+ expect(result.isEmpty).toBe(true);
393+ });
394+
395+ test('handles undefined dynamicMacros gracefully', async ({ page }) => {
396+ const result = await page.evaluate(async () => {
397+ /** @type {import('../../public/scripts/macros/engine/MacroEnvBuilder.js')} */
398+ const { MacroEnvBuilder } = await import('./scripts/macros/engine/MacroEnvBuilder.js');
399+
400+ const ctx = {
401+ content: '',
402+ // dynamicMacros not provided
403+ };
404+
405+ const env = MacroEnvBuilder.buildFromRawEnv(ctx);
406+ return {
407+ hasDynamicMacros: 'dynamicMacros' in env,
408+ isEmpty: Object.keys(env.dynamicMacros).length === 0,
409+ };
410+ });
411+
412+ expect(result.hasDynamicMacros).toBe(true);
413+ expect(result.isEmpty).toBe(true);
414+ });
230415 });
231416
232417 test('sets system.model field from getGeneratingModel helper', async ({ page }) => {
tests/frontend/MacroSlashCommands.e2e.js+177 -0
@@ -0,0 +1,177 @@
1+import { test, expect } from '@playwright/test';
2+import { testSetup } from './frontent-test-utils.js';
3+
4+test.describe('MacroSlashCommands', () => {
5+ test.beforeEach(testSetup.awaitST);
6+
7+ test.describe('Parser Flags', () => {
8+ test('should bypass REPLACE_GETVAR', async ({ page }) => {
9+ const output = await page.evaluate(async () => {
10+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
11+ const { power_user } = await import('./scripts/power-user.js');
12+
13+ power_user.experimental_macro_engine = true;
14+
15+ return (await executeSlashCommandsWithOptions('/parser-flag REPLACE_GETVAR || /setvar key=x \\{\\{lastMessageId}} || /pass {{getvar::x}}')).pipe;
16+ });
17+
18+ expect(output).toBe('{{lastMessageId}}');
19+ });
20+ });
21+
22+ test.describe('{{pipe}} Macro', () => {
23+ test('should support {{pipe}} macro', async ({ page }) => {
24+ const output = await page.evaluate(async () => {
25+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
26+ const { power_user } = await import('./scripts/power-user.js');
27+
28+ power_user.experimental_macro_engine = true;
29+
30+ return (await executeSlashCommandsWithOptions('/pass Hello World || /pass {{pipe}}')).pipe;
31+ });
32+
33+ expect(output).toBe('Hello World');
34+ });
35+ });
36+
37+ test.describe('{{var}} Macro', () => {
38+ test('should support {{var::key}} macro', async ({ page }) => {
39+ const output = await page.evaluate(async () => {
40+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
41+ const { power_user } = await import('./scripts/power-user.js');
42+
43+ power_user.experimental_macro_engine = true;
44+
45+ return (await executeSlashCommandsWithOptions('/let key=greeting Hello || /pass {{var::greeting}}')).pipe;
46+ });
47+
48+ expect(output).toBe('Hello');
49+ });
50+
51+ test('should support {{var::key::index}} macro', async ({ page }) => {
52+ const output = await page.evaluate(async () => {
53+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
54+ const { power_user } = await import('./scripts/power-user.js');
55+
56+ power_user.experimental_macro_engine = true;
57+
58+ return (await executeSlashCommandsWithOptions('/let key=list ["item1","item2","item3"] || /pass {{var::list::1}}')).pipe;
59+ });
60+
61+ expect(output).toBe('item2');
62+ });
63+
64+ test('should not fail on unknown variable keys', async ({ page }) => {
65+ const output = await page.evaluate(async () => {
66+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
67+ const { power_user } = await import('./scripts/power-user.js');
68+
69+ power_user.experimental_macro_engine = true;
70+ return (await executeSlashCommandsWithOptions('/pass {{var::unknownKey}}')).pipe;
71+ });
72+
73+ expect(output).toBe('');
74+ });
75+
76+ test('should not fail on out-of-bounds variable index', async ({ page }) => {
77+ const output = await page.evaluate(async () => {
78+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
79+ const { power_user } = await import('./scripts/power-user.js');
80+
81+ power_user.experimental_macro_engine = true;
82+
83+ return (await executeSlashCommandsWithOptions('/let key=test {"key":"value"} || /pass {{var::test::error}}')).pipe;
84+ });
85+
86+ expect(output).toBe('');
87+ });
88+ });
89+
90+ test.describe('{{arg}} Macro', () => {
91+ test('should support {{arg}} macro with plain value', async ({ page }) => {
92+ const output = await page.evaluate(async () => {
93+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
94+ const { power_user } = await import('./scripts/power-user.js');
95+
96+ power_user.experimental_macro_engine = true;
97+
98+ return (await executeSlashCommandsWithOptions('/qr-arg hello world || /pass {{arg::hello}}')).pipe;
99+ });
100+
101+ expect(output).toBe('world');
102+ });
103+
104+ test('should support {{arg}} macro with closure value', async ({ page }) => {
105+ const output = await page.evaluate(async () => {
106+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
107+ const { power_user } = await import('./scripts/power-user.js');
108+
109+ power_user.experimental_macro_engine = true;
110+
111+ return (await executeSlashCommandsWithOptions('/qr-arg x {: /echo test :} || /echo {{arg::x}}')).pipe;
112+ });
113+
114+ expect(output).toBe('[Closure]');
115+ });
116+
117+ test('should support mixed type {{arg}} macro values', async ({ page }) => {
118+ const output = await page.evaluate(async () => {
119+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
120+ const { power_user } = await import('./scripts/power-user.js');
121+
122+ power_user.experimental_macro_engine = true;
123+
124+ return (await executeSlashCommandsWithOptions('/qr-arg a simple || /qr-arg b {: /echo closure :} || /echo {{arg::a}} and {{arg::b}}')).pipe;
125+ });
126+
127+ expect(output).toBe('simple and ,[Closure]');
128+ });
129+
130+ test('should support wildcard {{arg}} macro', async ({ page }) => {
131+ const output = await page.evaluate(async () => {
132+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
133+ const { power_user } = await import('./scripts/power-user.js');
134+
135+ power_user.experimental_macro_engine = true;
136+
137+ return (await executeSlashCommandsWithOptions('/qr-arg * wildcard || /pass {{arg::any}}')).pipe;
138+ });
139+
140+ expect(output).toBe('wildcard');
141+ });
142+ });
143+
144+ test.describe('Custom Scope Macros', () => {
145+ test('should support {{timesIndex}} in /times command', async ({ page }) => {
146+ const output = await page.evaluate(async () => {
147+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
148+ const { power_user } = await import('./scripts/power-user.js');
149+
150+ power_user.experimental_macro_engine = true;
151+
152+ return (await executeSlashCommandsWithOptions('/times 1 {: /pass {{timesIndex}} :}')).pipe;
153+ });
154+
155+ expect(output).toBe('0');
156+ });
157+
158+ test('should support custom SlashCommandScope macros', async ({ page }) => {
159+ const output = await page.evaluate(async () => {
160+ const { executeSlashCommandsWithOptions } = await import('./scripts/slash-commands.js');
161+ const { SlashCommandScope } = await import('./scripts/slash-commands/SlashCommandScope.js');
162+ const { power_user } = await import('./scripts/power-user.js');
163+
164+ power_user.experimental_macro_engine = true;
165+
166+ // Create a custom scope with a macro
167+ const customScope = new SlashCommandScope(null);
168+ customScope.setMacro('uno::dos::tres', 'CUSTOM_VALUE');
169+ customScope.setMacro('uno::dos::quatro', 'SHOULD_NOT_BE_USED');
170+ customScope.setMacro('uno::dos::*', 'WILDCARD_VALUE');
171+ return (await executeSlashCommandsWithOptions('/pass {{uno::dos::tres}}', { scope: customScope })).pipe;
172+ });
173+
174+ expect(output).toBe('CUSTOM_VALUE');
175+ });
176+ });
177+});