PageSourceSearch

https://zcjg-hub.hf.space/scripts/slash-commands/SlashCommandParser.js

js zcjg-hub.hf.space collected 2026-10-03 17:34:44 UTC 54,828 bytes, 1,356 lines download raw bytes

1import { hljs } from '../../lib.js';
2import { power_user } from '../power-user.js';
3import { isFalseBoolean, isTrueBoolean, uuidv4 } from '../utils.js';
4import { SlashCommand } from './SlashCommand.js';
5import { ARGUMENT_TYPE, SlashCommandArgument } from './SlashCommandArgument.js';
6import { SlashCommandClosure } from './SlashCommandClosure.js';
7import { SlashCommandExecutor } from './SlashCommandExecutor.js';
8import { SlashCommandParserError } from './SlashCommandParserError.js';
9import { AutoCompleteNameResult } from '../autocomplete/AutoCompleteNameResult.js';
10import { SlashCommandQuickReplyAutoCompleteOption } from './SlashCommandQuickReplyAutoCompleteOption.js';
11import { SlashCommandScope } from './SlashCommandScope.js';
12import { SlashCommandVariableAutoCompleteOption } from './SlashCommandVariableAutoCompleteOption.js';
13import { SlashCommandNamedArgumentAssignment } from './SlashCommandNamedArgumentAssignment.js';
14import { SlashCommandAbortController } from './SlashCommandAbortController.js';
15import { SlashCommandAutoCompleteNameResult } from './SlashCommandAutoCompleteNameResult.js';
16import { SlashCommandUnnamedArgumentAssignment } from './SlashCommandUnnamedArgumentAssignment.js';
17import { SlashCommandEnumValue } from './SlashCommandEnumValue.js';
18import {
19    findUnclosedScopes,
20    buildMacroAutoCompleteResult,
21} from '../autocomplete/MacroAutoCompleteHelper.js';
22import { SlashCommandBreakPoint } from './SlashCommandBreakPoint.js';
23import { SlashCommandDebugController } from './SlashCommandDebugController.js';
24import { commonEnumProviders } from './SlashCommandCommonEnumsProvider.js';
25import { SlashCommandBreak } from './SlashCommandBreak.js';
26import { parseMacroContext } from '../autocomplete/EnhancedMacroAutoCompleteOption.js';
27
28/** @typedef {import('./SlashCommand.js').NamedArgumentsCapture} NamedArgumentsCapture */
29/** @typedef {import('./SlashCommand.js').NamedArguments} NamedArguments */
30/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */
31/** @typedef {import('../autocomplete/EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */
32
33/**
34 * @enum {Number}
35 * @readonly
36 * @typedef {{[id:PARSER_FLAG]:boolean}} ParserFlags
37 */
38export const PARSER_FLAG = {
39    'STRICT_ESCAPING': 1,
40    'REPLACE_GETVAR': 2,
41};
42
43export class SlashCommandParser {
44    /** @type {Object.<string, SlashCommand>} */ static commands = {};
45
46    /**
47     * @deprecated Use SlashCommandParser.addCommandObject() instead.
48     * @param {string} command Command name
49     * @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
50     * @param {string[]} aliases List of alternative command names
51     * @param {string} helpString Help text shown in autocomplete and command browser
52     */
53    static addCommand(command, callback, aliases, helpString = '') {
54        this.addCommandObject(SlashCommand.fromProps({
55            name: command,
56            callback,
57            aliases,
58            helpString,
59        }));
60    }
61    /**
62     *
63     * @param {SlashCommand} command
64     */
65    static addCommandObject(command) {
66        const reserved = ['/', '#', ':', 'parser-flag', 'breakpoint'];
67        for (const start of reserved) {
68            if (command.name.toLowerCase().startsWith(start) || (command.aliases ?? []).find(a => a.toLowerCase().startsWith(start))) {
69                throw new Error(`Illegal Name. Slash command name cannot begin with "${start}".`);
70            }
71        }
72        this.addCommandObjectUnsafe(command);
73    }
74    /**
75     *
76     * @param {SlashCommand} command
77     */
78    static addCommandObjectUnsafe(command) {
79        if ([command.name, ...command.aliases].some(x => Object.hasOwn(this.commands, x))) {
80            console.trace('WARN: Duplicate slash command registered!', [command.name, ...command.aliases]);
81        }
82
83        const stack = new Error().stack.split('\n').map(it => it.trim());
84        command.isExtension = stack.find(it => it.includes('/scripts/extensions/')) != null;
85        command.isThirdParty = stack.find(it => it.includes('/scripts/extensions/third-party/')) != null;
86        if (command.isThirdParty) {
87            command.source = stack.find(it => it.includes('/scripts/extensions/third-party/')).replace(/^.*?\/scripts\/extensions\/third-party\/([^/]+)\/.*$/, '$1');
88        } else if (command.isExtension) {
89            command.source = stack.find(it => it.includes('/scripts/extensions/')).replace(/^.*?\/scripts\/extensions\/([^/]+)\/.*$/, '$1');
90        } else {
91            const idx = stack.findLastIndex(it => it.includes('at SlashCommandParser.')) + 1;
92            command.source = stack[idx].replace(/^.*?\/((?:scripts\/)?(?:[^/]+)\.js).*$/, '$1');
93        }
94
95        this.commands[command.name] = command;
96
97        if (Array.isArray(command.aliases)) {
98            command.aliases.forEach((alias) => {
99                this.commands[alias] = command;
100            });
101        }
102    }
103
104
105    get commands() {
106        return SlashCommandParser.commands;
107    }
108    /** @type {Object.<string, string>} */ helpStrings = {};
109    /** @type {boolean} */ verifyCommandNames = true;
110    /** @type {string} */ text;
111    /** @type {number} */ index;
112    /** @type {SlashCommandAbortController} */ abortController;
113    /** @type {SlashCommandDebugController} */ debugController;
114    /** @type {SlashCommandScope} */ scope;
115    /** @type {SlashCommandClosure} */ closure;
116
117    /** @type {Object.<PARSER_FLAG,boolean>} */ flags = {};
118
119    /** @type {boolean} */ jumpedEscapeSequence = false;
120
121    /** @type {{start:number, end:number}[]} */ closureIndex;
122    /** @type {{start:number, end:number, name:string}[]} */ macroIndex;
123    /** @type {SlashCommandExecutor[]} */ commandIndex;
124    /** @type {SlashCommandScope[]} */ scopeIndex;
125
126    /** @type {string} */ parserContext;
127
128    get userIndex() { return this.index; }
129
130    get ahead() {
131        return this.text.slice(this.index + 1);
132    }
133    get behind() {
134        return this.text.slice(0, this.index);
135    }
136    get char() {
137        return this.text[this.index];
138    }
139    get endOfText() {
140        return this.index >= this.text.length || (/\s/.test(this.char) && /^\s+$/.test(this.ahead));
141    }
142
143
144    constructor() {
145        // add dummy commands for help strings / autocomplete
146        if (!Object.keys(this.commands).includes('parser-flag')) {
147            const help = {};
148            help[PARSER_FLAG.REPLACE_GETVAR] = 'Replace all {{getvar::}} and {{getglobalvar::}} macros with scoped variables to avoid double macro substitution.';
149            help[PARSER_FLAG.STRICT_ESCAPING] = 'Allows to escape all delimiters with backslash, and allows escaping of backslashes.';
150            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'parser-flag',
151                unnamedArgumentList: [
152                    SlashCommandArgument.fromProps({
153                        description: 'The parser flag to modify.',
154                        typeList: [ARGUMENT_TYPE.STRING],
155                        isRequired: true,
156                        enumList: Object.keys(PARSER_FLAG).map(flag => new SlashCommandEnumValue(flag, help[PARSER_FLAG[flag]])),
157                    }),
158                    SlashCommandArgument.fromProps({
159                        description: 'The state of the parser flag to set.',
160                        typeList: [ARGUMENT_TYPE.BOOLEAN],
161                        defaultValue: 'on',
162                        enumList: commonEnumProviders.boolean('onOff')(),
163                    }),
164                ],
165                splitUnnamedArgument: true,
166                helpString: 'Set a parser flag.',
167            }));
168        }
169        if (!Object.keys(this.commands).includes('/')) {
170            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: '/',
171                aliases: ['#'],
172                unnamedArgumentList: [
173                    SlashCommandArgument.fromProps({
174                        description: 'commentary',
175                        typeList: [ARGUMENT_TYPE.STRING],
176                    }),
177                ],
178                helpString: 'Write a comment.',
179            }));
180        }
181        if (!Object.keys(this.commands).includes('breakpoint')) {
182            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'breakpoint',
183                helpString: 'Set a breakpoint for debugging in the QR Editor.',
184            }));
185        }
186        if (!Object.keys(this.commands).includes('break')) {
187            SlashCommandParser.addCommandObjectUnsafe(SlashCommand.fromProps({ name: 'break',
188                helpString: 'Break out of a loop or closure executed through /run or /:',
189                unnamedArgumentList: [
190                    SlashCommandArgument.fromProps({ description: 'value to pass down the pipe instead of the current pipe value',
191                        typeList: Object.values(ARGUMENT_TYPE),
192                    }),
193                ],
194            }));
195        }
196
197        //TODO should not be re-registered from every instance
198        this.registerLanguage();
199    }
200    registerLanguage() {
201        // NUMBER mode is copied from highlightjs's own implementation for JavaScript
202        // https://tc39.es/ecma262/#sec-literals-numeric-literals
203        const decimalDigits = '[0-9](_?[0-9])*';
204        const frac = `\\.(${decimalDigits})`;
205        // DecimalIntegerLiteral, including Annex B NonOctalDecimalIntegerLiteral
206        // https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals
207        const decimalInteger = '0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*';
208        const NUMBER = {
209            className: 'number',
210            variants: [
211                // DecimalLiteral
212                { begin: `(\\b(${decimalInteger})((${frac})|\\.)?|(${frac}))` +
213        `[eE][+-]?(${decimalDigits})\\b` },
214                { begin: `\\b(${decimalInteger})\\b((${frac})\\b|\\.)?|(${frac})\\b` },
215
216                // DecimalBigIntegerLiteral
217                { begin: '\\b(0|[1-9](_?[0-9])*)n\\b' },
218
219                // NonDecimalIntegerLiteral
220                { begin: '\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b' },
221                { begin: '\\b0[bB][0-1](_?[0-1])*n?\\b' },
222                { begin: '\\b0[oO][0-7](_?[0-7])*n?\\b' },
223
224                // LegacyOctalIntegerLiteral (does not include underscore separators)
225                // https://tc39.es/ecma262/#sec-additional-syntax-numeric-literals
226                { begin: '\\b0[0-7]+n?\\b' },
227            ],
228            relevance: 0,
229        };
230
231        function getQuotedRunRegex() {
232            try {
233                return new RegExp('(".+?(?<!\\\\)")|((?:[^\\s\\|"]|"[^"]*")*)(\\||$|\\s)');
234            } catch {
235                // fallback for browsers that don't support lookbehind
236                return /(".+?")|(\S+?)(\||$|\s)/;
237            }
238        }
239
240        const BLOCK_COMMENT = {
241            scope: 'comment',
242            begin: /\/\*/,
243            end: /\*\|/,
244            contains: [],
245        };
246        const COMMENT = {
247            scope: 'comment',
248            begin: /\/[/#]/,
249            end: /\||$|:}/,
250            contains: [],
251        };
252        const ABORT = {
253            begin: /\/(abort|breakpoint)/,
254            beginScope: 'abort',
255            end: /\||$|(?=:})/,
256            excludeEnd: false,
257            returnEnd: true,
258            contains: [],
259        };
260        const IMPORT = {
261            scope: 'command',
262            begin: /\/(import)/,
263            beginScope: 'keyword',
264            end: /\||$|(?=:})/,
265            excludeEnd: false,
266            returnEnd: true,
267            contains: [],
268        };
269        const BREAK = {
270            scope: 'command',
271            begin: /\/(break)/,
272            beginScope: 'keyword',
273            end: /\||$|(?=:})/,
274            excludeEnd: false,
275            returnEnd: true,
276            contains: [],
277        };
278        const LET = {
279            begin: [
280                /\/(let|var)\s+/,
281            ],
282            beginScope: {
283                1: 'variable',
284            },
285            end: /\||$|:}/,
286            excludeEnd: false,
287            returnEnd: true,
288            contains: [],
289        };
290        const SETVAR = {
291            begin: /\/(setvar|setglobalvar)\s+/,
292            beginScope: 'variable',
293            end: /\||$|:}/,
294            excludeEnd: false,
295            returnEnd: true,
296            contains: [],
297        };
298        const GETVAR = {
299            begin: /\/(getvar|getglobalvar)\s+/,
300            beginScope: 'variable',
301            end: /\||$|:}/,
302            excludeEnd: false,
303            returnEnd: true,
304            contains: [],
305        };
306        const RUN = {
307            match: [
308                /\/:/,
309                getQuotedRunRegex(),
310                /\||$|(?=:})/,
311            ],
312            className: {
313                1: 'variable.language',
314                2: 'title.function.invoke',
315            },
316            contains: [], // defined later
317        };
318        const COMMAND = {
319            scope: 'command',
320            begin: /\/\S+/,
321            beginScope: 'title.function',
322            end: /\||$|(?=:})/,
323            excludeEnd: false,
324            returnEnd: true,
325            contains: [], // defined later
326        };
327        const CLOSURE = {
328            scope: 'closure',
329            begin: /{:/,
330            end: /:}(\(\))?/,
331            beginScope: 'punctuation',
332            endScope: 'punctuation',
333            contains: [], // defined later
334        };
335        const NAMED_ARG = {
336            scope: 'property',
337            begin: /\w+=/,
338            end: '',
339        };
340        const MACRO = {
341            scope: 'variable',
342            begin: /{{/,
343            end: /}}/,
344        };
345        const PIPEBREAK = {
346            beginScope: 'pipebreak',
347            begin: /\|\|/,
348            end: '',
349        };
350        const PIPE = {
351            beginScope: 'pipe',
352            begin: /\|/,
353            end: '',
354        };
355        BLOCK_COMMENT.contains.push(
356            BLOCK_COMMENT,
357        );
358        RUN.contains.push(
359            hljs.BACKSLASH_ESCAPE,
360            NAMED_ARG,
361            hljs.QUOTE_STRING_MODE,
362            NUMBER,
363            MACRO,
364            CLOSURE,
365        );
366        IMPORT.contains.push(
367            hljs.BACKSLASH_ESCAPE,
368            NAMED_ARG,
369            NUMBER,
370            MACRO,
371            CLOSURE,
372            hljs.QUOTE_STRING_MODE,
373        );
374        BREAK.contains.push(
375            hljs.BACKSLASH_ESCAPE,
376            NAMED_ARG,
377            NUMBER,
378            MACRO,
379            CLOSURE,
380            hljs.QUOTE_STRING_MODE,
381        );
382        LET.contains.push(
383            hljs.BACKSLASH_ESCAPE,
384            NAMED_ARG,
385            NUMBER,
386            MACRO,
387            CLOSURE,
388            hljs.QUOTE_STRING_MODE,
389        );
390        SETVAR.contains.push(
391            hljs.BACKSLASH_ESCAPE,
392            NAMED_ARG,
393            NUMBER,
394            MACRO,
395            CLOSURE,
396            hljs.QUOTE_STRING_MODE,
397        );
398        GETVAR.contains.push(
399            hljs.BACKSLASH_ESCAPE,
400            NAMED_ARG,
401            hljs.QUOTE_STRING_MODE,
402            NUMBER,
403            MACRO,
404            CLOSURE,
405        );
406        ABORT.contains.push(
407            hljs.BACKSLASH_ESCAPE,
408            NAMED_ARG,
409            NUMBER,
410            MACRO,
411            CLOSURE,
412            hljs.QUOTE_STRING_MODE,
413        );
414        COMMAND.contains.push(
415            hljs.BACKSLASH_ESCAPE,
416            NAMED_ARG,
417            NUMBER,
418            MACRO,
419            CLOSURE,
420            hljs.QUOTE_STRING_MODE,
421        );
422        CLOSURE.contains.push(
423            hljs.BACKSLASH_ESCAPE,
424            BLOCK_COMMENT,
425            COMMENT,
426            ABORT,
427            IMPORT,
428            BREAK,
429            NAMED_ARG,
430            NUMBER,
431            MACRO,
432            RUN,
433            LET,
434            GETVAR,
435            SETVAR,
436            COMMAND,
437            'self',
438            hljs.QUOTE_STRING_MODE,
439            PIPEBREAK,
440            PIPE,
441        );
442        hljs.registerLanguage('stscript', () => ({
443            case_insensitive: false,
444            keywords: [],
445            contains: [
446                hljs.BACKSLASH_ESCAPE,
447                BLOCK_COMMENT,
448                COMMENT,
449                ABORT,
450                IMPORT,
451                BREAK,
452                RUN,
453                LET,
454                GETVAR,
455                SETVAR,
456                COMMAND,
457                CLOSURE,
458                PIPEBREAK,
459                PIPE,
460            ],
461        }));
462    }
463
464    getHelpString() {
465        return '<div class="slashHelp">Loading...</div>';
466    }
467
468    /**
469     *
470     * @param {*} text The text to parse.
471     * @param {*} index Index to check for names (cursor position).
472     */
473    async getNameAt(text, index) {
474        if (this.text != text) {
475            try {
476                this.parse(text, false);
477            } catch (e) {
478                // do nothing
479                console.warn(e);
480            }
481        }
482        const executor = this.commandIndex
483            .filter(it => it.start <= index && (it.end >= index || it.end == null))
484            .slice(-1)[0]
485            ?? null
486        ;
487
488        if (executor) {
489            const childClosure = this.closureIndex
490                .find(it => it.start <= index && (it.end >= index || it.end == null) && it.start > executor.start)
491                ?? null
492            ;
493            if (childClosure !== null) return null;
494            // Check if cursor is inside a macro
495            const macroEntry = this.macroIndex.findLast(it => it.start <= index && it.end >= index);
496            if (macroEntry) {
497                // Build macro info object for shared function
498                const macroContent = text.slice(macroEntry.start + 2, macroEntry.end - (text.slice(macroEntry.end - 2, macroEntry.end) === '}}' ? 2 : 0));
499                const macro = {
500                    start: macroEntry.start,
501                    end: macroEntry.end,
502                    content: macroContent,
503                };
504
505                // Use the shared macro autocomplete builder
506                const result = await buildMacroAutoCompleteResult(text, index, { macro });
507                if (result) return result;
508            }
509
510            // Check if cursor is in scoped content of an unclosed macro (not inside a macro)
511            const textUpToCursor = text.slice(0, index);
512            const unclosedScopes = findUnclosedScopes(textUpToCursor);
513            if (unclosedScopes.length > 0) {
514                // Use the shared macro autocomplete builder for scoped content
515                const result = await buildMacroAutoCompleteResult(text, index, {
516                    macro: null,
517                    textUpToCursor,
518                    unclosedScopes,
519                });
520                if (result) return result;
521            }
522            if (executor.name == ':') {
523                const options = this.scopeIndex[this.commandIndex.indexOf(executor)]
524                    ?.allVariableNames
525                    ?.map(it => new SlashCommandVariableAutoCompleteOption(it))
526                    ?? []
527                ;
528                try {
529                    if ('quickReplyApi' in globalThis) {
530                        const qrApi = globalThis.quickReplyApi;
531                        options.push(...qrApi.listSets()
532                            .map(set => qrApi.listQuickReplies(set).map(qr => `${set}.${qr}`))
533                            .flat()
534                            .map(qr => new SlashCommandQuickReplyAutoCompleteOption(qr)),
535                        );
536                    }
537                } catch { /* empty */ }
538                const result = new AutoCompleteNameResult(
539                    executor.unnamedArgumentList[0]?.value.toString(),
540                    executor.start,
541                    options,
542                    true,
543                    () =>
543 `No matching variables in scope and no matching Quick Replies for "${result.name}"`,
544                    () => 'No variables in scope and no Quick Replies found.',
545                );
546                return result;
547            }
548            const result = new SlashCommandAutoCompleteNameResult(executor, this.scopeIndex[this.commandIndex.indexOf(executor)], this.commands);
549            return result;
550        }
551        return null;
552    }
553
554    /**
555     * Moves the index <length> number of characters forward and returns the last character taken.
556     * @param {number} length Number of characters to take.
557     * @param {boolean} keep Whether to add the characters to the kept text.
558     * @returns The last character taken.
559     */
560    take(length = 1) {
561        this.jumpedEscapeSequence = false;
562        let content = this.char;
563        this.index++;
564        if (length > 1) {
565            content = this.take(length - 1);
566        }
567        return content;
568    }
569    discardWhitespace() {
570        while (/\s/.test(this.char)) {
571            this.take(); // discard whitespace
572            this.jumpedEscapeSequence = false;
573        }
574    }
575    /**
576     * Tests if the next characters match a symbol.
577     * Moves the index forward if the next characters are backslashes directly followed by the symbol.
578     * Expects that the current char is taken after testing.
579     * @param {string|RegExp} sequence Sequence of chars or regex character group that is the symbol.
580     * @param {number} offset Offset from the current index (won't move the index if offset != 0).
581     * @returns Whether the next characters are the indicated symbol.
582     */
583    testSymbol(sequence, offset = 0) {
584        if (!this.flags[PARSER_FLAG.STRICT_ESCAPING]) return this.testSymbolLooseyGoosey(sequence, offset);
585        // /echo abc | /echo def
586        // -> TOAST: abc
587        // -> TOAST: def
588        // /echo abc \| /echo def
589        // -> TOAST: abc | /echo def
590        // /echo abc \\| /echo def
591        // -> TOAST: abc \
592        // -> TOAST: def
593        // /echo abc \\\| /echo def
594        // -> TOAST: abc \| /echo def
595        // /echo abc \\\\| /echo def
596        // -> TOAST: abc \\
597        // -> TOAST: def
598        // /echo title=\:} \{: | /echo title=\{: \:}
599        // -> TOAST: *:}* {:
600        // -> TOAST: *{:* :}
601        const escapeOffset = this.jumpedEscapeSequence ? -1 : 0;
602        const escapes = this.text.slice(this.index + offset + escapeOffset).replace(/^(\\*).*$/s, '$1').length;
603        const test = (sequence instanceof RegExp) ?
604            (text) => new RegExp(`^${sequence.source}`).test(text) :
605            (text) => text.startsWith(sequence)
606        ;
607        if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) {
608            // no backslashes before sequence
609            //   -> sequence found
610            if (escapes == 0) return true;
611            // uneven number of backslashes before sequence
612            //   = the final backslash escapes the sequence
613            //   = every preceding pair is one literal backslash
614            //    -> move index forward to skip the backslash escaping the first backslash or the symbol
615            // even number of backslashes before sequence
616            //   = every pair is one literal backslash
617            //    -> move index forward to skip the backslash escaping the first backslash
618            if (!this.jumpedEscapeSequence && offset == 0) {
619                this.index++;
620                this.jumpedEscapeSequence = true;
621            }
622            return false;
623        }
624    }
625
626    testSymbolLooseyGoosey(sequence, offset = 0) {
627        const escapeOffset = this.jumpedEscapeSequence ? -1 : 0;
628        const escapes = this.text[this.index + offset + escapeOffset] == '\\' ? 1 : 0;
629        const test = (sequence instanceof RegExp) ?
630            (text) => new RegExp(`^${sequence.source}`).test(text) :
631            (text) => text.startsWith(sequence)
632        ;
633        if (test(this.text.slice(this.index + offset + escapeOffset + escapes))) {
634            // no backslashes before sequence
635            //   -> sequence found
636            if (escapes == 0) return true;
637            // otherwise
638            //   -> sequence found
639            if (!this.jumpedEscapeSequence && offset == 0) {
640                this.index++;
641                this.jumpedEscapeSequence = true;
642            }
643            return false;
644        }
645    }
646
647    replaceGetvar(value) {
648        // Not needed with the new parser.
649        if (power_user.experimental_macro_engine) {
650            return value;
651        }
652        return value.replace(/{{(get(?:global)?var)::([^}]+)}}/gi, (match, cmd, name, idx) => {
653            name = name.trim();
654            cmd = cmd.toLowerCase();
655            const startIdx = this.index - value.length + idx;
656            const endIdx = this.index - value.length + idx + match.length;
657            // store pipe
658            const pipeName = `_PARSER_PIPE_${uuidv4()}`;
659            const storePipe = new SlashCommandExecutor(startIdx); {
660                storePipe.end = endIdx;
661                storePipe.command = this.commands.let;
662                storePipe.name = 'let';
663                const nameAss = new SlashCommandUnnamedArgumentAssignment();
664                nameAss.value = pipeName;
665                const valAss = new SlashCommandUnnamedArgumentAssignment();
666                valAss.value = '{{pipe}}';
667                storePipe.unnamedArgumentList = [nameAss, valAss];
668                this.closure.executorList.push(storePipe);
669            }
670            // getvar / getglobalvar
671            const getvar = new SlashCommandExecutor(startIdx); {
672                getvar.end = endIdx;
673                getvar.command = this.commands[cmd];
674                getvar.name = cmd;
675                const nameAss = new SlashCommandUnnamedArgumentAssignment();
676                nameAss.value = name;
677                getvar.unnamedArgumentList = [nameAss];
678                this.closure.executorList.push(getvar);
679            }
680            // set to temp scoped var
681            const varName = `_PARSER_VAR_${uuidv4()}`;
682            const setvar = new SlashCommandExecutor(startIdx); {
683                setvar.end = endIdx;
684                setvar.command = this.commands.let;
685                setvar.name = 'let';
686                const nameAss = new SlashCommandUnnamedArgumentAssignment();
687                nameAss.value = varName;
688                const valAss = new SlashCommandUnnamedArgumentAssignment();
689                valAss.value = '{{pipe}}';
690                setvar.unnamedArgumentList = [nameAss, valAss];
691                this.closure.executorList.push(setvar);
692            }
693            // return pipe
694            const returnPipe = new SlashCommandExecutor(startIdx); {
695                returnPipe.end = endIdx;
696                returnPipe.command = this.commands.return;
697                returnPipe.name = 'return';
698                const varAss = new SlashCommandUnnamedArgumentAssignment();
699                varAss.value = `{{var::${pipeName}}}`;
700                returnPipe.unnamedArgumentList = [varAss];
701                this.closure.executorList.push(returnPipe);
702            }
703            return `{{var::${varName}}}`;
704        });
705    }
706
707
708    parse(text, verifyCommandNames = true, flags = null, abortController = null, debugController = null) {
709        this.verifyCommandNames = verifyCommandNames;
710        for (const key of Object.keys(PARSER_FLAG)) {
711            this.flags[PARSER_FLAG[key]] = flags?.[PARSER_FLAG[key]] ?? power_user.stscript.parser.flags[PARSER_FLAG[key]] ?? false;
712        }
713        this.abortController = abortController;
714        this.debugController = debugController;
715        this.text = text;
716        this.index = 0;
717        this.scope = null;
718        this.closureIndex = [];
719        this.commandIndex = [];
720        this.scopeIndex = [];
721        this.macroIndex = [];
722        this.parserContext = uuidv4();
723        const closure = this.parseClosure(true);
724        return closure;
725    }
726
727    testClosure() {
728        return this.testSymbol('{:');
729    }
730    testClosureEnd() {
731        if (!this.scope.parent) {
732            // "root" closure does not have {: and :}
733            if (this.index >= this.text.length) return true;
734            return false;
735        }
736        if (!this.verifyCommandNames) {
737            if (this.index >= this.text.length) return true;
738        } else {
739            if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed closure at position ${this.userIndex}`, this.text, this.index);
740        }
741        return this.testSymbol(':}');
742    }
743    parseClosure(isRoot = false) {
744        const closureIndexEntry = { start: this.index + 1, end: null };
745        this.closureIndex.push(closureIndexEntry);
746        let injectPipe = true;
747        if (!isRoot) this.take(2); // discard opening {:
748        const textStart = this.index;
749        let closure = new SlashCommandClosure(this.scope);
750        closure.parserContext = this.parserContext;
751        closure.fullText = this.text;
752        closure.abortController = this.abortController;
753        closure.debugController = this.debugController;
754        this.scope = closure.scope;
755        const oldClosure = this.closure;
756        this.closure = closure;
757        this.discardWhitespace();
758        while (this.testNamedArgument()) {
759            const arg = this.parseNamedArgument();
760            closure.argumentList.push(arg);
761            this.scope.variableNames.push(arg.name);
762            this.discardWhitespace();
763        }
764        while (!this.testClosureEnd()) {
765            if (this.testBlockComment()) {
766                this.parseBlockComment();
767            } else if (this.testComment()) {
768                this.parseComment();
769            } else if (this.testParserFlag()) {
770                this.parseParserFlag();
771            } else if (this.testRunShorthand()) {
772                const cmd = this.parseRunShorthand();
773                closure.executorList.push(cmd);
774                injectPipe = true;
775            } else if (this.testBreakPoint()) {
776                const bp = this.parseBreakPoint();
777                if (this.debugController) {
778                    closure.executorList.push(bp);
779                }
780            } else if (this.testBreak()) {
781                const b = this.parseBreak();
782                closure.executorList.push(b);
783            } else if (this.testCommand()) {
784                const cmd = this.parseCommand();
785                cmd.injectPipe = injectPipe;
786                closure.executorList.push(cmd);
787                injectPipe = true;
788            } else {
789                while (!this.testCommandEnd()) this.take(); // discard plain text and comments
790            }
791            this.discardWhitespace();
792            // first pipe marks end of command
793            if (this.testSymbol('|')) {
794                this.take(); // discard first pipe
795                // second pipe indicates no pipe injection for the next command
796                if (this.testSymbol('|')) {
797                    injectPipe = false;
798                    this.take(); // discard second pipe
799                }
800            }
801            this.discardWhitespace(); // discard further whitespace
802        }
803        closure.rawText = this.text.slice(textStart, this.index);
804        if (!isRoot) this.take(2); // discard closing :}
805        if (this.testSymbol('()')) {
806            this.take(2); // discard ()
807            closure.executeNow = true;
808        }
809        closureIndexEntry.end = this.index - 1;
810        this.scope = closure.scope.parent;
811        this.closure = oldClosure ?? closure;
812        return closure;
813    }
814
815    testBreakPoint() {
816        return this.testSymbol(/\/breakpoint\s*\|/);
817    }
818    parseBreakPoint() {
819        const cmd = new SlashCommandBreakPoint();
820        cmd.name = 'breakpoint';
821        cmd.command = this.commands.breakpoint;
822        cmd.start = this.index + 1;
823        this.take('/breakpoint'.length);
824        cmd.end = this.index;
825        this.commandIndex.push(cmd);
826        this.scopeIndex.push(this.scope.getCopy());
827        return cmd;
828    }
829
830    testBreak() {
831        return this.testSymbol(/\/break(\s|\||$)/);
832    }
833    parseBreak() {
834        const cmd = new SlashCommandBreak();
835        cmd.name = 'break';
836        cmd.command = this.commands.break;
837        cmd.start = this.index + 1;
838        this.take('/break'.length);
839        this.discardWhitespace();
840        if (this.testUnnamedArgument()) {
841            cmd.unnamedArgumentList.push(...this.parseUnnamedArgument());
842        }
843        cmd.end = this.index;
844        this.commandIndex.push(cmd);
845        this.scopeIndex.push(this.scope.getCopy());
846        return cmd;
847    }
848
849    testBlockComment() {
850        return this.testSymbol('/*');
851    }
852    testBlockCommentEnd() {
853        if (!this.verifyCommandNames) {
854            if (this.index >= this.text.length) return true;
855        } else {
856            if (this.ahead.length < 1) throw new SlashCommandParserError(`Unclosed block comment at position ${this.userIndex}`, this.text, this.index);
857        }
858        return this.testSymbol('*|');
859    }
860    parseBlockComment() {
861        const start = this.index + 1;
862        const cmd = new SlashCommandExecutor(start);
863        cmd.command = this.commands['*'];
864        this.commandIndex.push(cmd);
865        this.scopeIndex.push(this.scope.getCopy());
866        this.take(); // discard "/"
867        cmd.name = this.take(); //set "*" as name
868        while (!this.testBlockCommentEnd()) {
869            if (this.testBlockComment()) {
870                this.parseBlockComment();
871            }
872            this.take();
873        }
874        this.take(2); // take closing "*|"
875        cmd.end = this.index - 1;
876    }
877
878    testComment() {
879        return this.testSymbol(/\/[/#]/);
880    }
881    testCommentEnd() {
882        if (!this.verifyCommandNames) {
883            if (this.index >= this.text.length) return true;
884        } else {
885            if (this.endOfText) throw new SlashCommandParserError(`Unclosed comment at position ${this.userIndex}`, this.text, this.index);
886        }
887        return this.testSymbol('|');
888    }
889    parseComment() {
890        const start = this.index + 1;
891        const cmd = new SlashCommandExecutor(start);
892        cmd.command = this.commands['/'];
893        this.commandIndex.push(cmd);
894        this.scopeIndex.push(this.scope.getCopy());
895        this.take(); // discard "/"
896        cmd.name = this.take(); // set second "/" or "#" as name
897        while (!this.testCommentEnd()) this.take();
898        cmd.end = this.index;
899    }
900
901    testParserFlag() {
902        return this.testSymbol('/parser-flag ');
903    }
904    testParserFlagEnd() {
905        return this.testCommandEnd();
906    }
907    parseParserFlag() {
908        const start = this.index + 1;
909        const cmd = new SlashCommandExecutor(start);
910        cmd.name = 'parser-flag';
911        cmd.unnamedArgumentList = [];
912        cmd.command = this.commands[cmd.name];
913        this.commandIndex.push(cmd);
914        this.scopeIndex.push(this.scope.getCopy());
915        this.take(13); // discard "/parser-flag "
916        cmd.startNamedArgs = -1;
917        cmd.endNamedArgs = -1;
918        cmd.startUnnamedArgs = this.index;
919        cmd.unnamedArgumentList = this.parseUnnamedArgument(true);
920        const [flag, state] = cmd.unnamedArgumentList ?? [null, null];
921        cmd.endUnnamedArgs = this.index;
922        if (Object.keys(PARSER_FLAG).includes(flag.value.toString())) {
923            this.flags[PARSER_FLAG[flag.value.toString()]] = isTrueBoolean(state?.value.toString() ?? 'on');
924        }
925        cmd.end = this.index;
926    }
927
928    testRunShorthand() {
929        return this.testSymbol('/:') && !this.testSymbol(':}', 1);
930    }
931    testRunShorthandEnd() {
932        return this.testCommandEnd();
933    }
934    parseRunShorthand() {
935        const start = this.index + 2;
936        const cmd = new SlashCommandExecutor(start);
937        cmd.name = ':';
938        cmd.unnamedArgumentList = [];
939        cmd.command = this.commands.run;
940        this.commandIndex.push(cmd);
941        this.scopeIndex.push(this.scope.getCopy());
942        this.take(2); //discard "/:"
943        const assignment = new SlashCommandUnnamedArgumentAssignment();
944        if (this.testQuotedValue()) assignment.value = this.parseQuotedValue();
945        else assignment.value = this.parseValue();
946        cmd.unnamedArgumentList = [assignment];
947        this.discardWhitespace();
948        cmd.startNamedArgs = this.index;
949        while (this.testNamedArgument()) {
950            const arg = this.parseNamedArgument();
951            cmd.namedArgumentList.push(arg);
952            this.discardWhitespace();
953        }
954        cmd.endNamedArgs = this.index;
955        this.discardWhitespace();
956        // /run shorthand does not take unnamed arguments (the command name practically *is* the unnamed argument)
957        if (this.testRunShorthandEnd()) {
958            cmd.end = this.index;
959            return cmd;
960        } else {
961            console.warn(this.behind, this.char, this.ahead);
962            throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index);
963        }
964    }
965
966    testCommand() {
967        return this.testSymbol('/');
968    }
969    testCommandEnd() {
970        if (this.testClosureEnd()) return true;
971        // Only treat | as command end if we're not inside macro braces {{}}
972        if (this.testSymbol('|') && !this.isInsideMacroBraces()) return true;
973        return false;
974    }
975
976    /**
977     * Checks if the current position is inside unclosed macro braces {{...}}.
978     * This prevents pipes inside macros from being treated as command separators.
979     * @returns {boolean} True if inside unclosed macro braces.
980     */
981    isInsideMacroBraces() {
982        const textBehind = this.behind;
983        let depth = 0;
984
985        // Scan through the text to track macro brace depth
986        for (let i = 0; i < textBehind.length; i++) {
987            if (textBehind[i] === '{' && textBehind[i + 1] === '{') {
988                depth++;
989                i++; // Skip the second {
990            } else if (textBehind[i] === '}' && textBehind[i + 1] === '}') {
991                depth = Math.max(0, depth - 1);
992                i++; // Skip the second }
993            }
994        }
995
996        return depth > 0;
997    }
998    parseCommand() {
999        const start = this.index + 1;
1000        const cmd = new SlashCommandExecutor(start);
1001        cmd.parserFlags = Object.assign({}, this.flags);
1002        this.commandIndex.push(cmd);
1003        this.scopeIndex.push(this.scope.getCopy());
1004        this.take(); // discard "/"
1005        while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end
1006        this.discardWhitespace();
1007        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);
1008        cmd.command = this.commands[cmd.name];
1009        cmd.startNamedArgs = this.index;
1010        cmd.endNamedArgs = this.index;
1011        while (this.testNamedArgument()) {
1012            const arg = this.parseNamedArgument();
1013            cmd.namedArgumentList.push(arg);
1014            cmd.endNamedArgs = this.index;
1015            this.discardWhitespace();
1016        }
1017        this.discardWhitespace();
1018        cmd.startUnnamedArgs = this.index - (/\s(\s*)$/s.exec(this.behind)?.[1]?.length ?? 0);
1019        cmd.endUnnamedArgs = this.index;
1020        if (this.testUnnamedArgument()) {
1021            const rawQuotesArg = cmd?.namedArgumentList?.find(a => a.name === 'raw');
1022            const rawQuotes = cmd?.command?.rawQuotes && rawQuotesArg ? !isFalseBoolean(rawQuotesArg?.value?.toString()) : cmd?.command?.rawQuotes;
1023            cmd.unnamedArgumentList = this.parseUnnamedArgument(cmd.command?.unnamedArgumentList?.length && cmd?.command?.splitUnnamedArgument, cmd?.command?.splitUnnamedArgumentCount, rawQuotes);
1024            cmd.endUnnamedArgs = this.index;
1025            if (cmd.name == 'let') {
1026                const keyArg = cmd.namedArgumentList.find(it => it.name == 'key');
1027                if (keyArg) {
1028                    this.scope.variableNames.push(keyArg.value.toString());
1029                } else if (typeof cmd.unnamedArgumentList[0]?.value == 'string') {
1030                    this.scope.variableNames.push(cmd.unnamedArgumentList[0].value);
1031                }
1032            } else if (cmd.name == 'import') {
1033                const value = /**@type {string[]}*/(cmd.unnamedArgumentList.map(it => it.value));
1034                for (let i = 0; i < value.length; i++) {
1035                    const srcName = value[i];
1036                    let dstName = srcName;
1037                    if (i + 2 < value.length && value[i + 1] == 'as') {
1038                        dstName = value[i + 2];
1039                        i += 2;
1040                    }
1041                    this.scope.variableNames.push(dstName);
1042                }
1043            }
1044        }
1045        if (this.testCommandEnd()) {
1046            cmd.end = this.index;
1047            return cmd;
1048        } else {
1049            console.warn(this.behind, this.char, this.ahead);
1050            throw new SlashCommandParserError(`Unexpected end of command at position ${this.userIndex}: "/${cmd.name}"`, this.text, this.index);
1051        }
1052    }
1053
1054    testNamedArgument() {
1055        return /^(\w+)=/.test(`${this.char}${this.ahead}`);
1056    }
1057    parseNamedArgument() {
1058        let assignment = new SlashCommandNamedArgumentAssignment();
1059        assignment.start = this.index;
1060        let key = '';
1061        while (/\w/.test(this.char)) key += this.take(); // take chars
1062        this.take(); // discard "="
1063        assignment.name = key;
1064        if (this.testClosure()) {
1065            assignment.value = this.parseClosure();
1066        } else if (this.testQuotedValue()) {
1067            assignment.value = this.parseQuotedValue();
1068        } else if (this.testListValue()) {
1069            assignment.value = this.parseListValue();
1070        } else if (this.testValue()) {
1071            assignment.value = this.parseValue();
1072        }
1073        assignment.end = this.index;
1074        return assignment;
1075    }
1076
1077    testUnnamedArgument() {
1078        return !this.testCommandEnd();
1079    }
1080    testUnnamedArgumentEnd() {
1081        return this.testCommandEnd();
1082    }
1083    parseUnnamedArgument(split, splitCount = null, rawQuotes = false) {
1084        const wasSplit = split;
1085        /**@type {SlashCommandClosure|String}*/
1086        let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
1087        let isList = split;
1088        let listValues = [];
1089        let listQuoted = []; // keep track of which listValues were quoted
1090        /**@type {SlashCommandUnnamedArgumentAssignment}*/
1091        let assignment = new SlashCommandUnnamedArgumentAssignment();
1092        assignment.start = this.index;
1093        if (!split && !rawQuotes && this.testQuotedValue()) {
1094            // if the next bit is a quoted value, take the whole value and gather contents as a list
1095            assignment.value = this.parseQuotedValue();
1096            assignment.end = this.index;
1097            isList = true;
1098            listValues.push(assignment);
1099            listQuoted.push(true);
1100            assignment = new SlashCommandUnnamedArgumentAssignment();
1101            assignment.start = this.index;
1102        }
1103        while (!this.testUnnamedArgumentEnd()) {
1104            if (split && splitCount && listValues.length >= splitCount) {
1105                // the split count has just been reached: stop splitting, the rest is one singular value
1106                split = false;
1107                if (this.testQuotedValue()) {
1108                    // if the next bit is a quoted value, take the whole value
1109                    assignment.value = this.parseQuotedValue();
1110                    assignment.end = this.index;
1111                    listValues.push(assignment);
1112                    listQuoted.push(true);
1113                    assignment = new SlashCommandUnnamedArgumentAssignment();
1114                    assignment.start = this.index;
1115                }
1116            }
1117            if (this.testClosure()) {
1118                isList = true;
1119                if (value.length > 0) {
1120                    this.indexMacros(this.index - value.length, value);
1121                    assignment.value = value;
1122                    listValues.push(assignment);
1123                    listQuoted.push(false);
1124                    assignment = new SlashCommandUnnamedArgumentAssignment();
1125                    assignment.start = this.index;
1126                    if (!split && this.testQuotedValue()) {
1127                        // if where currently not splitting and the next bit is a quoted value, take the whole value
1128                        assignment.value = this.parseQuotedValue();
1129                        assignment.end = this.index;
1130                        listValues.push(assignment);
1131                        listQuoted.push(true);
1132                        assignment = new SlashCommandUnnamedArgumentAssignment();
1133                        assignment.start = this.index;
1134                    } else {
1135                        value = '';
1136                    }
1137                }
1138                assignment.start = this.index;
1139                assignment.value = this.parseClosure();
1140                assignment.end = this.index;
1141                listValues.push(assignment);
1142                assignment = new SlashCommandUnnamedArgumentAssignment();
1143                assignment.start = this.index;
1144                if (split) this.discardWhitespace();
1145            } else if (split) {
1146                if (this.testQuotedValue()) {
1147                    assignment.start = this.index;
1148                    assignment.value = this.parseQuotedValue();
1149                    assignment.end = this.index;
1150                    listValues.push(assignment);
1151                    listQuoted.push(true);
1152                    assignment = new SlashCommandUnnamedArgumentAssignment();
1153                } else if (this.testListValue()) {
1154                    assignment.start = this.index;
1155                    assignment.value = this.parseListValue();
1156                    assignment.end = this.index;
1157                    listValues.push(assignment);
1158                    listQuoted.push(false);
1159                    assignment = new SlashCommandUnnamedArgumentAssignment();
1160                } else if (this.testValue()) {
1161                    assignment.start = this.index;
1162                    assignment.value = this.parseValue();
1163                    assignment.end = this.index;
1164                    listValues.push(assignment);
1165                    listQuoted.push(false);
1166                    assignment = new SlashCommandUnnamedArgumentAssignment();
1167                } else {
1168                    throw new SlashCommandParserError(`Unexpected end of unnamed argument at index ${this.userIndex}.`);
1169                }
1170                this.discardWhitespace();
1171            } else {
1172                value += this.take();
1173                assignment.end = this.index;
1174            }
1175        }
1176        if (isList && value.length > 0) {
1177            assignment.value = value;
1178            listValues.push(assignment);
1179            listQuoted.push(false);
1180        }
1181        if (isList) {
1182            const firstVal = listValues[0];
1183            if (typeof firstVal?.value == 'string') {
1184                if (!listQuoted[0]) {
1185                    // only trim the first part if it wasn't quoted
1186                    firstVal.value = firstVal.value.trimStart();
1187                }
1188                if (firstVal.value.length == 0) {
1189                    listValues.shift();
1190                    listQuoted.shift();
1191                }
1192            }
1193            const lastVal = listValues.slice(-1)[0];
1194            if (typeof lastVal?.value == 'string') {
1195                if (!listQuoted.slice(-1)[0]) {
1196                    // only trim the last part if it wasn't quoted
1197                    lastVal.value = lastVal.value.trimEnd();
1198                }
1199                if (lastVal.value.length == 0) {
1200                    listValues.pop();
1201                    listQuoted.pop();
1202                }
1203            }
1204            if (wasSplit && splitCount && splitCount + 1 < listValues.length) {
1205                // if split with a split count and there are more values than expected
1206                // -> should be result of quoting + additional (non-whitespace) text
1207                // -> join the parts into one and restore quotes
1208                const joined = new SlashCommandUnnamedArgumentAssignment();
1209                joined.start = listValues[splitCount].start;
1210                joined.end = listValues.slice(-1)[0].end;
1211                joined.value = '';
1212                for (let i = splitCount; i < listValues.length; i++) {
1213                    if (listQuoted[i]) joined.value += `"${listValues[i].value}"`;
1214                    else joined.value += listValues[i].value;
1215                }
1216                listValues = [
1217                    ...listValues.slice(0, splitCount),
1218                    joined,
1219                ];
1220            }
1221            return listValues;
1222        }
1223        this.indexMacros(this.index - value.length, value);
1224        value = value.trim();
1225        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
1226            value = this.replaceGetvar(value);
1227        }
1228        assignment.value = value;
1229        return [assignment];
1230    }
1231
1232    testQuotedValue() {
1233        return this.testSymbol('"');
1234    }
1235    testQuotedValueEnd() {
1236        if (this.endOfText) {
1237            if (this.verifyCommandNames) throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index);
1238            else return true;
1239        }
1240        if (!this.verifyCommandNames && this.testClosureEnd()) return true;
1241        if (this.verifyCommandNames && !this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd()) {
1242            throw new SlashCommandParserError(`Unexpected end of quoted value at position ${this.index}`, this.text, this.index);
1243        }
1244        return this.testSymbol('"') || (!this.flags[PARSER_FLAG.STRICT_ESCAPING] && this.testCommandEnd());
1245    }
1246    parseQuotedValue() {
1247        this.take(); // discard opening quote
1248        let value = '';
1249        while (!this.testQuotedValueEnd()) value += this.take(); // take all chars until closing quote
1250        this.take(); // discard closing quote
1251        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
1252            value = this.replaceGetvar(value);
1253        }
1254        this.indexMacros(this.index - value.length, value);
1255        return value;
1256    }
1257
1258    testListValue() {
1259        return this.testSymbol('[');
1260    }
1261    testListValueEnd() {
1262        if (this.endOfText) throw new SlashCommandParserError(`Unexpected end of list value at position ${this.index}`, this.text, this.index);
1263        return this.testSymbol(']');
1264    }
1265    parseListValue() {
1266        let value = this.take(); // take the already tested opening bracket
1267        while (!this.testListValueEnd()) value += this.take(); // take all chars until closing bracket
1268        value += this.take(); // take closing bracket
1269        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
1270            value = this.replaceGetvar(value);
1271        }
1272        this.indexMacros(this.index - value.length, value);
1273        return value;
1274    }
1275
1276    testValue() {
1277        return !this.testSymbol(/\s/);
1278    }
1279    testValueEnd() {
1280        if (this.testSymbol(/\s/)) return true;
1281        return this.testCommandEnd();
1282    }
1283    parseValue() {
1284        let value = this.jumpedEscapeSequence ? this.take() : ''; // take the first, already tested, char if it is an escaped one
1285        while (!this.testValueEnd()) value += this.take(); // take all chars until value end
1286        if (this.flags[PARSER_FLAG.REPLACE_GETVAR]) {
1287            value = this.replaceGetvar(value);
1288        }
1289        this.indexMacros(this.index - value.length, value);
1290        return value;
1291    }
1292
1293    indexMacros(offset, text) {
1294        // Index all macros including nested ones
1295        // We need to track brace depth to properly handle nested macros like {{reverse::Hey {{user}}}}
1296        let i = 0;
1297        while (i < text.length - 1) {
1298            // Look for macro start {{
1299            if (text[i] === '{' && text[i + 1] === '{') {
1300                const macroStart = i;
1301                i += 2; // Skip {{
1302
1303                // Find where this macro ends, tracking nested braces
1304                let depth = 1;
1305                let macroEnd = text.length; // Default to end if unclosed
1306
1307                while (i < text.length - 1 && depth > 0) {
1308                    if (text[i] === '{' && text[i + 1] === '{') {
1309                        // Nested macro start - recursively index it
1310                        // The nested macro will be indexed in subsequent iterations
1311                        depth++;
1312                        i += 2;
1313                    } else if (text[i] === '}' && text[i + 1] === '}') {
1314                        depth--;
1315                        if (depth === 0) {
1316                            macroEnd = i + 2; // Include the closing }}
1317                        }
1318                        i += 2;
1319                    } else {
1320                        i++;
1321                    }
1322                }
1323
1324                // Extract macro content (between {{ and }} or end)
1325                const contentEnd = macroEnd === text.length ? macroEnd : macroEnd - 2;
1326                const macroContent = text.slice(macroStart + 2, contentEnd);
1327
1328                // Use parseMacroContext to extract the identifier
1329                const context = parseMacroContext(macroContent, macroContent.length);
1330
1331                this.macroIndex.push({
1332                    start: offset + macroStart,
1333                    end: offset + macroEnd,
1334                    name: context.identifier,
1335                });
1336
1337                // Continue from where we left off (don't skip ahead)
1338                // This ensures nested macros get their own index entries
1339                i = macroStart + 2; // Move past the opening {{ to look for nested macros
1340                // Skip to find nested {{ inside this macro's content
1341                while (i < contentEnd) {
1342                    if (text[i] === '{' && i + 1 < text.length && text[i + 1] === '{') {
1343                        break; // Found nested macro, outer loop will handle it
1344                    }
1345                    i++;
1346                }
1347                if (i >= contentEnd) {
1348                    // No nested macro found, skip to end of this macro
1349                    i = macroEnd;
1350                }
1351            } else {
1352                i++;
1353            }
1354        }
1355    }
1356}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.