import { power_user } from '../power-user.js'; import { isTrueBoolean, uuidv4 } from '../utils.js'; import { SlashCommand } from './SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument } from './SlashCommandArgument.js'; import { SlashCommandClosure } from './SlashCommandClosure.js'; import { SlashCommandCommandAutoCompleteOption } from './SlashCommandCommandAutoCompleteOption.js'; import { SlashCommandExecutor } from './SlashCommandExecutor.js'; import { SlashCommandParserError } from './SlashCommandParserError.js'; import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './SlashCommandParserNameResult.js'; import { SlashCommandQuickReplyAutoCompleteOption } from './SlashCommandQuickReplyAutoCompleteOption.js'; // eslint-disable-next-line no-unused-vars import { SlashCommandScope } from './SlashCommandScope.js'; import { SlashCommandVariableAutoCompleteOption } from './SlashCommandVariableAutoCompleteOption.js'; /**@readonly*/ /**@enum {Number}*/ export const PARSER_FLAG = { 'STRICT_ESCAPING': 1, 'REPLACE_GETVAR': 2, }; export class SlashCommandParser { // @ts-ignore /**@type {Object.}*/ static commands = {}; static addCommand(command, callback, aliases, helpString = '', interruptsGeneration = false, purgeFromMessage = true) { const reserved = ['/', '#', ':', 'parser-flag']; for (const start of reserved) { if (command.toLowerCase().startsWith(start) || (aliases ?? []).find(a=>a.toLowerCase().startsWith(start))) { throw new Error(`Illegal Name. Slash command name cannot begin with "${start}".`); } } this.addCommandUnsafe(command, callback, aliases, helpString, interruptsGeneration, purgeFromMessage); } static addCommandUnsafe(command, callback, aliases, helpString = '', interruptsGeneration = false, purgeFromMessage = true) { const fnObj = Object.assign(new SlashCommand(), { name:command, callback, helpString, interruptsGeneration, purgeFromMessage, aliases }); if ([command, ...aliases].some(x => Object.hasOwn(this.commands, x))) { console.trace('WARN: Duplicate slash command registered!', [command, ...aliases]); } this.commands[command] = fnObj; if (Array.isArray(aliases)) { aliases.forEach((alias) => { this.commands[alias] = fnObj; }); } } /** * * @param {SlashCommand} command */ static addCommandObject(command) { const reserved = ['/', '#', ':', 'parser-flag']; for (const start of reserved) { if (command.name.toLowerCase().startsWith(start) || (command.aliases ?? []).find(a=>a.toLowerCase().startsWith(start))) { throw new Error(`Illegal Name. Slash command name cannot begin with "${start}".`); } } this.addCommandObjectUnsafe(command); } /** * * @param {SlashCommand} command */ static addCommandObjectUnsafe(command) { if ([command.name, ...command.aliases].some(x => Object.hasOwn(this.commands, x))) { console.trace('WARN: Duplicate slash command registered!', [command.name, ...command.aliases]); } this.commands[command.name] = command; if (Array.isArray(command.aliases)) { command.aliases.forEach((alias) => { this.commands[alias] = command; }); } } get commands() { return SlashCommandParser.commands; } // @ts-ignore /**@type {Object.}*/ helpStrings = {}; /**@type {boolean}*/ verifyCommandNames = true; /**@type {string}*/ text; /**@type {string}*/ keptText; /**@type {number}*/ index; /**@type {SlashCommandScope}*/ scope; /**@type {SlashCommandClosure}*/ closure; /**@type {Object.}*/ flags = {}; /**@type {boolean}*/ jumpedEscapeSequence = false; /**@type {{start:number, end:number}[]}*/ closureIndex; /**@type {SlashCommandExecutor[]}*/ commandIndex; /**@type {SlashCommandScope[]}*/ scopeIndex; get userIndex() { return this.index - 2; } get ahead() { return this.text.slice(this.index + 1); } get behind() { return this.text.slice(0, this.index); } get char() { return this.text[this.index]; } get endOfText() { return this.index >= this.text.length || /^\s+$/.test(this.ahead); } constructor() { // add dummy commands for help strings / autocomplete const parserFlagCmd = new SlashCommand(); parserFlagCmd.name = 'parser-flag'; parserFlagCmd.unnamedArgumentList.push(new SlashCommandArgument( 'The parser flag to modify.', ARGUMENT_TYPE.STRING, true, false, null, Object.keys(PARSER_FLAG), )); parserFlagCmd.unnamedArgumentList.push(new SlashCommandArgument( 'The state of the parser flag to set.', ARGUMENT_TYPE.BOOLEAN, false, false, 'on', ['on', 'off'], )); parserFlagCmd.helpString = 'Set a parser flag.'; SlashCommandParser.addCommandObjectUnsafe(parserFlagCmd); const commentCmd = new SlashCommand(); commentCmd.name = '/'; commentCmd.aliases.push('#'); commentCmd.unnamedArgumentList.push(new SlashCommandArgument( 'commentary', ARGUMENT_TYPE.STRING, )); commentCmd.helpString = 'Write a comment.'; SlashCommandParser.addCommandObjectUnsafe(commentCmd); this.registerLanguage(); } registerLanguage() { // NUMBER mode is copied from highlightjs's own implementation for JavaScript // https://tc39.es/ecma262/#sec-literals-numeric-literals const decimalDigits = '[0-9](_?[0-9])*'; const frac = `\\.(${decimalDigits})`; // DecimalIntegerLiteral, including Annex B NonOctalDecimalIntegerLiteral // https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals const decimalInteger = '0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*'; const NUMBER = { className: 'number', variants: [ // DecimalLiteral { begin: `(\\b(${decimalInteger})((${frac})|\\.)?|(${frac}))` + `[eE][+-]?(${decimalDigits})\\b` }, { begin: `\\b(${decimalInteger})\\b((${frac})\\b|\\.)?|(${frac})\\b` }, // DecimalBigIntegerLiteral { begin: '\\b(0|[1-9](_?[0-9])*)n\\b' }, // NonDecimalIntegerLiteral { begin: '\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b' }, { begin: '\\b0[bB][0-1](_?[0-1])*n?\\b' }, { begin: '\\b0[oO][0-7](_?[0-7])*n?\\b' }, // LegacyOctalIntegerLiteral (does not include underscore separators) // https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals { begin: '\\b0[0-7]+n?\\b' }, ], relevance: 0, }; const COMMENT = { scope: 'comment', begin: /\/[/#]/, end: /\||$|:}/, contains: [], }; const LET = { begin: [ /\/(let|var)\s+/, ], beginScope: { 1: 'variable', }, end: /\||$|:}/, contains: [], }; const SETVAR = { begin: /\/(setvar|setglobalvar)\s+/, beginScope: 'variable', end: /\||$|:}/, excludeEnd: true, contains: [], }; const GETVAR = { begin: /\/(getvar|getglobalvar)\s+/, beginScope: 'variable', end: /\||$|:}/, excludeEnd: true, contains: [], }; const RUN = { match: [ /\/:/, /(".+?(?({ case_insensitive: false, keywords: ['|'], contains: [ hljs.BACKSLASH_ESCAPE, COMMENT, RUN, LET, GETVAR, SETVAR, COMMAND, CLOSURE, ], })); } addCommand(command, callback, aliases, helpString = '', interruptsGeneration = false, purgeFromMessage = true) { SlashCommandParser.addCommand(command, callback, aliases, helpString, interruptsGeneration, purgeFromMessage); } addDummyCommand(command, aliases, helpString) { SlashCommandParser.addCommandUnsafe(command, null, aliases, helpString, true, true); } getHelpString() { return '
Loading...
'; } getHelpStringX() { const listItems = Object .keys(this.commands) .filter(key=>this.commands[key].name == key) .map(key=>this.commands[key]) .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) .map(x => x.helpStringFormatted) .map(x => `
  • ${x}
  • `) .join('\n'); return `

    Slash commands:

      ${listItems}
    Slash commands can be batched into a single input by adding a pipe character | at the end, and then writing a new slash command.
    • Example:/cut 1 | /sys Hello, | /continue
    • This will remove the first message in chat, send a system message that starts with 'Hello,', and then ask the AI to continue the message.
    `; } /** * * @param {*} text The text to parse. * @param {*} index Index to check for names (cursor position). */ async getNameAt(text, index) { if (this.text != `{:${text}:}`) { try { this.parse(text, false); } catch (e) { // do nothing console.warn(e); } } index += 2; const executor = this.commandIndex .filter(it=>it.start <= index && (it.end >= index || it.end == null)) .slice(-1)[0] ?? null ; if (executor) { const childClosure = this.closureIndex .find(it=>it.start <= index && (it.end >= index || it.end == null) && it.start > executor.start) ?? null ; if (childClosure !== null) return null; if (executor.name == ':') { const options = this.scopeIndex[this.commandIndex.indexOf(executor)] ?.allVariableNames ?.map(it=>new SlashCommandVariableAutoCompleteOption(it)) ?? [] ; try { const qrApi = (await import('../extensions/quick-reply/index.js')).quickReplyApi; options.push(...qrApi.listSets() .map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`)) .flat() .map(qr=>new SlashCommandQuickReplyAutoCompleteOption(qr)), ); } catch { /* empty */ } const result = new SlashCommandParserNameResult( NAME_RESULT_TYPE.CLOSURE, executor.value.toString(), executor.start - 2, options, true, ()=>`No matching variables in scope and no matching Quick Replies for "${result.name}"`, ()=>'No variables in scope and no Quick Replies found.', ); return result; } const result = new SlashCommandParserNameResult( NAME_RESULT_TYPE.COMMAND, executor.name, executor.start - 2, Object .keys(this.commands) .map(key=>new SlashCommandCommandAutoCompleteOption(this.commands[key], key)) , false, ()=>`No matching slash commands for "/${result.name}"`, ()=>'No slash commands found!', ); return result; } return null; } /** * Moves the index number of characters forward and returns the last character taken. * @param {number} length Number of characters to take. * @param {boolean} keep Whether to add the characters to the kept text. * @returns The last character taken. */ take(length = 1, keep = false) { this.jumpedEscapeSequence = false; let content = this.char; this.index++; if (keep) this.keptText += content; if (length > 1) { content = this.take(length - 1, keep); } return content; } discardWhitespace() { while (/\s/.test(this.char)) { this.take(); // discard whitespace this.jumpedEscapeSequence = false; } } /** * Tests if the next characters match a symbol. * Moves the index forward if the next characters are backslashes directly followed by the symbol. * Expects that the current char is taken after testing. * @param {string|RegExp} sequence Sequence of chars or regex character group that is the symbol. * @param {number} offset Offset from the current index (won't move the index if offset != 0). * @returns Whether the next characters are the indicated symbol. */ testSymbol(sequence, offset = 0) { if (!this.flags[PARSER_FLAG.STRICT_ESCAPING]) return this.testSymbolLooseyGoosey(sequence, offset); // /echo abc | /echo def // -> TOAST: abc // -> TOAST: def // /echo abc \| /echo def // -> TOAST: abc | /echo def // /echo abc \\| /echo def // -> TOAST: abc \ // -> TOAST: def // /echo abc \\\| /echo def // -> TOAST: abc \| /echo def // /echo abc \\\\| /echo def // -> TOAST: abc \\ // -> TOAST: def // /echo title=\:} \{: | /echo title=\{: \:} // -> TOAST: *:}* {: // -> TOAST: *{:* :} const escapeOffset = this.jumpedEscapeSequence ? -1 : 0; const escapes = this.text.slice(this.index + offset + escapeOffset).replace(/^(\\*).*$/s, '$1').length; const test = (sequence instanceof RegExp) ? (text) => new RegExp(`^${sequence.source}`).test(text) : (text) => text.startsWith(sequence) ; if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) { // no backslashes before sequence // -> sequence found if (escapes == 0) return true; // uneven number of backslashes before sequence // = the final backslash escapes the sequence // = every preceding pair is one literal backslash // -> move index forward to skip the backslash escaping the first backslash or the symbol // even number of backslashes before sequence // = every pair is one literal backslash // -> move index forward to skip the backslash escaping the first backslash if (!this.jumpedEscapeSequence && offset == 0) { this.index++; this.jumpedEscapeSequence = true; } return false; } } testSymbolLooseyGoosey(sequence, offset = 0) { const escapeOffset = this.jumpedEscapeSequence ? -1 : 0; const escapes = this.text[this.index + offset + escapeOffset] == '\\' ? 1 : 0; const test = (sequence instanceof RegExp) ? (text) => new RegExp(`^${sequence.source}`).test(text) : (text) => text.startsWith(sequence) ; if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) { // no backslashes before sequence // -> sequence found if (escapes == 0) return true; // otherwise // -> sequence found if (!this.jumpedEscapeSequence && offset == 0) { this.index++; this.jumpedEscapeSequence = true; } return false; } } replaceGetvar(value) { return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (_, cmd, name) => { name = name.trim(); // store pipe const pipeName = `_PARSER_${uuidv4()}`; const storePipe = new SlashCommandExecutor(null); storePipe.command = this.commands['let']; storePipe.name = 'let'; storePipe.value = `${pipeName} {{pipe}}`; this.closure.executorList.push(storePipe); // getvar / getglobalvar const getvar = new SlashCommandExecutor(null); getvar.command = this.commands[cmd]; getvar.name = 'cmd'; getvar.value = name; this.closure.executorList.push(getvar); // set to temp scoped var const varName = `_PARSER_${uuidv4()}`; const setvar = new SlashCommandExecutor(null); setvar.command = this.commands['let']; setvar.name = 'let'; setvar.value = `${varName} {{pipe}}`; this.closure.executorList.push(setvar); // return pipe const returnPipe = new SlashCommandExecutor(null); returnPipe.command = this.commands['return']; returnPipe.name = 'return'; returnPipe.value = `{{var::${pipeName}}}`; this.closure.executorList.push(returnPipe); return `{{var::${varName}}}`; }); } parse(text, verifyCommandNames = true, flags = null) { this.verifyCommandNames = verifyCommandNames; for (const key of Object.keys(PARSER_FLAG)) { this.flags[PARSER_FLAG[key]] = flags?.[PARSER_FLAG[key]] ?? power_user.stscript.parser.flags[PARSER_FLAG[key]] ?? false; } this.text = `{:${text}:}`; this.keptText = ''; this.index = 0; this.scope = null; this.closureIndex = []; this.commandIndex = []; this.scopeIndex = []; const closure = this.parseClosure(); closure.keptText = this.keptText; return closure; } testClosure() { return this.testSymbol('{:'); } testClosureEnd() { if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed closure at position ${this.userIndex}`, this.text, this.index); return this.testSymbol(':}'); } parseClosure() { const closureIndexEntry = { start:this.index + 1, end:null }; this.closureIndex.push(closureIndexEntry); let injectPipe = true; this.take(2); // discard opening {: let closure = new SlashCommandClosure(this.scope); this.scope = closure.scope; this.closure = closure; this.discardWhitespace(); while (this.testNamedArgument()) { const arg = this.parseNamedArgument(); closure.arguments[arg.key] = arg.value; this.scope.variableNames.push(arg.key); this.discardWhitespace(); } while (!this.testClosureEnd()) { if (this.testComment()) { this.parseComment(); } else if (this.testParserFlag()) { this.parseParserFlag(); } else if (this.testRunShorthand()) { const cmd = this.parseRunShorthand(); closure.executorList.push(cmd); injectPipe = true; } else if (this.testCommand()) { const cmd = this.parseCommand(); cmd.injectPipe = injectPipe; closure.executorList.push(cmd); injectPipe = true; } else { while (!this.testCommandEnd()) this.take(); // discard plain text and comments } this.discardWhitespace(); // first pipe marks end of command if (this.testSymbol('|')) { this.take(); // discard first pipe // second pipe indicates no pipe injection for the next command if (this.testSymbol('|')) { injectPipe = false; this.take(); // discard second pipe } } this.discardWhitespace(); // discard further whitespace } this.take(2); // discard closing :} if (this.testSymbol('()')) { this.take(2); // discard () closure.executeNow = true; } closureIndexEntry.end = this.index - 1; this.discardWhitespace(); // discard trailing whitespace this.scope = closure.scope.parent; return closure; } testComment() { return this.testSymbol(/\/[/#]/); } testCommentEnd() { return this.testCommandEnd(); } parseComment() { const start = this.index + 1; const cmd = new SlashCommandExecutor(start); this.commandIndex.push(cmd); this.scopeIndex.push(this.scope.getCopy()); this.take(); // discard "/" cmd.name = this.take(); // set second "/" or "#" as name while (!this.testCommentEnd()) this.take(); cmd.end = this.index; } testParserFlag() { return this.testSymbol('/parser-flag '); } testParserFlagEnd() { return this.testCommandEnd(); } parseParserFlag() { const start = this.index + 1; const cmd = new SlashCommandExecutor(start); cmd.name = 'parser-flag'; cmd.value = ''; this.commandIndex.push(cmd); this.scopeIndex.push(this.scope.getCopy()); this.take(13); // discard "/parser-flag " const [flag, state] = this.parseUnnamedArgument()?.split(/\s+/) ?? [null, null]; if (Object.keys(PARSER_FLAG).includes(flag)) { this.flags[PARSER_FLAG[flag]] = isTrueBoolean(state ?? 'on'); } cmd.end = this.index; } testRunShorthand() { return this.testSymbol('/:') && !this.testSymbol(':}', 1); } testRunShorthandEnd() { return this.testCommandEnd(); } parseRunShorthand() { const start = this.index + 2; const cmd = new SlashCommandExecutor(start); cmd.name = ':'; cmd.value = ''; cmd.command = this.commands['run']; this.commandIndex.push(cmd); this.scopeIndex.push(this.scope.getCopy()); this.take(2); //discard "/:" if (this.testQuotedValue()) cmd.value = this.parseQuotedValue(); else cmd.value = this.parseValue(); this.discardWhitespace(); while (this.testNamedArgument()) { const arg = this.parseNamedArgument(); cmd.args[arg.key] = arg.value; this.discardWhitespace(); } this.discardWhitespace(); // /run shorthand does not take unnamed arguments (the command name practically *is* the unnamed argument) if (this.testRunShorthandEnd()) { cmd.end = this.index; if (!cmd.command?.purgeFromMessage) this.keptText += this.text.slice(cmd.start, cmd.end); return cmd; } else { console.warn(this.behind, this.char, this.ahead); throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index); } } testCommand() { return this.testSymbol('/'); } testCommandEnd() { return this.testClosureEnd() || this.testSymbol('|'); } parseCommand() { const start = this.index + 1; const cmd = new SlashCommandExecutor(start); cmd.parserFlags = Object.assign({}, this.flags); this.commandIndex.push(cmd); this.scopeIndex.push(this.scope.getCopy()); this.take(); // discard "/" while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end this.discardWhitespace(); if (this.verifyCommandNames && !this.commands[cmd.name]) throw new SlashCommandParserError(`Unknown command at position ${this.index - cmd.name.length - 2}: "/${cmd.name}"`, this.text, this.index - cmd.name.length); cmd.command = this.commands[cmd.name]; while (this.testNamedArgument()) { const arg = this.parseNamedArgument(); cmd.args[arg.key] = arg.value; this.discardWhitespace(); } this.discardWhitespace(); if (this.testUnnamedArgument()) { cmd.value = this.parseUnnamedArgument(); if (cmd.name == 'let') { if (Array.isArray(cmd.value)) { if (typeof cmd.value[0] == 'string') { this.scope.variableNames.push(cmd.value[0]); } } else if (typeof cmd.value == 'string') { this.scope.variableNames.push(cmd.value.split(/\s+/)[0]); } } } if (this.testCommandEnd()) { cmd.end = this.index; if (!cmd.command?.purgeFromMessage) this.keptText += this.text.slice(cmd.start, cmd.end); return cmd; } else { console.warn(this.behind, this.char, this.ahead); throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index); } } testNamedArgument() { return /^(\w+)=/.test(`${this.char}${this.ahead}`); } parseNamedArgument() { let key = ''; while (/\w/.test(this.char)) key += this.take(); // take chars this.take(); // discard "=" let value; if (this.testClosure()) { value = this.parseClosure(); } else if (this.testQuotedValue()) { value = this.parseQuotedValue(); } else if (this.testListValue()) { value = this.parseListValue(); } else if (this.testValue()) { value = this.parseValue(); } return { key, value }; } testUnnamedArgument() { return !this.testCommandEnd(); } testUnnamedArgumentEnd() { return this.testCommandEnd(); } parseUnnamedArgument() { /**@type {SlashCommandClosure|String}*/ let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one let isList = false; let listValues = []; while (!this.testUnnamedArgumentEnd()) { if (this.testClosure()) { isList = true; if (value.length > 0) { listValues.push(value.trim()); value = ''; } listValues.push(this.parseClosure()); } else if (this.testQuotedValue()) { isList = true; if (value.length > 0) { listValues.push(value.trim()); value = ''; } listValues.push(this.parseQuotedValue()); } else { value += this.take(); } } if (isList && value.trim().length > 0) { listValues.push(value.trim()); } if (isList) { if (listValues.length == 1) return listValues[0]; return listValues; } value = value.trim(); if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) { value = this.replaceGetvar(value); } return value; } testQuotedValue() { return this.testSymbol('"'); } testQuotedValueEnd() { if (this.endOfText) { if (this.verifyCommandNames) throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index); else return true; } if (!this.verifyCommandNames && this.testClosureEnd()) return true; if (this.verifyCommandNames && !this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd()) { throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index); } return this.testSymbol('"') || (!this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd()); } parseQuotedValue() { this.take(); // discard opening quote let value = ''; while (!this.testQuotedValueEnd()) value += this.take(); // take all chars until closing quote this.take(); // discard closing quote if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) { value = this.replaceGetvar(value); } return value; } testListValue() { return this.testSymbol('['); } testListValueEnd() { if (this.endOfText) throw new SlashCommandParserError(`Unexpected end of list value at position ${this.index}`, this.text, this.index); return this.testSymbol(']'); } parseListValue() { let value = this.take(); // take the already tested opening bracket while (!this.testListValueEnd()) value += this.take(); // take all chars until closing bracket value += this.take(); // take closing bracket if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) { value = this.replaceGetvar(value); } return value; } testValue() { return !this.testSymbol(/\s/); } testValueEnd() { if (this.testSymbol(/\s/)) return true; return this.testCommandEnd(); } parseValue() { let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one while (!this.testValueEnd()) value += this.take(); // take all chars until value end if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) { value = this.replaceGetvar(value); } return value; } }