util: add ansi-seq-parser helper (#43661)

This is split out of #43058, but makes sense on its own: and makes
strip_tab_ansi() a lore more complete and correct.

ptybroker is going to use for the ansi sequence parser for other
purposes too, but making strip_tab_ansi() work better is enough of a
reason to have this I think.
This commit is contained in:
Yu Watanabe
2026-09-23 08:58:16 +09:00
committed by GitHub
7 changed files with 825 additions and 98 deletions
+85 -90
View File
@@ -3,6 +3,7 @@
#include <stdio.h>
#include "alloc-util.h"
#include "ansi-seq.h"
#include "escape.h"
#include "extract-word.h"
#include "glyph-util.h"
@@ -14,7 +15,6 @@
#include "path-util.h"
#include "string-util.h"
#include "strv.h"
#include "terminal-util.h"
#include "utf8.h"
char* first_word(const char *s, const char *word) {
@@ -703,16 +703,32 @@ static void advance_offsets(
shift[1] += size;
}
static void retract_offsets(
size_t begin,
size_t end,
size_t offsets[2], /* note: we can't use [static 2] here, since this may be NULL */
size_t shift[static 2]) {
/* The input range [begin, end) was dropped from the output: offsets past it move back by its
* length, offsets inside it collapse onto its beginning. The shift is tracked in unsigned modular
* arithmetic, which is fine as the sum of all shifts applied to an offset is never negative: at most
* as many bytes can be dropped before an offset as there are bytes before it. */
if (!offsets)
return;
assert(shift);
assert(begin <= end);
for (size_t k = 0; k < 2; k++)
if (offsets[k] > begin)
shift[k] -= MIN(offsets[k], end) - begin;
}
char* strip_tab_ansi(char **ibuf, size_t *_isz, size_t highlight[2]) {
const char *begin = NULL;
enum {
STATE_OTHER,
STATE_ESCAPE,
STATE_CSI,
STATE_OSC,
STATE_OSC_CLOSING,
} state = STATE_OTHER;
_cleanup_(ansi_seq_parser_done) AnsiSeqParser parser = {};
_cleanup_(memstream_done) MemStream m = {};
const char *begin = NULL;
size_t isz, shift[2] = {}, n_carriage_returns = 0;
FILE *f;
@@ -723,16 +739,14 @@ char* strip_tab_ansi(char **ibuf, size_t *_isz, size_t highlight[2]) {
/* This does three things:
*
* 1. Replaces TABs by 8 spaces
* 2. Strips ANSI color sequences (a subset of CSI), i.e. ESC '[' … 'm' sequences
* 3. Strips ANSI operating system sequences (OSC), i.e. ESC ']' … ST sequences
* 4. Strip trailing \r characters (since they would "move the cursor", but have no
* 2. Strips ANSI sequences (CSI, OSC and all other escape sequences)
* 3. Strips trailing \r characters (since they would "move the cursor", but have no
* other effect).
*
* Everything else will be left as it is. In particular other ANSI sequences are left as they are, as
* are any other special characters. Truncated ANSI sequences are left-as is too. This call is
* supposed to suppress the most basic formatting noise, but nothing else.
*
* Why care for OSC sequences? Well, to undo what terminal_urlify() and friends generate. */
* Any other special characters are left as they are. Invalid or truncated ANSI sequences are
* dropped, too: when stripping ANSI sequences it's better to strip too much than too little. This
* call is supposed to suppress formatting noise (including what terminal_urlify() and friends
* generate), but nothing else. */
isz = _isz ? *_isz : strlen(*ibuf);
@@ -742,28 +756,48 @@ char* strip_tab_ansi(char **ibuf, size_t *_isz, size_t highlight[2]) {
if (!f)
return NULL;
for (const char *i = *ibuf; i < *ibuf + isz + 1; i++) {
for (const char *i = *ibuf; i < *ibuf + isz; i++) {
int e;
bool eot = i >= *ibuf + isz;
switch (state) {
case STATE_OTHER:
if (eot)
for (;;) {
e = ansi_seq_parser_feed(&parser, *i);
if (e < 0)
return NULL;
if (e != ANSI_SEQ_EVENT_ABORT)
break;
/* Eat up bogus sequences. The aborted sequence is over (and dropped, up to but
* excluding the current character, which is fed again), hence forget about it, so
* that the next sequence flushes pending carriage returns again. */
if (begin) {
retract_offsets(begin - *ibuf, i - *ibuf, highlight, shift);
begin = NULL;
}
}
switch (e) {
case ANSI_SEQ_EVENT_TEXT:
if (*i == '\r') {
n_carriage_returns++;
break;
} else if (*i == '\n')
/* Ignore carriage returns before new line */
continue;
}
if (*i == '\n') {
/* Ignore carriage returns before new line. They are dropped from the output,
* hence the offsets need to account for them. (Note that pending carriage
* returns always immediately precede the current character, since anything
* else flushes them.) */
retract_offsets(
(size_t) (i - *ibuf) - n_carriage_returns,
i - *ibuf,
highlight,
shift);
n_carriage_returns = 0;
}
for (; n_carriage_returns > 0; n_carriage_returns--)
fputc('\r', f);
if (*i == '\x1B')
state = STATE_ESCAPE;
else if (*i == '\t') {
if (*i == '\t') {
fputs(" ", f);
advance_offsets(i - *ibuf, highlight, shift, 7);
} else
@@ -771,75 +805,36 @@ char* strip_tab_ansi(char **ibuf, size_t *_isz, size_t highlight[2]) {
break;
case STATE_ESCAPE:
assert(n_carriage_returns == 0);
case ANSI_SEQ_EVENT_SEQUENCE:
if (!begin) {
/* This starts a new sequence, flush any pending carriage returns first */
for (; n_carriage_returns > 0; n_carriage_returns--)
fputc('\r', f);
if (eot) {
fputc('\x1B', f);
advance_offsets(i - *ibuf, highlight, shift, 1);
break;
} else if (*i == '[') { /* ANSI CSI */
state = STATE_CSI;
begin = i + 1;
} else if (*i == ']') { /* ANSI OSC */
state = STATE_OSC;
begin = i + 1;
} else {
fputc('\x1B', f);
fputc(*i, f);
advance_offsets(i - *ibuf, highlight, shift, 1);
state = STATE_OTHER;
begin = i;
}
break;
case STATE_CSI:
assert(n_carriage_returns == 0);
if (eot || !strchr(DIGITS ";:m", *i)) { /* EOT or invalid chars in sequence */
fputc('\x1B', f);
fputc('[', f);
advance_offsets(i - *ibuf, highlight, shift, 2);
state = STATE_OTHER;
i = begin-1;
} else if (*i == 'm')
state = STATE_OTHER;
case ANSI_SEQ_EVENT_END:
/* A complete sequence, drop it (the current character is its last one) */
assert(begin);
retract_offsets(begin - *ibuf, i + 1 - *ibuf, highlight, shift);
begin = NULL;
break;
case STATE_OSC:
assert(n_carriage_returns == 0);
/* There are three kinds of OSC terminators: \x07, \x1b\x5c or \x9c. We only support
* the first two, because the last one is a valid UTF-8 codepoint and hence creates
* an ambiguity (many Terminal emulators refuse to support it as well). */
if (eot || (!IN_SET(*i, '\x07', '\x1b') && !osc_char_is_valid(*i))) { /* EOT or invalid chars in sequence */
fputc('\x1B', f);
fputc(']', f);
advance_offsets(i - *ibuf, highlight, shift, 2);
state = STATE_OTHER;
i = begin-1;
} else if (*i == '\x07') /* Single character ST */
state = STATE_OTHER;
else if (*i == '\x1B')
state = STATE_OSC_CLOSING;
break;
case STATE_OSC_CLOSING:
if (eot || *i != '\x5c') { /* EOT or incomplete two-byte ST in sequence */
fputc('\x1B', f);
fputc(']', f);
advance_offsets(i - *ibuf, highlight, shift, 2);
state = STATE_OTHER;
i = begin-1;
} else if (*i == '\x5c')
state = STATE_OTHER;
break;
default:
assert_not_reached();
}
}
/* If the input ends in the middle of a sequence the truncated sequence is dropped as well, hence
* the offsets need to account for it, too. */
if (begin)
retract_offsets(begin - *ibuf, isz, highlight, shift);
/* Trailing carriage returns are dropped, too */
retract_offsets(isz - n_carriage_returns, isz, highlight, shift);
char *obuf;
if (memstream_finalize(&m, &obuf, _isz) < 0)
return NULL;