File size: 8,117 Bytes
6efa67a |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
import { MacroParser } from './MacroParser.js';
import { MacroCstWalker } from './MacroCstWalker.js';
import { MacroRegistry } from './MacroRegistry.js';
import { logMacroGeneralError, logMacroInternalError, logMacroRuntimeWarning, logMacroSyntaxWarning } from './MacroDiagnostics.js';
/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
/** @typedef {import('./MacroEnv.types.js').MacroEnv} MacroEnv */
/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
/**
* The singleton instance of the MacroEngine.
*
* @type {MacroEngine}
*/
let instance;
export { instance as MacroEngine };
class MacroEngine {
/** @type {MacroEngine} */ static #instance;
/** @type {MacroEngine} */ static get instance() { return MacroEngine.#instance ?? (MacroEngine.#instance = new MacroEngine()); }
constructor() { }
/**
* Evaluates a string containing macros and resolves them.
*
* @param {string} input - The input string to evaluate.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The resolved string.
*/
evaluate(input, env) {
if (!input) {
return '';
}
const safeEnv = Object.freeze({ ...env });
const preProcessed = this.#runPreProcessors(input, safeEnv);
const { cst, lexingErrors, parserErrors } = MacroParser.parseDocument(preProcessed);
// For now, we log and still try to process what we can.
if (lexingErrors && lexingErrors.length > 0) {
logMacroSyntaxWarning({ phase: 'lexing', input, errors: lexingErrors });
}
if (parserErrors && parserErrors.length > 0) {
logMacroSyntaxWarning({ phase: 'parsing', input, errors: parserErrors });
}
// If the parser did not produce a valid CST, fall back to the original input.
if (!cst || typeof cst !== 'object' || !cst.children) {
logMacroGeneralError({ message: 'Macro parser produced an invalid CST. Returning original input.', error: { input, lexingErrors, parserErrors } });
return input;
}
let evaluated;
try {
evaluated = MacroCstWalker.evaluateDocument({
text: preProcessed,
cst,
env: safeEnv,
resolveMacro: this.#resolveMacro.bind(this),
});
} catch (error) {
logMacroGeneralError({ message: 'Macro evaluation failed. Returning original input.', error: { input, error } });
return input;
}
const result = this.#runPostProcessors(evaluated, safeEnv);
return result;
}
/**
* Resolves a macro call.
*
* @param {MacroCall} call - The macro call to resolve.
* @returns {string} The resolved macro.
*/
#resolveMacro(call) {
const { name, env } = call;
const raw = `{{${call.rawInner}}}`;
if (!name) return raw;
// 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.
/** @type {MacroDefinition?} */
let defOverride = null;
if (Object.hasOwn(env.dynamicMacros, name)) {
const impl = env.dynamicMacros[name];
defOverride = {
name,
aliases: [],
category: 'dynamic',
description: 'Dynamic macro',
minArgs: 0,
maxArgs: 0,
unnamedArgDefs: [],
list: null,
strictArgs: true, // Fail dynamic macros if they are called with arguments
returns: null,
returnType: 'string',
displayOverride: null,
exampleUsage: [],
source: { name: 'dynamic', isExtension: false, isThirdParty: false },
aliasOf: null,
aliasVisible: null,
handler: typeof impl === 'function' ? impl : () => impl,
};
}
// If not, check if the macro exists and is registered
if (!defOverride && !MacroRegistry.hasMacro(name)) {
return raw; // Unknown macro: keep macro syntax, but nested macros inside rawInner are already resolved.
}
try {
const result = MacroRegistry.executeMacro(call, { defOverride });
try {
return call.env.functions.postProcess(result);
} catch (error) {
logMacroInternalError({ message: `Macro "${name}" postProcess function failed.`, call, error });
return result;
}
} catch (error) {
const isRuntimeError = !!(error && (error.name === 'MacroRuntimeError' || error.isMacroRuntimeError));
if (isRuntimeError) {
logMacroRuntimeWarning({ message: (error.message || `Macro "${name}" execution failed.`), call, error });
} else {
logMacroInternalError({ message: `Macro "${name}" internal execution error.`, call, error });
}
return raw;
}
}
/**
* Runs pre-processors on the input text, before the engine processes the input.
*
* @param {string} text - The input text to process.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The processed text.
*/
#runPreProcessors(text, env) {
let result = text;
// This legacy macro will not be supported by the new macro parser, but rather regex-replaced beforehand
// {{time_UTC-10}} => {{time::UTC-10}}
result = result.replace(/{{time_(UTC[+-]\d+)}}/gi, (_match, utcOffset) => {
return `{{time::${utcOffset}}}`;
});
// Legacy non-curly markers like <USER>, <BOT>, <GROUP>, etc.
// These are rewritten into their equivalent macro forms so they go through the normal engine pipeline.
result = result.replace(/<USER>/gi, '{{user}}');
result = result.replace(/<BOT>/gi, '{{char}}');
result = result.replace(/<CHAR>/gi, '{{char}}');
result = result.replace(/<GROUP>/gi, '{{group}}');
result = result.replace(/<CHARIFNOTGROUP>/gi, '{{charIfNotGroup}}');
return result;
}
/**
* Runs post-processors on the input text, after the engine finished processing the input.
*
* @param {string} text - The input text to process.
* @param {MacroEnv} env - The environment to pass to the macro handler.
* @returns {string} The processed text.
*/
#runPostProcessors(text, env) {
let result = text;
// Unescape braces: \{ → { and \} → }
// Since \{\{ doesn't match {{ (MacroStart), it passes through as plain text.
// We only need to remove the backslashes in post-processing.
result = result.replace(/\\([{}])/g, '$1');
// The original trim macro is reaching over the boundaries of the defined macro. This is not something the engine supports.
// To treat {{trim}} as it was before, we won't process it by the engine itself,
// but doing a regex replace on {{trim}} and the surrounding area, after all other macros have been processed.
result = result.replace(/(?:\r?\n)*{{trim}}(?:\r?\n)*/gi, '');
return result;
}
/**
* Normalizes macro results into a string.
* This mirrors the behavior of the legacy macro system in a simplified way.
*
* @param {any} value
* @returns {string}
*/
normalizeMacroResult(value) {
if (value === null || value === undefined) {
return '';
}
if (value instanceof Date) {
return value.toISOString();
}
if (typeof value === 'object' || Array.isArray(value)) {
try {
return JSON.stringify(value);
} catch (_error) {
return String(value);
}
}
return String(value);
}
}
instance = MacroEngine.instance;
|