Spaces:
Running
Running
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'; | |
import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js'; | |
import { SlashCommandDebugController } from './SlashCommandDebugController.js'; | |
import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js'; | |
import { SlashCommandBreak } from './SlashCommandBreak.js'; | |
/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */ | |
/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */ | |
/**@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:NamedArguments|NamedArgumentsCapture, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise<string|SlashCommandClosure>} callback 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', 'breakpoint']; | |
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]); | |
} | |
const stack = new Error().stack.split('\n').map(it=>it.trim()); | |
command.isExtension = stack.find(it=>it.includes('/scripts/extensions/')) != null; | |
command.isThirdParty = stack.find(it=>it.includes('/scripts/extensions/third-party/')) != null; | |
if (command.isThirdParty) { | |
command.source = stack.find(it=>it.includes('/scripts/extensions/third-party/')).replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1'); | |
} else if (command.isExtension) { | |
command.source = stack.find(it=>it.includes('/scripts/extensions/')).replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1'); | |
} else { | |
const idx = stack.findLastIndex(it=>it.includes('at SlashCommandParser.')) + 1; | |
command.source = stack[idx].replace(/^.*?\/((?:scripts\/)?(?:[^/]+)\.js).*$/, '$1'); | |
} | |
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 {SlashCommandDebugController}*/ debugController; | |
/**@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; | |
/**@type {string}*/ parserContext; | |
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.char) && /^\s+$/.test(this.ahead)); | |
} | |
constructor() { | |
// 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: commonEnumProviders.boolean('onOff')(), | |
}), | |
], | |
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.', | |
})); | |
} | |
if (!Object.keys(this.commands).includes('breakpoint')) { | |
SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'breakpoint', | |
helpString: 'Set a breakpoint for debugging in the QR Editor.', | |
})); | |
} | |
if (!Object.keys(this.commands).includes('break')) { | |
SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'break', | |
helpString: 'Break out of a loop or closure executed through /run or /:', | |
unnamedArgumentList: [ | |
SlashCommandArgument.fromProps({ description: 'value to pass down the pipe instead of the current pipe value', | |
typeList: Object.values(ARGUMENT_TYPE), | |
}), | |
], | |
})); | |
} | |
//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, | |
}; | |
function getQuotedRunRegex() { | |
try { | |
return new RegExp('(".+?(?<!\\\\)")|(\\S+?)(\\||$|\\s)'); | |
} catch { | |
// fallback for browsers that don't support lookbehind | |
return /(".+?")|(\S+?)(\||$|\s)/; | |
} | |
} | |
const BLOCK_COMMENT = { | |
scope: 'comment', | |
begin: /\/\*/, | |
end: /\*\|/, | |
contains: [], | |
}; | |
const COMMENT = { | |
scope: 'comment', | |
begin: /\/[/#]/, | |
end: /\||$|:}/, | |
contains: [], | |
}; | |
const ABORT = { | |
begin: /\/(abort|breakpoint)/, | |
beginScope: 'abort', | |
end: /\||$|(?=:})/, | |
excludeEnd: false, | |
returnEnd: true, | |
contains: [], | |
}; | |
const IMPORT = { | |
scope: 'command', | |
begin: /\/(import)/, | |
beginScope: 'keyword', | |
end: /\||$|(?=:})/, | |
excludeEnd: false, | |
returnEnd: true, | |
contains: [], | |
}; | |
const BREAK = { | |
scope: 'command', | |
begin: /\/(break)/, | |
beginScope: 'keyword', | |
end: /\||$|(?=:})/, | |
excludeEnd: false, | |
returnEnd: true, | |
contains: [], | |
}; | |
const LET = { | |
begin: [ | |
/\/(let|var)\s+/, | |
], | |
beginScope: { | |
1: 'variable', | |
}, | |
end: /\||$|:}/, | |
excludeEnd: false, | |
returnEnd: true, | |
contains: [], | |
}; | |
const SETVAR = { | |
begin: /\/(setvar|setglobalvar)\s+/, | |
beginScope: 'variable', | |
end: /\||$|:}/, | |
excludeEnd: false, | |
returnEnd: true, | |
contains: [], | |
}; | |
const GETVAR = { | |
begin: /\/(getvar|getglobalvar)\s+/, | |
beginScope: 'variable', | |
end: /\||$|:}/, | |
excludeEnd: false, | |
returnEnd: true, | |
contains: [], | |
}; | |
const RUN = { | |
match: [ | |
/\/:/, | |
getQuotedRunRegex(), | |
/\||$|(?=:})/, | |
], | |
className: { | |
1: 'variable.language', | |
2: 'title.function.invoke', | |
}, | |
contains: [], // defined later | |
}; | |
const COMMAND = { | |
scope: 'command', | |
begin: /\/\S+/, | |
beginScope: 'title.function', | |
end: /\||$|(?=:})/, | |
excludeEnd: false, | |
returnEnd: 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: /}}/, | |
}; | |
const PIPEBREAK = { | |
beginScope: 'pipebreak', | |
begin: /\|\|/, | |
end: '', | |
}; | |
const PIPE = { | |
beginScope: 'pipe', | |
begin: /\|/, | |
end: '', | |
}; | |
BLOCK_COMMENT.contains.push( | |
BLOCK_COMMENT, | |
); | |
RUN.contains.push( | |
hljs.BACKSLASH_ESCAPE, | |
NAMED_ARG, | |
hljs.QUOTE_STRING_MODE, | |
NUMBER, | |
MACRO, | |
CLOSURE, | |
); | |
IMPORT.contains.push( | |
hljs.BACKSLASH_ESCAPE, | |
NAMED_ARG, | |
NUMBER, | |
MACRO, | |
CLOSURE, | |
hljs.QUOTE_STRING_MODE, | |
); | |
BREAK.contains.push( | |
hljs.BACKSLASH_ESCAPE, | |
NAMED_ARG, | |
NUMBER, | |
MACRO, | |
CLOSURE, | |
hljs.QUOTE_STRING_MODE, | |
); | |
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, | |
); | |
ABORT.contains.push( | |
hljs.BACKSLASH_ESCAPE, | |
NAMED_ARG, | |
NUMBER, | |
MACRO, | |
CLOSURE, | |
hljs.QUOTE_STRING_MODE, | |
); | |
COMMAND.contains.push( | |
hljs.BACKSLASH_ESCAPE, | |
NAMED_ARG, | |
NUMBER, | |
MACRO, | |
CLOSURE, | |
hljs.QUOTE_STRING_MODE, | |
); | |
CLOSURE.contains.push( | |
hljs.BACKSLASH_ESCAPE, | |
BLOCK_COMMENT, | |
COMMENT, | |
ABORT, | |
IMPORT, | |
BREAK, | |
NAMED_ARG, | |
NUMBER, | |
MACRO, | |
RUN, | |
LET, | |
GETVAR, | |
SETVAR, | |
COMMAND, | |
'self', | |
hljs.QUOTE_STRING_MODE, | |
PIPEBREAK, | |
PIPE, | |
); | |
hljs.registerLanguage('stscript', ()=>({ | |
case_insensitive: false, | |
keywords: [], | |
contains: [ | |
hljs.BACKSLASH_ESCAPE, | |
BLOCK_COMMENT, | |
COMMENT, | |
ABORT, | |
IMPORT, | |
BREAK, | |
RUN, | |
LET, | |
GETVAR, | |
SETVAR, | |
COMMAND, | |
CLOSURE, | |
PIPEBREAK, | |
PIPE, | |
], | |
})); | |
} | |
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); | |
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.scopeIndex[this.commandIndex.indexOf(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, (match, cmd, name, idx) => { | |
name = name.trim(); | |
const startIdx = this.index - value.length + idx; | |
const endIdx = this.index - value.length + idx + match.length; | |
// store pipe | |
const pipeName = `_PARSER_PIPE_${uuidv4()}`; | |
const storePipe = new SlashCommandExecutor(startIdx); { | |
storePipe.end = endIdx; | |
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(startIdx); { | |
getvar.end = endIdx; | |
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_VAR_${uuidv4()}`; | |
const setvar = new SlashCommandExecutor(startIdx); { | |
setvar.end = endIdx; | |
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(startIdx); { | |
returnPipe.end = endIdx; | |
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, debugController = 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.debugController = debugController; | |
this.text = text; | |
this.index = 0; | |
this.scope = null; | |
this.closureIndex = []; | |
this.commandIndex = []; | |
this.scopeIndex = []; | |
this.macroIndex = []; | |
this.parserContext = uuidv4(); | |
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 {: | |
const textStart = this.index; | |
let closure = new SlashCommandClosure(this.scope); | |
closure.parserContext = this.parserContext; | |
closure.fullText = this.text; | |
closure.abortController = this.abortController; | |
closure.debugController = this.debugController; | |
this.scope = closure.scope; | |
const oldClosure = this.closure; | |
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.testBlockComment()) { | |
this.parseBlockComment(); | |
} else 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.testBreakPoint()) { | |
const bp = this.parseBreakPoint(); | |
if (this.debugController) { | |
closure.executorList.push(bp); | |
} | |
} else if (this.testBreak()) { | |
const b = this.parseBreak(); | |
closure.executorList.push(b); | |
} 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 | |
} | |
closure.rawText = this.text.slice(textStart, this.index); | |
if (!isRoot) this.take(2); // discard closing :} | |
if (this.testSymbol('()')) { | |
this.take(2); // discard () | |
closure.executeNow = true; | |
} | |
closureIndexEntry.end = this.index - 1; | |
this.scope = closure.scope.parent; | |
this.closure = oldClosure ?? closure; | |
return closure; | |
} | |
testBreakPoint() { | |
return this.testSymbol(/\/breakpoint\s*\|/); | |
} | |
parseBreakPoint() { | |
const cmd = new SlashCommandBreakPoint(); | |
cmd.name = 'breakpoint'; | |
cmd.command = this.commands['breakpoint']; | |
cmd.start = this.index + 1; | |
this.take('/breakpoint'.length); | |
cmd.end = this.index; | |
this.commandIndex.push(cmd); | |
this.scopeIndex.push(this.scope.getCopy()); | |
return cmd; | |
} | |
testBreak() { | |
return this.testSymbol(/\/break(\s|\||$)/); | |
} | |
parseBreak() { | |
const cmd = new SlashCommandBreak(); | |
cmd.name = 'break'; | |
cmd.command = this.commands['break']; | |
cmd.start = this.index + 1; | |
this.take('/break'.length); | |
this.discardWhitespace(); | |
if (this.testUnnamedArgument()) { | |
cmd.unnamedArgumentList.push(...this.parseUnnamedArgument()); | |
} | |
cmd.end = this.index; | |
this.commandIndex.push(cmd); | |
this.scopeIndex.push(this.scope.getCopy()); | |
return cmd; | |
} | |
testBlockComment() { | |
return this.testSymbol('/*'); | |
} | |
testBlockCommentEnd() { | |
if (!this.verifyCommandNames) { | |
if (this.index >= this.text.length) return true; | |
} else { | |
if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed block comment at position ${this.userIndex}`, this.text, this.index); | |
} | |
return this.testSymbol('*|'); | |
} | |
parseBlockComment() { | |
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 "*" as name | |
while (!this.testBlockCommentEnd()) { | |
if (this.testBlockComment()) { | |
this.parseBlockComment(); | |
} | |
this.take(); | |
} | |
this.take(2); // take closing "*|" | |
cmd.end = this.index - 1; | |
} | |
testComment() { | |
return this.testSymbol(/\/[/#]/); | |
} | |
testCommentEnd() { | |
if (!this.verifyCommandNames) { | |
if (this.index >= this.text.length) return true; | |
} else { | |
if (this.endOfText) throw new SlashCommandParserError(`Unclosed comment at position ${this.userIndex}`, this.text, this.index); | |
} | |
return this.testSymbol('|'); | |
} | |
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(); | |
cmd.startNamedArgs = this.index; | |
while (this.testNamedArgument()) { | |
const arg = this.parseNamedArgument(); | |
cmd.namedArgumentList.push(arg); | |
this.discardWhitespace(); | |
} | |
cmd.endNamedArgs = this.index; | |
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 - (/\s(\s*)$/s.exec(this.behind)?.[1]?.length ?? 0); | |
cmd.endUnnamedArgs = this.index; | |
if (this.testUnnamedArgument()) { | |
cmd.unnamedArgumentList = this.parseUnnamedArgument(cmd.command?.unnamedArgumentList?.length && cmd?.command?.splitUnnamedArgument, cmd?.command?.splitUnnamedArgumentCount); | |
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); | |
} | |
} else if (cmd.name == 'import') { | |
const value = /**@type {string[]}*/(cmd.unnamedArgumentList.map(it=>it.value)); | |
for (let i = 0; i < value.length; i++) { | |
const srcName = value[i]; | |
let dstName = srcName; | |
if (i + 2 < value.length && value[i + 1] == 'as') { | |
dstName = value[i + 2]; | |
i += 2; | |
} | |
this.scope.variableNames.push(dstName); | |
} | |
} | |
} | |
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, splitCount = null) { | |
const wasSplit = 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 = []; | |
let listQuoted = []; // keep track of which listValues were quoted | |
/**@type {SlashCommandUnnamedArgumentAssignment}*/ | |
let assignment = new SlashCommandUnnamedArgumentAssignment(); | |
assignment.start = this.index; | |
if (!split && this.testQuotedValue()) { | |
// if the next bit is a quoted value, take the whole value and gather contents as a list | |
assignment.value = this.parseQuotedValue(); | |
assignment.end = this.index; | |
isList = true; | |
listValues.push(assignment); | |
listQuoted.push(true); | |
assignment = new SlashCommandUnnamedArgumentAssignment(); | |
assignment.start = this.index; | |
} | |
while (!this.testUnnamedArgumentEnd()) { | |
if (split && splitCount && listValues.length >= splitCount) { | |
// the split count has just been reached: stop splitting, the rest is one singular value | |
split = false; | |
if (this.testQuotedValue()) { | |
// if the next bit is a quoted value, take the whole value | |
assignment.value = this.parseQuotedValue(); | |
assignment.end = this.index; | |
listValues.push(assignment); | |
listQuoted.push(true); | |
assignment = new SlashCommandUnnamedArgumentAssignment(); | |
assignment.start = this.index; | |
} | |
} | |
if (this.testClosure()) { | |
isList = true; | |
if (value.length > 0) { | |
this.indexMacros(this.index - value.length, value); | |
assignment.value = value; | |
listValues.push(assignment); | |
listQuoted.push(false); | |
assignment = new SlashCommandUnnamedArgumentAssignment(); | |
assignment.start = this.index; | |
if (!split && this.testQuotedValue()) { | |
// if where currently not splitting and the next bit is a quoted value, take the whole value | |
assignment.value = this.parseQuotedValue(); | |
assignment.end = this.index; | |
listValues.push(assignment); | |
listQuoted.push(true); | |
assignment = new SlashCommandUnnamedArgumentAssignment(); | |
assignment.start = this.index; | |
} else { | |
value = ''; | |
} | |
} | |
assignment.start = this.index; | |
assignment.value = this.parseClosure(); | |
assignment.end = this.index; | |
listValues.push(assignment); | |
assignment = new SlashCommandUnnamedArgumentAssignment(); | |
assignment.start = this.index; | |
if (split) this.discardWhitespace(); | |
} else if (split) { | |
if (this.testQuotedValue()) { | |
assignment.start = this.index; | |
assignment.value = this.parseQuotedValue(); | |
assignment.end = this.index; | |
listValues.push(assignment); | |
listQuoted.push(true); | |
assignment = new SlashCommandUnnamedArgumentAssignment(); | |
} else if (this.testListValue()) { | |
assignment.start = this.index; | |
assignment.value = this.parseListValue(); | |
assignment.end = this.index; | |
listValues.push(assignment); | |
listQuoted.push(false); | |
assignment = new SlashCommandUnnamedArgumentAssignment(); | |
} else if (this.testValue()) { | |
assignment.start = this.index; | |
assignment.value = this.parseValue(); | |
assignment.end = this.index; | |
listValues.push(assignment); | |
listQuoted.push(false); | |
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.length > 0) { | |
assignment.value = value; | |
listValues.push(assignment); | |
listQuoted.push(false); | |
} | |
if (isList) { | |
const firstVal = listValues[0]; | |
if (typeof firstVal?.value == 'string') { | |
if (!listQuoted[0]) { | |
// only trim the first part if it wasn't quoted | |
firstVal.value = firstVal.value.trimStart(); | |
} | |
if (firstVal.value.length == 0) { | |
listValues.shift(); | |
listQuoted.shift(); | |
} | |
} | |
const lastVal = listValues.slice(-1)[0]; | |
if (typeof lastVal?.value == 'string') { | |
if (!listQuoted.slice(-1)[0]) { | |
// only trim the last part if it wasn't quoted | |
lastVal.value = lastVal.value.trimEnd(); | |
} | |
if (lastVal.value.length == 0) { | |
listValues.pop(); | |
listQuoted.pop(); | |
} | |
} | |
if (wasSplit && splitCount && splitCount + 1 < listValues.length) { | |
// if split with a split count and there are more values than expected | |
// -> should be result of quoting + additional (non-whitespace) text | |
// -> join the parts into one and restore quotes | |
const joined = new SlashCommandUnnamedArgumentAssignment(); | |
joined.start = listValues[splitCount].start; | |
joined.end = listValues.slice(-1)[0].end; | |
joined.value = ''; | |
for (let i = splitCount; i < listValues.length; i++) { | |
if (listQuoted[i]) joined.value += `"${listValues[i].value}"`; | |
else joined.value += listValues[i].value; | |
} | |
listValues = [ | |
...listValues.slice(0, splitCount), | |
joined, | |
]; | |
} | |
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)); | |
} | |
} | |
} | |