Parse + decorate rendered ANSI cargo output

Use ANSI control characters to display text decorations matching the
VScode terminal theme, and strip them out when providing text content
for rustc diagnostics.

This adds the small `anser` library to parse the control codes, and it
also supports HTML output so it should be fairly easy to switch to a
rendered HTML/webview implementation if desired.
This commit is contained in:
Ian Chamberlain 2023-01-03 10:16:16 -05:00
parent f32e20edb9
commit 1b8141b54c
No known key found for this signature in database
GPG Key ID: BD7B25D58170E124
5 changed files with 272 additions and 24 deletions

View File

@ -9,6 +9,7 @@
"version": "0.5.0-dev",
"license": "MIT OR Apache-2.0",
"dependencies": {
"anser": "^2.1.1",
"d3": "^7.6.1",
"d3-graphviz": "^5.0.2",
"vscode-languageclient": "^8.0.2"
@ -394,6 +395,11 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/anser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz",
"integrity": "sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ=="
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@ -4096,6 +4102,11 @@
"uri-js": "^4.2.2"
}
},
"anser": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.1.1.tgz",
"integrity": "sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ=="
},
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",

View File

@ -35,6 +35,7 @@
"test": "cross-env TEST_VARIABLE=test node ./out/tests/runTests.js"
},
"dependencies": {
"anser": "^2.1.1",
"d3": "^7.6.1",
"d3-graphviz": "^5.0.2",
"vscode-languageclient": "^8.0.2"

View File

@ -3,6 +3,7 @@ import * as vscode from "vscode";
import * as ra from "../src/lsp_ext";
import * as Is from "vscode-languageclient/lib/common/utils/is";
import { assert } from "./util";
import * as diagnostics from "./diagnostics";
import { WorkspaceEdit } from "vscode";
import { Config, substituteVSCodeVariables } from "./config";
import { randomUUID } from "crypto";
@ -120,12 +121,12 @@ export async function createClient(
},
async handleDiagnostics(
uri: vscode.Uri,
diagnostics: vscode.Diagnostic[],
diagnosticList: vscode.Diagnostic[],
next: lc.HandleDiagnosticsSignature
) {
const preview = config.previewRustcOutput;
const errorCode = config.useRustcErrorCode;
diagnostics.forEach((diag, idx) => {
diagnosticList.forEach((diag, idx) => {
// Abuse the fact that VSCode leaks the LSP diagnostics data field through the
// Diagnostic class, if they ever break this we are out of luck and have to go
// back to the worst diagnostics experience ever:)
@ -154,8 +155,8 @@ export async function createClient(
}
diag.code = {
target: vscode.Uri.from({
scheme: "rust-analyzer-diagnostics-view",
path: "/diagnostic message",
scheme: diagnostics.URI_SCHEME,
path: `/diagnostic message [${idx.toString()}]`,
fragment: uri.toString(),
query: idx.toString(),
}),
@ -163,7 +164,7 @@ export async function createClient(
};
}
});
return next(uri, diagnostics);
return next(uri, diagnosticList);
},
async provideHover(
document: vscode.TextDocument,

View File

@ -0,0 +1,212 @@
import * as anser from "anser";
import * as vscode from "vscode";
import { ProviderResult, Range, TextEditorDecorationType, ThemeColor, window } from "vscode";
import { Ctx } from "./ctx";
export const URI_SCHEME = "rust-analyzer-diagnostics-view";
export class TextDocumentProvider implements vscode.TextDocumentContentProvider {
private _onDidChange = new vscode.EventEmitter<vscode.Uri>();
public constructor(private readonly ctx: Ctx) {}
get onDidChange(): vscode.Event<vscode.Uri> {
return this._onDidChange.event;
}
triggerUpdate(uri: vscode.Uri) {
if (uri.scheme === URI_SCHEME) {
this._onDidChange.fire(uri);
}
}
dispose() {
this._onDidChange.dispose();
}
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
const contents = getRenderedDiagnostic(this.ctx, uri);
return anser.ansiToText(contents);
}
}
function getRenderedDiagnostic(ctx: Ctx, uri: vscode.Uri): string {
const diags = ctx.client?.diagnostics?.get(vscode.Uri.parse(uri.fragment, true));
if (!diags) {
return "Unable to find original rustc diagnostic";
}
const diag = diags[parseInt(uri.query)];
if (!diag) {
return "Unable to find original rustc diagnostic";
}
const rendered = (diag as unknown as { data?: { rendered?: string } }).data?.rendered;
if (!rendered) {
return "Unable to find original rustc diagnostic";
}
return rendered;
}
interface AnserStyle {
fg: string;
bg: string;
fg_truecolor: string;
bg_truecolor: string;
decorations: Array<anser.DecorationName>;
}
export class AnsiDecorationProvider implements vscode.Disposable {
private _decorationTypes = new Map<AnserStyle, TextEditorDecorationType>();
public constructor(private readonly ctx: Ctx) {}
dispose(): void {
for (const decorationType of this._decorationTypes.values()) {
decorationType.dispose();
}
this._decorationTypes.clear();
}
async provideDecorations(editor: vscode.TextEditor) {
if (editor.document.uri.scheme !== URI_SCHEME) {
return;
}
const decorations = (await this._getDecorations(editor.document.uri)) || [];
for (const [decorationType, ranges] of decorations) {
editor.setDecorations(decorationType, ranges);
}
}
private _getDecorations(
uri: vscode.Uri
): ProviderResult<[TextEditorDecorationType, Range[]][]> {
const stringContents = getRenderedDiagnostic(this.ctx, uri);
const lines = stringContents.split("\n");
const result = new Map<TextEditorDecorationType, Range[]>();
// Populate all known decoration types in the result. This forces any
// lingering decorations to be cleared if the text content changes to
// something without ANSI codes for a given decoration type.
for (const decorationType of this._decorationTypes.values()) {
result.set(decorationType, []);
}
for (const [lineNumber, line] of lines.entries()) {
const totalEscapeLength = 0;
// eslint-disable-next-line camelcase
const parsed = anser.ansiToJson(line, { use_classes: true });
let offset = 0;
for (const span of parsed) {
const { content, ...style } = span;
const range = new Range(
lineNumber,
offset - totalEscapeLength,
lineNumber,
offset + content.length - totalEscapeLength
);
offset += content.length;
const decorationType = this._getDecorationType(style);
if (!result.has(decorationType)) {
result.set(decorationType, []);
}
result.get(decorationType)!.push(range);
}
}
return [...result];
}
private _getDecorationType(style: AnserStyle): TextEditorDecorationType {
let decorationType = this._decorationTypes.get(style);
if (decorationType) {
return decorationType;
}
const fontWeight = style.decorations.find((s) => s === "bold");
const fontStyle = style.decorations.find((s) => s === "italic");
const textDecoration = style.decorations.find((s) => s === "underline");
decorationType = window.createTextEditorDecorationType({
backgroundColor: AnsiDecorationProvider._convertColor(style.bg, style.bg_truecolor),
color: AnsiDecorationProvider._convertColor(style.fg, style.fg_truecolor),
fontWeight,
fontStyle,
textDecoration,
});
this._decorationTypes.set(style, decorationType);
return decorationType;
}
// NOTE: This could just be a kebab-case to camelCase conversion, but I think it's
// a short enough list to just write these by hand
static readonly _anserToThemeColor: Record<string, ThemeColor> = {
"ansi-black": "ansiBlack",
"ansi-white": "ansiWhite",
"ansi-red": "ansiRed",
"ansi-green": "ansiGreen",
"ansi-yellow": "ansiYellow",
"ansi-blue": "ansiBlue",
"ansi-magenta": "ansiMagenta",
"ansi-cyan": "ansiCyan",
"ansi-bright-black": "ansiBrightBlack",
"ansi-bright-white": "ansiBrightWhite",
"ansi-bright-red": "ansiBrightRed",
"ansi-bright-green": "ansiBrightGreen",
"ansi-bright-yellow": "ansiBrightYellow",
"ansi-bright-blue": "ansiBrightBlue",
"ansi-bright-magenta": "ansiBrightMagenta",
"ansi-bright-cyan": "ansiBrightCyan",
};
private static _convertColor(
color?: string,
truecolor?: string
): ThemeColor | string | undefined {
if (!color) {
return undefined;
}
if (color === "ansi-truecolor") {
if (!truecolor) {
return undefined;
}
return `rgb(${truecolor})`;
}
const paletteMatch = color.match(/ansi-palette-(.+)/);
if (paletteMatch) {
const paletteColor = paletteMatch[1];
// anser won't return both the RGB and the color name at the same time,
// so just fake a single foreground control char with the palette number:
const spans = anser.ansiToJson(`\x1b[38;5;${paletteColor}m`);
const rgb = spans[1].fg;
if (rgb) {
return `rgb(${rgb})`;
}
}
const themeColor = AnsiDecorationProvider._anserToThemeColor[color];
if (themeColor) {
return new ThemeColor("terminal." + themeColor);
}
return undefined;
}
}

View File

@ -3,6 +3,7 @@ import * as lc from "vscode-languageclient/node";
import * as commands from "./commands";
import { CommandFactory, Ctx, fetchWorkspace } from "./ctx";
import * as diagnostics from "./diagnostics";
import { activateTaskProvider } from "./tasks";
import { setContextValue } from "./util";
@ -48,30 +49,52 @@ async function activateServer(ctx: Ctx): Promise<RustAnalyzerExtensionApi> {
ctx.pushExtCleanup(activateTaskProvider(ctx.config));
}
const diagnosticProvider = new diagnostics.TextDocumentProvider(ctx);
ctx.pushExtCleanup(
vscode.workspace.registerTextDocumentContentProvider(
"rust-analyzer-diagnostics-view",
new (class implements vscode.TextDocumentContentProvider {
async provideTextDocumentContent(uri: vscode.Uri): Promise<string> {
const diags = ctx.client?.diagnostics?.get(
vscode.Uri.parse(uri.fragment, true)
);
if (!diags) {
return "Unable to find original rustc diagnostic";
}
const diag = diags[parseInt(uri.query)];
if (!diag) {
return "Unable to find original rustc diagnostic";
}
const rendered = (diag as unknown as { data?: { rendered?: string } }).data
?.rendered;
return rendered ?? "Unable to find original rustc diagnostic";
}
})()
diagnostics.URI_SCHEME,
diagnosticProvider
)
);
const decorationProvider = new diagnostics.AnsiDecorationProvider(ctx);
ctx.pushExtCleanup(decorationProvider);
async function decorateVisibleEditors(document: vscode.TextDocument) {
for (const editor of vscode.window.visibleTextEditors) {
if (document === editor.document) {
await decorationProvider.provideDecorations(editor);
}
}
}
vscode.workspace.onDidChangeTextDocument(
async (event) => await decorateVisibleEditors(event.document),
null,
ctx.subscriptions
);
vscode.workspace.onDidOpenTextDocument(decorateVisibleEditors, null, ctx.subscriptions);
vscode.window.onDidChangeActiveTextEditor(
async (editor) => {
if (editor) {
diagnosticProvider.triggerUpdate(editor.document.uri);
await decorateVisibleEditors(editor.document);
}
},
null,
ctx.subscriptions
);
vscode.window.onDidChangeVisibleTextEditors(
async (visibleEditors) => {
for (const editor of visibleEditors) {
diagnosticProvider.triggerUpdate(editor.document.uri);
await decorationProvider.provideDecorations(editor);
}
},
null,
ctx.subscriptions
);
vscode.workspace.onDidChangeWorkspaceFolders(
async (_) => ctx.onWorkspaceFolderChanges(),
null,