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>

b453fdc5d86898c9e64343c7541be49391b8d0fa

Wolfsblvt <wolfsblvt@gmail.com>

Signed
8 files changed, +141 -29Ignore whitespace
public/scripts/autocomplete/AutoComplete.js+4 -2
@@ -692,8 +692,10 @@ export class AutoComplete {
692692 */
693693 async select() {
694694 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)
696696 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;
697699 this.textarea.selectionEnd = this.textarea.selectionStart;
698700 this.show(false, false, true);
699701 } else {
public/scripts/autocomplete/AutoCompleteOption.js+9 -0
@@ -13,6 +13,15 @@ export class AutoCompleteOption {
1313 /** @type {(input:string)=>boolean} */ matchProvider;
1414 /** @type {(input:string)=>string} */ valueProvider;
1515 /** @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+
1625 /**
1726 * Priority for sorting. Lower values = higher priority (sorted first).
1827 * Default is 100 (normal priority). Use lower values for items that should appear at the top.
public/scripts/autocomplete/EnhancedMacroAutoCompleteOption.js+40 -10
@@ -9,7 +9,7 @@ import {
99 createSourceIndicator,
1010 createAliasIndicator,
1111 renderMacroDetails,
1212} from '../macros/engine/MacroBrowser.js';
1313import { enumIcons } from '../slash-commands/SlashCommandCommonEnumsProvider.js';
1414import { ValidFlagSymbols } from '../macros/engine/MacroFlags.js';
1515import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js';
@@ -54,6 +54,9 @@ import { MACRO_VARIABLE_SHORTHAND_PATTERN } from '../macros/engine/MacroLexer.js
5454 * @property {boolean} [noBraces=false] - If true, display without {{ }} braces (for use as values, e.g., in {{if}} conditions).
5555 * @property {string} [paddingAfter=''] - Whitespace to add before closing }} (for matching opening whitespace style).
5656 * @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.
5760 */
5861
5962export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
@@ -63,6 +66,9 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
6366 /** @type {MacroAutoCompleteContext|null} */
6467 #context = null;
6568
69+ /** @type {EnhancedMacroAutoCompleteOptions|null} */
70+ #options = null;
71+
6672 /** @type {boolean} */
6773 #noBraces = false;
6874
@@ -83,12 +89,12 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
8389 if (contextOrOptions && typeof contextOrOptions === 'object') {
8490 if ('noBraces' in contextOrOptions || 'paddingAfter' in contextOrOptions || 'closeWithBraces' in contextOrOptions) {
8591 // It's an options object
8692 const this.#options = /** @type {EnhancedMacroAutoCompleteOptions} */ (contextOrOptions);
8793 this.#noBraces = this.#options.noBraces ?? false;
8894 this.#paddingAfter = this.#options.paddingAfter ?? '';
8995
9096 // If noBraces mode with closeWithBraces, complete with name + padding + }}
9197 if (this.#options.closeWithBraces) {
9298 this.valueProvider = () => `${macro.name}${this.#paddingAfter}}}`;
9399 this.makeSelectable = true;
94100 }
@@ -110,6 +116,12 @@ export class EnhancedMacroAutoCompleteOption extends AutoCompleteOption {
110116 this.makeSelectable = true; // Required when using valueProvider
111117 }
112118 }
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+ }
113125 }
114126
115127 /** @returns {MacroDefinition} */
@@ -907,20 +919,38 @@ export class MacroClosingTagAutoCompleteOption extends AutoCompleteOption {
907919 /** @type {string} */
908920 #macroName;
909921
922+ /** @type {string} */
923+ #paddingBefore;
924+
925+ /** @type {string} */
926+ #paddingAfter;
927+
910928 /**
911929 * @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 {{.
912934 */
913935 constructor(macroName, options = {}) {
914936 // The closing tag is what we're suggesting - use /macroName as the name for matching
915937 const closingTag = `/${macroName}`;
916938 super(closingTag, '{/');
917939 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;
918948
919949 // Custom valueProvider to return the correct replacement text
920950 // Autocomplete REPLACESIncludes the typedtarget identifierpaddingBefore entirely,from sothe returnopening thetag, fullreplacing closingany taguser-typed whitespace
921951 this.valueProvider = () => {
922- // Return full closing tag content (without {{ since that's before the identifier)
952+ // Return: paddingBefore + /macroName + paddingAfter + }}
923953 return `${this.#paddingBefore}/${macroName}${this.#paddingAfter}}}`;
924954 };
925955
926956 // Make selectable so TAB completion works (valueProvider alone makes it non-selectable)
@@ -1033,7 +1063,7 @@ export function parseMacroContext(macroText, cursorOffset) {
10331063 while (i < macroText.length) {
10341064 const char = macroText[i];
10351065 // Check if this looks like a closing tag: `/` followed by an identifier character
10361066 if (char === '/' && i + 1 < macroText.length && /[a-zA-Z_Z/]/.test(macroText[i + 1])) {
10371067 // This is a closing tag identifier, not a flag - stop parsing flags
10381068 break;
10391069 }
public/scripts/macros/definitions/core-macros.js+7 -0
@@ -261,6 +261,13 @@ export function registerCoreMacros() {
261261 MacroRegistry.registerMacro('//', {
262262 aliases: [{ alias: 'comment', visible: false }],
263263 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+ ],
264271 list: true, // We consume any arguments as if this is a list, but we'll ignore them in the handler anyway
265272 strictArgs: false, // and we also always remove it, even if the parsing might say it's invalid
266273 description: 'Comment macro that produces an empty string. Can be used for writing into prompt definitions, without being passed to the context.',
public/scripts/macros/MacroBrowser.js → public/scripts/macros/engine/MacroBrowser.js+10 -4
@@ -3,11 +3,12 @@
33 * Similar to SlashCommandBrowser but for the macro system.
44 */
55
66import { MacroRegistry, MacroCategory } from './engine/MacroRegistry.js';
77import { performFuzzySearch } from '../../power-user.js';
8+import { escapeRegex } from '/scripts/utils.js';
89
910/** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */
1011/** @typedef {import('./engine/MacroRegistry.js').MacroValueType} MacroValueType */
1112
1213/**
1314 * Category display names and order for documentation.
@@ -328,6 +329,11 @@ function getCategoryConfig(category) {
328329export function formatMacroSignature(macro) {
329330 // Use displayOverride if provided
330331 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+ }
331337 return macro.displayOverride;
332338 }
333339
public/scripts/macros/engine/MacroCstWalker.js+40 -2
@@ -190,7 +190,7 @@ class MacroCstWalker {
190190 * @param {Object} options
191191 * @param {string} options.text - The document text.
192192 * @param {CstNode} options.cst - The parsed CST.
193193 * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} - Array of unclosed macro info, innermost last.
194194 */
195195 findUnclosedScopes(options) {
196196 const { text, cst } = options;
@@ -203,7 +203,7 @@ class MacroCstWalker {
203203 // Don't process scoped macros - we want to find the raw opening/closing pairs
204204 // Just extract macro info and find unmatched openers
205205
206206 /** @type {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>} */
207207 const unclosedStack = [];
208208
209209 // Extract macro names and closing status
@@ -222,10 +222,15 @@ class MacroCstWalker {
222222 } else {
223223 // Opening tag - check if this macro can accept scoped content
224224 if (this.#canAcceptScopedContent(item.node, info.name)) {
225+ // Extract whitespace padding from the macro
226+ const { paddingBefore, paddingAfter } = this.#extractMacroPadding(item.node, text);
227+
225228 unclosedStack.push({
226229 name: info.name,
227230 startOffset: item.startOffset,
228231 endOffset: item.endOffset,
232+ paddingBefore,
233+ paddingAfter,
229234 });
230235 }
231236 }
@@ -234,6 +239,39 @@ class MacroCstWalker {
234239 return unclosedStack;
235240 }
236241
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+
237275 /** @typedef {{ type: 'plaintext', startOffset: number, endOffset: number, token: IToken }} DocumentItemPlaintext */
238276 /** @typedef {{ type: 'macro', startOffset: number, endOffset: number, node: CstNode, scopedContent?: { startOffset: number, endOffset: number, closingEndOffset: number }, keepRaw?: boolean }} DocumentItemMacro */
239277 /** @typedef {DocumentItemPlaintext | DocumentItemMacro} DocumentItem */
public/scripts/slash-commands/SlashCommandParser.js+30 -10
@@ -808,7 +808,13 @@ export class SlashCommandParser {
808808 if (unclosedScopes.length > 0) {
809809 // Suggest closing the innermost (last) unclosed scope first
810810 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+ });
812818 options.push(closingOption);
813819
814820 // If inside a scoped {{if}}, also suggest {{else}}
@@ -891,6 +897,9 @@ export class SlashCommandParser {
891897 if (!macroContext) {
892898 macroContext = /** @type {EnhancedMacroAutoCompleteOptions} */ ({
893899 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,
894903 });
895904 }
896905
@@ -1216,7 +1225,7 @@ export class SlashCommandParser {
12161225 * Uses the MacroParser and MacroCstWalker for accurate analysis.
12171226 *
12181227 * @param {string} textUpToCursor - The document text up to the cursor position.
12191228 * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
12201229 */
12211230 #findUnclosedScopes(textUpToCursor) {
12221231 if (!textUpToCursor) return [];
@@ -1239,32 +1248,43 @@ export class SlashCommandParser {
12391248 * Used when the parser fails on incomplete input.
12401249 *
12411250 * @param {string} text - The text to analyze.
12421251 * @returns {Array<{ name: string, startOffset: number, endOffset: number, paddingBefore: string, paddingAfter: string }>}
12431252 */
12441253 #findUnclosedScopesRegex(text) {
12451254 // 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
12471256 const macroPattern = /\{\{(\s*)(\/?)([\w-]+)/g;
12481257 const stack = [];
12491258
12501259 let match;
12511260 while ((match = macroPattern.exec(text)) !== null) {
12521261 const isClosingpaddingBefore = match[1] === '/';
12531262 const nameisClosing = match[2] === '/';
1263+ const name = match[3];
12541264
12551265 if (isClosing) {
12561266 // Pop matching opener (case-insensitive)
12571267 if (stack.length > 0 && stack[stack.length - 1].name.toLowerCase() === name.toLowerCase()) {
12581268 stack.pop();
12591269 }
12601270 } else {
12611271 // Check if macro can accept scoped content
12621272 const macroDef = macroSystem.registry.getPrimaryMacro(name);
12631273 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+
12641282 stack.push({
12651283 name,
12661284 startOffset: match.index,
12671285 endOffset: match.index + match[0].length,
1286+ paddingBefore,
1287+ paddingAfter,
12681288 });
12691289 }
12701290 }
public/scripts/system-messages.js+1 -1
@@ -4,7 +4,7 @@ import { t } from './i18n.js';
44import { getMessageTimeStamp } from './RossAscends-mods.js';
55import { getSlashCommandsHelp } from './slash-commands.js';
66import { SlashCommandBrowser } from './slash-commands/SlashCommandBrowser.js';
77import { MacroBrowser, getMacrosHelp } from './macros/engine/MacroBrowser.js';
88import { renderTemplateAsync } from './templates.js';
99
1010/** @type {Record<string, ChatMessage>} */