Blame Raw
· · · 242 lines (8.9 KB)
0 contributors
1/** @typedef {import('./MacroCstWalker.js').MacroCall} MacroCall */
2/** @typedef {import('./MacroRegistry.js').MacroDefinition} MacroDefinition */
3/** @typedef {import('chevrotain').ILexingError} ILexingError */
4/** @typedef {import('chevrotain').IRecognitionException} IRecognitionException */
5
6import { t } from '/scripts/i18n.js';
7import { Popup, POPUP_RESULT } from '/scripts/popup.js';
8import { power_user } from '/scripts/power-user.js';
9import { accountStorage } from '/scripts/util/AccountStorage.js';
10import { SimpleMutex } from '/scripts/util/SimpleMutex.js';
11
12/**
13 * @typedef {Object} MacroErrorContext
14 * @property {string} [macroName]
15 * @property {MacroCall} [call]
16 * @property {MacroDefinition} [def]
17 */
18
19/**
20 * Options for creating a macro runtime error.
21 *
22 * @typedef {MacroErrorContext & { message: string }} MacroRuntimeErrorOptions
23 */
24
25/**
26 * Options for logging macro warnings or errors.
27 *
28 * @typedef {MacroErrorContext & { message: string, error?: any }} MacroLogOptions
29 */
30
31
32// Use mutex here so even on parallel usage without awaiting the popup, this will only show up once.
33export const onboardingExperimentalMacroEngineMutex = new SimpleMutex(onboardingExperimentalMacroEngineUnsafe);
34
35/**
36 * Onboards the user to use the experimental macro engine.
37 * Asks the user to enable it if they haven't already.
38 *
39 * @param {string|null} feature - The feature that requires the experimental macro engine, or null if not applicable or unknown.
40 * @returns {Promise<void>} - A promise that resolves when the user has been onboarded.
41 */
42export const onboardingExperimentalMacroEngine = onboardingExperimentalMacroEngineMutex.update.bind(onboardingExperimentalMacroEngineMutex);
43
44async function onboardingExperimentalMacroEngineUnsafe(feature = null) {
45 // Show a popup once telling a user that they are using experimental features that only work with the new engine.
46 // Ask them if they want to turn the experimental engine on.
47 if (power_user.experimental_macro_engine) return;
48
49 // If already shown, do not show again
50 const shown = accountStorage.getItem('slash_command_experimental_engine_warning_shown');
51 if (shown === 'true') return;
52
53 const result = await Popup.show.confirm(t`Experimental Macro Engine`, `
54 <p>${t`You are using experimental macro features that require the new macro engine.`}</p>
55 ${feature ? `<div class="info-block hint">
56 <span>${t`Recognized Feature: `}<strong>${feature}</strong></span>
57 </div>` : ''}
58 <p>${t`For more information on the new macro engine, visit the <br />${`<a href="https://docs.sillytavern.app/usage/core-concepts/macros/">${t`Macro Documentation`}</a>`}.`}</p>
59 <p>${t`You can enable the engine any time under:<br />${t`User Settings`} → ${t`Experimental Macro Engine`}`}</p>
60 <p>${t`Would you like to enable it now?`}</p>`);
61 if (result == POPUP_RESULT.AFFIRMATIVE) {
62 power_user.experimental_macro_engine = true;
63 $('#experimental_macro_engine').prop('checked', power_user.experimental_macro_engine).trigger('input');
64 }
65
66 // Only show this once
67 accountStorage.setItem('slash_command_experimental_engine_warning_shown', 'true');
68}
69
70/**
71 * Creates an error representing a runtime macro invocation problem (such as
72 * arity or type mismatches). These errors are intended to be caught by the
73 * MacroEngine, which will log them as runtime warnings and leave the macro
74 * raw in the evaluated text.
75 *
76 * @param {MacroRuntimeErrorOptions} options
77 * @returns {Error}
78 */
79export function createMacroRuntimeError({ message, call, def, macroName }) {
80 const inferredName = inferMacroName(call, def, macroName);
81
82 const error = new Error(message);
83 error.name = 'MacroRuntimeError';
84 // @ts-ignore - custom tagging for downstream classification
85 error.isMacroRuntimeError = true;
86 // @ts-ignore - helpful metadata for debugging
87 error.macroName = inferredName;
88 // @ts-ignore - best-effort location information
89 error.macroRange = call && call.range ? call.range : null;
90 // @ts-ignore - attach raw call/definition for convenience
91 if (call) error.macroCall = call;
92 // @ts-ignore
93 if (def) error.macroDefinition = def;
94
95 return error;
96}
97
98/**
99 * Logs a macro runtime warning with consistent, helpful context. These
100 * correspond to issues in how a macro was written in the text (e.g. invalid
101 * arguments), not bugs in macro definitions or the engine itself.
102 *
103 * @param {MacroLogOptions} options
104 */
105export function logMacroRuntimeWarning({ message, call, def, macroName, error }) {
106 const payload = buildMacroPayload({ call, def, macroName, error });
107 console.warn('[Macro] Warning:', message, payload);
108}
109
110/**
111 * Logs an internal macro error (definition or engine bug) with a consistent
112 * schema. These are surfaced as red errors in the console.
113 *
114 * @param {MacroLogOptions} options
115 */
116export function logMacroInternalError({ message, call, macroName, error }) {
117 const payload = buildMacroPayload({ call, def: undefined, macroName, error });
118 console.error('[Macro] Error:', message, payload);
119}
120
121/**
122 * Logs a warning during macro registration.
123 *
124 * @param {{ message: string, macroName?: string, error?: any }} options
125 */
126export function logMacroRegisterWarning({ message, macroName, error = undefined }) {
127 const payload = buildMacroPayload({ macroName, error });
128 console.warn('[Macro] Warning:', message, payload);
129}
130
131/**
132 * Logs an error during macro registration. Used when registration fails
133 * and the macro will not be available.
134 *
135 * @param {{ message: string, macroName?: string, error?: any }} options
136 */
137export function logMacroRegisterError({ message, macroName, error = undefined }) {
138 const payload = buildMacroPayload({ macroName, error });
139 console.error('[Macro] Registration Error:', message, payload);
140}
141
142/**
143 * Logs a macro error with a consistent schema.
144 *
145 * @param {{ message: string, error?: any }} options
146 */
147export function logMacroGeneralError({ message, error }) {
148 console.error('[Macro] Error:', message, error);
149}
150
151/**
152 * Logs lexer/parser syntax warnings for the macro engine with a compact,
153 * human-readable payload.
154 *
155 * @param {{ phase: 'lexing', input: string, errors: ILexingError[] }|{ phase: 'parsing', input: string, errors: IRecognitionException[] }} options
156 */
157export function logMacroSyntaxWarning({ phase, input, errors }) {
158 if (!errors || errors.length === 0) {
159 return;
160 }
161
162 /** @type {{ message: string, line: number|null, column: number|null, length: number|null }[]} */
163 const issues = errors.map((err) => {
164 const hasOwnLine = typeof err.line === 'number';
165 const hasOwnColumn = typeof err.column === 'number';
166
167 const token = /** @type {{ startLine?: number, startColumn?: number, startOffset?: number, endOffset?: number }|undefined} */ (err.token);
168
169 const line = hasOwnLine ? err.line : (token && typeof token.startLine === 'number' ? token.startLine : null);
170 const column = hasOwnColumn ? err.column : (token && typeof token.startColumn === 'number' ? token.startColumn : null);
171
172 /** @type {number|null} */
173 let length = null;
174 if (typeof err.length === 'number') {
175 length = err.length;
176 } else if (token && typeof token.startOffset === 'number' && typeof token.endOffset === 'number') {
177 length = token.endOffset - token.startOffset + 1;
178 }
179
180 return {
181 message: err.message,
182 line,
183 column,
184 length,
185 };
186 });
187
188 const label = phase === 'lexing' ? 'Lexing' : 'Parsing';
189
190 /** @type {Record<string, any>} */
191 const payload = {
192 phase,
193 count: issues.length,
194 issues,
195 input,
196 };
197
198 console.warn('[Macro] Warning:', `${label} errors detected`, payload);
199}
200
201/**
202 * Builds a structured payload for macro logging.
203 *
204 * @param {MacroErrorContext & { error?: any }} ctx
205 */
206function buildMacroPayload({ call, def, macroName, error }) {
207 const inferredName = inferMacroName(call, def, macroName);
208
209 /** @type {Record<string, any>} */
210 const payload = {
211 macroName: inferredName,
212 };
213
214 if (call && call.range) payload.range = call.range;
215 if (call && typeof call.rawInner === 'string') payload.raw = call.rawInner;
216 if (call) payload.call = call;
217 if (def) payload.def = def;
218 if (error) payload.error = error;
219
220 return payload;
221}
222
223/**
224 * Infers the most appropriate macro name from the available context.
225 *
226 * @param {MacroCall} [call]
227 * @param {MacroDefinition} [def]
228 * @param {string} [explicit]
229 * @returns {string}
230 */
231function inferMacroName(call, def, explicit) {
232 if (typeof explicit === 'string' && explicit.trim()) {
233 return explicit.trim();
234 }
235 if (call && typeof call.name === 'string' && call.name.trim()) {
236 return call.name.trim();
237 }
238 if (def && typeof def.name === 'string' && def.name.trim()) {
239 return def.name.trim();
240 }
241 return 'unknown';
242}