Macros 2.0 - `list`-supported Macros Autocomplete Improvements (#5135) * fix(macros): prevent list-arg macros from accepting scoped content List-arg macros now correctly reject scoped content since they only accept arbitrary inline arguments. Updated validation logic in autocomplete, scope detection, and CST walker to check for `list === null` before allowing scopes. Also improved list item hint display to show total count when typing additional items. * feat(macros): display min/max constraints for list arguments in autocomplete hints Add visual indication of list argument constraints by showing min/max values in autocomplete hints. List items now display "(list, min: X, max: Y)" or "(variable-length list)" when no constraints exist. Includes new CSS styling for smaller, dimmed hint text. * feat(macros): validate list argument min/max constraints and improve warning display Add validation for list argument constraints (min/max) with specific error messages when too few or too many list items are provided. Continue highlighting current argument position even when warnings are present (except where semantically invalid), allowing users to navigate back to valid arguments while seeing "too many arguments" warnings.
Signed| @@ -566,6 +566,11 @@ | ||
| 566 | 566 | font-size: 0.8em; |
| 567 | 567 | } |
| 568 | 568 | |
| 569 | +.macro-ac-arg-hint .macro-ac-arg-hint-small { | |
| 570 | + font-size: 0.85em; | |
| 571 | + opacity: 0.8; | |
| 572 | +} | |
| 573 | + | |
| 569 | 574 | .macro-ac-hint-type { |
| 570 | 575 | font-family: var(--monoFontFamily); |
| 571 | 576 | font-size: 0.85em; |
| @@ -223,15 +223,20 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | ||
| 223 | 223 | // Determine current argument index for highlighting |
| 224 | 224 | const currentArgIndex = this.#context?.currentArgIndex ?? -1; |
| 225 | 225 | |
| 226 | 226 | // RenderFor argumentmost hintwarnings, bannerwe ifcan we'restill typinghighlight anwhich argument (andwe noare warning)currently at. |
| 227 | - if (!warning && currentArgIndex >= 0) { | |
| 227 | + // This even goes for "too many arguments" when navigating the cursor back to | |
| 228 | + // a valid argument. | |
| 229 | + // Extend this in the future, if *some* warnings don't make sense to still highlight args. | |
| 230 | + const hightlightArgsHint = currentArgIndex >= 0; | |
| 231 | + | |
| 232 | + // Render argument hint banner if we're typing an argument | |
| 233 | + if (hightlightArgsHint && currentArgIndex >= 0) { | |
| 228 | 234 | const hint = this.#renderArgumentHint(); |
| 229 | 235 | if (hint) frag.append(hint); |
| 230 | 236 | } |
| 231 | 237 | |
| 232 | 238 | // Reuse MacroBrowser's renderMacroDetails with options |
| 233 | - // Don't highlight args if there's a warning | |
| 239 | + const details = renderMacroDetails(this.#macro, { currentArgIndex: hightlightArgsHint ? currentArgIndex : -1 }); | |
| 234 | - const details = renderMacroDetails(this.#macro, { currentArgIndex: warning ? -1 : currentArgIndex }); | |
| 235 | 240 | |
| 236 | 241 | // Add class for autocomplete-specific styling overrides |
| 237 | 242 | details.classList.add('macro-ac-details'); |
| @@ -261,7 +266,7 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | ||
| 261 | 266 | // Space-separated syntax provides 1 arg; with scoped content you can provide a 2nd arg |
| 262 | 267 | // So it's valid for macros with maxArgs <= 2 (or with list args) |
| 263 | 268 | if (this.#context.hasSpaceArgContent) { |
| 264 | 269 | if (maxArgs === 0 && !hasList) { |
| 265 | 270 | return 'This macro does not accept any arguments. Remove the space or use a different macro.'; |
| 266 | 271 | } |
| 267 | 272 | if (!hasList && maxArgs > 2) { |
| @@ -270,10 +275,27 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | ||
| 270 | 275 | } |
| 271 | 276 | |
| 272 | 277 | // Check if trying to add args to a no-arg macro via :: |
| 273 | - if (this.#context.separatorCount > 0 && maxArgs === 0) { | |
| 278 | + // List-arg macros can accept args even if maxArgs === 0 | |
| 279 | + if (this.#context.separatorCount > 0 && maxArgs === 0 && !hasList) { | |
| 274 | 280 | return 'This macro does not accept any arguments.'; |
| 275 | 281 | } |
| 276 | 282 | |
| 283 | + // Check list bounds (min/max) if the macro has a list with constraints | |
| 284 | + if (hasList && typeof this.#macro.list === 'object') { | |
| 285 | + const listItemCount = Math.max(0, argCount - maxArgs); | |
| 286 | + const listMin = this.#macro.list.min ?? 0; | |
| 287 | + const listMax = this.#macro.list.max ?? null; | |
| 288 | + | |
| 289 | + if (listItemCount < listMin) { | |
| 290 | + const needed = listMin - listItemCount; | |
| 291 | + return `Not enough list items yet: this macro requires at least ${listMin} item${listMin === 1 ? '' : 's'}, but only ${listItemCount} provided. Add ${needed} more.`; | |
| 292 | + } | |
| 293 | + | |
| 294 | + if (listMax !== null && listItemCount > listMax) { | |
| 295 | + return `Too many list items: this macro accepts at most ${listMax} item${listMax === 1 ? '' : 's'}, but ${listItemCount} provided.`; | |
| 296 | + } | |
| 297 | + } | |
| 298 | + | |
| 277 | 299 | return null; |
| 278 | 300 | } |
| 279 | 301 | |
| @@ -353,8 +375,23 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | ||
| 353 | 375 | if (isListArg) { |
| 354 | 376 | // List argument hint |
| 355 | 377 | const listIndex = argIndex - this.#macro.maxArgs + 1; |
| 378 | + const totalListItems = this.#context.args.length - this.#macro.maxArgs; | |
| 379 | + | |
| 356 | 380 | const text = document.createElement('span'); |
| 357 | 381 | text.innerHTML = `<strong>List item ${listIndex}</strong>${(listIndex < totalListItems ? ` (of ${totalListItems})` : '')}`; |
| 382 | + | |
| 383 | + const listInfo = document.createElement('span'); | |
| 384 | + listInfo.classList.add('macro-ac-arg-hint-small'); | |
| 385 | + const minMax = []; | |
| 386 | + if (this.#macro.list.min > 0) minMax.push(`min: ${this.#macro.list.min}`); | |
| 387 | + if (this.#macro.list.max !== null) minMax.push(`max: ${this.#macro.list.max}`); | |
| 388 | + if (minMax.length > 0) { | |
| 389 | + listInfo.textContent = ` (list, ${minMax.join(', ')})`; | |
| 390 | + } else { | |
| 391 | + listInfo.textContent = ' (variable-length list)'; | |
| 392 | + } | |
| 393 | + text.appendChild(listInfo); | |
| 394 | + | |
| 358 | 395 | hint.append(text); |
| 359 | 396 | } else { |
| 360 | 397 | // Unnamed argument hint (required or optional) |
| @@ -112,8 +112,9 @@ export function findUnclosedScopesRegex(text) { | ||
| 112 | 112 | } |
| 113 | 113 | } else { |
| 114 | 114 | // Check if macro can accept scoped content |
| 115 | + // List-arg macros don't support scopes - they accept arbitrary inline args instead | |
| 115 | 116 | const macroDef = macroSystem.registry.getPrimaryMacro(name); |
| 116 | 117 | if (macroDef && macroDef.maxArgs > 0 && macroDef.list === null) { |
| 117 | 118 | // Try to find closing }} to extract trailing whitespace |
| 118 | 119 | let paddingAfter = ''; |
| 119 | 120 | const afterMatch = text.slice(match.index + match[0].length); |
| @@ -1309,16 +1309,14 @@ class MacroCstWalker { | ||
| 1309 | 1309 | const argumentNodes = /** @type {CstNode[]} */ (argumentsNode?.children?.argument || []); |
| 1310 | 1310 | const currentArgCount = argumentNodes.length; |
| 1311 | 1311 | |
| 1312 | - // Check if adding 1 more argument (scoped content) would be valid | |
| 1312 | + // List-arg macros don't support scoped content - they accept arbitrary inline args instead | |
| 1313 | - const newArgCount = currentArgCount + 1; | |
| 1314 | - | |
| 1315 | - // Macro must accept at least newArgCount arguments | |
| 1316 | - // For macros with list args, they can accept unlimited after maxArgs | |
| 1317 | 1313 | if (def.list) { |
| 1318 | - // With list: valid if newArgCount >= minArgs (list can absorb extra) | |
| 1314 | + return false; | |
| 1319 | - return newArgCount >= def.minArgs; | |
| 1320 | 1315 | } |
| 1321 | 1316 | |
| 1317 | + // Check if adding 1 more argument (scoped content) would be valid | |
| 1318 | + const newArgCount = currentArgCount + 1; | |
| 1319 | + | |
| 1322 | 1320 | // Without list: newArgCount must be between minArgs and maxArgs |
| 1323 | 1321 | return newArgCount >= def.minArgs && newArgCount <= def.maxArgs; |
| 1324 | 1322 | } |