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.

9f4449973b0207955fa057018f668a7f7e0dc8ce

Wolfsblvt <wolfsblvt@gmail.com>

Signed
5 files changed, +271 -26Showing whitespace changes
public/css/macros.css+22 -0
@@ -507,6 +507,28 @@
507507 border-radius: 3px;
508508}
509509
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+
510532/* Closing tag autocomplete option */
511533.autoComplete > .item.macro-closing-tag-item > .type {
512534 color: var(--ac-color-matchedText, var(--SmartThemeBorderColor));
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+54 -1
@@ -36,6 +36,7 @@ import { onboardingExperimentalMacroEngine } from '../macros/engine/MacroDiagnos
3636 * @property {boolean} hasSpaceArgContent - Whether there's actual content after the space (not just whitespace).
3737 * @property {number} separatorCount - Number of '::' separators found.
3838 * @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).
3940 * @property {string} [scopedMacroName] - Name of the scoped macro if in scoped content.
4041 * @property {boolean} isVariableShorthand - Whether this is a variable shorthand (starts with . or $).
4142 * @property {'.'|'$'|null} variablePrefix - The variable prefix (. for local, $ for global), or null.
@@ -307,12 +308,23 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
307308 const info = document.createElement('div');
308309 info.classList.add('macro-ac-scoped-info');
309310
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+
310319 const icon = document.createElement('i');
311320 icon.classList.add('fa-solid', 'fa-layer-group');
312321 info.append(icon);
313322
314323 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}`;
316328 info.append(text);
317329
318330 return info;
@@ -1113,12 +1125,20 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
11131125 /** @type {string} */
11141126 #paddingAfter;
11151127
1128+ /** @type {boolean} */
1129+ #isOptional;
1130+
1131+ /** @type {number} */
1132+ #nestingLevel;
1133+
11161134 /**
11171135 * @param {string} macroName - The name of the macro to close.
11181136 * @param {Object} [options] - Optional configuration.
11191137 * @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding).
11201138 * @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding).
11211139 * @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).
11221142 */
11231143 constructor(macroName, options = {}) {
11241144 // The closing tag is what we're suggesting - use /macroName as the name for matching
@@ -1127,6 +1147,8 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
11271147 this.#macroName = macroName;
11281148 this.#paddingBefore = options.paddingBefore ?? '';
11291149 this.#paddingAfter = options.paddingAfter ?? '';
1150+ this.#isOptional = options.isOptional ?? false;
1151+ this.#nestingLevel = options.nestingLevel ?? 0;
11301152
11311153 // Calculate the replacement offset to replace any existing whitespace the user typed
11321154 // This allows us to normalize the whitespace to match the opening tag's style
@@ -1144,6 +1166,10 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
11441166 // Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
11451167 this.makeSelectable = true;
11461168
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+
11471173 // Highest priority - closing tags should always appear at the very top
11481174 this.sortPriority = 1;
11491175 }
@@ -1195,7 +1221,21 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
11951221 help.classList.add('help');
11961222 const content = document.createElement('span');
11971223 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 {
11981236 content.textContent = `Close the {{${this.#macroName}}} scoped macro.`;
1237+ }
1238+
11991239 help.append(content);
12001240 li.append(help);
12011241
@@ -1212,6 +1252,14 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
12121252 const details = document.createElement('div');
12131253 details.classList.add('macro-closing-tag-details');
12141254
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+
12151263 // Header
12161264 const header = document.createElement('h3');
12171265 header.innerHTML = `Close <code>{{${this.#macroName}}}</code>`;
@@ -1219,7 +1267,12 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
12191267
12201268 // Description
12211269 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 {
12221274 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+ }
12231276 details.append(desc);
12241277
12251278 frag.append(details);
public/scripts/autocomplete/MacroAutoComplete.js+1 -1
@@ -120,7 +120,7 @@ export function setMacroAutoComplete(textarea, { autocompleteMode = MACRO_AUTOCO
120120 const ac = new AutoComplete(
121121 textarea,
122122 () => shouldActivateMacroAutocomplete(ac.text, textarea.selectionStart, { isForced: ac.isShowForced, autocompleteMode }),
123123 (text, index) => getMacroAutoCompleteAt(text, index, { isForced: ac.isShowForced }),
124124 true, // isFloating - always use floating mode for free text macro autocomplete
125125 );
126126
public/scripts/autocomplete/MacroAutoCompleteHelper.js+188 -21
@@ -55,6 +55,7 @@ import { extension_settings } from '../extensions.js';
5555 * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro
5656 * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor
5757 * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes
58+ * @property {boolean} [isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space)
5859 */
5960
6061/** @typedef {(EnhancedMacroAutoCompleteOption|MacroFlagAutoCompleteOption|MacroClosingTagAutoCompleteOption|VariableShorthandAutoCompleteOption|VariableNameAutoCompleteOption|VariableOperatorAutoCompleteOption|VariableValueContextAutoCompleteOption|SimpleAutoCompleteOption)} AnyMacroAutoCompleteOption */
@@ -102,9 +103,12 @@ export function findUnclosedScopesRegex(text) {
102103 const name = match[3];
103104
104105 if (isClosing) {
105106 // PopFind 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);
108112 }
109113 } else {
110114 // Check if macro can accept scoped content
@@ -133,6 +137,70 @@ export function findUnclosedScopesRegex(text) {
133137}
134138
135139/**
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+/**
136204 * Builds autocomplete options for variable shorthand syntax (.varName or $varName).
137205 * @param {MacroAutoCompleteContext} context
138206 * @param {Object} [opts] - Optional configuration.
@@ -385,9 +453,11 @@ export function buildVariableShorthandOptions(context, opts = {}) {
385453 * When typing arguments (after ::), prioritizes the exact macro match.
386454 * @param {MacroAutoCompleteContext} context
387455 * @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).
388458 * @returns {AnyMacroAutoCompleteOption[]}
389459 */
390460export function buildEnhancedMacroOptions(context, textUpToCursor, { isForced = false } = {}) {
391461 /** @type {AnyMacroAutoCompleteOption[]} */
392462 const options = [];
393463
@@ -395,26 +465,53 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) {
395465 return buildVariableShorthandOptions(context);
396466 }
397467
398468 // Check for unclosed scoped macros and suggest closing tags first
469+ // Iterate from innermost to outermost, adding optional scopes and stopping at first required scope
399470 const unclosedScopes = findUnclosedScopes(textUpToCursor);
400471 if (unclosedScopes.length > 0) {
401472 let firstRequiredPriority = 1; // SuggestPriority closingfor the innermostfirst required (lastnon-optional) unclosed scope first
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,
408489 currentPadding: context.paddingBefore,
490+ isOptional: isOptional,
491+ nestingLevel: nestingLevel,
409492 });
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+
410502 options.push(closingOption);
411503
412504 // If inside a scoped {{if}}, also suggest {{else}} (only once, for innermost if)
413505 if (innermostScope!elseOptionAdded && scope.name === 'if') {
414506 const macroDef = macroSystem.registry.getPrimaryMacro('else');
415507 const elseOption = new EnhancedMacroAutoCompleteOption(macroDef);
416508 elseOption.sortPriority = 2;
417509 options.push(elseOption);
510+ elseOptionAdded = true;
511+ }
512+
513+ // Stop once we've added a required scope
514+ if (foundRequired) break;
418515 }
419516 }
420517
@@ -524,8 +621,35 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) {
524621 }
525622 }
526623
527624 // If typing args/closing brace but no macro matches, addcheck afor "noclosing match"macro indicatorcontext
528625 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) {
529653 const noMatchOption = new SimpleAutoCompleteOption({
530654 name: context.identifier,
531655 symbol: '❌',
@@ -539,6 +663,7 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) {
539663 noMatchOption.sortPriority = 0; // Top priority
540664 options.unshift(noMatchOption);
541665 }
666+ }
542667
543668 return options;
544669}
@@ -788,6 +913,7 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
788913 macro = null,
789914 textUpToCursor = null,
790915 unclosedScopes = null,
916+ isForced = false,
791917} = {}) {
792918 // Compute textUpToCursor if not provided
793919 if (textUpToCursor === null) {
@@ -799,10 +925,14 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
799925 unclosedScopes = findUnclosedScopes(textUpToCursor);
800926 }
801927
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+
802932 // If cursor is NOT inside a macro, check if we're in scoped content
803933 if (!macro) {
804934 if (unclosedScopesfilteredScopes.length > 0) {
805935 const scopedMacro = unclosedScopesfilteredScopes[unclosedScopesfilteredScopes.length - 1];
806936
807937 // Find where the opening macro ends
808938 const openingEnd = text.indexOf('}}', scopedMacro.startOffset);
@@ -811,10 +941,14 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
811941 const macroContent = text.slice(scopedMacro.startOffset + 2, openingEnd);
812942 const baseContext = parseMacroContext(macroContent, macroContent.length);
813943
944+ // Check if this scope is optional (for display purposes)
945+ const scopeIsOptional = isScopeOptional(scopedMacro, textUpToCursor);
946+
814947 const scopedContext = {
815948 ...baseContext,
816949 currentArgIndex: baseContext.args.length,
817950 isInScopedContent: true,
951+ isScopedContentOptional: scopeIsOptional,
818952 scopedMacroName: scopedMacro.name,
819953 };
820954
@@ -848,15 +982,19 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
848982
849983 if (isCursorAtClosing) {
850984 // Cursor is at the closing }} - check if this is an unclosed scoped macro
851985 if (unclosedScopesfilteredScopes.length > 0) {
852986 const scopedMacro = unclosedScopesfilteredScopes[unclosedScopesfilteredScopes.length - 1];
853987 // Check if the current macro IS the unclosed scoped macro
854988 if (scopedMacro.startOffset === macro.start) {
855989 // 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+
856993 const scopedContext = {
857994 ...context,
858995 currentArgIndex: context.args.length,
859996 isInScopedContent: true,
997+ isScopedContentOptional: scopeIsOptional,
860998 scopedMacroName: scopedMacro.name,
861999 };
8621000
@@ -875,6 +1013,33 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
8751013 }
8761014 }
8771015 }
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+
8781043 // Not a scoped macro, just clear arg highlighting
8791044 context.currentArgIndex = -1;
8801045 }
@@ -1041,9 +1206,11 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
10411206 *
10421207 * @param {string} text - The full text content.
10431208 * @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).
10441211 * @returns {Promise<AutoCompleteNameResult|null>}
10451212 */
10461213export async function getMacroAutoCompleteAt(text, cursorPos, { isForced = false } = {}) {
10471214 const macro = findMacroAtCursor(text, cursorPos);
10481215 return buildMacroAutoCompleteResult(text, cursorPos, { macro, isForced });
10491216}
public/scripts/macros/engine/MacroCstWalker.js+6 -3
@@ -231,9 +231,12 @@ class MacroCstWalker {
231231 if (!info) continue;
232232
233233 if (info.isClosing) {
234234 // Closing tag - popFind matching opener fromin stack (case-insensitive match)
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);
237240 }
238241 // If no matching opener, ignore (orphan closing tag)
239242 } else {