feat: Advanced Regex Chain Debugger and Testing Panel (#4418) * feat: Advanced Regex Chain Debugger and Testing Panel * fix * fix_2 * fix_3 * Address code review suggestions for debugger feature * feat: Add match statistics to debugger * ESLint * feat: Enhance debugger with diff view and nav popup * ESLint * Fix styles and full results expand * Diff clean-up --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

176825bdb87faa33a296e4d54ab25a09dc657f5f

MAX-TAB <60381043+MAX-TAB@users.noreply.github.com>

Signed
5 files changed, +882 -2Showing whitespace changes
public/scripts/extensions/regex/debugger.css+255 -0
@@ -0,0 +1,255 @@
1+/* Styles for the debugger UI */
2+#regex_debugger_rules {
3+ margin: 10px 0;
4+}
5+
6+#regex_debugger_rules,
7+#regex_debugger_rules .sortable-list {
8+ padding-left: 0;
9+}
10+
11+.regex-debugger-rules-list {
12+ position: relative;
13+}
14+
15+.regex-debugger-rule {
16+ display: flex;
17+ align-items: center;
18+ padding: 8px 10px;
19+ border: 1px solid var(--SmartThemeBorderColor);
20+ border-radius: 5px;
21+ cursor: pointer;
22+ background-color: var(--black30a);
23+ gap: 5px;
24+ margin-bottom: 5px;
25+}
26+
27+#regex_debugger_run_test_header {
28+ justify-content: space-between;
29+ align-items: center;
30+}
31+
32+#regex_debugger_expand_steps,
33+#regex_debugger_expand_final,
34+#regex_debugger_save_order {
35+ position: absolute;
36+ top: 0;
37+ right: 0;
38+ margin: 0;
39+}
40+
41+.regex-debugger-rule:hover {
42+ filter: brightness(1.1);
43+}
44+
45+.regex-debugger-rule .handle {
46+ cursor: grab;
47+ margin-right: 5px;
48+}
49+
50+.regex-debugger-rule .rule-details {
51+ flex-grow: 1;
52+ text-align: left;
53+ display: flex;
54+ align-items: baseline;
55+ gap: 5px;
56+}
57+
58+.regex-debugger-rule .rule-name {
59+ font-weight: bold;
60+}
61+
62+.regex-debugger-rule .rule-regex {
63+ font-size: 0.8em;
64+ opacity: 0.8;
65+ font-family: var(--monoFontFamily);
66+}
67+
68+.regex-debugger-rule .rule-scope {
69+ font-size: 0.8em;
70+ padding: 2px 6px;
71+ border-radius: 5px;
72+ background-color: var(--black30a);
73+ margin-left: auto;
74+ margin-right: 10px;
75+}
76+
77+.regex-debugger-rule .menu_button {
78+ margin: 0;
79+}
80+
81+#regex_debugger_raw_input {
82+ min-height: 1.8em;
83+}
84+
85+#regex_debugger_steps_output {
86+ min-height: 2em;
87+ max-height: 300px;
88+ overflow-y: auto;
89+ border: 1px solid var(--SmartThemeBorderColor);
90+ border-radius: 5px;
91+ padding: 5px;
92+ background-color: var(--black30a);
93+ font-family: var(--monoFontFamily);
94+ font-size: 0.9em;
95+}
96+
97+#regex_debugger_final_output {
98+ min-height: 2em;
99+ max-height: 300px;
100+ overflow-y: auto;
101+ border: 1px solid var(--SmartThemeBorderColor);
102+ border-radius: 5px;
103+ padding: 5px;
104+ background-color: var(--black30a);
105+ white-space: pre-wrap;
106+ word-break: break-word;
107+}
108+
109+.step-header {
110+ margin-top: 10px;
111+ margin-bottom: 5px;
112+}
113+
114+.step-output {
115+ white-space: pre-wrap;
116+ word-break: break-all;
117+ padding: 5px;
118+ background-color: var(--black30a);
119+ border-radius: 5px;
120+}
121+
122+/* Classes to replace inline styles */
123+.regex-debugger-no-rules {
124+ padding: 10px;
125+ text-align: center;
126+ opacity: 0.8;
127+}
128+
129+.regex-debugger-list-header {
130+ font-weight: bold;
131+ padding: 10px;
132+}
133+
134+/* Styles for statistics */
135+.step-header {
136+ display: flex;
137+ justify-content: space-between;
138+ align-items: center;
139+}
140+
141+.step-metrics {
142+ font-size: 0.8em;
143+ opacity: 0.8;
144+ font-weight: normal;
145+}
146+
147+.regex-debugger-summary {
148+ padding: 8px;
149+ margin-bottom: 10px;
150+ border: 1px solid var(--SmartThemeBorderColor);
151+ background-color: var(--black30a);
152+ border-radius: 5px;
153+ text-align: center;
154+ font-size: 0.9em;
155+}
156+
157+.regex-debugger-tester .results-header {
158+ position: relative;
159+ margin: 10px 0;
160+}
161+
162+.regex-debugger-tester .radio_group {
163+ text-align: left;
164+}
165+
166+/* Styles for statistics and highlighting additions */
167+.step-header {
168+ display: flex;
169+ justify-content: space-between;
170+ align-items: center;
171+ flex-wrap: wrap;
172+ /* Allow wrapping on small screens */
173+}
174+
175+.step-metrics {
176+ font-size: 0.8em;
177+ opacity: 0.8;
178+ font-weight: normal;
179+ white-space: nowrap;
180+ /* Prevent metrics from breaking line */
181+ margin-left: 10px;
182+}
183+
184+.regex-debugger-summary {
185+ padding: 8px;
186+ margin-bottom: 10px;
187+ border: 1px solid var(--SmartThemeBorderColor);
188+ background-color: var(--black30a);
189+ border-radius: 5px;
190+ text-align: center;
191+ font-size: 0.9em;
192+}
193+
194+/* New highlight color for added text */
195+mark.green_hl {
196+ background-color: #28a745;
197+ /* A standard green color */
198+ color: white;
199+}
200+
201+/* New highlight color for deleted text */
202+mark.red_hl {
203+ background-color: #dc3545;
204+ /* A standard red color */
205+ color: white;
206+ text-decoration: line-through;
207+}
208+
209+/* Styles for the expanded view with navigation */
210+.expanded-regex-container {
211+ display: flex;
212+ height: 75vh;
213+ /* Give the container a good height */
214+ overflow: hidden;
215+}
216+
217+.expanded-regex-nav {
218+ flex: 0 0 200px;
219+ /* Fixed width for the nav bar */
220+ border-right: 1px solid var(--SmartThemeBorderColor);
221+ padding: 5px;
222+ overflow-y: auto;
223+ background-color: var(--black30a);
224+}
225+
226+.expanded-regex-nav a {
227+ display: block;
228+ padding: 6px 8px;
229+ text-decoration: none;
230+ color: var(--SmartThemeMainColor);
231+ border-radius: 5px;
232+ white-space: nowrap;
233+ overflow: hidden;
234+ text-overflow: ellipsis;
235+}
236+
237+.expanded-regex-nav a:hover {
238+ background-color: var(--background_hover_color);
239+}
240+
241+.expanded-regex-nav a.active {
242+ background-color: var(--highlight_color);
243+ color: var(--text_color_black);
244+}
245+
246+.expanded-regex-content {
247+ flex-grow: 1;
248+ overflow-y: auto;
249+ padding-left: 10px;
250+}
251+
252+#regex_debugger_render_mode {
253+ padding-right: 20px;
254+ margin-top: 5px;
255+}
public/scripts/extensions/regex/debugger.html+175 -0
@@ -0,0 +1,175 @@
1+<div class="regex-debugger-container">
2+ <!-- Rules List Column -->
3+ <div class="regex-debugger-rules-list">
4+ <h3>
5+ <i class="fa-solid fa-list-ol"></i>
6+ <span data-i18n="ext_regex_debugger_active_rules"
7+ >Active Rules</span
8+ >
9+ </h3>
10+ <div class="flex-container">
11+ <button
12+ id="regex_debugger_save_order"
13+ class="menu_button menu_button_icon interactable"
14+ title="Save current rule order"
15+ tabindex="0"
16+ >
17+ <i class="fa-solid fa-floppy-disk"></i>
18+ <span data-i18n="ext_regex_debugger_save_order"
19+ >Save Order</span
20+ >
21+ </button>
22+ </div>
23+ <ul id="regex_debugger_rules" class="sortable-list">
24+ <!-- Rules will be populated here by JavaScript -->
25+ </ul>
26+ </div>
27+
28+ <!-- Testing Area Column -->
29+ <div class="regex-debugger-tester">
30+ <h3>
31+ <i class="fa-solid fa-vial"></i>
32+ <span data-i18n="ext_regex_debugger_testing_area"
33+ >Testing Area</span
34+ >
35+ </h3>
36+ <div class="regex-debugger-io">
37+ <div class="regex-debugger-input">
38+ <label
39+ for="regex_debugger_raw_input"
40+ data-i18n="ext_regex_debugger_raw_input"
41+ >Raw Input</label
42+ >
43+ <textarea
44+ id="regex_debugger_raw_input"
45+ class="text_pole autoSetHeight"
46+ rows="4"
47+ ></textarea>
48+ </div>
49+ <div
50+ id="regex_debugger_run_test_header"
51+ class="flex-container"
52+ >
53+ <button
54+ id="regex_debugger_run_test"
55+ class="menu_button menu_button_icon interactable"
56+ title="Run the test pipeline"
57+ tabindex="0"
58+ >
59+ <i class="fa-solid fa-play"></i>
60+ <span data-i18n="ext_regex_debugger_run_test"
61+ >Run Test</span
62+ >
63+ </button>
64+ <div class="flex-container gap10px">
65+ <div class="radio_group">
66+ <label
67+ ><input
68+ type="radio"
69+ name="display_mode"
70+ value="replace"
71+ checked
72+ />
73+ <span data-i18n="ext_regex_debugger_display_replace"
74+ >Replace</span
75+ ></label
76+ >
77+ <label
78+ ><input
79+ type="radio"
80+ name="display_mode"
81+ value="highlight"
82+ />
83+ <span
84+ data-i18n="ext_regex_debugger_display_highlight"
85+ >Highlight</span
86+ ></label
87+ >
88+ </div>
89+ <select
90+ id="regex_debugger_render_mode"
91+ >
92+ <option
93+ value="text"
94+ data-i18n="ext_regex_debugger_render_text"
95+ >
96+ Render as Text
97+ </option>
98+ <option
99+ value="message"
100+ data-i18n="ext_regex_debugger_render_message"
101+ >
102+ Render as Message
103+ </option>
104+ </select>
105+ </div>
106+ </div>
107+ <div class="regex-debugger-results">
108+ <div class="results-header">
109+ <h4>
110+ <i class="fa-solid fa-shoe-prints"></i>
111+ <span data-i18n="ext_regex_debugger_step_by_step"
112+ >Step-by-step Transformation</span
113+ >
114+ </h4>
115+ <div
116+ id="regex_debugger_expand_steps"
117+ class="menu_button menu_button_icon"
118+ title="Expand view"
119+ >
120+ <i class="fa-solid fa-expand"></i>
121+ </div>
122+ </div>
123+ <div id="regex_debugger_steps_output" class="results-box"></div>
124+
125+ <div class="results-header">
126+ <h4>
127+ <i class="fa-solid fa-flag-checkered"></i>
128+ <span data-i18n="ext_regex_debugger_final_output"
129+ >Final Output</span
130+ >
131+ </h4>
132+ <div
133+ id="regex_debugger_expand_final"
134+ class="menu_button menu_button_icon"
135+ title="Expand view"
136+ >
137+ <i class="fa-solid fa-expand"></i>
138+ </div>
139+ </div>
140+ <div
141+ id="regex_debugger_final_output"
142+ class="results-box final-output"
143+ ></div>
144+ </div>
145+ </div>
146+ </div>
147+</div>
148+
149+<!-- Template for a single rule item -->
150+<template id="regex_debugger_rule_template">
151+ <li class="regex-debugger-rule" draggable="true">
152+ <i class="fa-solid fa-grip-vertical handle"></i>
153+ <label class="checkbox">
154+ <input type="checkbox" class="rule-enabled" checked />
155+ </label>
156+ <div class="rule-details">
157+ <span class="rule-name"></span>
158+ <code class="rule-regex"></code>
159+ <small class="rule-scope"></small>
160+ </div>
161+ <div class="menu_button menu_button_icon edit_rule" title="Edit Rule">
162+ <i class="fa-solid fa-pencil"></i>
163+ </div>
164+ </li>
165+</template>
166+
167+<!-- Template for a single transformation step -->
168+<template id="regex_debugger_step_template">
169+ <div class="step-result">
170+ <div class="step-header">
171+ <strong></strong>
172+ </div>
173+ <pre class="step-output"></pre>
174+ </div>
175+</template>
public/scripts/extensions/regex/dropdown.html+4 -0
@@ -26,6 +26,10 @@
2626 <i class="fa-solid fa-edit"></i>
2727 <small data-i18n="ext_regex_bulk_edit">Bulk Edit</small>
2828 </label>
29+ <div id="open_regex_debugger" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_debugger_desc" title="Advanced Regex Debugger">
30+ <i class="fa-solid fa-bug-slash"></i>
31+ <small data-i18n="ext_regex_debugger">Debugger</small>
32+ </div>
2933 </div>
3034 <div class="regex_bulk_operations flex-container justifyCenter">
3135 <div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">
public/scripts/extensions/regex/index.js+446 -2
@@ -1,4 +1,4 @@
11import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
22import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js';
33import { selected_group } from '../../group-chats.js';
44import { callGenericPopup, POPUP_TYPE } from '../../popup.js';
@@ -7,7 +7,7 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
77import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
88import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
99import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
1010import { download, equalsIgnoreCaseAndAccents, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4, escapeHtml } from '../../utils.js';
1111import { regex_placement, runRegexScript, substitute_find_regex } from './engine.js';
1212import { t } from '../../i18n.js';
1313import { accountStorage } from '../../util/AccountStorage.js';
@@ -94,6 +94,11 @@ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
9494 if (currentChatId !== undefined && currentChatId !== null) {
9595 await reloadCurrentChat();
9696 }
97+
98+ const debuggerPopup = $('#regex_debugger_popup');
99+ if (debuggerPopup.length) {
100+ populateDebuggerRuleList(debuggerPopup.parent());
101+ }
97102}
98103
99104async function deleteRegexScript({ id, isScoped }) {
@@ -330,6 +335,442 @@ async function onRegexEditorOpenClick(existingId, isScoped) {
330335}
331336
332337/**
338+ * Builds an HTML string for a replacement, highlighting literal parts in green
339+ * and keeping back-referenced parts plain.
340+ * @param {RegExpMatchArray} match The match object from `matchAll`.
341+ * @param {string} pattern The replacement pattern string (e.g., "new text $1").
342+ * @returns {string} The constructed HTML string.
343+ */
344+function buildReplacementHtml(match, pattern) {
345+ const container = document.createDocumentFragment();
346+ let lastIndex = 0;
347+ const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
348+
349+ let reMatch;
350+ while ((reMatch = backrefRegex.exec(pattern)) !== null) {
351+ // Part of the pattern before the back-reference is a literal.
352+ const literalPart = pattern.substring(lastIndex, reMatch.index);
353+ if (literalPart) {
354+ const mark = document.createElement('mark');
355+ mark.className = 'green_hl';
356+ mark.innerText = literalPart;
357+ container.appendChild(mark);
358+ }
359+
360+ const backref = reMatch[0];
361+ if (backref === '$$') {
362+ container.appendChild(document.createTextNode('$'));
363+ } else if (backref === '$&') {
364+ const mark = document.createElement('mark');
365+ mark.className = 'yellow_hl';
366+ mark.innerText = match[0];
367+ container.appendChild(mark);
368+ } else if (backref === '$`') {
369+ container.appendChild(document.createTextNode(match.input.substring(0, match.index)));
370+ } else if (backref === '$\'') {
371+ container.appendChild(document.createTextNode(match.input.substring(match.index + match[0].length)));
372+ } else { // It's a numbered capture group, $n.
373+ const groupIndex = parseInt(reMatch[1], 10);
374+ if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
375+ const mark = document.createElement('mark');
376+ mark.className = 'yellow_hl';
377+ mark.innerText = match[groupIndex];
378+ container.appendChild(mark);
379+ } else {
380+ // Not a valid group index, treat it as a literal.
381+ const mark = document.createElement('mark');
382+ mark.className = 'green_hl';
383+ mark.innerText = backref;
384+ container.appendChild(mark);
385+ }
386+ }
387+ lastIndex = backrefRegex.lastIndex;
388+ }
389+
390+ // The final part of the pattern after the last back-reference.
391+ const finalLiteralPart = pattern.substring(lastIndex);
392+ if (finalLiteralPart) {
393+ const mark = document.createElement('mark');
394+ mark.className = 'green_hl';
395+ mark.innerText = finalLiteralPart;
396+ container.appendChild(mark);
397+ }
398+
399+ // To get the HTML content, we need a temporary parent element.
400+ const tempDiv = document.createElement('div');
401+ tempDiv.appendChild(container);
402+ return tempDiv.innerHTML;
403+}
404+
405+function executeRegexScriptForDebugging(script, text) {
406+ let err;
407+ let originalRegex;
408+
409+ try {
410+ originalRegex = regexFromString(script.findRegex);
411+ if (!originalRegex) throw new Error('Invalid regex string');
412+ } catch (e) {
413+ err = `Compile error: ${e.message}`;
414+ return { output: text, highlightedOutput: text, error: err, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
415+ }
416+
417+ const globalRegex = new RegExp(originalRegex.source, originalRegex.flags.includes('g') ? originalRegex.flags : originalRegex.flags + 'g');
418+ const matches = [...text.matchAll(globalRegex)];
419+
420+ if (matches.length === 0) {
421+ return { output: text, highlightedOutput: escapeHtml(text), error: null, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
422+ }
423+
424+ let outputText = '';
425+ let highlightedOutput = ''; // This will now be our "diff view"
426+ let lastIndex = 0;
427+ let totalCharsCaptured = 0;
428+ let totalCharsAdded = 0;
429+ let totalCharsRemoved = 0;
430+
431+ try {
432+ for (const match of matches) {
433+ const originalMatchText = match[0];
434+ totalCharsCaptured += originalMatchText.length;
435+
436+ // Append text between matches (this part is unchanged)
437+ const precedingText = text.substring(lastIndex, match.index);
438+ outputText += precedingText;
439+ highlightedOutput += escapeHtml(precedingText);
440+
441+ // --- Start of new diff and statistics logic ---
442+ let charsAddedInMatch = 0;
443+ let charsKeptFromMatch = 0;
444+ const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
445+ let lastPatternIndex = 0;
446+ let reMatch;
447+ let replacementForPlainText = '';
448+
449+ // This loop calculates the stats accurately
450+ while ((reMatch = backrefRegex.exec(script.replaceString)) !== null) {
451+ const literalPart = script.replaceString.substring(lastPatternIndex, reMatch.index);
452+ charsAddedInMatch += literalPart.length;
453+ replacementForPlainText += literalPart;
454+ const backref = reMatch[0];
455+ if (backref === '$$') {
456+ replacementForPlainText += '$';
457+ } else if (backref === '$&') {
458+ charsKeptFromMatch += (match[0] || '').length; replacementForPlainText += (match[0] || '');
459+ } else if (backref === '$`') {
460+ const part = match.input.substring(0, match.index); charsKeptFromMatch += part.length; replacementForPlainText += part;
461+ } else if (backref === '$\'') {
462+ const part = match.input.substring(match.index + match[0].length); charsKeptFromMatch += part.length; replacementForPlainText += part;
463+ } else {
464+ const groupIndex = parseInt(reMatch[1], 10);
465+ if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
466+ charsKeptFromMatch += match[groupIndex].length;
467+ replacementForPlainText += match[groupIndex];
468+ }
469+ }
470+ lastPatternIndex = backrefRegex.lastIndex;
471+ }
472+ const finalLiteralPart = script.replaceString.substring(lastPatternIndex);
473+ charsAddedInMatch += finalLiteralPart.length;
474+ replacementForPlainText += finalLiteralPart;
475+
476+ totalCharsAdded += charsAddedInMatch;
477+ totalCharsRemoved += (originalMatchText.length - charsKeptFromMatch);
478+
479+ outputText += replacementForPlainText;
480+ // --- End of statistics logic ---
481+
482+ // --- Build the new Diff View HTML ---
483+ // 1. Show the entire original match as "removed" (red strikethrough)
484+ highlightedOutput += `<mark class='red_hl'>${escapeHtml(originalMatchText)}</mark>`;
485+ // 2. Add an arrow to signify transformation
486+ highlightedOutput += ' → ';
487+ // 3. Build the replacement string with green (added) and yellow (kept) parts
488+ highlightedOutput += buildReplacementHtml(match, script.replaceString);
489+
490+ lastIndex = match.index + originalMatchText.length;
491+ }
492+
493+ // Append text after the last match
494+ const trailingText = text.substring(lastIndex);
495+ outputText += trailingText;
496+ highlightedOutput += escapeHtml(trailingText);
497+
498+ } catch (e) {
499+ err = (err ? err + '; ' : '') + `Replace error: ${e.message}`;
500+ outputText = text; // Fallback
501+ highlightedOutput = escapeHtml(text);
502+ }
503+
504+ return {
505+ output: outputText,
506+ highlightedOutput: highlightedOutput,
507+ error: err,
508+ charsCaptured: totalCharsCaptured,
509+ charsAdded: totalCharsAdded,
510+ charsRemoved: totalCharsRemoved,
511+ };
512+}
513+
514+function populateDebuggerRuleList(container) {
515+ const rulesContainer = container.find('#regex_debugger_rules');
516+ const ruleTemplate = container.find('#regex_debugger_rule_template');
517+ if (!rulesContainer.length || !ruleTemplate.length) {
518+ console.error('Regex Debugger: Could not find rule list or template in the DOM.');
519+ return;
520+ }
521+
522+ rulesContainer.empty();
523+
524+ const allScripts = getRegexScripts();
525+ if (!allScripts || allScripts.length === 0) {
526+ rulesContainer.append('<div class="regex-debugger-no-rules">No regex rules found.</div>');
527+ return;
528+ }
529+
530+ const globalScriptIds = new Set((extension_settings.regex ?? []).map(s => s.id));
531+ const globalScripts = [];
532+ const scopedScripts = [];
533+
534+ allScripts.forEach(script => {
535+ const scriptCopy = structuredClone(script); // Use structuredClone for deep copy
536+ if (globalScriptIds.has(script.id)) {
537+ // @ts-ignore
538+ scriptCopy.isScoped = false;
539+ globalScripts.push(scriptCopy);
540+ } else {
541+ // @ts-ignore
542+ scriptCopy.isScoped = true;
543+ scopedScripts.push(scriptCopy);
544+ }
545+ });
546+
547+ container.data('allScripts', [...globalScripts, ...scopedScripts]);
548+
549+ const renderRule = (script) => {
550+ if (!script.id) script.id = uuidv4();
551+ const ruleElementContent = $(ruleTemplate.prop('content')).clone();
552+ const ruleElement = ruleElementContent.find('.regex-debugger-rule');
553+
554+ ruleElement.attr('data-id', script.id);
555+ // @ts-ignore
556+ ruleElement.find('.rule-name').text(script.scriptName);
557+ ruleElement.find('.rule-regex').text(script.findRegex);
558+ // @ts-ignore
559+ ruleElement.find('.rule-scope').text(script.isScoped ? 'Scoped' : 'Global');
560+ ruleElement.find('.rule-enabled').prop('checked', !script.disabled);
561+ // @ts-ignore
562+ ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.isScoped));
563+
564+ ruleElement.on('click', function (event) {
565+ if ($(event.target).is('input, .menu_button, .menu_button i')) {
566+ return;
567+ }
568+ const scriptId = $(this).data('id');
569+ const stepElement = $(`#step-result-${scriptId}`);
570+ const container = $('#regex_debugger_steps_output');
571+
572+ if (stepElement.length && container.length) {
573+ // Replace scrollIntoView with scrollTop animation
574+ const targetTop = stepElement.position().top;
575+ const containerScrollTop = container.scrollTop();
576+ const containerHeight = container.height();
577+
578+ // Center the element if possible
579+ let scrollTo = containerScrollTop + targetTop - (containerHeight / 2) + (stepElement.height() / 2);
580+
581+ container.animate({ scrollTop: scrollTo }, 300); // 300ms smooth scroll
582+
583+ stepElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
584+ setTimeout(() => stepElement.css('background-color', ''), 1000);
585+ }
586+ });
587+
588+ return ruleElementContent;
589+ };
590+
591+ if (globalScripts.length > 0) {
592+ rulesContainer.append('<div class="list-header regex-debugger-list-header">Global Rules</div>');
593+ const globalList = $('<ul id="regex_debugger_rules_global" class="sortable-list"></ul>');
594+ globalScripts.forEach(script => globalList.append(renderRule(script)));
595+ rulesContainer.append(globalList);
596+ }
597+
598+ if (scopedScripts.length > 0) {
599+ rulesContainer.append('<div class="list-header regex-debugger-list-header">Scoped Rules</div>');
600+ const scopedList = $('<ul id="regex_debugger_rules_scoped" class="sortable-list"></ul>');
601+ scopedScripts.forEach(script => scopedList.append(renderRule(script)));
602+ rulesContainer.append(scopedList);
603+ }
604+}
605+
606+/**
607+ * Opens the regex debugger.
608+ * @returns {Promise<void>}
609+ */
610+async function onRegexDebuggerOpenClick() {
611+ const templateContent = await renderExtensionTemplateAsync('regex', 'debugger');
612+ const debuggerHtml = $('<div>').html(templateContent);
613+
614+ const stepTemplate = debuggerHtml.find('#regex_debugger_step_template');
615+
616+ populateDebuggerRuleList(debuggerHtml);
617+
618+ // @ts-ignore
619+ debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
620+ // @ts-ignore
621+ debuggerHtml.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
622+
623+ debuggerHtml.find('#regex_debugger_run_test').on('click', function () {
624+ const allScripts = debuggerHtml.data('allScripts');
625+ const orderedRuleIds = [
626+ ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
627+ ...$('#regex_debugger_rules_scoped').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
628+ ];
629+
630+ const rawInput = String($('#regex_debugger_raw_input').val());
631+ const stepsOutput = $('#regex_debugger_steps_output');
632+ const finalOutput = $('#regex_debugger_final_output');
633+
634+ if (!stepsOutput.length || !finalOutput.length) return;
635+
636+ const displayMode = $('input[name="display_mode"]:checked').val();
637+ stepsOutput.empty();
638+ finalOutput.empty();
639+ $('#regex_debugger_final_summary').remove();
640+
641+ if (!allScripts) return;
642+ let textForNextStep = rawInput;
643+ let totalCharsCaptured = 0;
644+ let totalCharsAdded = 0;
645+ let totalCharsRemoved = 0;
646+
647+ orderedRuleIds.forEach(scriptId => {
648+ const ruleElement = $(`#regex_debugger_rules [data-id="${scriptId}"]`);
649+ if (!ruleElement.find('.rule-enabled').is(':checked')) return;
650+
651+ const script = allScripts.find(s => s.id === scriptId);
652+
653+ if (script) {
654+ const result = executeRegexScriptForDebugging(script, textForNextStep);
655+ totalCharsCaptured += result.charsCaptured;
656+ totalCharsAdded += result.charsAdded;
657+ totalCharsRemoved += result.charsRemoved;
658+
659+ const stepElement = $(stepTemplate.prop('content')).clone();
660+ // Set the ID on the TOP-LEVEL element that is being appended.
661+ stepElement.find('>:first-child').attr('id', `step-result-${script.id}`);
662+ const stepHeader = stepElement.find('.step-header');
663+ stepHeader.find('strong').text(`After: ${script.scriptName}`);
664+
665+ const metricsHtml = `<span class="step-metrics">Captured: ${result.charsCaptured}, Added: +${result.charsAdded}, Removed: -${result.charsRemoved}</span>`;
666+ stepHeader.append(metricsHtml);
667+
668+ if (displayMode === 'highlight') {
669+ stepElement.find('.step-output').html(result.highlightedOutput);
670+ } else {
671+ stepElement.find('.step-output').text(result.output);
672+ }
673+
674+ if (result.error) {
675+ stepHeader.append($(`<div class='warning_text text_rose-500'>${result.error}</div>`));
676+ }
677+
678+ stepsOutput.append(stepElement);
679+ textForNextStep = result.output;
680+ }
681+ });
682+
683+ const summaryHtml = `
684+ <div id="regex_debugger_final_summary" class="regex-debugger-summary">
685+ <strong>Total Captured:</strong> ${totalCharsCaptured} | <strong>Total Added:</strong> +${totalCharsAdded} | <strong>Total Removed:</strong> -${totalCharsRemoved}
686+ </div>
687+ `;
688+ finalOutput.before(summaryHtml);
689+
690+ const renderMode = $('#regex_debugger_render_mode').val();
691+ if (renderMode === 'message') {
692+ const formattedHtml = messageFormatting(textForNextStep, 'Debugger', true, false, null);
693+ const messageBlock = $('<div class="mes"><div class="mes_text"></div></div>');
694+ messageBlock.find('.mes_text').html(formattedHtml);
695+ finalOutput.append(messageBlock);
696+ } else {
697+ finalOutput.text(textForNextStep);
698+ }
699+ });
700+
701+ debuggerHtml.find('#regex_debugger_save_order').on('click', async function () {
702+ const allKnownScripts = getRegexScripts();
703+ const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
704+ const newScopedScripts = $('#regex_debugger_rules_scoped').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
705+
706+ extension_settings.regex = newGlobalScripts;
707+ if (this_chid !== undefined) {
708+ await writeExtensionField(this_chid, 'regex_scripts', newScopedScripts);
709+ }
710+
711+ saveSettingsDebounced();
712+ await loadRegexScripts();
713+ toastr.success(t`Regex script order saved!`);
714+
715+ const currentPopupContent = $('div:has(> #regex_debugger_rules)');
716+ populateDebuggerRuleList(currentPopupContent);
717+ // @ts-ignore
718+ currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
719+ // @ts-ignore
720+ currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
721+ });
722+
723+ debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () {
724+ const popupContainer = $('<div class="expanded-regex-container"></div>');
725+ const navPanel = $('<div class="expanded-regex-nav"><h4>Steps</h4></div>');
726+ const contentPanel = $('<div class="expanded-regex-content"></div>');
727+
728+ const content = $('#regex_debugger_steps_output').clone().html();
729+ contentPanel.html(content);
730+
731+ $('#regex_debugger_rules .regex-debugger-rule').each(function () {
732+ const ruleElement = $(this);
733+ const scriptId = ruleElement.data('id');
734+ const scriptName = ruleElement.find('.rule-name').text();
735+
736+ const link = $(`<a href="#">${escapeHtml(scriptName)}</a>`);
737+ link.data('target-id', `step-result-${scriptId}`);
738+
739+ link.on('click', function (e) {
740+ e.preventDefault();
741+ navPanel.find('a').removeClass('active');
742+ $(this).addClass('active');
743+
744+ const targetId = $(this).data('target-id');
745+ // The selector is now correct for the structure.
746+ const targetElement = contentPanel.find(`#${targetId}`);
747+
748+ if (targetElement.length) {
749+ const scrollTo = contentPanel.scrollTop() + targetElement.position().top;
750+ contentPanel.animate({ scrollTop: scrollTo }, 300);
751+
752+ targetElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
753+ setTimeout(() => targetElement.css('background-color', ''), 1000);
754+ }
755+ });
756+
757+ navPanel.append(link);
758+ });
759+
760+ popupContainer.append(navPanel).append(contentPanel);
761+ callGenericPopup(popupContainer, POPUP_TYPE.TEXT, 'Step-by-step Transformation', { wide: true, allowVerticalScrolling: false });
762+ });
763+
764+ debuggerHtml.find('#regex_debugger_expand_final').on('click', function () {
765+ const content = $('#regex_debugger_final_output').html();
766+ const popupContent = $('<div style="height: 70vh; overflow-y: auto;"></div>').html(content);
767+ callGenericPopup(popupContent, POPUP_TYPE.TEXT, 'Final Output', { wide: true, allowVerticalScrolling: true });
768+ });
769+
770+ await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true });
771+}
772+
773+/**
333774 * Updates the info block in the regex editor with hints regarding the find regex.
334775 * @param {JQuery<HTMLElement>} editorHtml The editor HTML
335776 */
@@ -606,6 +1047,7 @@ jQuery(async () => {
6061047 $('#open_regex_editor').on('click', function () {
6071048 onRegexEditorOpenClick(false, false);
6081049 });
1050+ $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick);
6091051 $('#open_scoped_editor').on('click', function () {
6101052 if (this_chid === undefined) {
6111053 toastr.error(t`No character selected.`);
@@ -726,6 +1168,7 @@ jQuery(async () => {
7261168 },
7271169 ];
7281170 for (const { selector, setter, getter } of sortableDatas) {
1171+ // @ts-ignore
7291172 $(selector).sortable({
7301173 delay: getSortableDelay(),
7311174 stop: async function () {
@@ -778,6 +1221,7 @@ jQuery(async () => {
7781221 });
7791222
7801223 await loadRegexScripts();
1224+ // @ts-ignore
7811225 $('#saved_regex_scripts').sortable('enable');
7821226
7831227 const localEnumProviders = {
public/scripts/extensions/regex/style.css+2 -0
@@ -1,3 +1,5 @@
1+@import "debugger.css";
2+
13.regex_settings .menu_button {
24 width: fit-content;
35 display: flex;