Skip to content

Commit a0cd961

Browse files
committed
util,console: colorize regexp groups, character classes, etc
This adds a parser to parse the regular expression and to highlight different parts of a regular expression in case colors are active. It is a one time pass algorithm and should therefore not cause too much overhead during parsing. As side effect, it is now possible to create individual styles to colorize inspected values values as a user likes. This might for example be expanded to numbers with numeric separators, highlighting the separators or decimal points different. It would in theory also be possible to return a changed string. That is however not the intention for this API as it is only triggered in case colors are active.
1 parent f993fca commit a0cd961

2 files changed

Lines changed: 284 additions & 6 deletions

File tree

doc/api/util.md

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,11 @@ stream.write('With ES6');
845845
<!-- YAML
846846
added: v0.3.0
847847
changes:
848+
- version:
849+
- REPLACEME
850+
pr-url: https://github.com/nodejs/node/pull/XXXXX
851+
description: The util.inspect.styles.regexp style is now a method that is
852+
invoked for coloring the stringified regular expression.
848853
- version:
849854
- v17.3.0
850855
- v16.14.0
@@ -1295,7 +1300,12 @@ The default styles and associated colors are:
12951300
* `name`: (no styling)
12961301
* `null`: `bold`
12971302
* `number`: `yellow`
1298-
* `regexp`: `red`
1303+
* `regexp`: A method that colors character classes, groups, assertions, and
1304+
other parts for improved readability. To customize the coloring, change the
1305+
`colors` property. It is set to
1306+
`['red', 'green', 'yellow', 'cyan', 'magenta']` by default and may be
1307+
adjusted as needed. The array is repetitively iterated through depending on
1308+
the "depth".
12991309
* `special`: `cyan` (e.g., `Proxies`)
13001310
* `string`: `green`
13011311
* `symbol`: `green`
@@ -1307,6 +1317,17 @@ terminals. To verify color support use [`tty.hasColors()`][].
13071317
Predefined control codes are listed below (grouped as "Modifiers", "Foreground
13081318
colors", and "Background colors").
13091319

1320+
#### Complex custom coloring
1321+
1322+
It is possible to define a method as style. It receives the stringified value
1323+
of the input. It is invoked in case coloring is active and the type is
1324+
inspected.
1325+
1326+
Example: `util.inspect.styles.regexp(value)`
1327+
1328+
* `value` {string} The string representation of the input type.
1329+
* Returns: {string} The adjusted representation of `object`.
1330+
13101331
#### Modifiers
13111332

13121333
Modifier support varies throughout different terminals. They will mostly be

lib/internal/util/inspect.js

Lines changed: 262 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,6 @@ defineColorAlias('inverse', 'swapColors');
497497
defineColorAlias('inverse', 'swapcolors');
498498
defineColorAlias('doubleunderline', 'doubleUnderline');
499499

500-
// TODO(BridgeAR): Add function style support for more complex styles.
501500
// Don't use 'blue' not visible on cmd.exe
502501
inspect.styles = ObjectAssign({ __proto__: null }, {
503502
special: 'cyan',
@@ -510,11 +509,264 @@ inspect.styles = ObjectAssign({ __proto__: null }, {
510509
symbol: 'green',
511510
date: 'magenta',
512511
// "name": intentionally not styling
513-
// TODO(BridgeAR): Highlight regular expressions properly.
514-
regexp: 'red',
512+
regexp: highlightRegExp,
515513
module: 'underline',
516514
});
517515

516+
// Define the palette for RegExp group depth highlighting. Can be changed by users.
517+
inspect.styles.regexp.colors = ['green', 'red', 'yellow', 'cyan', 'magenta'];
518+
519+
const highlightRegExpColors = inspect.styles.regexp.colors.slice();
520+
521+
function findSequence(regexpString, i, start, end, write) {
522+
// TODO(BridgeAR): Improve the output by using a different depth for the content.
523+
let seq = start;
524+
i++;
525+
while (i < regexpString.length && regexpString[i] !== end) {
526+
seq += regexpString[i++];
527+
}
528+
if (i < regexpString.length) {
529+
seq += end;
530+
i++;
531+
}
532+
return seq;
533+
}
534+
535+
/**
536+
* Tokenize and colorize a JavaScript RegExp pattern per ECMAScript grammar.
537+
* This is a tolerant single-pass highlighter, not a validator.
538+
* It supports: groups (named/unnamed, lookaround), assertions, alternation,
539+
* quantifiers, escapes (incl. Unicode properties), character classes and
540+
* backreferences. Character class set-ops in /v are highlighted heuristically.
541+
* @param {string} regexpString
542+
* @returns {string}
543+
*/
544+
function highlightRegExp(regexpString) {
545+
let out = '';
546+
let i = 0;
547+
let depth = 0;
548+
let inClass = false;
549+
550+
// TODO(BridgeAR): Add group type tracking. That allows to increase the depth
551+
// in case the same type is next to each other.
552+
// let groupType = 0;
553+
554+
// Verify palette and update cache if user changed colors
555+
const paletteNames = highlightRegExp.colors?.length > 0 ?
556+
highlightRegExp.colors :
557+
highlightRegExpColors;
558+
559+
const palette = paletteNames.reduce((acc, name) => {
560+
const color = inspect.colors[name];
561+
if (color) acc.push([`\u001b[${color[0]}m`, `\u001b[${color[1]}m`]);
562+
return acc;
563+
}, []);
564+
565+
const write = (str) => {
566+
const idx = depth % palette.length;
567+
const color = palette[idx];
568+
out += color[0] + str + color[1];
569+
return idx;
570+
};
571+
572+
// Opening '/'. Input always starts with '/'
573+
write('/');
574+
depth++;
575+
i = 1;
576+
577+
// Parse pattern until next unescaped '/'
578+
while (i < regexpString.length) {
579+
const ch = regexpString[i];
580+
581+
if (inClass) {
582+
if (ch === '\\') {
583+
let seq = '\\';
584+
i++;
585+
if (i < regexpString.length) {
586+
seq += regexpString[i++];
587+
if (i < regexpString.length && seq[1] === 'u' && regexpString[i] === '{') {
588+
const str = findSequence(regexpString, i, '{', '}');
589+
seq += str;
590+
i += str.length;
591+
} else if (seq[1] === 'x') {
592+
seq += regexpString.slice(i, i + 2);
593+
i += 2;
594+
}
595+
}
596+
write(seq);
597+
} else if (ch === ']') {
598+
depth--;
599+
write(']');
600+
i++;
601+
inClass = false;
602+
} else if (ch === '-' && regexpString[i - 1] !== '[' &&
603+
i + 1 < regexpString.length && regexpString[i + 1] !== ']') {
604+
depth++;
605+
write('-');
606+
depth--;
607+
i++;
608+
} else {
609+
write(ch);
610+
i++;
611+
}
612+
} else if (ch === '[') {
613+
// Enter class
614+
write('[');
615+
depth++;
616+
i++;
617+
inClass = true;
618+
} else if (ch === '(') {
619+
write('(');
620+
depth++;
621+
i++;
622+
if (i < regexpString.length && regexpString[i] === '?') {
623+
// Assertions and named groups
624+
i++;
625+
const a = i < regexpString.length ? regexpString[i] : '';
626+
if (a === ':' || a === '=' || a === '!') {
627+
depth--;
628+
write('?');
629+
write(a);
630+
depth++;
631+
i++;
632+
} else {
633+
const b = i + 1 < regexpString.length ? regexpString[i + 1] : '';
634+
if (a === '<' && (b === '=' || b === '!')) {
635+
depth--;
636+
write('?<');
637+
depth++;
638+
write(b);
639+
i += 2;
640+
} else if (a === '<') {
641+
// Named capture: write '?<name>' as a single colored token
642+
i++; // consume '<'
643+
const start = i;
644+
while (i < regexpString.length && regexpString[i] !== '>') {
645+
i++;
646+
}
647+
const name = regexpString.slice(start, i);
648+
if (i < regexpString.length && regexpString[i] === '>') {
649+
depth--;
650+
write('?<');
651+
depth++;
652+
write(name);
653+
depth--;
654+
write('>');
655+
depth++;
656+
i++;
657+
} else {
658+
depth--;
659+
write('?<');
660+
depth++;
661+
write(name);
662+
}
663+
} else {
664+
write('?');
665+
}
666+
}
667+
}
668+
} else if (ch === ')') {
669+
depth--;
670+
write(')');
671+
i++;
672+
} else if (ch === '\\') {
673+
let seq = '\\';
674+
i++;
675+
if (i < regexpString.length) {
676+
seq += regexpString[i++];
677+
const next = seq[1];
678+
if (i < regexpString.length) {
679+
if (next === 'u' && regexpString[i] === '{') {
680+
const str = findSequence(regexpString, i, '{', '}');
681+
seq += str;
682+
i += str.length;
683+
} else if (next === 'x') {
684+
seq += regexpString.slice(i, i + 2);
685+
i += 2;
686+
} else if (next >= '0' && next <= '9') {
687+
while (i < regexpString.length && regexpString[i] >= '0' && regexpString[i] <= '9') {
688+
seq += regexpString[i++];
689+
}
690+
} else if (next === 'k' && regexpString[i] === '<') {
691+
const str = findSequence(regexpString, i, '<', '>');
692+
seq += str;
693+
i += str.length;
694+
} else if ((next === 'p' || next === 'P') && regexpString[i] === '{') {
695+
const str = findSequence(regexpString, i, '{', '}');
696+
seq += str;
697+
i += str.length;
698+
}
699+
}
700+
}
701+
depth++;
702+
write(seq);
703+
depth--;
704+
} else if (ch === '|' || ch === '+' || ch === '*' || ch === '?' || ch === ',' || ch === '^' || ch === '$') {
705+
depth += 3;
706+
write(ch);
707+
depth -= 3;
708+
i++;
709+
} else if (ch === '{') {
710+
write('{');
711+
depth++;
712+
i++;
713+
let digits = '';
714+
while (i < regexpString.length && regexpString[i] >= '0' && regexpString[i] <= '9') {
715+
digits += regexpString[i++];
716+
}
717+
if (digits) {
718+
depth++;
719+
write(digits);
720+
depth--;
721+
}
722+
if (i < regexpString.length && regexpString[i] === ',') {
723+
write(',');
724+
i++;
725+
}
726+
let digits2 = '';
727+
while (i < regexpString.length && regexpString[i] >= '0' && regexpString[i] <= '9') {
728+
digits2 += regexpString[i++];
729+
}
730+
if (digits2) {
731+
depth++;
732+
write(digits2);
733+
depth--;
734+
}
735+
if (i < regexpString.length && regexpString[i] === '}') {
736+
depth--;
737+
write('}');
738+
i++;
739+
}
740+
if (i < regexpString.length && regexpString[i] === '?') {
741+
// depth++;
742+
write('?');
743+
// depth--;
744+
i++;
745+
}
746+
} else if (ch === '/') {
747+
// Stop at closing delimiter (unescaped, outside of character class)
748+
break;
749+
} else {
750+
depth++;
751+
write(ch);
752+
depth--;
753+
i++;
754+
}
755+
}
756+
757+
// Closing delimiter and flags
758+
if (i < regexpString.length && regexpString[i] === '/') {
759+
depth--;
760+
write('/');
761+
i++;
762+
if (i < regexpString.length) {
763+
depth++;
764+
write(regexpString.slice(i));
765+
}
766+
}
767+
return out;
768+
}
769+
518770
function addQuotes(str, quotes) {
519771
if (quotes === -1) {
520772
return `"${str}"`;
@@ -601,8 +853,12 @@ function stylizeWithColor(str, styleType) {
601853
const style = inspect.styles[styleType];
602854
if (style !== undefined) {
603855
const color = inspect.colors[style];
604-
if (color !== undefined)
856+
if (color !== undefined) {
605857
return `\u001b[${color[0]}m${str}\u001b[${color[1]}m`;
858+
}
859+
if (typeof style === 'function') {
860+
return style(str);
861+
}
606862
}
607863
return str;
608864
}
@@ -1038,9 +1294,10 @@ function formatRaw(ctx, value, recurseTimes, typedArray) {
10381294
const prefix = getPrefix(constructor, tag, 'RegExp');
10391295
if (prefix !== 'RegExp ')
10401296
base = `${prefix}${base}`;
1297+
base = ctx.stylize(base, 'regexp');
10411298
if ((keys.length === 0 && protoProps === undefined) ||
10421299
(recurseTimes > ctx.depth && ctx.depth !== null)) {
1043-
return ctx.stylize(base, 'regexp');
1300+
return base;
10441301
}
10451302
} else if (isDate(value)) {
10461303
// Make dates with properties first say the date

0 commit comments

Comments
 (0)