PageSourceSearch

https://zcjg-hub.hf.space/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js

js zcjg-hub.hf.space collected 2026-10-03 17:35:52 UTC 76,879 bytes, 1,872 lines download raw bytes

1/**
2 * Enhanced macro autocomplete option for the new MacroRegistry-based system.
3 * Reuses rendering logic from MacroBrowser for consistency and DRY.
4 */
5
6import { AutoCompleteOption } from './AutoCompleteOption.js';
7import {
8    formatMacroSignature,
9    createSourceIndicator,
10    createAliasIndicator,
11    renderMacroDetails,
12} from '../macros/engine/MacroBrowser.js';
13import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
14import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
15import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
16import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
17
18/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
19
20/**
21 * Macro context passed from the parser to provide cursor position info.
22 * @typedef {Object} MacroAutoCompleteContext
23 * @property {string} fullText - The full macro text being typed (without {{ }}).
24 * @property {number} cursorOffset - Cursor position within the macro text.
25 * @property {string} paddingBefore - Padding before the macro identifier/flags.
26 * @property {string} identifier - The macro identifier (name).
27 * @property {number} identifierStart - Start position of the identifier within the macro text.
28 * @property {string[]} flags - Array of flag symbols typed (e.g., ['!', '?']).
29 * @property {string|null} currentFlag - The flag symbol cursor is currently on (last typed flag), or null.
30 * @property {boolean} isInFlagsArea - Whether cursor is in the flags area (before identifier starts).
31 * @property {string[]} args - Array of arguments typed so far.
32 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
33 * @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').
34 * @property {boolean} isTypingClosingBrace - Whether cursor is typing the first closing brace on a standalone macro.
35 * @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).
36 * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
37 * @property {number} separatorCount - Number of '::' separators found.
38 * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
39 * @property {boolean} [isScopedContentOptional] - Whether the scoped content is optional (for display purposes).
40 * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
41 * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
42 * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
43 * @property {string} variableName - The variable name being typed (after the prefix).
44 * @property {number} variableNameEnd - The end of the variable name (for partial matches).
45 * @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
46 * @property {number} variableOperatorEnd - The end of the variable operator (for partial matches).
47 * @property {string} variableValue - The value after the operator (for = and +=).
48 * @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
49 * @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
50 * @property {boolean} isTypingValue - Whether cursor is after an operator that requires a value.
51 * @property {boolean} [hasInvalidTrailingChars] - Whether there are invalid characters after the variable name.
52 * @property {string} [invalidTrailingChars] - The invalid trailing characters (for error display).
53 * @property {string} [partialOperator] - Partial operator prefix being typed ('+' or '-').
54 * @property {boolean} [isOperatorComplete] - Whether a complete operator (++ or --) was typed that doesn't need a value.
55 */
56
57/**
58 * @typedef {Object} EnhancedMacroAutoCompleteOptions
59 * @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions).
60 * @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style).
61 * @property {boolean} [closeWithBraces=false] - If true, the completion will add }} to close the macro.
62 * @property {string[]} [flags=[]] - The currently already written flags for this autocomplete.
63 * @property {string} [currentFlag] - The current flag that is present, if any.
64 * @property {string} [fullText] - The currently written full text.
65 */
66
67export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
68    /** @type {MacroDefinition} */
69    #macro;
70
71    /** @type {MacroAutoCompleteContext|null} */
72    #context = null;
73
74    /** @type {EnhancedMacroAutoCompleteOptions|null} */
75    #options = null;
76
77    /** @type {boolean} */
78    #noBraces = false;
79
80    /** @type {string} */
81    #paddingAfter = '';
82
83    /**
84     * @param {MacroDefinition} macro - The macro definition from MacroRegistry.
85     * @param {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} [contextOrOptions] - Context for argument hints, or options object.
86     */
87    constructor(macro, contextOrOptions = null) {
88        // Use the macro name as the autocomplete key
89        super(macro.name, enumIcons.macro);
90        this.#macro = macro;
91
92        // Detect if second argument is context or options
93        // Context has 'identifier' property, options may have 'noBraces'
94        if (contextOrOptions && typeof contextOrOptions === 'object') {
95            if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) {
96                // It's an options object
97                this.#options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions);
98                this.#noBraces = this.#options.noBraces ?? false;
99                this.#paddingAfter = this.#options.paddingAfter ?? '';
100
101                // If noBraces mode with closeWithBraces, complete with name + padding + }}
102                if (this.#options.closeWithBraces) {
103                    this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
104                    this.makeSelectable = true;
105                }
106            } else {
107                // It's a context object
108                this.#context = /** @type {MacroAutoCompleteContext} */ (contextOrOptions);
109            }
110        }
111
112        // nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
113        // When noBraces is true, nameOffset = 0 since we don't show braces
114        this.nameOffset = this.#noBraces ? 0 : 2;
115
116        // For macros that take no arguments, auto-complete with closing }} (unless already set by options)
117        if (!this.valueProvider) {
118            const takesNoArgs = macro.minArgs === 0 && macro.maxArgs === 0 && macro.list === null;
119            if (takesNoArgs) {
120                this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
121                this.makeSelectable = true; // Required when using valueProvider
122            }
123        }
124
125        // {{//}} needs special handling. If we autocomplete right after **one** slash is already typed, we need to replace that, as it's treated as a flag otherwise.
126        const fullText = this.#options?.fullText ?? this.#context?.fullText ?? '';
127        if (macro.name === '//' && fullText.endsWith('/')) {
128            this.replacementStartOffset = (this.replacementStartOffset ?? 0) - 1; // Cut the leading slash
129        }
130    }
131
132    /** @returns {MacroDefinition} */
133    get macro() {
134        return this.#macro;
135    }
136
137    /**
138     * Renders the list item for the autocomplete dropdown.
139     * Tight display: [icon] [signature] [description] [alias icon?] [source icon]
140     * @returns {HTMLElement}
141     */
142    renderItem() {
143        const li = document.createElement('li');
144        li.classList.add('item', 'macro-ac-item');
145        li.setAttribute('data-name', this.name);
146        li.setAttribute('data-option-type', 'macro');
147
148        // Type icon
149        const type = document.createElement('span');
150        type.classList.add('type', 'monospace');
151        type.textContent = '{}';
152        li.append(type);
153
154        // Specs container (for fuzzy highlight compatibility)
155        const specs = document.createElement('span');
156        specs.classList.add('specs');
157
158        // Name with character spans for fuzzy highlighting
159        const nameEl = document.createElement('span');
160        nameEl.classList.add('name', 'monospace');
161
162        // Build signature with individual character spans
163        // When noBraces is true, show just the macro name without {{ }}
164        const sigText = this.#noBraces ? this.#macro.name : formatMacroSignature(this.#macro);
165        for (const char of sigText) {
166            const span = document.createElement('span');
167            span.textContent = char;
168            nameEl.append(span);
169        }
170        specs.append(nameEl);
171        li.append(specs);
172
173        // Stopgap (spacer for flex layout)
174        const stopgap = document.createElement('span');
175        stopgap.classList.add('stopgap');
176        li.append(stopgap);
177
178        // Help text (description)
179        const help = document.createElement('span');
180        help.classList.add('help');
181        const content = document.createElement('span');
182        content.classList.add('helpContent');
183        content.textContent = this.#macro.description || '';
184        help.append(content);
185        li.append(help);
186
187        // Alias indicator icon (if this is an alias)
188        const aliasIcon = createAliasIndicator(this.#macro);
189        if (aliasIcon) {
190            aliasIcon.classList.add('macro-ac-indicator');
191            li.append(aliasIcon);
192        }
193
194        // Source indicator icon
195        const sourceIcon = createSourceIndicator(this.#macro);
196        sourceIcon.classList.add('macro-ac-indicator');
197        li.append(sourceIcon);
198
199        return li;
200    }
201
202    /**
203     * Renders the details panel content.
204     * Reuses renderMacroDetails from MacroBrowser with autocomplete-specific options.
205     * @returns {DocumentFragment}
206     */
207    renderDetails() {
208        const frag = document.createDocumentFragment();
209
210        // Check for arity warnings
211        const warning = this.#getArityWarning();
212        if (warning) {
213            const warningEl = this.#renderWarning(warning);
214            frag.append(warningEl);
215        }
216
217        // Show scoped content info banner if we're in scoped content
218        if (this.#context?.isInScopedContent) {
219            const scopedInfo = this.#renderScopedContentInfo();
220            if (scopedInfo) frag.append(scopedInfo);
221        }
222
223        // Determine current argument index for highlighting
224        const currentArgIndex = this.#context?.currentArgIndex ?? -1;
225
226        // For most warnings, we can still highlight which argument we are currently at.
227        // This even goes for "too many arguments" when navigating the cursor back to
228        // a valid argument.
229        // Extend this in the future, if *some* warnings don't make sense to still highlight args.
230        const hightlightArgsHint = currentArgIndex >= 0;
231
232        // Render argument hint banner if we're typing an argument
233        if (hightlightArgsHint && currentArgIndex >= 0) {
234            const hint = this.#renderArgumentHint();
235            if (hint) frag.append(hint);
236        }
237
238        // Reuse MacroBrowser's renderMacroDetails with options
239        const details = renderMacroDetails(this.#macro, { currentArgIndex: hightlightArgsHint ? currentArgIndex : -1 });
240
241        // Add class for autocomplete-specific styling overrides
242        details.classList.add('macro-ac-details');
243        frag.append(details);
244
245        return frag;
246    }
247
248    /**
249     * Checks for arity-related warnings based on the current context.
250     * @returns {string|null} Warning message, or null if no warning.
251     */
252    #getArityWarning() {
253        if (!this.#context) return null;
254
255        const argCount = this.#context.args.length;
256        const maxArgs = this.#macro.maxArgs;
257        //const minArgs = this.#macro.minArgs;
258        const hasList = this.#macro.list !== null;
259
260        // Check for too many arguments (only if no list args)
261        if (!hasList && argCount > maxArgs) {
262            return `Too many arguments: this macro accepts ${maxArgs === 0 ? 'no arguments' : `up to ${maxArgs} argument${maxArgs === 1 ? '' : 's'}`}, but ${argCount} provided.`;
263        }
264
265        // Check for space-separated arg on macro that doesn't support it
266        // Space-separated syntax provides 1 arg; with scoped content you can provide a 2nd arg
267        // So it's valid for macros with maxArgs <= 2 (or with list args)
268        if (this.#context.hasSpaceArgContent) {
269            if (maxArgs === 0 && !hasList) {
270                return 'This macro does not accept any arguments. Remove the space or use a different macro.';
271            }
272            if (!hasList && maxArgs > 2) {
273                return `Space-separated syntax only works for macros with up to 2 arguments. Use :: separators instead: {{${this.#macro.name}::arg1::arg2}}`;
274            }
275        }
276
277        // Check if trying to add args to a no-arg macro via ::
278        // List-arg macros can accept args even if maxArgs === 0
279        if (this.#context.separatorCount > 0 && maxArgs === 0 && !hasList) {
280            return 'This macro does not accept any arguments.';
281        }
282
283        // Check list bounds (min/max) if the macro has a list with constraints
284        if (hasList && typeof this.#macro.list === 'object') {
285            const listItemCount = Math.max(0, argCount - maxArgs);
286            const listMin = this.#macro.list.min ?? 0;
287            const listMax = this.#macro.list.max ?? null;
288
289            if (listItemCount < listMin) {
290                const needed = listMin - listItemCount;
291                return `Not enough list items yet: this macro requires at least ${listMin} item${listMin === 1 ? '' : 's'}, but only ${listItemCount} provided. Add ${needed} more.`;
292            }
293
294            if (listMax !== null && listItemCount > listMax) {
295                return `Too many list items: this macro accepts at most ${listMax} item${listMax === 1 ? '' : 's'}, but ${listItemCount} provided.`;
296            }
297        }
298
299        return null;
300    }
301
302    /**
303     * Renders a warning banner.
304     * @param {string} message - The warning message.
305     * @returns {HTMLElement}
306     */
307    #renderWarning(message) {
308        const warning = document.createElement('div');
309        warning.classList.add('macro-ac-warning');
310
311        const icon = document.createElement('i');
312        icon.classList.add('fa-solid', 'fa-triangle-exclamation');
313        warning.append(icon);
314
315        const text = document.createElement('span');
316        text.textContent = message;
317        warning.append(text);
318
319        return warning;
320    }
321
322    /**
323     * Renders the scoped content info banner.
324     * Shows when cursor is inside scoped content of an unclosed macro.
325     * @returns {HTMLElement|null}
326     */
327    #renderScopedContentInfo() {
328        if (!this.#context?.isInScopedContent) return null;
329
330        const info = document.createElement('div');
331        info.classList.add('macro-ac-scoped-info');
332
333        // If the scoped content is optional, show a prominent OPTIONAL badge
334        if (this.#context.isScopedContentOptional) {
335            const optionalBadge = document.createElement('span');
336            optionalBadge.classList.add('macro-ac-optional-badge');
337            optionalBadge.textContent = 'OPTIONAL';
338            info.append(optionalBadge);
339        }
340
341        const icon = document.createElement('i');
342        icon.classList.add('fa-solid', 'fa-layer-group');
343        info.append(icon);
344
345        const text = document.createElement('span');
346        const closingHint = this.#context.isScopedContentOptional
347            ? `Can optionally close with <code>{{/${this.#context.scopedMacroName}}}</code>`
348            : `Close with <code>{{/${this.#context.scopedMacroName}}}</code>`;
349        text.innerHTML = `Typing <strong>scoped content</strong> for <code>{{${this.#context.scopedMacroName}}}</code>. ${closingHint}`;
350        info.append(text);
351
352        return info;
353    }
354
355    /**
356     * Renders the current argument hint banner.
357     * @returns {HTMLElement|null}
358     */
359    #renderArgumentHint() {
360        if (!this.#context || this.#context.currentArgIndex < 0) return null;
361
362        const argIndex = this.#context.currentArgIndex;
363        const isListArg = argIndex >= this.#macro.maxArgs;
364
365        // If we're beyond unnamed args and there's no list, no hint
366        if (isListArg && !this.#macro.list) return null;
367
368        const hint = document.createElement('div');
369        hint.classList.add('macro-ac-arg-hint');
370
371        const icon = document.createElement('i');
372        icon.classList.add('fa-solid', 'fa-arrow-right');
373        hint.append(icon);
374
375        if (isListArg) {
376            // List argument hint
377            const listIndex = argIndex - this.#macro.maxArgs + 1;
378            const totalListItems = this.#context.args.length - this.#macro.maxArgs;
379
380            const text = document.createElement('span');
381            text.innerHTML = `<strong>List item ${listIndex}</strong>${(listIndex < totalListItems ? ` (of ${totalListItems})` : '')}`;
382
383            const listInfo = document.createElement('span');
384            listInfo.classList.add('macro-ac-arg-hint-small');
385            const minMax = [];
386            if (this.#macro.list.min > 0) minMax.push(`min: ${this.#macro.list.min}`);
387            if (this.#macro.list.max !== null) minMax.push(`max: ${this.#macro.list.max}`);
388            if (minMax.length > 0) {
389                listInfo.textContent = ` (list, ${minMax.join(', ')})`;
390            } else {
391                listInfo.textContent = ' (variable-length list)';
392            }
393            text.appendChild(listInfo);
394
395            hint.append(text);
396        } else {
397            // Unnamed argument hint (required or optional)
398            const argDef = this.#macro.unnamedArgDefs[argIndex];
399            let optionalLabel = '';
400            if (argDef?.optional) {
401                optionalLabel = argDef.defaultValue !== undefined
402                    ? ` <em>(optional, default: ${argDef.defaultValue === '' ? '<empty string>' : argDef.defaultValue})</em>`
403                    : ' <em>(optional)</em>';
404            }
405            const text = document.createElement('span');
406            text.innerHTML = `<strong>${argDef?.name || `Argument ${argIndex + 1}`}</strong>${optionalLabel}`;
407            if (argDef?.type) {
408                const typeSpan = document.createElement('code');
409                typeSpan.classList.add('macro-ac-hint-type');
410                if (Array.isArray(argDef.type)) {
411                    typeSpan.textContent = argDef.type.join(' | ');
412                    typeSpan.title = `Accepts: ${argDef.type.join(', ')}`;
413                } else {
414                    typeSpan.textContent = argDef.type;
415                }
416                text.append(' ', typeSpan);
417            }
418            hint.append(text);
419
420            if (argDef?.description) {
421                const descSpan = document.createElement('span');
422                descSpan.classList.add('macro-ac-hint-desc');
423                descSpan.textContent = ` — ${argDef.description}`;
424                hint.append(descSpan);
425            }
426
427            if (argDef?.sampleValue) {
428                const sampleSpan = document.createElement('span');
429                sampleSpan.classList.add('macro-ac-hint-sample');
430                sampleSpan.textContent = ` (e.g. ${argDef.sampleValue})`;
431                hint.append(sampleSpan);
432            }
433        }
434
435        return hint;
436    }
437}
438
439/**
440 * Autocomplete option for macro execution flags.
441 * Shows flag symbol, name, and description.
442 * Uses default AutoCompleteOption rendering for consistent styling.
443 */
444export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
445    /** @type {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
446    #flagDef;
447
448    /**
449     * @param {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} flagDef - The flag definition.
450     */
451    constructor(flagDef) {
452        // Use the flag symbol as the name, with a flag icon
453        // Display name includes both symbol and name for clarity
454        super(flagDef.type, '🚩');
455        this.#flagDef = flagDef;
456    }
457
458    /** @returns {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
459    get flagDefinition() {
460        return this.#flagDef;
461    }
462
463    /**
464     * Renders the autocomplete list item for this flag.
465     * Uses the same structure as other autocomplete options for consistent styling.
466     * @returns {HTMLElement}
467     */
468    renderItem() {
469        // Use base class makeItem for consistent styling
470        const li = this.makeItem(
471            `${this.#flagDef.type} ${this.#flagDef.name}`, // Display: "? Optional"
472            '🚩',
473            true, // noSlash
474            [], // namedArguments
475            [], // unnamedArguments
476            'void', // returnType
477            this.#flagDef.description + (this.#flagDef.implemented ? '' : ' (planned)'), // helpString
478        );
479        li.setAttribute('data-name', this.name);
480        li.setAttribute('data-option-type', 'flag');
481        return li;
482    }
483
484    /**
485     * Renders the details panel for this flag.
486     * @returns {DocumentFragment}
487     */
488    renderDetails() {
489        const frag = document.createDocumentFragment();
490
491        const details = document.createElement('div');
492        details.classList.add('macro-flag-details');
493
494        // Header with flag symbol and name
495        const header = document.createElement('h3');
496        header.classList.add('macro-flag-details-header');
497        header.innerHTML = `<code>${this.#flagDef.type}</code> ${this.#flagDef.name} Flag`;
498        details.append(header);
499
500        // Description
501        const desc = document.createElement('p');
502        desc.classList.add('macro-flag-details-desc');
503        desc.textContent = this.#flagDef.description;
504        details.append(desc);
505
506        // Status
507        const status = document.createElement('p');
508        status.classList.add('macro-flag-details-status');
509        status.innerHTML = `<strong>Status:</strong> ${this.#flagDef.implemented ? 'Implemented' : 'Planned for future release'}`;
510        details.append(status);
511
512        // Parser effect note
513        if (this.#flagDef.affectsParser) {
514            const parserNote = document.createElement('p');
515            parserNote.classList.add('macro-flag-details-note');
516            parserNote.innerHTML = '<em>This flag affects how the macro is parsed.</em>';
517            details.append(parserNote);
518        }
519
520        frag.append(details);
521        return frag;
522    }
523}
524
525/**
526 * Enum of variable shorthand prefix types.
527 * @readonly
528 * @enum {string}
529 */
530export const VariableShorthandType = Object.freeze({
531    /** Local variable prefix (`.`) */
532    LOCAL: '.',
533    /** Global variable prefix (`$`) */
534    GLOBAL: '$',
535});
536
537/**
538 * @typedef {Object} VariableShorthandDefinition
539 * @property {VariableShorthandType} type - The prefix symbol.
540 * @property {string} name - Human-readable name.
541 * @property {string} description - Description of what this prefix does.
542 * @property {string[]} operations - List of supported operations.
543 */
544
545/**
546 * Definitions for variable shorthand prefixes.
547 * @type {Map<string, VariableShorthandDefinition>}
548 */
549export const VariableShorthandDefinitions = new Map([
550    [VariableShorthandType.LOCAL, {
551        type: VariableShorthandType.LOCAL,
552        name: 'Local Variable',
553        description: 'Access or modify a local variable (scoped to current chat).',
554        operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
555    }],
556    [VariableShorthandType.GLOBAL, {
557        type: VariableShorthandType.GLOBAL,
558        name: 'Global Variable',
559        description: 'Access or modify a global variable (shared across all chats).',
560        operations: ['get', 'set (=)', 'increment (++)', 'decrement (--)', 'add (+=)', 'subtract (-=)', 'logical or (||)', 'nullish coalescing (??)', 'logical or assign (||=)', 'nullish coalescing assign (??=)', 'equals (==)', 'not equals (!=)', 'greater than (>)', 'greater than or equal (>=)', 'less than (<)', 'less than or equal (<=)'],
561    }],
562]);
563
564/**
565 * Set of valid variable shorthand prefix symbols.
566 * @type {Set<string>}
567 */
568export const ValidVariableShorthandSymbols = new Set(Object.values(VariableShorthandType));
569
570/**
571 * Regex pattern for valid variable shorthand names.
572 * Must start with a letter, can contain word chars, underscores and hyphens, but must not end with an underscore or hyphen.
573 * Examples: myVar, my-var, my_var, myVar123, my-long-var-name
574 * Invalid: my-, my--, -var, 123var
575 * @type {RegExp}
576 */
577const VARIABLE_SHORTHAND_NAME_PATTERN = new RegExp(`^${MACRO_VARIABLE_SHORTHAND_PATTERN.source}`);
578
579/**
580 * Checks if a variable name is valid for use with variable shorthand syntax.
581 * @param {string} name - The variable name to validate.
582 * @returns {boolean} True if the name is valid for shorthand syntax.
583 */
584export function isValidVariableShorthandName(name) {
585    if (!name || typeof name !== 'string') return false;
586    return VARIABLE_SHORTHAND_NAME_PATTERN.test(name);
587}
588
589/**
590 * Autocomplete option for variable shorthand prefixes.
591 * Shows prefix symbol, name, and description.
592 * This provides entry into the variable shorthand syntax ({{.varName}} or {{$varName}}).
593 */
594export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
595    /** @type {VariableShorthandDefinition} */
596    #varDef;
597
598    /**
599     * @param {VariableShorthandDefinition} varDef - The variable shorthand definition.
600     */
601    constructor(varDef) {
602        // Use the prefix symbol as the name, with a variable icon
603        super(varDef.type, '📦');
604        this.#varDef = varDef;
605    }
606
607    /** @returns {VariableShorthandDefinition} */
608    get variableDefinition() {
609        return this.#varDef;
610    }
611
612    /**
613     * Renders the autocomplete list item for this variable shorthand.
614     * @returns {HTMLElement}
615     */
616    renderItem() {
617        const li = this.makeItem(
618            `${this.#varDef.type} ${this.#varDef.name}`,
619            '📦',
620            true, // noSlash
621            [], // namedArguments
622            [], // unnamedArguments
623            'any', // returnType
624            this.#varDef.description,
625        );
626        li.setAttribute('data-name', this.name);
627        li.setAttribute('data-option-type', 'variable-shorthand');
628        return li;
629    }
630
631    /**
632     * Renders the details panel for this variable shorthand.
633     * @returns {DocumentFragment}
634     */
635    renderDetails() {
636        const frag = document.createDocumentFragment();
637
638        const details = document.createElement('div');
639        details.classList.add('macro-variable-details');
640
641        // Header with prefix symbol and name
642        const header = document.createElement('h3');
643        header.classList.add('macro-variable-details-header');
644        header.innerHTML = `<code>${this.#varDef.type}</code> ${this.#varDef.name}`;
645        details.append(header);
646
647        // Description
648        const desc = document.createElement('p');
649        desc.classList.add('macro-variable-details-desc');
650        desc.textContent = this.#varDef.description;
651        details.append(desc);
652
653        // Supported operations
654        const opsHeader = document.createElement('p');
655        opsHeader.innerHTML = '<strong>Supported Operations:</strong>';
656        details.append(opsHeader);
657
658        const opsList = document.createElement('ul');
659        opsList.classList.add('macro-variable-details-ops');
660        for (const op of this.#varDef.operations) {
661            const li = document.createElement('li');
662            li.textContent = op;
663            opsList.append(li);
664        }
665        details.append(opsList);
666
667        // Examples
668        const exampleHeader = document.createElement('p');
669        exampleHeader.innerHTML = '<strong>Examples:</strong>';
670        details.append(exampleHeader);
671
672        const exampleList = document.createElement('ul');
673        exampleList.classList.add('macro-variable-details-examples');
674        const prefix = this.#varDef.type;
675        const examples = [
676            `{{${prefix}myvar}} - Get variable value`,
677            `{{${prefix}myvar = value}} - Set variable (returns nothing)`,
678            `{{${prefix}counter++}} - Increment and get value`,
679            `{{${prefix}counter--}} - Decrement and get value`,
680            `{{${prefix}myvar += text}} - Append/add (returns nothing)`,
681            `{{${prefix}score -= 5}} - Subtract (returns nothing)`,
682            `{{${prefix}myvar || default}} - Get with fallback if falsy`,
683            `{{${prefix}myvar ?? default}} - Get with fallback if undefined`,
684            `{{${prefix}myvar ||= value}} - Set if falsy, get value`,
685            `{{${prefix}myvar ??= value}} - Set if undefined, get value`,
686            `{{${prefix}myvar == test}} - Compare (returns true/false)`,
687            `{{${prefix}myvar != test}} - Compare not equal (returns true/false)`,
688            `{{${prefix}score > 10}} - Greater than (numeric, returns true/false)`,
689            `{{${prefix}score >= 10}} - Greater than or equal (numeric)`,
690            `{{${prefix}score < 10}} - Less than (numeric, returns true/false)`,
691            `{{${prefix}score <= 10}} - Less than or equal (numeric)`,
692        ];
693        for (const ex of examples) {
694            const li = document.createElement('li');
695            li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
696            exampleList.append(li);
697        }
698        details.append(exampleList);
699
700        frag.append(details);
701        return frag;
702    }
703}
704
705/**
706 * Autocomplete option for a specific variable name.
707 * Shows variable name with scope indicator (local/global).
708 */
709export class VariableNameAutoCompleteOption extends AutoCompleteOption {
710    /** @type {string} */
711    #varName;
712
713    /** @type {'local'|'global'} */
714    #scope;
715
716    /** @type {boolean} */
717    #isNewVariable;
718
719    /** @type {boolean} */
720    #isInvalidName;
721
722    /**
723     * @param {string} varName - The variable name.
724     * @param {'local'|'global'} scope - Whether this is a local or global variable.
725     * @param {boolean} [isNewVariable=false] - Whether this is a "create new variable" option.
726     * @param {boolean} [isInvalidName=false] - Whether this name is invalid for shorthand syntax.
727     */
728    constructor(varName, scope, isNewVariable = false, isInvalidName = false) {
729        const icon = scope === 'local' ? 'L' : 'G';
730        super(varName, icon);
731        this.#varName = varName;
732        this.#scope = scope;
733        this.#isNewVariable = isNewVariable;
734        this.#isInvalidName = isInvalidName;
735    }
736
737    /** @returns {string} */
738    get variableName() {
739        return this.#varName;
740    }
741
742    /** @returns {'local'|'global'} */
743    get scope() {
744        return this.#scope;
745    }
746
747    /** @returns {boolean} */
748    get isNewVariable() {
749        return this.#isNewVariable;
750    }
751
752    /** @returns {boolean} */
753    get isInvalidName() {
754        return this.#isInvalidName;
755    }
756
757    /**
758     * Renders the autocomplete list item for this variable.
759     * @returns {HTMLElement}
760     */
761    renderItem() {
762        const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
763        let description;
764        if (this.#isInvalidName) {
765            description = '⚠️ Invalid variable name for shorthand';
766        } else if (this.#isNewVariable) {
767            description = `Define new ${scopeLabel.toLowerCase()} variable`;
768        } else {
769            description = `${scopeLabel} variable`;
770        }
771
772        const li = this.makeItem(
773            this.#varName,
774            this.typeIcon,
775            true, // noSlash
776            [], // namedArguments
777            [], // unnamedArguments
778            'any', // returnType
779            description,
780        );
781        li.setAttribute('data-name', this.name);
782        li.setAttribute('data-option-type', 'variable-name');
783        if (this.#isNewVariable) {
784            li.classList.add('variable-new');
785        }
786        if (this.#isInvalidName) {
787            li.classList.add('variable-invalid');
788        }
789        return li;
790    }
791
792    /**
793     * Renders the details panel for this variable.
794     * @returns {DocumentFragment}
795     */
796    renderDetails() {
797        const frag = document.createDocumentFragment();
798
799        const details = document.createElement('div');
800        details.classList.add('macro-variable-name-details');
801
802        const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
803        const prefix = this.#scope === 'local' ? '.' : '$';
804
805        // Show big warning for invalid names
806        if (this.#isInvalidName) {
807            const warningBox = document.createElement('div');
808            warningBox.classList.add('variable-invalid-warning');
809            warningBox.style.cssText = 'background: #ff000033; border: 2px solid #ff0000; border-radius: 4px; padding: 10px; margin-bottom: 10px;';
810
811            const warningHeader = document.createElement('h3');
812            warningHeader.style.cssText = 'color: #ff6b6b; margin: 0 0 8px 0;';
813            warningHeader.textContent = '⚠️ Invalid Variable Name';
814            warningBox.append(warningHeader);
815
816            const warningText = document.createElement('p');
817            warningText.style.cssText = 'margin: 0 0 8px 0;';
818            warningText.innerHTML = `The name <code>${this.#varName}</code> cannot be used with variable shorthand syntax.`;
819            warningBox.append(warningText);
820
821            const rulesText = document.createElement('p');
822            rulesText.style.cssText = 'margin: 0; font-size: 0.9em;';
823            rulesText.innerHTML = '<strong>Valid names must:</strong><br>• Start with a letter (a-z, A-Z)<br>• Contain only letters, numbers, underscores, or hyphens<br>• Not end with an underscore or hyphen';
824            warningBox.append(rulesText);
825
826            details.append(warningBox);
827            frag.append(details);
828            return frag;
829        }
830
831        // Header
832        const header = document.createElement('h3');
833        header.innerHTML = this.#isNewVariable
834            ? `<code>${prefix}${this.#varName}</code> (New ${scopeLabel} Variable)`
835            : `<code>${prefix}${this.#varName}</code> ${scopeLabel} Variable`;
836        details.append(header);
837
838        // Description
839        const desc = document.createElement('p');
840        const variableSuggestion = this.#scope === 'local'
841            ? 'Local variables are scoped to the current chat.'
842            : 'Global variables are shared across all chats.';
843        if (this.#isNewVariable) {
844            desc.textContent = `Creates a new ${scopeLabel.toLowerCase()} variable named "${this.#varName}". ${variableSuggestion}`;
845        } else {
846            desc.textContent = `Access or modify the ${scopeLabel.toLowerCase()} variable "${this.#varName}". ${variableSuggestion}`;
847        }
848        details.append(desc);
849
850        // Usage examples
851        const usageHeader = document.createElement('p');
852        usageHeader.innerHTML = '<strong>Usage:</strong>';
853        details.append(usageHeader);
854
855        const usageList = document.createElement('ul');
856        const examples = [
857            `{{${prefix}${this.#varName}}} - Get value`,
858            `{{${prefix}${this.#varName} = value}} - Set value`,
859            `{{${prefix}${this.#varName}++}} - Increment`,
860            `{{${prefix}${this.#varName}--}} - Decrement`,
861            `{{${prefix}${this.#varName} += text}} - Append/add`,
862            `{{${prefix}${this.#varName} -= 5}} - Subtract`,
863            `{{${prefix}${this.#varName} || default}} - Get with fallback if falsy`,
864            `{{${prefix}${this.#varName} ?? default}} - Get with fallback if undefined`,
865            `{{${prefix}${this.#varName} ||= value}} - Set if falsy, get value`,
866            `{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,
867            `{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,
868            `{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`,
869            `{{${prefix}${this.#varName} > 10}} - Greater than (numeric)`,
870            `{{${prefix}${this.#varName} >= 10}} - Greater than or equal (numeric)`,
871            `{{${prefix}${this.#varName} < 10}} - Less than (numeric)`,
872            `{{${prefix}${this.#varName} <= 10}} - Less than or equal (numeric)`,
873        ];
874        for (const ex of examples) {
875            const li = document.createElement('li');
876            li.innerHTML = `<code>${ex.split(' - ')[0]}</code> - ${ex.split(' - ')[1]}`;
877            usageList.append(li);
878        }
879        details.append(usageList);
880
881        frag.append(details);
882        return frag;
883    }
884}
885
886/**
887 * Checks if an operator is a short one that could be a prefix of a longer operator.
888 * For example, '>' is a prefix of '>=', '<' is a prefix of '<='.
889 * @param {string} op - The operator to check.
890 * @returns {boolean} True if the operator could be a prefix of a longer operator.
891 */
892function isShortOperatorPrefix(op) {
893    // These operators could have longer variants typed after them
894    const shortPrefixes = ['>', '<', '=', '|', '?', '+', '-', '!'];
895    return shortPrefixes.includes(op);
896}
897
898/**
899 * Variable shorthand operators with metadata.
900 * @type {Map<string, { symbol: string, name: string, description: string, needsValue: boolean }>}
901 */
902export const VariableOperatorDefinitions = new Map([
903    ['=', {
904        symbol: '=',
905        name: 'Set',
906        description: 'Set the variable to a new value. Returns nothing.',
907        needsValue: true,
908    }],
909    ['++', {
910        symbol: '++',
911        name: 'Increment',
912        description: 'Increment the variable by 1 (numeric). Returns the new value.',
913        needsValue: false,
914    }],
915    ['--', {
916        symbol: '--',
917        name: 'Decrement',
918        description: 'Decrement the variable by 1 (numeric). Returns the new value.',
919        needsValue: false,
920    }],
921    ['+=', {
922        symbol: '+=',
923        name: 'Add',
924        description: 'Add to the variable (numeric addition or string concatenation). Returns nothing.',
925        needsValue: true,
926    }],
927    ['-=', {
928        symbol: '-=',
929        name: 'Subtract',
930        description: 'Subtract a numeric value from the variable. Returns nothing.',
931        needsValue: true,
932    }],
933    ['||', {
934        symbol: '||',
935        name: 'Logical Or',
936        description: 'Return the fallback value if the variable is falsy, otherwise return the variable value.',
937        needsValue: true,
938    }],
939    ['??', {
940        symbol: '??',
941        name: 'Nullish Coalescing',
942        description: 'Return the fallback value only if the variable does not exist, otherwise return the variable value (even if falsy).',
943        needsValue: true,
944    }],
945    ['||=', {
946        symbol: '||=',
947        name: 'Logical Or Assign',
948        description: 'If the variable is falsy, set it to the value and return it; otherwise return the current value.',
949        needsValue: true,
950    }],
951    ['??=', {
952        symbol: '??=',
953        name: 'Nullish Coalescing Assign',
954        description: 'If the variable does not exist, set it to the value and return it; otherwise return the current value.',
955        needsValue: true,
956    }],
957    ['==', {
958        symbol: '==',
959        name: 'Equals',
960        description: 'Compare the variable value to another value. Returns "true" or "false".',
961        needsValue: true,
962    }],
963    ['!=', {
964        symbol: '!=',
965        name: 'Not Equals',
966        description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',
967        needsValue: true,
968    }],
969    ['>', {
970        symbol: '>',
971        name: 'Greater Than',
972        description: 'Numeric comparison. Returns "true" if variable is greater than value, "false" otherwise.',
973        needsValue: true,
974    }],
975    ['>=', {
976        symbol: '>=',
977        name: 'Greater Than or Equal',
978        description: 'Numeric comparison. Returns "true" if variable is greater than or equal to value, "false" otherwise.',
979        needsValue: true,
980    }],
981    ['<', {
982        symbol: '<',
983        name: 'Less Than',
984        description: 'Numeric comparison. Returns "true" if variable is less than value, "false" otherwise.',
985        needsValue: true,
986    }],
987    ['<=', {
988        symbol: '<=',
989        name: 'Less Than or Equal',
990        description: 'Numeric comparison. Returns "true" if variable is less than or equal to value, "false" otherwise.',
991        needsValue: true,
992    }],
993]);
994
995/**
996 * Autocomplete option for a variable operator.
997 * Shows operator symbol, name, and description.
998 */
999export class VariableOperatorAutoCompleteOption extends AutoCompleteOption {
1000    /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1001    #operatorDef;
1002
1003    /**
1004     * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
1005     */
1006    constructor(operatorDef) {
1007        super(operatorDef.symbol, '⚡');
1008        this.#operatorDef = operatorDef;
1009    }
1010
1011    /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1012    get operatorDefinition() {
1013        return this.#operatorDef;
1014    }
1015
1016    /**
1017     * Renders the autocomplete list item for this operator.
1018     * @returns {HTMLElement}
1019     */
1020    renderItem() {
1021        const li = this.makeItem(
1022            `${this.#operatorDef.symbol} ${this.#operatorDef.name}`,
1023            '⚡',
1024            true, // noSlash
1025            [], // namedArguments
1026            [], // unnamedArguments
1027            'void', // returnType
1028            this.#operatorDef.description,
1029        );
1030        li.setAttribute('data-name', this.name);
1031        li.setAttribute('data-option-type', 'variable-operator');
1032        return li;
1033    }
1034
1035    /**
1036     * Renders the details panel for this operator.
1037     * @returns {DocumentFragment}
1038     */
1039    renderDetails() {
1040        const frag = document.createDocumentFragment();
1041
1042        const details = document.createElement('div');
1043        details.classList.add('macro-variable-operator-details');
1044
1045        // Header
1046        const header = document.createElement('h3');
1047        header.innerHTML = `<code>${this.#operatorDef.symbol}</code> ${this.#operatorDef.name}`;
1048        details.append(header);
1049
1050        // Description
1051        const desc = document.createElement('p');
1052        desc.textContent = this.#operatorDef.description;
1053        details.append(desc);
1054
1055        // Value note
1056        const valueNote = document.createElement('p');
1057        valueNote.innerHTML = this.#operatorDef.needsValue
1058            ? '<em>This operator requires a value after it.</em>'
1059            : '<em>This operator does not take a value.</em>';
1060        details.append(valueNote);
1061
1062        frag.append(details);
1063        return frag;
1064    }
1065}
1066
1067/**
1068 * Non-selectable autocomplete option that shows context about the value being typed.
1069 * Displays info about what value is expected based on the operator.
1070 */
1071export class VariableValueContextAutoCompleteOption extends AutoCompleteOption {
1072    /** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1073    #operatorDef;
1074
1075    /** @type {string} */
1076    #currentValue;
1077
1078    /**
1079     * @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
1080     * @param {string} [currentValue=''] - The value currently being typed.
1081     */
1082    constructor(operatorDef, currentValue = '') {
1083        super('value', '📝');
1084        this.#operatorDef = operatorDef;
1085        this.#currentValue = currentValue;
1086        this.forceFullNameMatch = true;
1087    }
1088
1089    /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
1090    get operatorDefinition() {
1091        return this.#operatorDef;
1092    }
1093
1094    /**
1095     * Renders the autocomplete list item for this value context.
1096     * @returns {HTMLElement}
1097     */
1098    renderItem() {
1099        const li = this.makeItem(
1100            '<value>',
1101            '📝',
1102            true, // noSlash
1103            [], // namedArguments
1104            [], // unnamedArguments
1105            'any', // returnType
1106            `${this.#operatorDef.name} (${this.#operatorDef.symbol}) expects a value`,
1107        );
1108        li.setAttribute('data-name', this.name);
1109        li.setAttribute('data-option-type', 'variable-value-context');
1110        return li;
1111    }
1112
1113    /**
1114     * Renders the details panel for this value context.
1115     * @returns {DocumentFragment}
1116     */
1117    renderDetails() {
1118        const frag = document.createDocumentFragment();
1119
1120        const details = document.createElement('div');
1121        details.classList.add('macro-variable-value-context-details');
1122
1123        // Header
1124        const header = document.createElement('h3');
1125        header.innerHTML = `Value for <code>${this.#operatorDef.symbol}</code> (${this.#operatorDef.name})`;
1126        details.append(header);
1127
1128        // Description of what value is expected
1129        const desc = document.createElement('p');
1130        desc.textContent = this.#operatorDef.description;
1131        details.append(desc);
1132
1133        // Current value being typed
1134        if (this.#currentValue) {
1135            const currentNote = document.createElement('p');
1136            currentNote.innerHTML = `<em>Currently typing:</em> <code>${this.#currentValue}</code>`;
1137            details.append(currentNote);
1138        }
1139
1140        // Hint
1141        const hint = document.createElement('p');
1142        hint.classList.add('hint');
1143        hint.innerHTML = '<em>Type your value and close with <code>}}</code> to complete the macro.</em>';
1144        details.append(hint);
1145
1146        frag.append(details);
1147        return frag;
1148    }
1149}
1150
1151/**
1152 * Autocomplete option for closing a scoped macro.
1153 * Suggests {{/macroName}} to close an unclosed scoped macro.
1154 */
1155export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
1156    /** @type {string} */
1157    #macroName;
1158
1159    /** @type {string} */
1160    #paddingBefore;
1161
1162    /** @type {string} */
1163    #paddingAfter;
1164
1165    /** @type {boolean} */
1166    #isOptional;
1167
1168    /** @type {number} */
1169    #nestingLevel;
1170
1171    /**
1172     * @param {string} macroName - The name of the macro to close.
1173     * @param {Object} [options] - Optional configuration.
1174     * @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding).
1175     * @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding).
1176     * @param {string} [options.currentPadding=''] - Whitespace the user has already typed after {{.
1177     * @param {boolean} [options.isOptional=false] - Whether this closing tag is for an optional scope.
1178     * @param {number} [options.nestingLevel=0] - Nesting level (0 = innermost).
1179     */
1180    constructor(macroName, options = {}) {
1181        // The closing tag is what we're suggesting - use /macroName as the name for matching
1182        const closingTag = `/${macroName}`;
1183        super(closingTag, '{/');
1184        this.#macroName = macroName;
1185        this.#paddingBefore = options.paddingBefore ?? '';
1186        this.#paddingAfter = options.paddingAfter ?? '';
1187        this.#isOptional = options.isOptional ?? false;
1188        this.#nestingLevel = options.nestingLevel ?? 0;
1189
1190        // Calculate the replacement offset to replace any existing whitespace the user typed
1191        // This allows us to normalize the whitespace to match the opening tag's style
1192        const currentPadding = options.currentPadding ?? '';
1193        // Negative offset to start replacement earlier (eating the user's whitespace)
1194        this.replacementStartOffset = -currentPadding.length;
1195
1196        // Custom valueProvider to return the correct replacement text
1197        // Includes the target paddingBefore from the opening tag, replacing any user-typed whitespace
1198        this.valueProvider = () => {
1199            // Return: paddingBefore + /macroName + paddingAfter + }}
1200            return `${this.#paddingBefore}/${macroName}${this.#paddingAfter}}}`;
1201        };
1202
1203        // Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
1204        this.makeSelectable = true;
1205
1206        // nameOffset = 2 to skip the {{ prefix in the display for fuzzy highlighting
1207        // The name is /macroName but display shows {{/macroName}}
1208        this.nameOffset = 2;
1209
1210        // Highest priority - closing tags should always appear at the very top
1211        this.sortPriority = 1;
1212    }
1213
1214    /** @returns {string} */
1215    get macroName() {
1216        return this.#macroName;
1217    }
1218
1219    /**
1220     * Renders the autocomplete list item for this closing tag.
1221     * Uses the same structure as other macro options for consistent styling.
1222     * @returns {HTMLElement}
1223     */
1224    renderItem() {
1225        const li = document.createElement('li');
1226        li.classList.add('item', 'macro-ac-item');
1227
1228        // Type icon (same column as other macros)
1229        const type = document.createElement('span');
1230        type.classList.add('type', 'monospace');
1231        type.textContent = this.typeIcon;
1232        li.append(type);
1233
1234        // Specs container (for fuzzy highlight compatibility)
1235        const specs = document.createElement('span');
1236        specs.classList.add('specs');
1237
1238        // Name element with character spans
1239        const nameEl = document.createElement('span');
1240        nameEl.classList.add('name', 'monospace');
1241        // Display full closing tag like other macros show full syntax
1242        const displayName = `{{/${this.#macroName}}}`;
1243        for (const char of displayName) {
1244            const span = document.createElement('span');
1245            span.textContent = char;
1246            nameEl.append(span);
1247        }
1248        specs.append(nameEl);
1249        li.append(specs);
1250
1251        // Stopgap (spacer for flex layout)
1252        const stopgap = document.createElement('span');
1253        stopgap.classList.add('stopgap');
1254        li.append(stopgap);
1255
1256        // Help text (description)
1257        const help = document.createElement('span');
1258        help.classList.add('help');
1259        const content = document.createElement('span');
1260        content.classList.add('helpContent');
1261
1262        // Build description based on optional status and nesting
1263        if (this.#isOptional) {
1264            const optionalBadge = document.createElement('span');
1265            optionalBadge.classList.add('macro-ac-optional-badge', 'macro-ac-optional-badge-small');
1266            optionalBadge.textContent = 'OPTIONAL';
1267            content.append(optionalBadge);
1268            content.append(' ');
1269
1270            const nestingInfo = this.#nestingLevel > 0 ? ` (nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep)` : '';
1271            content.append(document.createTextNode(`Optionally close {{${this.#macroName}}}${nestingInfo}`));
1272        } else {
1273            content.textContent = `Close the {{${this.#macroName}}} scoped macro.`;
1274        }
1275
1276        help.append(content);
1277        li.append(help);
1278
1279        return li;
1280    }
1281
1282    /**
1283     * Renders the details panel for this closing tag.
1284     * @returns {DocumentFragment}
1285     */
1286    renderDetails() {
1287        const frag = document.createDocumentFragment();
1288
1289        const details = document.createElement('div');
1290        details.classList.add('macro-closing-tag-details');
1291
1292        // If optional, show badge at the top
1293        if (this.#isOptional) {
1294            const optionalBadge = document.createElement('span');
1295            optionalBadge.classList.add('macro-ac-optional-badge');
1296            optionalBadge.textContent = 'OPTIONAL';
1297            details.append(optionalBadge);
1298        }
1299
1300        // Header
1301        const header = document.createElement('h3');
1302        header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`;
1303        details.append(header);
1304
1305        // Description
1306        const desc = document.createElement('p');
1307        if (this.#isOptional) {
1308            const nestingInfo = this.#nestingLevel > 0 ? ` This scope is nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep.` : '';
1309            desc.textContent = `Optionally inserts the closing tag {{/${this.#macroName}}}. The scoped content for this macro is optional - you can close it or leave it open.${nestingInfo}`;
1310        } else {
1311            desc.textContent = `Inserts the closing tag {{/${this.#macroName}}} to complete the scoped macro. The content between the opening and closing tags will be passed as the last argument.`;
1312        }
1313        details.append(desc);
1314
1315        frag.append(details);
1316        return frag;
1317    }
1318}
1319
1320/**
1321 * Parses the macro text to determine current argument context.
1322 * Handles leading whitespace and flags before the identifier.
1323 *
1324 * @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "!user" or "  description  ".
1325 * @param {number} cursorOffset - Cursor position within macroText.
1326 * @returns {MacroAutoCompleteContext}
1327 */
1328export function parseMacroContext(macroText, cursorOffset) {
1329    let i = 0;
1330
1331    // Skip leading whitespace (but NOT newlines - those stop macro parsing for autocomplete)
1332    while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1333        i++;
1334    }
1335
1336    // Extract flags (special symbols before the identifier)
1337    // Track position after each flag to determine which flag cursor is on
1338    // Special case: `/` followed by identifier chars is a closing tag, not a flag
1339    const flags = [];
1340    const flagEndPositions = []; // Position right after each flag (before any whitespace)
1341    while (i < macroText.length) {
1342        const char = macroText[i];
1343        // Check if this looks like a closing tag: `/` followed by an identifier character
1344        if (char === '/' && i + 1 < macroText.length && /[a-zA-Z/]/.test(macroText[i + 1])) {
1345            // This is a closing tag identifier, not a flag - stop parsing flags
1346            break;
1347        }
1348        if (ValidFlagSymbols.has(char)) {
1349            flags.push(char);
1350            i++;
1351            flagEndPositions.push(i); // Position right after this flag
1352            // Skip whitespace between flags (but NOT newlines - those stop macro parsing for autocomplete)
1353            while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1354                i++;
1355            }
1356        } else {
1357            break;
1358        }
1359    }
1360
1361    // Determine which flag cursor is currently on (if any)
1362    // The "current" flag is the last one typed when cursor is still in the flags area
1363    // This ensures the last typed flag shows at the top of the autocomplete list
1364    let currentFlag = null;
1365    if (flags.length > 0) {
1366        // If cursor is at or after the last flag position but before identifier starts,
1367        // the last flag is the "current" one (just typed)
1368        const lastFlagEnd = flagEndPositions[flagEndPositions.length - 1];
1369        if (cursorOffset >= lastFlagEnd - 1) {
1370            currentFlag = flags[flags.length - 1];
1371        }
1372    }
1373
1374    if (flags.length > 0) {
1375        void onboardingExperimentalMacroEngine('macro flags');
1376    }
1377
1378    // Check for variable shorthand prefix (. or $)
1379    // These trigger variable expression mode instead of regular macro parsing
1380    /** @type {'.'|'$'|null} */
1381    let variablePrefix = null;
1382    let variableName = '';
1383    /** @type {string|null} */
1384    let variableOperator = null;
1385    let variableValue = '';
1386    let isVariableShorthand = false;
1387    let isTypingVariableName = false;
1388    let isTypingOperator = false;
1389    let isTypingValue = false;
1390    let variableNameEnd = i;
1391
1392    const remainingAfterFlags = macroText.slice(i);
1393    if (remainingAfterFlags.startsWith('.') || remainingAfterFlags.startsWith('$')) {
1394        isVariableShorthand = true;
1395        variablePrefix = /** @type {'.'|'$'} */ (remainingAfterFlags[0]);
1396        i++; // Move past the prefix
1397
1398        // Variable names: start with letter, can have hyphens inside, must not end with hyphen
1399        const varNameMatch = macroText.slice(i).match(VARIABLE_SHORTHAND_NAME_PATTERN);
1400        if (varNameMatch) {
1401            variableName = varNameMatch[0];
1402            i += variableName.length;
1403        }
1404        variableNameEnd = i;
1405
1406        // Skip whitespace before operator
1407        while (i < macroText.length && /\s/.test(macroText[i])) {
1408            i++;
1409        }
1410
1411        // Check for operators: ++, --, +=, -=, ||=, ??=, ||, ??, ==, =
1412        // Order matters: longer operators must be checked before shorter ones
1413        // Also track partial operator prefixes for autocomplete
1414        const operatorText = macroText.slice(i);
1415        let hasInvalidTrailingChars = false;
1416        let invalidTrailingChars = '';
1417        let partialOperator = '';
1418        if (operatorText.startsWith('++')) {
1419            variableOperator = '++';
1420            i += 2;
1421        } else if (operatorText.startsWith('--')) {
1422            variableOperator = '--';
1423            i += 2;
1424        } else if (operatorText.startsWith('||=')) {
1425            variableOperator = '||=';
1426            i += 3;
1427        } else if (operatorText.startsWith('??=')) {
1428            variableOperator = '??=';
1429            i += 3;
1430        } else if (operatorText.startsWith('||')) {
1431            variableOperator = '||';
1432            i += 2;
1433        } else if (operatorText.startsWith('??')) {
1434            variableOperator = '??';
1435            i += 2;
1436        } else if (operatorText.startsWith('+=')) {
1437            variableOperator = '+=';
1438            i += 2;
1439        } else if (operatorText.startsWith('-=')) {
1440            variableOperator = '-=';
1441            i += 2;
1442        } else if (operatorText.startsWith('==')) {
1443            variableOperator = '==';
1444            i += 2;
1445        } else if (operatorText.startsWith('!=')) {
1446            variableOperator = '!=';
1447            i += 2;
1448        } else if (operatorText.startsWith('>=')) {
1449            variableOperator = '>=';
1450            i += 2;
1451        } else if (operatorText.startsWith('>')) {
1452            variableOperator = '>';
1453            i += 1;
1454        } else if (operatorText.startsWith('<=')) {
1455            variableOperator = '<=';
1456            i += 2;
1457        } else if (operatorText.startsWith('<')) {
1458            variableOperator = '<';
1459            i += 1;
1460        } else if (operatorText.startsWith('=')) {
1461            variableOperator = '=';
1462            i += 1;
1463        } else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!') || operatorText.startsWith('>') || operatorText.startsWith('<')) {
1464            // Partial operator prefix - user is typing an operator
1465            partialOperator = operatorText[0];
1466        } else if (operatorText.length > 0 && !/^\s/.test(operatorText) && !operatorText.startsWith('}')) {
1467            // There's non-whitespace after the variable name that isn't a valid operator
1468            // This is an invalid trailing character (e.g., $my$ or .var@test)
1469            // Exception: } is the closing brace, not an invalid char
1470            hasInvalidTrailingChars = true;
1471            invalidTrailingChars = operatorText.trim();
1472        }
1473
1474        // Track where the operator ends (for cursor position checks)
1475        const variableOperatorEnd = i;
1476
1477        // Check if operator requires a value
1478        const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;
1479        const operatorNeedsValue = operatorDef?.needsValue ?? false;
1480
1481        // If operator requires a value, parse the value
1482        // Do this BEFORE isTypingClosingBrace detection so we can check for } in value area
1483        // let valueStartPos = i;
1484        if (operatorNeedsValue) {
1485            // Skip whitespace after operator
1486            while (i < macroText.length && /\s/.test(macroText[i])) {
1487                i++;
1488            }
1489            // valueStartPos = i;
1490            variableValue = macroText.slice(i).trimEnd();
1491        }
1492
1493        // Detect if typing first closing brace on a variable shorthand
1494        // This happens when operatorText is just "}" or when cursor is beyond content (after }})
1495        let isTypingClosingBrace = false;
1496        if (operatorText.startsWith('}') && !variableOperator) {
1497            // Typing first } on a standalone variable shorthand like {{.Lila}
1498            isTypingClosingBrace = true;
1499        } else if (cursorOffset > macroText.length && !variableOperator) {
1500            // Cursor is after }} on a standalone variable shorthand like {{.Lila}}|
1501            isTypingClosingBrace = true;
1502        } else if (cursorOffset > macroText.length && variableOperator) {
1503            // Cursor is after }} on any operator shorthand like {{.Lila++}}| or {{.Lila+=4}}|
1504            isTypingClosingBrace = true;
1505        } else if (cursorOffset >= macroText.length && variableOperator && !operatorNeedsValue) {
1506            // Cursor at end of complete operator (++ or --) like {{.Lila++ or {{.Lila++  (with trailing space)
1507            isTypingClosingBrace = true;
1508        } else if (cursorOffset >= macroText.length && !variableOperator && variableName.length > 0) {
1509            // Cursor at end of standalone variable (with or without trailing whitespace) like {{.Lila or {{ .Lila
1510            isTypingClosingBrace = true;
1511        } else if (operatorNeedsValue && variableValue.length > 0 && variableValue.endsWith('}')) {
1512            // Typing first } after a value like {{.Lila+=4}
1513            isTypingClosingBrace = true;
1514            // Strip the } from the value
1515            variableValue = variableValue.slice(0, -1);
1516        } else if (operatorNeedsValue && cursorOffset >= macroText.length && variableValue.length > 0) {
1517            // Cursor at end after typing a value (including trailing whitespace) like {{.Lila+=4
1518            // This means the shorthand is "complete" and ready to close
1519            isTypingClosingBrace = true;
1520        }
1521
1522        // Determine cursor position context for autocomplete
1523        // Note: isTypingClosingBrace takes precedence - if we're typing a closing brace,
1524        // we don't want to show operator suggestions, just the current state
1525        const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;
1526        if (cursorOffset < prefixEnd) {
1527            // Cursor is before the prefix - still in flags area conceptually
1528            isTypingVariableName = false;
1529        } else if (cursorOffset <= variableNameEnd) {
1530            // Cursor is in the variable name area (including at the end)
1531            isTypingVariableName = true;
1532        } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars && !isTypingClosingBrace) {
1533            // Cursor is after variable name but no operator yet (and no invalid chars)
1534            // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<'
1535            // But NOT when typing a closing brace - that takes precedence
1536            isTypingOperator = true;
1537        } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) {
1538            // Short operator that could be prefix of longer one (e.g., > could become >=)
1539            // But ONLY if cursor is still in the operator area, not past it into value
1540            isTypingOperator = true;
1541        } else if (operatorNeedsValue) {
1542            // Operator that requires value - cursor is in value area
1543            isTypingValue = true;
1544        }
1545        // For ++ and --, the operator is complete (no value needed)
1546        // For invalid trailing chars, none of the typing flags will be true
1547        const isOperatorComplete = (variableOperator === '++' || variableOperator === '--');
1548
1549        void onboardingExperimentalMacroEngine('variable shorthands');
1550
1551        // Return early for variable shorthand - different structure than regular macros
1552        return {
1553            fullText: macroText,
1554            cursorOffset,
1555            paddingBefore: macroText.match(/^\s+/)?.[0] ?? '',
1556            identifier: '', // No macro identifier for variable shorthand
1557            identifierStart: -1,
1558            isInFlagsArea: false,
1559            flags,
1560            currentFlag,
1561            args: [],
1562            currentArgIndex: -1,
1563            isTypingSeparator: false,
1564            isTypingClosingBrace,
1565            hasSpaceAfterIdentifier: false,
1566            hasSpaceArgContent: false,
1567            separatorCount: 0,
1568            // Variable shorthand specific properties
1569            isVariableShorthand,
1570            variablePrefix,
1571            variableName,
1572            variableNameEnd,
1573            variableOperator,
1574            variableOperatorEnd,
1575            variableValue,
1576            isTypingVariableName,
1577            isTypingOperator,
1578            isTypingValue,
1579            isOperatorComplete,
1580            hasInvalidTrailingChars,
1581            invalidTrailingChars,
1582            partialOperator,
1583        };
1584    }
1585
1586    // Regular macro parsing (not variable shorthand)
1587    // Now parse the identifier and arguments starting from position i
1588    const remainingText = macroText.slice(i);
1589    const parts = [];
1590    /** @type {{ start: number, end: number }[]} */
1591    const separatorPositions = []; // Track positions of :: separators
1592    let currentPart = '';
1593    let partStart = i;
1594    let j = 0;
1595
1596    // Track nesting depth to skip :: inside nested macros
1597    let nestedDepth = 0;
1598    // Track if we've seen a :: separator - newlines before first :: should stop parsing
1599    let hasSeenSeparator = false;
1600    // Track if we broke early (e.g., at a newline)
1601    let brokeEarly = false;
1602    while (j < remainingText.length) {
1603        // Before the first :: separator, newlines should stop parsing
1604        // This prevents text on the next line from being considered part of the identifier/space-arg
1605        if (!hasSeenSeparator && nestedDepth === 0 && (remainingText[j] === '\n' || remainingText[j] === '\r')) {
1606            // Stop parsing here - don't include the newline or anything after
1607            brokeEarly = true;
1608            break;
1609        }
1610        // Track nested macro braces
1611        if (remainingText[j] === '{' && remainingText[j + 1] === '{') {
1612            nestedDepth++;
1613            currentPart += '{{';
1614            j += 2;
1615            continue;
1616        }
1617        if (remainingText[j] === '}' && remainingText[j + 1] === '}') {
1618            nestedDepth = Math.max(0, nestedDepth - 1);
1619            currentPart += '}}';
1620            j += 2;
1621            continue;
1622        }
1623        // Only count :: as separator when not inside nested macros
1624        if (nestedDepth === 0 && remainingText[j] === ':' && remainingText[j + 1] === ':') {
1625            parts.push({ text: currentPart, start: partStart, end: i + j });
1626            separatorPositions.push({ start: i + j, end: i + j + 2 });
1627            currentPart = '';
1628            j += 2;
1629            partStart = i + j;
1630            hasSeenSeparator = true;
1631        } else {
1632            currentPart += remainingText[j];
1633            j++;
1634        }
1635    }
1636    // Push the last part - use correct end position if we broke early.
1637    // If we broke early (at a newline) AND cursor is past that point, don't push -
1638    // this filters out text on the next line from being considered part of this macro.
1639    // But if we didn't break early (cursor at end of closed macro), always push.
1640    const lastPartEnd = brokeEarly ? i + j : macroText.length;
1641    const shouldPushLastPart = !brokeEarly || cursorOffset <= lastPartEnd;
1642    if (shouldPushLastPart) {
1643        parts.push({ text: currentPart, start: partStart, end: lastPartEnd });
1644    }
1645
1646    // Determine if cursor is in the flags area (at or before identifier starts)
1647    const identifierStartPos = parts[0]?.start ?? i;
1648    const isInFlagsArea = cursorOffset <= identifierStartPos;
1649
1650    // Check if cursor is on a partial separator (single ':' that might become '::')
1651    const isTypingSeparator = remainingText.length > 0 &&
1652        cursorOffset > identifierStartPos &&
1653        macroText[cursorOffset - 1] === ':' &&
1654        macroText[cursorOffset] !== ':' &&
1655        (cursorOffset < 2 || macroText[cursorOffset - 2] !== ':');
1656
1657    // Parse identifier and space-separated argument from the first part
1658    // "getvar myvar" -> identifier="getvar", spaceArg="myvar"
1659    // "setvar " -> identifier="setvar", spaceArg="" (just whitespace, no content yet)
1660    const firstPartText = parts[0]?.text || '';
1661    const trimmedFirstPart = firstPartText.trimStart();
1662    const firstSpaceInIdentifier = trimmedFirstPart.search(/\s/);
1663
1664    let identifierOnly;
1665    let spaceArgText = '';
1666    //let spaceArgStart = -1;
1667    let hasSpaceAfterIdentifier = false;
1668
1669    if (firstSpaceInIdentifier > 0 && separatorPositions.length === 0) {
1670        // There's whitespace inside the first part - split identifier from space-arg
1671        identifierOnly = trimmedFirstPart.slice(0, firstSpaceInIdentifier);
1672        const afterIdentifier = trimmedFirstPart.slice(firstSpaceInIdentifier);
1673        // Check if there's actual content after the whitespace (not just spaces or ::)
1674        const contentAfterSpace = afterIdentifier.trimStart();
1675        hasSpaceAfterIdentifier = afterIdentifier.length > 0; // Has at least a space
1676
1677        if (contentAfterSpace.length > 0 && !contentAfterSpace.startsWith(':')) {
1678            // There's actual argument content after the space
1679            spaceArgText = contentAfterSpace;
1680            //spaceArgStart = identifierStartPos + firstSpaceInIdentifier + (afterIdentifier.length - contentAfterSpace.length);
1681        }
1682    } else {
1683        identifierOnly = trimmedFirstPart.trimEnd();
1684    }
1685
1686    // Calculate identifier end position (for space-after-identifier detection)
1687    const identifierEndPos = identifierStartPos + (firstPartText.length - firstPartText.trimStart().length) + identifierOnly.length;
1688
1689    // Determine which part the cursor is in
1690    let currentArgIndex = -1;
1691
1692    // Only consider being in an argument if we've passed a separator
1693    if (separatorPositions.length > 0) {
1694        // Find which argument we're in based on separator positions
1695        for (let sepIdx = 0; sepIdx < separatorPositions.length; sepIdx++) {
1696            const sep = separatorPositions[sepIdx];
1697            if (cursorOffset >= sep.end) {
1698                // We're past this separator, so we're in at least this argument
1699                currentArgIndex = sepIdx;
1700            }
1701        }
1702    } else if (spaceArgText.length > 0 || (hasSpaceAfterIdentifier && cursorOffset > identifierEndPos)) {
1703        // Space-separated arg: either has content, or cursor is past identifier+space
1704        currentArgIndex = 0;
1705    }
1706
1707    // If typing a separator, we're still on identifier/previous arg, not the next one
1708    if (isTypingSeparator) {
1709        currentArgIndex = -1;
1710    }
1711
1712    const leftPadding = macroText.match(/^\s+/)?.[0] ?? '';
1713
1714    if (leftPadding) {
1715        void onboardingExperimentalMacroEngine('leading whitespace');
1716    }
1717
1718    // Clean identifier: strip trailing colons (for partial :: typing)
1719    // Also strip trailing single } (for partial }} typing) - but only if no separators/args
1720    let cleanIdentifier = identifierOnly.replace(/:+$/, '');
1721    let isTypingClosingBrace = false;
1722    if (separatorPositions.length === 0 && !hasSpaceAfterIdentifier && cleanIdentifier.endsWith('}')) {
1723        // Typing first closing brace on a standalone macro like {{char}
1724        cleanIdentifier = cleanIdentifier.slice(0, -1);
1725        isTypingClosingBrace = true;
1726    }
1727
1728    // Build args array - include space-separated arg if present
1729    // Trim args like the macro engine does
1730    let args = parts.slice(1).map(p => p.text.trim());
1731    if (spaceArgText.length > 0) {
1732        args = [spaceArgText, ...args];
1733    }
1734
1735    return {
1736        fullText: macroText,
1737        cursorOffset,
1738        paddingBefore: leftPadding,
1739        identifier: cleanIdentifier,
1740        identifierStart: identifierStartPos,
1741        isInFlagsArea,
1742        flags,
1743        currentFlag,
1744        args,
1745        currentArgIndex,
1746        isTypingSeparator,
1747        isTypingClosingBrace,
1748        hasSpaceAfterIdentifier,
1749        hasSpaceArgContent: spaceArgText.length > 0,
1750        separatorCount: separatorPositions.length,
1751        // Default variable shorthand properties (not a variable shorthand)
1752        isVariableShorthand: false,
1753        variablePrefix: null,
1754        variableName: '',
1755        variableNameEnd: null,
1756        variableOperator: null,
1757        variableOperatorEnd: null,
1758        variableValue: '',
1759        isTypingVariableName: false,
1760        isTypingOperator: false,
1761        isTypingValue: false,
1762    };
1763}
1764
1765/**
1766 * A simple, generic autocomplete option for displaying basic items with name, symbol, and description.
1767 * Useful for simple options like inversion markers, prefixes, etc. without needing a full custom class.
1768 *
1769 * @extends AutoCompleteOption
1770 */
1771export class SimpleAutoCompleteOption extends AutoCompleteOption {
1772    /** @type {string} */
1773    #description;
1774
1775    /** @type {string|null} */
1776    #detailedDescription;
1777
1778    /**
1779     * @param {Object} config - Configuration for the option.
1780     * @param {string} config.name - The option name/key (used for matching).
1781     * @param {string} [config.symbol=' '] - Icon/symbol shown in the type column.
1782     * @param {string} [config.description=''] - Short description shown inline.
1783     * @param {string} [config.detailedDescription] - Longer description for details panel (supports HTML). Falls back to description if not provided.
1784     * @param {string} [config.type='simple'] - Type identifier for CSS/data attributes.
1785     */
1786    constructor({ name, symbol = ' ', description = '', detailedDescription = null, type = 'simple' }) {
1787        super(name, symbol, type);
1788        this.#description = description;
1789        this.#detailedDescription = detailedDescription;
1790    }
1791
1792    /** @returns {string} */
1793    get description() {
1794        return this.#description;
1795    }
1796
1797    /** @returns {string} */
1798    get detailedDescription() {
1799        return this.#detailedDescription ?? this.#description;
1800    }
1801
1802    /**
1803     * @returns {HTMLElement}
1804     */
1805    renderItem() {
1806        const li = document.createElement('li');
1807        li.classList.add('item');
1808        li.setAttribute('data-name', this.name);
1809        li.setAttribute('data-option-type', this.type);
1810
1811        // Type icon
1812        const typeSpan = document.createElement('span');
1813        typeSpan.classList.add('type', 'monospace');
1814        typeSpan.textContent = this.typeIcon;
1815        li.append(typeSpan);
1816
1817        // Name
1818        const specs = document.createElement('span');
1819        specs.classList.add('specs');
1820        const nameSpan = document.createElement('span');
1821        nameSpan.classList.add('name', 'monospace');
1822        this.name.split('').forEach(char => {
1823            const span = document.createElement('span');
1824            span.textContent = char;
1825            nameSpan.append(span);
1826        });
1827        specs.append(nameSpan);
1828        li.append(specs);
1829
1830        // Stopgap
1831        const stopgap = document.createElement('span');
1832        stopgap.classList.add('stopgap');
1833        li.append(stopgap);
1834
1835        // Help/description
1836        const help = document.createElement('span');
1837        help.classList.add('help');
1838        const content = document.createElement('span');
1839        content.classList.add('helpContent');
1840        content.textContent = this.#description;
1841        help.append(content);
1842        li.append(help);
1843
1844        return li;
1845    }
1846
1847    /**
1848     * @returns {DocumentFragment}
1849     */
1850    renderDetails() {
1851        const frag = document.createDocumentFragment();
1852
1853        // Header with name
1854        const specs = document.createElement('div');
1855        specs.classList.add('specs');
1856        const nameDiv = document.createElement('div');
1857        nameDiv.classList.add('name', 'monospace');
1858        nameDiv.textContent = this.name;
1859        specs.append(nameDiv);
1860        frag.append(specs);
1861
1862        // Description
1863        if (this.detailedDescription) {
1864            const helpDiv = document.createElement('div');
1865            helpDiv.classList.add('help');
1866            helpDiv.innerHTML = this.detailedDescription;
1867            frag.append(helpDiv);
1868        }
1869
1870        return frag;
1871    }
1872}

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.