Blame Raw
Cohee · e3f41666 · · 634 lines (28.0 KB)
1 contributor
1import { substituteParams } from '../../script.js';
2import { power_user } from '../power-user.js';
3import { delay, escapeRegex, uuidv4 } from '../utils.js';
4import { SlashCommand } from './SlashCommand.js';
5import { SlashCommandAbortController } from './SlashCommandAbortController.js';
6import { SlashCommandBreak } from './SlashCommandBreak.js';
7import { SlashCommandBreakController } from './SlashCommandBreakController.js';
8import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
9import { SlashCommandClosureResult } from './SlashCommandClosureResult.js';
10import { SlashCommandDebugController } from './SlashCommandDebugController.js';
11import { SlashCommandExecutionError } from './SlashCommandExecutionError.js';
12import { SlashCommandExecutor } from './SlashCommandExecutor.js';
13import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
14import { SlashCommandScope } from './SlashCommandScope.js';
15
16export class SlashCommandClosure {
17 /** @type {SlashCommandScope} */ scope;
18 /** @type {boolean} */ executeNow = false;
19 /** @type {SlashCommandNamedArgumentAssignment[]} */ argumentList = [];
20 /** @type {SlashCommandNamedArgumentAssignment[]} */ providedArgumentList = [];
21 /** @type {SlashCommandExecutor[]} */ executorList = [];
22 /** @type {SlashCommandAbortController} */ abortController;
23 /** @type {SlashCommandBreakController} */ breakController;
24 /** @type {SlashCommandDebugController} */ debugController;
25 /** @type {(done:number, total:number)=>void} */ onProgress;
26 /** @type {string} */ rawText;
27 /** @type {string} */ fullText;
28 /** @type {string} */ parserContext;
29 /** @type {string} */ #source = uuidv4();
30 get source() { return this.#source; }
31 set source(value) {
32 this.#source = value;
33 for (const executor of this.executorList) {
34 executor.source = value;
35 }
36 }
37
38 /**@type {number}*/
39 get commandCount() {
40 return this.executorList.map(executor => executor.commandCount).reduce((sum, cur) => sum + cur, 0);
41 }
42
43 constructor(parent) {
44 this.scope = new SlashCommandScope(parent);
45 }
46
47 toString() {
48 return `[Closure]${this.executeNow ? '()' : ''}`;
49 }
50
51 /**
52 * Performs parameter substitution using the macro engine.
53 * @param {string} text Text to substitute
54 * @param {SlashCommandScope} scope Script scope
55 * @param {{key:string, value:string|SlashCommandClosure}[]} macroList Custom scope macros
56 * @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]} Substituted text or list of strings/closures
57 */
58 substituteWithMacroEngine(text, scope, macroList) {
59 /** @type {Record<string, import('./../macros/engine/MacroEnv.types.js').DynamicMacroValue>} */
60 const dynamicMacros = {
61 'pipe': () => scope.pipe,
62 'var': {
63 strictArgs: false,
64 list: { min: 1, max: 2 },
65 handler: (context) => {
66 try {
67 // NB: Legacy replacer halted the script execution on unknown variables
68 return scope.getVariable(context.list[0], context.list[1]);
69 } catch (error) {
70 console.warn('{{var}} dynamic macro execution error:', error);
71 return '';
72 }
73 },
74 },
75 };
76
77 // Special marker to denote closures in the substituted text
78 const CLOSURE_BOUNDARY = '\uFFF0~CLOSURE~\uFFF0';
79 /** @type {Map<string, SlashCommandClosure>} */
80 const closures = new Map();
81 /** @type {Record<string, { args: string[], value: string|SlashCommandClosure }[]>} */
82 const customMacros = {};
83
84 for (const macro of macroList) {
85 const [name, ...rest] = macro.key.split('::');
86 if (!Object.hasOwn(customMacros, name)) {
87 customMacros[name] = [];
88 }
89 customMacros[name].push({ args: rest, value: macro.value });
90 }
91
92 for (const [macroName, macroArguments] of Object.entries(customMacros)) {
93 dynamicMacros[macroName] = {
94 strictArgs: false,
95 list: { min: 0, max: Number.MAX_SAFE_INTEGER },
96 handler: (context) => {
97 // Sort to prefer exact matches over wildcard matches
98 const sortedMacroArgs = macroArguments.toSorted((a, b) => {
99 const aHasWildcard = a.args.includes('*');
100 const bHasWildcard = b.args.includes('*');
101 if (aHasWildcard && !bHasWildcard) return 1;
102 if (!aHasWildcard && bHasWildcard) return -1;
103 return 0;
104 });
105
106 const findMacroMatch = (/** @type {{args: string[]}} */ i) => {
107 // Exact match
108 if (i.args.length === context.list.length && i.args.every((arg, index) => arg === context.list[index])) {
109 return true;
110 }
111 // Wildcard match - if any definition arg is '*', it matches any value at that position
112 if (i.args.length === context.list.length) {
113 return i.args.every((arg, index) => arg === '*' || arg === context.list[index]);
114 }
115 return false;
116 };
117
118 const replacer = sortedMacroArgs.find(findMacroMatch)?.value;
119 if (replacer instanceof SlashCommandClosure) {
120 replacer.abortController = this.abortController;
121 replacer.breakController = this.breakController;
122 replacer.scope.parent = this.scope;
123 if (this.debugController && !replacer.debugController) {
124 replacer.debugController = this.debugController;
125 }
126
127 const closureKey = uuidv4();
128 closures.set(closureKey, replacer);
129 return `${CLOSURE_BOUNDARY}${closureKey}${CLOSURE_BOUNDARY}`;
130 }
131
132 return String(replacer ?? '');
133 },
134 };
135 }
136
137 const substitutedText = substituteParams(text, { dynamicMacros });
138
139 // If any closures were inserted, split the text accordingly
140 if (closures.size > 0) {
141 const parts = substitutedText.split(CLOSURE_BOUNDARY).map(part => closures.has(part) ? closures.get(part) : part).filter(Boolean);
142 return parts.length === 1 ? parts[0] : parts;
143 }
144
145 // No closures, return substituted text as-is
146 return substitutedText;
147 }
148
149 /**
150 *
151 * @param {string} text
152 * @param {SlashCommandScope} scope
153 * @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]}
154 */
155 substituteParams(text, scope = null) {
156 let isList = false;
157 let listValues = [];
158 scope = scope ?? this.scope;
159 const escapeMacro = (it, isAnchored = false) => {
160 const regexText = escapeRegex(it.key.replace(/\*/g, '~~~WILDCARD~~~'))
161 .replaceAll('~~~WILDCARD~~~', '(?:(?:(?!(?:::|}})).)*)')
162 ;
163 if (isAnchored) {
164 return `^${regexText}$`;
165 }
166 return regexText;
167 };
168 const macroList = scope.macroList.toSorted((a, b) => {
169 if (a.key.includes('*') && !b.key.includes('*')) return 1;
170 if (!a.key.includes('*') && b.key.includes('*')) return -1;
171 if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*');
172 return 0;
173 });
174 if (power_user.experimental_macro_engine) {
175 return this.substituteWithMacroEngine(text, scope, macroList);
176 }
177 const macros = macroList.map(it => escapeMacro(it)).join('|');
178 const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`);
179 let done = '';
180 let remaining = text;
181 while (re.test(remaining)) {
182 const match = re.exec(remaining);
183 const before = substituteParams(remaining.slice(0, match.index));
184 const after = remaining.slice(match.index + match[0].length);
185 const replacer = match.groups.pipe ? scope.pipe : match.groups.var ? scope.getVariable(match.groups.var, match.groups.index) : macroList.find(it => it.key == match.groups.macro || new RegExp(escapeMacro(it, true)).test(match.groups.macro))?.value;
186 if (replacer instanceof SlashCommandClosure) {
187 replacer.abortController = this.abortController;
188 replacer.breakController = this.breakController;
189 replacer.scope.parent = this.scope;
190 if (this.debugController && !replacer.debugController) {
191 replacer.debugController = this.debugController;
192 }
193 isList = true;
194 if (match.index > 0) {
195 listValues.push(before);
196 }
197 listValues.push(replacer);
198 if (match.index + match[0].length + 1 < remaining.length) {
199 const rest = this.substituteParams(after, scope);
200 listValues.push(...(Array.isArray(rest) ? rest : [rest]));
201 }
202 break;
203 } else {
204 done = `${done}${before}${replacer}`;
205 remaining = after;
206 }
207 }
208 if (!isList) {
209 text = `${done}${substituteParams(remaining)}`;
210 }
211
212 if (isList) {
213 if (listValues.length > 1) return listValues;
214 return listValues[0];
215 }
216 return text;
217 }
218
219 getCopy() {
220 const closure = new SlashCommandClosure();
221 closure.scope = this.scope.getCopy();
222 closure.executeNow = this.executeNow;
223 closure.argumentList = this.argumentList;
224 closure.providedArgumentList = this.providedArgumentList;
225 closure.executorList = this.executorList;
226 closure.abortController = this.abortController;
227 closure.breakController = this.breakController;
228 closure.debugController = this.debugController;
229 closure.rawText = this.rawText;
230 closure.fullText = this.fullText;
231 closure.parserContext = this.parserContext;
232 closure.source = this.source;
233 closure.onProgress = this.onProgress;
234 return closure;
235 }
236
237 /**
238 *
239 * @returns {Promise<SlashCommandClosureResult>}
240 */
241 async execute() {
242 // execute a copy of the closure to no taint it and its scope with the effects of its execution
243 // as this would affect the closure being called a second time (e.g., loop, multiple /run calls)
244 const closure = this.getCopy();
245 const gen = closure.executeDirect();
246 let step;
247 while (!step?.done) {
248 step = await gen.next(this.debugController?.testStepping(this) ?? false);
249 if (!(step.value instanceof SlashCommandClosureResult) && this.debugController) {
250 this.debugController.isStepping = await this.debugController.awaitBreakPoint(step.value.closure, step.value.executor);
251 }
252 }
253 return step.value;
254 }
255
256 async* executeDirect() {
257 this.debugController?.down(this);
258 // closure arguments
259 for (const arg of this.argumentList) {
260 let v = arg.value;
261 if (v instanceof SlashCommandClosure) {
262 /**@type {SlashCommandClosure}*/
263 const closure = v;
264 closure.scope.parent = this.scope;
265 closure.breakController = this.breakController;
266 if (closure.executeNow) {
267 v = (await closure.execute())?.pipe;
268 } else {
269 v = closure;
270 }
271 } else {
272 v = this.substituteParams(v);
273 }
274 // unescape value
275 if (typeof v == 'string') {
276 v = v
277 ?.replace(/\\\{/g, '{')
278 ?.replace(/\\\}/g, '}')
279 ;
280 }
281 this.scope.letVariable(arg.name, v);
282 }
283 for (const arg of this.providedArgumentList) {
284 let v = arg.value;
285 if (v instanceof SlashCommandClosure) {
286 /**@type {SlashCommandClosure}*/
287 const closure = v;
288 closure.scope.parent = this.scope;
289 closure.breakController = this.breakController;
290 if (closure.executeNow) {
291 v = (await closure.execute())?.pipe;
292 } else {
293 v = closure;
294 }
295 } else {
296 v = this.substituteParams(v, this.scope.parent);
297 }
298 // unescape value
299 if (typeof v == 'string') {
300 v = v
301 ?.replace(/\\\{/g, '{')
302 ?.replace(/\\\}/g, '}')
303 ;
304 }
305 this.scope.setVariable(arg.name, v);
306 }
307
308 if (this.executorList.length == 0) {
309 this.scope.pipe = '';
310 }
311 const stepper = this.executeStep();
312 let step;
313 while (!step?.done && !this.breakController?.isBreak) {
314 // get executor before execution
315 step = await stepper.next();
316 if (step.value instanceof SlashCommandBreakPoint) {
317 console.log('encountered SlashCommandBreakPoint');
318 if (this.debugController) {
319 // resolve args
320 step = await stepper.next();
321 // "execute" breakpoint
322 step = await stepper.next();
323 // get next executor
324 step = await stepper.next();
325 // breakpoint has to yield before arguments are resolved if one of the
326 // arguments is an immediate closure, otherwise you cannot step into the
327 // immediate closure
328 const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it => it.value instanceof SlashCommandClosure && it.value.executeNow);
329 const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it => it.value instanceof SlashCommandClosure && it.value.executeNow);
330 if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
331 this.debugController.isStepping = yield { closure: this, executor: step.value };
332 } else {
333 this.debugController.isStepping = true;
334 this.debugController.stepStack[this.debugController.stepStack.length - 1] = true;
335 }
336 }
337 } else if (!step.done && this.debugController?.testStepping(this)) {
338 this.debugController.isSteppingInto = false;
339 // if stepping, have to yield before arguments are resolved if one of the arguments
340 // is an immediate closure, otherwise you cannot step into the immediate closure
341 const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it => it.value instanceof SlashCommandClosure && it.value.executeNow);
342 const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it => it.value instanceof SlashCommandClosure && it.value.executeNow);
343 if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) {
344 this.debugController.isStepping = yield { closure: this, executor: step.value };
345 }
346 }
347 // resolve args
348 step = await stepper.next();
349 if (step.value instanceof SlashCommandBreak) {
350 console.log('encountered SlashCommandBreak');
351 if (this.breakController) {
352 this.breakController?.break();
353 break;
354 }
355 } else if (!step.done && this.debugController?.testStepping(this)) {
356 this.debugController.isSteppingInto = false;
357 this.debugController.isStepping = yield { closure: this, executor: step.value };
358 }
359 // execute executor
360 step = await stepper.next();
361 }
362
363 // if execution has returned a closure result, return that (should only happen on abort)
364 if (step.value instanceof SlashCommandClosureResult) {
365 this.debugController?.up();
366 return step.value;
367 }
368 /**@type {SlashCommandClosureResult} */
369 const result = Object.assign(new SlashCommandClosureResult(), { pipe: this.scope.pipe, isBreak: this.breakController?.isBreak ?? false });
370 this.debugController?.up();
371 return result;
372 }
373 /**
374 * Generator that steps through the executor list.
375 * Every executor is split into three steps:
376 * - before arguments are resolved
377 * - after arguments are resolved
378 * - after execution
379 */
380 async* executeStep() {
381 let done = 0;
382 let isFirst = true;
383 for (const executor of this.executorList) {
384 this.onProgress?.(done, this.commandCount);
385 if (this.debugController) {
386 this.debugController.setExecutor(executor);
387 this.debugController.namedArguments = undefined;
388 this.debugController.unnamedArguments = undefined;
389 }
390 // yield before doing anything with this executor, the debugger might want to do
391 // something with it (e.g., breakpoint, immediate closures that need resolving
392 // or stepping into)
393 yield executor;
394 /**@type {import('./SlashCommand.js').NamedArguments} */
395 // @ts-ignore
396 let args = {
397 _scope: this.scope,
398 _parserFlags: executor.parserFlags,
399 _abortController: this.abortController,
400 _debugController: this.debugController,
401 _hasUnnamedArgument: executor.unnamedArgumentList.length > 0,
402 };
403 if (executor instanceof SlashCommandBreakPoint) {
404 // nothing to do for breakpoints, just raise counter and yield for "before exec"
405 done++;
406 yield executor;
407 isFirst = false;
408 } else if (executor instanceof SlashCommandBreak) {
409 // /break need to resolve the unnamed arg and put it into pipe, then yield
410 // for "before exec"
411 const value = await this.substituteUnnamedArgument(executor, isFirst, args);
412 done += this.executorList.length - this.executorList.indexOf(executor);
413 this.scope.pipe = value ?? this.scope.pipe;
414 yield executor;
415 isFirst = false;
416 } else {
417 // regular commands do all the argument resolving logic...
418 await this.substituteNamedArguments(executor, args);
419 let value = await this.substituteUnnamedArgument(executor, isFirst, args);
420
421 let abortResult = await this.testAbortController();
422 if (abortResult) {
423 return abortResult;
424 }
425 if (this.debugController) {
426 this.debugController.namedArguments = args;
427 this.debugController.unnamedArguments = value ?? '';
428 }
429 // then yield for "before exec"
430 yield executor;
431 // followed by command execution
432 executor.onProgress = (subDone, subTotal) => this.onProgress?.(done + subDone, this.commandCount);
433 const isStepping = this.debugController?.testStepping(this);
434 if (this.debugController) {
435 this.debugController.isStepping = false || this.debugController.isSteppingInto;
436 }
437 try {
438 this.scope.pipe = await executor.command.callback(args, value ?? '');
439 } catch (ex) {
440 throw new SlashCommandExecutionError(ex, ex.message, executor.name, executor.start, executor.end, this.fullText.slice(executor.start, executor.end), this.fullText);
441 }
442 if (this.debugController) {
443 this.debugController.namedArguments = undefined;
444 this.debugController.unnamedArguments = undefined;
445 this.debugController.isStepping = isStepping;
446 }
447 this.#lintPipe(executor.command);
448 done += executor.commandCount;
449 this.onProgress?.(done, this.commandCount);
450 abortResult = await this.testAbortController();
451 if (abortResult) {
452 return abortResult;
453 }
454 }
455 // finally, yield for "after exec"
456 yield executor;
457 isFirst = false;
458 }
459 }
460
461 async testPaused() {
462 while (!this.abortController?.signal?.aborted && this.abortController?.signal?.paused) {
463 await delay(200);
464 }
465 }
466 async testAbortController() {
467 await this.testPaused();
468 if (this.abortController?.signal?.aborted) {
469 const result = new SlashCommandClosureResult();
470 result.isAborted = true;
471 result.isQuietlyAborted = this.abortController.signal.isQuiet;
472 result.abortReason = this.abortController.signal.reason.toString();
473 return result;
474 }
475 }
476
477 /**
478 * @param {SlashCommandExecutor} executor
479 * @param {import('./SlashCommand.js').NamedArguments} args
480 */
481 async substituteNamedArguments(executor, args) {
482 /**
483 * Handles the assignment of named arguments, considering if they accept multiple values
484 * @param {string} name The name of the argument, as defined for the command execution
485 * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]} value The value to be assigned
486 */
487 const assign = (name, value) => {
488 // If an array is supposed to be assigned, assign it one by one
489 if (Array.isArray(value)) {
490 for (const val of value) {
491 assign(name, val);
492 }
493 return;
494 }
495
496 const definition = executor.command.namedArgumentList.find(x => x.name == name);
497
498 // Prefer definition name if a valid named args defintion is found
499 name = definition?.name ?? name;
500
501 // Unescape named argument
502 if (value && typeof value == 'string') {
503 value = value
504 .replace(/\\\{/g, '{')
505 .replace(/\\\}/g, '}');
506 }
507
508 // If the named argument accepts multiple values, we have to make sure to build an array correctly
509 if (definition?.acceptsMultiple) {
510 if (args[name] !== undefined) {
511 // If there already is something for that named arg, make the value is an array and add to it
512 let currentValue = args[name];
513 if (!Array.isArray(currentValue)) {
514 currentValue = [currentValue];
515 }
516 currentValue.push(value);
517 args[name] = currentValue;
518 } else {
519 // If there is nothing in there, we create an array with that singular value
520 args[name] = [value];
521 }
522 } else {
523 args[name] !== undefined && console.debug(`Named argument assigned multiple times: ${name}`);
524 args[name] = value;
525 }
526 };
527
528 // substitute named arguments
529 for (const arg of executor.namedArgumentList) {
530 if (arg.value instanceof SlashCommandClosure) {
531 /**@type {SlashCommandClosure}*/
532 const closure = arg.value;
533 closure.scope.parent = this.scope;
534 closure.breakController = this.breakController;
535 if (this.debugController && !closure.debugController) {
536 closure.debugController = this.debugController;
537 }
538 if (closure.executeNow) {
539 assign(arg.name, (await closure.execute())?.pipe);
540 } else {
541 assign(arg.name, closure);
542 }
543 } else {
544 assign(arg.name, this.substituteParams(arg.value));
545 }
546 }
547 }
548
549 /**
550 * @param {SlashCommandExecutor} executor
551 * @param {boolean} isFirst
552 * @param {import('./SlashCommand.js').NamedArguments} args
553 * @returns {Promise<string|SlashCommandClosure|(string|SlashCommandClosure)[]>}
554 */
555 async substituteUnnamedArgument(executor, isFirst, args) {
556 let value;
557 // substitute unnamed argument
558 if (executor.unnamedArgumentList.length == 0) {
559 if (!isFirst && executor.injectPipe) {
560 value = this.scope.pipe;
561 args._hasUnnamedArgument = this.scope.pipe !== null && this.scope.pipe !== undefined;
562 }
563 } else {
564 value = [];
565 for (let i = 0; i < executor.unnamedArgumentList.length; i++) {
566 /** @type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */
567 let v = executor.unnamedArgumentList[i].value;
568 if (v instanceof SlashCommandClosure) {
569 /**@type {SlashCommandClosure}*/
570 const closure = v;
571 closure.scope.parent = this.scope;
572 closure.breakController = this.breakController;
573 if (this.debugController && !closure.debugController) {
574 closure.debugController = this.debugController;
575 }
576 if (closure.executeNow) {
577 v = (await closure.execute())?.pipe;
578 } else {
579 v = closure;
580 }
581 } else {
582 v = this.substituteParams(v);
583 }
584 value[i] = v;
585 }
586 if (!executor.command.splitUnnamedArgument) {
587 if (value.length == 1) {
588 value = value[0];
589 } else if (!value.find(it => it instanceof SlashCommandClosure)) {
590 value = value.join('');
591 }
592 }
593 }
594 // unescape unnamed argument
595 if (typeof value == 'string') {
596 value = value
597 ?.replace(/\\\{/g, '{')
598 ?.replace(/\\\}/g, '}')
599 ;
600 } else if (Array.isArray(value)) {
601 value = value.map(v => {
602 if (typeof v == 'string') {
603 return v
604 ?.replace(/\\\{/g, '{')
605 ?.replace(/\\\}/g, '}');
606 }
607 return v;
608 });
609 }
610
611 value ??= '';
612
613 // Make sure that if unnamed args are split, it should always return an array
614 if (executor.command.splitUnnamedArgument && !Array.isArray(value)) {
615 value = [value];
616 }
617
618 return value;
619 }
620
621 /**
622 * Auto-fixes the pipe if it is not a valid result for STscript.
623 * @param {SlashCommand} command Command being executed
624 */
625 #lintPipe(command) {
626 if (this.scope.pipe === undefined || this.scope.pipe === null) {
627 console.warn(`/${command.name} returned undefined or null. Auto-fixing to empty string.`);
628 this.scope.pipe = '';
629 } else if (!(typeof this.scope.pipe == 'string' || this.scope.pipe instanceof SlashCommandClosure)) {
630 console.warn(`/${command.name} returned illegal type (${typeof this.scope.pipe} - ${this.scope.pipe.constructor?.name ?? ''}). Auto-fixing to stringified JSON.`);
631 this.scope.pipe = JSON.stringify(this.scope.pipe) ?? '';
632 }
633 }
634}