mirror of
https://github.com/SillyTavern/SillyTavern.git
synced 2025-06-05 21:59:27 +02:00
STscript Parser Rewrite (#1965)
* set isForced to true on input * make floating auto-complete follow horizontal scrolling * add callable closure vars * changes to /let and /var for callable closures * fix error message * fix scope for closure arguments * if should return the pipe result from closures * use /run to call closures and no arguments on immediate closures * throw exception from QRs window-function if no match * when to show autocomplete vs info only * autocomplete positioning * autocomplete styling * add theming to autocomplete (theme, dark, light) * improve autocomplete show/hide logic and editor selection * use blur tint color instead of chat tint color and use blur setting * cleanup and docs * use scope macros for QR args * add enter to select autocomplete * fix no executor found * cleanup and comment * fix alias list in help string * fallback to empty string piped value if null or undefined * fix typo * blur textarea on ctrl+enter execute (and refocus after) * stop executeSlashCommand if parser throws * move /let and /var callbacks into functions * switch textarea to monospace when value starts with slash * add double pipe a pipe breaker * fix /? slash * remove some logging * add "/:name" as shorthand for "/run name" after all * move shit around * fix error message * use testRunShorthandEnd * use parseQuotedValue and parseValue to determine name for "/:" QR labels and set names can include spaces * add some adjustments to make autocomplete work properly some hint in there about "/:" would still be nice * add autocomplete style selector * only strip quotes from subcommand if they are at both ends * fix JSDoc * escaping * allow open quotes on dry run * throwing shit at the wall for /: autocomplete * escapes only for symbols * clean up autocomplete * improve performance * fix scope macros * remove unescaping of pipes * fix macros in scope copy * fix "/? slash" * don't run parser for getNameAt if text has not changed * fix options filter * re-enable blur listener * restore selection on non-replace select * fix for escaping first character of value * add support for {{pipe}} and {{var::}} closures * add index support to var macro * add scoped var macro to macro help * more escape fixes * reduce autocomplete render debounce * cleanup * restore old escape handling and parser flag for strict escaping * fix "no match" autocomplete message * add dummy commands for comments and parser flag * fix type annotations * somewhat safer macro replacements * fix autocomplete select on blank / "no match" * fix cutting off handled part in substitution * add parser flag REPLACE_GETVAR Replaces all {{getvar::}} and {{getglobalvar::}} macros with {{var::}}. Inserts a series of command executors before the command with the macros that: - save {{pipe}} to a var - call /getvar or /getglobalvar to get the variable used in the macro - call /let to save the retrieved variable - return the saved {{pipe}} value This helps to avoid double-substitutions when the var values contain text that could be interpreted as macros. * remove old parser * fix send on enter when no match * deal with pipes in quoted values (loose escaping) * add default parser flags to user settings * allow quoted values in unnamed argument * set parser flag without explicit state to "on" * add click hint on parser error toast * dirty more detailed cmd defs * remove name from unnamed arg * move autocomplete into class and floating with details * replace jQuery's trigger('input') on #send_textarea with native events because jQuery does not dispatch the native event * fix ctrl+space * fix arrow navigation * add comments * fix pointer block * add static fromProps * fix up dummy commands * migrate all commands to addCommandObject * remove commented comment command * fix alias in details * add range as argument type * switch to addCommandObject * switch to addCommandObject * fix height * fix floating details position on left * re-enable blur event * use auto width for full details on floating autocomplete * auto-size floating full details * fix typo * re-enable blur listener * don't prevent enter when selected item is fully typed out * add autocomplete details tooltips * add language to slash command examples * move makeItem into option and command and fix click select * use autocomplete parts in /? slash * fix alias formatting * add language to slash command examples * fix details position on initial input history * small screen styles * replace registerSlashCommand with detailed declarations * put name on first line * add missing returns * fix missing comma * fix alias display in autocomplete list * remove args from help string * move parser settings to its own section * jsdoc * hljs stscript lang * add hljs to autocomplete help examples * add missing import * apply autocomplete colors to stscript codeblocks (hljs) * add fromProps * cache autocomplete elements * towards generic autocomplete * remove unused imports * fix blanks * add return types * re-enable blur * fix blank check * Caption messages by id * add aborting command execution * fix return type * fix chat input font reset * add slash command progress indicator * add missing return * mark registerSlashCommand deprecated * why?? * separate abort logic for commands * remove parsing of quoted values from unnamed arg * add adjustable autocomplete width * revert stop button pulse * add progress and pause/abort to QR editor * add resize event on autocomplete width change * add key= argument to all get vars * refactoring * introduce NamedArgumentAsignment * add TODOs * refactoring * record start and end of named arg assignment * refactoring * prevent duplicate calls to show * refactoring * remove macro ac * add secondary autocomplete and enum descriptions * add syntax highlighting to QR editor * add enum descriptions to /while * add /let key=... to scope variable names * add unnamed argument assignment class and unnamed argument splitting * fix QR editor style * remove dash before autocomplete help text * add autocomplete for unnamed enums * fix remaining dom after holding backslash * fix for unnamed enums * fix autocomplete for /parser-flag * add parser-flag enum help * fix type annotations * fix autocomplete result for /: * add colored autocomplete type icons * collapse second line autocomplete help if empty * mark optional named args in autocomplete * fix when what * remove duplicate debug buttons * dispatch input on autocomplete select * prevent grow from editor syntax layer * add auto-adjust qr editor caret color * remove text-shadow from autocomplete * join value strings in /let and /var * add /abort syntax highlight * fix attempting secondary result when there is none * rename settings headers and split autocomplete / stscript * add parser flag tooltips * add tooltips to chat width stops * fix typo * return clone of help item * fix enum string * don't make optional notice for autocomplete arguments smaller * avoid scrollbar in chat input * add rudimentary macro autocomplete * strip macro from helptext * finally remove closure delimiters around root * cleanup * fix index stuff for removed closure delimiters * fix type hint * add child commands to progress indicator * include sub-separator in macro autocomplete * remove all mentions of interruptsGeneration and purge * remove unused imports * fix syntax highlight with newline at end of input * cleanup select pointer events * coalesce onProgress call * add regex to STscript syntax highlighting * fix closure end * fix autocomplete type icon alignment * adjustments for small screens * fix removing wrong element * add missing "at=" arg to /sys, /comment, /sendas * add font scale setting for autocomplete * add target=_blank for parser flag links * fix for searching enums * remove REGEXP_MODE from hljs just causes trouble * fix autocomplete in closures * fix typo * fix type hint * Get rid of scroll bar on load * Add type hint for /send name argument. Fix 'at' types * Add 'negative' arg hint to /sd command * reenable blur event * Allow /summarize to process any text * Compact layout of script toggles * Expand CSS by default * fix double ranger indicator and adjust to narrow container * make custom css input fill available vertical space * reduce scroll lag * use default cursor on scrollbar * Clean-up module loading in index.html * fix tab indent with hljs --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
359
public/scripts/slash-commands/SlashCommand.js
Normal file
359
public/scripts/slash-commands/SlashCommand.js
Normal file
@ -0,0 +1,359 @@
|
||||
import { SlashCommandArgument, SlashCommandNamedArgument } from './SlashCommandArgument.js';
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
|
||||
|
||||
|
||||
export class SlashCommand {
|
||||
/**
|
||||
* Creates a SlashCommand from a properties object.
|
||||
* @param {Object} props
|
||||
* @param {string} [props.name]
|
||||
* @param {(namedArguments:Object.<string,string|SlashCommandClosure>, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|void|Promise<string|SlashCommandClosure|void>} [props.callback]
|
||||
* @param {string} [props.helpString]
|
||||
* @param {boolean} [props.splitUnnamedArgument]
|
||||
* @param {string[]} [props.aliases]
|
||||
* @param {string} [props.returns]
|
||||
* @param {SlashCommandNamedArgument[]} [props.namedArgumentList]
|
||||
* @param {SlashCommandArgument[]} [props.unnamedArgumentList]
|
||||
*/
|
||||
static fromProps(props) {
|
||||
const instance = Object.assign(new this(), props);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {string}*/ name;
|
||||
/**@type {(namedArguments:Object<string, string|SlashCommandClosure>, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>}*/ callback;
|
||||
/**@type {string}*/ helpString;
|
||||
/**@type {boolean}*/ splitUnnamedArgument = false;
|
||||
/**@type {string[]}*/ aliases = [];
|
||||
/**@type {string}*/ returns;
|
||||
/**@type {SlashCommandNamedArgument[]}*/ namedArgumentList = [];
|
||||
/**@type {SlashCommandArgument[]}*/ unnamedArgumentList = [];
|
||||
|
||||
/**@type {Object.<string, HTMLElement>}*/ helpCache = {};
|
||||
/**@type {Object.<string, DocumentFragment>}*/ helpDetailsCache = {};
|
||||
|
||||
renderHelpItem(key = null) {
|
||||
key = key ?? this.name;
|
||||
if (!this.helpCache[key]) {
|
||||
const typeIcon = '[/]';
|
||||
const li = document.createElement('li'); {
|
||||
li.classList.add('item');
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('type');
|
||||
type.classList.add('monospace');
|
||||
type.textContent = typeIcon;
|
||||
li.append(type);
|
||||
}
|
||||
const specs = document.createElement('span'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = '/';
|
||||
key.split('').forEach(char=>{
|
||||
const span = document.createElement('span'); {
|
||||
span.textContent = char;
|
||||
name.append(span);
|
||||
}
|
||||
});
|
||||
specs.append(name);
|
||||
}
|
||||
const body = document.createElement('span'); {
|
||||
body.classList.add('body');
|
||||
const args = document.createElement('span'); {
|
||||
args.classList.add('arguments');
|
||||
for (const arg of this.namedArgumentList) {
|
||||
const argItem = document.createElement('span'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('namedArgument');
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('argument-name');
|
||||
name.textContent = arg.name;
|
||||
argItem.append(name);
|
||||
}
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
args.append(argItem);
|
||||
}
|
||||
}
|
||||
for (const arg of this.unnamedArgumentList) {
|
||||
const argItem = document.createElement('span'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('unnamedArgument');
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
args.append(argItem);
|
||||
}
|
||||
}
|
||||
body.append(args);
|
||||
}
|
||||
const returns = document.createElement('span'); {
|
||||
returns.classList.add('returns');
|
||||
returns.textContent = this.returns ?? 'void';
|
||||
body.append(returns);
|
||||
}
|
||||
specs.append(body);
|
||||
}
|
||||
li.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
const content = document.createElement('span'); {
|
||||
content.classList.add('helpContent');
|
||||
content.innerHTML = this.helpString;
|
||||
const text = content.textContent;
|
||||
content.innerHTML = '';
|
||||
content.textContent = text;
|
||||
help.append(content);
|
||||
}
|
||||
li.append(help);
|
||||
}
|
||||
if (this.aliases.length > 0) {
|
||||
const aliases = document.createElement('span'); {
|
||||
aliases.classList.add('aliases');
|
||||
aliases.append(' (alias: ');
|
||||
for (const aliasName of this.aliases) {
|
||||
const alias = document.createElement('span'); {
|
||||
alias.classList.add('monospace');
|
||||
alias.textContent = `/${aliasName}`;
|
||||
aliases.append(alias);
|
||||
}
|
||||
}
|
||||
aliases.append(')');
|
||||
// li.append(aliases);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.helpCache[key] = li;
|
||||
}
|
||||
return /**@type {HTMLElement}*/(this.helpCache[key].cloneNode(true));
|
||||
}
|
||||
|
||||
renderHelpDetails(key = null) {
|
||||
key = key ?? this.name;
|
||||
if (!this.helpDetailsCache[key]) {
|
||||
const frag = document.createDocumentFragment();
|
||||
const cmd = this;
|
||||
const namedArguments = cmd.namedArgumentList ?? [];
|
||||
const unnamedArguments = cmd.unnamedArgumentList ?? [];
|
||||
const returnType = cmd.returns ?? 'void';
|
||||
const helpString = cmd.helpString ?? 'NO DETAILS';
|
||||
const aliasList = [cmd.name, ...(cmd.aliases ?? [])].filter(it=>it != key);
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.title = 'command name';
|
||||
name.textContent = `/${key}`;
|
||||
specs.append(name);
|
||||
}
|
||||
const body = document.createElement('div'); {
|
||||
body.classList.add('body');
|
||||
const args = document.createElement('ul'); {
|
||||
args.classList.add('arguments');
|
||||
for (const arg of namedArguments) {
|
||||
const listItem = document.createElement('li'); {
|
||||
listItem.classList.add('argumentItem');
|
||||
const argSpec = document.createElement('div'); {
|
||||
argSpec.classList.add('argumentSpec');
|
||||
const argItem = document.createElement('div'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('namedArgument');
|
||||
argItem.title = `${arg.isRequired ? '' : 'optional '}named argument`;
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
const name = document.createElement('span'); {
|
||||
name.classList.add('argument-name');
|
||||
name.title = `${argItem.title} - name`;
|
||||
name.textContent = arg.name;
|
||||
argItem.append(name);
|
||||
}
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
enums.title = `${argItem.title} - accepted values`;
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
types.title = `${argItem.title} - accepted types`;
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
argSpec.append(argItem);
|
||||
}
|
||||
if (arg.defaultValue !== null) {
|
||||
const argDefault = document.createElement('div'); {
|
||||
argDefault.classList.add('argument-default');
|
||||
argDefault.title = 'default value';
|
||||
argDefault.textContent = arg.defaultValue.toString();
|
||||
argSpec.append(argDefault);
|
||||
}
|
||||
}
|
||||
listItem.append(argSpec);
|
||||
}
|
||||
const desc = document.createElement('div'); {
|
||||
desc.classList.add('argument-description');
|
||||
desc.innerHTML = arg.description;
|
||||
listItem.append(desc);
|
||||
}
|
||||
args.append(listItem);
|
||||
}
|
||||
}
|
||||
for (const arg of unnamedArguments) {
|
||||
const listItem = document.createElement('li'); {
|
||||
listItem.classList.add('argumentItem');
|
||||
const argItem = document.createElement('div'); {
|
||||
argItem.classList.add('argument');
|
||||
argItem.classList.add('unnamedArgument');
|
||||
argItem.title = `${arg.isRequired ? '' : 'optional '}unnamed argument`;
|
||||
if (!arg.isRequired || (arg.defaultValue ?? false)) argItem.classList.add('optional');
|
||||
if (arg.acceptsMultiple) argItem.classList.add('multiple');
|
||||
if (arg.enumList.length > 0) {
|
||||
const enums = document.createElement('span'); {
|
||||
enums.classList.add('argument-enums');
|
||||
enums.title = `${argItem.title} - accepted values`;
|
||||
for (const e of arg.enumList) {
|
||||
const enumItem = document.createElement('span'); {
|
||||
enumItem.classList.add('argument-enum');
|
||||
enumItem.textContent = e.value;
|
||||
enums.append(enumItem);
|
||||
}
|
||||
}
|
||||
argItem.append(enums);
|
||||
}
|
||||
} else {
|
||||
const types = document.createElement('span'); {
|
||||
types.classList.add('argument-types');
|
||||
types.title = `${argItem.title} - accepted types`;
|
||||
for (const t of arg.typeList) {
|
||||
const type = document.createElement('span'); {
|
||||
type.classList.add('argument-type');
|
||||
type.textContent = t;
|
||||
types.append(type);
|
||||
}
|
||||
}
|
||||
argItem.append(types);
|
||||
}
|
||||
}
|
||||
listItem.append(argItem);
|
||||
}
|
||||
const desc = document.createElement('div'); {
|
||||
desc.classList.add('argument-description');
|
||||
desc.innerHTML = arg.description;
|
||||
listItem.append(desc);
|
||||
}
|
||||
args.append(listItem);
|
||||
}
|
||||
}
|
||||
body.append(args);
|
||||
}
|
||||
const returns = document.createElement('span'); {
|
||||
returns.classList.add('returns');
|
||||
returns.title = [null, undefined, 'void'].includes(returnType) ? 'command does not return anything' : 'return value';
|
||||
returns.textContent = returnType ?? 'void';
|
||||
body.append(returns);
|
||||
}
|
||||
specs.append(body);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.innerHTML = helpString;
|
||||
for (const code of help.querySelectorAll('pre > code')) {
|
||||
code.classList.add('language-stscript');
|
||||
hljs.highlightElement(code);
|
||||
}
|
||||
frag.append(help);
|
||||
}
|
||||
if (aliasList.length > 0) {
|
||||
const aliases = document.createElement('span'); {
|
||||
aliases.classList.add('aliases');
|
||||
for (const aliasName of aliasList) {
|
||||
const alias = document.createElement('span'); {
|
||||
alias.classList.add('alias');
|
||||
alias.textContent = `/${aliasName}`;
|
||||
aliases.append(alias);
|
||||
}
|
||||
}
|
||||
frag.append(aliases);
|
||||
}
|
||||
}
|
||||
this.helpDetailsCache[key] = frag;
|
||||
}
|
||||
const frag = document.createDocumentFragment();
|
||||
frag.append(this.helpDetailsCache[key].cloneNode(true));
|
||||
return frag;
|
||||
}
|
||||
}
|
27
public/scripts/slash-commands/SlashCommandAbortController.js
Normal file
27
public/scripts/slash-commands/SlashCommandAbortController.js
Normal file
@ -0,0 +1,27 @@
|
||||
export class SlashCommandAbortController {
|
||||
/**@type {SlashCommandAbortSignal}*/ signal;
|
||||
|
||||
|
||||
constructor() {
|
||||
this.signal = new SlashCommandAbortSignal();
|
||||
}
|
||||
abort(reason = 'No reason.') {
|
||||
this.signal.aborted = true;
|
||||
this.signal.reason = reason;
|
||||
}
|
||||
pause(reason = 'No reason.') {
|
||||
this.signal.paused = true;
|
||||
this.signal.reason = reason;
|
||||
}
|
||||
continue(reason = 'No reason.') {
|
||||
this.signal.paused = false;
|
||||
this.signal.reason = reason;
|
||||
}
|
||||
}
|
||||
|
||||
export class SlashCommandAbortSignal {
|
||||
/**@type {boolean}*/ paused = false;
|
||||
/**@type {boolean}*/ aborted = false;
|
||||
/**@type {string}*/ reason = null;
|
||||
|
||||
}
|
121
public/scripts/slash-commands/SlashCommandArgument.js
Normal file
121
public/scripts/slash-commands/SlashCommandArgument.js
Normal file
@ -0,0 +1,121 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
|
||||
|
||||
|
||||
/**@readonly*/
|
||||
/**@enum {string}*/
|
||||
export const ARGUMENT_TYPE = {
|
||||
'STRING': 'string',
|
||||
'NUMBER': 'number',
|
||||
'RANGE': 'range',
|
||||
'BOOLEAN': 'bool',
|
||||
'VARIABLE_NAME': 'varname',
|
||||
'CLOSURE': 'closure',
|
||||
'SUBCOMMAND': 'subcommand',
|
||||
'LIST': 'list',
|
||||
'DICTIONARY': 'dictionary',
|
||||
};
|
||||
|
||||
|
||||
|
||||
export class SlashCommandArgument {
|
||||
/**
|
||||
* Creates an unnamed argument from a poperties object.
|
||||
* @param {Object} props
|
||||
* @param {string} props.description description of the argument
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} props.typeList default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
|
||||
* @param {boolean} [props.isRequired] default: false - whether the argument is required (false = optional argument)
|
||||
* @param {boolean} [props.acceptsMultiple] default: false - whether argument accepts multiple values
|
||||
* @param {string|SlashCommandClosure} [props.defaultValue] default value if no value is provided
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList] list of accepted values
|
||||
*/
|
||||
static fromProps(props) {
|
||||
return new SlashCommandArgument(
|
||||
props.description,
|
||||
props.typeList ?? [ARGUMENT_TYPE.STRING],
|
||||
props.isRequired ?? false,
|
||||
props.acceptsMultiple ?? false,
|
||||
props.defaultValue ?? null,
|
||||
props.enumList ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {string}*/ description;
|
||||
/**@type {ARGUMENT_TYPE[]}*/ typeList = [];
|
||||
/**@type {boolean}*/ isRequired = false;
|
||||
/**@type {boolean}*/ acceptsMultiple = false;
|
||||
/**@type {string|SlashCommandClosure}*/ defaultValue;
|
||||
/**@type {SlashCommandEnumValue[]}*/ enumList = [];
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} description
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
|
||||
* @param {string|SlashCommandClosure} defaultValue
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums
|
||||
*/
|
||||
constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = []) {
|
||||
this.description = description;
|
||||
this.typeList = types ? Array.isArray(types) ? types : [types] : [];
|
||||
this.isRequired = isRequired ?? false;
|
||||
this.acceptsMultiple = acceptsMultiple ?? false;
|
||||
this.defaultValue = defaultValue;
|
||||
this.enumList = (enums ? Array.isArray(enums) ? enums : [enums] : []).map(it=>{
|
||||
if (it instanceof SlashCommandEnumValue) return it;
|
||||
return new SlashCommandEnumValue(it);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export class SlashCommandNamedArgument extends SlashCommandArgument {
|
||||
/**
|
||||
* Creates an unnamed argument from a poperties object.
|
||||
* @param {Object} props
|
||||
* @param {string} props.name the argument's name
|
||||
* @param {string[]} [props.aliasList] list of aliases
|
||||
* @param {string} props.description description of the argument
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} props.typeList default: ARGUMENT_TYPE.STRING - list of accepted types (from ARGUMENT_TYPE)
|
||||
* @param {boolean} [props.isRequired] default: false - whether the argument is required (false = optional argument)
|
||||
* @param {boolean} [props.acceptsMultiple] default: false - whether argument accepts multiple values
|
||||
* @param {string|SlashCommandClosure} [props.defaultValue] default value if no value is provided
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList] list of accepted values
|
||||
*/
|
||||
static fromProps(props) {
|
||||
return new SlashCommandNamedArgument(
|
||||
props.name,
|
||||
props.description,
|
||||
props.typeList ?? [ARGUMENT_TYPE.STRING],
|
||||
props.isRequired ?? false,
|
||||
props.acceptsMultiple ?? false,
|
||||
props.defaultValue ?? null,
|
||||
props.enumList ?? [],
|
||||
props.aliasList ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**@type {string}*/ name;
|
||||
/**@type {string[]}*/ aliasList = [];
|
||||
|
||||
|
||||
/**
|
||||
* @param {string} name
|
||||
* @param {string} description
|
||||
* @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types
|
||||
* @param {string|SlashCommandClosure} defaultValue
|
||||
* @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums
|
||||
*/
|
||||
constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = []) {
|
||||
super(description, types, isRequired, acceptsMultiple, defaultValue, enums);
|
||||
this.name = name;
|
||||
this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : [];
|
||||
}
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
import { AutoCompleteNameResult } from '../autocomplete/AutoCompleteNameResult.js';
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
import { AutoCompleteSecondaryNameResult } from '../autocomplete/AutoCompleteSecondaryNameResult.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandNamedArgument } from './SlashCommandArgument.js';
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { SlashCommandCommandAutoCompleteOption } from './SlashCommandCommandAutoCompleteOption.js';
|
||||
import { SlashCommandEnumAutoCompleteOption } from './SlashCommandEnumAutoCompleteOption.js';
|
||||
import { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
import { SlashCommandNamedArgumentAutoCompleteOption } from './SlashCommandNamedArgumentAutoCompleteOption.js';
|
||||
|
||||
export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult {
|
||||
/**@type {SlashCommandExecutor}*/ executor;
|
||||
|
||||
/**
|
||||
* @param {SlashCommandExecutor} executor
|
||||
* @param {Object.<string,SlashCommand>} commands
|
||||
*/
|
||||
constructor(executor, commands) {
|
||||
super(
|
||||
executor.name,
|
||||
executor.start,
|
||||
Object
|
||||
.keys(commands)
|
||||
.map(key=>new SlashCommandCommandAutoCompleteOption(commands[key], key))
|
||||
,
|
||||
false,
|
||||
()=>`No matching slash commands for "/${this.name}"`,
|
||||
()=>'No slash commands found!',
|
||||
);
|
||||
this.executor = executor;
|
||||
}
|
||||
|
||||
getSecondaryNameAt(text, index, isSelect) {
|
||||
const namedResult = this.getNamedArgumentAt(text, index, isSelect);
|
||||
if (!namedResult || namedResult.optionList.length == 0 || !namedResult.isRequired) {
|
||||
const unnamedResult = this.getUnnamedArgumentAt(text, index, isSelect);
|
||||
if (!namedResult) return unnamedResult;
|
||||
if (namedResult && unnamedResult) {
|
||||
const combinedResult = new AutoCompleteSecondaryNameResult(
|
||||
namedResult.name,
|
||||
namedResult.start,
|
||||
[...namedResult.optionList, ...unnamedResult.optionList],
|
||||
);
|
||||
combinedResult.isRequired = namedResult.isRequired || unnamedResult.isRequired;
|
||||
return combinedResult;
|
||||
}
|
||||
}
|
||||
return namedResult;
|
||||
}
|
||||
|
||||
getNamedArgumentAt(text, index, isSelect) {
|
||||
const notProvidedNamedArguments = this.executor.command.namedArgumentList.filter(arg=>!this.executor.namedArgumentList.find(it=>it.name == arg.name));
|
||||
let name;
|
||||
let value;
|
||||
let start;
|
||||
let cmdArg;
|
||||
let argAssign;
|
||||
const unamedArgLength = this.executor.endUnnamedArgs - this.executor.startUnnamedArgs;
|
||||
const namedArgsFollowedBySpace = text[this.executor.endNamedArgs] == ' ';
|
||||
if (this.executor.startNamedArgs <= index && this.executor.endNamedArgs + (namedArgsFollowedBySpace ? 1 : 0) >= index) {
|
||||
// cursor is somewhere within the named arguments (including final space)
|
||||
argAssign = this.executor.namedArgumentList.find(it=>it.start <= index && it.end >= index);
|
||||
if (argAssign) {
|
||||
const [argName, ...v] = text.slice(argAssign.start, index).split(/(?<==)/);
|
||||
name = argName;
|
||||
value = v.join('');
|
||||
start = argAssign.start;
|
||||
cmdArg = this.executor.command.namedArgumentList.find(it=>[it.name, `${it.name}=`].includes(argAssign.name));
|
||||
if (cmdArg) notProvidedNamedArguments.push(cmdArg);
|
||||
} else {
|
||||
name = '';
|
||||
start = index;
|
||||
}
|
||||
} else if (unamedArgLength > 0 && index >= this.executor.startUnnamedArgs && index <= this.executor.endUnnamedArgs) {
|
||||
// cursor is somewhere within the unnamed arguments
|
||||
//TODO if index is in first array item and that is a string, treat it as an unfinished named arg
|
||||
if (typeof this.executor.unnamedArgumentList[0].value == 'string') {
|
||||
if (index <= this.executor.startUnnamedArgs + this.executor.unnamedArgumentList[0].value.length) {
|
||||
name = this.executor.unnamedArgumentList[0].value.slice(0, index - this.executor.startUnnamedArgs);
|
||||
start = this.executor.startUnnamedArgs;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name.includes('=') && cmdArg) {
|
||||
// if cursor is already behind "=" check for enums
|
||||
/**@type {SlashCommandNamedArgument} */
|
||||
if (cmdArg && cmdArg.enumList?.length) {
|
||||
if (isSelect && cmdArg.enumList.includes(value) && argAssign && argAssign.end == index) {
|
||||
return null;
|
||||
}
|
||||
const result = new AutoCompleteSecondaryNameResult(
|
||||
value,
|
||||
start + name.length,
|
||||
cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(it)),
|
||||
true,
|
||||
);
|
||||
result.isRequired = true;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if (notProvidedNamedArguments.length > 0) {
|
||||
const result = new AutoCompleteSecondaryNameResult(
|
||||
name,
|
||||
start,
|
||||
notProvidedNamedArguments.map(it=>new SlashCommandNamedArgumentAutoCompleteOption(it, this.executor.command)),
|
||||
false,
|
||||
);
|
||||
result.isRequired = notProvidedNamedArguments.find(it=>it.isRequired) != null;
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getUnnamedArgumentAt(text, index, isSelect) {
|
||||
const lastArgIsBlank = this.executor.unnamedArgumentList.slice(-1)[0]?.value == '';
|
||||
const notProvidedArguments = this.executor.command.unnamedArgumentList.slice(this.executor.unnamedArgumentList.length - (lastArgIsBlank ? 1 : 0));
|
||||
let value;
|
||||
let start;
|
||||
let cmdArg;
|
||||
let argAssign;
|
||||
if (this.executor.startUnnamedArgs <= index && this.executor.endUnnamedArgs + 1 >= index) {
|
||||
// cursor is somwehere in the unnamed args
|
||||
const idx = this.executor.unnamedArgumentList.findIndex(it=>it.start <= index && it.end >= index);
|
||||
if (idx > -1) {
|
||||
argAssign = this.executor.unnamedArgumentList[idx];
|
||||
cmdArg = this.executor.command.unnamedArgumentList[idx];
|
||||
if (cmdArg && cmdArg.enumList.length > 0) {
|
||||
value = argAssign.value.toString().slice(0, index - argAssign.start);
|
||||
start = argAssign.start;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
value = '';
|
||||
start = index;
|
||||
cmdArg = notProvidedArguments[0];
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cmdArg == null || cmdArg.enumList.length == 0) return null;
|
||||
|
||||
const result = new AutoCompleteSecondaryNameResult(
|
||||
value,
|
||||
start,
|
||||
cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(it)),
|
||||
false,
|
||||
);
|
||||
const isCompleteValue = cmdArg.enumList.find(it=>it.value == value);
|
||||
const isSelectedValue = isSelect && isCompleteValue;
|
||||
result.isRequired = cmdArg.isRequired && !isSelectedValue && !isCompleteValue;
|
||||
return result;
|
||||
}
|
||||
}
|
148
public/scripts/slash-commands/SlashCommandBrowser.js
Normal file
148
public/scripts/slash-commands/SlashCommandBrowser.js
Normal file
@ -0,0 +1,148 @@
|
||||
import { escapeRegex } from '../utils.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandParser } from './SlashCommandParser.js';
|
||||
|
||||
export class SlashCommandBrowser {
|
||||
/**@type {SlashCommand[]}*/ cmdList;
|
||||
/**@type {HTMLElement}*/ dom;
|
||||
/**@type {HTMLElement}*/ search;
|
||||
/**@type {HTMLElement}*/ details;
|
||||
/**@type {Object.<string,HTMLElement>}*/ itemMap = {};
|
||||
/**@type {MutationObserver}*/ mo;
|
||||
|
||||
renderInto(parent) {
|
||||
if (!this.dom) {
|
||||
const queryRegex = /(?:(?:^|\s+)([^\s"][^\s]*?)(?:\s+|$))|(?:(?:^|\s+)"(.*?)(?:"|$)(?:\s+|$))/;
|
||||
const root = document.createElement('div'); {
|
||||
this.dom = root;
|
||||
const search = document.createElement('div'); {
|
||||
search.classList.add('search');
|
||||
const lbl = document.createElement('label'); {
|
||||
lbl.classList.add('searchLabel');
|
||||
lbl.textContent = 'Search: ';
|
||||
const inp = document.createElement('input'); {
|
||||
this.search = inp;
|
||||
inp.classList.add('searchInput');
|
||||
inp.classList.add('text_pole');
|
||||
inp.type = 'search';
|
||||
inp.placeholder = 'Search slash commands - use quotes to search "literal" instead of fuzzy';
|
||||
inp.addEventListener('input', ()=>{
|
||||
this.details?.remove();
|
||||
this.details = null;
|
||||
let query = inp.value.trim();
|
||||
if (query.slice(-1) == '"' && !/(?:^|\s+)"/.test(query)) {
|
||||
query = `"${query}`;
|
||||
}
|
||||
let fuzzyList = [];
|
||||
let quotedList = [];
|
||||
while (query.length > 0) {
|
||||
const match = queryRegex.exec(query);
|
||||
if (!match) break;
|
||||
if (match[1] !== undefined) {
|
||||
fuzzyList.push(new RegExp(`^(.*?)${match[1].split('').map(char=>`(${escapeRegex(char)})`).join('(.*?)')}(.*?)$`, 'i'));
|
||||
} else if (match[2] !== undefined) {
|
||||
quotedList.push(match[2]);
|
||||
}
|
||||
query = query.slice(match.index + match[0].length);
|
||||
}
|
||||
for (const cmd of this.cmdList) {
|
||||
const targets = [
|
||||
cmd.name,
|
||||
...cmd.namedArgumentList.map(it=>it.name),
|
||||
...cmd.namedArgumentList.map(it=>it.description),
|
||||
...cmd.namedArgumentList.map(it=>it.enumList.map(e=>e.value)).flat(),
|
||||
...cmd.namedArgumentList.map(it=>it.typeList).flat(),
|
||||
...cmd.unnamedArgumentList.map(it=>it.description),
|
||||
...cmd.unnamedArgumentList.map(it=>it.enumList.map(e=>e.value)).flat(),
|
||||
...cmd.unnamedArgumentList.map(it=>it.typeList).flat(),
|
||||
...cmd.aliases,
|
||||
cmd.helpString,
|
||||
];
|
||||
const find = ()=>targets.find(t=>(fuzzyList.find(f=>f.test(t)) ?? quotedList.find(q=>t.includes(q))) !== undefined) !== undefined;
|
||||
if (fuzzyList.length + quotedList.length == 0 || find()) {
|
||||
this.itemMap[cmd.name].classList.remove('isFiltered');
|
||||
} else {
|
||||
this.itemMap[cmd.name].classList.add('isFiltered');
|
||||
}
|
||||
}
|
||||
});
|
||||
lbl.append(inp);
|
||||
}
|
||||
search.append(lbl);
|
||||
}
|
||||
root.append(search);
|
||||
}
|
||||
const container = document.createElement('div'); {
|
||||
container.classList.add('commandContainer');
|
||||
const list = document.createElement('div'); {
|
||||
list.classList.add('autoComplete');
|
||||
this.cmdList = Object
|
||||
.keys(SlashCommandParser.commands)
|
||||
.filter(key => SlashCommandParser.commands[key].name == key) // exclude aliases
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
.map(key => SlashCommandParser.commands[key])
|
||||
;
|
||||
for (const cmd of this.cmdList) {
|
||||
const item = cmd.renderHelpItem();
|
||||
this.itemMap[cmd.name] = item;
|
||||
let details;
|
||||
item.addEventListener('click', ()=>{
|
||||
if (!details) {
|
||||
details = document.createElement('div'); {
|
||||
details.classList.add('autoComplete-detailsWrap');
|
||||
const inner = document.createElement('div'); {
|
||||
inner.classList.add('autoComplete-details');
|
||||
inner.append(cmd.renderHelpDetails());
|
||||
details.append(inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.details != details) {
|
||||
Array.from(list.querySelectorAll('.selected')).forEach(it=>it.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
this.details?.remove();
|
||||
container.append(details);
|
||||
this.details = details;
|
||||
const pRect = list.getBoundingClientRect();
|
||||
const rect = item.children[0].getBoundingClientRect();
|
||||
details.style.setProperty('--targetOffset', rect.top - pRect.top);
|
||||
} else {
|
||||
item.classList.remove('selected');
|
||||
details.remove();
|
||||
this.details = null;
|
||||
}
|
||||
});
|
||||
list.append(item);
|
||||
}
|
||||
container.append(list);
|
||||
}
|
||||
root.append(container);
|
||||
}
|
||||
root.classList.add('slashCommandBrowser');
|
||||
}
|
||||
}
|
||||
parent.append(this.dom);
|
||||
|
||||
this.mo = new MutationObserver(muts=>{
|
||||
if (muts.find(mut=>Array.from(mut.removedNodes).find(it=>it == this.dom || it.contains(this.dom)))) {
|
||||
this.mo.disconnect();
|
||||
window.removeEventListener('keydown', boundHandler);
|
||||
}
|
||||
});
|
||||
this.mo.observe(document.querySelector('#chat'), { childList:true, subtree:true });
|
||||
const boundHandler = this.handleKeyDown.bind(this);
|
||||
window.addEventListener('keydown', boundHandler);
|
||||
return this.dom;
|
||||
}
|
||||
|
||||
handleKeyDown(evt) {
|
||||
if (!evt.shiftKey && !evt.altKey && evt.ctrlKey && evt.key.toLowerCase() == 'f') {
|
||||
if (!this.dom.closest('body')) return;
|
||||
if (this.dom.closest('.mes') && !this.dom.closest('.last_mes')) return;
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
evt.stopImmediatePropagation();
|
||||
this.search.focus();
|
||||
}
|
||||
}
|
||||
}
|
261
public/scripts/slash-commands/SlashCommandClosure.js
Normal file
261
public/scripts/slash-commands/SlashCommandClosure.js
Normal file
@ -0,0 +1,261 @@
|
||||
import { substituteParams } from '../../script.js';
|
||||
import { delay, escapeRegex } from '../utils.js';
|
||||
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
|
||||
import { SlashCommandClosureExecutor } from './SlashCommandClosureExecutor.js';
|
||||
import { SlashCommandClosureResult } from './SlashCommandClosureResult.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;
|
||||
// @ts-ignore
|
||||
/**@type {SlashCommandNamedArgumentAssignment[]}*/ argumentList = [];
|
||||
// @ts-ignore
|
||||
/**@type {SlashCommandNamedArgumentAssignment[]}*/ providedArgumentList = [];
|
||||
/**@type {SlashCommandExecutor[]}*/ executorList = [];
|
||||
/**@type {SlashCommandAbortController}*/ abortController;
|
||||
/**@type {(done:number, total:number)=>void}*/ onProgress;
|
||||
|
||||
/**@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]';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} text
|
||||
* @param {SlashCommandScope} scope
|
||||
* @returns
|
||||
*/
|
||||
substituteParams(text, scope = null) {
|
||||
let isList = false;
|
||||
let listValues = [];
|
||||
scope = scope ?? this.scope;
|
||||
const macros = scope.macroList.map(it=>escapeRegex(it.key)).join('|');
|
||||
const re = new RegExp(`({{pipe}})|(?:{{var::([^\\s]+?)(?:::((?!}}).+))?}})|(?:{{(${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[1] ? scope.pipe : match[2] ? scope.getVariable(match[2], match[3]) : scope.macroList.find(it=>it.key == match[4])?.value;
|
||||
if (replacer instanceof SlashCommandClosure) {
|
||||
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.onProgress = this.onProgress;
|
||||
return closure;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @returns Promise<SlashCommandClosureResult>
|
||||
*/
|
||||
async execute() {
|
||||
const closure = this.getCopy();
|
||||
return await closure.executeDirect();
|
||||
}
|
||||
|
||||
async executeDirect() {
|
||||
// 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;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
let done = 0;
|
||||
for (const executor of this.executorList) {
|
||||
this.onProgress?.(done, this.commandCount);
|
||||
if (executor instanceof SlashCommandClosureExecutor) {
|
||||
const closure = this.scope.getVariable(executor.name);
|
||||
if (!closure || !(closure instanceof SlashCommandClosure)) throw new Error(`${executor.name} is not a closure.`);
|
||||
closure.scope.parent = this.scope;
|
||||
closure.providedArgumentList = executor.providedArgumentList;
|
||||
const result = await closure.execute();
|
||||
this.scope.pipe = result.pipe;
|
||||
} else {
|
||||
let args = {
|
||||
_scope: this.scope,
|
||||
_parserFlags: executor.parserFlags,
|
||||
};
|
||||
let 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;
|
||||
if (closure.executeNow) {
|
||||
args[arg.name] = (await closure.execute())?.pipe;
|
||||
} else {
|
||||
args[arg.name] = closure;
|
||||
}
|
||||
} else {
|
||||
args[arg.name] = this.substituteParams(arg.value);
|
||||
}
|
||||
// unescape named argument
|
||||
if (typeof args[arg.name] == 'string') {
|
||||
args[arg.name] = args[arg.name]
|
||||
?.replace(/\\\{/g, '{')
|
||||
?.replace(/\\\}/g, '}')
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
// substitute unnamed argument
|
||||
if (executor.unnamedArgumentList.length == 0) {
|
||||
if (executor.injectPipe) {
|
||||
value = this.scope.pipe;
|
||||
}
|
||||
} else {
|
||||
value = [];
|
||||
for (let i = 0; i < executor.unnamedArgumentList.length; i++) {
|
||||
let v = executor.unnamedArgumentList[i].value;
|
||||
if (v instanceof SlashCommandClosure) {
|
||||
/**@type {SlashCommandClosure}*/
|
||||
const closure = v;
|
||||
closure.scope.parent = this.scope;
|
||||
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, '}')
|
||||
;
|
||||
}
|
||||
|
||||
let abortResult = await this.testAbortController();
|
||||
if (abortResult) {
|
||||
return abortResult;
|
||||
}
|
||||
executor.onProgress = (subDone, subTotal)=>this.onProgress?.(done + subDone, this.commandCount);
|
||||
this.scope.pipe = await executor.command.callback(args, value ?? '');
|
||||
done += executor.commandCount;
|
||||
this.onProgress?.(done, this.commandCount);
|
||||
abortResult = await this.testAbortController();
|
||||
if (abortResult) {
|
||||
return abortResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
/**@type {SlashCommandClosureResult} */
|
||||
const result = Object.assign(new SlashCommandClosureResult(), { pipe: this.scope.pipe });
|
||||
return result;
|
||||
}
|
||||
|
||||
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.abortReason = this.abortController.signal.reason.toString();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
|
||||
|
||||
export class SlashCommandClosureExecutor {
|
||||
/**@type {String}*/ name = '';
|
||||
// @ts-ignore
|
||||
/**@type {SlashCommandNamedArgumentAssignment[]}*/ providedArgumentList = [];
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
export class SlashCommandClosureResult {
|
||||
/**@type {boolean}*/ interrupt = false;
|
||||
/**@type {string}*/ pipe;
|
||||
/**@type {boolean}*/ isAborted = false;
|
||||
/**@type {string}*/ abortReason;
|
||||
/**@type {boolean}*/ isError = false;
|
||||
/**@type {string}*/ errorMessage;
|
||||
}
|
@ -0,0 +1,37 @@
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
|
||||
export class SlashCommandCommandAutoCompleteOption extends AutoCompleteOption {
|
||||
/**@type {SlashCommand}*/ command;
|
||||
|
||||
|
||||
get value() {
|
||||
return this.command;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {SlashCommand} command
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(command, name) {
|
||||
super(name);
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.command.renderHelpItem(this.name);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'command');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
return this.command.renderHelpDetails(this.name);
|
||||
}
|
||||
}
|
@ -0,0 +1,46 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
|
||||
export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption {
|
||||
/**@type {SlashCommandEnumValue}*/ enumValue;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param {SlashCommandEnumValue} enumValue
|
||||
*/
|
||||
constructor(enumValue) {
|
||||
super(enumValue.value, '◊');
|
||||
this.enumValue = enumValue;
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, '◊', true, [], [], null, this.enumValue.description);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'enum');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.name;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.textContent = this.enumValue.description;
|
||||
frag.append(help);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
13
public/scripts/slash-commands/SlashCommandEnumValue.js
Normal file
13
public/scripts/slash-commands/SlashCommandEnumValue.js
Normal file
@ -0,0 +1,13 @@
|
||||
export class SlashCommandEnumValue {
|
||||
/**@type {string}*/ value;
|
||||
/**@type {string}*/ description;
|
||||
|
||||
constructor(value, description = null) {
|
||||
this.value = value;
|
||||
this.description = description;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.value;
|
||||
}
|
||||
}
|
45
public/scripts/slash-commands/SlashCommandExecutor.js
Normal file
45
public/scripts/slash-commands/SlashCommandExecutor.js
Normal file
@ -0,0 +1,45 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { PARSER_FLAG } from './SlashCommandParser.js';
|
||||
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
|
||||
|
||||
export class SlashCommandExecutor {
|
||||
/**@type {Boolean}*/ injectPipe = true;
|
||||
/**@type {Number}*/ start;
|
||||
/**@type {Number}*/ end;
|
||||
/**@type {Number}*/ startNamedArgs;
|
||||
/**@type {Number}*/ endNamedArgs;
|
||||
/**@type {Number}*/ startUnnamedArgs;
|
||||
/**@type {Number}*/ endUnnamedArgs;
|
||||
/**@type {String}*/ name = '';
|
||||
/**@type {SlashCommand}*/ command;
|
||||
// @ts-ignore
|
||||
/**@type {SlashCommandNamedArgumentAssignment[]}*/ namedArgumentList = [];
|
||||
/**@type {SlashCommandUnnamedArgumentAssignment[]}*/ unnamedArgumentList = [];
|
||||
/**@type {Object<PARSER_FLAG,boolean>} */ parserFlags;
|
||||
|
||||
get commandCount() {
|
||||
return 1
|
||||
+ this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>/**@type {SlashCommandClosure}*/(it.value).commandCount).reduce((cur, sum)=>cur + sum, 0)
|
||||
+ this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>/**@type {SlashCommandClosure}*/(it.value).commandCount).reduce((cur, sum)=>cur + sum, 0)
|
||||
;
|
||||
}
|
||||
|
||||
set onProgress(value) {
|
||||
const closures = /**@type {SlashCommandClosure[]}*/([
|
||||
...this.namedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>it.value),
|
||||
...this.unnamedArgumentList.filter(it=>it.value instanceof SlashCommandClosure).map(it=>it.value),
|
||||
]);
|
||||
for (const closure of closures) {
|
||||
closure.onProgress = value;
|
||||
}
|
||||
}
|
||||
|
||||
constructor(start) {
|
||||
this.start = start;
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
|
||||
export class SlashCommandNamedArgumentAssignment {
|
||||
/**@type {number}*/ start;
|
||||
/**@type {number}*/ end;
|
||||
/**@type {string}*/ name;
|
||||
/**@type {string|SlashCommandClosure}*/ value;
|
||||
|
||||
|
||||
constructor() {
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
import { SlashCommand } from './SlashCommand.js';
|
||||
import { SlashCommandNamedArgument } from './SlashCommandArgument.js';
|
||||
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
|
||||
|
||||
export class SlashCommandNamedArgumentAutoCompleteOption extends AutoCompleteOption {
|
||||
/**@type {SlashCommandNamedArgument}*/ arg;
|
||||
/**@type {SlashCommand}*/ cmd;
|
||||
|
||||
/**
|
||||
* @param {SlashCommandNamedArgument} arg
|
||||
*/
|
||||
constructor(arg, cmd) {
|
||||
super(`${arg.name}=`);
|
||||
this.arg = arg;
|
||||
this.cmd = cmd;
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, '⌗', true, [], [], null, `${this.arg.isRequired ? '' : '(optional) '}${this.arg.description ?? ''}`);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'namedArgument');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.name;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.innerHTML = `${this.arg.isRequired ? '' : '(optional) '}${this.arg.description ?? ''}`;
|
||||
frag.append(help);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
949
public/scripts/slash-commands/SlashCommandParser.js
Normal file
949
public/scripts/slash-commands/SlashCommandParser.js
Normal file
@ -0,0 +1,949 @@
|
||||
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 { SlashCommandExecutor } from './SlashCommandExecutor.js';
|
||||
import { SlashCommandParserError } from './SlashCommandParserError.js';
|
||||
import { AutoCompleteNameResult } from '../autocomplete/AutoCompleteNameResult.js';
|
||||
import { SlashCommandQuickReplyAutoCompleteOption } from './SlashCommandQuickReplyAutoCompleteOption.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { SlashCommandScope } from './SlashCommandScope.js';
|
||||
import { SlashCommandVariableAutoCompleteOption } from './SlashCommandVariableAutoCompleteOption.js';
|
||||
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { SlashCommandAbortController } from './SlashCommandAbortController.js';
|
||||
import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
|
||||
import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
|
||||
import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
|
||||
import { MacroAutoCompleteOption } from '../autocomplete/MacroAutoCompleteOption.js';
|
||||
|
||||
/**@readonly*/
|
||||
/**@enum {Number}*/
|
||||
export const PARSER_FLAG = {
|
||||
'STRICT_ESCAPING': 1,
|
||||
'REPLACE_GETVAR': 2,
|
||||
};
|
||||
|
||||
export class SlashCommandParser {
|
||||
// @ts-ignore
|
||||
/**@type {Object.<string, SlashCommand>}*/ static commands = {};
|
||||
|
||||
/**
|
||||
* @deprecated Use SlashCommandParser.addCommandObject() instead.
|
||||
* @param {string} command Command name
|
||||
* @param {(namedArguments:Object.<string,string|SlashCommandClosure>, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|void|Promise<string|SlashCommandClosure|void>} callback The function to execute when the command is called
|
||||
* @param {string[]} aliases List of alternative command names
|
||||
* @param {string} helpString Help text shown in autocomplete and command browser
|
||||
*/
|
||||
static addCommand(command, callback, aliases, helpString = '') {
|
||||
this.addCommandObject(SlashCommand.fromProps({
|
||||
name: command,
|
||||
callback,
|
||||
aliases,
|
||||
helpString,
|
||||
}));
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @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.<string, string>}*/ helpStrings = {};
|
||||
/**@type {boolean}*/ verifyCommandNames = true;
|
||||
/**@type {string}*/ text;
|
||||
/**@type {number}*/ index;
|
||||
/**@type {SlashCommandAbortController}*/ abortController;
|
||||
/**@type {SlashCommandScope}*/ scope;
|
||||
/**@type {SlashCommandClosure}*/ closure;
|
||||
|
||||
/**@type {Object.<PARSER_FLAG,boolean>}*/ flags = {};
|
||||
|
||||
/**@type {boolean}*/ jumpedEscapeSequence = false;
|
||||
|
||||
/**@type {{start:number, end:number}[]}*/ closureIndex;
|
||||
/**@type {{start:number, end:number, name:string}[]}*/ macroIndex;
|
||||
/**@type {SlashCommandExecutor[]}*/ commandIndex;
|
||||
/**@type {SlashCommandScope[]}*/ scopeIndex;
|
||||
|
||||
get userIndex() { return this.index; }
|
||||
|
||||
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() {
|
||||
//TODO should not be re-registered from every instance
|
||||
// add dummy commands for help strings / autocomplete
|
||||
if (!Object.keys(this.commands).includes('parser-flag')) {
|
||||
const help = {};
|
||||
help[PARSER_FLAG.REPLACE_GETVAR] = 'Replace all {{getvar::}} and {{getglobalvar::}} macros with scoped variables to avoid double macro substitution.';
|
||||
help[PARSER_FLAG.STRICT_ESCAPING] = 'Allows to escape all delimiters with backslash, and allows escaping of backslashes.';
|
||||
SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'parser-flag',
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The parser flag to modify.',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
isRequired: true,
|
||||
enumList: Object.keys(PARSER_FLAG).map(flag=>new SlashCommandEnumValue(flag, help[PARSER_FLAG[flag]])),
|
||||
}),
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'The state of the parser flag to set.',
|
||||
typeList: [ARGUMENT_TYPE.BOOLEAN],
|
||||
defaultValue: 'on',
|
||||
enumList: ['on', 'off'],
|
||||
}),
|
||||
],
|
||||
splitUnnamedArgument: true,
|
||||
helpString: 'Set a parser flag.',
|
||||
}));
|
||||
}
|
||||
if (!Object.keys(this.commands).includes('/')) {
|
||||
SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: '/',
|
||||
aliases: ['#'],
|
||||
unnamedArgumentList: [
|
||||
SlashCommandArgument.fromProps({
|
||||
description: 'commentary',
|
||||
typeList: [ARGUMENT_TYPE.STRING],
|
||||
}),
|
||||
],
|
||||
helpString: 'Write a comment.',
|
||||
}));
|
||||
}
|
||||
|
||||
//TODO should not be re-registered from every instance
|
||||
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 ABORT = {
|
||||
scope: 'abort',
|
||||
begin: /\/abort/,
|
||||
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: [
|
||||
/\/:/,
|
||||
/(".+?(?<!\\)") |(\S+?) /,
|
||||
],
|
||||
className: {
|
||||
1: 'variable.language',
|
||||
2: 'title.function.invoke',
|
||||
},
|
||||
contains: [], // defined later
|
||||
};
|
||||
const COMMAND = {
|
||||
scope: 'command',
|
||||
begin: /\/\S+/,
|
||||
beginScope: 'title.function',
|
||||
end: /\||$|(?=:})/,
|
||||
excludeEnd: true,
|
||||
contains: [], // defined later
|
||||
};
|
||||
const CLOSURE = {
|
||||
scope: 'closure',
|
||||
begin: /{:/,
|
||||
end: /:}(\(\))?/,
|
||||
beginScope: 'punctuation',
|
||||
endScope: 'punctuation',
|
||||
contains: [], // defined later
|
||||
};
|
||||
const NAMED_ARG = {
|
||||
scope: 'property',
|
||||
begin: /\w+=/,
|
||||
end: '',
|
||||
};
|
||||
const MACRO = {
|
||||
scope: 'variable',
|
||||
begin: /{{/,
|
||||
end: /}}/,
|
||||
};
|
||||
RUN.contains.push(
|
||||
hljs.BACKSLASH_ESCAPE,
|
||||
NAMED_ARG,
|
||||
hljs.QUOTE_STRING_MODE,
|
||||
NUMBER,
|
||||
MACRO,
|
||||
CLOSURE,
|
||||
);
|
||||
LET.contains.push(
|
||||
hljs.BACKSLASH_ESCAPE,
|
||||
NAMED_ARG,
|
||||
NUMBER,
|
||||
MACRO,
|
||||
CLOSURE,
|
||||
hljs.QUOTE_STRING_MODE,
|
||||
);
|
||||
SETVAR.contains.push(
|
||||
hljs.BACKSLASH_ESCAPE,
|
||||
NAMED_ARG,
|
||||
NUMBER,
|
||||
MACRO,
|
||||
CLOSURE,
|
||||
hljs.QUOTE_STRING_MODE,
|
||||
);
|
||||
GETVAR.contains.push(
|
||||
hljs.BACKSLASH_ESCAPE,
|
||||
NAMED_ARG,
|
||||
hljs.QUOTE_STRING_MODE,
|
||||
NUMBER,
|
||||
MACRO,
|
||||
CLOSURE,
|
||||
);
|
||||
COMMAND.contains.push(
|
||||
hljs.BACKSLASH_ESCAPE,
|
||||
NAMED_ARG,
|
||||
NUMBER,
|
||||
MACRO,
|
||||
CLOSURE,
|
||||
hljs.QUOTE_STRING_MODE,
|
||||
);
|
||||
CLOSURE.contains.push(
|
||||
hljs.BACKSLASH_ESCAPE,
|
||||
COMMENT,
|
||||
ABORT,
|
||||
NAMED_ARG,
|
||||
NUMBER,
|
||||
MACRO,
|
||||
RUN,
|
||||
LET,
|
||||
GETVAR,
|
||||
SETVAR,
|
||||
COMMAND,
|
||||
'self',
|
||||
hljs.QUOTE_STRING_MODE,
|
||||
);
|
||||
hljs.registerLanguage('stscript', ()=>({
|
||||
case_insensitive: false,
|
||||
keywords: ['|'],
|
||||
contains: [
|
||||
hljs.BACKSLASH_ESCAPE,
|
||||
COMMENT,
|
||||
ABORT,
|
||||
RUN,
|
||||
LET,
|
||||
GETVAR,
|
||||
SETVAR,
|
||||
COMMAND,
|
||||
CLOSURE,
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
getHelpString() {
|
||||
return '<div class="slashHelp">Loading...</div>';
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
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;
|
||||
const macro = this.macroIndex.findLast(it=>it.start <= index && it.end >= index);
|
||||
console.log(macro);
|
||||
if (macro) {
|
||||
const frag = document.createRange().createContextualFragment(await (await fetch('/scripts/templates/macros.html')).text());
|
||||
const options = [...frag.querySelectorAll('ul:nth-of-type(2n+1) > li')].map(li=>new MacroAutoCompleteOption(
|
||||
li.querySelector('tt').textContent.slice(2, -2).replace(/^([^\s:]+[\s:]+).*$/, '$1'),
|
||||
li.querySelector('tt').textContent,
|
||||
(li.querySelector('tt').remove(),li.innerHTML),
|
||||
));
|
||||
const result = new AutoCompleteNameResult(
|
||||
macro.name,
|
||||
macro.start + 2,
|
||||
options,
|
||||
false,
|
||||
()=>`No matching macros for "{{${result.name}}}"`,
|
||||
()=>'No macros found.',
|
||||
);
|
||||
return result;
|
||||
}
|
||||
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 AutoCompleteNameResult(
|
||||
executor.unnamedArgumentList[0]?.value.toString(),
|
||||
executor.start,
|
||||
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 SlashCommandAutoCompleteNameResult(executor, this.commands);
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the index <length> 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) {
|
||||
this.jumpedEscapeSequence = false;
|
||||
let content = this.char;
|
||||
this.index++;
|
||||
if (length > 1) {
|
||||
content = this.take(length - 1);
|
||||
}
|
||||
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';
|
||||
const nameAss = new SlashCommandUnnamedArgumentAssignment();
|
||||
nameAss.value = pipeName;
|
||||
const valAss = new SlashCommandUnnamedArgumentAssignment();
|
||||
valAss.value = '{{pipe}}';
|
||||
storePipe.unnamedArgumentList = [nameAss, valAss];
|
||||
this.closure.executorList.push(storePipe);
|
||||
}
|
||||
// getvar / getglobalvar
|
||||
const getvar = new SlashCommandExecutor(null); {
|
||||
getvar.command = this.commands[cmd];
|
||||
getvar.name = 'cmd';
|
||||
const nameAss = new SlashCommandUnnamedArgumentAssignment();
|
||||
nameAss.value = name;
|
||||
getvar.unnamedArgumentList = [nameAss];
|
||||
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';
|
||||
const nameAss = new SlashCommandUnnamedArgumentAssignment();
|
||||
nameAss.value = varName;
|
||||
const valAss = new SlashCommandUnnamedArgumentAssignment();
|
||||
valAss.value = '{{pipe}}';
|
||||
setvar.unnamedArgumentList = [nameAss, valAss];
|
||||
this.closure.executorList.push(setvar);
|
||||
}
|
||||
// return pipe
|
||||
const returnPipe = new SlashCommandExecutor(null); {
|
||||
returnPipe.command = this.commands['return'];
|
||||
returnPipe.name = 'return';
|
||||
const varAss = new SlashCommandUnnamedArgumentAssignment();
|
||||
varAss.value = `{{var::${pipeName}}}`;
|
||||
returnPipe.unnamedArgumentList = [varAss];
|
||||
this.closure.executorList.push(returnPipe);
|
||||
}
|
||||
return `{{var::${varName}}}`;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
parse(text, verifyCommandNames = true, flags = null, abortController = 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.abortController = abortController;
|
||||
this.text = text;
|
||||
this.index = 0;
|
||||
this.scope = null;
|
||||
this.closureIndex = [];
|
||||
this.commandIndex = [];
|
||||
this.scopeIndex = [];
|
||||
this.macroIndex = [];
|
||||
const closure = this.parseClosure(true);
|
||||
return closure;
|
||||
}
|
||||
|
||||
testClosure() {
|
||||
return this.testSymbol('{:');
|
||||
}
|
||||
testClosureEnd() {
|
||||
if (!this.scope.parent) {
|
||||
// "root" closure does not have {: and :}
|
||||
if (this.index >= this.text.length) return true;
|
||||
return false;
|
||||
}
|
||||
if (!this.verifyCommandNames) {
|
||||
if (this.index >= this.text.length) return true;
|
||||
} else {
|
||||
if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed closure at position ${this.userIndex}`, this.text, this.index);
|
||||
}
|
||||
return this.testSymbol(':}');
|
||||
}
|
||||
parseClosure(isRoot = false) {
|
||||
const closureIndexEntry = { start:this.index + 1, end:null };
|
||||
this.closureIndex.push(closureIndexEntry);
|
||||
let injectPipe = true;
|
||||
if (!isRoot) this.take(2); // discard opening {:
|
||||
let closure = new SlashCommandClosure(this.scope);
|
||||
closure.abortController = this.abortController;
|
||||
this.scope = closure.scope;
|
||||
this.closure = closure;
|
||||
this.discardWhitespace();
|
||||
while (this.testNamedArgument()) {
|
||||
const arg = this.parseNamedArgument();
|
||||
closure.argumentList.push(arg);
|
||||
this.scope.variableNames.push(arg.name);
|
||||
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
|
||||
}
|
||||
if (!isRoot) 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);
|
||||
cmd.command = this.commands['/'];
|
||||
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.unnamedArgumentList = [];
|
||||
cmd.command = this.commands[cmd.name];
|
||||
this.commandIndex.push(cmd);
|
||||
this.scopeIndex.push(this.scope.getCopy());
|
||||
this.take(13); // discard "/parser-flag "
|
||||
cmd.startNamedArgs = -1;
|
||||
cmd.endNamedArgs = -1;
|
||||
cmd.startUnnamedArgs = this.index;
|
||||
cmd.unnamedArgumentList = this.parseUnnamedArgument(true);
|
||||
const [flag, state] = cmd.unnamedArgumentList ?? [null, null];
|
||||
cmd.endUnnamedArgs = this.index;
|
||||
if (Object.keys(PARSER_FLAG).includes(flag.value.toString())) {
|
||||
this.flags[PARSER_FLAG[flag.value.toString()]] = isTrueBoolean(state?.value.toString() ?? '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.unnamedArgumentList = [];
|
||||
cmd.command = this.commands['run'];
|
||||
this.commandIndex.push(cmd);
|
||||
this.scopeIndex.push(this.scope.getCopy());
|
||||
this.take(2); //discard "/:"
|
||||
const assignment = new SlashCommandUnnamedArgumentAssignment();
|
||||
if (this.testQuotedValue()) assignment.value = this.parseQuotedValue();
|
||||
else assignment.value = this.parseValue();
|
||||
cmd.unnamedArgumentList = [assignment];
|
||||
this.discardWhitespace();
|
||||
while (this.testNamedArgument()) {
|
||||
const arg = this.parseNamedArgument();
|
||||
cmd.namedArgumentList.push(arg);
|
||||
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;
|
||||
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}: "/${cmd.name}"`, this.text, this.index - cmd.name.length);
|
||||
cmd.command = this.commands[cmd.name];
|
||||
cmd.startNamedArgs = this.index;
|
||||
cmd.endNamedArgs = this.index;
|
||||
while (this.testNamedArgument()) {
|
||||
const arg = this.parseNamedArgument();
|
||||
cmd.namedArgumentList.push(arg);
|
||||
cmd.endNamedArgs = this.index;
|
||||
this.discardWhitespace();
|
||||
}
|
||||
this.discardWhitespace();
|
||||
cmd.startUnnamedArgs = this.index;
|
||||
cmd.endUnnamedArgs = this.index;
|
||||
if (this.testUnnamedArgument()) {
|
||||
cmd.unnamedArgumentList = this.parseUnnamedArgument(cmd.command?.unnamedArgumentList?.length && cmd?.command?.splitUnnamedArgument);
|
||||
cmd.endUnnamedArgs = this.index;
|
||||
if (cmd.name == 'let') {
|
||||
const keyArg = cmd.namedArgumentList.find(it=>it.name == 'key');
|
||||
if (keyArg) {
|
||||
this.scope.variableNames.push(keyArg.value.toString());
|
||||
} else if (typeof cmd.unnamedArgumentList[0]?.value == 'string') {
|
||||
this.scope.variableNames.push(cmd.unnamedArgumentList[0].value);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.testCommandEnd()) {
|
||||
cmd.end = this.index;
|
||||
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 assignment = new SlashCommandNamedArgumentAssignment();
|
||||
assignment.start = this.index;
|
||||
let key = '';
|
||||
while (/\w/.test(this.char)) key += this.take(); // take chars
|
||||
this.take(); // discard "="
|
||||
assignment.name = key;
|
||||
if (this.testClosure()) {
|
||||
assignment.value = this.parseClosure();
|
||||
} else if (this.testQuotedValue()) {
|
||||
assignment.value = this.parseQuotedValue();
|
||||
} else if (this.testListValue()) {
|
||||
assignment.value = this.parseListValue();
|
||||
} else if (this.testValue()) {
|
||||
assignment.value = this.parseValue();
|
||||
}
|
||||
assignment.end = this.index;
|
||||
return assignment;
|
||||
}
|
||||
|
||||
testUnnamedArgument() {
|
||||
return !this.testCommandEnd();
|
||||
}
|
||||
testUnnamedArgumentEnd() {
|
||||
return this.testCommandEnd();
|
||||
}
|
||||
parseUnnamedArgument(split) {
|
||||
/**@type {SlashCommandClosure|String}*/
|
||||
let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
|
||||
let isList = split;
|
||||
let listValues = [];
|
||||
/**@type {SlashCommandUnnamedArgumentAssignment}*/
|
||||
let assignment = new SlashCommandUnnamedArgumentAssignment();
|
||||
assignment.start = this.index;
|
||||
while (!this.testUnnamedArgumentEnd()) {
|
||||
if (this.testClosure()) {
|
||||
isList = true;
|
||||
if (value.length > 0) {
|
||||
assignment.end = assignment.end - (value.length - value.trim().length);
|
||||
this.indexMacros(this.index - value.length, value);
|
||||
assignment.value = value.trim();
|
||||
listValues.push(assignment);
|
||||
assignment = new SlashCommandUnnamedArgumentAssignment();
|
||||
assignment.start = this.index;
|
||||
value = '';
|
||||
}
|
||||
assignment.value = this.parseClosure();
|
||||
assignment.end = this.index;
|
||||
listValues.push(assignment);
|
||||
assignment = new SlashCommandUnnamedArgumentAssignment();
|
||||
assignment.start = this.index;
|
||||
} else if (split) {
|
||||
if (this.testQuotedValue()) {
|
||||
assignment.start = this.index;
|
||||
assignment.value = this.parseQuotedValue();
|
||||
assignment.end = this.index;
|
||||
listValues.push(assignment);
|
||||
assignment = new SlashCommandUnnamedArgumentAssignment();
|
||||
} else if (this.testListValue()) {
|
||||
assignment.start = this.index;
|
||||
assignment.value = this.parseListValue();
|
||||
assignment.end = this.index;
|
||||
listValues.push(assignment);
|
||||
assignment = new SlashCommandUnnamedArgumentAssignment();
|
||||
} else if (this.testValue()) {
|
||||
assignment.start = this.index;
|
||||
assignment.value = this.parseValue();
|
||||
assignment.end = this.index;
|
||||
listValues.push(assignment);
|
||||
assignment = new SlashCommandUnnamedArgumentAssignment();
|
||||
} else {
|
||||
throw new SlashCommandParserError(`Unexpected end of unnamed argument at index ${this.userIndex}.`);
|
||||
}
|
||||
this.discardWhitespace();
|
||||
} else {
|
||||
value += this.take();
|
||||
assignment.end = this.index;
|
||||
}
|
||||
}
|
||||
if (isList && value.trim().length > 0) {
|
||||
assignment.value = value.trim();
|
||||
listValues.push(assignment);
|
||||
}
|
||||
if (isList) {
|
||||
return listValues;
|
||||
}
|
||||
this.indexMacros(this.index - value.length, value);
|
||||
value = value.trim();
|
||||
if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
|
||||
value = this.replaceGetvar(value);
|
||||
}
|
||||
assignment.value = value;
|
||||
return [assignment];
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
this.indexMacros(this.index - value.length, 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);
|
||||
}
|
||||
this.indexMacros(this.index - value.length, 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);
|
||||
}
|
||||
this.indexMacros(this.index - value.length, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
indexMacros(offset, text) {
|
||||
const re = /{{(?:((?:(?!}})[^\s:])+[\s:]*)((?:(?!}}).)*)(}}|}$|$))?/s;
|
||||
let remaining = text;
|
||||
let localOffset = 0;
|
||||
while (remaining.length > 0 && re.test(remaining)) {
|
||||
const match = re.exec(remaining);
|
||||
this.macroIndex.push({
|
||||
start: offset + localOffset + match.index,
|
||||
end: offset + localOffset + match.index + (match[0]?.length ?? 0),
|
||||
name: match[1] ?? '',
|
||||
});
|
||||
localOffset += match.index + (match[0]?.length ?? 0);
|
||||
remaining = remaining.slice(match.index + (match[0]?.length ?? 0));
|
||||
}
|
||||
}
|
||||
}
|
50
public/scripts/slash-commands/SlashCommandParserError.js
Normal file
50
public/scripts/slash-commands/SlashCommandParserError.js
Normal file
@ -0,0 +1,50 @@
|
||||
export class SlashCommandParserError extends Error {
|
||||
/**@type {String}*/ text;
|
||||
/**@type {Number}*/ index;
|
||||
|
||||
get line() {
|
||||
return this.text.slice(0, this.index).replace(/[^\n]/g, '').length;
|
||||
}
|
||||
get column() {
|
||||
return this.text.slice(0, this.index).split('\n').pop().length;
|
||||
}
|
||||
get hint() {
|
||||
let lineOffset = this.line.toString().length;
|
||||
let lineStart = this.index;
|
||||
let start = this.index;
|
||||
let end = this.index;
|
||||
let offset = 0;
|
||||
let lineCount = 0;
|
||||
while (offset < 10000 && lineCount < 3 && start >= 0) {
|
||||
if (this.text[start] == '\n') lineCount++;
|
||||
if (lineCount == 0) lineStart--;
|
||||
offset++;
|
||||
start--;
|
||||
}
|
||||
if (this.text[start + 1] == '\n') start++;
|
||||
offset = 0;
|
||||
while (offset < 10000 && this.text[end] != '\n') {
|
||||
offset++;
|
||||
end++;
|
||||
}
|
||||
let hint = [];
|
||||
let lines = this.text.slice(start + 1, end - 1).split('\n');
|
||||
let lineNum = this.line - lines.length + 1;
|
||||
let tabOffset = 0;
|
||||
for (const line of lines) {
|
||||
const num = `${' '.repeat(lineOffset - lineNum.toString().length)}${lineNum}`;
|
||||
lineNum++;
|
||||
const untabbedLine = line.replace(/\t/g, ' '.repeat(4));
|
||||
tabOffset = untabbedLine.length - line.length;
|
||||
hint.push(`${num}: ${untabbedLine}`);
|
||||
}
|
||||
hint.push(`${' '.repeat(this.index - lineStart + lineOffset + 1 + tabOffset)}^^^^^`);
|
||||
return hint.join('\n');
|
||||
}
|
||||
|
||||
constructor(message, text, index) {
|
||||
super(message);
|
||||
this.text = text;
|
||||
this.index = index;
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
|
||||
export class SlashCommandQuickReplyAutoCompleteOption extends AutoCompleteOption {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, 'QR', true);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'qr');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.name;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.textContent = 'Quick Reply';
|
||||
frag.append(help);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
114
public/scripts/slash-commands/SlashCommandScope.js
Normal file
114
public/scripts/slash-commands/SlashCommandScope.js
Normal file
@ -0,0 +1,114 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
|
||||
export class SlashCommandScope {
|
||||
/**@type {string[]}*/ variableNames = [];
|
||||
get allVariableNames() {
|
||||
const names = [...this.variableNames, ...(this.parent?.allVariableNames ?? [])];
|
||||
return names.filter((it,idx)=>idx == names.indexOf(it));
|
||||
}
|
||||
// @ts-ignore
|
||||
/**@type {object.<string, string|SlashCommandClosure>}*/ variables = {};
|
||||
// @ts-ignore
|
||||
/**@type {object.<string, string|SlashCommandClosure>}*/ macros = {};
|
||||
/**@type {{key:string, value:string|SlashCommandClosure}[]} */
|
||||
get macroList() {
|
||||
return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])];
|
||||
}
|
||||
/**@type {SlashCommandScope}*/ parent;
|
||||
/**@type {string}*/ #pipe;
|
||||
get pipe() {
|
||||
return this.#pipe ?? this.parent?.pipe;
|
||||
}
|
||||
set pipe(value) {
|
||||
this.#pipe = value;
|
||||
}
|
||||
|
||||
|
||||
constructor(parent) {
|
||||
this.parent = parent;
|
||||
}
|
||||
|
||||
getCopy() {
|
||||
const scope = new SlashCommandScope(this.parent);
|
||||
scope.variableNames = [...this.variableNames];
|
||||
scope.variables = Object.assign({}, this.variables);
|
||||
scope.macros = Object.assign({}, this.macros);
|
||||
scope.#pipe = this.#pipe;
|
||||
return scope;
|
||||
}
|
||||
|
||||
|
||||
setMacro(key, value) {
|
||||
this.macros[key] = value;
|
||||
}
|
||||
|
||||
|
||||
existsVariableInScope(key) {
|
||||
return Object.keys(this.variables).includes(key);
|
||||
}
|
||||
existsVariable(key) {
|
||||
return Object.keys(this.variables).includes(key) || this.parent?.existsVariable(key);
|
||||
}
|
||||
letVariable(key, value = undefined) {
|
||||
if (this.existsVariableInScope(key)) throw new SlashCommandScopeVariableExistsError(`Variable named "${key}" already exists.`);
|
||||
this.variables[key] = value;
|
||||
}
|
||||
setVariable(key, value, index = null) {
|
||||
if (this.existsVariableInScope(key)) {
|
||||
if (index !== null && index !== undefined) {
|
||||
let v = this.variables[key];
|
||||
try {
|
||||
v = JSON.parse(v);
|
||||
const numIndex = Number(index);
|
||||
if (Number.isNaN(numIndex)) {
|
||||
v[index] = value;
|
||||
} else {
|
||||
v[numIndex] = value;
|
||||
}
|
||||
v = JSON.stringify(v);
|
||||
} catch {
|
||||
v[index] = value;
|
||||
}
|
||||
this.variables[key] = v;
|
||||
} else {
|
||||
this.variables[key] = value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
if (this.parent) {
|
||||
return this.parent.setVariable(key, value, index);
|
||||
}
|
||||
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
|
||||
}
|
||||
getVariable(key, index = null) {
|
||||
if (this.existsVariableInScope(key)) {
|
||||
if (index !== null && index !== undefined) {
|
||||
let v = this.variables[key];
|
||||
try { v = JSON.parse(v); } catch { /* empty */ }
|
||||
const numIndex = Number(index);
|
||||
if (Number.isNaN(numIndex)) {
|
||||
v = v[index];
|
||||
} else {
|
||||
v = v[numIndex];
|
||||
}
|
||||
if (typeof v == 'object') return JSON.stringify(v);
|
||||
return v;
|
||||
} else {
|
||||
const value = this.variables[key];
|
||||
return (value === '' || isNaN(Number(value))) ? (value || '') : Number(value);
|
||||
}
|
||||
}
|
||||
if (this.parent) {
|
||||
return this.parent.getVariable(key, index);
|
||||
}
|
||||
throw new SlashCommandScopeVariableNotFoundError(`No such variable: "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
export class SlashCommandScopeVariableExistsError extends Error {}
|
||||
|
||||
|
||||
export class SlashCommandScopeVariableNotFoundError extends Error {}
|
@ -0,0 +1,11 @@
|
||||
import { SlashCommandClosure } from './SlashCommandClosure.js';
|
||||
|
||||
export class SlashCommandUnnamedArgumentAssignment {
|
||||
/**@type {number}*/ start;
|
||||
/**@type {number}*/ end;
|
||||
/**@type {string|SlashCommandClosure}*/ value;
|
||||
|
||||
|
||||
constructor() {
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { AutoCompleteOption } from '../autocomplete/AutoCompleteOption.js';
|
||||
|
||||
export class SlashCommandVariableAutoCompleteOption extends AutoCompleteOption {
|
||||
/**
|
||||
* @param {string} name
|
||||
*/
|
||||
constructor(name) {
|
||||
super(name);
|
||||
}
|
||||
|
||||
|
||||
renderItem() {
|
||||
let li;
|
||||
li = this.makeItem(this.name, '[𝑥]', true);
|
||||
li.setAttribute('data-name', this.name);
|
||||
li.setAttribute('data-option-type', 'variable');
|
||||
return li;
|
||||
}
|
||||
|
||||
|
||||
renderDetails() {
|
||||
const frag = document.createDocumentFragment();
|
||||
const specs = document.createElement('div'); {
|
||||
specs.classList.add('specs');
|
||||
const name = document.createElement('div'); {
|
||||
name.classList.add('name');
|
||||
name.classList.add('monospace');
|
||||
name.textContent = this.name;
|
||||
specs.append(name);
|
||||
}
|
||||
frag.append(specs);
|
||||
}
|
||||
const help = document.createElement('span'); {
|
||||
help.classList.add('help');
|
||||
help.textContent = 'scoped variable';
|
||||
frag.append(help);
|
||||
}
|
||||
return frag;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user