├── .github └── workflows │ ├── jsr.yml │ ├── test.yml │ └── udd.yml ├── .gitignore ├── LICENSE ├── README.md ├── csi.ts ├── csi_test.ts ├── deno.jsonc ├── mod.ts ├── parser.ts ├── parser_test.ts ├── sgr.ts └── sgr_test.ts /.github/workflows/jsr.yml: -------------------------------------------------------------------------------- 1 | name: jsr 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | permissions: 12 | contents: read 13 | id-token: write 14 | 15 | jobs: 16 | publish: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - uses: denoland/setup-deno@v1 23 | with: 24 | deno-version: ${{ env.DENO_VERSION }} 25 | - name: Publish 26 | run: | 27 | deno run -A jsr:@david/publish-on-tag@0.1.3 28 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | env: 4 | DENO_VERSION: 1.x 5 | 6 | on: 7 | schedule: 8 | - cron: "0 7 * * 0" 9 | push: 10 | branches: 11 | - main 12 | pull_request: 13 | 14 | jobs: 15 | check: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: denoland/setup-deno@v1 20 | with: 21 | deno-version: ${{ env.DENO_VERSION }} 22 | - name: Format 23 | run: | 24 | deno fmt --check 25 | - name: Lint 26 | run: deno lint 27 | - name: Type check 28 | run: deno task check 29 | 30 | test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v3 34 | - uses: denoland/setup-deno@v1 35 | with: 36 | deno-version: ${{ env.DENO_VERSION }} 37 | - name: Test 38 | run: | 39 | deno task test 40 | timeout-minutes: 5 41 | - name: JSR publish (dry-run) 42 | run: | 43 | deno publish --dry-run 44 | -------------------------------------------------------------------------------- /.github/workflows/udd.yml: -------------------------------------------------------------------------------- 1 | name: Update 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | udd: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: denoland/setup-deno@v1 14 | with: 15 | deno-version: "1.x" 16 | - name: Update dependencies 17 | run: | 18 | deno task upgrade > ../output.txt 19 | env: 20 | NO_COLOR: 1 21 | - name: Read ../output.txt 22 | id: log 23 | uses: juliangruber/read-file-action@v1 24 | with: 25 | path: ../output.txt 26 | - name: Commit changes 27 | run: | 28 | git config user.name '${{ github.actor }}' 29 | git config user.email '${{ github.actor }}@users.noreply.github.com' 30 | git commit -a -F- <?]*[!"#$%&'()*+,-.\/]*[@A-Z\[\]\\^_`a-z{|}~])/; 6 | 7 | export type Csi = { 8 | /** Cursor Up */ 9 | cuu?: number; 10 | /** Cursor Down */ 11 | cud?: number; 12 | /** Cursor Forward */ 13 | cuf?: number; 14 | /** Cursor Back */ 15 | cub?: number; 16 | /** Cursor Next Line */ 17 | cnl?: number; 18 | /** Cursor Previous Line */ 19 | cpl?: number; 20 | /** Cursor Horizontal Absolute */ 21 | cha?: number; 22 | /** Cursor Position */ 23 | cup?: [number, number]; 24 | /** Erase in Display */ 25 | ed?: number; 26 | /** Erase in Line */ 27 | el?: number; 28 | /** Scroll Up */ 29 | su?: number; 30 | /** Scroll Down */ 31 | sd?: number; 32 | /** Horizontal Vertical Position */ 33 | hvp?: [number, number]; 34 | /** Select Graphic Rendition */ 35 | sgr?: Sgr; 36 | /** Device Status Report */ 37 | dsr?: true; 38 | }; 39 | 40 | /** 41 | * Parse CSI sequence and return `Csi` object 42 | * 43 | * It throws an error when `sequence` is not CSI sequence. 44 | */ 45 | export function parseCsi(sequence: string): Csi { 46 | const m = sequence.match(patternCsi); 47 | if (!m) { 48 | throw new Error(`Failed to parse CSI sequence '${sequence}'`); 49 | } 50 | let expr = m[1]; 51 | const csi: Csi = {}; 52 | const ps: [RegExp, (m: RegExpMatchArray) => void][] = [ 53 | [/(\d*)A/, (m) => csi.cuu = Number(m[1] || 1)], 54 | [/(\d*)B/, (m) => csi.cud = Number(m[1] || 1)], 55 | [/(\d*)C/, (m) => csi.cuf = Number(m[1] || 1)], 56 | [/(\d*)D/, (m) => csi.cub = Number(m[1] || 1)], 57 | [/(\d*)E/, (m) => csi.cnl = Number(m[1] || 1)], 58 | [/(\d*)F/, (m) => csi.cpl = Number(m[1] || 1)], 59 | [/(\d*)G/, (m) => csi.cha = Number(m[1] || 1)], 60 | [/(\d*);(\d*)H/, (m) => csi.cup = [Number(m[1] || 1), Number(m[2] || 1)]], 61 | [/(\d*)J/, (m) => csi.ed = Number(m[1] || 0)], 62 | [/(\d*)K/, (m) => csi.el = Number(m[1] || 0)], 63 | [/(\d*)S/, (m) => csi.su = Number(m[1] || 1)], 64 | [/(\d*)T/, (m) => csi.sd = Number(m[1] || 1)], 65 | [/(\d+);(\d+)f/, (m) => csi.hvp = [Number(m[1]), Number(m[2])]], 66 | [/([\d;]*)m/, (m) => csi.sgr = parseSgr(m[1])], 67 | [/6n/, () => csi.dsr = true], 68 | [/^.*/, () => {}], 69 | ]; 70 | while (expr) { 71 | for (const [p, handler] of ps) { 72 | const m = expr.match(p); 73 | if (m) { 74 | handler(m); 75 | expr = expr.substring(m[0].length); 76 | break; 77 | } 78 | } 79 | } 80 | return csi; 81 | } 82 | -------------------------------------------------------------------------------- /csi_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert@1.0.2/equals"; 2 | import { type Csi, parseCsi } from "./csi.ts"; 3 | 4 | Deno.test("parseCsi", async (t) => { 5 | const testcases: [string, Csi][] = [ 6 | ["\x1b[10A", { cuu: 10 }], 7 | ["\x1b[B", { cud: 1 }], 8 | ["\x1b[1m", { sgr: { bold: true } }], 9 | ]; 10 | for (const [expr, expected] of testcases) { 11 | await t.step(`properly handle "${expr}"`, () => { 12 | const actual = parseCsi(expr); 13 | assertEquals(actual, expected); 14 | }); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "lock": false, 3 | "name": "@lambdalisue/ansi-escape-code", 4 | "version": "0.0.0", 5 | "exports": "./mod.ts", 6 | "tasks": { 7 | "test": "deno test --unstable -A --parallel", 8 | "check": "deno check --unstable $(find . -name '*.ts')", 9 | "upgrade": "deno run -A jsr:@molt/cli **/*.ts --write" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export type { Annotation } from "./parser.ts"; 2 | export type { Color, Sgr } from "./sgr.ts"; 3 | export type { Csi } from "./csi.ts"; 4 | export { trimAndParse } from "./parser.ts"; 5 | -------------------------------------------------------------------------------- /parser.ts: -------------------------------------------------------------------------------- 1 | import { type Csi, parseCsi, patternCsi } from "./csi.ts"; 2 | 3 | const patternCsiGlobal = new RegExp(patternCsi, "g"); 4 | 5 | export type Annotation = { 6 | offset: number; 7 | csi: Csi; 8 | raw: string; 9 | }; 10 | 11 | /** 12 | * Trim and parse ANSI escape code in `expr` and return it. 13 | */ 14 | export function trimAndParse(expr: string): [string, Annotation[]] { 15 | const annotations = [...expr.matchAll(patternCsiGlobal)].map((m) => { 16 | return { 17 | offset: m.index ?? 0, 18 | csi: parseCsi(m[0]), 19 | raw: m[0], 20 | }; 21 | }); 22 | for (let i = annotations.length - 1; i >= 0; i--) { 23 | const n = annotations[i].raw.length; 24 | for (let j = i + 1; j < annotations.length; j++) { 25 | annotations[j].offset -= n; 26 | } 27 | } 28 | return [expr.replaceAll(patternCsiGlobal, ""), annotations]; 29 | } 30 | -------------------------------------------------------------------------------- /parser_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert@1.0.2/equals"; 2 | import { type Annotation, trimAndParse } from "./parser.ts"; 3 | 4 | Deno.test("trimAndParse", async (t) => { 5 | const testcases: [string, [string, Annotation[]]][] = [ 6 | ["Hello world", ["Hello world", []]], 7 | ["\x1b[1mHello\x1b[m world", ["Hello world", [ 8 | { offset: 0, raw: "\x1b[1m", csi: { sgr: { bold: true } } }, 9 | { offset: 5, raw: "\x1b[m", csi: { sgr: { reset: true } } }, 10 | ]]], 11 | ["\x1b[1mHe\x1b[30mll\x1b[31mo\x1b[m world", ["Hello world", [ 12 | { offset: 0, raw: "\x1b[1m", csi: { sgr: { bold: true } } }, 13 | { offset: 2, raw: "\x1b[30m", csi: { sgr: { foreground: 0 } } }, 14 | { offset: 4, raw: "\x1b[31m", csi: { sgr: { foreground: 1 } } }, 15 | { offset: 5, raw: "\x1b[m", csi: { sgr: { reset: true } } }, 16 | ]]], 17 | ["\x1b[31mRed\x1b[m", [ 18 | "Red", 19 | [ 20 | { offset: 0, raw: "\x1b[31m", csi: { sgr: { foreground: 1 } } }, 21 | { offset: 3, raw: "\x1b[m", csi: { sgr: { reset: true } } }, 22 | ], 23 | ]], 24 | ["\x1b[1;31mBright red (old)\x1b[m", [ 25 | "Bright red (old)", 26 | [ 27 | { 28 | offset: 0, 29 | raw: "\x1b[1;31m", 30 | csi: { sgr: { bold: true, foreground: 1 } }, 31 | }, 32 | { offset: 16, raw: "\x1b[m", csi: { sgr: { reset: true } } }, 33 | ], 34 | ]], 35 | ["\x1b[91mBright red (new)\x1b[m", [ 36 | "Bright red (new)", 37 | [ 38 | { offset: 0, raw: "\x1b[91m", csi: { sgr: { foreground: 9 } } }, 39 | { offset: 16, raw: "\x1b[m", csi: { sgr: { reset: true } } }, 40 | ], 41 | ]], 42 | ["\x1b[32m|\x1b[m\x1b[33m\\\x1b[m", [ 43 | "|\\", 44 | [ 45 | { offset: 0, raw: "\x1b[32m", csi: { sgr: { foreground: 2 } } }, 46 | { offset: 1, raw: "\x1b[m", csi: { sgr: { reset: true } } }, 47 | { offset: 1, raw: "\x1b[33m", csi: { sgr: { foreground: 3 } } }, 48 | { offset: 2, raw: "\x1b[m", csi: { sgr: { reset: true } } }, 49 | ], 50 | ]], 51 | ]; 52 | for (const [expr, expected] of testcases) { 53 | await t.step(`properly handle "${expr}"`, () => { 54 | const actual = trimAndParse(expr); 55 | assertEquals(actual, expected); 56 | }); 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /sgr.ts: -------------------------------------------------------------------------------- 1 | export type Color = "default" | number | [number, number, number]; 2 | 3 | export type Sgr = { 4 | reset?: true; 5 | bold?: boolean; 6 | dim?: boolean; 7 | italic?: boolean; 8 | underline?: boolean; 9 | blinking?: boolean; 10 | inverse?: boolean; 11 | conceal?: boolean; 12 | strike?: boolean; 13 | foreground?: Color; 14 | background?: Color; 15 | }; 16 | 17 | /** 18 | * Parse SGR parameters and return `Sgr` object 19 | */ 20 | export function parseSgr(parameters: string): Sgr { 21 | const sgr: Sgr = {}; 22 | const ps: [RegExp, (m: RegExpMatchArray) => void][] = [ 23 | [ 24 | /^38;2;(\d*);(\d*);(\d*);/, 25 | (m) => 26 | sgr.foreground = [ 27 | Number(m[1] || 0), 28 | Number(m[2] || 0), 29 | Number(m[3] || 0), 30 | ], 31 | ], 32 | [ 33 | /^48;2;(\d*);(\d*);(\d*);/, 34 | (m) => 35 | sgr.background = [ 36 | Number(m[1] || 0), 37 | Number(m[2] || 0), 38 | Number(m[3] || 0), 39 | ], 40 | ], 41 | [/^38;5;(\d*);/, (m) => sgr.foreground = Number(m[1] || 0)], 42 | [/^48;5;(\d*);/, (m) => sgr.background = Number(m[1] || 0)], 43 | [/^39;/, () => sgr.foreground = "default"], 44 | [/^49;/, () => sgr.background = "default"], 45 | [/^3([0-7]);/, (m) => sgr.foreground = Number(m[1])], 46 | [/^4([0-7]);/, (m) => sgr.background = Number(m[1])], 47 | [/^9([0-7]);/, (m) => sgr.foreground = Number(m[1]) + 8], 48 | [/^10([0-7]);/, (m) => sgr.background = Number(m[1]) + 8], 49 | [/^0?;/, () => sgr.reset = true], 50 | [/^1;/, () => sgr.bold = true], 51 | [/^2;/, () => sgr.dim = true], 52 | [/^3;/, () => sgr.italic = true], 53 | [/^4;/, () => sgr.underline = true], 54 | [/^5;/, () => sgr.blinking = true], 55 | [/^7;/, () => sgr.inverse = true], 56 | [/^8;/, () => sgr.conceal = true], 57 | [/^9;/, () => sgr.strike = true], 58 | [/^22;/, () => { 59 | sgr.bold = false; 60 | sgr.dim = false; 61 | }], 62 | [/^23;/, () => sgr.italic = false], 63 | [/^24;/, () => sgr.underline = false], 64 | [/^25;/, () => sgr.blinking = false], 65 | [/^27;/, () => sgr.inverse = false], 66 | [/^28;/, () => sgr.conceal = false], 67 | [/^29;/, () => sgr.strike = false], 68 | [/^[^;]*;/, () => {}], 69 | ]; 70 | parameters = `${parameters};`; 71 | while (parameters) { 72 | for (const [p, handler] of ps) { 73 | const m = parameters.match(p); 74 | if (m) { 75 | handler(m); 76 | parameters = parameters.substring(m[0].length); 77 | break; 78 | } 79 | } 80 | } 81 | return sgr; 82 | } 83 | -------------------------------------------------------------------------------- /sgr_test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "jsr:@std/assert@1.0.2/equals"; 2 | 3 | import { parseSgr, type Sgr } from "./sgr.ts"; 4 | 5 | Deno.test("parseSgr", async (t) => { 6 | const testcases: [string, Sgr][] = [ 7 | ["", { reset: true }], 8 | ["1", { bold: true }], 9 | ["1;2;3", { bold: true, dim: true, italic: true }], 10 | ["1;31", { bold: true, foreground: 1 }], 11 | ["30;47", { foreground: 0, background: 7 }], 12 | ["90;107", { foreground: 8, background: 15 }], 13 | ["38;5;255;48;5;232", { foreground: 255, background: 232 }], 14 | ["38;5;;48;5;", { foreground: 0, background: 0 }], 15 | ["38;2;255;255;255;48;2;0;0;0", { 16 | foreground: [255, 255, 255], 17 | background: [0, 0, 0], 18 | }], 19 | ["38;2;;;;48;2;;;", { 20 | foreground: [0, 0, 0], 21 | background: [0, 0, 0], 22 | }], 23 | ["39;49", { foreground: "default", background: "default" }], 24 | // Fraktur (Gothic) that rarely supported (and parseSgr does not support as well) 25 | ["20", {}], 26 | ]; 27 | for (const [expr, expected] of testcases) { 28 | await t.step(`properly handle "${expr}"`, () => { 29 | const actual = parseSgr(expr); 30 | assertEquals(actual, expected); 31 | }); 32 | } 33 | }); 34 | --------------------------------------------------------------------------------