Auto merge of #13848 - ian-h-chamberlain:feature/color-compiler-diagnostics, r=ian-h-chamberlain

Colorize `cargo check` diagnostics in VSCode via text decorations

Fixes #13648

![colored-rustc-diagnostics](https://user-images.githubusercontent.com/11131775/209479884-10eef8ca-37b4-4aae-88f7-3591ac01b25e.gif)

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`](https://www.npmjs.com/package/anser) library (MIT license, no dependencies) 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 in the future

I also updated the default `cargo check` command to use the rendered ANSI diagnostics, although I'm not sure if it makes sense to put this kind of thing behind a feature flag, or whether it might have any issues on Windows (as I believe ANSI codes are not used for colorization there)?
This commit is contained in:
bors 2023-01-09 17:07:35 +00:00
commit 368e0bb32f
9 changed files with 328 additions and 31 deletions

View File

@ -47,6 +47,7 @@ pub enum FlycheckConfig {
features: Vec<String>, features: Vec<String>,
extra_args: Vec<String>, extra_args: Vec<String>,
extra_env: FxHashMap<String, String>, extra_env: FxHashMap<String, String>,
ansi_color_output: bool,
}, },
CustomCommand { CustomCommand {
command: String, command: String,
@ -293,12 +294,21 @@ impl FlycheckActor {
extra_args, extra_args,
features, features,
extra_env, extra_env,
ansi_color_output,
} => { } => {
let mut cmd = Command::new(toolchain::cargo()); let mut cmd = Command::new(toolchain::cargo());
cmd.arg(command); cmd.arg(command);
cmd.current_dir(&self.root); cmd.current_dir(&self.root);
cmd.args(["--workspace", "--message-format=json", "--manifest-path"]) cmd.arg("--workspace");
.arg(self.root.join("Cargo.toml").as_os_str());
cmd.arg(if *ansi_color_output {
"--message-format=json-diagnostic-rendered-ansi"
} else {
"--message-format=json"
});
cmd.arg("--manifest-path");
cmd.arg(self.root.join("Cargo.toml").as_os_str());
for target in target_triples { for target in target_triples {
cmd.args(["--target", target.as_str()]); cmd.args(["--target", target.as_str()]);

View File

@ -160,7 +160,9 @@ config_data! {
check_noDefaultFeatures | checkOnSave_noDefaultFeatures: Option<bool> = "null", check_noDefaultFeatures | checkOnSave_noDefaultFeatures: Option<bool> = "null",
/// Override the command rust-analyzer uses instead of `cargo check` for /// Override the command rust-analyzer uses instead of `cargo check` for
/// diagnostics on save. The command is required to output json and /// diagnostics on save. The command is required to output json and
/// should therefore include `--message-format=json` or a similar option. /// should therefore include `--message-format=json` or a similar option
/// (if your client supports the `colorDiagnosticOutput` experimental
/// capability, you can use `--message-format=json-diagnostic-rendered-ansi`).
/// ///
/// If you're changing this because you're using some tool wrapping /// If you're changing this because you're using some tool wrapping
/// Cargo, you might also want to change /// Cargo, you might also want to change
@ -1006,6 +1008,11 @@ impl Config {
self.experimental("serverStatusNotification") self.experimental("serverStatusNotification")
} }
/// Whether the client supports colored output for full diagnostics from `checkOnSave`.
pub fn color_diagnostic_output(&self) -> bool {
self.experimental("colorDiagnosticOutput")
}
pub fn publish_diagnostics(&self) -> bool { pub fn publish_diagnostics(&self) -> bool {
self.data.diagnostics_enable self.data.diagnostics_enable
} }
@ -1204,6 +1211,7 @@ impl Config {
}, },
extra_args: self.data.check_extraArgs.clone(), extra_args: self.data.check_extraArgs.clone(),
extra_env: self.check_on_save_extra_env(), extra_env: self.check_on_save_extra_env(),
ansi_color_output: self.color_diagnostic_output(),
}, },
} }
} }

View File

@ -792,3 +792,29 @@ export interface ClientCommandOptions {
commands: string[]; commands: string[];
} }
``` ```
## Colored Diagnostic Output
**Experimental Client Capability:** `{ "colorDiagnosticOutput": boolean }`
If this capability is set, the "full compiler diagnostics" provided by `checkOnSave`
will include ANSI color and style codes to render the diagnostic in a similar manner
as `cargo`. This is translated into `--message-format=json-diagnostic-rendered-ansi`
when flycheck is run, instead of the default `--message-format=json`.
The full compiler rendered diagnostics are included in the server response
regardless of this capability:
```typescript
// https://microsoft.github.io/language-server-protocol/specifications/specification-current#diagnostic
export interface Diagnostic {
...
data?: {
/**
* The human-readable compiler output as it would be printed to a terminal.
* Includes ANSI color and style codes if the client has set the experimental
* `colorDiagnosticOutput` capability.
*/
rendered?: string;
};
}

View File

@ -173,7 +173,9 @@ Whether to pass `--no-default-features` to Cargo. Defaults to
-- --
Override the command rust-analyzer uses instead of `cargo check` for Override the command rust-analyzer uses instead of `cargo check` for
diagnostics on save. The command is required to output json and diagnostics on save. The command is required to output json and
should therefore include `--message-format=json` or a similar option. should therefore include `--message-format=json` or a similar option
(if your client supports the `colorDiagnosticOutput` experimental
capability, you can use `--message-format=json-diagnostic-rendered-ansi`).
If you're changing this because you're using some tool wrapping If you're changing this because you're using some tool wrapping
Cargo, you might also want to change Cargo, you might also want to change

View File

@ -9,6 +9,7 @@
"version": "0.5.0-dev", "version": "0.5.0-dev",
"license": "MIT OR Apache-2.0", "license": "MIT OR Apache-2.0",
"dependencies": { "dependencies": {
"anser": "^2.1.1",
"d3": "^7.6.1", "d3": "^7.6.1",
"d3-graphviz": "^5.0.2", "d3-graphviz": "^5.0.2",
"vscode-languageclient": "^8.0.2" "vscode-languageclient": "^8.0.2"
@ -394,6 +395,11 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@ -4096,6 +4102,11 @@
"uri-js": "^4.2.2" "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": { "ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "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" "test": "cross-env TEST_VARIABLE=test node ./out/tests/runTests.js"
}, },
"dependencies": { "dependencies": {
"anser": "^2.1.1",
"d3": "^7.6.1", "d3": "^7.6.1",
"d3-graphviz": "^5.0.2", "d3-graphviz": "^5.0.2",
"vscode-languageclient": "^8.0.2" "vscode-languageclient": "^8.0.2"
@ -643,7 +644,7 @@
] ]
}, },
"rust-analyzer.check.overrideCommand": { "rust-analyzer.check.overrideCommand": {
"markdownDescription": "Override the command rust-analyzer uses instead of `cargo check` for\ndiagnostics on save. The command is required to output json and\nshould therefore include `--message-format=json` or a similar option.\n\nIf you're changing this because you're using some tool wrapping\nCargo, you might also want to change\n`#rust-analyzer.cargo.buildScripts.overrideCommand#`.\n\nIf there are multiple linked projects, this command is invoked for\neach of them, with the working directory being the project root\n(i.e., the folder containing the `Cargo.toml`).\n\nAn example command would be:\n\n```bash\ncargo check --workspace --message-format=json --all-targets\n```\n.", "markdownDescription": "Override the command rust-analyzer uses instead of `cargo check` for\ndiagnostics on save. The command is required to output json and\nshould therefore include `--message-format=json` or a similar option\n(if your client supports the `colorDiagnosticOutput` experimental\ncapability, you can use `--message-format=json-diagnostic-rendered-ansi`).\n\nIf you're changing this because you're using some tool wrapping\nCargo, you might also want to change\n`#rust-analyzer.cargo.buildScripts.overrideCommand#`.\n\nIf there are multiple linked projects, this command is invoked for\neach of them, with the working directory being the project root\n(i.e., the folder containing the `Cargo.toml`).\n\nAn example command would be:\n\n```bash\ncargo check --workspace --message-format=json --all-targets\n```\n.",
"default": null, "default": null,
"type": [ "type": [
"null", "null",

View File

@ -1,8 +1,10 @@
import * as anser from "anser";
import * as lc from "vscode-languageclient/node"; import * as lc from "vscode-languageclient/node";
import * as vscode from "vscode"; import * as vscode from "vscode";
import * as ra from "../src/lsp_ext"; import * as ra from "../src/lsp_ext";
import * as Is from "vscode-languageclient/lib/common/utils/is"; import * as Is from "vscode-languageclient/lib/common/utils/is";
import { assert } from "./util"; import { assert } from "./util";
import * as diagnostics from "./diagnostics";
import { WorkspaceEdit } from "vscode"; import { WorkspaceEdit } from "vscode";
import { Config, substituteVSCodeVariables } from "./config"; import { Config, substituteVSCodeVariables } from "./config";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
@ -120,12 +122,12 @@ export async function createClient(
}, },
async handleDiagnostics( async handleDiagnostics(
uri: vscode.Uri, uri: vscode.Uri,
diagnostics: vscode.Diagnostic[], diagnosticList: vscode.Diagnostic[],
next: lc.HandleDiagnosticsSignature next: lc.HandleDiagnosticsSignature
) { ) {
const preview = config.previewRustcOutput; const preview = config.previewRustcOutput;
const errorCode = config.useRustcErrorCode; 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 // 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 // Diagnostic class, if they ever break this we are out of luck and have to go
// back to the worst diagnostics experience ever:) // back to the worst diagnostics experience ever:)
@ -138,9 +140,10 @@ export async function createClient(
?.rendered; ?.rendered;
if (rendered) { if (rendered) {
if (preview) { if (preview) {
const decolorized = anser.ansiToText(rendered);
const index = const index =
rendered.match(/^(note|help):/m)?.index || rendered.length; decolorized.match(/^(note|help):/m)?.index || rendered.length;
diag.message = rendered diag.message = decolorized
.substring(0, index) .substring(0, index)
.replace(/^ -->[^\n]+\n/m, ""); .replace(/^ -->[^\n]+\n/m, "");
} }
@ -154,8 +157,8 @@ export async function createClient(
} }
diag.code = { diag.code = {
target: vscode.Uri.from({ target: vscode.Uri.from({
scheme: "rust-analyzer-diagnostics-view", scheme: diagnostics.URI_SCHEME,
path: "/diagnostic message", path: `/diagnostic message [${idx.toString()}]`,
fragment: uri.toString(), fragment: uri.toString(),
query: idx.toString(), query: idx.toString(),
}), }),
@ -163,7 +166,7 @@ export async function createClient(
}; };
} }
}); });
return next(uri, diagnostics); return next(uri, diagnosticList);
}, },
async provideHover( async provideHover(
document: vscode.TextDocument, document: vscode.TextDocument,
@ -330,6 +333,7 @@ class ExperimentalFeatures implements lc.StaticFeature {
caps.codeActionGroup = true; caps.codeActionGroup = true;
caps.hoverActions = true; caps.hoverActions = true;
caps.serverStatusNotification = true; caps.serverStatusNotification = true;
caps.colorDiagnosticOutput = true;
caps.commands = { caps.commands = {
commands: [ commands: [
"rust-analyzer.runSingle", "rust-analyzer.runSingle",

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 * as commands from "./commands";
import { CommandFactory, Ctx, fetchWorkspace } from "./ctx"; import { CommandFactory, Ctx, fetchWorkspace } from "./ctx";
import * as diagnostics from "./diagnostics";
import { activateTaskProvider } from "./tasks"; import { activateTaskProvider } from "./tasks";
import { setContextValue } from "./util"; import { setContextValue } from "./util";
@ -48,30 +49,52 @@ async function activateServer(ctx: Ctx): Promise<RustAnalyzerExtensionApi> {
ctx.pushExtCleanup(activateTaskProvider(ctx.config)); ctx.pushExtCleanup(activateTaskProvider(ctx.config));
} }
const diagnosticProvider = new diagnostics.TextDocumentProvider(ctx);
ctx.pushExtCleanup( ctx.pushExtCleanup(
vscode.workspace.registerTextDocumentContentProvider( vscode.workspace.registerTextDocumentContentProvider(
"rust-analyzer-diagnostics-view", diagnostics.URI_SCHEME,
new (class implements vscode.TextDocumentContentProvider { diagnosticProvider
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";
}
})()
) )
); );
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( vscode.workspace.onDidChangeWorkspaceFolders(
async (_) => ctx.onWorkspaceFolderChanges(), async (_) => ctx.onWorkspaceFolderChanges(),
null, null,