Macros 2.0 - Optional scoped content + improved closing-tag autocomplete (#5117) * feat(macros): Add optional scope detection and improved closing tag autocomplete - Implement `isScopeOptional()` to detect when scoped content is optional based on macro argument requirements - Filter optional scopes from autocomplete hints unless force-triggered (Ctrl+Space) - Add "OPTIONAL" badge styling for optional scoped content in autocomplete UI - Show multiple closing tag suggestions (innermost to outermost) with priority ordering - Display nesting level information for nested optional scopes * fix(autocomplete): show original macro details when typing closing tags Add nameOffset=2 to MacroClosingTagAutoCompleteOption to skip {{ prefix for fuzzy highlighting. When typing closing tags ({{/macroName}}), detect and show the original macro's details instead of "no match" error by looking up the macro definition and creating a non-selectable context option with no argument highlighting.
Signed| @@ -507,6 +507,28 @@ | |||
| 507 | border-radius: 3px; | 507 | border-radius: 3px; |
| 508 | } | 508 | } |
| 509 | 509 | ||
| 510 | /* OPTIONAL badge for optional scoped content */ | ||
| 511 | .macro-ac-optional-badge { | ||
| 512 | display: inline-block; | ||
| 513 | background: linear-gradient(135deg, #f0ad4e, #ec971f); | ||
| 514 | color: #000; | ||
| 515 | font-weight: bold; | ||
| 516 | font-size: 0.75em; | ||
| 517 | padding: 0.15em 0.5em; | ||
| 518 | border-radius: 3px; | ||
| 519 | text-transform: uppercase; | ||
| 520 | letter-spacing: 0.05em; | ||
| 521 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); | ||
| 522 | } | ||
| 523 | |||
| 524 | /* Smaller variant for use in autocomplete list items */ | ||
| 525 | .macro-ac-optional-badge-small { | ||
| 526 | font-size: 0.65em; | ||
| 527 | padding: 0.1em 0.35em; | ||
| 528 | vertical-align: middle; | ||
| 529 | color: #f0ad4e; | ||
| 530 | } | ||
| 531 | |||
| 510 | /* Closing tag autocomplete option */ | 532 | /* Closing tag autocomplete option */ |
| 511 | .autoComplete > .item.macro-closing-tag-item > .type { | 533 | .autoComplete > .item.macro-closing-tag-item > .type { |
| 512 | color: var(--ac-color-matchedText, var(--SmartThemeBorderColor)); | 534 | color: var(--ac-color-matchedText, var(--SmartThemeBorderColor)); |
| @@ -36,6 +36,7 @@ import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnos | |||
| 36 | * @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). |
| 37 | * @property {number} separatorCount - Number of '::' separators found. | 37 | * @property {number} separatorCount - Number of '::' separators found. |
| 38 | * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag). | 38 | * @property {boolean} [isInScopedContent] - Whether cursor is in scoped content (after }} but before closing tag). |
| 39 | * @property {boolean} [isScopedContentOptional] - Whether the scoped content is optional (for display purposes). | ||
| 39 | * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content. | 40 | * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content. |
| 40 | * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $). | 41 | * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $). |
| 41 | * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null. | 42 | * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null. |
| @@ -307,12 +308,23 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | |||
| 307 | const info = document.createElement('div'); | 308 | const info = document.createElement('div'); |
| 308 | info.classList.add('macro-ac-scoped-info'); | 309 | info.classList.add('macro-ac-scoped-info'); |
| 309 | 310 | ||
| 311 | // If the scoped content is optional, show a prominent OPTIONAL badge | ||
| 312 | if (this.#context.isScopedContentOptional) { | ||
| 313 | const optionalBadge = document.createElement('span'); | ||
| 314 | optionalBadge.classList.add('macro-ac-optional-badge'); | ||
| 315 | optionalBadge.textContent = 'OPTIONAL'; | ||
| 316 | info.append(optionalBadge); | ||
| 317 | } | ||
| 318 | |||
| 310 | const icon = document.createElement('i'); | 319 | const icon = document.createElement('i'); |
| 311 | icon.classList.add('fa-solid', 'fa-layer-group'); | 320 | icon.classList.add('fa-solid', 'fa-layer-group'); |
| 312 | info.append(icon); | 321 | info.append(icon); |
| 313 | 322 | ||
| 314 | const text = document.createElement('span'); | 323 | const text = document.createElement('span'); |
| 315 | text.innerHTML = `Typing <strong>scoped content</strong> for <code>{{${this.#context.scopedMacroName}}}</code>. Close with <code>{{/${this.#context.scopedMacroName}}}</code>`; | 324 | const closingHint = this.#context.isScopedContentOptional |
| 325 | ? `Can optionally close with <code>{{/${this.#context.scopedMacroName}}}</code>` | ||
| 326 | : `Close with <code>{{/${this.#context.scopedMacroName}}}</code>`; | ||
| 327 | text.innerHTML = `Typing <strong>scoped content</strong> for <code>{{${this.#context.scopedMacroName}}}</code>. ${closingHint}`; | ||
| 316 | info.append(text); | 328 | info.append(text); |
| 317 | 329 | ||
| 318 | return info; | 330 | return info; |
| @@ -1113,12 +1125,20 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | |||
| 1113 | /** @type {string} */ | 1125 | /** @type {string} */ |
| 1114 | #paddingAfter; | 1126 | #paddingAfter; |
| 1115 | 1127 | ||
| 1128 | /** @type {boolean} */ | ||
| 1129 | #isOptional; | ||
| 1130 | |||
| 1131 | /** @type {number} */ | ||
| 1132 | #nestingLevel; | ||
| 1133 | |||
| 1116 | /** | 1134 | /** |
| 1117 | * @param {string} macroName - The name of the macro to close. | 1135 | * @param {string} macroName - The name of the macro to close. |
| 1118 | * @param {Object} [options] - Optional configuration. | 1136 | * @param {Object} [options] - Optional configuration. |
| 1119 | * @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding). | 1137 | * @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding). |
| 1120 | * @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding). | 1138 | * @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding). |
| 1121 | * @param {string} [options.currentPadding=''] - Whitespace the user has already typed after {{. | 1139 | * @param {string} [options.currentPadding=''] - Whitespace the user has already typed after {{. |
| 1140 | * @param {boolean} [options.isOptional=false] - Whether this closing tag is for an optional scope. | ||
| 1141 | * @param {number} [options.nestingLevel=0] - Nesting level (0 = innermost). | ||
| 1122 | */ | 1142 | */ |
| 1123 | constructor(macroName, options = {}) { | 1143 | constructor(macroName, options = {}) { |
| 1124 | // The closing tag is what we're suggesting - use /macroName as the name for matching | 1144 | // The closing tag is what we're suggesting - use /macroName as the name for matching |
| @@ -1127,6 +1147,8 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | |||
| 1127 | this.#macroName = macroName; | 1147 | this.#macroName = macroName; |
| 1128 | this.#paddingBefore = options.paddingBefore ?? ''; | 1148 | this.#paddingBefore = options.paddingBefore ?? ''; |
| 1129 | this.#paddingAfter = options.paddingAfter ?? ''; | 1149 | this.#paddingAfter = options.paddingAfter ?? ''; |
| 1150 | this.#isOptional = options.isOptional ?? false; | ||
| 1151 | this.#nestingLevel = options.nestingLevel ?? 0; | ||
| 1130 | 1152 | ||
| 1131 | // Calculate the replacement offset to replace any existing whitespace the user typed | 1153 | // Calculate the replacement offset to replace any existing whitespace the user typed |
| 1132 | // This allows us to normalize the whitespace to match the opening tag's style | 1154 | // This allows us to normalize the whitespace to match the opening tag's style |
| @@ -1144,6 +1166,10 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | |||
| 1144 | // Make selectable so TAB completion works (valueProvider alone makes it non-selectable) | 1166 | // Make selectable so TAB completion works (valueProvider alone makes it non-selectable) |
| 1145 | this.makeSelectable = true; | 1167 | this.makeSelectable = true; |
| 1146 | 1168 | ||
| 1169 | // nameOffset = 2 to skip the {{ prefix in the display for fuzzy highlighting | ||
| 1170 | // The name is /macroName but display shows {{/macroName}} | ||
| 1171 | this.nameOffset = 2; | ||
| 1172 | |||
| 1147 | // Highest priority - closing tags should always appear at the very top | 1173 | // Highest priority - closing tags should always appear at the very top |
| 1148 | this.sortPriority = 1; | 1174 | this.sortPriority = 1; |
| 1149 | } | 1175 | } |
| @@ -1195,7 +1221,21 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | |||
| 1195 | help.classList.add('help'); | 1221 | help.classList.add('help'); |
| 1196 | const content = document.createElement('span'); | 1222 | const content = document.createElement('span'); |
| 1197 | content.classList.add('helpContent'); | 1223 | content.classList.add('helpContent'); |
| 1224 | |||
| 1225 | // Build description based on optional status and nesting | ||
| 1226 | if (this.#isOptional) { | ||
| 1227 | const optionalBadge = document.createElement('span'); | ||
| 1228 | optionalBadge.classList.add('macro-ac-optional-badge', 'macro-ac-optional-badge-small'); | ||
| 1229 | optionalBadge.textContent = 'OPTIONAL'; | ||
| 1230 | content.append(optionalBadge); | ||
| 1231 | content.append(' '); | ||
| 1232 | |||
| 1233 | const nestingInfo = this.#nestingLevel > 0 ? ` (nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep)` : ''; | ||
| 1234 | content.append(document.createTextNode(`Optionally close {{${this.#macroName}}}${nestingInfo}`)); | ||
| 1235 | } else { | ||
| 1198 | content.textContent = `Close the {{${this.#macroName}}} scoped macro.`; | 1236 | content.textContent = `Close the {{${this.#macroName}}} scoped macro.`; |
| 1237 | } | ||
| 1238 | |||
| 1199 | help.append(content); | 1239 | help.append(content); |
| 1200 | li.append(help); | 1240 | li.append(help); |
| 1201 | 1241 | ||
| @@ -1212,6 +1252,14 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | |||
| 1212 | const details = document.createElement('div'); | 1252 | const details = document.createElement('div'); |
| 1213 | details.classList.add('macro-closing-tag-details'); | 1253 | details.classList.add('macro-closing-tag-details'); |
| 1214 | 1254 | ||
| 1255 | // If optional, show badge at the top | ||
| 1256 | if (this.#isOptional) { | ||
| 1257 | const optionalBadge = document.createElement('span'); | ||
| 1258 | optionalBadge.classList.add('macro-ac-optional-badge'); | ||
| 1259 | optionalBadge.textContent = 'OPTIONAL'; | ||
| 1260 | details.append(optionalBadge); | ||
| 1261 | } | ||
| 1262 | |||
| 1215 | // Header | 1263 | // Header |
| 1216 | const header = document.createElement('h3'); | 1264 | const header = document.createElement('h3'); |
| 1217 | header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`; | 1265 | header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`; |
| @@ -1219,7 +1267,12 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | |||
| 1219 | 1267 | ||
| 1220 | // Description | 1268 | // Description |
| 1221 | const desc = document.createElement('p'); | 1269 | const desc = document.createElement('p'); |
| 1270 | if (this.#isOptional) { | ||
| 1271 | const nestingInfo = this.#nestingLevel > 0 ? ` This scope is nested ${this.#nestingLevel} level${this.#nestingLevel > 1 ? 's' : ''} deep.` : ''; | ||
| 1272 | desc.textContent = `Optionally inserts the closing tag {{/${this.#macroName}}}. The scoped content for this macro is optional - you can close it or leave it open.${nestingInfo}`; | ||
| 1273 | } else { | ||
| 1222 | desc.textContent = `Inserts the closing tag {{/${this.#macroName}}} to complete the scoped macro. The content between the opening and closing tags will be passed as the last argument.`; | 1274 | desc.textContent = `Inserts the closing tag {{/${this.#macroName}}} to complete the scoped macro. The content between the opening and closing tags will be passed as the last argument.`; |
| 1275 | } | ||
| 1223 | details.append(desc); | 1276 | details.append(desc); |
| 1224 | 1277 | ||
| 1225 | frag.append(details); | 1278 | frag.append(details); |
| @@ -120,7 +120,7 @@ export function setMacroAutoComplete(textarea, { autocompleteMode = MACRO_AUTOCO | |||
| 120 | const ac = new AutoComplete( | 120 | const ac = new AutoComplete( |
| 121 | textarea, | 121 | textarea, |
| 122 | () => shouldActivateMacroAutocomplete(ac.text, textarea.selectionStart, { isForced: ac.isShowForced, autocompleteMode }), | 122 | () => shouldActivateMacroAutocomplete(ac.text, textarea.selectionStart, { isForced: ac.isShowForced, autocompleteMode }), |
| 123 | (text, index) => getMacroAutoCompleteAt(text, index), | 123 | (text, index) => getMacroAutoCompleteAt(text, index, { isForced: ac.isShowForced }), |
| 124 | true, // isFloating - always use floating mode for free text macro autocomplete | 124 | true, // isFloating - always use floating mode for free text macro autocomplete |
| 125 | ); | 125 | ); |
| 126 | 126 | ||
| @@ -55,6 +55,7 @@ import { extension_settings } from '../extensions.js'; | |||
| 55 | * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro | 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 | 56 | * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor |
| 57 | * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes | 57 | * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes |
| 58 | * @property {boolean} [isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space) | ||
| 58 | */ | 59 | */ |
| 59 | 60 | ||
| 60 | /** @typedef {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption|SimpleAutoCompleteOption)} AnyMacroAutoCompleteOption */ | 61 | /** @typedef {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption|SimpleAutoCompleteOption)} AnyMacroAutoCompleteOption */ |
| @@ -102,9 +103,12 @@ export function findUnclosedScopesRegex(text) { | |||
| 102 | const name = match[3]; | 103 | const name = match[3]; |
| 103 | 104 | ||
| 104 | if (isClosing) { | 105 | if (isClosing) { |
| 105 | // Pop matching opener (case-insensitive) | 106 | // Find matching opener in stack (case-insensitive) |
| 106 | if (stack.length > 0 && stack[stack.length - 1].name.toLowerCase() === name.toLowerCase()) { | 107 | // When closing an outer scope, all inner unclosed scopes are implicitly closed |
| 107 | stack.pop(); | 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); | ||
| 108 | } | 112 | } |
| 109 | } else { | 113 | } else { |
| 110 | // Check if macro can accept scoped content | 114 | // Check if macro can accept scoped content |
| @@ -133,6 +137,70 @@ export function findUnclosedScopesRegex(text) { | |||
| 133 | } | 137 | } |
| 134 | 138 | ||
| 135 | /** | 139 | /** |
| 140 | * Checks if a scoped macro's scope content is optional (i.e., all required args are already filled). | ||
| 141 | * Used to determine whether to show the scope hint by default or only when forced. | ||
| 142 | * | ||
| 143 | * @param {UnclosedScope} scope - The unclosed scope info. | ||
| 144 | * @param {string} textUpToCursor - The text up to cursor to parse the macro content. | ||
| 145 | * @returns {boolean} - True if the scope content is optional. | ||
| 146 | */ | ||
| 147 | function isScopeOptional(scope, textUpToCursor) { | ||
| 148 | const def = macroSystem.registry.getPrimaryMacro(scope.name); | ||
| 149 | if (!def) { | ||
| 150 | // Unknown macro - treat scope as required (show hint) | ||
| 151 | return false; | ||
| 152 | } | ||
| 153 | |||
| 154 | // Find the macro's closing }} to extract its content | ||
| 155 | const openingEnd = textUpToCursor.indexOf('}}', scope.startOffset); | ||
| 156 | if (openingEnd === -1) { | ||
| 157 | // Macro not closed yet - can't determine | ||
| 158 | return false; | ||
| 159 | } | ||
| 160 | |||
| 161 | // Extract content between {{ and }} to count arguments | ||
| 162 | const macroContent = textUpToCursor.slice(scope.startOffset + 2, openingEnd); | ||
| 163 | const context = parseMacroContext(macroContent, macroContent.length); | ||
| 164 | |||
| 165 | // Count current arguments (including space-separated arg if present) | ||
| 166 | const currentArgCount = context.args.length; | ||
| 167 | |||
| 168 | // The scoped content would be the next argument (currentArgCount + 1) | ||
| 169 | // Scope is optional if: | ||
| 170 | // 1. Current args already meet minArgs requirement, AND | ||
| 171 | // 2. Adding one more (scope) would still be <= maxArgs | ||
| 172 | const wouldBeArgIndex = currentArgCount; // 0-indexed | ||
| 173 | const scopeIsOptional = currentArgCount >= def.minArgs && wouldBeArgIndex < def.maxArgs; | ||
| 174 | |||
| 175 | // Check if the argument at wouldBeArgIndex is marked as optional in the definition | ||
| 176 | if (def.unnamedArgDefs && def.unnamedArgDefs[wouldBeArgIndex]) { | ||
| 177 | return def.unnamedArgDefs[wouldBeArgIndex].optional === true; | ||
| 178 | } | ||
| 179 | |||
| 180 | // If no explicit arg definition, use the min/max args logic | ||
| 181 | return scopeIsOptional; | ||
| 182 | } | ||
| 183 | |||
| 184 | /** | ||
| 185 | * Filters unclosed scopes to exclude those with optional scope content. | ||
| 186 | * Used when autocomplete is not force-triggered (Ctrl+Space). | ||
| 187 | * | ||
| 188 | * @param {UnclosedScope[]} unclosedScopes - The unclosed scopes to filter. | ||
| 189 | * @param {string} textUpToCursor - The text up to cursor. | ||
| 190 | * @param {boolean} isForced - Whether autocomplete was force-triggered. | ||
| 191 | * @returns {UnclosedScope[]} - Filtered scopes (excludes optional scopes unless forced). | ||
| 192 | */ | ||
| 193 | function filterOptionalScopes(unclosedScopes, textUpToCursor, isForced) { | ||
| 194 | if (isForced) { | ||
| 195 | // When forced, show all scopes including optional ones | ||
| 196 | return unclosedScopes; | ||
| 197 | } | ||
| 198 | |||
| 199 | // Filter out scopes where the scope content is optional | ||
| 200 | return unclosedScopes.filter(scope => !isScopeOptional(scope, textUpToCursor)); | ||
| 201 | } | ||
| 202 | |||
| 203 | /** | ||
| 136 | * Builds autocomplete options for variable shorthand syntax (.varName or $varName). | 204 | * Builds autocomplete options for variable shorthand syntax (.varName or $varName). |
| 137 | * @param {MacroAutoCompleteContext} context | 205 | * @param {MacroAutoCompleteContext} context |
| 138 | * @param {Object} [opts] - Optional configuration. | 206 | * @param {Object} [opts] - Optional configuration. |
| @@ -385,9 +453,11 @@ export function buildVariableShorthandOptions(context, opts = {}) { | |||
| 385 | * When typing arguments (after ::), prioritizes the exact macro match. | 453 | * When typing arguments (after ::), prioritizes the exact macro match. |
| 386 | * @param {MacroAutoCompleteContext} context | 454 | * @param {MacroAutoCompleteContext} context |
| 387 | * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection. | 455 | * @param {string} [textUpToCursor] - Full document text up to cursor, for unclosed scope detection. |
| 456 | * @param {Object} [opts] - Additional options. | ||
| 457 | * @param {boolean} [opts.isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space). | ||
| 388 | * @returns {AnyMacroAutoCompleteOption[]} | 458 | * @returns {AnyMacroAutoCompleteOption[]} |
| 389 | */ | 459 | */ |
| 390 | export function buildEnhancedMacroOptions(context, textUpToCursor) { | 460 | export function buildEnhancedMacroOptions(context, textUpToCursor, { isForced = false } = {}) { |
| 391 | /** @type {AnyMacroAutoCompleteOption[]} */ | 461 | /** @type {AnyMacroAutoCompleteOption[]} */ |
| 392 | const options = []; | 462 | const options = []; |
| 393 | 463 | ||
| @@ -395,26 +465,53 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) { | |||
| 395 | return buildVariableShorthandOptions(context); | 465 | return buildVariableShorthandOptions(context); |
| 396 | } | 466 | } |
| 397 | 467 | ||
| 398 | // Check for unclosed scoped macros and suggest closing tags first | 468 | // Check for unclosed scoped macros and suggest closing tags |
| 469 | // Iterate from innermost to outermost, adding optional scopes and stopping at first required scope | ||
| 399 | const unclosedScopes = findUnclosedScopes(textUpToCursor); | 470 | const unclosedScopes = findUnclosedScopes(textUpToCursor); |
| 400 | if (unclosedScopes.length > 0) { | 471 | if (unclosedScopes.length > 0) { |
| 401 | // Suggest closing the innermost (last) unclosed scope first | 472 | let firstRequiredPriority = 1; // Priority for the first required (non-optional) scope |
| 402 | const innermostScope = unclosedScopes[unclosedScopes.length - 1]; | 473 | let optionalPriority = 3; // Lower priority for optional scopes |
| 403 | // Preserve whitespace padding from the opening tag | 474 | let foundRequired = false; |
| 404 | // Pass currentPadding so the closing tag can replace user-typed whitespace with the target padding | 475 | let elseOptionAdded = false; |
| 405 | const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name, { | 476 | |
| 406 | paddingBefore: innermostScope.paddingBefore, | 477 | // Iterate from innermost (last) to outermost (first) |
| 407 | paddingAfter: innermostScope.paddingAfter, | 478 | for (let i = unclosedScopes.length - 1; i >= 0; i--) { |
| 479 | const scope = unclosedScopes[i]; | ||
| 480 | const isOptional = isScopeOptional(scope, textUpToCursor); | ||
| 481 | const nestingLevel = unclosedScopes.length - 1 - i; // 0 = innermost | ||
| 482 | |||
| 483 | // If we've already found a required scope, stop adding more | ||
| 484 | if (foundRequired && !isOptional) break; | ||
| 485 | |||
| 486 | const closingOption = new MacroClosingTagAutoCompleteOption(scope.name, { | ||
| 487 | paddingBefore: scope.paddingBefore, | ||
| 488 | paddingAfter: scope.paddingAfter, | ||
| 408 | currentPadding: context.paddingBefore, | 489 | currentPadding: context.paddingBefore, |
| 490 | isOptional: isOptional, | ||
| 491 | nestingLevel: nestingLevel, | ||
| 409 | }); | 492 | }); |
| 493 | |||
| 494 | if (isOptional) { | ||
| 495 | closingOption.sortPriority = optionalPriority++; | ||
| 496 | } else { | ||
| 497 | // First required scope gets top priority | ||
| 498 | closingOption.sortPriority = firstRequiredPriority; | ||
| 499 | foundRequired = true; | ||
| 500 | } | ||
| 501 | |||
| 410 | options.push(closingOption); | 502 | options.push(closingOption); |
| 411 | 503 | ||
| 412 | // If inside a scoped {{if}}, also suggest {{else}} | 504 | // If inside a scoped {{if}}, also suggest {{else}} (only once, for innermost if) |
| 413 | if (innermostScope.name === 'if') { | 505 | if (!elseOptionAdded && scope.name === 'if') { |
| 414 | const macroDef = macroSystem.registry.getPrimaryMacro('else'); | 506 | const macroDef = macroSystem.registry.getPrimaryMacro('else'); |
| 415 | const elseOption = new EnhancedMacroAutoCompleteOption(macroDef); | 507 | const elseOption = new EnhancedMacroAutoCompleteOption(macroDef); |
| 416 | elseOption.sortPriority = 2; | 508 | elseOption.sortPriority = 2; |
| 417 | options.push(elseOption); | 509 | options.push(elseOption); |
| 510 | elseOptionAdded = true; | ||
| 511 | } | ||
| 512 | |||
| 513 | // Stop once we've added a required scope | ||
| 514 | if (foundRequired) break; | ||
| 418 | } | 515 | } |
| 419 | } | 516 | } |
| 420 | 517 | ||
| @@ -524,8 +621,35 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) { | |||
| 524 | } | 621 | } |
| 525 | } | 622 | } |
| 526 | 623 | ||
| 527 | // If typing args/closing brace but no macro matches, add a "no match" indicator | 624 | // If typing args/closing brace but no macro matches, check for closing macro context |
| 528 | if (shouldShowMatchingMacroDetails && !hasMatchingMacro && context.identifier.length > 0) { | 625 | if (shouldShowMatchingMacroDetails && !hasMatchingMacro && context.identifier.length > 0) { |
| 626 | // Check if this is a closing macro (starts with /) - show original macro's details | ||
| 627 | // Note: We look up the macro directly, not from unclosedScopes, because the closing tag | ||
| 628 | // itself may have already closed the scope by this point in the text | ||
| 629 | const isClosingMacro = context.identifier.startsWith('/'); | ||
| 630 | const closingMacroName = isClosingMacro ? context.identifier.slice(1) : null; | ||
| 631 | const macroDef = closingMacroName ? macroSystem.registry.getPrimaryMacro(closingMacroName) : null; | ||
| 632 | |||
| 633 | if (macroDef) { | ||
| 634 | // Show the original macro's details for the closing tag | ||
| 635 | // Create a context that shows we're closing the scope (no argument highlight) | ||
| 636 | const closingContext = /** @type {MacroAutoCompleteContext} */ ({ | ||
| 637 | ...context, | ||
| 638 | identifier: macroDef.name, | ||
| 639 | currentArgIndex: -1, // No argument highlight | ||
| 640 | isClosingTag: true, | ||
| 641 | }); | ||
| 642 | const closingOption = new EnhancedMacroAutoCompleteOption(macroDef, closingContext); | ||
| 643 | closingOption.valueProvider = () => ''; | ||
| 644 | closingOption.makeSelectable = false; | ||
| 645 | closingOption.matchProvider = () => true; | ||
| 646 | closingOption.sortPriority = 0; | ||
| 647 | options.unshift(closingOption); | ||
| 648 | hasMatchingMacro = true; // Prevent "no match" message | ||
| 649 | } | ||
| 650 | |||
| 651 | // Only show "no match" if we didn't find a matching closing scope | ||
| 652 | if (!hasMatchingMacro) { | ||
| 529 | const noMatchOption = new SimpleAutoCompleteOption({ | 653 | const noMatchOption = new SimpleAutoCompleteOption({ |
| 530 | name: context.identifier, | 654 | name: context.identifier, |
| 531 | symbol: '❌', | 655 | symbol: '❌', |
| @@ -539,6 +663,7 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) { | |||
| 539 | noMatchOption.sortPriority = 0; // Top priority | 663 | noMatchOption.sortPriority = 0; // Top priority |
| 540 | options.unshift(noMatchOption); | 664 | options.unshift(noMatchOption); |
| 541 | } | 665 | } |
| 666 | } | ||
| 542 | 667 | ||
| 543 | return options; | 668 | return options; |
| 544 | } | 669 | } |
| @@ -788,6 +913,7 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | |||
| 788 | macro = null, | 913 | macro = null, |
| 789 | textUpToCursor = null, | 914 | textUpToCursor = null, |
| 790 | unclosedScopes = null, | 915 | unclosedScopes = null, |
| 916 | isForced = false, | ||
| 791 | } = {}) { | 917 | } = {}) { |
| 792 | // Compute textUpToCursor if not provided | 918 | // Compute textUpToCursor if not provided |
| 793 | if (textUpToCursor === null) { | 919 | if (textUpToCursor === null) { |
| @@ -799,10 +925,14 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | |||
| 799 | unclosedScopes = findUnclosedScopes(textUpToCursor); | 925 | unclosedScopes = findUnclosedScopes(textUpToCursor); |
| 800 | } | 926 | } |
| 801 | 927 | ||
| 928 | // Filter out optional scopes unless forced (Ctrl+Space) | ||
| 929 | // This prevents intrusive hints for macros like {{trim}} where scope is optional | ||
| 930 | const filteredScopes = filterOptionalScopes(unclosedScopes, textUpToCursor, isForced); | ||
| 931 | |||
| 802 | // If cursor is NOT inside a macro, check if we're in scoped content | 932 | // If cursor is NOT inside a macro, check if we're in scoped content |
| 803 | if (!macro) { | 933 | if (!macro) { |
| 804 | if (unclosedScopes.length > 0) { | 934 | if (filteredScopes.length > 0) { |
| 805 | const scopedMacro = unclosedScopes[unclosedScopes.length - 1]; | 935 | const scopedMacro = filteredScopes[filteredScopes.length - 1]; |
| 806 | 936 | ||
| 807 | // Find where the opening macro ends | 937 | // Find where the opening macro ends |
| 808 | const openingEnd = text.indexOf('}}', scopedMacro.startOffset); | 938 | const openingEnd = text.indexOf('}}', scopedMacro.startOffset); |
| @@ -811,10 +941,14 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | |||
| 811 | const macroContent = text.slice(scopedMacro.startOffset + 2, openingEnd); | 941 | const macroContent = text.slice(scopedMacro.startOffset + 2, openingEnd); |
| 812 | const baseContext = parseMacroContext(macroContent, macroContent.length); | 942 | const baseContext = parseMacroContext(macroContent, macroContent.length); |
| 813 | 943 | ||
| 944 | // Check if this scope is optional (for display purposes) | ||
| 945 | const scopeIsOptional = isScopeOptional(scopedMacro, textUpToCursor); | ||
| 946 | |||
| 814 | const scopedContext = { | 947 | const scopedContext = { |
| 815 | ...baseContext, | 948 | ...baseContext, |
| 816 | currentArgIndex: baseContext.args.length, | 949 | currentArgIndex: baseContext.args.length, |
| 817 | isInScopedContent: true, | 950 | isInScopedContent: true, |
| 951 | isScopedContentOptional: scopeIsOptional, | ||
| 818 | scopedMacroName: scopedMacro.name, | 952 | scopedMacroName: scopedMacro.name, |
| 819 | }; | 953 | }; |
| 820 | 954 | ||
| @@ -848,15 +982,19 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | |||
| 848 | 982 | ||
| 849 | if (isCursorAtClosing) { | 983 | if (isCursorAtClosing) { |
| 850 | // Cursor is at the closing }} - check if this is an unclosed scoped macro | 984 | // Cursor is at the closing }} - check if this is an unclosed scoped macro |
| 851 | if (unclosedScopes.length > 0) { | 985 | if (filteredScopes.length > 0) { |
| 852 | const scopedMacro = unclosedScopes[unclosedScopes.length - 1]; | 986 | const scopedMacro = filteredScopes[filteredScopes.length - 1]; |
| 853 | // Check if the current macro IS the unclosed scoped macro | 987 | // Check if the current macro IS the unclosed scoped macro |
| 854 | if (scopedMacro.startOffset === macro.start) { | 988 | if (scopedMacro.startOffset === macro.start) { |
| 855 | // Show scoped context - cursor is right at the end of the opening tag | 989 | // Show scoped context - cursor is right at the end of the opening tag |
| 990 | // Check if this scope is optional (for display purposes) | ||
| 991 | const scopeIsOptional = isScopeOptional(scopedMacro, textUpToCursor); | ||
| 992 | |||
| 856 | const scopedContext = { | 993 | const scopedContext = { |
| 857 | ...context, | 994 | ...context, |
| 858 | currentArgIndex: context.args.length, | 995 | currentArgIndex: context.args.length, |
| 859 | isInScopedContent: true, | 996 | isInScopedContent: true, |
| 997 | isScopedContentOptional: scopeIsOptional, | ||
| 860 | scopedMacroName: scopedMacro.name, | 998 | scopedMacroName: scopedMacro.name, |
| 861 | }; | 999 | }; |
| 862 | 1000 | ||
| @@ -875,6 +1013,33 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | |||
| 875 | } | 1013 | } |
| 876 | } | 1014 | } |
| 877 | } | 1015 | } |
| 1016 | |||
| 1017 | // Check if this is a closing tag ({{/macroName}}) - show original macro's details | ||
| 1018 | // Note: We look up the macro directly, not from unclosedScopes, because the closing tag | ||
| 1019 | // itself has already closed the scope by this point in the text | ||
| 1020 | if (context.identifier.startsWith('/')) { | ||
| 1021 | const closingMacroName = context.identifier.slice(1); | ||
| 1022 | const macroDef = macroSystem.registry.getPrimaryMacro(closingMacroName); | ||
| 1023 | if (macroDef) { | ||
| 1024 | const closingContext = /** @type {MacroAutoCompleteContext} */ ({ | ||
| 1025 | ...context, | ||
| 1026 | identifier: macroDef.name, | ||
| 1027 | currentArgIndex: -1, // No argument highlight | ||
| 1028 | isClosingTag: true, | ||
| 1029 | }); | ||
| 1030 | const closingOption = new EnhancedMacroAutoCompleteOption(macroDef, closingContext); | ||
| 1031 | closingOption.valueProvider = () => ''; | ||
| 1032 | closingOption.makeSelectable = false; | ||
| 1033 | |||
| 1034 | return new AutoCompleteNameResult( | ||
| 1035 | macroDef.name, | ||
| 1036 | macro.start + 2, | ||
| 1037 | [closingOption], | ||
| 1038 | false, | ||
| 1039 | ); | ||
| 1040 | } | ||
| 1041 | } | ||
| 1042 | |||
| 878 | // Not a scoped macro, just clear arg highlighting | 1043 | // Not a scoped macro, just clear arg highlighting |
| 879 | context.currentArgIndex = -1; | 1044 | context.currentArgIndex = -1; |
| 880 | } | 1045 | } |
| @@ -1041,9 +1206,11 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, { | |||
| 1041 | * | 1206 | * |
| 1042 | * @param {string} text - The full text content. | 1207 | * @param {string} text - The full text content. |
| 1043 | * @param {number} cursorPos - The cursor position. | 1208 | * @param {number} cursorPos - The cursor position. |
| 1209 | * @param {Object} [options={}] - Additional options. | ||
| 1210 | * @param {boolean} [options.isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space). | ||
| 1044 | * @returns {Promise<AutoCompleteNameResult|null>} | 1211 | * @returns {Promise<AutoCompleteNameResult|null>} |
| 1045 | */ | 1212 | */ |
| 1046 | export async function getMacroAutoCompleteAt(text, cursorPos) { | 1213 | export async function getMacroAutoCompleteAt(text, cursorPos, { isForced = false } = {}) { |
| 1047 | const macro = findMacroAtCursor(text, cursorPos); | 1214 | const macro = findMacroAtCursor(text, cursorPos); |
| 1048 | return buildMacroAutoCompleteResult(text, cursorPos, { macro }); | 1215 | return buildMacroAutoCompleteResult(text, cursorPos, { macro, isForced }); |
| 1049 | } | 1216 | } |
| @@ -231,9 +231,12 @@ class MacroCstWalker { | |||
| 231 | if (!info) continue; | 231 | if (!info) continue; |
| 232 | 232 | ||
| 233 | if (info.isClosing) { | 233 | if (info.isClosing) { |
| 234 | // Closing tag - pop matching opener from stack (case-insensitive match) | 234 | // Find matching opener in stack (case-insensitive) |
| 235 | if (unclosedStack.length > 0 && unclosedStack[unclosedStack.length - 1].name.toLowerCase() === info.name.toLowerCase()) { | 235 | // When closing an outer scope, all inner unclosed scopes are implicitly closed |
| 236 | unclosedStack.pop(); | 236 | const matchIndex = unclosedStack.findLastIndex(s => s.name.toLowerCase() === info.name.toLowerCase()); |
| 237 | if (matchIndex !== -1) { | ||
| 238 | // Pop everything from matchIndex to end (inclusive) - closes the matched scope and all nested ones | ||
| 239 | unclosedStack.splice(matchIndex); | ||
| 237 | } | 240 | } |
| 238 | // If no matching opener, ignore (orphan closing tag) | 241 | // If no matching opener, ignore (orphan closing tag) |
| 239 | } else { | 242 | } else { |