673 lines
30 KiB
JavaScript
673 lines
30 KiB
JavaScript
/** @typedef {import('chevrotain').CstNode} CstNode */
|
|
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
|
|
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
|
|
|
|
import { isFalseBoolean, isTrueBoolean } from '../../utils.js';
|
|
import { MacroEngine } from './MacroEngine.js';
|
|
import { createMacroRuntimeError, logMacroRegisterError, logMacroRegisterWarning, logMacroRuntimeWarning } from './MacroDiagnostics.js';
|
|
|
|
/**
|
|
* Enum of standard macro categories for grouping in documentation and autocomplete.
|
|
* Extensions may use these or define custom category strings.
|
|
*
|
|
* @readonly
|
|
* @enum {string}
|
|
*/
|
|
export const MacroCategory = Object.freeze({
|
|
/** Basic utilities and text manipulation (newline, noop, trim, reverse, comment) */
|
|
UTILITY: 'utility',
|
|
/** Randomization and dice rolling (random, pick, roll) */
|
|
RANDOM: 'random',
|
|
/** Participant names and name lists (user, char, group, notChar) */
|
|
NAMES: 'names',
|
|
/** Character card fields and persona (description, personality, scenario, mesExamples, persona) */
|
|
CHARACTER: 'character',
|
|
/** Chat history, messages, and swipes */
|
|
CHAT: 'chat',
|
|
/** Date, time, and duration macros */
|
|
TIME: 'time',
|
|
/** Local and global variable operations */
|
|
VARIABLE: 'variable',
|
|
/** Prompt templates for text completion (instruct sequences, system prompts, author's notes, context templates) */
|
|
PROMPTS: 'prompts',
|
|
/** Runtime application state (model, API, lastGenerationType, isMobile) */
|
|
STATE: 'state',
|
|
/** Macros that don't fit in any of the other categories, but don't really need/deserve their own */
|
|
MISC: 'misc',
|
|
});
|
|
|
|
/**
|
|
* Enum of standard macro value types for type checking and documentation.
|
|
* Used for both argument types and return types.
|
|
*
|
|
* @readonly
|
|
* @enum {string}
|
|
*/
|
|
export const MacroValueType = Object.freeze({
|
|
/** String value of any kind */
|
|
STRING: 'string',
|
|
/** Integer value (natural number, no decimal spaces) */
|
|
INTEGER: 'integer',
|
|
/** Number value (decimal spaces allowed, includes integers values) */
|
|
NUMBER: 'number',
|
|
/** Boolean value (true/false, 1/0, yes/no, on/off) */
|
|
BOOLEAN: 'boolean',
|
|
});
|
|
|
|
/**
|
|
* @typedef {Object} MacroDefinitionOptions
|
|
* @property {MacroAliasDef[]} [aliases] - Alternative names for this macro. Each alias creates a lookup entry pointing to the same definition.
|
|
* @property {MacroCategory|string} category - Category for grouping in documentation/autocomplete. Use MacroCategory enum values or a custom string.
|
|
* @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.
|
|
* @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.
|
|
* @property {boolean} [strictArgs=true] - Whether the macro should be strict about its arguments.
|
|
* @property {string} [description=''] - Add a description of what the macro does.
|
|
* @property {string} [returns] - Add a specific description of what the macro returns, if it is not obvious from the description.
|
|
* @property {MacroValueType|MacroValueType[]} [returnType=MacroValueType.STRING] - The type(s) this macro returns. Defaults to string.
|
|
* @property {string} [displayOverride] - Override the auto-generated macro signature for display (must include curly braces, e.g. "{{macro::arg}}").
|
|
* @property {string|string[]} [exampleUsage] - Example usage(s) shown in documentation (must include curly braces).
|
|
* @property {MacroHandler} handler - The handler function for the macro.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} MacroAliasDef
|
|
* @property {string} alias - The alias name.
|
|
* @property {boolean} [visible=true] - Whether this alias appears in documentation/autocomplete. Defaults to true.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} MacroUnnamedArgDef
|
|
* @property {string} name
|
|
* @property {boolean} [optional=false] - Whether this argument is optional. Optional args must form a contiguous suffix (no required args after an optional).
|
|
* @property {string} [defaultValue] - Default value for optional args. ONLY meaningful when optional is true. Shown in docs/autocomplete.
|
|
* @property {MacroValueType|MacroValueType[]} [type=MacroValueType.STRING] - Single type or array of accepted types.
|
|
* @property {string} [sampleValue]
|
|
* @property {string} [description]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} MacroListSpec
|
|
* @property {number} [min]
|
|
* @property {number} [max]
|
|
*/
|
|
|
|
/**
|
|
* @typedef {(context: MacroExecutionContext) => string} MacroHandler
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} MacroExecutionContext
|
|
* @property {string} name
|
|
* @property {string[]} args - All unnamed arguments passed to the macro.
|
|
* @property {string[]} unnamedArgs - Unnamed positional arguments (both required and optional, up to the defined count).
|
|
* @property {string[]|null} list - List arguments (after unnamed args), or null if list is not enabled.
|
|
* @property {{ [key: string]: string }|null} namedArgs - Reserved for future named argument support.
|
|
* @property {string} raw
|
|
* @property {MacroEnv} env
|
|
* @property {CstNode|null} cstNode
|
|
* @property {{ startOffset: number, endOffset: number }|null} range
|
|
* @property {(value: any) => string} normalize - Normalize function to use on unsure macro results to make sure they return strings as expected.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} MacroDefinition
|
|
* @property {string} name - Primary macro name.
|
|
* @property {MacroResolvedAlias[]} aliases - Parsed alias definitions for this macro.
|
|
* @property {MacroCategory|string} category
|
|
* @property {number} minArgs - Minimum number of unnamed args required (excludes optional args).
|
|
* @property {number} maxArgs - Maximum number of unnamed args accepted (includes optional args).
|
|
* @property {MacroUnnamedArgDef[]} unnamedArgDefs - Definitions for all unnamed positional arguments (required + optional).
|
|
* @property {{ min: number, max: (number|null) }|null} list
|
|
* @property {boolean} strictArgs
|
|
* @property {string} description
|
|
* @property {string|null} returns
|
|
* @property {MacroValueType|MacroValueType[]} returnType - The type(s) this macro returns.
|
|
* @property {string|null} displayOverride - Override for the auto-generated macro signature display.
|
|
* @property {string[]} exampleUsage - Example usage strings for documentation.
|
|
* @property {MacroHandler} handler
|
|
* @property {MacroSource} source
|
|
* @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.
|
|
* @property {boolean|null} aliasVisible - If this is an alias, whether this alias is visible in docs/autocomplete.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} MacroResolvedAlias
|
|
* @property {string} alias - The alias name.
|
|
* @property {boolean} visible - Whether this alias is visible in documentation/autocomplete.
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} MacroSource
|
|
* @property {string} name - Source identifier (extension name or script path)
|
|
* @property {boolean} isExtension - True if registered from an extension
|
|
* @property {boolean} isThirdParty - True if registered from a third-party extension
|
|
*/
|
|
|
|
/**
|
|
* The singleton instance of the MacroRegistry.
|
|
*
|
|
* @type {MacroRegistry}
|
|
*/
|
|
let instance;
|
|
export { instance as MacroRegistry };
|
|
|
|
class MacroRegistry {
|
|
/** @type {MacroRegistry} */ static #instance;
|
|
/** @type {MacroRegistry} */ static get instance() { return MacroRegistry.#instance ?? (MacroRegistry.#instance = new MacroRegistry()); }
|
|
|
|
/** @type {Map<string, MacroDefinition>} */
|
|
#macros;
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
constructor() {
|
|
/** @type {Map<string, MacroDefinition>} */
|
|
this.#macros = new Map();
|
|
}
|
|
|
|
/**
|
|
* Registers a macro with the registry.
|
|
* Errors during registration are caught and logged, the macro will not be registered, and the function returns null.
|
|
*
|
|
* @param {string} name - Macro name (identifier).
|
|
* @param {MacroDefinitionOptions} options - Macro registration options including handler and metadata.
|
|
* @returns {MacroDefinition|null} The registered definition, or null if registration failed.
|
|
*/
|
|
registerMacro(name, options) {
|
|
// Extract name early for error logging
|
|
name = typeof name === 'string' ? name.trim() : String(name);
|
|
|
|
try {
|
|
if (typeof name !== 'string' || !name) throw new Error('Macro name must be a non-empty string');
|
|
if (!options || typeof options !== 'object') throw new Error(`Macro "${name}" options must be a non-null object.`);
|
|
|
|
const {
|
|
aliases: rawAliases,
|
|
category: rawCategory,
|
|
unnamedArgs: rawUnnamedArgs,
|
|
list: rawList,
|
|
strictArgs: rawStrictArgs,
|
|
description: rawDescription,
|
|
returns: rawReturns,
|
|
returnType: rawReturnType,
|
|
displayOverride: rawDisplayOverride,
|
|
exampleUsage: rawExampleUsage,
|
|
handler,
|
|
} = options;
|
|
|
|
if (typeof handler !== 'function') throw new Error(`Macro "${name}" options.handler must be a function.`);
|
|
|
|
/** @type {MacroResolvedAlias[]} */
|
|
const aliases = [];
|
|
if (rawAliases !== undefined && rawAliases !== null) {
|
|
if (!Array.isArray(rawAliases)) throw new Error(`Macro "${name}" options.aliases must be an array.`);
|
|
for (const [i, aliasDef] of rawAliases.entries()) {
|
|
if (!aliasDef || typeof aliasDef !== 'object') throw new Error(`Macro "${name}" options.aliases[${i}] must be an object.`);
|
|
if (typeof aliasDef.alias !== 'string' || !aliasDef.alias.trim()) throw new Error(`Macro "${name}" options.aliases[${i}].alias must be a non-empty string.`);
|
|
const aliasName = aliasDef.alias.trim();
|
|
if (aliasName === name) throw new Error(`Macro "${name}" options.aliases[${i}].alias cannot be the same as the macro name.`);
|
|
const visible = aliasDef.visible !== false; // Default to true
|
|
aliases.push({ alias: aliasName, visible });
|
|
}
|
|
}
|
|
|
|
if (typeof rawCategory !== 'string' || !rawCategory.trim()) throw new Error(`Macro "${name}" options.category must be a non-empty string.`);
|
|
const category = rawCategory.trim();
|
|
|
|
let minArgs = 0;
|
|
let maxArgs = 0;
|
|
/** @type {MacroUnnamedArgDef[]} */
|
|
let unnamedArgDefs = [];
|
|
if (rawUnnamedArgs !== undefined) {
|
|
if (Array.isArray(rawUnnamedArgs)) {
|
|
// Parse array of argument definitions with optional support
|
|
let foundOptional = false;
|
|
unnamedArgDefs = rawUnnamedArgs.map((def, index) => {
|
|
if (!def || typeof def !== 'object') throw new Error(`Macro "${name}" options.unnamedArgs[${index}] must be an object when using argument definitions.`);
|
|
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.`);
|
|
|
|
// Validate: no required args after optional
|
|
if (foundOptional && !def.optional) {
|
|
throw new Error(`Macro "${name}" options.unnamedArgs[${index}] is required but follows an optional argument. Optional args must be a suffix.`);
|
|
}
|
|
if (def.optional) foundOptional = true;
|
|
|
|
/** @type {MacroUnnamedArgDef} */
|
|
const normalized = {
|
|
name: def.name.trim(),
|
|
optional: def.optional || false,
|
|
defaultValue: def.defaultValue?.trim(),
|
|
type: Array.isArray(def.type) && def.type.length === 0 ? 'string' : def.type ?? 'string',
|
|
sampleValue: def.sampleValue?.trim(),
|
|
description: typeof def.description === 'string' ? def.description : undefined,
|
|
};
|
|
|
|
const validTypes = ['string', 'integer', 'number', 'boolean'];
|
|
const type = Array.isArray(normalized.type) ? normalized.type : [normalized.type];
|
|
if (type.some(t => !validTypes.includes(t))) {
|
|
throw new Error(`Macro "${name}" options.unnamedArgs[${index}].type must be one of "string", "integer", "number", or "boolean" when provided.`);
|
|
}
|
|
|
|
return normalized;
|
|
});
|
|
|
|
// Compute minArgs (required count) and maxArgs (total count)
|
|
maxArgs = unnamedArgDefs.length;
|
|
minArgs = unnamedArgDefs.findIndex(d => d.optional);
|
|
if (minArgs === -1) minArgs = maxArgs; // No optional args, all are required
|
|
} else if (typeof rawUnnamedArgs === 'number') {
|
|
if (!Number.isInteger(rawUnnamedArgs) || rawUnnamedArgs < 0) {
|
|
throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer when provided.`);
|
|
}
|
|
minArgs = rawUnnamedArgs;
|
|
maxArgs = rawUnnamedArgs;
|
|
unnamedArgDefs = Array.from({ length: rawUnnamedArgs }, (_, i) => ({
|
|
name: `arg${i + 1}`,
|
|
optional: false,
|
|
type: 'string',
|
|
sampleValue: `arg${i + 1}`,
|
|
}));
|
|
} else {
|
|
throw new Error(`Macro "${name}" options.unnamedArgs must be a non-negative integer or an array of argument definitions when provided.`);
|
|
}
|
|
}
|
|
|
|
/** @type {{ min: number, max: (number|null) }|null} */
|
|
let list = null;
|
|
if (rawList !== undefined) {
|
|
if (typeof rawList === 'boolean') {
|
|
list = rawList ? { min: 0, max: null } : null;
|
|
} else if (typeof rawList === 'object' && rawList !== null) {
|
|
if (typeof rawList.min !== 'number' || rawList.min < 0) throw new Error(`Macro "${name}" options.list.min must be a non-negative integer when provided.`);
|
|
if (rawList.max !== undefined && typeof rawList.max !== 'number') throw new Error(`Macro "${name}" options.list.max must be a number when provided.`);
|
|
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.`);
|
|
list = { min: rawList.min, max: rawList.max ?? null };
|
|
} else {
|
|
throw new Error(`Macro "${name}" options.list must be a boolean or an object with numeric min/max when provided.`);
|
|
}
|
|
}
|
|
|
|
let strictArgs = true;
|
|
if (rawStrictArgs !== undefined) {
|
|
if (typeof rawStrictArgs !== 'boolean') throw new Error(`Macro "${name}" options.strictArgs must be a boolean when provided.`);
|
|
strictArgs = rawStrictArgs;
|
|
}
|
|
|
|
let description = '<no description>';
|
|
if (rawDescription !== undefined) {
|
|
if (typeof rawDescription !== 'string') throw new Error(`Macro "${name}" options.description must be a string when provided.`);
|
|
description = rawDescription;
|
|
}
|
|
|
|
let returns = null;
|
|
if (rawReturns !== undefined && rawReturns !== null) {
|
|
if (typeof rawReturns !== 'string') throw new Error(`Macro "${name}" options.returns must be a string when provided.`);
|
|
returns = rawReturns || '<empty string>';
|
|
}
|
|
|
|
// Process and validate returnType (defaults to 'string')
|
|
const validTypes = ['string', 'integer', 'number', 'boolean'];
|
|
let returnType = /** @type {MacroValueType|MacroValueType[]} */ ('string');
|
|
if (rawReturnType !== undefined && rawReturnType !== null) {
|
|
// Normalize to non-empty value or default
|
|
returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType;
|
|
// Validate all types
|
|
const typesToValidate = Array.isArray(returnType) ? returnType : [returnType];
|
|
if (typesToValidate.some(t => !validTypes.includes(t))) {
|
|
throw new Error(`Macro "${name}" options.returnType must be one of "string", "integer", "number", or "boolean" (or an array of these) when provided.`);
|
|
}
|
|
}
|
|
|
|
let displayOverride = null;
|
|
if (rawDisplayOverride !== undefined && rawDisplayOverride !== null) {
|
|
if (typeof rawDisplayOverride !== 'string') throw new Error(`Macro "${name}" options.displayOverride must be a string when provided.`);
|
|
displayOverride = rawDisplayOverride.trim();
|
|
if (displayOverride && !displayOverride.startsWith('{{')) {
|
|
logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.displayOverride should include curly braces. Auto-wrapping.` });
|
|
displayOverride = `{{${displayOverride}}}`;
|
|
}
|
|
}
|
|
|
|
/** @type {string[]} */
|
|
let exampleUsage = [];
|
|
if (rawExampleUsage !== undefined && rawExampleUsage !== null) {
|
|
const examples = Array.isArray(rawExampleUsage) ? rawExampleUsage : [rawExampleUsage];
|
|
for (const [i, ex] of examples.entries()) {
|
|
if (typeof ex !== 'string') throw new Error(`Macro "${name}" options.exampleUsage[${i}] must be a string.`);
|
|
let trimmed = ex.trim();
|
|
if (trimmed && !trimmed.startsWith('{{')) {
|
|
logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" options.exampleUsage[${i}] should include curly braces. Auto-wrapping.` });
|
|
trimmed = `{{${trimmed}}}`;
|
|
}
|
|
if (trimmed) exampleUsage.push(trimmed);
|
|
}
|
|
}
|
|
|
|
if (this.#macros.has(name)) {
|
|
logMacroRegisterWarning({ macroName: name, message: `Macro "${name}" is already registered and will be overwritten.` });
|
|
}
|
|
|
|
// Detect extension/third-party status from call stack
|
|
const { isExtension, isThirdParty, source } = detectMacroSource();
|
|
|
|
/** @type {MacroDefinition} */
|
|
const definition = {
|
|
name: name,
|
|
aliases,
|
|
category,
|
|
minArgs,
|
|
maxArgs,
|
|
unnamedArgDefs,
|
|
list,
|
|
strictArgs,
|
|
description,
|
|
returns,
|
|
returnType,
|
|
displayOverride,
|
|
exampleUsage,
|
|
handler,
|
|
source: {
|
|
name: source,
|
|
isExtension,
|
|
isThirdParty,
|
|
},
|
|
aliasOf: null,
|
|
aliasVisible: null,
|
|
};
|
|
|
|
this.#macros.set(name, definition);
|
|
|
|
// Register alias entries pointing to the same definition
|
|
for (const { alias, visible } of aliases) {
|
|
if (this.#macros.has(alias)) {
|
|
logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` });
|
|
}
|
|
/** @type {MacroDefinition} */
|
|
const aliasEntry = {
|
|
...definition,
|
|
name: alias, // The lookup name is the alias
|
|
aliasOf: name,
|
|
aliasVisible: visible,
|
|
};
|
|
this.#macros.set(alias, aliasEntry);
|
|
}
|
|
|
|
return definition;
|
|
} catch (error) {
|
|
logMacroRegisterError({
|
|
message: `Failed to register macro "${name}". The macro will not be available.`,
|
|
macroName: name,
|
|
error,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unregisters a macro.
|
|
*
|
|
* @param {string} name - Macro name (identifier).
|
|
* @returns {boolean} True if a macro was removed.
|
|
*/
|
|
unregisterMacro(name) {
|
|
if (typeof name !== 'string' || !name.trim()) throw new Error('Macro name must be a non-empty string');
|
|
name = name.trim();
|
|
return this.#macros.delete(name);
|
|
}
|
|
|
|
/**
|
|
* Checks whether a macro with the given name is registered.
|
|
*
|
|
* @param {string} name - Macro name (identifier).
|
|
* @returns {boolean}
|
|
*/
|
|
hasMacro(name) {
|
|
if (typeof name !== 'string' || !name.trim()) return false;
|
|
name = name.trim();
|
|
return this.#macros.has(name);
|
|
}
|
|
|
|
/**
|
|
* Returns the macro definition for a given name.
|
|
*
|
|
* @param {string} name - Macro name (identifier).
|
|
* @returns {MacroDefinition|undefined}
|
|
*/
|
|
getMacro(name) {
|
|
if (typeof name !== 'string' || !name.trim()) return undefined;
|
|
name = name.trim();
|
|
return this.#macros.get(name);
|
|
}
|
|
|
|
/**
|
|
* Returns the primary (non-alias) definition for a macro.
|
|
* If given an alias name, returns the primary definition it points to.
|
|
*
|
|
* @param {string} name - Macro name or alias.
|
|
* @returns {MacroDefinition|undefined}
|
|
*/
|
|
getPrimaryMacro(name) {
|
|
const def = this.getMacro(name);
|
|
if (!def) return undefined;
|
|
return def.aliasOf ? this.getMacro(def.aliasOf) : def;
|
|
}
|
|
|
|
/**
|
|
* Returns an array of all registered macros.
|
|
*
|
|
* @param {Object} [options] - Filter options.
|
|
* @param {boolean} [options.excludeAliases=false] - If true, excludes alias entries (only returns primary definitions).
|
|
* @param {boolean} [options.excludeHiddenAliases=false] - If true, excludes alias entries where visible=false.
|
|
* @returns {MacroDefinition[]}
|
|
*/
|
|
getAllMacros({ excludeAliases = false, excludeHiddenAliases = false } = {}) {
|
|
let macros = Array.from(this.#macros.values());
|
|
if (excludeAliases) {
|
|
macros = macros.filter(m => !m.aliasOf);
|
|
} else if (excludeHiddenAliases) {
|
|
macros = macros.filter(m => !m.aliasOf || m.aliasVisible !== false);
|
|
}
|
|
return macros;
|
|
}
|
|
|
|
/**
|
|
* Executes a macro for a given call.
|
|
*
|
|
* @param {MacroCall} call - Macro call information.
|
|
* @param {Object} [options] - Additional options.
|
|
* @param {MacroDefinition} [options.defOverride] - Override the macro definition.
|
|
* @returns {string}
|
|
*/
|
|
executeMacro(call, { defOverride } = {}) {
|
|
const name = call.name;
|
|
const def = defOverride || this.getMacro(name);
|
|
if (!def) {
|
|
throw new Error(`Macro "${name}" is not registered`);
|
|
}
|
|
|
|
const args = Array.isArray(call.args) ? call.args : [];
|
|
|
|
if (!isArgsValid(def, args)) {
|
|
const expectedMin = def.list ? def.minArgs + def.list.min : def.minArgs;
|
|
const expectedMax = def.list && def.list.max !== null
|
|
? def.maxArgs + def.list.max
|
|
: (def.list ? null : def.maxArgs);
|
|
|
|
const expectation = (() => {
|
|
if (expectedMax !== null && expectedMax !== expectedMin) return `between ${expectedMin} and ${expectedMax}`;
|
|
if (expectedMax !== null && expectedMax === expectedMin) return `${expectedMin}`;
|
|
return `at least ${expectedMin}`;
|
|
})();
|
|
|
|
const message = `Macro "${def.name}" called with ${args.length} unnamed arguments but expects ${expectation}.`;
|
|
if (def.strictArgs) {
|
|
throw createMacroRuntimeError({ message, call, def });
|
|
}
|
|
logMacroRuntimeWarning({ message, call, def });
|
|
}
|
|
|
|
// Compute unnamed args (required + optional, up to maxArgs)
|
|
const unnamedArgsCount = Math.min(args.length, def.maxArgs);
|
|
const unnamedArgsValues = args.slice(0, unnamedArgsCount);
|
|
const listValues = !def.list ? null : args.length > def.maxArgs ? args.slice(def.maxArgs) : [];
|
|
|
|
// Perform best-effort type validation for documented positional arguments.
|
|
// This can throw an error if the arguments are invalid.
|
|
validateArgTypes(call, def, unnamedArgsValues);
|
|
|
|
const namedArgs = null;
|
|
|
|
/** @type {MacroExecutionContext} */
|
|
const executionContext = {
|
|
name: def.name,
|
|
args,
|
|
unnamedArgs: unnamedArgsValues,
|
|
list: listValues,
|
|
namedArgs,
|
|
raw: call.rawInner,
|
|
env: call.env,
|
|
cstNode: call.cstNode,
|
|
range: call.range,
|
|
normalize: MacroEngine.normalizeMacroResult.bind(MacroEngine),
|
|
};
|
|
|
|
const result = def.handler(executionContext);
|
|
return executionContext.normalize(result);
|
|
}
|
|
}
|
|
|
|
instance = MacroRegistry.instance;
|
|
|
|
/**
|
|
* Validates the arguments for a macro definition.
|
|
* Supports required args (minArgs), optional args (up to maxArgs), and list tail.
|
|
*
|
|
* @param {MacroDefinition} def - Macro definition.
|
|
* @param {any[]} args - Arguments to validate.
|
|
* @returns {boolean} True if the arguments are valid, false otherwise.
|
|
*/
|
|
function isArgsValid(def, args) {
|
|
const hasListArgs = def.list !== null;
|
|
|
|
// Without list: args must be between minArgs and maxArgs (inclusive)
|
|
if (!hasListArgs) {
|
|
return args.length >= def.minArgs && args.length <= def.maxArgs;
|
|
}
|
|
|
|
// With list: args must be at least minArgs + list.min
|
|
const minRequired = def.minArgs + def.list.min;
|
|
if (args.length < minRequired) return false;
|
|
|
|
// List items are everything after maxArgs positional slots
|
|
const listCount = Math.max(0, args.length - def.maxArgs);
|
|
if (def.list.max !== null && listCount > def.list.max) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Performs type validation for unnamed positional arguments using the metadata
|
|
* defined on the macro definition. When strictArgs is true, invalid argument
|
|
* types cause an error to be thrown. When strictArgs is false, only warnings
|
|
* are logged and execution continues.
|
|
*
|
|
* @param {MacroCall} call
|
|
* @param {MacroDefinition} def
|
|
* @param {string[]} unnamedArgs
|
|
*/
|
|
function validateArgTypes(call, def, unnamedArgs) {
|
|
if (def.unnamedArgDefs.length === 0) return;
|
|
|
|
const defs = def.unnamedArgDefs;
|
|
const count = Math.min(defs.length, unnamedArgs.length);
|
|
for (let i = 0; i < count; i++) {
|
|
const argDef = defs[i];
|
|
const value = unnamedArgs[i];
|
|
if (!argDef || !argDef.type || typeof value !== 'string') {
|
|
// Misconfigured macro definition: always surface as an error.
|
|
throw new Error(`Macro "${call.name}" (position ${i + 1}) has invalid definition or type.`);
|
|
}
|
|
|
|
const types = Array.isArray(argDef.type) ? argDef.type : [argDef.type];
|
|
if (!types.some(type => isValueOfType(value, type))) {
|
|
const argName = argDef.name || `Argument ${i + 1}`;
|
|
const optionalLabel = argDef.optional ? ' (optional)' : '';
|
|
const message = `Macro "${call.name}" (position ${i + 1}${optionalLabel}) argument "${argName}" expected type ${argDef.type} but got value "${value}".`;
|
|
if (def.strictArgs) {
|
|
throw createMacroRuntimeError({ message, call, def: def });
|
|
}
|
|
logMacroRuntimeWarning({ message, call, def: def });
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether a string value conforms to the given macro argument type.
|
|
*
|
|
* @param {string} value
|
|
* @param {MacroValueType} type
|
|
* @returns {boolean}
|
|
*/
|
|
function isValueOfType(value, type) {
|
|
const trimmed = value.trim();
|
|
|
|
if (type === 'string') {
|
|
return true;
|
|
}
|
|
if (type === 'integer') {
|
|
return /^-?\d+$/.test(trimmed);
|
|
}
|
|
if (type === 'number') {
|
|
const n = Number(trimmed);
|
|
return Number.isFinite(n);
|
|
}
|
|
if (type === 'boolean') {
|
|
return isTrueBoolean(trimmed) || isFalseBoolean(trimmed);
|
|
}
|
|
|
|
// Unknown type: treat it as invalid.
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detects the source of a macro registration from the call stack.
|
|
* Similar to how SlashCommandParser detects command sources.
|
|
*
|
|
* @returns {{ isExtension: boolean, isThirdParty: boolean, source: string }}
|
|
*/
|
|
function detectMacroSource() {
|
|
const stack = new Error().stack?.split('\n').map(line => line.trim()) ?? [];
|
|
|
|
const isExtension = stack.some(line => line.includes('/scripts/extensions/'));
|
|
const isThirdParty = stack.some(line => line.includes('/scripts/extensions/third-party/'));
|
|
|
|
let source = 'unknown';
|
|
if (isThirdParty) {
|
|
const match = stack.find(line => line.includes('/scripts/extensions/third-party/'));
|
|
if (match) {
|
|
source = match.replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1');
|
|
}
|
|
} else if (isExtension) {
|
|
const match = stack.find(line => line.includes('/scripts/extensions/'));
|
|
if (match) {
|
|
source = match.replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1');
|
|
}
|
|
} else {
|
|
// Find the first meaningful caller outside MacroRegistry
|
|
const callerIdx = stack.findIndex(line =>
|
|
line.includes('registerMacro') && line.includes('MacroRegistry'),
|
|
);
|
|
if (callerIdx >= 0 && callerIdx + 1 < stack.length) {
|
|
const callerLine = stack[callerIdx + 1];
|
|
// Extract script path from stack frame
|
|
const scriptMatch = callerLine.match(/\/((?:scripts\/)?(?:macros\/)?[^/]+\.js)/);
|
|
if (scriptMatch) {
|
|
source = scriptMatch[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
return { isExtension, isThirdParty, source };
|
|
}
|