| 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. */ |
| 13 | export type AnsiColor = { palette: number } | { rgb: [number, number, number] }; |
| 14 | |
| 15 | export 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 | |
| 26 | export type AnsiSpan = { text: string; style: AnsiStyle }; |
| 27 | |
| 28 | const ESC = "\u001b"; |
| 29 | |
| 30 | /** Whether `text` holds an escape sequence or another control character worth reading out. */ |
| 31 | export 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. */ |
| 37 | export 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 | */ |
| 49 | function 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. */ |
| 58 | function 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 | */ |
| 108 | export 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 | |
| 170 | function 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 | |
| 176 | function 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. */ |
| 190 | export 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 | |
| 200 | function 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 | */ |
| 213 | export 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 | */ |
| 241 | export 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 | } |