From 6bb2f8e43a6ff078131e4db18784a899e90af385 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 `<` and `:`, used by mouse reports and by sub parameters, nor final bytes such as `@`, `X`, `d`, `a` and `b`. 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: any number of parameter bytes (0x30-0x3F), any number of intermediate bytes (0x20-0x2F), then a single final byte (0x40-0x7E). Both existing alternatives are kept and the new one is only reached where they do not match, so a match can be extended but never shortened. The new alternative carries its own introducer instead of sharing the existing prefix, because the shared prefix also consumes `;` and the two competing for the same run made the match quadratic. Signed-off-by: Luan Taraschi <130802253+luantaraschi@users.noreply.github.com> --- lib/internal/util/inspect.js | 18 +++++++++++--- .../test-util-stripvtcontrolcharacters.js | 24 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/lib/internal/util/inspect.js b/lib/internal/util/inspect.js index 193ae8524632..df83ea736450 100644 --- a/lib/internal/util/inspect.js +++ b/lib/internal/util/inspect.js @@ -286,12 +286,24 @@ 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\\u009B][[\\]()#;?]*' + - '(?:(?:(?:(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]+)*' + + '(?:(?:(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]+)*' + '|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/\\#&.:=?%@~_]*)*)?' + '(?:\\u0007|\\u001B\\u005C|\\u009C))' + - '|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?' + - '[\\dA-PR-TZcf-nq-uy=><~]))', 'g', + // Control sequences, using the structure ECMA-48 5.4 defines: any number + // of parameter bytes (0x30-0x3F), 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', ); let getStringWidth; diff --git a/test/parallel/test-util-stripvtcontrolcharacters.js b/test/parallel/test-util-stripvtcontrolcharacters.js index a33d18d26dbc..853cda998ddb 100644 --- a/test/parallel/test-util-stripvtcontrolcharacters.js +++ b/test/parallel/test-util-stripvtcontrolcharacters.js @@ -21,6 +21,30 @@ for (const ST of ['\u0007', '\u001B\u005C', '\u009C']) { ); } +// 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 used to have its tail left behind. +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 + // Sub parameters are separated by `:`, which is a parameter byte as well. + ['a\u001B[38:2:255:0:0mb', 'ab'], // Truecolour in its colon form + ['a\u001B[4:3mb', 'ab'], // Curly underline + // Intermediate bytes. + ['a\u001B[2 qb', 'ab'], // DECSCUSR, set cursor style + ['a\u001B[!pb', 'ab'], // DECSTR, soft terminal reset +); + test('util.stripVTControlCharacters', (t) => { for (const [before, expected] of tests) { t.assert.strictEqual(util.stripVTControlCharacters(before), expected);