Macros 2.0 [Fix] - Macro display override uses alias name if relevant + preserve whitespaces for autocomplete closing tag (#4953) * Refactor: Move MacroBrowser.js to macros/engine directory and update import paths * Replace macro name with alias in displayOverride for macro browser - Import escapeRegex utility for safe regex pattern creation - Check if macro has aliasOf property when displayOverride is present - Replace all occurrences of the original macro name with the alias name in displayOverride text - Use word boundary regex pattern to match macro names in {{macro}}, {{macro::}}, and {{macro }} contexts * Add whitespace padding preservation for macro closing tag autocomplete - Extract paddingBefore and paddingAfter from opening macro tags in MacroCstWalker - Pass padding info through unclosed scope detection in findUnclosedScopes() and findUnclosedScopesRegex() - Update MacroClosingTagAutoCompleteOption constructor to accept padding options - Apply same whitespace padding to closing tags as their corresponding opening tags - Update regex fallback to capture and preserve whitespace from opening tags * Add replacementStartOffset to AutoCompleteOption for whitespace normalization in closing tags - Add replacementStartOffset property to AutoCompleteOption class with JSDoc documentation - Update AutoComplete.select() to apply per-option replacement offset when calculating effectiveStart - Calculate replacementStartOffset in MacroClosingTagAutoCompleteOption based on currentPadding length - Pass currentPadding from context.paddingBefore to MacroClosingTagAutoCompleteOption in SlashCommandParser * Fix closing tag detection regex to correctly autocomplete // macro * Add flags and fullText to EnhancedMacroAutoCompleteOption context and fix {{//}} autocomplete offset - Add flags, currentFlag, and fullText properties to EnhancedMacroAutoCompleteOptions JSDoc - Store options object as #options field in EnhancedMacroAutoCompleteOption for later access - Pass flags, currentFlag, and fullText from context to macroContext in SlashCommandParser - Fix {{//}} macro autocomplete to adjust replacementStartOffset when typed after single slash to avoid treating it as flag * Add unnamedArgs definition to {{//}} comment macro for better documentation --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -692,8 +692,10 @@ export class AutoComplete { | ||
| 692 | 692 | */ |
| 693 | 693 | async select() { |
| 694 | 694 | if (this.isReplaceable && this.selectedItem.value !== null) { |
| 695 | - this.textarea.value = `${this.text.slice(0, this.effectiveParserResult.start)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`; | |
| 695 | + // Apply per-option replacement offset (e.g., for closing tags that need to replace leading whitespace) | |
| 696 | 696 | this.textarea.selectionStartconst effectiveStart = this.effectiveParserResult.start + (this.selectedItem.replacer.lengthreplacementStartOffset ?? 0); |
| 697 | + this.textarea.value = `${this.text.slice(0, effectiveStart)}${this.selectedItem.replacer}${this.text.slice(this.effectiveParserResult.start + this.effectiveParserResult.name.length + (this.startQuote ? 1 : 0) + (this.endQuote ? 1 : 0))}`; | |
| 698 | + this.textarea.selectionStart = effectiveStart + this.selectedItem.replacer.length; | |
| 697 | 699 | this.textarea.selectionEnd = this.textarea.selectionStart; |
| 698 | 700 | this.show(false, false, true); |
| 699 | 701 | } else { |
| @@ -13,6 +13,15 @@ export class AutoCompleteOption { | ||
| 13 | 13 | /** @type {(input:string)=>boolean} */ matchProvider; |
| 14 | 14 | /** @type {(input:string)=>string} */ valueProvider; |
| 15 | 15 | /** @type {boolean} */ makeSelectable = false; |
| 16 | + | |
| 17 | + /** | |
| 18 | + * Offset to adjust the replacement start position. | |
| 19 | + * Negative values start replacement earlier (e.g., -2 to include 2 chars before normal start). | |
| 20 | + * Used by closing tag autocomplete to replace leading whitespace. | |
| 21 | + * @type {number} | |
| 22 | + */ | |
| 23 | + replacementStartOffset = 0; | |
| 24 | + | |
| 16 | 25 | /** |
| 17 | 26 | * Priority for sorting. Lower values = higher priority (sorted first). |
| 18 | 27 | * Default is 100 (normal priority). Use lower values for items that should appear at the top. |
| @@ -9,7 +9,7 @@ import { | ||
| 9 | 9 | createSourceIndicator, |
| 10 | 10 | createAliasIndicator, |
| 11 | 11 | renderMacroDetails, |
| 12 | 12 | } from '../macros/engine/MacroBrowser.js'; |
| 13 | 13 | import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 14 | 14 | import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js'; |
| 15 | 15 | import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js'; |
| @@ -54,6 +54,9 @@ import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js | ||
| 54 | 54 | * @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions). |
| 55 | 55 | * @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style). |
| 56 | 56 | * @property {boolean} [closeWithBraces=false] - If true, the completion will add }} to close the macro. |
| 57 | + * @property {string[]} [flags=[]] - The currently already written flags for this autocomplete. | |
| 58 | + * @property {string} [currentFlag] - The current flag that is present, if any. | |
| 59 | + * @property {string} [fullText] - The currently written full text. | |
| 57 | 60 | */ |
| 58 | 61 | |
| 59 | 62 | export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { |
| @@ -63,6 +66,9 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | ||
| 63 | 66 | /** @type {MacroAutoCompleteContext|null} */ |
| 64 | 67 | #context = null; |
| 65 | 68 | |
| 69 | + /** @type {EnhancedMacroAutoCompleteOptions|null} */ | |
| 70 | + #options = null; | |
| 71 | + | |
| 66 | 72 | /** @type {boolean} */ |
| 67 | 73 | #noBraces = false; |
| 68 | 74 | |
| @@ -83,12 +89,12 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | ||
| 83 | 89 | if (contextOrOptions && typeof contextOrOptions === 'object') { |
| 84 | 90 | if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) { |
| 85 | 91 | // It's an options object |
| 86 | 92 | const this.#options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions); |
| 87 | 93 | this.#noBraces = this.#options.noBraces ?? false; |
| 88 | 94 | this.#paddingAfter = this.#options.paddingAfter ?? ''; |
| 89 | 95 | |
| 90 | 96 | // If noBraces mode with closeWithBraces, complete with name + padding + }} |
| 91 | 97 | if (this.#options.closeWithBraces) { |
| 92 | 98 | this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`; |
| 93 | 99 | this.makeSelectable = true; |
| 94 | 100 | } |
| @@ -110,6 +116,12 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption { | ||
| 110 | 116 | this.makeSelectable = true; // Required when using valueProvider |
| 111 | 117 | } |
| 112 | 118 | } |
| 119 | + | |
| 120 | + // {{//}} needs special handling. If we autocomplete right after **one** slash is already typed, we need to replace that, as it's treated as a flag otherwise. | |
| 121 | + const fullText = this.#options?.fullText ?? this.#context?.fullText ?? ''; | |
| 122 | + if (macro.name === '//' && fullText.endsWith('/')) { | |
| 123 | + this.replacementStartOffset = (this.replacementStartOffset ?? 0) - 1; // Cut the leading slash | |
| 124 | + } | |
| 113 | 125 | } |
| 114 | 126 | |
| 115 | 127 | /** @returns {MacroDefinition} */ |
| @@ -907,20 +919,38 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption { | ||
| 907 | 919 | /** @type {string} */ |
| 908 | 920 | #macroName; |
| 909 | 921 | |
| 922 | + /** @type {string} */ | |
| 923 | + #paddingBefore; | |
| 924 | + | |
| 925 | + /** @type {string} */ | |
| 926 | + #paddingAfter; | |
| 927 | + | |
| 910 | 928 | /** |
| 911 | 929 | * @param {string} macroName - The name of the macro to close. |
| 930 | + * @param {Object} [options] - Optional configuration. | |
| 931 | + * @param {string} [options.paddingBefore=''] - Whitespace after {{ in opening tag (target padding). | |
| 932 | + * @param {string} [options.paddingAfter=''] - Whitespace before }} in opening tag (target padding). | |
| 933 | + * @param {string} [options.currentPadding=''] - Whitespace the user has already typed after {{. | |
| 912 | 934 | */ |
| 913 | 935 | constructor(macroName, options = {}) { |
| 914 | 936 | // The closing tag is what we're suggesting - use /macroName as the name for matching |
| 915 | 937 | const closingTag = `/${macroName}`; |
| 916 | 938 | super(closingTag, '{/'); |
| 917 | 939 | this.#macroName = macroName; |
| 940 | + this.#paddingBefore = options.paddingBefore ?? ''; | |
| 941 | + this.#paddingAfter = options.paddingAfter ?? ''; | |
| 942 | + | |
| 943 | + // Calculate the replacement offset to replace any existing whitespace the user typed | |
| 944 | + // This allows us to normalize the whitespace to match the opening tag's style | |
| 945 | + const currentPadding = options.currentPadding ?? ''; | |
| 946 | + // Negative offset to start replacement earlier (eating the user's whitespace) | |
| 947 | + this.replacementStartOffset = -currentPadding.length; | |
| 918 | 948 | |
| 919 | 949 | // Custom valueProvider to return the correct replacement text |
| 920 | 950 | // Autocomplete REPLACESIncludes the typedtarget identifierpaddingBefore entirely,from sothe returnopening thetag, fullreplacing closingany taguser-typed whitespace |
| 921 | 951 | this.valueProvider = () => { |
| 922 | - // Return full closing tag content (without {{ since that's before the identifier) | |
| 952 | + // Return: paddingBefore + /macroName + paddingAfter + }} | |
| 923 | 953 | return `${this.#paddingBefore}/${macroName}${this.#paddingAfter}}}`; |
| 924 | 954 | }; |
| 925 | 955 | |
| 926 | 956 | // Make selectable so TAB completion works (valueProvider alone makes it non-selectable) |
| @@ -1033,7 +1063,7 @@ export function parseMacroContext(macroText, cursorOffset) { | ||
| 1033 | 1063 | while (i < macroText.length) { |
| 1034 | 1064 | const char = macroText[i]; |
| 1035 | 1065 | // Check if this looks like a closing tag: `/` followed by an identifier character |
| 1036 | 1066 | if (char === '/' && i + 1 < macroText.length && /[a-zA-Z_Z/]/.test(macroText[i + 1])) { |
| 1037 | 1067 | // This is a closing tag identifier, not a flag - stop parsing flags |
| 1038 | 1068 | break; |
| 1039 | 1069 | } |
| @@ -261,6 +261,13 @@ export function registerCoreMacros() { | ||
| 261 | 261 | MacroRegistry.registerMacro('//', { |
| 262 | 262 | aliases: [{ alias: 'comment', visible: false }], |
| 263 | 263 | category: MacroCategory.UTILITY, |
| 264 | + unnamedArgs: [ | |
| 265 | + { | |
| 266 | + name: 'comment', | |
| 267 | + type: MacroValueType.STRING, | |
| 268 | + description: 'Any kind of text as comment. If you want multiline comments, consider using a scoped macro like {{//}}First\nSecond{{///}}.', | |
| 269 | + }, | |
| 270 | + ], | |
| 264 | 271 | list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway |
| 265 | 272 | strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid |
| 266 | 273 | description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.', |
| @@ -3,11 +3,12 @@ | ||
| 3 | 3 | * Similar to SlashCommandBrowser but for the macro system. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | 6 | import { MacroRegistry, MacroCategory } from './engine/MacroRegistry.js'; |
| 7 | 7 | import { performFuzzySearch } from '../../power-user.js'; |
| 8 | +import { escapeRegex } from '/scripts/utils.js'; | |
| 8 | 9 | |
| 9 | 10 | /** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */ |
| 10 | 11 | /** @typedef {import('./engine/MacroRegistry.js').MacroValueType} MacroValueType */ |
| 11 | 12 | |
| 12 | 13 | /** |
| 13 | 14 | * Category display names and order for documentation. |
| @@ -328,6 +329,11 @@ function getCategoryConfig(category) { | ||
| 328 | 329 | export function formatMacroSignature(macro) { |
| 329 | 330 | // Use displayOverride if provided |
| 330 | 331 | if (macro.displayOverride) { |
| 332 | + if (macro.aliasOf) { | |
| 333 | + // Replace all occurrences of the macro name with the alias for this list | |
| 334 | + const escapedMainName = escapeRegex(macro.aliasOf); | |
| 335 | + return macro.displayOverride.replace(new RegExp(`(?<=[\\b{\\s])${escapedMainName}(?=[\\b}:\\s])`, 'g'), `${macro.name}`); | |
| 336 | + } | |
| 331 | 337 | return macro.displayOverride; |
| 332 | 338 | } |
| 333 | 339 | |
| @@ -190,7 +190,7 @@ class MacroCstWalker { | ||
| 190 | 190 | * @param {Object} options |
| 191 | 191 | * @param {string} options.text - The document text. |
| 192 | 192 | * @param {CstNode} options.cst - The parsed CST. |
| 193 | 193 | * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} - Array of unclosed macro info, innermost last. |
| 194 | 194 | */ |
| 195 | 195 | findUnclosedScopes(options) { |
| 196 | 196 | const { text, cst } = options; |
| @@ -203,7 +203,7 @@ class MacroCstWalker { | ||
| 203 | 203 | // Don't process scoped macros - we want to find the raw opening/closing pairs |
| 204 | 204 | // Just extract macro info and find unmatched openers |
| 205 | 205 | |
| 206 | 206 | /** @type {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} */ |
| 207 | 207 | const unclosedStack = []; |
| 208 | 208 | |
| 209 | 209 | // Extract macro names and closing status |
| @@ -222,10 +222,15 @@ class MacroCstWalker { | ||
| 222 | 222 | } else { |
| 223 | 223 | // Opening tag - check if this macro can accept scoped content |
| 224 | 224 | if (this.#canAcceptScopedContent(item.node, info.name)) { |
| 225 | + // Extract whitespace padding from the macro | |
| 226 | + const { paddingBefore, paddingAfter } = this.#extractMacroPadding(item.node, text); | |
| 227 | + | |
| 225 | 228 | unclosedStack.push({ |
| 226 | 229 | name: info.name, |
| 227 | 230 | startOffset: item.startOffset, |
| 228 | 231 | endOffset: item.endOffset, |
| 232 | + paddingBefore, | |
| 233 | + paddingAfter, | |
| 229 | 234 | }); |
| 230 | 235 | } |
| 231 | 236 | } |
| @@ -234,6 +239,39 @@ class MacroCstWalker { | ||
| 234 | 239 | return unclosedStack; |
| 235 | 240 | } |
| 236 | 241 | |
| 242 | + /** | |
| 243 | + * Extracts the whitespace padding from a macro node. | |
| 244 | + * Returns the whitespace after {{ and before }}. | |
| 245 | + * | |
| 246 | + * @param {CstNode} macroNode - The macro CST node. | |
| 247 | + * @param {string} text - The source text. | |
| 248 | + * @returns {{ paddingBefore: string, paddingAfter: string }} | |
| 249 | + */ | |
| 250 | + #extractMacroPadding(macroNode, text) { | |
| 251 | + const children = macroNode.children || {}; | |
| 252 | + const startToken = /** @type {IToken?} */ ((children['Macro.Start'] || [])[0]); | |
| 253 | + const endToken = /** @type {IToken?} */ ((children['Macro.End'] || [])[0]); | |
| 254 | + | |
| 255 | + if (!startToken || !endToken) { | |
| 256 | + return { paddingBefore: '', paddingAfter: '' }; | |
| 257 | + } | |
| 258 | + | |
| 259 | + // Get the raw text inside the macro (between {{ and }}) | |
| 260 | + const innerStart = startToken.endOffset + 1; | |
| 261 | + const innerEnd = endToken.startOffset; | |
| 262 | + const innerText = text.slice(innerStart, innerEnd); | |
| 263 | + | |
| 264 | + // Extract leading whitespace (paddingBefore) | |
| 265 | + const leadingMatch = innerText.match(/^(\s*)/); | |
| 266 | + const paddingBefore = leadingMatch ? leadingMatch[1] : ''; | |
| 267 | + | |
| 268 | + // Extract trailing whitespace (paddingAfter) | |
| 269 | + const trailingMatch = innerText.match(/(\s*)$/); | |
| 270 | + const paddingAfter = trailingMatch ? trailingMatch[1] : ''; | |
| 271 | + | |
| 272 | + return { paddingBefore, paddingAfter }; | |
| 273 | + } | |
| 274 | + | |
| 237 | 275 | /** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */ |
| 238 | 276 | /** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode, scopedContent?: { startOffset: number, endOffset: number, closingEndOffset: number }, keepRaw?: boolean }} DocumentItemMacro */ |
| 239 | 277 | /** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */ |
| @@ -808,7 +808,13 @@ export class SlashCommandParser { | ||
| 808 | 808 | if (unclosedScopes.length > 0) { |
| 809 | 809 | // Suggest closing the innermost (last) unclosed scope first |
| 810 | 810 | const innermostScope = unclosedScopes[unclosedScopes.length - 1]; |
| 811 | - const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name); | |
| 811 | + // Preserve whitespace padding from the opening tag | |
| 812 | + // Pass currentPadding so the closing tag can replace user-typed whitespace with the target padding | |
| 813 | + const closingOption = new MacroClosingTagAutoCompleteOption(innermostScope.name, { | |
| 814 | + paddingBefore: innermostScope.paddingBefore, | |
| 815 | + paddingAfter: innermostScope.paddingAfter, | |
| 816 | + currentPadding: context.paddingBefore, | |
| 817 | + }); | |
| 812 | 818 | options.push(closingOption); |
| 813 | 819 | |
| 814 | 820 | // If inside a scoped {{if}}, also suggest {{else}} |
| @@ -891,6 +897,9 @@ export class SlashCommandParser { | ||
| 891 | 897 | if (!macroContext) { |
| 892 | 898 | macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({ |
| 893 | 899 | paddingAfter: context.paddingBefore, // Match whitespace before the macro - will only be used if the macro gets auto-closed |
| 900 | + flags: context.flags, | |
| 901 | + currentFlag: context.currentFlag, | |
| 902 | + fullText: context.fullText, | |
| 894 | 903 | }); |
| 895 | 904 | } |
| 896 | 905 | |
| @@ -1216,7 +1225,7 @@ export class SlashCommandParser { | ||
| 1216 | 1225 | * Uses the MacroParser and MacroCstWalker for accurate analysis. |
| 1217 | 1226 | * |
| 1218 | 1227 | * @param {string} textUpToCursor - The document text up to the cursor position. |
| 1219 | 1228 | * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} |
| 1220 | 1229 | */ |
| 1221 | 1230 | #findUnclosedScopes(textUpToCursor) { |
| 1222 | 1231 | if (!textUpToCursor) return []; |
| @@ -1239,32 +1248,43 @@ export class SlashCommandParser { | ||
| 1239 | 1248 | * Used when the parser fails on incomplete input. |
| 1240 | 1249 | * |
| 1241 | 1250 | * @param {string} text - The text to analyze. |
| 1242 | 1251 | * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} |
| 1243 | 1252 | */ |
| 1244 | 1253 | #findUnclosedScopesRegex(text) { |
| 1245 | 1254 | // Simple regexRegex to find macro openings and closings, capturing whitespace padding |
| 1246 | - // This is a fallback - less accurate but works on partial input | |
| 1255 | + // Group 1: padding after {{, Group 2: optional /, Group 3: macro name | |
| 1247 | 1256 | const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g; |
| 1248 | 1257 | const stack = []; |
| 1249 | 1258 | |
| 1250 | 1259 | let match; |
| 1251 | 1260 | while ((match = macroPattern.exec(text)) !== null) { |
| 1252 | 1261 | const isClosingpaddingBefore = match[1] === '/'; |
| 1253 | 1262 | const nameisClosing = match[2] === '/'; |
| 1263 | + const name = match[3]; | |
| 1254 | 1264 | |
| 1255 | 1265 | if (isClosing) { |
| 1256 | 1266 | // Pop matching opener (case-insensitive) |
| 1257 | 1267 | if (stack.length > 0 && stack[stack.length - 1].name.toLowerCase() === name.toLowerCase()) { |
| 1258 | 1268 | stack.pop(); |
| 1259 | 1269 | } |
| 1260 | 1270 | } else { |
| 1261 | 1271 | // Check if macro can accept scoped content |
| 1262 | 1272 | const macroDef = macroSystem.registry.getPrimaryMacro(name); |
| 1263 | 1273 | if (macroDef && macroDef.maxArgs > 0) { |
| 1274 | + // Try to find closing }} to extract trailing whitespace | |
| 1275 | + let paddingAfter = ''; | |
| 1276 | + const afterMatch = text.slice(match.index + match[0].length); | |
| 1277 | + const closingMatch = afterMatch.match(/^[^}]*?(\s*)\}\}/); | |
| 1278 | + if (closingMatch) { | |
| 1279 | + paddingAfter = closingMatch[1]; | |
| 1280 | + } | |
| 1281 | + | |
| 1264 | 1282 | stack.push({ |
| 1265 | 1283 | name, |
| 1266 | 1284 | startOffset: match.index, |
| 1267 | 1285 | endOffset: match.index + match[0].length, |
| 1286 | + paddingBefore, | |
| 1287 | + paddingAfter, | |
| 1268 | 1288 | }); |
| 1269 | 1289 | } |
| 1270 | 1290 | } |
| @@ -4,7 +4,7 @@ import { t } from './i18n.js'; | ||
| 4 | 4 | import { getMessageTimeStamp } from './RossAscends-mods.js'; |
| 5 | 5 | import { getSlashCommandsHelp } from './slash-commands.js'; |
| 6 | 6 | import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js'; |
| 7 | 7 | import { MacroBrowser, getMacrosHelp } from './macros/engine/MacroBrowser.js'; |
| 8 | 8 | import { renderTemplateAsync } from './templates.js'; |
| 9 | 9 | |
| 10 | 10 | /** @type {Record<string, ChatMessage>} */ |