Blame Raw
· · · 1217 lines (55.4 KB)
0 contributors
1/**
2 * Shared utilities for macro autocomplete functionality.
3 * Used by both SlashCommandParser (for slash command context) and MacroAutoComplete (for free text).
4 *
5 * This module extracts common macro autocomplete logic to avoid duplication and ensure
6 * consistent behavior across all contexts where macro autocomplete is used.
7 */
8
9import { AutoCompleteNameResult } from './AutoCompleteNameResult.js';
10import {
11 EnhancedMacroAutoCompleteOption,
12 MacroFlagAutoCompleteOption,
13 MacroClosingTagAutoCompleteOption,
14 VariableShorthandAutoCompleteOption,
15 VariableShorthandDefinitions,
16 VariableNameAutoCompleteOption,
17 VariableOperatorAutoCompleteOption,
18 VariableValueContextAutoCompleteOption,
19 VariableOperatorDefinitions,
20 isValidVariableShorthandName,
21 parseMacroContext,
22 SimpleAutoCompleteOption,
23} from './EnhancedMacroAutoCompleteOption.js';
24import { macros as macroSystem } from '../macros/macro-system.js';
25import { MacroFlagDefinitions, MacroFlagType } from '../macros/engine/MacroFlags.js';
26import { MacroParser } from '../macros/engine/MacroParser.js';
27import { MacroCstWalker } from '../macros/engine/MacroCstWalker.js';
28import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnostics.js';
29import { chat_metadata } from '/script.js';
30import { extension_settings } from '../extensions.js';
31
32/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').MacroAutoCompleteContext} MacroAutoCompleteContext */
33/** @typedef {import('./EnhancedMacroAutoCompleteOption.js').EnhancedMacroAutoCompleteOptions} EnhancedMacroAutoCompleteOptions */
34/** @typedef {import('./AutoCompleteOption.js').AutoCompleteOption} AutoCompleteOption */
35/*** @typedef {import('../macros/macro-system.js').MacroDefinition} MacroDefinition */
36
37/**
38 * @typedef {Object} MacroInfo
39 * @property {number} start - Start position of the macro in text (at first {)
40 * @property {number} end - End position of the macro in text (after last })
41 * @property {string} content - The content between {{ and }}
42 */
43
44/**
45 * @typedef {Object} UnclosedScope
46 * @property {string} name - Macro name
47 * @property {number} startOffset - Start position in text
48 * @property {number} endOffset - End position of opening tag
49 * @property {string} paddingBefore - Whitespace before macro name
50 * @property {string} paddingAfter - Whitespace after macro content
51 */
52
53/**
54 * @typedef {Object} BuildMacroAutoCompleteOptions
55 * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro
56 * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor
57 * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes
58 * @property {boolean} [isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space)
59 */
60
61/** @typedef {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption|SimpleAutoCompleteOption)} AnyMacroAutoCompleteOption */
62
63/**
64 * Finds unclosed scoped macros in the text up to cursor position.
65 * Uses the MacroParser and MacroCstWalker for accurate analysis.
66 *
67 * @param {string} textUpToCursor - The document text up to the cursor position.
68 * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
69 */
70export function findUnclosedScopes(textUpToCursor) {
71 if (!textUpToCursor) return [];
72
73 try {
74 // Parse the document to get the CST
75 const { cst } = MacroParser.parseDocument(textUpToCursor);
76 if (!cst) return [];
77
78 // Use the CST walker to find unclosed scopes
79 return MacroCstWalker.findUnclosedScopes({ text: textUpToCursor, cst });
80 } catch {
81 // If parsing fails (incomplete input), fall back to simple regex approach
82 return findUnclosedScopesRegex(textUpToCursor);
83 }
84}
85
86/**
87 * Fallback regex-based approach for finding unclosed scopes.
88 * Used when the parser fails on incomplete input.
89 *
90 * @param {string} text - The text to analyze.
91 * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
92 */
93export function findUnclosedScopesRegex(text) {
94 // Regex to find macro openings and closings, capturing whitespace padding
95 // Group 1: padding after {{, Group 2: optional /, Group 3: macro name
96 const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g;
97 const stack = [];
98
99 let match;
100 while ((match = macroPattern.exec(text)) !== null) {
101 const paddingBefore = match[1];
102 const isClosing = match[2] === '/';
103 const name = match[3];
104
105 if (isClosing) {
106 // Find matching opener in stack (case-insensitive)
107 // When closing an outer scope, all inner unclosed scopes are implicitly closed
108 const matchIndex = stack.findLastIndex(s => s.name.toLowerCase() === name.toLowerCase());
109 if (matchIndex !== -1) {
110 // Pop everything from matchIndex to end (inclusive) - closes the matched scope and all nested ones
111 stack.splice(matchIndex);
112 }
113 } else {
114 // Check if macro can accept scoped content
115 // List-arg macros don't support scopes - they accept arbitrary inline args instead
116 const macroDef = macroSystem.registry.getPrimaryMacro(name);
117 if (macroDef && macroDef.maxArgs > 0 && macroDef.list === null) {
118 // Try to find closing }} to extract trailing whitespace
119 let paddingAfter = '';
120 const afterMatch = text.slice(match.index + match[0].length);
121 const closingMatch = afterMatch.match(/^[^}]*?(\s*)\}\}/);
122 if (closingMatch) {
123 paddingAfter = closingMatch[1];
124 }
125
126 stack.push({
127 name,
128 startOffset: match.index,
129 endOffset: match.index + match[0].length,
130 paddingBefore,
131 paddingAfter,
132 });
133 }
134 }
135 }
136
137 return stack;
138}
139
140/**
141 * Checks if a scoped macro's scope content is optional (i.e., all required args are already filled).
142 * Used to determine whether to show the scope hint by default or only when forced.
143 *
144 * @param {UnclosedScope} scope - The unclosed scope info.
145 * @param {string} textUpToCursor - The text up to cursor to parse the macro content.
146 * @returns {boolean} - True if the scope content is optional.
147 */
148function isScopeOptional(scope, textUpToCursor) {
149 const def = macroSystem.registry.getPrimaryMacro(scope.name);
150 if (!def) {
151 // Unknown macro - treat scope as required (show hint)
152 return false;
153 }
154
155 // Find the macro's closing }} to extract its content
156 const openingEnd = textUpToCursor.indexOf('}}', scope.startOffset);
157 if (openingEnd === -1) {
158 // Macro not closed yet - can't determine
159 return false;
160 }
161
162 // Extract content between {{ and }} to count arguments
163 const macroContent = textUpToCursor.slice(scope.startOffset + 2, openingEnd);
164 const context = parseMacroContext(macroContent, macroContent.length);
165
166 // Count current arguments (including space-separated arg if present)
167 const currentArgCount = context.args.length;
168
169 // The scoped content would be the next argument (currentArgCount + 1)
170 // Scope is optional if:
171 // 1. Current args already meet minArgs requirement, AND
172 // 2. Adding one more (scope) would still be <= maxArgs
173 const wouldBeArgIndex = currentArgCount; // 0-indexed
174 const scopeIsOptional = currentArgCount >= def.minArgs && wouldBeArgIndex < def.maxArgs;
175
176 // Check if the argument at wouldBeArgIndex is marked as optional in the definition
177 if (def.unnamedArgDefs && def.unnamedArgDefs[wouldBeArgIndex]) {
178 return def.unnamedArgDefs[wouldBeArgIndex].optional === true;
179 }
180
181 // If no explicit arg definition, use the min/max args logic
182 return scopeIsOptional;
183}
184
185/**
186 * Filters unclosed scopes to exclude those with optional scope content.
187 * Used when autocomplete is not force-triggered (Ctrl+Space).
188 *
189 * @param {UnclosedScope[]} unclosedScopes - The unclosed scopes to filter.
190 * @param {string} textUpToCursor - The text up to cursor.
191 * @param {boolean} isForced - Whether autocomplete was force-triggered.
192 * @returns {UnclosedScope[]} - Filtered scopes (excludes optional scopes unless forced).
193 */
194function filterOptionalScopes(unclosedScopes, textUpToCursor, isForced) {
195 if (isForced) {
196 // When forced, show all scopes including optional ones
197 return unclosedScopes;
198 }
199
200 // Filter out scopes where the scope content is optional
201 return unclosedScopes.filter(scope => !isScopeOptional(scope, textUpToCursor));
202}
203
204/**
205 * Builds autocomplete options for variable shorthand syntax (.varName or $varName).
206 * @param {MacroAutoCompleteContext} context
207 * @param {Object} [opts] - Optional configuration.
208 * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).
209 * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.
210 * @returns {AnyMacroAutoCompleteOption[]}
211 */
212export function buildVariableShorthandOptions(context, opts = {}) {
213 const { forIfCondition = false, paddingAfter = '' } = opts;
214 /** @type {AnyMacroAutoCompleteOption[]} */
215 const options = [];
216
217 const isLocal = context.variablePrefix === '.';
218 const scope = isLocal ? 'local' : 'global';
219
220
221 // Always show the typed variable prefix as a non-completable option (like flags do)
222 // This allows the details panel to show information about the prefix
223 const prefixDef = VariableShorthandDefinitions.get(context.variablePrefix);
224 if (prefixDef) {
225 const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
226 prefixOption.valueProvider = () => ''; // Already typed, don't re-insert
227 prefixOption.makeSelectable = false;
228 prefixOption.sortPriority = 1; // Show at top
229 prefixOption.matchProvider = () => true; // Always show regardless of filtering
230 options.push(prefixOption);
231 }
232
233 // If typing the variable name, suggest existing variables
234 // Get existing variable names from the appropriate scope
235 // Filter to only include names that are valid for shorthand syntax
236 const existingVariables = getVariableNames(scope)
237 .filter(name => isValidVariableShorthandName(name));
238
239 // Check if the typed variable name exactly matches an existing variable
240 const variableNameMatchesExisting = context.variableName.length > 0 && existingVariables.includes(context.variableName);
241
242 if (context.isTypingVariableName) {
243 // Add existing variables that match the typed name
244 for (const varName of existingVariables) {
245 const option = new VariableNameAutoCompleteOption(varName, scope, false);
246 // Not selectable if it matches the typed name
247 if (varName === context.variableName) {
248 option.valueProvider = () => '';
249 option.makeSelectable = false;
250 }
251 // For {{if}} condition, provide full value with closing braces
252 if (forIfCondition) {
253 option.valueProvider = () => `${varName}${paddingAfter}}}`; // No variable prefix, as that has been written and committed already.
254 option.makeSelectable = true;
255 }
256 // Variables matching the typed prefix get higher priority
257 option.sortPriority = varName.startsWith(context.variableName) ? 3 : 10;
258 options.push(option);
259 }
260
261 // If typing a name that doesn't exist, offer to create a new variable
262 // But if the name is invalid for shorthand syntax, show a warning instead
263 if (context.variableName.length > 0 && !existingVariables.includes(context.variableName)) {
264 const isInvalid = !isValidVariableShorthandName(context.variableName);
265 const newVarOption = new VariableNameAutoCompleteOption(context.variableName, scope, true, isInvalid);
266 newVarOption.sortPriority = isInvalid ? 2 : 4; // Invalid names get higher priority to show warning
267 if (isInvalid) {
268 // Make it non-selectable since it can't be used
269 newVarOption.valueProvider = () => '';
270 newVarOption.makeSelectable = false;
271 } else if (forIfCondition) {
272 // For {{if}} condition, provide full value with closing braces
273 newVarOption.valueProvider = () => `${context.variablePrefix}${context.variableName}${paddingAfter}}}`;
274 newVarOption.makeSelectable = true;
275 }
276 options.push(newVarOption);
277 }
278
279 // If the typed variable name exactly matches an existing variable, also show operators
280 // This allows users to see available operators without having to type a space first
281 if (variableNameMatchesExisting) {
282 for (const [, operatorDef] of VariableOperatorDefinitions) {
283 const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
284 opOption.sortPriority = 6; // Lower priority than variable suggestions
285 opOption.matchProvider = () => true; // Always show
286 // IMPORTANT: Operators should INSERT after variable name, not replace it
287 // Use replacementStartOffset to shift insertion point past the variable name
288 opOption.replacementStartOffset = context.variableName.length;
289 options.push(opOption);
290 }
291 }
292 }
293
294 // If there are invalid trailing characters after the variable name, show a warning
295 if (context.hasInvalidTrailingChars) {
296 // Show the full invalid name (variableName + invalidTrailingChars) with a warning
297 const fullInvalidName = context.variableName + (context.invalidTrailingChars || '');
298 const invalidOption = new VariableNameAutoCompleteOption(
299 fullInvalidName,
300 scope,
301 false,
302 true, // isInvalidName - triggers warning display
303 );
304 invalidOption.valueProvider = () => ''; // Don't insert anything
305 invalidOption.makeSelectable = false;
306 invalidOption.sortPriority = 2;
307 invalidOption.matchProvider = () => true; // Always show
308 options.push(invalidOption);
309 // Return early - don't show operators when syntax is invalid
310 return options;
311 }
312
313 // If ready for operator (after variable name), suggest operators
314 if (context.isTypingOperator) {
315 // Show the current variable name as context (already typed)
316 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
317 varNameOption.valueProvider = () => ''; // Already typed, don't re-insert
318 varNameOption.makeSelectable = false;
319 varNameOption.sortPriority = 2;
320 varNameOption.matchProvider = () => true; // Always show
321 options.push(varNameOption);
322
323 // Then show available operators, filtered by partial prefix if any
324 // Also filter by current complete operator to show longer variants (e.g., > shows >=)
325 const partialOp = context.partialOperator || '';
326 const currentOp = context.variableOperator || '';
327 const filterPrefix = partialOp || currentOp;
328 for (const [, operatorDef] of VariableOperatorDefinitions) {
329 // Filter by operator prefix if user is typing one
330 // This allows typing ">" to show both ">" and ">="
331 if (filterPrefix && !operatorDef.symbol.startsWith(filterPrefix)) {
332 continue;
333 }
334 const opOption = new VariableOperatorAutoCompleteOption(operatorDef);
335 // Exact match gets higher priority
336 opOption.sortPriority = operatorDef.symbol === currentOp ? 4 : 5;
337 // Already-typed operator is non-selectable
338 if (operatorDef.symbol === currentOp) {
339 opOption.valueProvider = () => '';
340 opOption.makeSelectable = false;
341 }
342 // Always match operators when showing operator suggestions
343 opOption.matchProvider = () => true;
344 options.push(opOption);
345 }
346 }
347
348 // If typing value (after = or +=), no autocomplete needed - freeform text
349 // But we show the current context for reference (greyed out, non-selectable)
350 if (context.isTypingValue && !context.isTypingOperator && !context.isTypingClosingBrace) {
351 // Show the current variable name as context (non-selectable)
352 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
353 varNameOption.valueProvider = () => ''; // Context only
354 varNameOption.makeSelectable = false;
355 varNameOption.sortPriority = 2;
356 varNameOption.matchProvider = () => true; // Always show
357 options.push(varNameOption);
358
359 // Show the operator that was used (non-selectable)
360 if (context.variableOperator) {
361 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
362 if (opDef) {
363 const opOption = new VariableOperatorAutoCompleteOption(opDef);
364 opOption.valueProvider = () => ''; // Already typed
365 opOption.makeSelectable = false;
366 opOption.sortPriority = 3;
367 opOption.matchProvider = () => true; // Always show
368 options.push(opOption);
369
370 // Show value context info (non-selectable)
371 const valueOption = new VariableValueContextAutoCompleteOption(opDef, context.variableValue);
372 valueOption.valueProvider = () => ''; // Context only
373 valueOption.makeSelectable = false;
374 valueOption.sortPriority = 4;
375 valueOption.matchProvider = () => true; // Always show
376 options.push(valueOption);
377 }
378 }
379 }
380
381 // If operator is complete (++ or --), show context without value input (non-selectable)
382 if (context.isOperatorComplete && !context.isTypingOperator) {
383 // Show the current variable name as context (non-selectable)
384 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
385 varNameOption.valueProvider = () => ''; // Context only
386 varNameOption.makeSelectable = false;
387 varNameOption.sortPriority = 2;
388 varNameOption.matchProvider = () => true; // Always show
389 options.push(varNameOption);
390
391 // Show the operator that was used (non-selectable)
392 if (context.variableOperator) {
393 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
394 if (opDef) {
395 const opOption = new VariableOperatorAutoCompleteOption(opDef);
396 opOption.valueProvider = () => ''; // Already typed
397 opOption.makeSelectable = false;
398 opOption.sortPriority = 3;
399 opOption.matchProvider = () => true; // Always show
400 options.push(opOption);
401 }
402 }
403 }
404
405 // If typing closing brace on a variable shorthand (without operator), show the current state
406 // This handles cases like {{.Lila} or {{.Lila}}| where we want to show what was typed
407 if (context.isTypingClosingBrace && !context.isOperatorComplete && !context.isTypingOperator && !context.isTypingValue) {
408 // Show the current variable name as context (non-selectable)
409 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
410 varNameOption.valueProvider = () => ''; // Context only
411 varNameOption.makeSelectable = false;
412 varNameOption.sortPriority = 2;
413 varNameOption.matchProvider = () => true; // Always show
414 options.push(varNameOption);
415 }
416
417 // If typing closing brace after a value operator (like {{.Lila+=4}} or {{.Lila+=4}),
418 // show the full context (variable + operator + value)
419 if (context.isTypingClosingBrace && context.variableOperator && context.isTypingValue) {
420 // Show the current variable name as context (non-selectable)
421 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
422 varNameOption.valueProvider = () => ''; // Context only
423 varNameOption.makeSelectable = false;
424 varNameOption.sortPriority = 2;
425 varNameOption.matchProvider = () => true; // Always show
426 options.push(varNameOption);
427
428 // Show the operator that was used (non-selectable)
429 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
430 if (opDef) {
431 const opOption = new VariableOperatorAutoCompleteOption(opDef);
432 opOption.valueProvider = () => ''; // Already typed
433 opOption.makeSelectable = false;
434 opOption.sortPriority = 3;
435 opOption.matchProvider = () => true; // Always show
436 options.push(opOption);
437
438 // Show value context info (non-selectable)
439 const valueOption = new VariableValueContextAutoCompleteOption(opDef, context.variableValue);
440 valueOption.valueProvider = () => ''; // Context only
441 valueOption.makeSelectable = false;
442 valueOption.sortPriority = 4;
443 valueOption.matchProvider = () => true; // Always show
444 options.push(valueOption);
445 }
446 }
447
448 return options;
449}
450
451/**
452 * Builds enhanced macro autocomplete options from the MacroRegistry.
453 * When in the flags area (before identifier), includes flag options.
454 * When typing arguments (after ::), prioritizes the exact macro match.
455 * @param {MacroAutoCompleteContext} context
456 * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
457 * @param {Object} [opts] - Additional options.
458 * @param {boolean} [opts.isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space).
459 * @returns {AnyMacroAutoCompleteOption[]}
460 */
461export function buildEnhancedMacroOptions(context, textUpToCursor, { isForced = false } = {}) {
462 /** @type {AnyMacroAutoCompleteOption[]} */
463 const options = [];
464
465 if (context.isVariableShorthand) {
466 return buildVariableShorthandOptions(context);
467 }
468
469 // Check for unclosed scoped macros and suggest closing tags
470 // Iterate from innermost to outermost, adding optional scopes and stopping at first required scope
471 const unclosedScopes = findUnclosedScopes(textUpToCursor);
472 if (unclosedScopes.length > 0) {
473 let firstRequiredPriority = 1; // Priority for the first required (non-optional) scope
474 let optionalPriority = 3; // Lower priority for optional scopes
475 let foundRequired = false;
476 let elseOptionAdded = false;
477
478 // Iterate from innermost (last) to outermost (first)
479 for (let i = unclosedScopes.length - 1; i >= 0; i--) {
480 const scope = unclosedScopes[i];
481 const isOptional = isScopeOptional(scope, textUpToCursor);
482 const nestingLevel = unclosedScopes.length - 1 - i; // 0 = innermost
483
484 // If we've already found a required scope, stop adding more
485 if (foundRequired && !isOptional) break;
486
487 const closingOption = new MacroClosingTagAutoCompleteOption(scope.name, {
488 paddingBefore: scope.paddingBefore,
489 paddingAfter: scope.paddingAfter,
490 currentPadding: context.paddingBefore,
491 isOptional: isOptional,
492 nestingLevel: nestingLevel,
493 });
494
495 if (isOptional) {
496 closingOption.sortPriority = optionalPriority++;
497 } else {
498 // First required scope gets top priority
499 closingOption.sortPriority = firstRequiredPriority;
500 foundRequired = true;
501 }
502
503 options.push(closingOption);
504
505 // If inside a scoped {{if}}, also suggest {{else}} (only once, for innermost if)
506 if (!elseOptionAdded && scope.name === 'if') {
507 const macroDef = macroSystem.registry.getPrimaryMacro('else');
508 const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
509 elseOption.sortPriority = 2;
510 options.push(elseOption);
511 elseOptionAdded = true;
512 }
513
514 // Stop once we've added a required scope
515 if (foundRequired) break;
516 }
517 }
518
519 // If cursor is in the flags area (before identifier starts), include flag options
520 if (context.isInFlagsArea) {
521 // Build flag options with priority-based sorting
522 // Last typed flag has highest priority (1), other flags have lower priority (10)
523 // Already-typed flags (except last) are hidden from the list
524 const lastTypedFlag = context.flags.length > 0 ? context.flags[context.flags.length - 1] : null;
525
526 // Add last typed flag with high priority (so it appears at top)
527 if (lastTypedFlag) {
528 const lastFlagDef = MacroFlagDefinitions.get(lastTypedFlag);
529 if (lastFlagDef) {
530 const lastFlagOption = new MacroFlagAutoCompleteOption(lastFlagDef);
531 // Mark as already typed - valueProvider returns empty so it doesn't re-insert
532 lastFlagOption.valueProvider = () => '';
533 lastFlagOption.makeSelectable = false;
534 // High priority to appear at top (after closing tags at 1)
535 lastFlagOption.sortPriority = 2;
536 options.push(lastFlagOption);
537 }
538 }
539
540 // Add flags that haven't been typed yet (skip already-typed ones except last)
541 for (const [symbol, flagDef] of MacroFlagDefinitions) {
542 // Skip the last typed flag (already added above) and other already-typed flags
543 if (context.flags.includes(symbol)) {
544 continue;
545 }
546 const flagOption = new MacroFlagAutoCompleteOption(flagDef);
547
548 // Define whether this flag is selectable (and at the top), based on being implemented, and closing actually being relevant
549 let isSelectable = flagDef.implemented;
550 if (flagDef.type === MacroFlagType.CLOSING_BLOCK && !unclosedScopes.length) isSelectable = false;
551 if (!isSelectable) {
552 flagOption.valueProvider = () => '';
553 flagOption.makeSelectable = false;
554 }
555 // Normal flag priority
556 flagOption.sortPriority = isSelectable ? 10 : 12;
557 options.push(flagOption);
558 }
559
560 // Add variable shorthand prefix options (. for local, $ for global)
561 // These allow users to type variable shorthands instead of macro names
562 for (const [, varShorthandDef] of VariableShorthandDefinitions) {
563 const varOption = new VariableShorthandAutoCompleteOption(varShorthandDef);
564 varOption.sortPriority = 8; // Between implemented flags (10) and unimplemented (12)
565 options.push(varOption);
566 }
567 }
568
569 // Get all macros from the registry (excluding hidden aliases)
570 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
571
572 // If we're typing arguments (after ::), only show the context to the matching macro
573 // Also treat typing closing brace the same way - show details for matching macro
574 const isTypingArgs = context.currentArgIndex >= 0;
575 const isTypingClosingBrace = context.isTypingClosingBrace ?? false;
576 const shouldShowMatchingMacroDetails = isTypingArgs || isTypingClosingBrace;
577
578 // Check if we're inside a scoped {{if}} for {{else}} selectability
579 const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');
580
581 // Track if any macro matches the identifier (for "no match" message)
582 let hasMatchingMacro = false;
583
584 for (const macro of allMacros) {
585 // Check if this macro matches the typed identifier
586 const isExactMatch = macro.name === context.identifier;
587 const isAliasMatch = macro.aliasOf === context.identifier;
588
589 if (isExactMatch || isAliasMatch) {
590 hasMatchingMacro = true;
591 }
592
593 // Only pass context to the macro that matches the identifier being typed
594 // This ensures argument hints only show for the relevant macro
595 /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */
596 let macroContext = (isExactMatch || isAliasMatch) ? context : null;
597
598 // If no context, we pass some options for additional details though
599 if (!macroContext) {
600 macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({
601 paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed
602 flags: context.flags,
603 currentFlag: context.currentFlag,
604 fullText: context.fullText,
605 });
606 }
607
608 const option = new EnhancedMacroAutoCompleteOption(macro, macroContext);
609
610 // {{else}} is only selectable inside a scoped {{if}} block
611 // Outside of {{if}}, it should appear in the list but not be tab-completable
612 if (macro.name === 'else' && !isInsideScopedIf) {
613 option.valueProvider = () => '';
614 option.makeSelectable = false;
615 }
616
617 // When typing arguments or closing brace, prioritize exact matches by putting them first
618 if (shouldShowMatchingMacroDetails && (isExactMatch || isAliasMatch)) {
619 options.unshift(option);
620 } else {
621 options.push(option);
622 }
623 }
624
625 // If typing args/closing brace but no macro matches, check for closing macro context
626 if (shouldShowMatchingMacroDetails && !hasMatchingMacro && context.identifier.length > 0) {
627 // Check if this is a closing macro (starts with /) - show original macro's details
628 // Note: We look up the macro directly, not from unclosedScopes, because the closing tag
629 // itself may have already closed the scope by this point in the text
630 const isClosingMacro = context.identifier.startsWith('/');
631 const closingMacroName = isClosingMacro ? context.identifier.slice(1) : null;
632 const macroDef = closingMacroName ? macroSystem.registry.getPrimaryMacro(closingMacroName) : null;
633
634 if (macroDef) {
635 // Show the original macro's details for the closing tag
636 // Create a context that shows we're closing the scope (no argument highlight)
637 const closingContext = /** @type {MacroAutoCompleteContext} */ ({
638 ...context,
639 identifier: macroDef.name,
640 currentArgIndex: -1, // No argument highlight
641 isClosingTag: true,
642 });
643 const closingOption = new EnhancedMacroAutoCompleteOption(macroDef, closingContext);
644 closingOption.valueProvider = () => '';
645 closingOption.makeSelectable = false;
646 closingOption.matchProvider = () => true;
647 closingOption.sortPriority = 0;
648 options.unshift(closingOption);
649 hasMatchingMacro = true; // Prevent "no match" message
650 }
651
652 // Only show "no match" if we didn't find a matching closing scope
653 if (!hasMatchingMacro) {
654 const noMatchOption = new SimpleAutoCompleteOption({
655 name: context.identifier,
656 symbol: '❌',
657 description: `No macro found: "${context.identifier}"`,
658 detailedDescription: `The macro name <code>${context.identifier}</code> does not exist.<br><br>Check spelling or use a different macro name.`,
659 type: 'error',
660 });
661 noMatchOption.valueProvider = () => '';
662 noMatchOption.makeSelectable = false;
663 noMatchOption.matchProvider = () => true; // Always show
664 noMatchOption.sortPriority = 0; // Top priority
665 options.unshift(noMatchOption);
666 }
667 }
668
669 return options;
670}
671
672/**
673 * Builds autocomplete options for {{if}} condition - shows zero-arg macros as shorthand.
674 * @param {MacroAutoCompleteContext} context
675 * @param {MacroDefinition[]} allMacros
676 * @param {string} macroInnerText - The text inside the macro braces (e.g., " if pers" from "{{ if pers").
677 * @returns {AutoCompleteOption[]}
678 */
679export function buildIfConditionOptions(context, allMacros, macroInnerText) {
680 /** @type {AutoCompleteOption[]} */
681 const options = [];
682
683 // Calculate padding from the original macro text for matching whitespace on completion
684 // e.g., " if pers" -> leading padding = " " (whitespace before 'if', used before '}}')
685 const leadingMatch = macroInnerText.match(/^(\s*)/);
686 const paddingAfter = leadingMatch ? leadingMatch[1] : '';
687
688 // Get the condition text being typed (trimmed for detection)
689 const conditionText = (context.args[0] || '').trim();
690
691 // Check for inversion prefix (!) - also trim whitespace after !
692 const hasInversionPrefix = conditionText.startsWith('!');
693 const conditionAfterInversion = hasInversionPrefix ? conditionText.slice(1).trimStart() : conditionText;
694
695 const inversionOption = new SimpleAutoCompleteOption({
696 name: '!',
697 symbol: '🔁',
698 description: 'Invert condition (NOT)',
699 detailedDescription: 'Inverts the condition result. If the condition is truthy, it becomes falsy, and vice versa.<br><br>Example: <code>{{if !myVar}}</code> executes when <code>myVar</code> is empty or zero.',
700 type: 'inverse',
701 });
702
703 // Check if condition starts with a variable shorthand prefix (with or without !)
704 const isTypingVariableShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
705
706 if (isTypingVariableShorthand) {
707 // User is typing a variable shorthand - reuse #buildVariableShorthandOptions
708 const prefix = /** @type {'.'|'$'} */ (conditionAfterInversion[0]);
709 const varNameTyped = conditionAfterInversion.slice(1); // Variable name after the prefix
710
711 // If inverted, show the ! as non-selectable context
712 if (hasInversionPrefix) {
713 inversionOption.valueProvider = () => ''; // Already typed
714 inversionOption.makeSelectable = false;
715 inversionOption.sortPriority = 0;
716 options.push(inversionOption);
717 }
718
719 // Create a synthetic context for #buildVariableShorthandOptions
720 /** @type {MacroAutoCompleteContext} */
721 const varContext = {
722 ...context,
723 isVariableShorthand: true,
724 variablePrefix: prefix,
725 variableName: varNameTyped,
726 isTypingVariableName: true,
727 isTypingOperator: false,
728 isTypingValue: false,
729 isOperatorComplete: false,
730 hasInvalidTrailingChars: false,
731 variableOperator: null,
732 variableValue: '',
733 };
734
735 const varOptions = buildVariableShorthandOptions(varContext, { forIfCondition: true, paddingAfter });
736 options.push(...varOptions);
737 return options;
738 }
739
740 // Not typing a variable shorthand - show macro options, variable shorthand prefixes, and inversion
741
742 // Show ! inversion option at the top when nothing typed, or keep it visible (non-selectable) if already typed
743 if (conditionText.length === 0) {
744 // Nothing typed - offer ! as selectable option
745 inversionOption.valueProvider = () => '!';
746 inversionOption.makeSelectable = true;
747 inversionOption.sortPriority = -1; // Show at very top
748 options.push(inversionOption);
749 } else if (hasInversionPrefix && conditionAfterInversion.length === 0) {
750 // Just ! typed - show it as non-selectable context, then show macro names and variable prefixes
751 inversionOption.valueProvider = () => ''; // Already typed
752 inversionOption.makeSelectable = false;
753 inversionOption.sortPriority = -1;
754 options.push(inversionOption);
755 }
756
757 // Add variable shorthand prefix options when no content typed yet (or just ! typed)
758 if (conditionAfterInversion.length === 0) {
759 for (const [, prefixDef] of VariableShorthandDefinitions) {
760 const prefixOption = new VariableShorthandAutoCompleteOption(prefixDef);
761 // Complete with just the prefix symbol
762 prefixOption.valueProvider = () => prefixDef.type;
763 prefixOption.makeSelectable = true;
764 prefixOption.sortPriority = 0; // Show at top
765 options.push(prefixOption);
766 }
767 }
768
769 // Add zero-arg macros as condition shorthand options
770 for (const macro of allMacros) {
771 // Only include macros that require zero arguments (can be auto-resolved)
772 if (macro.minArgs !== 0) continue;
773
774 // Skip internal/utility macros that don't make sense as conditions
775 if (['else', 'noop', 'trim', '//'].includes(macro.name)) continue;
776
777 const option = new EnhancedMacroAutoCompleteOption(macro, {
778 noBraces: true,
779 paddingAfter,
780 closeWithBraces: true,
781 });
782 options.push(option);
783 }
784
785 return options;
786}
787
788/**
789 * Finds macro boundaries at a given cursor position in any text.
790 * Works independently of slash command parsing.
791 *
792 * @param {string} text - The full text content.
793 * @param {number} cursorPos - The cursor position in the text.
794 * @returns {{ start: number, end: number, content: string } | null}
795 */
796export function findMacroAtCursor(text, cursorPos) {
797 // Search backwards for opening {{ while tracking nesting depth for nested macros
798 let openPos = -1;
799 let depth = 0;
800
801 // If cursor is right after }}, those are the closing braces of the macro we're looking for,
802 // not nested braces. Skip them by starting the search before them.
803 let searchStart = cursorPos - 1;
804 let cursorAfterClosingBraces = false;
805 if (cursorPos >= 2 && text[cursorPos - 1] === '}' && text[cursorPos - 2] === '}') {
806 searchStart = cursorPos - 3; // Start before the }}
807 cursorAfterClosingBraces = true;
808 }
809
810 for (let i = searchStart; i >= 0; i--) {
811 if (text[i] === '}' && i > 0 && text[i - 1] === '}') {
812 // Found }}, going backwards means we're entering a nested macro
813 depth++;
814 i--; // Skip the other brace
815 continue;
816 }
817 if (text[i] === '{' && i > 0 && text[i - 1] === '{') {
818 if (depth > 0) {
819 // This {{ closes a nested macro we entered going backwards
820 depth--;
821 i--; // Skip the other brace
822 continue;
823 }
824 // Found our opening {{ at depth 0
825 openPos = i - 1;
826 break;
827 }
828 }
829
830 if (openPos === -1) return null;
831
832 // Search forwards for closing }} while tracking nesting depth
833 let closePos = -1;
834
835 // If cursor is right after }}, we already know where the closing braces are
836 if (cursorAfterClosingBraces) {
837 closePos = cursorPos;
838 } else {
839 depth = 0;
840 for (let i = cursorPos; i < text.length - 1; i++) {
841 if (text[i] === '{' && text[i + 1] === '{') {
842 // Found {{, entering a nested macro
843 depth++;
844 i++; // Skip the other brace
845 continue;
846 }
847 if (text[i] === '}' && text[i + 1] === '}') {
848 if (depth > 0) {
849 // This }} closes a nested macro
850 depth--;
851 i++; // Skip the other brace
852 continue;
853 }
854 // Found our closing }} at depth 0
855 closePos = i + 2;
856 break;
857 }
858 }
859
860 if (closePos === -1) {
861 closePos = text.length;
862 }
863 }
864
865 const hasClosingBraces = closePos <= text.length && text.slice(closePos - 2, closePos) === '}}';
866 const content = text.slice(openPos + 2, hasClosingBraces ? closePos - 2 : closePos);
867
868 return {
869 start: openPos,
870 end: closePos,
871 content,
872 };
873}
874
875/**
876 * Gets variable names from the specified scope.
877 *
878 * @param {'local'|'global'} scope - The variable scope.
879 * @returns {string[]} Array of variable names.
880 */
881export function getVariableNames(scope) {
882 try {
883 // Import chat_metadata and extension_settings dynamically to avoid circular deps
884 // These are the same sources used by commonEnumProviders.variables
885 if (scope === 'local') {
886 // Local variables are in chat_metadata.variables
887 return Object.keys(chat_metadata?.variables ?? {});
888 } else {
889 // Global variables are in extension_settings.variables.global
890 return Object.keys(extension_settings?.variables?.global ?? {});
891 }
892 } catch {
893 return [];
894 }
895}
896
897/**
898 * Core function to build macro autocomplete results.
899 * Used by both SlashCommandParser (slash command context) and MacroAutoComplete (free text).
900 *
901 * This is the shared implementation that handles:
902 * - Scoped content detection and context display
903 * - {{if}} condition special handling
904 * - Variable shorthand syntax (.var, $var)
905 * - Flag handling
906 * - Regular macro options
907 *
908 * @param {string} text - The full text content.
909 * @param {number} cursorPos - The cursor position.
910 * @param {BuildMacroAutoCompleteOptions} [options={}] - Optional pre-computed values.
911 * @returns {Promise<AutoCompleteNameResult|null>}
912 */
913export async function buildMacroAutoCompleteResult(text, cursorPos, {
914 macro = null,
915 textUpToCursor = null,
916 unclosedScopes = null,
917 isForced = false,
918} = {}) {
919 // Compute textUpToCursor if not provided
920 if (textUpToCursor === null) {
921 textUpToCursor = text.slice(0, cursorPos);
922 }
923
924 // Compute unclosedScopes if not provided
925 if (unclosedScopes === null) {
926 unclosedScopes = findUnclosedScopes(textUpToCursor);
927 }
928
929 // Filter out optional scopes unless forced (Ctrl+Space)
930 // This prevents intrusive hints for macros like {{trim}} where scope is optional
931 const filteredScopes = filterOptionalScopes(unclosedScopes, textUpToCursor, isForced);
932
933 // If cursor is NOT inside a macro, check if we're in scoped content
934 if (!macro) {
935 if (filteredScopes.length > 0) {
936 const scopedMacro = filteredScopes[filteredScopes.length - 1];
937
938 // Find where the opening macro ends
939 const openingEnd = text.indexOf('}}', scopedMacro.startOffset);
940 if (openingEnd !== -1 && cursorPos >= openingEnd + 2) {
941 // We're in scoped content - show parent macro's details
942 const macroContent = text.slice(scopedMacro.startOffset + 2, openingEnd);
943 const baseContext = parseMacroContext(macroContent, macroContent.length);
944
945 // Check if this scope is optional (for display purposes)
946 const scopeIsOptional = isScopeOptional(scopedMacro, textUpToCursor);
947
948 const scopedContext = {
949 ...baseContext,
950 currentArgIndex: baseContext.args.length,
951 isInScopedContent: true,
952 isScopedContentOptional: scopeIsOptional,
953 scopedMacroName: scopedMacro.name,
954 };
955
956 await onboardingExperimentalMacroEngine('scoped macros');
957
958 const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
959 if (macroDef) {
960 const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
961 scopedOption.valueProvider = () => '';
962 scopedOption.makeSelectable = false;
963
964 return new AutoCompleteNameResult(
965 scopedMacro.name,
966 scopedMacro.startOffset + 2,
967 [scopedOption],
968 false,
969 );
970 }
971 }
972 }
973 return null;
974 }
975
976 // Cursor is inside a macro - parse context
977 const cursorInMacro = cursorPos - macro.start - 2;
978 const context = parseMacroContext(macro.content, cursorInMacro);
979
980 // Check if cursor is at/after closing }}
981 const macroEndsBrackets = text.slice(macro.end - 2, macro.end) === '}}';
982 const isCursorAtClosing = macroEndsBrackets && cursorPos >= macro.end - 1;
983
984 if (isCursorAtClosing) {
985 // Cursor is at the closing }} - check if this is an unclosed scoped macro
986 if (filteredScopes.length > 0) {
987 const scopedMacro = filteredScopes[filteredScopes.length - 1];
988 // Check if the current macro IS the unclosed scoped macro
989 if (scopedMacro.startOffset === macro.start) {
990 // Show scoped context - cursor is right at the end of the opening tag
991 // Check if this scope is optional (for display purposes)
992 const scopeIsOptional = isScopeOptional(scopedMacro, textUpToCursor);
993
994 const scopedContext = {
995 ...context,
996 currentArgIndex: context.args.length,
997 isInScopedContent: true,
998 isScopedContentOptional: scopeIsOptional,
999 scopedMacroName: scopedMacro.name,
1000 };
1001
1002 const macroDef = macroSystem.registry.getPrimaryMacro(scopedMacro.name);
1003 if (macroDef) {
1004 const scopedOption = new EnhancedMacroAutoCompleteOption(macroDef, scopedContext);
1005 scopedOption.valueProvider = () => '';
1006 scopedOption.makeSelectable = false;
1007
1008 return new AutoCompleteNameResult(
1009 scopedMacro.name,
1010 macro.start + 2,
1011 [scopedOption],
1012 false,
1013 );
1014 }
1015 }
1016 }
1017
1018 // Check if this is a closing tag ({{/macroName}}) - show original macro's details
1019 // Note: We look up the macro directly, not from unclosedScopes, because the closing tag
1020 // itself has already closed the scope by this point in the text
1021 if (context.identifier.startsWith('/')) {
1022 const closingMacroName = context.identifier.slice(1);
1023 const macroDef = macroSystem.registry.getPrimaryMacro(closingMacroName);
1024 if (macroDef) {
1025 const closingContext = /** @type {MacroAutoCompleteContext} */ ({
1026 ...context,
1027 identifier: macroDef.name,
1028 currentArgIndex: -1, // No argument highlight
1029 isClosingTag: true,
1030 });
1031 const closingOption = new EnhancedMacroAutoCompleteOption(macroDef, closingContext);
1032 closingOption.valueProvider = () => '';
1033 closingOption.makeSelectable = false;
1034
1035 return new AutoCompleteNameResult(
1036 macroDef.name,
1037 macro.start + 2,
1038 [closingOption],
1039 false,
1040 );
1041 }
1042 }
1043
1044 // Not a scoped macro, just clear arg highlighting
1045 context.currentArgIndex = -1;
1046 }
1047
1048 // Use the identifier from context (handles whitespace and flags)
1049 // Start position must be where the identifier actually begins (after whitespace/flags)
1050 // so that the autocomplete range calculation works correctly
1051 const identifier = context.identifier;
1052 const identifierStartInText = macro.start + 2 + context.identifierStart;
1053
1054 // Special case for {{if}} condition: use the condition text for matching/replacement
1055 const isTypingIfCondition = context.identifier === 'if' && context.currentArgIndex === 0;
1056 if (isTypingIfCondition) {
1057 // Get the typed condition text and calculate its start position
1058 const conditionText = context.args[0] || '';
1059 // Find where the condition argument starts in the macro text
1060 const separatorMatch = macro.content.match(/^.*?if\s*(?:::?)\s*/);
1061 const spaceMatch = macro.content.match(/^.*?if\s+/);
1062 let conditionStartOffset;
1063 if (separatorMatch) {
1064 conditionStartOffset = separatorMatch[0].length;
1065 } else if (spaceMatch) {
1066 conditionStartOffset = spaceMatch[0].length;
1067 } else {
1068 conditionStartOffset = context.identifierStart + identifier.length;
1069 }
1070 const conditionStartInText = macro.start + 2 + conditionStartOffset;
1071
1072 // Build if-condition options using macroContent for padding calculation
1073 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
1074 const options = buildIfConditionOptions(context, allMacros, macro.content);
1075
1076 // For variable shorthand in {{if}} condition, adjust identifier and start position
1077 // Same fix as for regular variable shorthands - identifier must be just the var name
1078 // Also handle ! inversion prefix: !.var or !$var or !macroName
1079 const trimmedCondition = conditionText.trim();
1080 const hasInversion = trimmedCondition.startsWith('!');
1081 // Trim whitespace after ! to handle "! $myvar" syntax
1082 const conditionAfterInversion = hasInversion ? trimmedCondition.slice(1).trimStart() : trimmedCondition;
1083 const isTypingVarShorthand = conditionAfterInversion.startsWith('.') || conditionAfterInversion.startsWith('$');
1084 let resultIdentifier = conditionText;
1085 let resultStart = conditionStartInText;
1086
1087 if (isTypingVarShorthand) {
1088 // Identifier = just the variable name part (without prefix and without !)
1089 resultIdentifier = conditionAfterInversion.slice(1);
1090 // Start = after the ! (if any) and the prefix
1091 const prefixChar = conditionAfterInversion[0];
1092 const prefixPosInCondition = conditionText.indexOf(prefixChar, hasInversion ? 1 : 0);
1093 resultStart = conditionStartInText + prefixPosInCondition + 1;
1094 } else if (hasInversion && conditionAfterInversion.length === 0) {
1095 // Just ! (possibly with whitespace) typed - identifier should be empty so other options can match
1096 resultIdentifier = '';
1097 // Start at end of actual condition text (including any whitespace after !)
1098 // This ensures cursor is within the name range for filtering
1099 resultStart = conditionStartInText + conditionText.length;
1100 } else if (hasInversion && conditionAfterInversion.length > 0) {
1101 // Typing a macro name after ! (e.g., !descr) - identifier should be just the macro name
1102 resultIdentifier = conditionAfterInversion;
1103 // Start = after the ! and any whitespace, at the beginning of the macro name
1104 const macroNameStart = trimmedCondition.indexOf(conditionAfterInversion);
1105 resultStart = conditionStartInText + macroNameStart;
1106 }
1107
1108 await onboardingExperimentalMacroEngine('{{if}} macro');
1109
1110 return new AutoCompleteNameResult(
1111 resultIdentifier,
1112 resultStart,
1113 options,
1114 false,
1115 () => isTypingVarShorthand
1116 ? 'Enter a variable name for the condition'
1117 : 'Use {{macro}} syntax for dynamic conditions',
1118 () => isTypingVarShorthand
1119 ? 'Enter a variable name or select from the list'
1120 : 'Enter a macro name or {{macro}} for the condition',
1121 );
1122 }
1123
1124 // Build regular macro options
1125 /** @type {()=>string|undefined} */
1126 let makeNoMatchText = undefined;
1127 /** @type {()=>string|undefined} */
1128 let makeNoOptionsText = undefined;
1129
1130 const options = buildEnhancedMacroOptions(context, textUpToCursor);
1131
1132 // For variable shorthands, calculate the correct identifier and start position
1133 // based on what the user is currently typing (variable name, operator, or value)
1134 let resultIdentifier = identifier;
1135 let resultStart = identifierStartInText;
1136 if (context.isVariableShorthand && context.variablePrefix) {
1137 // Find where the prefix is in the macro content
1138 const prefixIndex = macro.content.indexOf(context.variablePrefix);
1139
1140 if (context.isTypingVariableName) {
1141 // Typing variable name: identifier = variableName, start = after prefix
1142 resultIdentifier = context.variableName;
1143 if (prefixIndex >= 0) {
1144 resultStart = macro.start + 2 + prefixIndex + 1; // +1 to skip the prefix
1145 }
1146 } else if (context.isTypingOperator) {
1147 // Typing operator: identifier = partial operator or current operator, start = after variable name
1148 resultIdentifier = context.partialOperator || context.variableOperator || '';
1149 // Use actual variableNameEnd position from parsing (accounts for whitespace)
1150 resultStart = macro.start + 2 + context.variableNameEnd;
1151 // Skip whitespace between variable name and operator
1152 while (resultStart < cursorPos && /\s/.test(text[resultStart])) {
1153 resultStart++;
1154 }
1155 } else if (context.isOperatorComplete) {
1156 // Operator complete (++ or --) - show context but no value input needed
1157 resultIdentifier = '';
1158 resultStart = cursorPos; // Cursor at end
1159 } else if (context.hasInvalidTrailingChars) {
1160 // Invalid chars after variable name: show the invalid chars for warning
1161 resultIdentifier = context.invalidTrailingChars || '';
1162 // Use actual variableNameEnd position from parsing
1163 resultStart = macro.start + 2 + context.variableNameEnd;
1164 } else if (context.isTypingValue && !context.isTypingClosingBrace) {
1165 // Typing value: identifier = value being typed, start = after operator
1166 resultIdentifier = context.variableValue;
1167 // Use actual operatorEnd position from parsing (accounts for whitespace)
1168 resultStart = macro.start + 2 + context.variableOperatorEnd;
1169 // Skip any whitespace between operator and value
1170 while (resultStart < cursorPos && /\s/.test(text[resultStart])) {
1171 resultStart++;
1172 }
1173
1174 makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
1175 makeNoOptionsText = () => 'Enter a variable value';
1176 } else if (context.isTypingClosingBrace) {
1177 // Typing closing brace on variable shorthand - show context, no replacement needed
1178 resultIdentifier = '';
1179 resultStart = cursorPos;
1180 } else {
1181 // Fallback: use variable name
1182 resultIdentifier = context.variableName;
1183 if (prefixIndex >= 0) {
1184 resultStart = macro.start + 2 + prefixIndex + 1;
1185 }
1186 }
1187
1188 if (!makeNoMatchText && !makeNoOptionsText) {
1189 makeNoMatchText = () => 'Invalid syntax or variable name (must be alphanumeric, not ending in hyphen or underscore). Use a valid macro name or syntax.';
1190 makeNoOptionsText = () => 'Enter a variable name to create or use a new variable';
1191 }
1192 }
1193
1194 return new AutoCompleteNameResult(
1195 resultIdentifier,
1196 resultStart,
1197 options,
1198 false,
1199 makeNoMatchText,
1200 makeNoOptionsText,
1201 );
1202}
1203
1204/**
1205 * Entry point for macro autocomplete in free text contexts.
1206 * Finds the macro at cursor position and delegates to the shared builder.
1207 *
1208 * @param {string} text - The full text content.
1209 * @param {number} cursorPos - The cursor position.
1210 * @param {Object} [options={}] - Additional options.
1211 * @param {boolean} [options.isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space).
1212 * @returns {Promise<AutoCompleteNameResult|null>}
1213 */
1214export async function getMacroAutoCompleteAt(text, cursorPos, { isForced = false } = {}) {
1215 const macro = findMacroAtCursor(text, cursorPos);
1216 return buildMacroAutoCompleteResult(text, cursorPos, { macro, isForced });
1217}