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 @@
507 border-radius: 3px;507 border-radius: 3px;
508}508}
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
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));
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+54 -1
@@ -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');
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
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);
313322
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);
317329
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;
11151127
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 matching1144 // 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;
11301152
1131 // Calculate the replacement offset to replace any existing whitespace the user typed1153 // 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 style1154 // 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;
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
1147 // Highest priority - closing tags should always appear at the very top1173 // 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);
12011241
@@ -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');
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
1215 // Header1263 // 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 {
12191267
1220 // Description1268 // 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);
12241277
1225 frag.append(details);1278 frag.append(details);
public/scripts/autocomplete/MacroAutoComplete.js+1 -1
@@ -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 autocomplete124 true, // isFloating - always use floating mode for free text macro autocomplete
125 );125 );
126126
public/scripts/autocomplete/MacroAutoCompleteHelper.js+188 -21
@@ -55,6 +55,7 @@ import { extension_settings } from '../extensions.js';
55 * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro55 * @property {MacroInfo|null} [macro=null] - Macro info if cursor is inside a macro
56 * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor56 * @property {string|null} [textUpToCursor=null] - Pre-computed text up to cursor
57 * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes57 * @property {UnclosedScope[]|null} [unclosedScopes=null] - Pre-computed unclosed scopes
58 * @property {boolean} [isForced=false] - Whether autocomplete was force-triggered (Ctrl+Space)
58 */59 */
5960
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];
103104
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 content114 // Check if macro can accept scoped content
@@ -133,6 +137,70 @@ export function findUnclosedScopesRegex(text) {
133}137}
134138
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 */
147function 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 */
193function 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} context205 * @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} context454 * @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 */
390export function buildEnhancedMacroOptions(context, textUpToCursor) {460export function buildEnhancedMacroOptions(context, textUpToCursor, { isForced = false } = {}) {
391 /** @type {AnyMacroAutoCompleteOption[]} */461 /** @type {AnyMacroAutoCompleteOption[]} */
392 const options = [];462 const options = [];
393463
@@ -395,26 +465,53 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) {
395 return buildVariableShorthandOptions(context);465 return buildVariableShorthandOptions(context);
396 }466 }
397467
398 // Check for unclosed scoped macros and suggest closing tags first468 // 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 first472 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 tag474 let foundRequired = false;
404 // Pass currentPadding so the closing tag can replace user-typed whitespace with the target padding475 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);
411503
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 }
420517
@@ -524,8 +621,35 @@ export function buildEnhancedMacroOptions(context, textUpToCursor) {
524 }621 }
525 }622 }
526623
527 // If typing args/closing brace but no macro matches, add a "no match" indicator624 // 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 priority663 noMatchOption.sortPriority = 0; // Top priority
540 options.unshift(noMatchOption);664 options.unshift(noMatchOption);
541 }665 }
666 }
542667
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 provided918 // 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 }
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
802 // If cursor is NOT inside a macro, check if we're in scoped content932 // 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];
806936
807 // Find where the opening macro ends937 // 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);
813943
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 };
820954
@@ -848,15 +982,19 @@ export async function buildMacroAutoCompleteResult(text, cursorPos, {
848982
849 if (isCursorAtClosing) {983 if (isCursorAtClosing) {
850 // Cursor is at the closing }} - check if this is an unclosed scoped macro984 // 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 macro987 // 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 tag989 // 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 };
8621000
@@ -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 highlighting1043 // 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 */
1046export async function getMacroAutoCompleteAt(text, cursorPos) {1213export 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}
public/scripts/macros/engine/MacroCstWalker.js+6 -3
@@ -231,9 +231,12 @@ class MacroCstWalker {
231 if (!info) continue;231 if (!info) continue;
232232
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 {