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:
Len
2024-05-12 15:15:05 -04:00
committed by GitHub
parent c7d75b7789
commit 1d75b98393
57 changed files with 8777 additions and 736 deletions

View 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;
}
}

View 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;
}

View 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] : [];
}
}

View File

@ -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;
}
}

View 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();
}
}
}

View 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;
}
}
}

View File

@ -0,0 +1,7 @@
import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
export class SlashCommandClosureExecutor {
/**@type {String}*/ name = '';
// @ts-ignore
/**@type {SlashCommandNamedArgumentAssignment[]}*/ providedArgumentList = [];
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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() {
}
}

View File

@ -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;
}
}

View 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));
}
}
}

View 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;
}
}

View File

@ -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;
}
}

View 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 {}

View File

@ -0,0 +1,11 @@
import { SlashCommandClosure } from './SlashCommandClosure.js';
export class SlashCommandUnnamedArgumentAssignment {
/**@type {number}*/ start;
/**@type {number}*/ end;
/**@type {string|SlashCommandClosure}*/ value;
constructor() {
}
}

View File

@ -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;
}
}