Macros 2.0 - Improve Autocomplete edge cases on completing macros (#5093) * fix(autocomplete): handle nested macros in :: separator parsing Track nesting depth when parsing macro parts to avoid treating :: inside nested macros as separators. Increment depth on {{ and decrement on }}, only recognizing :: as a separator when nestedDepth is 0. * chore(autocomplete): add missing properties for non-variable macros Add `variableNameEnd` and `variableOperatorEnd` fields to track end positions of variable components in macro parsing context. * fix(autocomplete): stop macro parsing at newlines before first :: separator Prevent newlines before the first :: separator from being included in macro identifier/space-arg parsing. Track separator state and break early when encountering newlines at depth 0 before any :: is seen. Only include the last part if cursor is within its bounds to avoid extending macro context across line breaks. * fix(autocomplete): handle cursor position after closing braces in macro detection Skip closing }} when searching backwards if cursor is immediately after them, treating them as the target macro's closing braces rather than nested braces. Skip forward search entirely when cursor is after }}, using cursor position as closePos directly. * fix(autocomplete): show macro details when typing closing brace and add no-match indicator Track `isTypingClosingBrace` state when cursor is after a single `}` on standalone macros (no separators/args). Strip trailing `}` from identifier in this case. Show matching macro details when typing closing brace, same as when typing args. Add "no match" error option when typing args/closing brace but identifier doesn't match any macro. Extract `AnyMacroAutoCompleteOption` typedef for cleaner type annotations * fix(autocomplete): show variable context when typing closing brace on shorthand macros Detect `isTypingClosingBrace` state for variable shorthands (e.g., `{{.Lila}`, `{{.Lila+=4}`). Parse value before brace detection to check for trailing `}`. Show non-selectable context options (variable name, operator, value) when typing closing brace instead of operator/value suggestions. Strip trailing `}` from value when detected. Set `resultStart` to cursor position when typing closing brace to avoid replacement * fix(autocomplete): stop flag parsing at newlines and show context when typing closing brace on variable shorthands Skip newlines (not just all whitespace) between flags to prevent extending macro context across line breaks. Show variable context (name, operator, value) when typing closing brace on shorthand macros by excluding `isTypingClosingBrace` from the value-typing check. * Fix highlight of value autocomplete option * Rename permanentMatch to forceFullNameMatch --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

06b77ec94c0b9107bbe4e7e049976083b711c73b

Wolfsblvt <wolfsblvt@gmail.com>

Signed
4 files changed, +214 -35Ignore whitespace
public/scripts/autocomplete/AutoComplete.js+4 -0
@@ -155,6 +155,10 @@ export class AutoComplete {
155 */155 */
156 updateName(item) {156 updateName(item) {
157 const chars = Array.from(item.dom.querySelector('.name').children);157 const chars = Array.from(item.dom.querySelector('.name').children);
158 if (item.forceFullNameMatch) {
159 chars.forEach(c => c.classList.toggle('matched', true));
160 return;
161 }
158 switch (this.matchType) {162 switch (this.matchType) {
159 case 'strict': {163 case 'strict': {
160 chars.forEach((it, idx) => {164 chars.forEach((it, idx) => {
public/scripts/autocomplete/AutoCompleteOption.js+1 -0
@@ -13,6 +13,7 @@ export class AutoCompleteOption {
13 /** @type {(input:string)=>boolean} */ matchProvider;13 /** @type {(input:string)=>boolean} */ matchProvider;
14 /** @type {(input:string)=>string} */ valueProvider;14 /** @type {(input:string)=>string} */ valueProvider;
15 /** @type {boolean} */ makeSelectable = false;15 /** @type {boolean} */ makeSelectable = false;
16 /** @type {boolean} */ forceFullNameMatch = false;
1617
17 /**18 /**
18 * Offset to adjust the replacement start position.19 * Offset to adjust the replacement start position.
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+91 -8
@@ -31,6 +31,7 @@ import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnos
31 * @property {string[]} args - Array of arguments typed so far.31 * @property {string[]} args - Array of arguments typed so far.
32 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).32 * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier).
33 * @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').33 * @property {boolean} isTypingSeparator - Whether cursor is on a partial separator (single ':').
34 * @property {boolean} isTypingClosingBrace - Whether cursor is typing the first closing brace on a standalone macro.
34 * @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).35 * @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args).
35 * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).36 * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
36 * @property {number} separatorCount - Number of '::' separators found.37 * @property {number} separatorCount - Number of '::' separators found.
@@ -1033,6 +1034,7 @@ export class VariableValueContextAutoCompleteOption extends AutoCompleteOption {
1033 super('value', '📝');1034 super('value', '📝');
1034 this.#operatorDef = operatorDef;1035 this.#operatorDef = operatorDef;
1035 this.#currentValue = currentValue;1036 this.#currentValue = currentValue;
1037 this.forceFullNameMatch = true;
1036 }1038 }
10371039
1038 /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */1040 /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */
@@ -1236,8 +1238,8 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
1236export function parseMacroContext(macroText, cursorOffset) {1238export function parseMacroContext(macroText, cursorOffset) {
1237 let i = 0;1239 let i = 0;
12381240
1239 // Skip leading whitespace1241 // Skip leading whitespace (but NOT newlines - those stop macro parsing for autocomplete)
1240 while (i < macroText.length && /\s/.test(macroText[i])) {1242 while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1241 i++;1243 i++;
1242 }1244 }
12431245
@@ -1257,8 +1259,8 @@ export function parseMacroContext(macroText, cursorOffset) {
1257 flags.push(char);1259 flags.push(char);
1258 i++;1260 i++;
1259 flagEndPositions.push(i); // Position right after this flag1261 flagEndPositions.push(i); // Position right after this flag
1260 // Skip whitespace between flags1262 // Skip whitespace between flags (but NOT newlines - those stop macro parsing for autocomplete)
1261 while (i < macroText.length && /\s/.test(macroText[i])) {1263 while (i < macroText.length && /[ \t]/.test(macroText[i])) {
1262 i++;1264 i++;
1263 }1265 }
1264 } else {1266 } else {
@@ -1387,15 +1389,49 @@ export function parseMacroContext(macroText, cursorOffset) {
1387 const operatorNeedsValue = operatorDef?.needsValue ?? false;1389 const operatorNeedsValue = operatorDef?.needsValue ?? false;
13881390
1389 // If operator requires a value, parse the value1391 // If operator requires a value, parse the value
1392 // Do this BEFORE isTypingClosingBrace detection so we can check for } in value area
1393 // let valueStartPos = i;
1390 if (operatorNeedsValue) {1394 if (operatorNeedsValue) {
1391 // Skip whitespace after operator1395 // Skip whitespace after operator
1392 while (i < macroText.length && /\s/.test(macroText[i])) {1396 while (i < macroText.length && /\s/.test(macroText[i])) {
1393 i++;1397 i++;
1394 }1398 }
1399 // valueStartPos = i;
1395 variableValue = macroText.slice(i).trimEnd();1400 variableValue = macroText.slice(i).trimEnd();
1396 }1401 }
13971402
1403 // Detect if typing first closing brace on a variable shorthand
1404 // This happens when operatorText is just "}" or when cursor is beyond content (after }})
1405 let isTypingClosingBrace = false;
1406 if (operatorText.startsWith('}') && !variableOperator) {
1407 // Typing first } on a standalone variable shorthand like {{.Lila}
1408 isTypingClosingBrace = true;
1409 } else if (cursorOffset > macroText.length && !variableOperator) {
1410 // Cursor is after }} on a standalone variable shorthand like {{.Lila}}|
1411 isTypingClosingBrace = true;
1412 } else if (cursorOffset > macroText.length && variableOperator) {
1413 // Cursor is after }} on any operator shorthand like {{.Lila++}}| or {{.Lila+=4}}|
1414 isTypingClosingBrace = true;
1415 } else if (cursorOffset >= macroText.length && variableOperator && !operatorNeedsValue) {
1416 // Cursor at end of complete operator (++ or --) like {{.Lila++ or {{.Lila++ (with trailing space)
1417 isTypingClosingBrace = true;
1418 } else if (cursorOffset >= macroText.length && !variableOperator && variableName.length > 0) {
1419 // Cursor at end of standalone variable (with or without trailing whitespace) like {{.Lila or {{ .Lila
1420 isTypingClosingBrace = true;
1421 } else if (operatorNeedsValue && variableValue.length > 0 && variableValue.endsWith('}')) {
1422 // Typing first } after a value like {{.Lila+=4}
1423 isTypingClosingBrace = true;
1424 // Strip the } from the value
1425 variableValue = variableValue.slice(0, -1);
1426 } else if (operatorNeedsValue && cursorOffset >= macroText.length && variableValue.length > 0) {
1427 // Cursor at end after typing a value (including trailing whitespace) like {{.Lila+=4
1428 // This means the shorthand is "complete" and ready to close
1429 isTypingClosingBrace = true;
1430 }
1431
1398 // Determine cursor position context for autocomplete1432 // Determine cursor position context for autocomplete
1433 // Note: isTypingClosingBrace takes precedence - if we're typing a closing brace,
1434 // we don't want to show operator suggestions, just the current state
1399 const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;1435 const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1;
1400 if (cursorOffset < prefixEnd) {1436 if (cursorOffset < prefixEnd) {
1401 // Cursor is before the prefix - still in flags area conceptually1437 // Cursor is before the prefix - still in flags area conceptually
@@ -1403,9 +1439,10 @@ export function parseMacroContext(macroText, cursorOffset) {
1403 } else if (cursorOffset <= variableNameEnd) {1439 } else if (cursorOffset <= variableNameEnd) {
1404 // Cursor is in the variable name area (including at the end)1440 // Cursor is in the variable name area (including at the end)
1405 isTypingVariableName = true;1441 isTypingVariableName = true;
1406 } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars) {1442 } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars && !isTypingClosingBrace) {
1407 // Cursor is after variable name but no operator yet (and no invalid chars)1443 // Cursor is after variable name but no operator yet (and no invalid chars)
1408 // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<'1444 // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<'
1445 // But NOT when typing a closing brace - that takes precedence
1409 isTypingOperator = true;1446 isTypingOperator = true;
1410 } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) {1447 } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) {
1411 // Short operator that could be prefix of longer one (e.g., > could become >=)1448 // Short operator that could be prefix of longer one (e.g., > could become >=)
@@ -1434,6 +1471,7 @@ export function parseMacroContext(macroText, cursorOffset) {
1434 args: [],1471 args: [],
1435 currentArgIndex: -1,1472 currentArgIndex: -1,
1436 isTypingSeparator: false,1473 isTypingSeparator: false,
1474 isTypingClosingBrace,
1437 hasSpaceAfterIdentifier: false,1475 hasSpaceAfterIdentifier: false,
1438 hasSpaceArgContent: false,1476 hasSpaceArgContent: false,
1439 separatorCount: 0,1477 separatorCount: 0,
@@ -1465,20 +1503,55 @@ export function parseMacroContext(macroText, cursorOffset) {
1465 let partStart = i;1503 let partStart = i;
1466 let j = 0;1504 let j = 0;
14671505
1506 // Track nesting depth to skip :: inside nested macros
1507 let nestedDepth = 0;
1508 // Track if we've seen a :: separator - newlines before first :: should stop parsing
1509 let hasSeenSeparator = false;
1510 // Track if we broke early (e.g., at a newline)
1511 let brokeEarly = false;
1468 while (j < remainingText.length) {1512 while (j < remainingText.length) {
1469 if (remainingText[j] === ':' && remainingText[j + 1] === ':') {1513 // Before the first :: separator, newlines should stop parsing
1514 // This prevents text on the next line from being considered part of the identifier/space-arg
1515 if (!hasSeenSeparator && nestedDepth === 0 && (remainingText[j] === '\n' || remainingText[j] === '\r')) {
1516 // Stop parsing here - don't include the newline or anything after
1517 brokeEarly = true;
1518 break;
1519 }
1520 // Track nested macro braces
1521 if (remainingText[j] === '{' && remainingText[j + 1] === '{') {
1522 nestedDepth++;
1523 currentPart += '{{';
1524 j += 2;
1525 continue;
1526 }
1527 if (remainingText[j] === '}' && remainingText[j + 1] === '}') {
1528 nestedDepth = Math.max(0, nestedDepth - 1);
1529 currentPart += '}}';
1530 j += 2;
1531 continue;
1532 }
1533 // Only count :: as separator when not inside nested macros
1534 if (nestedDepth === 0 && remainingText[j] === ':' && remainingText[j + 1] === ':') {
1470 parts.push({ text: currentPart, start: partStart, end: i + j });1535 parts.push({ text: currentPart, start: partStart, end: i + j });
1471 separatorPositions.push({ start: i + j, end: i + j + 2 });1536 separatorPositions.push({ start: i + j, end: i + j + 2 });
1472 currentPart = '';1537 currentPart = '';
1473 j += 2;1538 j += 2;
1474 partStart = i + j;1539 partStart = i + j;
1540 hasSeenSeparator = true;
1475 } else {1541 } else {
1476 currentPart += remainingText[j];1542 currentPart += remainingText[j];
1477 j++;1543 j++;
1478 }1544 }
1479 }1545 }
1480 // Push the last part1546 // Push the last part - use correct end position if we broke early.
1481 parts.push({ text: currentPart, start: partStart, end: macroText.length });1547 // If we broke early (at a newline) AND cursor is past that point, don't push -
1548 // this filters out text on the next line from being considered part of this macro.
1549 // But if we didn't break early (cursor at end of closed macro), always push.
1550 const lastPartEnd = brokeEarly ? i + j : macroText.length;
1551 const shouldPushLastPart = !brokeEarly || cursorOffset <= lastPartEnd;
1552 if (shouldPushLastPart) {
1553 parts.push({ text: currentPart, start: partStart, end: lastPartEnd });
1554 }
14821555
1483 // Determine if cursor is in the flags area (at or before identifier starts)1556 // Determine if cursor is in the flags area (at or before identifier starts)
1484 const identifierStartPos = parts[0]?.start ?? i;1557 const identifierStartPos = parts[0]?.start ?? i;
@@ -1553,7 +1626,14 @@ export function parseMacroContext(macroText, cursorOffset) {
1553 }1626 }
15541627
1555 // Clean identifier: strip trailing colons (for partial :: typing)1628 // Clean identifier: strip trailing colons (for partial :: typing)
1629 // Also strip trailing single } (for partial }} typing) - but only if no separators/args
1556 let cleanIdentifier = identifierOnly.replace(/:+$/, '');1630 let cleanIdentifier = identifierOnly.replace(/:+$/, '');
1631 let isTypingClosingBrace = false;
1632 if (separatorPositions.length === 0 && !hasSpaceAfterIdentifier && cleanIdentifier.endsWith('}')) {
1633 // Typing first closing brace on a standalone macro like {{char}
1634 cleanIdentifier = cleanIdentifier.slice(0, -1);
1635 isTypingClosingBrace = true;
1636 }
15571637
1558 // Build args array - include space-separated arg if present1638 // Build args array - include space-separated arg if present
1559 // Trim args like the macro engine does1639 // Trim args like the macro engine does
@@ -1574,6 +1654,7 @@ export function parseMacroContext(macroText, cursorOffset) {
1574 args,1654 args,
1575 currentArgIndex,1655 currentArgIndex,
1576 isTypingSeparator,1656 isTypingSeparator,
1657 isTypingClosingBrace,
1577 hasSpaceAfterIdentifier,1658 hasSpaceAfterIdentifier,
1578 hasSpaceArgContent: spaceArgText.length > 0,1659 hasSpaceArgContent: spaceArgText.length > 0,
1579 separatorCount: separatorPositions.length,1660 separatorCount: separatorPositions.length,
@@ -1581,7 +1662,9 @@ export function parseMacroContext(macroText, cursorOffset) {
1581 isVariableShorthand: false,1662 isVariableShorthand: false,
1582 variablePrefix: null,1663 variablePrefix: null,
1583 variableName: '',1664 variableName: '',
1665 variableNameEnd: null,
1584 variableOperator: null,1666 variableOperator: null,
1667 variableOperatorEnd: null,
1585 variableValue: '',1668 variableValue: '',
1586 isTypingVariableName: false,1669 isTypingVariableName: false,
1587 isTypingOperator: false,1670 isTypingOperator: false,
public/scripts/autocomplete/MacroAutoCompleteHelper.js+118 -27
@@ -57,6 +57,8 @@ import { extension_settings } from '../extensions.js';
57 * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes57 * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes
58 */58 */
5959
60/** @typedef {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption|SimpleAutoCompleteOption)} AnyMacroAutoCompleteOption */
61
60/**62/**
61 * Finds unclosed scoped macros in the text up to cursor position.63 * Finds unclosed scoped macros in the text up to cursor position.
62 * Uses the MacroParser and MacroCstWalker for accurate analysis.64 * Uses the MacroParser and MacroCstWalker for accurate analysis.
@@ -136,11 +138,11 @@ export function findUnclosedScopesRegex(text) {
136 * @param {Object} [opts] - Optional configuration.138 * @param {Object} [opts] - Optional configuration.
137 * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).139 * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}).
138 * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.140 * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}.
139 * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}141 * @returns {AnyMacroAutoCompleteOption[]}
140 */142 */
141export function buildVariableShorthandOptions(context, opts = {}) {143export function buildVariableShorthandOptions(context, opts = {}) {
142 const { forIfCondition = false, paddingAfter = '' } = opts;144 const { forIfCondition = false, paddingAfter = '' } = opts;
143 /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption)[]} */145 /** @type {AnyMacroAutoCompleteOption[]} */
144 const options = [];146 const options = [];
145147
146 const isLocal = context.variablePrefix === '.';148 const isLocal = context.variablePrefix === '.';
@@ -276,7 +278,7 @@ export function buildVariableShorthandOptions(context, opts = {}) {
276278
277 // If typing value (after = or +=), no autocomplete needed - freeform text279 // If typing value (after = or +=), no autocomplete needed - freeform text
278 // But we show the current context for reference (greyed out, non-selectable)280 // But we show the current context for reference (greyed out, non-selectable)
279 if (context.isTypingValue && !context.isTypingOperator) {281 if (context.isTypingValue && !context.isTypingOperator && !context.isTypingClosingBrace) {
280 // Show the current variable name as context (non-selectable)282 // Show the current variable name as context (non-selectable)
281 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);283 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
282 varNameOption.valueProvider = () => ''; // Context only284 varNameOption.valueProvider = () => ''; // Context only
@@ -331,6 +333,49 @@ export function buildVariableShorthandOptions(context, opts = {}) {
331 }333 }
332 }334 }
333335
336 // If typing closing brace on a variable shorthand (without operator), show the current state
337 // This handles cases like {{.Lila} or {{.Lila}}| where we want to show what was typed
338 if (context.isTypingClosingBrace && !context.isOperatorComplete && !context.isTypingOperator && !context.isTypingValue) {
339 // Show the current variable name as context (non-selectable)
340 const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false);
341 varNameOption.valueProvider = () => ''; // Context only
342 varNameOption.makeSelectable = false;
343 varNameOption.sortPriority = 2;
344 varNameOption.matchProvider = () => true; // Always show
345 options.push(varNameOption);
346 }
347
348 // If typing closing brace after a value operator (like {{.Lila+=4}} or {{.Lila+=4}),
349 // show the full context (variable + operator + value)
350 if (context.isTypingClosingBrace && context.variableOperator && context.isTypingValue) {
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 const opDef = VariableOperatorDefinitions.get(context.variableOperator);
361 if (opDef) {
362 const opOption = new VariableOperatorAutoCompleteOption(opDef);
363 opOption.valueProvider = () => ''; // Already typed
364 opOption.makeSelectable = false;
365 opOption.sortPriority = 3;
366 opOption.matchProvider = () => true; // Always show
367 options.push(opOption);
368
369 // Show value context info (non-selectable)
370 const valueOption = new VariableValueContextAutoCompleteOption(opDef, context.variableValue);
371 valueOption.valueProvider = () => ''; // Context only
372 valueOption.makeSelectable = false;
373 valueOption.sortPriority = 4;
374 valueOption.matchProvider = () => true; // Always show
375 options.push(valueOption);
376 }
377 }
378
334 return options;379 return options;
335}380}
336381
@@ -340,10 +385,10 @@ export function buildVariableShorthandOptions(context, opts = {}) {
340 * When typing arguments (after ::), prioritizes the exact macro match.385 * When typing arguments (after ::), prioritizes the exact macro match.
341 * @param {MacroAutoCompleteContext} context386 * @param {MacroAutoCompleteContext} context
342 * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.387 * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection.
343 * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]}388 * @returns {AnyMacroAutoCompleteOption[]}
344 */389 */
345export function buildEnhancedMacroOptions(context, textUpToCursor) {390export function buildEnhancedMacroOptions(context, textUpToCursor) {
346 /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)[]} */391 /** @type {AnyMacroAutoCompleteOption[]} */
347 const options = [];392 const options = [];
348393
349 if (context.isVariableShorthand) {394 if (context.isVariableShorthand) {
@@ -427,16 +472,26 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) {
427 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });472 const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true });
428473
429 // If we're typing arguments (after ::), only show the context to the matching macro474 // If we're typing arguments (after ::), only show the context to the matching macro
475 // Also treat typing closing brace the same way - show details for matching macro
430 const isTypingArgs = context.currentArgIndex >= 0;476 const isTypingArgs = context.currentArgIndex >= 0;
477 const isTypingClosingBrace = context.isTypingClosingBrace ?? false;
478 const shouldShowMatchingMacroDetails = isTypingArgs || isTypingClosingBrace;
431479
432 // Check if we're inside a scoped {{if}} for {{else}} selectability480 // Check if we're inside a scoped {{if}} for {{else}} selectability
433 const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');481 const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if');
434482
483 // Track if any macro matches the identifier (for "no match" message)
484 let hasMatchingMacro = false;
485
435 for (const macro of allMacros) {486 for (const macro of allMacros) {
436 // Check if this macro matches the typed identifier487 // Check if this macro matches the typed identifier
437 const isExactMatch = macro.name === context.identifier;488 const isExactMatch = macro.name === context.identifier;
438 const isAliasMatch = macro.aliasOf === context.identifier;489 const isAliasMatch = macro.aliasOf === context.identifier;
439490
491 if (isExactMatch || isAliasMatch) {
492 hasMatchingMacro = true;
493 }
494
440 // Only pass context to the macro that matches the identifier being typed495 // Only pass context to the macro that matches the identifier being typed
441 // This ensures argument hints only show for the relevant macro496 // This ensures argument hints only show for the relevant macro
442 /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */497 /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */
@@ -461,14 +516,30 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) {
461 option.makeSelectable = false;516 option.makeSelectable = false;
462 }517 }
463518
464 // When typing arguments, prioritize exact matches by putting them first519 // When typing arguments or closing brace, prioritize exact matches by putting them first
465 if (isTypingArgs && (isExactMatch || isAliasMatch)) {520 if (shouldShowMatchingMacroDetails && (isExactMatch || isAliasMatch)) {
466 options.unshift(option);521 options.unshift(option);
467 } else {522 } else {
468 options.push(option);523 options.push(option);
469 }524 }
470 }525 }
471526
527 // If typing args/closing brace but no macro matches, add a "no match" indicator
528 if (shouldShowMatchingMacroDetails && !hasMatchingMacro && context.identifier.length > 0) {
529 const noMatchOption = new SimpleAutoCompleteOption({
530 name: context.identifier,
531 symbol: '❌',
532 description: `No macro found: "${context.identifier}"`,
533 detailedDescription: `The macro name <code>${context.identifier}</code> does not exist.<br><br>Check spelling or use a different macro name.`,
534 type: 'error',
535 });
536 noMatchOption.valueProvider = () => '';
537 noMatchOption.makeSelectable = false;
538 noMatchOption.matchProvider = () => true; // Always show
539 noMatchOption.sortPriority = 0; // Top priority
540 options.unshift(noMatchOption);
541 }
542
472 return options;543 return options;
473}544}
474545
@@ -600,7 +671,17 @@ export function findMacroAtCursor(text, cursorPos) {
600 // Search backwards for opening {{ while tracking nesting depth for nested macros671 // Search backwards for opening {{ while tracking nesting depth for nested macros
601 let openPos = -1;672 let openPos = -1;
602 let depth = 0;673 let depth = 0;
603 for (let i = cursorPos - 1; i >= 0; i--) {674
675 // If cursor is right after }}, those are the closing braces of the macro we're looking for,
676 // not nested braces. Skip them by starting the search before them.
677 let searchStart = cursorPos - 1;
678 let cursorAfterClosingBraces = false;
679 if (cursorPos >= 2 && text[cursorPos - 1] === '}' && text[cursorPos - 2] === '}') {
680 searchStart = cursorPos - 3; // Start before the }}
681 cursorAfterClosingBraces = true;
682 }
683
684 for (let i = searchStart; i >= 0; i--) {
604 if (text[i] === '}' && i > 0 && text[i - 1] === '}') {685 if (text[i] === '}' && i > 0 && text[i - 1] === '}') {
605 // Found }}, going backwards means we're entering a nested macro686 // Found }}, going backwards means we're entering a nested macro
606 depth++;687 depth++;
@@ -624,29 +705,35 @@ export function findMacroAtCursor(text, cursorPos) {
624705
625 // Search forwards for closing }} while tracking nesting depth706 // Search forwards for closing }} while tracking nesting depth
626 let closePos = -1;707 let closePos = -1;
627 depth = 0;708
628 for (let i = cursorPos; i < text.length - 1; i++) {709 // If cursor is right after }}, we already know where the closing braces are
629 if (text[i] === '{' && text[i + 1] === '{') {710 if (cursorAfterClosingBraces) {
630 // Found {{, entering a nested macro711 closePos = cursorPos;
631 depth++;712 } else {
632 i++; // Skip the other brace713 depth = 0;
633 continue;714 for (let i = cursorPos; i < text.length - 1; i++) {
634 }715 if (text[i] === '{' && text[i + 1] === '{') {
635 if (text[i] === '}' && text[i + 1] === '}') {716 // Found {{, entering a nested macro
636 if (depth > 0) {717 depth++;
637 // This }} closes a nested macro
638 depth--;
639 i++; // Skip the other brace718 i++; // Skip the other brace
640 continue;719 continue;
641 }720 }
642 // Found our closing }} at depth 0721 if (text[i] === '}' && text[i + 1] === '}') {
643 closePos = i + 2;722 if (depth > 0) {
644 break;723 // This }} closes a nested macro
724 depth--;
725 i++; // Skip the other brace
726 continue;
727 }
728 // Found our closing }} at depth 0
729 closePos = i + 2;
730 break;
731 }
645 }732 }
646 }
647733
648 if (closePos === -1) {734 if (closePos === -1) {
649 closePos = text.length;735 closePos = text.length;
736 }
650 }737 }
651738
652 const hasClosingBraces = closePos <= text.length && text.slice(closePos - 2, closePos) === '}}';739 const hasClosingBraces = closePos <= text.length && text.slice(closePos - 2, closePos) === '}}';
@@ -908,7 +995,7 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
908 resultIdentifier = context.invalidTrailingChars || '';995 resultIdentifier = context.invalidTrailingChars || '';
909 // Use actual variableNameEnd position from parsing996 // Use actual variableNameEnd position from parsing
910 resultStart = macro.start + 2 + context.variableNameEnd;997 resultStart = macro.start + 2 + context.variableNameEnd;
911 } else if (context.isTypingValue) {998 } else if (context.isTypingValue && !context.isTypingClosingBrace) {
912 // Typing value: identifier = value being typed, start = after operator999 // Typing value: identifier = value being typed, start = after operator
913 resultIdentifier = context.variableValue;1000 resultIdentifier = context.variableValue;
914 // Use actual operatorEnd position from parsing (accounts for whitespace)1001 // Use actual operatorEnd position from parsing (accounts for whitespace)
@@ -920,6 +1007,10 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
9201007
921 makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;1008 makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`;
922 makeNoOptionsText = () => 'Enter a variable value';1009 makeNoOptionsText = () => 'Enter a variable value';
1010 } else if (context.isTypingClosingBrace) {
1011 // Typing closing brace on variable shorthand - show context, no replacement needed
1012 resultIdentifier = '';
1013 resultStart = cursorPos;
923 } else {1014 } else {
924 // Fallback: use variable name1015 // Fallback: use variable name
925 resultIdentifier = context.variableName;1016 resultIdentifier = context.variableName;