Spaces:
Paused
Paused
| import { substituteParams } from '../../script.js'; | |
| import { delay, escapeRegex, uuidv4 } from '../utils.js'; | |
| import { SlashCommand } from './SlashCommand.js'; | |
| import { SlashCommandAbortController } from './SlashCommandAbortController.js'; | |
| import { SlashCommandBreak } from './SlashCommandBreak.js'; | |
| import { SlashCommandBreakController } from './SlashCommandBreakController.js'; | |
| import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js'; | |
| import { SlashCommandClosureResult } from './SlashCommandClosureResult.js'; | |
| import { SlashCommandDebugController } from './SlashCommandDebugController.js'; | |
| import { SlashCommandExecutionError } from './SlashCommandExecutionError.js'; | |
| import { SlashCommandExecutor } from './SlashCommandExecutor.js'; | |
| import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js'; | |
| import { SlashCommandScope } from './SlashCommandScope.js'; | |
| export class SlashCommandClosure { | |
| /** @type {SlashCommandScope} */ scope; | |
| /** @type {boolean} */ executeNow = false; | |
| /** @type {SlashCommandNamedArgumentAssignment[]} */ argumentList = []; | |
| /** @type {SlashCommandNamedArgumentAssignment[]} */ providedArgumentList = []; | |
| /** @type {SlashCommandExecutor[]} */ executorList = []; | |
| /** @type {SlashCommandAbortController} */ abortController; | |
| /** @type {SlashCommandBreakController} */ breakController; | |
| /** @type {SlashCommandDebugController} */ debugController; | |
| /** @type {(done:number, total:number)=>void} */ onProgress; | |
| /** @type {string} */ rawText; | |
| /** @type {string} */ fullText; | |
| /** @type {string} */ parserContext; | |
| /** @type {string} */ #source = uuidv4(); | |
| get source() { return this.#source; } | |
| set source(value) { | |
| this.#source = value; | |
| for (const executor of this.executorList) { | |
| executor.source = value; | |
| } | |
| } | |
| /**@type {number}*/ | |
| get commandCount() { | |
| return this.executorList.map(executor=>executor.commandCount).reduce((sum,cur)=>sum + cur, 0); | |
| } | |
| constructor(parent) { | |
| this.scope = new SlashCommandScope(parent); | |
| } | |
| toString() { | |
| return `[Closure]${this.executeNow ? '()' : ''}`; | |
| } | |
| /** | |
| * | |
| * @param {string} text | |
| * @param {SlashCommandScope} scope | |
| * @returns {string|SlashCommandClosure|(string|SlashCommandClosure)[]} | |
| */ | |
| substituteParams(text, scope = null) { | |
| let isList = false; | |
| let listValues = []; | |
| scope = scope ?? this.scope; | |
| const escapeMacro = (it, isAnchored = false)=>{ | |
| const regexText = escapeRegex(it.key.replace(/\*/g, '~~~WILDCARD~~~')) | |
| .replaceAll('~~~WILDCARD~~~', '(?:(?:(?!(?:::|}})).)*)') | |
| ; | |
| if (isAnchored) { | |
| return `^${regexText}$`; | |
| } | |
| return regexText; | |
| }; | |
| const macroList = scope.macroList.toSorted((a,b)=>{ | |
| if (a.key.includes('*') && !b.key.includes('*')) return 1; | |
| if (!a.key.includes('*') && b.key.includes('*')) return -1; | |
| if (a.key.includes('*') && b.key.includes('*')) return b.key.indexOf('*') - a.key.indexOf('*'); | |
| return 0; | |
| }); | |
| const macros = macroList.map(it=>escapeMacro(it)).join('|'); | |
| const re = new RegExp(`(?<pipe>{{pipe}})|(?:{{var::(?<var>[^\\s]+?)(?:::(?<varIndex>(?!}}).+))?}})|(?:{{(?<macro>${macros})}})`); | |
| let done = ''; | |
| let remaining = text; | |
| while (re.test(remaining)) { | |
| const match = re.exec(remaining); | |
| const before = substituteParams(remaining.slice(0, match.index)); | |
| const after = remaining.slice(match.index + match[0].length); | |
| 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; | |
| if (replacer instanceof SlashCommandClosure) { | |
| replacer.abortController = this.abortController; | |
| replacer.breakController = this.breakController; | |
| replacer.scope.parent = this.scope; | |
| if (this.debugController && !replacer.debugController) { | |
| replacer.debugController = this.debugController; | |
| } | |
| isList = true; | |
| if (match.index > 0) { | |
| listValues.push(before); | |
| } | |
| listValues.push(replacer); | |
| if (match.index + match[0].length + 1 < remaining.length) { | |
| const rest = this.substituteParams(after, scope); | |
| listValues.push(...(Array.isArray(rest) ? rest : [rest])); | |
| } | |
| break; | |
| } else { | |
| done = `${done}${before}${replacer}`; | |
| remaining = after; | |
| } | |
| } | |
| if (!isList) { | |
| text = `${done}${substituteParams(remaining)}`; | |
| } | |
| if (isList) { | |
| if (listValues.length > 1) return listValues; | |
| return listValues[0]; | |
| } | |
| return text; | |
| } | |
| getCopy() { | |
| const closure = new SlashCommandClosure(); | |
| closure.scope = this.scope.getCopy(); | |
| closure.executeNow = this.executeNow; | |
| closure.argumentList = this.argumentList; | |
| closure.providedArgumentList = this.providedArgumentList; | |
| closure.executorList = this.executorList; | |
| closure.abortController = this.abortController; | |
| closure.breakController = this.breakController; | |
| closure.debugController = this.debugController; | |
| closure.rawText = this.rawText; | |
| closure.fullText = this.fullText; | |
| closure.parserContext = this.parserContext; | |
| closure.source = this.source; | |
| closure.onProgress = this.onProgress; | |
| return closure; | |
| } | |
| /** | |
| * | |
| * @returns {Promise<SlashCommandClosureResult>} | |
| */ | |
| async execute() { | |
| // execute a copy of the closure to no taint it and its scope with the effects of its execution | |
| // as this would affect the closure being called a second time (e.g., loop, multiple /run calls) | |
| const closure = this.getCopy(); | |
| const gen = closure.executeDirect(); | |
| let step; | |
| while (!step?.done) { | |
| step = await gen.next(this.debugController?.testStepping(this) ?? false); | |
| if (!(step.value instanceof SlashCommandClosureResult) && this.debugController) { | |
| this.debugController.isStepping = await this.debugController.awaitBreakPoint(step.value.closure, step.value.executor); | |
| } | |
| } | |
| return step.value; | |
| } | |
| async * executeDirect() { | |
| this.debugController?.down(this); | |
| // closure arguments | |
| for (const arg of this.argumentList) { | |
| let v = arg.value; | |
| if (v instanceof SlashCommandClosure) { | |
| /**@type {SlashCommandClosure}*/ | |
| const closure = v; | |
| closure.scope.parent = this.scope; | |
| closure.breakController = this.breakController; | |
| if (closure.executeNow) { | |
| v = (await closure.execute())?.pipe; | |
| } else { | |
| v = closure; | |
| } | |
| } else { | |
| v = this.substituteParams(v); | |
| } | |
| // unescape value | |
| if (typeof v == 'string') { | |
| v = v | |
| ?.replace(/\\\{/g, '{') | |
| ?.replace(/\\\}/g, '}') | |
| ; | |
| } | |
| this.scope.letVariable(arg.name, v); | |
| } | |
| for (const arg of this.providedArgumentList) { | |
| let v = arg.value; | |
| if (v instanceof SlashCommandClosure) { | |
| /**@type {SlashCommandClosure}*/ | |
| const closure = v; | |
| closure.scope.parent = this.scope; | |
| closure.breakController = this.breakController; | |
| if (closure.executeNow) { | |
| v = (await closure.execute())?.pipe; | |
| } else { | |
| v = closure; | |
| } | |
| } else { | |
| v = this.substituteParams(v, this.scope.parent); | |
| } | |
| // unescape value | |
| if (typeof v == 'string') { | |
| v = v | |
| ?.replace(/\\\{/g, '{') | |
| ?.replace(/\\\}/g, '}') | |
| ; | |
| } | |
| this.scope.setVariable(arg.name, v); | |
| } | |
| if (this.executorList.length == 0) { | |
| this.scope.pipe = ''; | |
| } | |
| const stepper = this.executeStep(); | |
| let step; | |
| while (!step?.done && !this.breakController?.isBreak) { | |
| // get executor before execution | |
| step = await stepper.next(); | |
| if (step.value instanceof SlashCommandBreakPoint) { | |
| console.log('encountered SlashCommandBreakPoint'); | |
| if (this.debugController) { | |
| // resolve args | |
| step = await stepper.next(); | |
| // "execute" breakpoint | |
| step = await stepper.next(); | |
| // get next executor | |
| step = await stepper.next(); | |
| // breakpoint has to yield before arguments are resolved if one of the | |
| // arguments is an immediate closure, otherwise you cannot step into the | |
| // immediate closure | |
| const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow); | |
| const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow); | |
| if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) { | |
| this.debugController.isStepping = yield { closure:this, executor:step.value }; | |
| } else { | |
| this.debugController.isStepping = true; | |
| this.debugController.stepStack[this.debugController.stepStack.length - 1] = true; | |
| } | |
| } | |
| } else if (!step.done && this.debugController?.testStepping(this)) { | |
| this.debugController.isSteppingInto = false; | |
| // if stepping, have to yield before arguments are resolved if one of the arguments | |
| // is an immediate closure, otherwise you cannot step into the immediate closure | |
| const hasImmediateClosureInNamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.namedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow); | |
| const hasImmediateClosureInUnnamedArgs = /**@type {SlashCommandExecutor}*/(step.value)?.unnamedArgumentList?.find(it=>it.value instanceof SlashCommandClosure && it.value.executeNow); | |
| if (hasImmediateClosureInNamedArgs || hasImmediateClosureInUnnamedArgs) { | |
| this.debugController.isStepping = yield { closure:this, executor:step.value }; | |
| } | |
| } | |
| // resolve args | |
| step = await stepper.next(); | |
| if (step.value instanceof SlashCommandBreak) { | |
| console.log('encountered SlashCommandBreak'); | |
| if (this.breakController) { | |
| this.breakController?.break(); | |
| break; | |
| } | |
| } else if (!step.done && this.debugController?.testStepping(this)) { | |
| this.debugController.isSteppingInto = false; | |
| this.debugController.isStepping = yield { closure:this, executor:step.value }; | |
| } | |
| // execute executor | |
| step = await stepper.next(); | |
| } | |
| // if execution has returned a closure result, return that (should only happen on abort) | |
| if (step.value instanceof SlashCommandClosureResult) { | |
| this.debugController?.up(); | |
| return step.value; | |
| } | |
| /**@type {SlashCommandClosureResult} */ | |
| const result = Object.assign(new SlashCommandClosureResult(), { pipe: this.scope.pipe, isBreak: this.breakController?.isBreak ?? false }); | |
| this.debugController?.up(); | |
| return result; | |
| } | |
| /** | |
| * Generator that steps through the executor list. | |
| * Every executor is split into three steps: | |
| * - before arguments are resolved | |
| * - after arguments are resolved | |
| * - after execution | |
| */ | |
| async * executeStep() { | |
| let done = 0; | |
| let isFirst = true; | |
| for (const executor of this.executorList) { | |
| this.onProgress?.(done, this.commandCount); | |
| if (this.debugController) { | |
| this.debugController.setExecutor(executor); | |
| this.debugController.namedArguments = undefined; | |
| this.debugController.unnamedArguments = undefined; | |
| } | |
| // yield before doing anything with this executor, the debugger might want to do | |
| // something with it (e.g., breakpoint, immediate closures that need resolving | |
| // or stepping into) | |
| yield executor; | |
| /**@type {import('./SlashCommand.js').NamedArguments} */ | |
| // @ts-ignore | |
| let args = { | |
| _scope: this.scope, | |
| _parserFlags: executor.parserFlags, | |
| _abortController: this.abortController, | |
| _debugController: this.debugController, | |
| _hasUnnamedArgument: executor.unnamedArgumentList.length > 0, | |
| }; | |
| if (executor instanceof SlashCommandBreakPoint) { | |
| // nothing to do for breakpoints, just raise counter and yield for "before exec" | |
| done++; | |
| yield executor; | |
| isFirst = false; | |
| } else if (executor instanceof SlashCommandBreak) { | |
| // /break need to resolve the unnamed arg and put it into pipe, then yield | |
| // for "before exec" | |
| const value = await this.substituteUnnamedArgument(executor, isFirst, args); | |
| done += this.executorList.length - this.executorList.indexOf(executor); | |
| this.scope.pipe = value ?? this.scope.pipe; | |
| yield executor; | |
| isFirst = false; | |
| } else { | |
| // regular commands do all the argument resolving logic... | |
| await this.substituteNamedArguments(executor, args); | |
| let value = await this.substituteUnnamedArgument(executor, isFirst, args); | |
| let abortResult = await this.testAbortController(); | |
| if (abortResult) { | |
| return abortResult; | |
| } | |
| if (this.debugController) { | |
| this.debugController.namedArguments = args; | |
| this.debugController.unnamedArguments = value ?? ''; | |
| } | |
| // then yield for "before exec" | |
| yield executor; | |
| // followed by command execution | |
| executor.onProgress = (subDone, subTotal)=>this.onProgress?.(done + subDone, this.commandCount); | |
| const isStepping = this.debugController?.testStepping(this); | |
| if (this.debugController) { | |
| this.debugController.isStepping = false || this.debugController.isSteppingInto; | |
| } | |
| try { | |
| this.scope.pipe = await executor.command.callback(args, value ?? ''); | |
| } catch (ex) { | |
| throw new SlashCommandExecutionError(ex, ex.message, executor.name, executor.start, executor.end, this.fullText.slice(executor.start, executor.end), this.fullText); | |
| } | |
| if (this.debugController) { | |
| this.debugController.namedArguments = undefined; | |
| this.debugController.unnamedArguments = undefined; | |
| this.debugController.isStepping = isStepping; | |
| } | |
| this.#lintPipe(executor.command); | |
| done += executor.commandCount; | |
| this.onProgress?.(done, this.commandCount); | |
| abortResult = await this.testAbortController(); | |
| if (abortResult) { | |
| return abortResult; | |
| } | |
| } | |
| // finally, yield for "after exec" | |
| yield executor; | |
| isFirst = false; | |
| } | |
| } | |
| async testPaused() { | |
| while (!this.abortController?.signal?.aborted && this.abortController?.signal?.paused) { | |
| await delay(200); | |
| } | |
| } | |
| async testAbortController() { | |
| await this.testPaused(); | |
| if (this.abortController?.signal?.aborted) { | |
| const result = new SlashCommandClosureResult(); | |
| result.isAborted = true; | |
| result.isQuietlyAborted = this.abortController.signal.isQuiet; | |
| result.abortReason = this.abortController.signal.reason.toString(); | |
| return result; | |
| } | |
| } | |
| /** | |
| * @param {SlashCommandExecutor} executor | |
| * @param {import('./SlashCommand.js').NamedArguments} args | |
| */ | |
| async substituteNamedArguments(executor, args) { | |
| /** | |
| * Handles the assignment of named arguments, considering if they accept multiple values | |
| * @param {string} name The name of the argument, as defined for the command execution | |
| * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]} value The value to be assigned | |
| */ | |
| const assign = (name, value) => { | |
| // If an array is supposed to be assigned, assign it one by one | |
| if (Array.isArray(value)) { | |
| for (const val of value) { | |
| assign(name, val); | |
| } | |
| return; | |
| } | |
| const definition = executor.command.namedArgumentList.find(x => x.name == name); | |
| // Prefer definition name if a valid named args defintion is found | |
| name = definition?.name ?? name; | |
| // Unescape named argument | |
| if (value && typeof value == 'string') { | |
| value = value | |
| .replace(/\\\{/g, '{') | |
| .replace(/\\\}/g, '}'); | |
| } | |
| // If the named argument accepts multiple values, we have to make sure to build an array correctly | |
| if (definition?.acceptsMultiple) { | |
| if (args[name] !== undefined) { | |
| // If there already is something for that named arg, make the value is an array and add to it | |
| let currentValue = args[name]; | |
| if (!Array.isArray(currentValue)) { | |
| currentValue = [currentValue]; | |
| } | |
| currentValue.push(value); | |
| args[name] = currentValue; | |
| } else { | |
| // If there is nothing in there, we create an array with that singular value | |
| args[name] = [value]; | |
| } | |
| } else { | |
| args[name] !== undefined && console.debug(`Named argument assigned multiple times: ${name}`); | |
| args[name] = value; | |
| } | |
| }; | |
| // substitute named arguments | |
| for (const arg of executor.namedArgumentList) { | |
| if (arg.value instanceof SlashCommandClosure) { | |
| /**@type {SlashCommandClosure}*/ | |
| const closure = arg.value; | |
| closure.scope.parent = this.scope; | |
| closure.breakController = this.breakController; | |
| if (this.debugController && !closure.debugController) { | |
| closure.debugController = this.debugController; | |
| } | |
| if (closure.executeNow) { | |
| assign(arg.name, (await closure.execute())?.pipe); | |
| } else { | |
| assign(arg.name, closure); | |
| } | |
| } else { | |
| assign(arg.name, this.substituteParams(arg.value)); | |
| } | |
| } | |
| } | |
| /** | |
| * @param {SlashCommandExecutor} executor | |
| * @param {boolean} isFirst | |
| * @param {import('./SlashCommand.js').NamedArguments} args | |
| * @returns {Promise<string|SlashCommandClosure|(string|SlashCommandClosure)[]>} | |
| */ | |
| async substituteUnnamedArgument(executor, isFirst, args) { | |
| let value; | |
| // substitute unnamed argument | |
| if (executor.unnamedArgumentList.length == 0) { | |
| if (!isFirst && executor.injectPipe) { | |
| value = this.scope.pipe; | |
| args._hasUnnamedArgument = this.scope.pipe !== null && this.scope.pipe !== undefined; | |
| } | |
| } else { | |
| value = []; | |
| for (let i = 0; i < executor.unnamedArgumentList.length; i++) { | |
| /** @type {string|SlashCommandClosure|(string|SlashCommandClosure)[]} */ | |
| let v = executor.unnamedArgumentList[i].value; | |
| if (v instanceof SlashCommandClosure) { | |
| /**@type {SlashCommandClosure}*/ | |
| const closure = v; | |
| closure.scope.parent = this.scope; | |
| closure.breakController = this.breakController; | |
| if (this.debugController && !closure.debugController) { | |
| closure.debugController = this.debugController; | |
| } | |
| if (closure.executeNow) { | |
| v = (await closure.execute())?.pipe; | |
| } else { | |
| v = closure; | |
| } | |
| } else { | |
| v = this.substituteParams(v); | |
| } | |
| value[i] = v; | |
| } | |
| if (!executor.command.splitUnnamedArgument) { | |
| if (value.length == 1) { | |
| value = value[0]; | |
| } else if (!value.find(it=>it instanceof SlashCommandClosure)) { | |
| value = value.join(''); | |
| } | |
| } | |
| } | |
| // unescape unnamed argument | |
| if (typeof value == 'string') { | |
| value = value | |
| ?.replace(/\\\{/g, '{') | |
| ?.replace(/\\\}/g, '}') | |
| ; | |
| } else if (Array.isArray(value)) { | |
| value = value.map(v=>{ | |
| if (typeof v == 'string') { | |
| return v | |
| ?.replace(/\\\{/g, '{') | |
| ?.replace(/\\\}/g, '}'); | |
| } | |
| return v; | |
| }); | |
| } | |
| value ??= ''; | |
| // Make sure that if unnamed args are split, it should always return an array | |
| if (executor.command.splitUnnamedArgument && !Array.isArray(value)) { | |
| value = [value]; | |
| } | |
| return value; | |
| } | |
| /** | |
| * Auto-fixes the pipe if it is not a valid result for STscript. | |
| * @param {SlashCommand} command Command being executed | |
| */ | |
| #lintPipe(command) { | |
| if (this.scope.pipe === undefined || this.scope.pipe === null) { | |
| console.warn(`/${command.name} returned undefined or null. Auto-fixing to empty string.`); | |
| this.scope.pipe = ''; | |
| } else if (!(typeof this.scope.pipe == 'string' || this.scope.pipe instanceof SlashCommandClosure)) { | |
| console.warn(`/${command.name} returned illegal type (${typeof this.scope.pipe} - ${this.scope.pipe.constructor?.name ?? ''}). Auto-fixing to stringified JSON.`); | |
| this.scope.pipe = JSON.stringify(this.scope.pipe) ?? ''; | |
| } | |
| } | |
| } | |