From 66fdbc9764c522bd2a87bf95e20b1ade777258b9 Mon Sep 17 00:00:00 2001 From: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:50:14 +0000 Subject: [PATCH] util: strip whole CSI sequences per ECMA-48 stripVTControlCharacters() recognises a control sequence by a hand written list of final bytes, inherited from the bundled copy of ansi-regex. The list covers neither parameter bytes such as `<`, used by mouse reports, nor final bytes such as `@`, `X`, `d`, `a` and `b`, nor intermediate bytes at all. A sequence that uses one of them matches only in part, so the rest of it is left in the string rather than removed. getStringWidth() strips before measuring and readline places the cursor from that width, so the leftovers are counted as printable columns. Add an alternative built from the control sequence structure in ECMA-48 5.4: CSI, then any number of parameter bytes (0x30-0x3F), then any number of intermediate bytes (0x20-0x2F), then a single final byte (0x40-0x7E). Both other alternatives are kept, and the new one carries its own introducer rather than sharing the existing prefix, because that prefix also consumes `;` and the two competing for the same run made the match quadratic. Eleven cases, every one well formed and in use by terminals today. The colon sub-parameter form is not among them, since the OSC work already covers it. Signed-off-by: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> --- lib/internal/util/inspect.js | 10 +++++++++ .../test-util-stripvtcontrolcharacters.js | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/internal/util/inspect.js b/lib/internal/util/inspect.js index 1a029c7cf761..8e0b6eeed5f0 100644 --- a/lib/internal/util/inspect.js +++ b/lib/internal/util/inspect.js @@ -286,7 +286,17 @@ const meta = [ // License: MIT by Sindre Sorhus // Matches all ansi escape code sequences in a string const ansi = new RegExp( + // Sequences that run until a string terminator, such as OSC 8 hyperlinks. '(?:\\u001B\\][\\s\\S]*?(?:\\u0007|\\u001B\\u005C|\\u009C))' + + // Control sequences, using the structure ECMA-48 5.4 defines: CSI, then any + // number of parameter bytes (0x30-0x3F), then any number of intermediate + // bytes (0x20-0x2F), then a single final byte (0x40-0x7E). The list of final + // bytes below covers neither parameter bytes such as `<`, used by mouse + // reports, nor final bytes such as `@`, so it stops in the middle of the + // sequence and leaves the rest in the output. + '|(?:\\u001B\\[|\\u009B)[\\u0030-\\u003F]*[\\u0020-\\u002F]*[\\u0040-\\u007E]' + + // Everything else the reference implementation matched, including sequences + // whose last byte is a digit, such as `ESC 7`. '|[\\u001B\\u009B][[\\]()#;?]*' + '(?:\\d{1,4}(?:[;:]\\d{0,4})*)?' + '[\\dA-PR-TZcf-nq-uy=><~]', 'g', diff --git a/test/parallel/test-util-stripvtcontrolcharacters.js b/test/parallel/test-util-stripvtcontrolcharacters.js index efda16687821..022776362641 100644 --- a/test/parallel/test-util-stripvtcontrolcharacters.js +++ b/test/parallel/test-util-stripvtcontrolcharacters.js @@ -37,6 +37,27 @@ tests.push( ['\u001B[4:3mUnderline\u001B[4:0m', 'Underline'], ); +// Ref: ECMA-48 5.4. A control sequence is CSI, then any number of parameter +// bytes (0x30-0x3F), then any number of intermediate bytes (0x20-0x2F), then a +// single final byte (0x40-0x7E). Every sequence below is well formed and in use +// by terminals today, and every one of them has its tail left behind today. +tests.push( + // Parameter bytes other than digits, `;` and `:`. + ['a\u001B[<35;10;20Mb', 'ab'], // SGR mouse report + ['a\u001B[>1;2cb', 'ab'], // Secondary device attributes + ['a\u001B[=5hb', 'ab'], + ['a\u009B<35;10;20Mb', 'ab'], // Same, with the 8 bit CSI introducer + // Final bytes that the reference implementation does not list. + ['a\u001B[3@b', 'ab'], // ICH, insert character + ['a\u001B[3Xb', 'ab'], // ECH, erase character + ['a\u001B[5db', 'ab'], // VPA, line position absolute + ['a\u001B[3bb', 'ab'], // REP, repeat preceding character + ['a\u001B[2ab', 'ab'], // HPR, character position forward + // Intermediate bytes. + ['a\u001B[2 qb', 'ab'], // DECSCUSR, set cursor style + ['a\u001B[!pb', 'ab'], // DECSTR, soft terminal reset +); + // Unterminated OSC does not match the OSC alternative; the CSI alternative may // still consume a short prefix (here ESC ] 8 ;; h), leaving the remainder. tests.push(