| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { markSpans, paletteRgb, parseAnsi, spanLook, stripAnsi } from "./ansi.ts"; |
| 5 | |
| 6 | const E = "\u001b"; |
| 7 | |
| 8 | test("plain text is one unstyled span", () => { |
| 9 | assert.deepEqual(parseAnsi("cargo build"), [{ text: "cargo build", style: {} }]); |
| 10 | assert.deepEqual(parseAnsi(""), []); |
| 11 | }); |
| 12 | |
| 13 | test("SGR codes style the text after them until a reset", () => { |
| 14 | assert.deepEqual(parseAnsi(`${E}[1m${E}[32mCompiling${E}[0m g1t v0.2.0`), [ |
| 15 | { text: "Compiling", style: { bold: true, fg: { palette: 2 } } }, |
| 16 | { text: " g1t v0.2.0", style: {} }, |
| 17 | ]); |
| 18 | // Several in one sequence, an empty one resetting, and the switches off. |
| 19 | assert.deepEqual(parseAnsi(`${E}[2;3;4;9mx${E}[22;23my${E}[24;29mz${E}[mw`), [ |
| 20 | { text: "x", style: { dim: true, italic: true, underline: true, strike: true } }, |
| 21 | { text: "y", style: { underline: true, strike: true } }, |
| 22 | // The same look, one span. |
| 23 | { text: "zw", style: {} }, |
| 24 | ]); |
| 25 | // Bright colours, backgrounds, and the defaults back. |
| 26 | assert.deepEqual(parseAnsi(`${E}[91;104mhot${E}[39mbg${E}[49mnone`), [ |
| 27 | { text: "hot", style: { fg: { palette: 9 }, bg: { palette: 12 } } }, |
| 28 | { text: "bg", style: { bg: { palette: 12 } } }, |
| 29 | { text: "none", style: {} }, |
| 30 | ]); |
| 31 | assert.deepEqual(parseAnsi(`${E}[7minverse${E}[27m`), [{ text: "inverse", style: { inverse: true } }]); |
| 32 | }); |
| 33 | |
| 34 | test("256 colours and 24-bit colours, in both of their forms", () => { |
| 35 | assert.deepEqual(parseAnsi(`${E}[38;5;208morange${E}[48;5;3mon`), [ |
| 36 | { text: "orange", style: { fg: { palette: 208 } } }, |
| 37 | { text: "on", style: { fg: { palette: 208 }, bg: { palette: 3 } } }, |
| 38 | ]); |
| 39 | assert.deepEqual(parseAnsi(`${E}[38;2;255;100;0mrgb${E}[0m`), [{ text: "rgb", style: { fg: { rgb: [255, 100, 0] } } }]); |
| 40 | assert.deepEqual(parseAnsi(`${E}[38:2::10:20:30mitu${E}[48:5:17mbg`), [ |
| 41 | { text: "itu", style: { fg: { rgb: [10, 20, 30] } } }, |
| 42 | { text: "bg", style: { fg: { rgb: [10, 20, 30] }, bg: { palette: 17 } } }, |
| 43 | ]); |
| 44 | // A colour out of range is left alone, and what follows still reads. |
| 45 | assert.deepEqual(parseAnsi(`${E}[38;5;999;1mx`), [{ text: "x", style: { bold: true } }]); |
| 46 | assert.deepEqual(paletteRgb(16), [0, 0, 0]); |
| 47 | assert.deepEqual(paletteRgb(196), [255, 0, 0]); |
| 48 | assert.deepEqual(paletteRgb(232), [8, 8, 8]); |
| 49 | assert.deepEqual(paletteRgb(255), [238, 238, 238]); |
| 50 | }); |
| 51 | |
| 52 | test("every other escape is dropped, and a link keeps its text", () => { |
| 53 | // Moving the cursor and clearing the line, as progress bars do. |
| 54 | assert.equal(stripAnsi(`${E}[2K${E}[1G Downloading${E}[?25l done${E}[?25h`), " Downloading done"); |
| 55 | // An OSC 8 hyperlink, closed with BEL and with ESC \. |
| 56 | assert.equal(stripAnsi(`see ${E}]8;;https://g1t.sh${E}\\the docs${E}]8;;${E}\\ now`), "see the docs now"); |
| 57 | assert.equal(stripAnsi(`${E}]0;title\u0007after`), "after"); |
| 58 | // A character set, a two-character escape, a bell and a backspace. |
| 59 | assert.equal(stripAnsi(`${E}(Bplain${E}7 kept\u0007\u0008`), "plain kept"); |
| 60 | // A tab stays; a sequence cut off at the end of the line goes. |
| 61 | assert.equal(stripAnsi(`a\tb${E}[3`), "a\tb"); |
| 62 | assert.equal(stripAnsi(`end${E}`), "end"); |
| 63 | assert.equal(stripAnsi("no escapes"), "no escapes"); |
| 64 | }); |
| 65 | |
| 66 | test("the 16 colours are theme classes; the rest are inline colours", () => { |
| 67 | assert.deepEqual(spanLook({ bold: true, fg: { palette: 1 }, bg: { palette: 15 } }), { className: "ansi-fg-1 ansi-bg-15 font-bold", style: undefined }); |
| 68 | assert.deepEqual(spanLook({ fg: { palette: 196 }, bg: { rgb: [1, 2, 3] } }), { |
| 69 | className: "", |
| 70 | style: { color: "rgb(255, 0, 0)", backgroundColor: "rgb(1, 2, 3)" }, |
| 71 | }); |
| 72 | assert.deepEqual(spanLook({ inverse: true, fg: { palette: 4 } }), { className: "ansi-fg-inverse ansi-bg-4", style: undefined }); |
| 73 | assert.equal(spanLook({ dim: true, italic: true, underline: true }).className, "opacity-70 italic underline"); |
| 74 | assert.equal(spanLook({}).className, ""); |
| 75 | }); |
| 76 | |
| 77 | test("a search marks its matches across spans, colours kept", () => { |
| 78 | const spans = parseAnsi(`${E}[32mtest${E}[0m result: ok. Test`); |
| 79 | assert.deepEqual(markSpans(spans, "T R"), [ |
| 80 | { text: "tes", style: { fg: { palette: 2 } }, match: false }, |
| 81 | { text: "t", style: { fg: { palette: 2 } }, match: true }, |
| 82 | { text: " r", style: {}, match: true }, |
| 83 | { text: "esult: ok. Test", style: {}, match: false }, |
| 84 | ]); |
| 85 | assert.deepEqual( |
| 86 | markSpans(spans, "test").map((span) => [span.text, span.match]), |
| 87 | [ |
| 88 | ["test", true], |
| 89 | [" result: ok. ", false], |
| 90 | ["Test", true], |
| 91 | ], |
| 92 | ); |
| 93 | assert.deepEqual(markSpans(spans, " "), spans.map((span) => ({ ...span, match: false }))); |
| 94 | }); |