Skip to content

Commit

Permalink
feat: support compound SGR ansi escape sequences
Browse files Browse the repository at this point in the history
  • Loading branch information
luciancooper committed Apr 22, 2024
1 parent 5193538 commit a7c278e
Show file tree
Hide file tree
Showing 9 changed files with 371 additions and 148 deletions.
21 changes: 15 additions & 6 deletions src/sliceString.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import parseAnsi from './parseAnsi';
import { parseEscape, closeEscapes, type AnsiEscape } from './utils';
import { parseEscape, openEscapes, closeEscapes, type AnsiEscape } from './utils';
import splitChars from './splitChars';
import charWidths from './charWidths';
import stringLength from './stringLength';
Expand Down Expand Up @@ -33,17 +33,26 @@ function createSlicer(
// current slice index
idx = 0,
// current stack index
ax = -1;
ax = -1,
// queued ansi stack closings
closedStack: AnsiEscape<number>[] = [];
// match all ansi escape codes
for (const [chunk, isEscape] of parseAnsi(string)) {
// check if chunk is an escape sequence
if (isEscape) {
// process this escape sequence
const closed = parseEscape(ansiStack, chunk, idx);
// add sequence to result if it closes an active item in the stack
if (closed != null && closed <= ax) result += chunk;
// add any newly closed sequences to the closed stack
if (closed?.length) closedStack.unshift(...closed);
continue;
}
// check if any unclosed sequences have accumulated
if (closedStack.length) {
// close acumulated ansi sequences
result += closeEscapes(closedStack.filter(([,,, cx]) => cx <= ax));
// reset the closed stack
closedStack = [];
}
// store the value of the slice index at the outset of this chunk
const sidx = idx;
// iterate through the characters in this chunk
Expand All @@ -52,7 +61,7 @@ function createSlicer(
if ((beginIndex < idx || (beginIndex === idx && span > 0)) && idx + span <= endIndex) {
// check if the stack index is less than the slice index at the start of this chunk
if (ax < sidx) {
result += ansiStack.filter(([,,, x]) => x > ax).map(([s]) => s).join('');
result += openEscapes(ansiStack.filter(([,,, x]) => x > ax));
ax = sidx;
}
// add char to the result
Expand All @@ -68,7 +77,7 @@ function createSlicer(
}
}
// close active items in the escape stack and return the result slice
return result + closeEscapes(ansiStack.filter(([,,, x]) => x <= ax));
return result + closeEscapes([...closedStack, ...ansiStack].filter(([,,, x]) => x <= ax));
};
}

Expand Down
33 changes: 16 additions & 17 deletions src/spliceChars.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import parseAnsi from './parseAnsi';
import { parseEscape, type AnsiEscape } from './utils';
import { parseEscape, openEscapes, closeEscapes, type AnsiEscape } from './utils';
import splitChars from './splitChars';
import stringLength from './stringLength';

Expand Down Expand Up @@ -47,10 +47,11 @@ export default function spliceChars(string: string, start: number, deleteCount:
// check if insert point has been reached or if chunk is not a SGR/hyperlink escape
if (closed === null || (!startFound && (idx < startIndex || insert))) {
result += chunk;
} else if (closed != null) {
// append escape if it closes an active escape
const [x, afterStart] = closed;
if (!afterStart && (x < startIndex || insert)) result += chunk;
} else if (closed.length) {
// add close sequences for any active escapes in the stack
result += closeEscapes(closed.filter(([,,, [x, afterStart]]) => (
!afterStart && (x < startIndex || insert)
)));
}
// increment conventional string index
i += chunk.length;
Expand All @@ -60,12 +61,11 @@ export default function spliceChars(string: string, start: number, deleteCount:
if (endFound) {
return result
// append any open escapes found after the insert point
+ ansiStack
.filter(([,,, [x, afterStart]]) => afterStart || (x === startIndex && !insert))
.map(([s]) => s)
.join('')
// add the rest of the string
+ string.slice(i);
+ openEscapes(ansiStack.filter(([,,, [x, afterStart]]) => (
afterStart || (x === startIndex && !insert)
)))
// add the rest of the string
+ string.slice(i);
}
// iterate through the characters in this chunk
for (const char of splitChars(chunk)) {
Expand All @@ -81,12 +81,11 @@ export default function spliceChars(string: string, start: number, deleteCount:
if (idx === endIndex) {
return result
// append any open escapes found after the insert point
+ ansiStack
.filter(([,,, [x, afterStart]]) => afterStart || (x === startIndex && !insert))
.map(([s]) => s)
.join('')
// add the rest of the string
+ string.slice(i);
+ openEscapes(ansiStack.filter(([,,, [x, afterStart]]) => (
afterStart || (x === startIndex && !insert)
)))
// add the rest of the string
+ string.slice(i);
}
}
// increment char index
Expand Down
9 changes: 3 additions & 6 deletions src/splitLines.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import parseAnsi from './parseAnsi';
import { parseEscape, closeEscapes, type AnsiEscape } from './utils';
import { parseEscape, openEscapes, closeEscapes, type AnsiEscape } from './utils';

/**
* Split a string with ANSI escape codes into an array of lines.
Expand Down Expand Up @@ -40,18 +40,15 @@ export default function splitLines(string: string): string[] {
const closed = parseEscape(ansiStack, chunk, [i, j]);
if (closed && j >= 0) {
// append escape if it closes an active item in the stack
const [xi, xj] = closed;
if (xi < i || (xi === i && xj < j)) line += chunk;
line += closeEscapes(closed.filter(([,,, [xi, xj]]) => xi < i || (xi === i && xj < j)));
} else if (closed === null) {
// escape is not a SGR/hyperlink escape
line += chunk;
}
continue;
}
// append any new escape sequences from the ansi stack
line += (j < 0 ? ansiStack : ansiStack.filter(([,,, [xi, xj]]) => (xi === i && xj === j)))
.map(([s]) => s)
.join('');
line += openEscapes(j < 0 ? ansiStack : ansiStack.filter(([,,, [xi, xj]]) => (xi === i && xj === j)));
// append this chunk
line += chunk;
// increment the intraline index
Expand Down
93 changes: 60 additions & 33 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,39 +3,41 @@ import { styleCodes, closingCodes } from './ansiCodes';
export type AnsiEscape<T> = [string, true, string, T] | [string, false, number, T];

export function parseEscape<T>(stack: AnsiEscape<T>[], seq: string, idx: T) {
const [, code, link] = /[\u001B\u009B](?:\[(\d+)(?:;\d+)*m|\]8;;(.*)\u0007)/.exec(seq) ?? [];
// index of the lowest item in the stack closed by this sequence
let closed;
const [, sgr, link] = /[\u001B\u009B](?:\[(\d+(?:;[\d;]+)?)m|\]8;;(.*)\u0007)/.exec(seq) ?? [],
// array of indexes of stack items closed by this sequence
closedIndex: number[] = [];
// update ansi escape stack
if (code) {
const n = Number(code);
if (closingCodes.includes(n)) {
// remove all escapes that this sequence closes from the stack
for (let x = stack.length - 1; x >= 0; x -= 1) {
const [, isLink, close, cidx] = stack[x]!;
// if item is a link or is not closed by this code, skip it
if (isLink || (n !== 0 && close !== n)) continue;
// remove style sequence from the stack
stack.splice(x, 1);
// update the closed index
closed = cidx;
if (sgr) {
// parse each sgr code
for (let re = /(?:[345]8;(?:2(?:;\d+){3}|5;\d+)|\d+)/g, m = re.exec(sgr); m; m = re.exec(sgr)) {
const [code] = m,
n = Number(/^[345]8;/.test(code) ? code.slice(0, 2) : code);
if (closingCodes.includes(n)) {
// remove all escapes that this sequence closes from the stack
for (let x = stack.length - 1; x >= 0; x -= 1) {
const [, isLink, close] = stack[x]!;
// if item is a link or is not closed by this code, skip it
if (isLink || (n !== 0 && n !== close) || closedIndex.includes(x)) continue;
// if closed by reset, update the stack item
if (n === 0) stack[x]![2] = 0;
// add stack item index to closed array
closedIndex.push(x);
}
} else {
// add this ansi escape to the stack
stack.push([code, false, styleCodes.get(n) ?? 0, idx]);
}
} else {
// add this ansi escape to the stack
stack.push([seq, false, styleCodes.get(n) ?? 0, idx]);
}
} else if (link !== undefined) {
// if link is an empty string, then this is a closing hyperlink sequence
if (!link.length) {
// remove all hyperlink escapes from the stack
for (let x = stack.length - 1; x >= 0; x -= 1) {
const [, isLink,, cidx] = stack[x]!;
const [, isLink] = stack[x]!;
// if item is not an open hyperlink, skip it
if (!isLink) continue;
// remove open hyperlink sequence from the stack
stack.splice(x, 1);
// update the closed index
closed = cidx;
if (!isLink || closedIndex.includes(x)) continue;
// add stack item index to closed array
closedIndex.push(x);
}
} else {
// add this hyperlink escape to the stack
Expand All @@ -45,18 +47,43 @@ export function parseEscape<T>(stack: AnsiEscape<T>[], seq: string, idx: T) {
// return null on a non SGR/hyperlink escape sequence
return null;
}
const closed: AnsiEscape<T>[] = [];
// remove closed items from the stack
if (closedIndex.length) {
for (const x of closedIndex.sort((a, b) => b - a)) {
// add stack item to closed array
closed.unshift(stack[x]!);
// remove style sequence from the stack
stack.splice(x, 1);
}
}
return closed;
}

export function closeEscapes<T>(stack: AnsiEscape<T>[]) {
const escapes = stack.map(([, isLink, close]) => (isLink ? close : `\x1b[${close}m`));
let result = '';
while (escapes.length) {
const esc = escapes.pop();
for (let i = escapes.length - 1; i >= 0; i -= 1) {
if (esc === escapes[i]) escapes.splice(i, 1);
export function openEscapes<T>(stack: AnsiEscape<T>[]) {
let esc = '',
sgr: string[] = [];
for (const [seq, isLink] of stack) {
if (!isLink) {
sgr.push(seq);
continue;
}
result += esc;
if (sgr.length) {
esc += `\x1b[${sgr.join(';')}m`;
sgr = [];
}
esc += seq;
}
return esc + (sgr.length ? `\x1b[${sgr.join(';')}m` : '');
}

export function closeEscapes<T>(stack: AnsiEscape<T>[]) {
const sgr: number[] = [];
let link = '';
for (const [, isLink, close] of stack) {
if (!isLink) {
if (!sgr.includes(close)) sgr.unshift(close);
} else link = close;
}
return result;
return link + (sgr.length ? (sgr.includes(0) ? '\x1b[0m' : `\x1b[${sgr.join(';')}m`) : '');
}
Loading

0 comments on commit a7c278e

Please sign in to comment.