diff --git a/editors/code/package-lock.json b/editors/code/package-lock.json index ee69d224760..4844837a06f 100644 --- a/editors/code/package-lock.json +++ b/editors/code/package-lock.json @@ -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", diff --git a/editors/code/package.json b/editors/code/package.json index 468368668fc..3fe189e2b3b 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -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" diff --git a/editors/code/src/client.ts b/editors/code/src/client.ts index e6595340aae..74cf44f42f7 100644 --- a/editors/code/src/client.ts +++ b/editors/code/src/client.ts @@ -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, diff --git a/editors/code/src/diagnostics.ts b/editors/code/src/diagnostics.ts new file mode 100644 index 00000000000..9695d8bf26d --- /dev/null +++ b/editors/code/src/diagnostics.ts @@ -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(); + + public constructor(private readonly ctx: Ctx) {} + + get onDidChange(): vscode.Event { + 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 { + 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; +} + +export class AnsiDecorationProvider implements vscode.Disposable { + private _decorationTypes = new Map(); + + 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(); + // 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 = { + "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; + } +} diff --git a/editors/code/src/main.ts b/editors/code/src/main.ts index 9a9667b2cd2..dd439317c70 100644 --- a/editors/code/src/main.ts +++ b/editors/code/src/main.ts @@ -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 { 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 { - 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,