/**
* Enhanced macro autocomplete option for the new MacroRegistry-based system.
* Reuses rendering logic from MacroBrowser for consistency and DRY.
*/
import { AutoCompleteOption } from './AutoCompleteOption.js';
import {
formatMacroSignature,
createSourceIndicator,
createAliasIndicator,
renderMacroDetails,
} from '../macros/engine/MacroBrowser.js';
import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
/** @typedef {import('../macros/engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
/**
* Macro context passed from the parser to provide cursor position info.
* @typedef {Object} MacroAutoCompleteContext
* @property {string} fullText - The full macro text being typed (without {{ }}).
* @property {number} cursorOffset - Cursor position within the macro text.
* @property {string} paddingBefore - Padding before the macro identifier/flags.
* @property {string} identifier - The macro identifier (name).
* @property {number} identifierStart - Start position of the identifier within the macro text.
* @property {string[]} flags - Array of flag symbols typed (e.g., ['!', '?']).
* @property {string|null} currentFlag - The flag symbol cursor is currently on (last typed flag), or null.
* @property {boolean} isInFlagsArea - Whether cursor is in the flags area (before identifier starts).
* @property {string[]} args - Array of arguments typed so far.
* @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
* @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').
* @property {boolean} isTypingClosingBrace - Whether cursor is typing the first closing brace on a standalone macro.
* @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).
* @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
* @property {number} separatorCount - Number of '::' separators found.
* @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag).
* @property {boolean} [isScopedContentOptional] - Whether the scoped content is optional (for display purposes).
* @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
* @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
* @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
* @property {string} variableName - The variable name being typed (after the prefix).
* @property {number} variableNameEnd - The end of the variable name (for partial matches).
* @property {string|null} variableOperator - The operator typed (=, ++, --, +=), or null.
* @property {number} variableOperatorEnd - The end of the variable operator (for partial matches).
* @property {string} variableValue - The value after the operator (for = and +=).
* @property {boolean} isTypingVariableName - Whether cursor is in the variable name area.
* @property {boolean} isTypingOperator - Whether cursor is at/after variable name, ready for operator.
* @property {boolean} isTypingValue - Whether cursor is after an operator that requires a value.
* @property {boolean} [hasInvalidTrailingChars] - Whether there are invalid characters after the variable name.
* @property {string} [invalidTrailingChars] - The invalid trailing characters (for error display).
* @property {string} [partialOperator] - Partial operator prefix being typed ('+' or '-').
* @property {boolean} [isOperatorComplete] - Whether a complete operator (++ or --) was typed that doesn't need a value.
*/
/**
* @typedef {Object} EnhancedMacroAutoCompleteOptions
* @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions).
* @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style).
* @property {boolean} [closeWithBraces=false] - If true, the completion will add }} to close the macro.
* @property {string[]} [flags=[]] - The currently already written flags for this autocomplete.
* @property {string} [currentFlag] - The current flag that is present, if any.
* @property {string} [fullText] - The currently written full text.
*/
export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
/** @type {MacroDefinition} */
#macro;
/** @type {MacroAutoCompleteContext|null} */
#context = null;
/** @type {EnhancedMacroAutoCompleteOptions|null} */
#options = null;
/** @type {boolean} */
#noBraces = false;
/** @type {string} */
#paddingAfter = '';
/**
* @param {MacroDefinition} macro - The macro definition from MacroRegistry.
* @param {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} [contextOrOptions] - Context for argument hints, or options object.
*/
constructor(macro, contextOrOptions = null) {
// Use the macro name as the autocomplete key
super(macro.name, enumIcons.macro);
this.#macro = macro;
// Detect if second argument is context or options
// Context has 'identifier' property, options may have 'noBraces'
if (contextOrOptions && typeof contextOrOptions === 'object') {
if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) {
// It's an options object
this.#options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions);
this.#noBraces = this.#options.noBraces ?? false;
this.#paddingAfter = this.#options.paddingAfter ?? '';
// If noBraces mode with closeWithBraces, complete with name + padding + }}
if (this.#options.closeWithBraces) {
this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
this.makeSelectable = true;
}
} else {
// It's a context object
this.#context = /** @type {MacroAutoCompleteContext} */ (contextOrOptions);
}
}
// nameOffset = 2 to skip the {{ prefix in the display (formatMacroSignature includes braces)
// When noBraces is true, nameOffset = 0 since we don't show braces
this.nameOffset = this.#noBraces ? 0 : 2;
// For macros that take no arguments, auto-complete with closing }} (unless already set by options)
if (!this.valueProvider) {
const takesNoArgs = macro.minArgs === 0 && macro.maxArgs === 0 && macro.list === null;
if (takesNoArgs) {
this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
this.makeSelectable = true; // Required when using valueProvider
}
}
// {{//}} 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.
const fullText = this.#options?.fullText ?? this.#context?.fullText ?? '';
if (macro.name === '//' && fullText.endsWith('/')) {
this.replacementStartOffset = (this.replacementStartOffset ?? 0) - 1; // Cut the leading slash
}
}
/** @returns {MacroDefinition} */
get macro() {
return this.#macro;
}
/**
* Renders the list item for the autocomplete dropdown.
* Tight display: [icon] [signature] [description] [alias icon?] [source icon]
* @returns {HTMLElement}
*/
renderItem() {
const li = document.createElement('li');
li.classList.add('item', 'macro-ac-item');
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'macro');
// Type icon
const type = document.createElement('span');
type.classList.add('type', 'monospace');
type.textContent = '{}';
li.append(type);
// Specs container (for fuzzy highlight compatibility)
const specs = document.createElement('span');
specs.classList.add('specs');
// Name with character spans for fuzzy highlighting
const nameEl = document.createElement('span');
nameEl.classList.add('name', 'monospace');
// Build signature with individual character spans
// When noBraces is true, show just the macro name without {{ }}
const sigText = this.#noBraces ? this.#macro.name : formatMacroSignature(this.#macro);
for (const char of sigText) {
const span = document.createElement('span');
span.textContent = char;
nameEl.append(span);
}
specs.append(nameEl);
li.append(specs);
// Stopgap (spacer for flex layout)
const stopgap = document.createElement('span');
stopgap.classList.add('stopgap');
li.append(stopgap);
// Help text (description)
const help = document.createElement('span');
help.classList.add('help');
const content = document.createElement('span');
content.classList.add('helpContent');
content.textContent = this.#macro.description || '';
help.append(content);
li.append(help);
// Alias indicator icon (if this is an alias)
const aliasIcon = createAliasIndicator(this.#macro);
if (aliasIcon) {
aliasIcon.classList.add('macro-ac-indicator');
li.append(aliasIcon);
}
// Source indicator icon
const sourceIcon = createSourceIndicator(this.#macro);
sourceIcon.classList.add('macro-ac-indicator');
li.append(sourceIcon);
return li;
}
/**
* Renders the details panel content.
* Reuses renderMacroDetails from MacroBrowser with autocomplete-specific options.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
// Check for arity warnings
const warning = this.#getArityWarning();
if (warning) {
const warningEl = this.#renderWarning(warning);
frag.append(warningEl);
}
// Show scoped content info banner if we're in scoped content
if (this.#context?.isInScopedContent) {
const scopedInfo = this.#renderScopedContentInfo();
if (scopedInfo) frag.append(scopedInfo);
}
// Determine current argument index for highlighting
const currentArgIndex = this.#context?.currentArgIndex ?? -1;
// For most warnings, we can still highlight which argument we are currently at.
// This even goes for "too many arguments" when navigating the cursor back to
// a valid argument.
// Extend this in the future, if *some* warnings don't make sense to still highlight args.
const hightlightArgsHint = currentArgIndex >= 0;
// Render argument hint banner if we're typing an argument
if (hightlightArgsHint && currentArgIndex >= 0) {
const hint = this.#renderArgumentHint();
if (hint) frag.append(hint);
}
// Reuse MacroBrowser's renderMacroDetails with options
const details = renderMacroDetails(this.#macro, { currentArgIndex: hightlightArgsHint ? currentArgIndex : -1 });
// Add class for autocomplete-specific styling overrides
details.classList.add('macro-ac-details');
frag.append(details);
return frag;
}
/**
* Checks for arity-related warnings based on the current context.
* @returns {string|null} Warning message, or null if no warning.
*/
#getArityWarning() {
if (!this.#context) return null;
const argCount = this.#context.args.length;
const maxArgs = this.#macro.maxArgs;
//const minArgs = this.#macro.minArgs;
const hasList = this.#macro.list !== null;
// Check for too many arguments (only if no list args)
if (!hasList && argCount > maxArgs) {
return `Too many arguments: this macro accepts ${maxArgs === 0 ? 'no arguments' : `up to ${maxArgs} argument${maxArgs === 1 ? '' : 's'}`}, but ${argCount} provided.`;
}
// Check for space-separated arg on macro that doesn't support it
// Space-separated syntax provides 1 arg; with scoped content you can provide a 2nd arg
// So it's valid for macros with maxArgs <= 2 (or with list args)
if (this.#context.hasSpaceArgContent) {
if (maxArgs === 0 && !hasList) {
return 'This macro does not accept any arguments. Remove the space or use a different macro.';
}
if (!hasList && maxArgs > 2) {
return `Space-separated syntax only works for macros with up to 2 arguments. Use :: separators instead: {{${this.#macro.name}::arg1::arg2}}`;
}
}
// Check if trying to add args to a no-arg macro via ::
// List-arg macros can accept args even if maxArgs === 0
if (this.#context.separatorCount > 0 && maxArgs === 0 && !hasList) {
return 'This macro does not accept any arguments.';
}
// Check list bounds (min/max) if the macro has a list with constraints
if (hasList && typeof this.#macro.list === 'object') {
const listItemCount = Math.max(0, argCount - maxArgs);
const listMin = this.#macro.list.min ?? 0;
const listMax = this.#macro.list.max ?? null;
if (listItemCount < listMin) {
const needed = listMin - listItemCount;
return `Not enough list items yet: this macro requires at least ${listMin} item${listMin === 1 ? '' : 's'}, but only ${listItemCount} provided. Add ${needed} more.`;
}
if (listMax !== null && listItemCount > listMax) {
return `Too many list items: this macro accepts at most ${listMax} item${listMax === 1 ? '' : 's'}, but ${listItemCount} provided.`;
}
}
return null;
}
/**
* Renders a warning banner.
* @param {string} message - The warning message.
* @returns {HTMLElement}
*/
#renderWarning(message) {
const warning = document.createElement('div');
warning.classList.add('macro-ac-warning');
const icon = document.createElement('i');
icon.classList.add('fa-solid', 'fa-triangle-exclamation');
warning.append(icon);
const text = document.createElement('span');
text.textContent = message;
warning.append(text);
return warning;
}
/**
* Renders the scoped content info banner.
* Shows when cursor is inside scoped content of an unclosed macro.
* @returns {HTMLElement|null}
*/
#renderScopedContentInfo() {
if (!this.#context?.isInScopedContent) return null;
const info = document.createElement('div');
info.classList.add('macro-ac-scoped-info');
// If the scoped content is optional, show a prominent OPTIONAL badge
if (this.#context.isScopedContentOptional) {
const optionalBadge = document.createElement('span');
optionalBadge.classList.add('macro-ac-optional-badge');
optionalBadge.textContent = 'OPTIONAL';
info.append(optionalBadge);
}
const icon = document.createElement('i');
icon.classList.add('fa-solid', 'fa-layer-group');
info.append(icon);
const text = document.createElement('span');
const closingHint = this.#context.isScopedContentOptional
? `Can optionally close with {{/${this.#context.scopedMacroName}}}`
: `Close with {{/${this.#context.scopedMacroName}}}`;
text.innerHTML = `Typing scoped content for {{${this.#context.scopedMacroName}}}. ${closingHint}`;
info.append(text);
return info;
}
/**
* Renders the current argument hint banner.
* @returns {HTMLElement|null}
*/
#renderArgumentHint() {
if (!this.#context || this.#context.currentArgIndex < 0) return null;
const argIndex = this.#context.currentArgIndex;
const isListArg = argIndex >= this.#macro.maxArgs;
// If we're beyond unnamed args and there's no list, no hint
if (isListArg && !this.#macro.list) return null;
const hint = document.createElement('div');
hint.classList.add('macro-ac-arg-hint');
const icon = document.createElement('i');
icon.classList.add('fa-solid', 'fa-arrow-right');
hint.append(icon);
if (isListArg) {
// List argument hint
const listIndex = argIndex - this.#macro.maxArgs + 1;
const totalListItems = this.#context.args.length - this.#macro.maxArgs;
const text = document.createElement('span');
text.innerHTML = `List item ${listIndex}${(listIndex < totalListItems ? ` (of ${totalListItems})` : '')}`;
const listInfo = document.createElement('span');
listInfo.classList.add('macro-ac-arg-hint-small');
const minMax = [];
if (this.#macro.list.min > 0) minMax.push(`min: ${this.#macro.list.min}`);
if (this.#macro.list.max !== null) minMax.push(`max: ${this.#macro.list.max}`);
if (minMax.length > 0) {
listInfo.textContent = ` (list, ${minMax.join(', ')})`;
} else {
listInfo.textContent = ' (variable-length list)';
}
text.appendChild(listInfo);
hint.append(text);
} else {
// Unnamed argument hint (required or optional)
const argDef = this.#macro.unnamedArgDefs[argIndex];
let optionalLabel = '';
if (argDef?.optional) {
optionalLabel = argDef.defaultValue !== undefined
? ` (optional, default: ${argDef.defaultValue === '' ? '' : argDef.defaultValue})`
: ' (optional)';
}
const text = document.createElement('span');
text.innerHTML = `${argDef?.name || `Argument ${argIndex + 1}`}${optionalLabel}`;
if (argDef?.type) {
const typeSpan = document.createElement('code');
typeSpan.classList.add('macro-ac-hint-type');
if (Array.isArray(argDef.type)) {
typeSpan.textContent = argDef.type.join(' | ');
typeSpan.title = `Accepts: ${argDef.type.join(', ')}`;
} else {
typeSpan.textContent = argDef.type;
}
text.append(' ', typeSpan);
}
hint.append(text);
if (argDef?.description) {
const descSpan = document.createElement('span');
descSpan.classList.add('macro-ac-hint-desc');
descSpan.textContent = ` — ${argDef.description}`;
hint.append(descSpan);
}
if (argDef?.sampleValue) {
const sampleSpan = document.createElement('span');
sampleSpan.classList.add('macro-ac-hint-sample');
sampleSpan.textContent = ` (e.g. ${argDef.sampleValue})`;
hint.append(sampleSpan);
}
}
return hint;
}
}
/**
* Autocomplete option for macro execution flags.
* Shows flag symbol, name, and description.
* Uses default AutoCompleteOption rendering for consistent styling.
*/
export class MacroFlagAutoCompleteOption extends AutoCompleteOption {
/** @type {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
#flagDef;
/**
* @param {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} flagDef - The flag definition.
*/
constructor(flagDef) {
// Use the flag symbol as the name, with a flag icon
// Display name includes both symbol and name for clarity
super(flagDef.type, '🚩');
this.#flagDef = flagDef;
}
/** @returns {import('../macros/engine/MacroFlags.js').MacroFlagDefinition} */
get flagDefinition() {
return this.#flagDef;
}
/**
* Renders the autocomplete list item for this flag.
* Uses the same structure as other autocomplete options for consistent styling.
* @returns {HTMLElement}
*/
renderItem() {
// Use base class makeItem for consistent styling
const li = this.makeItem(
`${this.#flagDef.type} ${this.#flagDef.name}`, // Display: "? Optional"
'🚩',
true, // noSlash
[], // namedArguments
[], // unnamedArguments
'void', // returnType
this.#flagDef.description + (this.#flagDef.implemented ? '' : ' (planned)'), // helpString
);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'flag');
return li;
}
/**
* Renders the details panel for this flag.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
const details = document.createElement('div');
details.classList.add('macro-flag-details');
// Header with flag symbol and name
const header = document.createElement('h3');
header.classList.add('macro-flag-details-header');
header.innerHTML = `${this.#flagDef.type} ${this.#flagDef.name} Flag`;
details.append(header);
// Description
const desc = document.createElement('p');
desc.classList.add('macro-flag-details-desc');
desc.textContent = this.#flagDef.description;
details.append(desc);
// Status
const status = document.createElement('p');
status.classList.add('macro-flag-details-status');
status.innerHTML = `Status: ${this.#flagDef.implemented ? 'Implemented' : 'Planned for future release'}`;
details.append(status);
// Parser effect note
if (this.#flagDef.affectsParser) {
const parserNote = document.createElement('p');
parserNote.classList.add('macro-flag-details-note');
parserNote.innerHTML = 'This flag affects how the macro is parsed.';
details.append(parserNote);
}
frag.append(details);
return frag;
}
}
/**
* Enum of variable shorthand prefix types.
* @readonly
* @enum {string}
*/
export const VariableShorthandType = Object.freeze({
/** Local variable prefix (`.`) */
LOCAL: '.',
/** Global variable prefix (`$`) */
GLOBAL: '$',
});
/**
* @typedef {Object} VariableShorthandDefinition
* @property {VariableShorthandType} type - The prefix symbol.
* @property {string} name - Human-readable name.
* @property {string} description - Description of what this prefix does.
* @property {string[]} operations - List of supported operations.
*/
/**
* Definitions for variable shorthand prefixes.
* @type {Map}
*/
export const VariableShorthandDefinitions = new Map([
[VariableShorthandType.LOCAL, {
type: VariableShorthandType.LOCAL,
name: 'Local Variable',
description: 'Access or modify a local variable (scoped to current chat).',
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 (<=)'],
}],
[VariableShorthandType.GLOBAL, {
type: VariableShorthandType.GLOBAL,
name: 'Global Variable',
description: 'Access or modify a global variable (shared across all chats).',
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 (<=)'],
}],
]);
/**
* Set of valid variable shorthand prefix symbols.
* @type {Set}
*/
export const ValidVariableShorthandSymbols = new Set(Object.values(VariableShorthandType));
/**
* Regex pattern for valid variable shorthand names.
* Must start with a letter, can contain word chars, underscores and hyphens, but must not end with an underscore or hyphen.
* Examples: myVar, my-var, my_var, myVar123, my-long-var-name
* Invalid: my-, my--, -var, 123var
* @type {RegExp}
*/
const VARIABLE_SHORTHAND_NAME_PATTERN = new RegExp(`^${MACRO_VARIABLE_SHORTHAND_PATTERN.source}`);
/**
* Checks if a variable name is valid for use with variable shorthand syntax.
* @param {string} name - The variable name to validate.
* @returns {boolean} True if the name is valid for shorthand syntax.
*/
export function isValidVariableShorthandName(name) {
if (!name || typeof name !== 'string') return false;
return VARIABLE_SHORTHAND_NAME_PATTERN.test(name);
}
/**
* Autocomplete option for variable shorthand prefixes.
* Shows prefix symbol, name, and description.
* This provides entry into the variable shorthand syntax ({{.varName}} or {{$varName}}).
*/
export class VariableShorthandAutoCompleteOption extends AutoCompleteOption {
/** @type {VariableShorthandDefinition} */
#varDef;
/**
* @param {VariableShorthandDefinition} varDef - The variable shorthand definition.
*/
constructor(varDef) {
// Use the prefix symbol as the name, with a variable icon
super(varDef.type, '📦');
this.#varDef = varDef;
}
/** @returns {VariableShorthandDefinition} */
get variableDefinition() {
return this.#varDef;
}
/**
* Renders the autocomplete list item for this variable shorthand.
* @returns {HTMLElement}
*/
renderItem() {
const li = this.makeItem(
`${this.#varDef.type} ${this.#varDef.name}`,
'📦',
true, // noSlash
[], // namedArguments
[], // unnamedArguments
'any', // returnType
this.#varDef.description,
);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'variable-shorthand');
return li;
}
/**
* Renders the details panel for this variable shorthand.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
const details = document.createElement('div');
details.classList.add('macro-variable-details');
// Header with prefix symbol and name
const header = document.createElement('h3');
header.classList.add('macro-variable-details-header');
header.innerHTML = `${this.#varDef.type} ${this.#varDef.name}`;
details.append(header);
// Description
const desc = document.createElement('p');
desc.classList.add('macro-variable-details-desc');
desc.textContent = this.#varDef.description;
details.append(desc);
// Supported operations
const opsHeader = document.createElement('p');
opsHeader.innerHTML = 'Supported Operations:';
details.append(opsHeader);
const opsList = document.createElement('ul');
opsList.classList.add('macro-variable-details-ops');
for (const op of this.#varDef.operations) {
const li = document.createElement('li');
li.textContent = op;
opsList.append(li);
}
details.append(opsList);
// Examples
const exampleHeader = document.createElement('p');
exampleHeader.innerHTML = 'Examples:';
details.append(exampleHeader);
const exampleList = document.createElement('ul');
exampleList.classList.add('macro-variable-details-examples');
const prefix = this.#varDef.type;
const examples = [
`{{${prefix}myvar}} - Get variable value`,
`{{${prefix}myvar = value}} - Set variable (returns nothing)`,
`{{${prefix}counter++}} - Increment and get value`,
`{{${prefix}counter--}} - Decrement and get value`,
`{{${prefix}myvar += text}} - Append/add (returns nothing)`,
`{{${prefix}score -= 5}} - Subtract (returns nothing)`,
`{{${prefix}myvar || default}} - Get with fallback if falsy`,
`{{${prefix}myvar ?? default}} - Get with fallback if undefined`,
`{{${prefix}myvar ||= value}} - Set if falsy, get value`,
`{{${prefix}myvar ??= value}} - Set if undefined, get value`,
`{{${prefix}myvar == test}} - Compare (returns true/false)`,
`{{${prefix}myvar != test}} - Compare not equal (returns true/false)`,
`{{${prefix}score > 10}} - Greater than (numeric, returns true/false)`,
`{{${prefix}score >= 10}} - Greater than or equal (numeric)`,
`{{${prefix}score < 10}} - Less than (numeric, returns true/false)`,
`{{${prefix}score <= 10}} - Less than or equal (numeric)`,
];
for (const ex of examples) {
const li = document.createElement('li');
li.innerHTML = `${ex.split(' - ')[0]} - ${ex.split(' - ')[1]}`;
exampleList.append(li);
}
details.append(exampleList);
frag.append(details);
return frag;
}
}
/**
* Autocomplete option for a specific variable name.
* Shows variable name with scope indicator (local/global).
*/
export class VariableNameAutoCompleteOption extends AutoCompleteOption {
/** @type {string} */
#varName;
/** @type {'local'|'global'} */
#scope;
/** @type {boolean} */
#isNewVariable;
/** @type {boolean} */
#isInvalidName;
/**
* @param {string} varName - The variable name.
* @param {'local'|'global'} scope - Whether this is a local or global variable.
* @param {boolean} [isNewVariable=false] - Whether this is a "create new variable" option.
* @param {boolean} [isInvalidName=false] - Whether this name is invalid for shorthand syntax.
*/
constructor(varName, scope, isNewVariable = false, isInvalidName = false) {
const icon = scope === 'local' ? 'L' : 'G';
super(varName, icon);
this.#varName = varName;
this.#scope = scope;
this.#isNewVariable = isNewVariable;
this.#isInvalidName = isInvalidName;
}
/** @returns {string} */
get variableName() {
return this.#varName;
}
/** @returns {'local'|'global'} */
get scope() {
return this.#scope;
}
/** @returns {boolean} */
get isNewVariable() {
return this.#isNewVariable;
}
/** @returns {boolean} */
get isInvalidName() {
return this.#isInvalidName;
}
/**
* Renders the autocomplete list item for this variable.
* @returns {HTMLElement}
*/
renderItem() {
const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
let description;
if (this.#isInvalidName) {
description = '⚠️ Invalid variable name for shorthand';
} else if (this.#isNewVariable) {
description = `Define new ${scopeLabel.toLowerCase()} variable`;
} else {
description = `${scopeLabel} variable`;
}
const li = this.makeItem(
this.#varName,
this.typeIcon,
true, // noSlash
[], // namedArguments
[], // unnamedArguments
'any', // returnType
description,
);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'variable-name');
if (this.#isNewVariable) {
li.classList.add('variable-new');
}
if (this.#isInvalidName) {
li.classList.add('variable-invalid');
}
return li;
}
/**
* Renders the details panel for this variable.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
const details = document.createElement('div');
details.classList.add('macro-variable-name-details');
const scopeLabel = this.#scope === 'local' ? 'Local' : 'Global';
const prefix = this.#scope === 'local' ? '.' : '$';
// Show big warning for invalid names
if (this.#isInvalidName) {
const warningBox = document.createElement('div');
warningBox.classList.add('variable-invalid-warning');
warningBox.style.cssText = 'background: #ff000033; border: 2px solid #ff0000; border-radius: 4px; padding: 10px; margin-bottom: 10px;';
const warningHeader = document.createElement('h3');
warningHeader.style.cssText = 'color: #ff6b6b; margin: 0 0 8px 0;';
warningHeader.textContent = '⚠️ Invalid Variable Name';
warningBox.append(warningHeader);
const warningText = document.createElement('p');
warningText.style.cssText = 'margin: 0 0 8px 0;';
warningText.innerHTML = `The name ${this.#varName} cannot be used with variable shorthand syntax.`;
warningBox.append(warningText);
const rulesText = document.createElement('p');
rulesText.style.cssText = 'margin: 0; font-size: 0.9em;';
rulesText.innerHTML = 'Valid names must:
• Start with a letter (a-z, A-Z)
• Contain only letters, numbers, underscores, or hyphens
• Not end with an underscore or hyphen';
warningBox.append(rulesText);
details.append(warningBox);
frag.append(details);
return frag;
}
// Header
const header = document.createElement('h3');
header.innerHTML = this.#isNewVariable
? `${prefix}${this.#varName} (New ${scopeLabel} Variable)`
: `${prefix}${this.#varName} ${scopeLabel} Variable`;
details.append(header);
// Description
const desc = document.createElement('p');
const variableSuggestion = this.#scope === 'local'
? 'Local variables are scoped to the current chat.'
: 'Global variables are shared across all chats.';
if (this.#isNewVariable) {
desc.textContent = `Creates a new ${scopeLabel.toLowerCase()} variable named "${this.#varName}". ${variableSuggestion}`;
} else {
desc.textContent = `Access or modify the ${scopeLabel.toLowerCase()} variable "${this.#varName}". ${variableSuggestion}`;
}
details.append(desc);
// Usage examples
const usageHeader = document.createElement('p');
usageHeader.innerHTML = 'Usage:';
details.append(usageHeader);
const usageList = document.createElement('ul');
const examples = [
`{{${prefix}${this.#varName}}} - Get value`,
`{{${prefix}${this.#varName} = value}} - Set value`,
`{{${prefix}${this.#varName}++}} - Increment`,
`{{${prefix}${this.#varName}--}} - Decrement`,
`{{${prefix}${this.#varName} += text}} - Append/add`,
`{{${prefix}${this.#varName} -= 5}} - Subtract`,
`{{${prefix}${this.#varName} || default}} - Get with fallback if falsy`,
`{{${prefix}${this.#varName} ?? default}} - Get with fallback if undefined`,
`{{${prefix}${this.#varName} ||= value}} - Set if falsy, get value`,
`{{${prefix}${this.#varName} ??= value}} - Set if undefined, get value`,
`{{${prefix}${this.#varName} == test}} - Compare (returns true/false)`,
`{{${prefix}${this.#varName} != test}} - Compare not equal (returns true/false)`,
`{{${prefix}${this.#varName} > 10}} - Greater than (numeric)`,
`{{${prefix}${this.#varName} >= 10}} - Greater than or equal (numeric)`,
`{{${prefix}${this.#varName} < 10}} - Less than (numeric)`,
`{{${prefix}${this.#varName} <= 10}} - Less than or equal (numeric)`,
];
for (const ex of examples) {
const li = document.createElement('li');
li.innerHTML = `${ex.split(' - ')[0]} - ${ex.split(' - ')[1]}`;
usageList.append(li);
}
details.append(usageList);
frag.append(details);
return frag;
}
}
/**
* Checks if an operator is a short one that could be a prefix of a longer operator.
* For example, '>' is a prefix of '>=', '<' is a prefix of '<='.
* @param {string} op - The operator to check.
* @returns {boolean} True if the operator could be a prefix of a longer operator.
*/
function isShortOperatorPrefix(op) {
// These operators could have longer variants typed after them
const shortPrefixes = ['>', '<', '=', '|', '?', '+', '-', '!'];
return shortPrefixes.includes(op);
}
/**
* Variable shorthand operators with metadata.
* @type {Map}
*/
export const VariableOperatorDefinitions = new Map([
['=', {
symbol: '=',
name: 'Set',
description: 'Set the variable to a new value. Returns nothing.',
needsValue: true,
}],
['++', {
symbol: '++',
name: 'Increment',
description: 'Increment the variable by 1 (numeric). Returns the new value.',
needsValue: false,
}],
['--', {
symbol: '--',
name: 'Decrement',
description: 'Decrement the variable by 1 (numeric). Returns the new value.',
needsValue: false,
}],
['+=', {
symbol: '+=',
name: 'Add',
description: 'Add to the variable (numeric addition or string concatenation). Returns nothing.',
needsValue: true,
}],
['-=', {
symbol: '-=',
name: 'Subtract',
description: 'Subtract a numeric value from the variable. Returns nothing.',
needsValue: true,
}],
['||', {
symbol: '||',
name: 'Logical Or',
description: 'Return the fallback value if the variable is falsy, otherwise return the variable value.',
needsValue: true,
}],
['??', {
symbol: '??',
name: 'Nullish Coalescing',
description: 'Return the fallback value only if the variable does not exist, otherwise return the variable value (even if falsy).',
needsValue: true,
}],
['||=', {
symbol: '||=',
name: 'Logical Or Assign',
description: 'If the variable is falsy, set it to the value and return it; otherwise return the current value.',
needsValue: true,
}],
['??=', {
symbol: '??=',
name: 'Nullish Coalescing Assign',
description: 'If the variable does not exist, set it to the value and return it; otherwise return the current value.',
needsValue: true,
}],
['==', {
symbol: '==',
name: 'Equals',
description: 'Compare the variable value to another value. Returns "true" or "false".',
needsValue: true,
}],
['!=', {
symbol: '!=',
name: 'Not Equals',
description: 'Compare the variable value to another value. Returns "true" if not equal, "false" if equal.',
needsValue: true,
}],
['>', {
symbol: '>',
name: 'Greater Than',
description: 'Numeric comparison. Returns "true" if variable is greater than value, "false" otherwise.',
needsValue: true,
}],
['>=', {
symbol: '>=',
name: 'Greater Than or Equal',
description: 'Numeric comparison. Returns "true" if variable is greater than or equal to value, "false" otherwise.',
needsValue: true,
}],
['<', {
symbol: '<',
name: 'Less Than',
description: 'Numeric comparison. Returns "true" if variable is less than value, "false" otherwise.',
needsValue: true,
}],
['<=', {
symbol: '<=',
name: 'Less Than or Equal',
description: 'Numeric comparison. Returns "true" if variable is less than or equal to value, "false" otherwise.',
needsValue: true,
}],
]);
/**
* Autocomplete option for a variable operator.
* Shows operator symbol, name, and description.
*/
export class VariableOperatorAutoCompleteOption extends AutoCompleteOption {
/** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
#operatorDef;
/**
* @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
*/
constructor(operatorDef) {
super(operatorDef.symbol, '⚡');
this.#operatorDef = operatorDef;
}
/** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
get operatorDefinition() {
return this.#operatorDef;
}
/**
* Renders the autocomplete list item for this operator.
* @returns {HTMLElement}
*/
renderItem() {
const li = this.makeItem(
`${this.#operatorDef.symbol} ${this.#operatorDef.name}`,
'⚡',
true, // noSlash
[], // namedArguments
[], // unnamedArguments
'void', // returnType
this.#operatorDef.description,
);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'variable-operator');
return li;
}
/**
* Renders the details panel for this operator.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
const details = document.createElement('div');
details.classList.add('macro-variable-operator-details');
// Header
const header = document.createElement('h3');
header.innerHTML = `${this.#operatorDef.symbol} ${this.#operatorDef.name}`;
details.append(header);
// Description
const desc = document.createElement('p');
desc.textContent = this.#operatorDef.description;
details.append(desc);
// Value note
const valueNote = document.createElement('p');
valueNote.innerHTML = this.#operatorDef.needsValue
? 'This operator requires a value after it.'
: 'This operator does not take a value.';
details.append(valueNote);
frag.append(details);
return frag;
}
}
/**
* Non-selectable autocomplete option that shows context about the value being typed.
* Displays info about what value is expected based on the operator.
*/
export class VariableValueContextAutoCompleteOption extends AutoCompleteOption {
/** @type {{ symbol: string, name: string, description: string, needsValue: boolean }} */
#operatorDef;
/** @type {string} */
#currentValue;
/**
* @param {{ symbol: string, name: string, description: string, needsValue: boolean }} operatorDef - The operator definition.
* @param {string} [currentValue=''] - The value currently being typed.
*/
constructor(operatorDef, currentValue = '') {
super('value', '📝');
this.#operatorDef = operatorDef;
this.#currentValue = currentValue;
this.forceFullNameMatch = true;
}
/** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
get operatorDefinition() {
return this.#operatorDef;
}
/**
* Renders the autocomplete list item for this value context.
* @returns {HTMLElement}
*/
renderItem() {
const li = this.makeItem(
'',
'📝',
true, // noSlash
[], // namedArguments
[], // unnamedArguments
'any', // returnType
`${this.#operatorDef.name} (${this.#operatorDef.symbol}) expects a value`,
);
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', 'variable-value-context');
return li;
}
/**
* Renders the details panel for this value context.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
const details = document.createElement('div');
details.classList.add('macro-variable-value-context-details');
// Header
const header = document.createElement('h3');
header.innerHTML = `Value for ${this.#operatorDef.symbol} (${this.#operatorDef.name})`;
details.append(header);
// Description of what value is expected
const desc = document.createElement('p');
desc.textContent = this.#operatorDef.description;
details.append(desc);
// Current value being typed
if (this.#currentValue) {
const currentNote = document.createElement('p');
currentNote.innerHTML = `Currently typing: ${this.#currentValue}`;
details.append(currentNote);
}
// Hint
const hint = document.createElement('p');
hint.classList.add('hint');
hint.innerHTML = 'Type your value and close with }} to complete the macro.';
details.append(hint);
frag.append(details);
return frag;
}
}
/**
* Autocomplete option for closing a scoped macro.
* Suggests {{/macroName}} to close an unclosed scoped macro.
*/
export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
/** @type {string} */
#macroName;
/** @type {string} */
#paddingBefore;
/** @type {string} */
#paddingAfter;
/** @type {boolean} */
#isOptional;
/** @type {number} */
#nestingLevel;
/**
* @param {string} macroName - The name of the macro to close.
* @param {Object} [options] - Optional configuration.
* @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding).
* @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding).
* @param {string} [options.currentPadding=''] - Whitespace the user has already typed after {{.
* @param {boolean} [options.isOptional=false] - Whether this closing tag is for an optional scope.
* @param {number} [options.nestingLevel=0] - Nesting level (0 = innermost).
*/
constructor(macroName, options = {}) {
// The closing tag is what we're suggesting - use /macroName as the name for matching
const closingTag = `/${macroName}`;
super(closingTag, '{/');
this.#macroName = macroName;
this.#paddingBefore = options.paddingBefore ?? '';
this.#paddingAfter = options.paddingAfter ?? '';
this.#isOptional = options.isOptional ?? false;
this.#nestingLevel = options.nestingLevel ?? 0;
// Calculate the replacement offset to replace any existing whitespace the user typed
// This allows us to normalize the whitespace to match the opening tag's style
const currentPadding = options.currentPadding ?? '';
// Negative offset to start replacement earlier (eating the user's whitespace)
this.replacementStartOffset = -currentPadding.length;
// Custom valueProvider to return the correct replacement text
// Includes the target paddingBefore from the opening tag, replacing any user-typed whitespace
this.valueProvider = () => {
// Return: paddingBefore + /macroName + paddingAfter + }}
return `${this.#paddingBefore}/${macroName}${this.#paddingAfter}}}`;
};
// Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
this.makeSelectable = true;
// nameOffset = 2 to skip the {{ prefix in the display for fuzzy highlighting
// The name is /macroName but display shows {{/macroName}}
this.nameOffset = 2;
// Highest priority - closing tags should always appear at the very top
this.sortPriority = 1;
}
/** @returns {string} */
get macroName() {
return this.#macroName;
}
/**
* Renders the autocomplete list item for this closing tag.
* Uses the same structure as other macro options for consistent styling.
* @returns {HTMLElement}
*/
renderItem() {
const li = document.createElement('li');
li.classList.add('item', 'macro-ac-item');
// Type icon (same column as other macros)
const type = document.createElement('span');
type.classList.add('type', 'monospace');
type.textContent = this.typeIcon;
li.append(type);
// Specs container (for fuzzy highlight compatibility)
const specs = document.createElement('span');
specs.classList.add('specs');
// Name element with character spans
const nameEl = document.createElement('span');
nameEl.classList.add('name', 'monospace');
// Display full closing tag like other macros show full syntax
const displayName = `{{/${this.#macroName}}}`;
for (const char of displayName) {
const span = document.createElement('span');
span.textContent = char;
nameEl.append(span);
}
specs.append(nameEl);
li.append(specs);
// Stopgap (spacer for flex layout)
const stopgap = document.createElement('span');
stopgap.classList.add('stopgap');
li.append(stopgap);
// Help text (description)
const help = document.createElement('span');
help.classList.add('help');
const content = document.createElement('span');
content.classList.add('helpContent');
// Build description based on optional status and nesting
if (this.#isOptional) {
const optionalBadge = document.createElement('span');
optionalBadge.classList.add('macro-ac-optional-badge', 'macro-ac-optional-badge-small');
optionalBadge.textContent = 'OPTIONAL';
content.append(optionalBadge);
content.append(' ');
const nestingInfo = this.#nestingLevel > 0 ? ` (nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep)` : '';
content.append(document.createTextNode(`Optionally close {{${this.#macroName}}}${nestingInfo}`));
} else {
content.textContent = `Close the {{${this.#macroName}}} scoped macro.`;
}
help.append(content);
li.append(help);
return li;
}
/**
* Renders the details panel for this closing tag.
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
const details = document.createElement('div');
details.classList.add('macro-closing-tag-details');
// If optional, show badge at the top
if (this.#isOptional) {
const optionalBadge = document.createElement('span');
optionalBadge.classList.add('macro-ac-optional-badge');
optionalBadge.textContent = 'OPTIONAL';
details.append(optionalBadge);
}
// Header
const header = document.createElement('h3');
header.innerHTML = `Close {{${this.#macroName}}}`;
details.append(header);
// Description
const desc = document.createElement('p');
if (this.#isOptional) {
const nestingInfo = this.#nestingLevel > 0 ? ` This scope is nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep.` : '';
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}`;
} else {
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.`;
}
details.append(desc);
frag.append(details);
return frag;
}
}
/**
* Parses the macro text to determine current argument context.
* Handles leading whitespace and flags before the identifier.
*
* @param {string} macroText - The text inside {{ }}, e.g., "roll::1d20" or "!user" or " description ".
* @param {number} cursorOffset - Cursor position within macroText.
* @returns {MacroAutoCompleteContext}
*/
export function parseMacroContext(macroText, cursorOffset) {
let i = 0;
// Skip leading whitespace (but NOT newlines - those stop macro parsing for autocomplete)
while (i < macroText.length && /[ \t]/.test(macroText[i])) {
i++;
}
// Extract flags (special symbols before the identifier)
// Track position after each flag to determine which flag cursor is on
// Special case: `/` followed by identifier chars is a closing tag, not a flag
const flags = [];
const flagEndPositions = []; // Position right after each flag (before any whitespace)
while (i < macroText.length) {
const char = macroText[i];
// Check if this looks like a closing tag: `/` followed by an identifier character
if (char === '/' && i + 1 < macroText.length && /[a-zA-Z/]/.test(macroText[i + 1])) {
// This is a closing tag identifier, not a flag - stop parsing flags
break;
}
if (ValidFlagSymbols.has(char)) {
flags.push(char);
i++;
flagEndPositions.push(i); // Position right after this flag
// Skip whitespace between flags (but NOT newlines - those stop macro parsing for autocomplete)
while (i < macroText.length && /[ \t]/.test(macroText[i])) {
i++;
}
} else {
break;
}
}
// Determine which flag cursor is currently on (if any)
// The "current" flag is the last one typed when cursor is still in the flags area
// This ensures the last typed flag shows at the top of the autocomplete list
let currentFlag = null;
if (flags.length > 0) {
// If cursor is at or after the last flag position but before identifier starts,
// the last flag is the "current" one (just typed)
const lastFlagEnd = flagEndPositions[flagEndPositions.length - 1];
if (cursorOffset >= lastFlagEnd - 1) {
currentFlag = flags[flags.length - 1];
}
}
if (flags.length > 0) {
void onboardingExperimentalMacroEngine('macro flags');
}
// Check for variable shorthand prefix (. or $)
// These trigger variable expression mode instead of regular macro parsing
/** @type {'.'|'$'|null} */
let variablePrefix = null;
let variableName = '';
/** @type {string|null} */
let variableOperator = null;
let variableValue = '';
let isVariableShorthand = false;
let isTypingVariableName = false;
let isTypingOperator = false;
let isTypingValue = false;
let variableNameEnd = i;
const remainingAfterFlags = macroText.slice(i);
if (remainingAfterFlags.startsWith('.') || remainingAfterFlags.startsWith('$')) {
isVariableShorthand = true;
variablePrefix = /** @type {'.'|'$'} */ (remainingAfterFlags[0]);
i++; // Move past the prefix
// Variable names: start with letter, can have hyphens inside, must not end with hyphen
const varNameMatch = macroText.slice(i).match(VARIABLE_SHORTHAND_NAME_PATTERN);
if (varNameMatch) {
variableName = varNameMatch[0];
i += variableName.length;
}
variableNameEnd = i;
// Skip whitespace before operator
while (i < macroText.length && /\s/.test(macroText[i])) {
i++;
}
// Check for operators: ++, --, +=, -=, ||=, ??=, ||, ??, ==, =
// Order matters: longer operators must be checked before shorter ones
// Also track partial operator prefixes for autocomplete
const operatorText = macroText.slice(i);
let hasInvalidTrailingChars = false;
let invalidTrailingChars = '';
let partialOperator = '';
if (operatorText.startsWith('++')) {
variableOperator = '++';
i += 2;
} else if (operatorText.startsWith('--')) {
variableOperator = '--';
i += 2;
} else if (operatorText.startsWith('||=')) {
variableOperator = '||=';
i += 3;
} else if (operatorText.startsWith('??=')) {
variableOperator = '??=';
i += 3;
} else if (operatorText.startsWith('||')) {
variableOperator = '||';
i += 2;
} else if (operatorText.startsWith('??')) {
variableOperator = '??';
i += 2;
} else if (operatorText.startsWith('+=')) {
variableOperator = '+=';
i += 2;
} else if (operatorText.startsWith('-=')) {
variableOperator = '-=';
i += 2;
} else if (operatorText.startsWith('==')) {
variableOperator = '==';
i += 2;
} else if (operatorText.startsWith('!=')) {
variableOperator = '!=';
i += 2;
} else if (operatorText.startsWith('>=')) {
variableOperator = '>=';
i += 2;
} else if (operatorText.startsWith('>')) {
variableOperator = '>';
i += 1;
} else if (operatorText.startsWith('<=')) {
variableOperator = '<=';
i += 2;
} else if (operatorText.startsWith('<')) {
variableOperator = '<';
i += 1;
} else if (operatorText.startsWith('=')) {
variableOperator = '=';
i += 1;
} else if (operatorText.startsWith('+') || operatorText.startsWith('-') || operatorText.startsWith('|') || operatorText.startsWith('?') || operatorText.startsWith('!') || operatorText.startsWith('>') || operatorText.startsWith('<')) {
// Partial operator prefix - user is typing an operator
partialOperator = operatorText[0];
} else if (operatorText.length > 0 && !/^\s/.test(operatorText) && !operatorText.startsWith('}')) {
// There's non-whitespace after the variable name that isn't a valid operator
// This is an invalid trailing character (e.g., $my$ or .var@test)
// Exception: } is the closing brace, not an invalid char
hasInvalidTrailingChars = true;
invalidTrailingChars = operatorText.trim();
}
// Track where the operator ends (for cursor position checks)
const variableOperatorEnd = i;
// Check if operator requires a value
const operatorDef = variableOperator ? VariableOperatorDefinitions.get(variableOperator) : null;
const operatorNeedsValue = operatorDef?.needsValue ?? false;
// If operator requires a value, parse the value
// Do this BEFORE isTypingClosingBrace detection so we can check for } in value area
// let valueStartPos = i;
if (operatorNeedsValue) {
// Skip whitespace after operator
while (i < macroText.length && /\s/.test(macroText[i])) {
i++;
}
// valueStartPos = i;
variableValue = macroText.slice(i).trimEnd();
}
// Detect if typing first closing brace on a variable shorthand
// This happens when operatorText is just "}" or when cursor is beyond content (after }})
let isTypingClosingBrace = false;
if (operatorText.startsWith('}') && !variableOperator) {
// Typing first } on a standalone variable shorthand like {{.Lila}
isTypingClosingBrace = true;
} else if (cursorOffset > macroText.length && !variableOperator) {
// Cursor is after }} on a standalone variable shorthand like {{.Lila}}|
isTypingClosingBrace = true;
} else if (cursorOffset > macroText.length && variableOperator) {
// Cursor is after }} on any operator shorthand like {{.Lila++}}| or {{.Lila+=4}}|
isTypingClosingBrace = true;
} else if (cursorOffset >= macroText.length && variableOperator && !operatorNeedsValue) {
// Cursor at end of complete operator (++ or --) like {{.Lila++ or {{.Lila++ (with trailing space)
isTypingClosingBrace = true;
} else if (cursorOffset >= macroText.length && !variableOperator && variableName.length > 0) {
// Cursor at end of standalone variable (with or without trailing whitespace) like {{.Lila or {{ .Lila
isTypingClosingBrace = true;
} else if (operatorNeedsValue && variableValue.length > 0 && variableValue.endsWith('}')) {
// Typing first } after a value like {{.Lila+=4}
isTypingClosingBrace = true;
// Strip the } from the value
variableValue = variableValue.slice(0, -1);
} else if (operatorNeedsValue && cursorOffset >= macroText.length && variableValue.length > 0) {
// Cursor at end after typing a value (including trailing whitespace) like {{.Lila+=4
// This means the shorthand is "complete" and ready to close
isTypingClosingBrace = true;
}
// Determine cursor position context for autocomplete
// Note: isTypingClosingBrace takes precedence - if we're typing a closing brace,
// we don't want to show operator suggestions, just the current state
const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;
if (cursorOffset < prefixEnd) {
// Cursor is before the prefix - still in flags area conceptually
isTypingVariableName = false;
} else if (cursorOffset <= variableNameEnd) {
// Cursor is in the variable name area (including at the end)
isTypingVariableName = true;
} else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars && !isTypingClosingBrace) {
// Cursor is after variable name but no operator yet (and no invalid chars)
// This includes partial operator prefixes like '+', '-', '|', '?', '>', '<'
// But NOT when typing a closing brace - that takes precedence
isTypingOperator = true;
} else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) {
// Short operator that could be prefix of longer one (e.g., > could become >=)
// But ONLY if cursor is still in the operator area, not past it into value
isTypingOperator = true;
} else if (operatorNeedsValue) {
// Operator that requires value - cursor is in value area
isTypingValue = true;
}
// For ++ and --, the operator is complete (no value needed)
// For invalid trailing chars, none of the typing flags will be true
const isOperatorComplete = (variableOperator === '++' || variableOperator === '--');
void onboardingExperimentalMacroEngine('variable shorthands');
// Return early for variable shorthand - different structure than regular macros
return {
fullText: macroText,
cursorOffset,
paddingBefore: macroText.match(/^\s+/)?.[0] ?? '',
identifier: '', // No macro identifier for variable shorthand
identifierStart: -1,
isInFlagsArea: false,
flags,
currentFlag,
args: [],
currentArgIndex: -1,
isTypingSeparator: false,
isTypingClosingBrace,
hasSpaceAfterIdentifier: false,
hasSpaceArgContent: false,
separatorCount: 0,
// Variable shorthand specific properties
isVariableShorthand,
variablePrefix,
variableName,
variableNameEnd,
variableOperator,
variableOperatorEnd,
variableValue,
isTypingVariableName,
isTypingOperator,
isTypingValue,
isOperatorComplete,
hasInvalidTrailingChars,
invalidTrailingChars,
partialOperator,
};
}
// Regular macro parsing (not variable shorthand)
// Now parse the identifier and arguments starting from position i
const remainingText = macroText.slice(i);
const parts = [];
/** @type {{ start: number, end: number }[]} */
const separatorPositions = []; // Track positions of :: separators
let currentPart = '';
let partStart = i;
let j = 0;
// Track nesting depth to skip :: inside nested macros
let nestedDepth = 0;
// Track if we've seen a :: separator - newlines before first :: should stop parsing
let hasSeenSeparator = false;
// Track if we broke early (e.g., at a newline)
let brokeEarly = false;
while (j < remainingText.length) {
// Before the first :: separator, newlines should stop parsing
// This prevents text on the next line from being considered part of the identifier/space-arg
if (!hasSeenSeparator && nestedDepth === 0 && (remainingText[j] === '\n' || remainingText[j] === '\r')) {
// Stop parsing here - don't include the newline or anything after
brokeEarly = true;
break;
}
// Track nested macro braces
if (remainingText[j] === '{' && remainingText[j + 1] === '{') {
nestedDepth++;
currentPart += '{{';
j += 2;
continue;
}
if (remainingText[j] === '}' && remainingText[j + 1] === '}') {
nestedDepth = Math.max(0, nestedDepth - 1);
currentPart += '}}';
j += 2;
continue;
}
// Only count :: as separator when not inside nested macros
if (nestedDepth === 0 && remainingText[j] === ':' && remainingText[j + 1] === ':') {
parts.push({ text: currentPart, start: partStart, end: i + j });
separatorPositions.push({ start: i + j, end: i + j + 2 });
currentPart = '';
j += 2;
partStart = i + j;
hasSeenSeparator = true;
} else {
currentPart += remainingText[j];
j++;
}
}
// Push the last part - use correct end position if we broke early.
// If we broke early (at a newline) AND cursor is past that point, don't push -
// this filters out text on the next line from being considered part of this macro.
// But if we didn't break early (cursor at end of closed macro), always push.
const lastPartEnd = brokeEarly ? i + j : macroText.length;
const shouldPushLastPart = !brokeEarly || cursorOffset <= lastPartEnd;
if (shouldPushLastPart) {
parts.push({ text: currentPart, start: partStart, end: lastPartEnd });
}
// Determine if cursor is in the flags area (at or before identifier starts)
const identifierStartPos = parts[0]?.start ?? i;
const isInFlagsArea = cursorOffset <= identifierStartPos;
// Check if cursor is on a partial separator (single ':' that might become '::')
const isTypingSeparator = remainingText.length > 0 &&
cursorOffset > identifierStartPos &&
macroText[cursorOffset - 1] === ':' &&
macroText[cursorOffset] !== ':' &&
(cursorOffset < 2 || macroText[cursorOffset - 2] !== ':');
// Parse identifier and space-separated argument from the first part
// "getvar myvar" -> identifier="getvar", spaceArg="myvar"
// "setvar " -> identifier="setvar", spaceArg="" (just whitespace, no content yet)
const firstPartText = parts[0]?.text || '';
const trimmedFirstPart = firstPartText.trimStart();
const firstSpaceInIdentifier = trimmedFirstPart.search(/\s/);
let identifierOnly;
let spaceArgText = '';
//let spaceArgStart = -1;
let hasSpaceAfterIdentifier = false;
if (firstSpaceInIdentifier > 0 && separatorPositions.length === 0) {
// There's whitespace inside the first part - split identifier from space-arg
identifierOnly = trimmedFirstPart.slice(0, firstSpaceInIdentifier);
const afterIdentifier = trimmedFirstPart.slice(firstSpaceInIdentifier);
// Check if there's actual content after the whitespace (not just spaces or ::)
const contentAfterSpace = afterIdentifier.trimStart();
hasSpaceAfterIdentifier = afterIdentifier.length > 0; // Has at least a space
if (contentAfterSpace.length > 0 && !contentAfterSpace.startsWith(':')) {
// There's actual argument content after the space
spaceArgText = contentAfterSpace;
//spaceArgStart = identifierStartPos + firstSpaceInIdentifier + (afterIdentifier.length - contentAfterSpace.length);
}
} else {
identifierOnly = trimmedFirstPart.trimEnd();
}
// Calculate identifier end position (for space-after-identifier detection)
const identifierEndPos = identifierStartPos + (firstPartText.length - firstPartText.trimStart().length) + identifierOnly.length;
// Determine which part the cursor is in
let currentArgIndex = -1;
// Only consider being in an argument if we've passed a separator
if (separatorPositions.length > 0) {
// Find which argument we're in based on separator positions
for (let sepIdx = 0; sepIdx < separatorPositions.length; sepIdx++) {
const sep = separatorPositions[sepIdx];
if (cursorOffset >= sep.end) {
// We're past this separator, so we're in at least this argument
currentArgIndex = sepIdx;
}
}
} else if (spaceArgText.length > 0 || (hasSpaceAfterIdentifier && cursorOffset > identifierEndPos)) {
// Space-separated arg: either has content, or cursor is past identifier+space
currentArgIndex = 0;
}
// If typing a separator, we're still on identifier/previous arg, not the next one
if (isTypingSeparator) {
currentArgIndex = -1;
}
const leftPadding = macroText.match(/^\s+/)?.[0] ?? '';
if (leftPadding) {
void onboardingExperimentalMacroEngine('leading whitespace');
}
// Clean identifier: strip trailing colons (for partial :: typing)
// Also strip trailing single } (for partial }} typing) - but only if no separators/args
let cleanIdentifier = identifierOnly.replace(/:+$/, '');
let isTypingClosingBrace = false;
if (separatorPositions.length === 0 && !hasSpaceAfterIdentifier && cleanIdentifier.endsWith('}')) {
// Typing first closing brace on a standalone macro like {{char}
cleanIdentifier = cleanIdentifier.slice(0, -1);
isTypingClosingBrace = true;
}
// Build args array - include space-separated arg if present
// Trim args like the macro engine does
let args = parts.slice(1).map(p => p.text.trim());
if (spaceArgText.length > 0) {
args = [spaceArgText, ...args];
}
return {
fullText: macroText,
cursorOffset,
paddingBefore: leftPadding,
identifier: cleanIdentifier,
identifierStart: identifierStartPos,
isInFlagsArea,
flags,
currentFlag,
args,
currentArgIndex,
isTypingSeparator,
isTypingClosingBrace,
hasSpaceAfterIdentifier,
hasSpaceArgContent: spaceArgText.length > 0,
separatorCount: separatorPositions.length,
// Default variable shorthand properties (not a variable shorthand)
isVariableShorthand: false,
variablePrefix: null,
variableName: '',
variableNameEnd: null,
variableOperator: null,
variableOperatorEnd: null,
variableValue: '',
isTypingVariableName: false,
isTypingOperator: false,
isTypingValue: false,
};
}
/**
* A simple, generic autocomplete option for displaying basic items with name, symbol, and description.
* Useful for simple options like inversion markers, prefixes, etc. without needing a full custom class.
*
* @extends AutoCompleteOption
*/
export class SimpleAutoCompleteOption extends AutoCompleteOption {
/** @type {string} */
#description;
/** @type {string|null} */
#detailedDescription;
/**
* @param {Object} config - Configuration for the option.
* @param {string} config.name - The option name/key (used for matching).
* @param {string} [config.symbol=' '] - Icon/symbol shown in the type column.
* @param {string} [config.description=''] - Short description shown inline.
* @param {string} [config.detailedDescription] - Longer description for details panel (supports HTML). Falls back to description if not provided.
* @param {string} [config.type='simple'] - Type identifier for CSS/data attributes.
*/
constructor({ name, symbol = ' ', description = '', detailedDescription = null, type = 'simple' }) {
super(name, symbol, type);
this.#description = description;
this.#detailedDescription = detailedDescription;
}
/** @returns {string} */
get description() {
return this.#description;
}
/** @returns {string} */
get detailedDescription() {
return this.#detailedDescription ?? this.#description;
}
/**
* @returns {HTMLElement}
*/
renderItem() {
const li = document.createElement('li');
li.classList.add('item');
li.setAttribute('data-name', this.name);
li.setAttribute('data-option-type', this.type);
// Type icon
const typeSpan = document.createElement('span');
typeSpan.classList.add('type', 'monospace');
typeSpan.textContent = this.typeIcon;
li.append(typeSpan);
// Name
const specs = document.createElement('span');
specs.classList.add('specs');
const nameSpan = document.createElement('span');
nameSpan.classList.add('name', 'monospace');
this.name.split('').forEach(char => {
const span = document.createElement('span');
span.textContent = char;
nameSpan.append(span);
});
specs.append(nameSpan);
li.append(specs);
// Stopgap
const stopgap = document.createElement('span');
stopgap.classList.add('stopgap');
li.append(stopgap);
// Help/description
const help = document.createElement('span');
help.classList.add('help');
const content = document.createElement('span');
content.classList.add('helpContent');
content.textContent = this.#description;
help.append(content);
li.append(help);
return li;
}
/**
* @returns {DocumentFragment}
*/
renderDetails() {
const frag = document.createDocumentFragment();
// Header with name
const specs = document.createElement('div');
specs.classList.add('specs');
const nameDiv = document.createElement('div');
nameDiv.classList.add('name', 'monospace');
nameDiv.textContent = this.name;
specs.append(nameDiv);
frag.append(specs);
// Description
if (this.detailedDescription) {
const helpDiv = document.createElement('div');
helpDiv.classList.add('help');
helpDiv.innerHTML = this.detailedDescription;
frag.append(helpDiv);
}
return frag;
}
}