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;