|
|
|
|
|
|
|
|
|
|
|
|
|
|
import { isFalseBoolean, isTrueBoolean } from '../../utils.js'; |
|
|
import { MacroEngine } from './MacroEngine.js'; |
|
|
import { createMacroRuntimeError, logMacroRegisterError, logMacroRegisterWarning, logMacroRuntimeWarning } from './MacroDiagnostics.js'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const MacroCategory = Object.freeze({ |
|
|
|
|
|
UTILITY: 'utility', |
|
|
|
|
|
RANDOM: 'random', |
|
|
|
|
|
NAMES: 'names', |
|
|
|
|
|
CHARACTER: 'character', |
|
|
|
|
|
CHAT: 'chat', |
|
|
|
|
|
TIME: 'time', |
|
|
|
|
|
VARIABLE: 'variable', |
|
|
|
|
|
PROMPTS: 'prompts', |
|
|
|
|
|
STATE: 'state', |
|
|
|
|
|
MISC: 'misc', |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const MacroValueType = Object.freeze({ |
|
|
|
|
|
STRING: 'string', |
|
|
|
|
|
INTEGER: 'integer', |
|
|
|
|
|
NUMBER: 'number', |
|
|
|
|
|
BOOLEAN: 'boolean', |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let instance; |
|
|
export { instance as MacroRegistry }; |
|
|
|
|
|
class MacroRegistry { |
|
|
static #instance; |
|
|
static get instance() { return MacroRegistry.#instance ?? (MacroRegistry.#instance = new MacroRegistry()); } |
|
|
|
|
|
|
|
|
#macros; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
constructor() { |
|
|
|
|
|
this.#macros = new Map(); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
registerMacro(name, options) { |
|
|
|
|
|
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.`); |
|
|
|
|
|
|
|
|
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; |
|
|
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; |
|
|
|
|
|
let unnamedArgDefs = []; |
|
|
if (rawUnnamedArgs !== undefined) { |
|
|
if (Array.isArray(rawUnnamedArgs)) { |
|
|
|
|
|
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.`); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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; |
|
|
}); |
|
|
|
|
|
|
|
|
maxArgs = unnamedArgDefs.length; |
|
|
minArgs = unnamedArgDefs.findIndex(d => d.optional); |
|
|
if (minArgs === -1) minArgs = maxArgs; |
|
|
} 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.`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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>'; |
|
|
} |
|
|
|
|
|
|
|
|
const validTypes = ['string', 'integer', 'number', 'boolean']; |
|
|
let returnType = ('string'); |
|
|
if (rawReturnType !== undefined && rawReturnType !== null) { |
|
|
|
|
|
returnType = Array.isArray(rawReturnType) && rawReturnType.length === 0 ? 'string' : rawReturnType; |
|
|
|
|
|
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}}}`; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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.` }); |
|
|
} |
|
|
|
|
|
|
|
|
const { isExtension, isThirdParty, source } = detectMacroSource(); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
for (const { alias, visible } of aliases) { |
|
|
if (this.#macros.has(alias)) { |
|
|
logMacroRegisterWarning({ macroName: name, message: `Alias "${alias}" for macro "${name}" overwrites an existing macro.` }); |
|
|
} |
|
|
|
|
|
const aliasEntry = { |
|
|
...definition, |
|
|
name: 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; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
hasMacro(name) { |
|
|
if (typeof name !== 'string' || !name.trim()) return false; |
|
|
name = name.trim(); |
|
|
return this.#macros.has(name); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getMacro(name) { |
|
|
if (typeof name !== 'string' || !name.trim()) return undefined; |
|
|
name = name.trim(); |
|
|
return this.#macros.get(name); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getPrimaryMacro(name) { |
|
|
const def = this.getMacro(name); |
|
|
if (!def) return undefined; |
|
|
return def.aliasOf ? this.getMacro(def.aliasOf) : def; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 }); |
|
|
} |
|
|
|
|
|
|
|
|
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) : []; |
|
|
|
|
|
|
|
|
|
|
|
validateArgTypes(call, def, unnamedArgsValues); |
|
|
|
|
|
const namedArgs = null; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function isArgsValid(def, args) { |
|
|
const hasListArgs = def.list !== null; |
|
|
|
|
|
|
|
|
if (!hasListArgs) { |
|
|
return args.length >= def.minArgs && args.length <= def.maxArgs; |
|
|
} |
|
|
|
|
|
|
|
|
const minRequired = def.minArgs + def.list.min; |
|
|
if (args.length < minRequired) return false; |
|
|
|
|
|
|
|
|
const listCount = Math.max(0, args.length - def.maxArgs); |
|
|
if (def.list.max !== null && listCount > def.list.max) return false; |
|
|
|
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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') { |
|
|
|
|
|
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 }); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
return false; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
const callerIdx = stack.findIndex(line => |
|
|
line.includes('registerMacro') && line.includes('MacroRegistry'), |
|
|
); |
|
|
if (callerIdx >= 0 && callerIdx + 1 < stack.length) { |
|
|
const callerLine = stack[callerIdx + 1]; |
|
|
|
|
|
const scriptMatch = callerLine.match(/\/((?:scripts\/)?(?:macros\/)?[^/]+\.js)/); |
|
|
if (scriptMatch) { |
|
|
source = scriptMatch[1]; |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
return { isExtension, isThirdParty, source }; |
|
|
} |
|
|
|