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>
Signed| @@ -155,6 +155,10 @@ export class AutoComplete { | ||
| 155 | 155 | */ |
| 156 | 156 | updateName(item) { |
| 157 | 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 | 162 | switch (this.matchType) { |
| 159 | 163 | case 'strict': { |
| 160 | 164 | chars.forEach((it, idx) => { |
| @@ -13,6 +13,7 @@ export class AutoCompleteOption { | ||
| 13 | 13 | /** @type {(input:string)=>boolean} */ matchProvider; |
| 14 | 14 | /** @type {(input:string)=>string} */ valueProvider; |
| 15 | 15 | /** @type {boolean} */ makeSelectable = false; |
| 16 | + /** @type {boolean} */ forceFullNameMatch = false; | |
| 16 | 17 | |
| 17 | 18 | /** |
| 18 | 19 | * Offset to adjust the replacement start position. |
| @@ -31,6 +31,7 @@ import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnos | ||
| 31 | 31 | * @property {string[]} args - Array of arguments typed so far. |
| 32 | 32 | * @property {number} currentArgIndex - Index of the argument being typed (-1 if on identifier). |
| 33 | 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 | 35 | * @property {boolean} hasSpaceAfterIdentifier - Whether there's a space after the identifier (for space-separated args). |
| 35 | 36 | * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace). |
| 36 | 37 | * @property {number} separatorCount - Number of '::' separators found. |
| @@ -1033,6 +1034,7 @@ export class VariableValueContextAutoCompleteOption extends AutoCompleteOption { | ||
| 1033 | 1034 | super('value', '📝'); |
| 1034 | 1035 | this.#operatorDef = operatorDef; |
| 1035 | 1036 | this.#currentValue = currentValue; |
| 1037 | + this.forceFullNameMatch = true; | |
| 1036 | 1038 | } |
| 1037 | 1039 | |
| 1038 | 1040 | /** @returns {{ symbol: string, name: string, description: string, needsValue: boolean }} */ |
| @@ -1236,8 +1238,8 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | ||
| 1236 | 1238 | export function parseMacroContext(macroText, cursorOffset) { |
| 1237 | 1239 | let i = 0; |
| 1238 | 1240 | |
| 1239 | - // Skip leading whitespace | |
| 1241 | + // Skip leading whitespace (but NOT newlines - those stop macro parsing for autocomplete) | |
| 1240 | 1242 | while (i < macroText.length && /[ \st]/.test(macroText[i])) { |
| 1241 | 1243 | i++; |
| 1242 | 1244 | } |
| 1243 | 1245 | |
| @@ -1257,8 +1259,8 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1257 | 1259 | flags.push(char); |
| 1258 | 1260 | i++; |
| 1259 | 1261 | flagEndPositions.push(i); // Position right after this flag |
| 1260 | 1262 | // Skip whitespace between flags (but NOT newlines - those stop macro parsing for autocomplete) |
| 1261 | 1263 | while (i < macroText.length && /[ \st]/.test(macroText[i])) { |
| 1262 | 1264 | i++; |
| 1263 | 1265 | } |
| 1264 | 1266 | } else { |
| @@ -1387,15 +1389,49 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1387 | 1389 | const operatorNeedsValue = operatorDef?.needsValue ?? false; |
| 1388 | 1390 | |
| 1389 | 1391 | // 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 | 1394 | if (operatorNeedsValue) { |
| 1391 | 1395 | // Skip whitespace after operator |
| 1392 | 1396 | while (i < macroText.length && /\s/.test(macroText[i])) { |
| 1393 | 1397 | i++; |
| 1394 | 1398 | } |
| 1399 | + // valueStartPos = i; | |
| 1395 | 1400 | variableValue = macroText.slice(i).trimEnd(); |
| 1396 | 1401 | } |
| 1397 | 1402 | |
| 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 | 1432 | // 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 | 1435 | const prefixEnd = (macroText.indexOf(variablePrefix) ?? 0) + 1; |
| 1400 | 1436 | if (cursorOffset < prefixEnd) { |
| 1401 | 1437 | // Cursor is before the prefix - still in flags area conceptually |
| @@ -1403,9 +1439,10 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1403 | 1439 | } else if (cursorOffset <= variableNameEnd) { |
| 1404 | 1440 | // Cursor is in the variable name area (including at the end) |
| 1405 | 1441 | isTypingVariableName = true; |
| 1406 | 1442 | } else if (variableName.length > 0 && !variableOperator && !hasInvalidTrailingChars && !isTypingClosingBrace) { |
| 1407 | 1443 | // Cursor is after variable name but no operator yet (and no invalid chars) |
| 1408 | 1444 | // This includes partial operator prefixes like '+', '-', '|', '?', '>', '<' |
| 1445 | + // But NOT when typing a closing brace - that takes precedence | |
| 1409 | 1446 | isTypingOperator = true; |
| 1410 | 1447 | } else if (variableName.length > 0 && variableOperator && isShortOperatorPrefix(variableOperator) && cursorOffset <= variableOperatorEnd) { |
| 1411 | 1448 | // Short operator that could be prefix of longer one (e.g., > could become >=) |
| @@ -1434,6 +1471,7 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1434 | 1471 | args: [], |
| 1435 | 1472 | currentArgIndex: -1, |
| 1436 | 1473 | isTypingSeparator: false, |
| 1474 | + isTypingClosingBrace, | |
| 1437 | 1475 | hasSpaceAfterIdentifier: false, |
| 1438 | 1476 | hasSpaceArgContent: false, |
| 1439 | 1477 | separatorCount: 0, |
| @@ -1465,20 +1503,55 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1465 | 1503 | let partStart = i; |
| 1466 | 1504 | let j = 0; |
| 1467 | 1505 | |
| 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 | 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 | 1535 | parts.push({ text: currentPart, start: partStart, end: i + j }); |
| 1471 | 1536 | separatorPositions.push({ start: i + j, end: i + j + 2 }); |
| 1472 | 1537 | currentPart = ''; |
| 1473 | 1538 | j += 2; |
| 1474 | 1539 | partStart = i + j; |
| 1540 | + hasSeenSeparator = true; | |
| 1475 | 1541 | } else { |
| 1476 | 1542 | currentPart += remainingText[j]; |
| 1477 | 1543 | j++; |
| 1478 | 1544 | } |
| 1479 | 1545 | } |
| 1480 | 1546 | // 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 | + } | |
| 1482 | 1555 | |
| 1483 | 1556 | // Determine if cursor is in the flags area (at or before identifier starts) |
| 1484 | 1557 | const identifierStartPos = parts[0]?.start ?? i; |
| @@ -1553,7 +1626,14 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1553 | 1626 | } |
| 1554 | 1627 | |
| 1555 | 1628 | // Clean identifier: strip trailing colons (for partial :: typing) |
| 1629 | + // Also strip trailing single } (for partial }} typing) - but only if no separators/args | |
| 1556 | 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 | + } | |
| 1557 | 1637 | |
| 1558 | 1638 | // Build args array - include space-separated arg if present |
| 1559 | 1639 | // Trim args like the macro engine does |
| @@ -1574,6 +1654,7 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1574 | 1654 | args, |
| 1575 | 1655 | currentArgIndex, |
| 1576 | 1656 | isTypingSeparator, |
| 1657 | + isTypingClosingBrace, | |
| 1577 | 1658 | hasSpaceAfterIdentifier, |
| 1578 | 1659 | hasSpaceArgContent: spaceArgText.length > 0, |
| 1579 | 1660 | separatorCount: separatorPositions.length, |
| @@ -1581,7 +1662,9 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1581 | 1662 | isVariableShorthand: false, |
| 1582 | 1663 | variablePrefix: null, |
| 1583 | 1664 | variableName: '', |
| 1665 | + variableNameEnd: null, | |
| 1584 | 1666 | variableOperator: null, |
| 1667 | + variableOperatorEnd: null, | |
| 1585 | 1668 | variableValue: '', |
| 1586 | 1669 | isTypingVariableName: false, |
| 1587 | 1670 | isTypingOperator: false, |
| @@ -57,6 +57,8 @@ import { extension_settings } from '../extensions.js'; | ||
| 57 | 57 | * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes |
| 58 | 58 | */ |
| 59 | 59 | |
| 60 | +/** @typedef {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption|SimpleAutoCompleteOption)} AnyMacroAutoCompleteOption */ | |
| 61 | + | |
| 60 | 62 | /** |
| 61 | 63 | * Finds unclosed scoped macros in the text up to cursor position. |
| 62 | 64 | * Uses the MacroParser and MacroCstWalker for accurate analysis. |
| @@ -136,11 +138,11 @@ export function findUnclosedScopesRegex(text) { | ||
| 136 | 138 | * @param {Object} [opts] - Optional configuration. |
| 137 | 139 | * @param {boolean} [opts.forIfCondition=false] - If true, options are for {{if}} condition (closes with }}). |
| 138 | 140 | * @param {string} [opts.paddingAfter=''] - Whitespace to add before closing }}. |
| 139 | 141 | * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)AnyMacroAutoCompleteOption[]} |
| 140 | 142 | */ |
| 141 | 143 | export function buildVariableShorthandOptions(context, opts = {}) { |
| 142 | 144 | const { forIfCondition = false, paddingAfter = '' } = opts; |
| 143 | 145 | /** @type {(VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption)AnyMacroAutoCompleteOption[]} */ |
| 144 | 146 | const options = []; |
| 145 | 147 | |
| 146 | 148 | const isLocal = context.variablePrefix === '.'; |
| @@ -276,7 +278,7 @@ export function buildVariableShorthandOptions(context, opts = {}) { | ||
| 276 | 278 | |
| 277 | 279 | // If typing value (after = or +=), no autocomplete needed - freeform text |
| 278 | 280 | // But we show the current context for reference (greyed out, non-selectable) |
| 279 | 281 | if (context.isTypingValue && !context.isTypingOperator && !context.isTypingClosingBrace) { |
| 280 | 282 | // Show the current variable name as context (non-selectable) |
| 281 | 283 | const varNameOption = new VariableNameAutoCompleteOption(context.variableName, scope, false); |
| 282 | 284 | varNameOption.valueProvider = () => ''; // Context only |
| @@ -331,6 +333,49 @@ export function buildVariableShorthandOptions(context, opts = {}) { | ||
| 331 | 333 | } |
| 332 | 334 | } |
| 333 | 335 | |
| 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 | 379 | return options; |
| 335 | 380 | } |
| 336 | 381 | |
| @@ -340,10 +385,10 @@ export function buildVariableShorthandOptions(context, opts = {}) { | ||
| 340 | 385 | * When typing arguments (after ::), prioritizes the exact macro match. |
| 341 | 386 | * @param {MacroAutoCompleteContext} context |
| 342 | 387 | * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection. |
| 343 | 388 | * @returns {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)AnyMacroAutoCompleteOption[]} |
| 344 | 389 | */ |
| 345 | 390 | export function buildEnhancedMacroOptions(context, textUpToCursor) { |
| 346 | 391 | /** @type {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption)AnyMacroAutoCompleteOption[]} */ |
| 347 | 392 | const options = []; |
| 348 | 393 | |
| 349 | 394 | if (context.isVariableShorthand) { |
| @@ -427,16 +472,26 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) { | ||
| 427 | 472 | const allMacros = macroSystem.registry.getAllMacros({ excludeHiddenAliases: true }); |
| 428 | 473 | |
| 429 | 474 | // 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 | 476 | const isTypingArgs = context.currentArgIndex >= 0; |
| 477 | + const isTypingClosingBrace = context.isTypingClosingBrace ?? false; | |
| 478 | + const shouldShowMatchingMacroDetails = isTypingArgs || isTypingClosingBrace; | |
| 431 | 479 | |
| 432 | 480 | // Check if we're inside a scoped {{if}} for {{else}} selectability |
| 433 | 481 | const isInsideScopedIf = unclosedScopes.some(scope => scope.name === 'if'); |
| 434 | 482 | |
| 483 | + // Track if any macro matches the identifier (for "no match" message) | |
| 484 | + let hasMatchingMacro = false; | |
| 485 | + | |
| 435 | 486 | for (const macro of allMacros) { |
| 436 | 487 | // Check if this macro matches the typed identifier |
| 437 | 488 | const isExactMatch = macro.name === context.identifier; |
| 438 | 489 | const isAliasMatch = macro.aliasOf === context.identifier; |
| 439 | 490 | |
| 491 | + if (isExactMatch || isAliasMatch) { | |
| 492 | + hasMatchingMacro = true; | |
| 493 | + } | |
| 494 | + | |
| 440 | 495 | // Only pass context to the macro that matches the identifier being typed |
| 441 | 496 | // This ensures argument hints only show for the relevant macro |
| 442 | 497 | /** @type {MacroAutoCompleteContext|EnhancedMacroAutoCompleteOptions|null} */ |
| @@ -461,14 +516,30 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) { | ||
| 461 | 516 | option.makeSelectable = false; |
| 462 | 517 | } |
| 463 | 518 | |
| 464 | 519 | // When typing arguments or closing brace, prioritize exact matches by putting them first |
| 465 | 520 | if (isTypingArgsshouldShowMatchingMacroDetails && (isExactMatch || isAliasMatch)) { |
| 466 | 521 | options.unshift(option); |
| 467 | 522 | } else { |
| 468 | 523 | options.push(option); |
| 469 | 524 | } |
| 470 | 525 | } |
| 471 | 526 | |
| 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 | 543 | return options; |
| 473 | 544 | } |
| 474 | 545 | |
| @@ -600,7 +671,17 @@ export function findMacroAtCursor(text, cursorPos) { | ||
| 600 | 671 | // Search backwards for opening {{ while tracking nesting depth for nested macros |
| 601 | 672 | let openPos = -1; |
| 602 | 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 | 685 | if (text[i] === '}' && i > 0 && text[i - 1] === '}') { |
| 605 | 686 | // Found }}, going backwards means we're entering a nested macro |
| 606 | 687 | depth++; |
| @@ -624,29 +705,35 @@ export function findMacroAtCursor(text, cursorPos) { | ||
| 624 | 705 | |
| 625 | 706 | // Search forwards for closing }} while tracking nesting depth |
| 626 | 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 macro | |
| 711 | + closePos = cursorPos; | |
| 631 | - depth++; | |
| 712 | + } else { | |
| 632 | - i++; // Skip the other brace | |
| 713 | + 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 | 718 | i++; // Skip the other brace |
| 640 | 719 | continue; |
| 641 | 720 | } |
| 642 | - // Found our closing }} at depth 0 | |
| 721 | + 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 | - } | |
| 647 | 733 | |
| 648 | 734 | if (closePos === -1) { |
| 649 | 735 | closePos = text.length; |
| 736 | + } | |
| 650 | 737 | } |
| 651 | 738 | |
| 652 | 739 | const hasClosingBraces = closePos <= text.length && text.slice(closePos - 2, closePos) === '}}'; |
| @@ -908,7 +995,7 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | ||
| 908 | 995 | resultIdentifier = context.invalidTrailingChars || ''; |
| 909 | 996 | // Use actual variableNameEnd position from parsing |
| 910 | 997 | resultStart = macro.start + 2 + context.variableNameEnd; |
| 911 | 998 | } else if (context.isTypingValue && !context.isTypingClosingBrace) { |
| 912 | 999 | // Typing value: identifier = value being typed, start = after operator |
| 913 | 1000 | resultIdentifier = context.variableValue; |
| 914 | 1001 | // Use actual operatorEnd position from parsing (accounts for whitespace) |
| @@ -920,6 +1007,10 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | ||
| 920 | 1007 | |
| 921 | 1008 | makeNoMatchText = () => `Type any value you want to ${context.variableOperator == '+=' ? `add to the variable '${context.variableName}'` : `set the variable '${context.variableName}' to`}.`; |
| 922 | 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 | 1014 | } else { |
| 924 | 1015 | // Fallback: use variable name |
| 925 | 1016 | resultIdentifier = context.variableName; |