Skip to content
267 linesCodeBlameRaw
1/**
2 * Colour in a job's log, as a terminal draws it. Tools colour their output
3 * when they see `GITHUB_ACTIONS=true` (chalk, cargo, pytest and the like),
4 * with SGR escape codes: bold, dim, italic, underline, the 16 colours, the
5 * 256-colour palette and 24-bit colour, for text and its background. Those
6 * are read into styled spans; every other escape sequence (moving the
7 * cursor, clearing the line, setting the window's title) is dropped, and a
8 * hyperlink (OSC 8) keeps its text. The 16 colours are theme tokens
9 * (`ansi-fg-N` and `ansi-bg-N` in app.css), so they read in light and dark.
10 */
11
12/** A colour: one of the 256 of the palette, or red, green and blue. */
13export type AnsiColor = { palette: number } | { rgb: [number, number, number] };
14
15export type AnsiStyle = {
16 bold?: boolean;
17 dim?: boolean;
18 italic?: boolean;
19 underline?: boolean;
20 strike?: boolean;
21 inverse?: boolean;
22 fg?: AnsiColor;
23 bg?: AnsiColor;
24};
25
26export type AnsiSpan = { text: string; style: AnsiStyle };
27
28const ESC = "\u001b";
29
30/** Whether `text` holds an escape sequence or another control character worth reading out. */
31export function hasEscapes(text: string): boolean {
32 // eslint-disable-next-line no-control-regex
33 return /[\u0000-\u0008\u000b-\u001f\u007f\u009b]/.test(text);
34}
35
36/** `text` without its escape sequences: what a search reads and a copy takes. */
37export function stripAnsi(text: string): string {
38 if (!hasEscapes(text)) return text;
39 return parseAnsi(text)
40 .map((span) => span.text)
41 .join("");
42}
43
44/**
45 * A parameter in ITU's colon form as its `;` form: `38:5:n`, `38:2::r:g:b`
46 * (the empty one names the colour space), and `4:3`, a curly underline,
47 * as an underline.
48 */
49function colonForm(part: string): string[] {
50 if (!part.includes(":")) return [part];
51 const pieces = part.split(":");
52 if ((pieces[0] === "38" || pieces[0] === "48") && pieces[1] === "2" && pieces.length >= 6) return [pieces[0], "2", ...pieces.slice(3, 6)];
53 if (pieces[0] === "4") return [pieces[1] === "0" ? "24" : "4"];
54 return pieces;
55}
56
57/** Reads one SGR sequence's parameters into `style`, in place. */
58function applySgr(style: AnsiStyle, params: string): void {
59 const codes = (params === "" ? "0" : params)
60 .split(";")
61 .flatMap(colonForm)
62 .map((part) => (part === "" ? 0 : Number(part)));
63 for (let i = 0; i < codes.length; i++) {
64 const code = codes[i]!;
65 if (!Number.isFinite(code)) continue;
66 if (code === 0) {
67 for (const key of Object.keys(style) as (keyof AnsiStyle)[]) delete style[key];
68 } else if (code === 1) style.bold = true;
69 else if (code === 2) style.dim = true;
70 else if (code === 3) style.italic = true;
71 else if (code === 4 || code === 21) style.underline = true;
72 else if (code === 7) style.inverse = true;
73 else if (code === 9) style.strike = true;
74 else if (code === 22) {
75 delete style.bold;
76 delete style.dim;
77 } else if (code === 23) delete style.italic;
78 else if (code === 24) delete style.underline;
79 else if (code === 27) delete style.inverse;
80 else if (code === 29) delete style.strike;
81 else if (code >= 30 && code <= 37) style.fg = { palette: code - 30 };
82 else if (code === 39) delete style.fg;
83 else if (code >= 40 && code <= 47) style.bg = { palette: code - 40 };
84 else if (code === 49) delete style.bg;
85 else if (code >= 90 && code <= 97) style.fg = { palette: code - 90 + 8 };
86 else if (code >= 100 && code <= 107) style.bg = { palette: code - 100 + 8 };
87 else if (code === 38 || code === 48) {
88 const key = code === 38 ? "fg" : "bg";
89 const kind = codes[i + 1];
90 if (kind === 5) {
91 const n = codes[i + 2];
92 if (n !== undefined && n >= 0 && n <= 255) style[key] = { palette: n };
93 i += 2;
94 } else if (kind === 2) {
95 const [r, g, b] = [codes[i + 2], codes[i + 3], codes[i + 4]];
96 if ([r, g, b].every((n) => n !== undefined && n >= 0 && n <= 255)) style[key] = { rgb: [r!, g!, b!] };
97 i += 4;
98 }
99 }
100 }
101}
102
103/**
104 * One line of a log as styled spans, in order, with every escape
105 * sequence read or dropped. A line starts unstyled, as each line of
106 * GitHub's log does.
107 */
108export function parseAnsi(text: string): AnsiSpan[] {
109 const spans: AnsiSpan[] = [];
110 const style: AnsiStyle = {};
111 let pending = "";
112 const push = () => {
113 if (!pending) return;
114 const last = spans[spans.length - 1];
115 if (last && sameStyle(last.style, style)) last.text += pending;
116 else spans.push({ text: pending, style: { ...style } });
117 pending = "";
118 };
119 let i = 0;
120 while (i < text.length) {
121 const char = text[i]!;
122 const code = char.charCodeAt(0);
123 if (char === ESC || code === 0x9b) {
124 const csi = code === 0x9b || text[i + 1] === "[";
125 if (csi) {
126 // CSI: parameters, intermediates, then one final byte.
127 let j = i + (code === 0x9b ? 1 : 2);
128 const start = j;
129 while (j < text.length && text.charCodeAt(j) >= 0x30 && text.charCodeAt(j) <= 0x3f) j++;
130 const params = text.slice(start, j);
131 while (j < text.length && text.charCodeAt(j) >= 0x20 && text.charCodeAt(j) <= 0x2f) j++;
132 const final = text[j];
133 if (final === "m" && /^[\d;:]*$/.test(params)) {
134 push();
135 applySgr(style, params);
136 }
137 i = final === undefined ? j : j + 1;
138 continue;
139 }
140 const next = text[i + 1];
141 if (next === "]" || next === "P" || next === "X" || next === "^" || next === "_") {
142 // OSC and the other strings: to BEL or ESC \. An OSC 8 hyperlink
143 // is two of these around its text, which stays.
144 let j = i + 2;
145 while (j < text.length && text[j] !== "\u0007" && !(text[j] === ESC && text[j + 1] === "\\")) j++;
146 i = j >= text.length ? j : text[j] === "\u0007" ? j + 1 : j + 2;
147 continue;
148 }
149 // ESC ( B and its kin pick a character set: three characters.
150 if (next !== undefined && "()*+-./#%".includes(next)) {
151 i += 3;
152 continue;
153 }
154 // Any other escape is two characters (ESC 7, ESC =, ESC M…).
155 i += next === undefined ? 1 : 2;
156 continue;
157 }
158 // Other control characters (a bell, a backspace) draw nothing; a tab stays.
159 if ((code < 0x20 && char !== "\t") || code === 0x7f) {
160 i++;
161 continue;
162 }
163 pending += char;
164 i++;
165 }
166 push();
167 return spans;
168}
169
170function sameColor(a: AnsiColor | undefined, b: AnsiColor | undefined): boolean {
171 if (!a || !b) return a === b;
172 if ("palette" in a) return "palette" in b && a.palette === b.palette;
173 return "rgb" in b && a.rgb.every((n, index) => n === b.rgb[index]);
174}
175
176function sameStyle(a: AnsiStyle, b: AnsiStyle): boolean {
177 return (
178 !!a.bold === !!b.bold &&
179 !!a.dim === !!b.dim &&
180 !!a.italic === !!b.italic &&
181 !!a.underline === !!b.underline &&
182 !!a.strike === !!b.strike &&
183 !!a.inverse === !!b.inverse &&
184 sameColor(a.fg, b.fg) &&
185 sameColor(a.bg, b.bg)
186 );
187}
188
189/** A colour of the 256-colour palette past the first 16, as red, green and blue. */
190export function paletteRgb(n: number): [number, number, number] {
191 if (n >= 232) {
192 const level = 8 + (n - 232) * 10;
193 return [level, level, level];
194 }
195 const index = n - 16;
196 const step = (value: number) => (value === 0 ? 0 : 55 + value * 40);
197 return [step(Math.floor(index / 36)), step(Math.floor(index / 6) % 6), step(index % 6)];
198}
199
200function cssColor(color: AnsiColor): { token: number } | { css: string } {
201 if ("palette" in color) {
202 if (color.palette < 16) return { token: color.palette };
203 const [r, g, b] = paletteRgb(color.palette);
204 return { css: `rgb(${r}, ${g}, ${b})` };
205 }
206 return { css: `rgb(${color.rgb.join(", ")})` };
207}
208
209/**
210 * How a span draws: classes for what the theme decides (the 16 colours,
211 * weight, slant, lines) and inline colours for the rest.
212 */
213export function spanLook(style: AnsiStyle): { className: string; style?: { color?: string; backgroundColor?: string } } {
214 const classes: string[] = [];
215 const inline: { color?: string; backgroundColor?: string } = {};
216 const [fg, bg] = style.inverse ? [style.bg, style.fg] : [style.fg, style.bg];
217 if (fg) {
218 const look = cssColor(fg);
219 if ("token" in look) classes.push(`ansi-fg-${look.token}`);
220 else inline.color = look.css;
221 } else if (style.inverse) classes.push("ansi-fg-inverse");
222 if (bg) {
223 const look = cssColor(bg);
224 if ("token" in look) classes.push(`ansi-bg-${look.token}`);
225 else inline.backgroundColor = look.css;
226 } else if (style.inverse) classes.push("ansi-bg-inverse");
227 if (style.bold) classes.push("font-bold");
228 if (style.dim) classes.push("opacity-70");
229 if (style.italic) classes.push("italic");
230 if (style.underline && style.strike) classes.push("[text-decoration-line:underline_line-through]");
231 else if (style.underline) classes.push("underline");
232 else if (style.strike) classes.push("line-through");
233 return { className: classes.join(" "), style: inline.color || inline.backgroundColor ? inline : undefined };
234}
235
236/**
237 * Spans cut where `query` (any case) appears in their text taken
238 * together, each piece marked as a match or not, so a search marks what
239 * it found without losing the colours around it.
240 */
241export function markSpans(spans: AnsiSpan[], query: string): (AnsiSpan & { match: boolean })[] {
242 const wanted = query.trim().toLowerCase();
243 if (!wanted) return spans.map((span) => ({ ...span, match: false }));
244 const plain = spans.map((span) => span.text).join("");
245 const lower = plain.toLowerCase();
246 // Where matches start and end, in the plain text.
247 const ranges: [number, number][] = [];
248 for (let found = lower.indexOf(wanted); found !== -1; found = lower.indexOf(wanted, found + wanted.length)) {
249 ranges.push([found, found + wanted.length]);
250 }
251 if (ranges.length === 0) return spans.map((span) => ({ ...span, match: false }));
252 const out: (AnsiSpan & { match: boolean })[] = [];
253 let at = 0;
254 for (const span of spans) {
255 const end = at + span.text.length;
256 let cursor = at;
257 while (cursor < end) {
258 const inside = ranges.find(([from, to]) => cursor >= from && cursor < to);
259 const nextStart = ranges.find(([from]) => from > cursor)?.[0] ?? Infinity;
260 const stop = Math.min(end, inside ? inside[1] : nextStart);
261 out.push({ text: plain.slice(cursor, stop), style: span.style, match: Boolean(inside) });
262 cursor = stop;
263 }
264 at = end;
265 }
266 return out;
267}