core-components: added AnsiProcessor for LogViewer
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -39,6 +39,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@types/react-sparklines": "^1.7.0",
|
||||
"@types/react-text-truncate": "^0.14.0",
|
||||
"ansi-regex": "^5.0.1",
|
||||
"classnames": "^2.2.6",
|
||||
"clsx": "^1.1.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
@@ -81,6 +82,7 @@
|
||||
"@types/d3-selection": "^3.0.1",
|
||||
"@types/d3-shape": "^3.0.1",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"@types/ansi-regex": "^5.0.0",
|
||||
"@types/dagre": "^0.7.44",
|
||||
"@types/google-protobuf": "^3.7.2",
|
||||
"@types/jest": "^26.0.7",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { AnsiProcessor } from './AnsiProcessor';
|
||||
|
||||
describe('AnsiProcessor', () => {
|
||||
it('should process a single line', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(processor.process('foo\x1b[31mbar\x1b[39mbaz')).toEqual([
|
||||
[
|
||||
{
|
||||
text: 'foo',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'bar',
|
||||
modifiers: { foreground: 'red' },
|
||||
},
|
||||
{
|
||||
text: 'baz',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(processor.process(`foo [32mbar[39m: baz`)).toEqual([
|
||||
[
|
||||
{
|
||||
text: 'foo ',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'bar',
|
||||
modifiers: { foreground: 'green' },
|
||||
},
|
||||
{
|
||||
text: ': baz',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process multiple lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(
|
||||
processor.process(`
|
||||
a\x1b[34mb\x1b[39mc
|
||||
x\x1b[44my\x1b[49mz
|
||||
`),
|
||||
).toEqual([
|
||||
[{ text: '', modifiers: {} }],
|
||||
[
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'blue' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { background: 'blue' },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
[{ text: '', modifiers: {} }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry state across lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
expect(
|
||||
processor.process(`
|
||||
a\x1b[45mb\x1b[35mc
|
||||
x\x1b[39my\x1b[49mz`),
|
||||
).toEqual([
|
||||
[{ text: '', modifiers: {} }],
|
||||
[
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'magenta', background: 'magenta' },
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: { foreground: 'magenta', background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { background: 'magenta' },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should carry forward state when appending lines', () => {
|
||||
const processor = new AnsiProcessor();
|
||||
const out1 = processor.process(`
|
||||
a\x1b[36mb\x1b[3mc`);
|
||||
expect(out1).toEqual([
|
||||
[{ text: '', modifiers: {} }],
|
||||
[
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'cyan' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const out2 = processor.process(`
|
||||
a\x1b[36mb\x1b[3mc
|
||||
x\x1b[39my\x1b[23mz`);
|
||||
expect(out2).toEqual([
|
||||
[{ text: '', modifiers: {} }],
|
||||
[
|
||||
{
|
||||
text: 'a',
|
||||
modifiers: {},
|
||||
},
|
||||
{
|
||||
text: 'b',
|
||||
modifiers: { foreground: 'cyan' },
|
||||
},
|
||||
{
|
||||
text: 'c',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'x',
|
||||
modifiers: { foreground: 'cyan', italic: true },
|
||||
},
|
||||
{
|
||||
text: 'y',
|
||||
modifiers: { italic: true },
|
||||
},
|
||||
{
|
||||
text: 'z',
|
||||
modifiers: {},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
// Verifies that we appended rather than reprocessed
|
||||
expect(out1[0]).toBe(out2[0]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2021 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import ansiRegexMaker from 'ansi-regex';
|
||||
|
||||
const ansiRegex = ansiRegexMaker();
|
||||
const newlineRegex = /\n\r?/g;
|
||||
|
||||
// A mapping of how each escape code changes the modifiers
|
||||
const codeModifiers = Object.fromEntries(
|
||||
Object.entries({
|
||||
1: m => ({ ...m, bold: true }),
|
||||
3: m => ({ ...m, italic: true }),
|
||||
4: m => ({ ...m, underline: true }),
|
||||
22: ({ bold: _, ...m }) => m,
|
||||
23: ({ italic: _, ...m }) => m,
|
||||
24: ({ underline: _, ...m }) => m,
|
||||
30: m => ({ ...m, foreground: 'black' }),
|
||||
31: m => ({ ...m, foreground: 'red' }),
|
||||
32: m => ({ ...m, foreground: 'green' }),
|
||||
33: m => ({ ...m, foreground: 'yellow' }),
|
||||
34: m => ({ ...m, foreground: 'blue' }),
|
||||
35: m => ({ ...m, foreground: 'magenta' }),
|
||||
36: m => ({ ...m, foreground: 'cyan' }),
|
||||
37: m => ({ ...m, foreground: 'white' }),
|
||||
39: ({ foreground: _, ...m }) => m,
|
||||
90: m => ({ ...m, foreground: 'grey' }),
|
||||
40: m => ({ ...m, background: 'black' }),
|
||||
41: m => ({ ...m, background: 'red' }),
|
||||
42: m => ({ ...m, background: 'green' }),
|
||||
43: m => ({ ...m, background: 'yellow' }),
|
||||
44: m => ({ ...m, background: 'blue' }),
|
||||
45: m => ({ ...m, background: 'magenta' }),
|
||||
46: m => ({ ...m, background: 'cyan' }),
|
||||
47: m => ({ ...m, background: 'white' }),
|
||||
49: ({ background: _, ...m }) => m,
|
||||
} as Record<string, (m: ChunkModifiers) => ChunkModifiers>).map(
|
||||
([code, modifier]) => [`\x1b[${code}m`, modifier],
|
||||
),
|
||||
);
|
||||
|
||||
export type AnsiColor =
|
||||
| 'black'
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'blue'
|
||||
| 'magenta'
|
||||
| 'cyan'
|
||||
| 'white'
|
||||
| 'grey';
|
||||
|
||||
export interface ChunkModifiers {
|
||||
foreground?: AnsiColor;
|
||||
background?: AnsiColor;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
}
|
||||
|
||||
export interface Chunk {
|
||||
text: string;
|
||||
modifiers: ChunkModifiers;
|
||||
}
|
||||
|
||||
export class AnsiProcessor {
|
||||
private text: string = '';
|
||||
private lines: Chunk[][] = [];
|
||||
|
||||
/**
|
||||
* Processes a chunk of text while keeping internal state that optimizes
|
||||
* subsequent processing that appends to the text.
|
||||
*/
|
||||
process(text: string): Chunk[][] {
|
||||
if (this.text === text) {
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
if (text.startsWith(this.text)) {
|
||||
const lastLineIndex = this.lines.length > 0 ? this.lines.length - 1 : 0;
|
||||
const lastLine = this.lines[lastLineIndex] ?? [];
|
||||
const lastChunk = lastLine[lastLine.length - 1] as Chunk | undefined;
|
||||
const newLines = this.processLines(
|
||||
(lastChunk?.text ?? '') + text.slice(this.text.length),
|
||||
lastChunk?.modifiers,
|
||||
);
|
||||
this.text = text;
|
||||
lastLine.splice(lastLine.length - 1, 1, ...newLines[0]);
|
||||
this.lines[lastLineIndex] = lastLine;
|
||||
this.lines.push(...newLines.slice(1));
|
||||
} else {
|
||||
this.lines = this.processLines(text);
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
return this.lines;
|
||||
}
|
||||
|
||||
// Split a chunk of text up into lines and process each line individually
|
||||
private processLines = (
|
||||
text: string,
|
||||
modifiers: ChunkModifiers = {},
|
||||
): Chunk[][] => {
|
||||
const lines: Chunk[][] = [];
|
||||
|
||||
let prevIndex = 0;
|
||||
let currentModifiers = modifiers;
|
||||
newlineRegex.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = newlineRegex.exec(text);
|
||||
if (!match) {
|
||||
lines.push(this.processText(text.slice(prevIndex), currentModifiers));
|
||||
return lines;
|
||||
}
|
||||
|
||||
const line = text.slice(prevIndex, match.index);
|
||||
prevIndex = match.index + match[0].length;
|
||||
|
||||
const chunks = this.processText(line, currentModifiers);
|
||||
lines.push(chunks);
|
||||
|
||||
// Modifiers that are active in the last chunk are carried over to the next line
|
||||
currentModifiers =
|
||||
chunks[chunks.length - 1].modifiers ?? currentModifiers;
|
||||
}
|
||||
};
|
||||
|
||||
// Processing of a one individual text chunk
|
||||
private processText = (
|
||||
fullText: string,
|
||||
modifiers: ChunkModifiers,
|
||||
): Chunk[] => {
|
||||
const chunks: Chunk[] = [];
|
||||
|
||||
let prevIndex = 0;
|
||||
let currentModifiers = modifiers;
|
||||
ansiRegex.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = ansiRegex.exec(fullText);
|
||||
if (!match) {
|
||||
chunks.push({
|
||||
text: fullText.slice(prevIndex),
|
||||
modifiers: currentModifiers,
|
||||
});
|
||||
return chunks;
|
||||
}
|
||||
|
||||
const text = fullText.slice(prevIndex, match.index);
|
||||
chunks.push({ text, modifiers: currentModifiers });
|
||||
|
||||
// For every escape code that we encounter we keep track of where the
|
||||
// next chunk of text starts, and what modifiers it has
|
||||
prevIndex = match.index + match[0].length;
|
||||
currentModifiers = this.processCode(match[0], currentModifiers);
|
||||
}
|
||||
};
|
||||
|
||||
private processCode = (
|
||||
code: string,
|
||||
modifiers: ChunkModifiers,
|
||||
): ChunkModifiers => {
|
||||
return codeModifiers[code]?.(modifiers) ?? modifiers;
|
||||
};
|
||||
}
|
||||
@@ -6974,6 +6974,13 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/ansi-regex@^5.0.0":
|
||||
version "5.0.0"
|
||||
resolved "https://registry.npmjs.org/@types/ansi-regex/-/ansi-regex-5.0.0.tgz#569a5189a92cc46d63fb2ad91e6b130f33d999c1"
|
||||
integrity sha512-SQafVL3pXFh/5qq/nN6p5858g//zSVzcb8JzCLtoVxm8YNPggMQfEIm7aaTNysxpw1S+lFTaW8kv+aR0/CEhCA==
|
||||
dependencies:
|
||||
ansi-regex "*"
|
||||
|
||||
"@types/archiver@^5.1.0":
|
||||
version "5.3.0"
|
||||
resolved "https://registry.npmjs.org/@types/archiver/-/archiver-5.3.0.tgz#2b34ba56d4d7102d256b922c7e91e09eab79db6f"
|
||||
@@ -9235,6 +9242,11 @@ ansi-html@0.0.7, ansi-html@^0.0.7:
|
||||
resolved "https://registry.npmjs.org/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e"
|
||||
integrity sha1-gTWEAhliqenm/QOflA0S9WynhZ4=
|
||||
|
||||
ansi-regex@*, ansi-regex@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
|
||||
integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
|
||||
|
||||
ansi-regex@^2.0.0:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
|
||||
@@ -9255,11 +9267,6 @@ ansi-regex@^5.0.0, ansi-regex@^5.0.1:
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
|
||||
|
||||
ansi-regex@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz#3183e38fae9a65d7cb5e53945cd5897d0260a06a"
|
||||
integrity sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==
|
||||
|
||||
ansi-styles@^2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
|
||||
|
||||
Reference in New Issue
Block a user