import { SlashCommand } from './SlashCommand.js'; import { OPTION_TYPE, SlashCommandAutoCompleteOption } from './SlashCommandAutoCompleteOption.js'; import { SlashCommandClosure } from './SlashCommandClosure.js'; import { SlashCommandExecutor } from './SlashCommandExecutor.js'; import { SlashCommandParserError } from './SlashCommandParserError.js'; import { NAME_RESULT_TYPE, SlashCommandParserNameResult } from './SlashCommandParserNameResult.js'; // eslint-disable-next-line no-unused-vars import { SlashCommandScope } from './SlashCommandScope.js'; export class SlashCommandParser { // @ts-ignore /**@type {Object.}*/ commands = {}; // @ts-ignore /**@type {Object.}*/ helpStrings = {}; /**@type {Boolean}*/ verifyCommandNames = true; /**@type {String}*/ text; /**@type {String}*/ keptText; /**@type {Number}*/ index; /**@type {SlashCommandScope}*/ scope; /**@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() { // 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 COMMAND = { scope: 'command', begin: /\/\S+/, beginScope: 'title.function', end: /\||$|:}/, contains: [], // defined later }; const CLOSURE = { scope: 'closure', begin: /{:/, end: /:}/, contains: [], // defined later }; const CLOSURE_ARGS = { scope: 'params', begin: /:}\(/, end: /\)/, contains: [], }; const NAMED_ARG = { scope: 'type', begin: /\w+=/, end: '', }; const MACRO = { scope: 'operator', begin: /{{/, end: /}}/, }; COMMAND.contains.push( hljs.BACKSLASH_ESCAPE, NAMED_ARG, hljs.QUOTE_STRING_MODE, NUMBER, MACRO, CLOSURE, CLOSURE_ARGS, ); CLOSURE.contains.push( hljs.BACKSLASH_ESCAPE, NAMED_ARG, hljs.QUOTE_STRING_MODE, NUMBER, MACRO, COMMAND, 'self', CLOSURE_ARGS, ); CLOSURE_ARGS.contains.push( hljs.BACKSLASH_ESCAPE, NAMED_ARG, hljs.QUOTE_STRING_MODE, NUMBER, MACRO, CLOSURE, 'self', ); hljs.registerLanguage('stscript', ()=>({ case_insensitive: false, keywords: ['|'], contains: [ hljs.BACKSLASH_ESCAPE, COMMAND, CLOSURE, CLOSURE_ARGS, ], })); } addCommand(command, callback, aliases, helpString = '', interruptsGeneration = false, purgeFromMessage = true) { if (['/', '#'].includes(command[0])) { throw new Error(`Illegal Name. Slash command name cannot begin with "${command[0]}".`); } 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!'); } this.commands[command] = fnObj; if (Array.isArray(aliases)) { aliases.forEach((alias) => { this.commands[alias] = fnObj; }); } } getHelpString() { 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. `; } /** * * @param {*} text The text to parse. * @param {*} index Index to check for names (cursor position). */ getNameAt(text, index) { 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 == ':') { return new SlashCommandParserNameResult( NAME_RESULT_TYPE.CLOSURE, executor.value.toString(), executor.start, this.scopeIndex[this.commandIndex.indexOf(executor)] ?.allVariableNames ?.map(it=>new SlashCommandAutoCompleteOption(OPTION_TYPE.VARIABLE_NAME, it, it)) ?? [] , ); } return new SlashCommandParserNameResult( NAME_RESULT_TYPE.COMMAND, executor.name, executor.start, ); } 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) { 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 } /** * 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) { // /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 const escapes = this.text.slice(this.index + offset).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 + 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 (offset == 0) this.index++; return false; } } parse(text, verifyCommandNames = true) { this.verifyCommandNames = verifyCommandNames; 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.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.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; } 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('/') && !this.testSymbol('//') && !this.testSymbol('/#'); } testCommandEnd() { return this.testClosureEnd() || this.testSymbol('|'); } parseCommand() { const start = this.index + 1; const cmd = new SlashCommandExecutor(start); 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 = ''; 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 { value += this.take(); } } if (isList && value.trim().length > 0) { listValues.push(value.trim()); } if (isList) { if (listValues.length == 1) return listValues[0]; return listValues; } return value.trim(); } 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; return this.testSymbol('"'); } 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 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 = ''; while (!this.testListValueEnd()) value += this.take(); // take all chars until closing bracket value += this.take(); // take closing bracket return value; } testValue() { return !this.testSymbol(/\s/); } testValueEnd() { if (this.testSymbol(/\s/)) return true; return this.testCommandEnd(); } parseValue() { let value = ''; while (!this.testValueEnd()) value += this.take(); // take all chars until value end return value; } }