Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions packages/web-shell/client/utils/ansi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest';
import { hasAnsi, parseAnsi } from './ansi';

const ESC = '\x1b[';

describe('parseAnsi', () => {
it('applies basic and bright colors, bold and dim', () => {
expect(parseAnsi(`${ESC}32mok`)).toEqual([
{ text: 'ok', color: '#48bb78', bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}1;91mhot`)).toEqual([
{ text: 'hot', color: '#feb2b2', bold: true, dim: false },
]);
expect(parseAnsi(`${ESC}2mfaint`)).toEqual([
{ text: 'faint', color: undefined, bold: false, dim: true },
]);
});

it('resets state on 0, 22 and 39', () => {
expect(parseAnsi(`${ESC}1;31ma${ESC}0mb`)).toEqual([
{ text: 'a', color: '#fc8181', bold: true, dim: false },
{ text: 'b', color: undefined, bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}1;2;31ma${ESC}22mb`)).toEqual([
{ text: 'a', color: '#fc8181', bold: true, dim: true },
{ text: 'b', color: '#fc8181', bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}31ma${ESC}39mb`)[1]!.color).toBeUndefined();
});

// The arguments of 38/48/58 are not SGR codes. Reading them as codes is what
// made `38;5;2` set dim (from the color index) instead of a color.
it('does not read 256-color arguments as SGR codes', () => {
expect(parseAnsi(`${ESC}38;5;2mgreen`)).toEqual([
{ text: 'green', color: '#48bb78', bold: false, dim: false },
]);
expect(parseAnsi(`${ESC}38;5;1mred`)[0]!.color).toBe('#fc8181');
// Bright half of the standard range maps onto the 90-97 palette.
expect(parseAnsi(`${ESC}38;5;9mbright`)[0]!.color).toBe('#feb2b2');
// 6x6x6 cube: 208 -> (5, 2, 0) -> #ff8700.
expect(parseAnsi(`${ESC}38;5;208morange`)[0]!.color).toBe('#ff8700');
// Cube corners.
expect(parseAnsi(`${ESC}38;5;16ma`)[0]!.color).toBe('#000000');
expect(parseAnsi(`${ESC}38;5;231ma`)[0]!.color).toBe('#ffffff');
// Grayscale ramp: 232 -> 8, 255 -> 238.
expect(parseAnsi(`${ESC}38;5;232ma`)[0]!.color).toBe('#080808');
expect(parseAnsi(`${ESC}38;5;255ma`)[0]!.color).toBe('#eeeeee');
});

it('does not let truecolor channels reset the style', () => {
expect(parseAnsi(`${ESC}38;2;255;0;0mred`)).toEqual([
{ text: 'red', color: '#ff0000', bold: false, dim: false },
]);
// A zero channel used to hit the `code === 0` reset, clearing the bold
// that was already set; two more channels are 128 and 255, neither of
// which is an SGR code at all.
expect(parseAnsi(`${ESC}1m${ESC}38;2;0;128;255mblue`)).toEqual([
{ text: 'blue', color: '#0080ff', bold: true, dim: false },
]);
});

it('keeps background and underline color out of the code stream', () => {
// 48;5;22 used to feed `22` to the reset-intensity branch, so setting a
// background silently un-bolded the text.
expect(parseAnsi(`${ESC}1m${ESC}48;5;22mtext`)).toEqual([
{ text: 'text', color: undefined, bold: true, dim: false },
]);
// Foreground survives a background change on the same sequence, and the
// trailing 1 is still read as bold once the 48 arguments are consumed.
expect(parseAnsi(`${ESC}31;48;2;0;0;0;1mtext`)).toEqual([
{ text: 'text', color: '#fc8181', bold: true, dim: false },
]);
// Asserting the whole segment, not just `bold`: dropping 58 from the trio
// leaks its `2` argument into the dim branch, which a bold-only assertion
// cannot see.
expect(parseAnsi(`${ESC}1m${ESC}58;5;2mtext`)).toEqual([
{ text: 'text', color: undefined, bold: true, dim: false },
]);
});

it('drops malformed extended-color sequences without corrupting state', () => {
// Out-of-range index and truncated argument lists yield no color rather
// than a bogus one, and never fall through to the plain-code branches.
// `38;2;999;0;0` is the truecolor equivalent: a channel outside 0–255.
for (const seq of [
'38;5;300',
'38;5',
'38;2;1;2',
'38;7;1',
'38',
'38;2;999;0;0',
]) {
expect(parseAnsi(`${ESC}1m${ESC}${seq}mtext`)).toEqual([
{ text: 'text', color: undefined, bold: true, dim: false },
]);
}
});

it('leaves an already-set color alone when the sequence is malformed', () => {
// An unreadable sequence is ignored, not treated as a reset: the red from
// code 31 has to survive it.
for (const seq of [
'38;5;300',
'38;5',
'38;2;1;2',
'38;7;1',
'38',
'38;2;999;0;0',
]) {
expect(parseAnsi(`${ESC}31m${ESC}${seq}mtext`)).toEqual([
{ text: 'text', color: '#fc8181', bold: false, dim: false },
]);
}
// A well-formed sequence still replaces it.
expect(parseAnsi(`${ESC}31m${ESC}38;5;21mtext`)[0]!.color).toBe('#0000ff');
});

it('splits text around sequences and keeps the trailing run', () => {
expect(parseAnsi(`plain${ESC}1mbold${ESC}0mtail`)).toEqual([
{ text: 'plain', color: undefined, bold: false, dim: false },
{ text: 'bold', color: undefined, bold: true, dim: false },
{ text: 'tail', color: undefined, bold: false, dim: false },
]);
expect(parseAnsi('no escapes')).toEqual([
{ text: 'no escapes', color: undefined, bold: false, dim: false },
]);
// `ESC[m` is shorthand for `ESC[0m`.
expect(parseAnsi(`${ESC}1ma${ESC}mb`)[1]!.bold).toBe(false);
});
});

describe('hasAnsi', () => {
it('detects a CSI introducer', () => {
expect(hasAnsi(`${ESC}0mx`)).toBe(true);
expect(hasAnsi('plain text')).toBe(false);
});
});
72 changes: 70 additions & 2 deletions packages/web-shell/client/utils/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,54 @@ const ANSI_COLORS: Record<number, string> = {
97: '#ffffff',
};

/** The six channel levels of the xterm 6x6x6 color cube (indices 16-231). */
const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];

interface Segment {
text: string;
color?: string;
bold?: boolean;
dim?: boolean;
}

// The channel and index parameters are read straight out of the escape
// sequence, so a truncated one yields `undefined` — the types say so rather
// than asserting the value away, and the guards below are what reject it.
function toHex(
r: number | undefined,
g: number | undefined,
b: number | undefined,
): string | undefined {
let hex = '#';
for (const v of [r, g, b]) {
if (v === undefined || !Number.isInteger(v) || v < 0 || v > 255) {
return undefined;
}
hex += v.toString(16).padStart(2, '0');
}
return hex;
}

/** Resolve an xterm 256-color index to a hex string. */
function xterm256(index: number | undefined): string | undefined {
if (index === undefined || !Number.isInteger(index)) return undefined;
if (index < 0 || index > 255) return undefined;
// 0-15 stay on the palette above, so a tool emitting `38;5;2` and one
// emitting `32` render as the same green.
if (index < 8) return ANSI_COLORS[30 + index];
if (index < 16) return ANSI_COLORS[90 + (index - 8)];
if (index < 232) {
const v = index - 16;
return toHex(
CUBE_LEVELS[Math.floor(v / 36)],
CUBE_LEVELS[Math.floor(v / 6) % 6],
CUBE_LEVELS[v % 6],
);
}
const level = 8 + (index - 232) * 10;
return toHex(level, level, level);
}

export function parseAnsi(input: string): Segment[] {
const segments: Segment[] = [];
let color: string | undefined;
Expand All @@ -41,8 +82,35 @@ export function parseAnsi(input: string): Segment[] {
pos = match.index + match[0].length;

const codes = match[1].split(';').map(Number);
for (const code of codes) {
if (code === 0) {
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
// 38/48/58 (foreground/background/underline color) never stand alone:
// each is followed by `5;<index>` or `2;<r>;<g>;<b>`. Those arguments
// have to be consumed here, or the loop reads them as codes in their own
// right — `38;5;2` would take the color index for "dim", and a truecolor
// value with a zero channel would hit the reset below and drop the
// color, bold and dim state that was already correct.
if (code === 38 || code === 48 || code === 58) {
const mode = codes[i + 1];
let value: string | undefined;
if (mode === 5) {
value = xterm256(codes[i + 2]);
i += 2;
} else if (mode === 2) {
value = toHex(codes[i + 2], codes[i + 3], codes[i + 4]);
i += 4;
} else {
// An unrecognized form has an unknown argument count, so there is no
// safe place to resume; drop the rest of this sequence rather than
// guess where the color ends.
break;
}
// Only the foreground maps onto a Segment. Background and underline
// color are still parsed so their arguments cannot leak into the loop.
// A malformed value leaves the current color alone: an unreadable
// sequence is ignored, not treated as a reset.
if (code === 38 && value !== undefined) color = value;
} else if (code === 0) {
color = undefined;
bold = false;
dim = false;
Expand Down
Loading