blob: ee84980f71dba35ac54176e20be635f4a5682492 [file]
/*---------------------------------------------------------
* Copyright 2026 The Go Authors. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------*/
/**
* @fileoverview Diagnostic Consolidation Architecture
*
* This module manages diagnostic deduplication and publication across four diagnostic
* sources according to a strict priority hierarchy:
*
* #0 Language Server (gopls) [Highest]
* #1 Build
* #2 Vet
* #3 Lint [Lowest]
*
* -------------------------------------------------------------------------------------
* ### Priority Hierarchy & Deduplication Rules
* -------------------------------------------------------------------------------------
* Diagnostics are deduplicated on a per-line basis using two symmetric rules:
*
* 1. Upstream Filtering:
* When a lower-priority tool runs, any incoming diagnostic on a line that already
* contains a diagnostic from a higher-priority source with equal or higher severity
* is ignored. If the incoming diagnostic is strictly more severe, it is surfaced.
*
* 2. Downstream Eviction:
* When a higher-priority source publishes diagnostics, any existing diagnostics
* from lower-priority sources on those same lines with equal or lower severity are evicted.
*
* -------------------------------------------------------------------------------------
* ### Runtime Modes
* -------------------------------------------------------------------------------------
* In practice, the extension operates under two primary conditions:
*
* - Language Server Enabled (Default):
* Build and vet on save are disabled because the language server provides compilation
* and analysis diagnostics in real time. The active hierarchy on save is effectively:
* Language Server > Lint
*
* - Language Server Disabled:
* The language server is inactive, and build, vet, and lint run on save.
* The active hierarchy on save is:
* Build > Vet > Lint
*
* -------------------------------------------------------------------------------------
* ### Architecture Note
* -------------------------------------------------------------------------------------
* The deduplication logic is coordinated across two separate places in the extension:
*
* 1. The Language Server integration (which intercepts language server diagnostic events
* and evicts colliding diagnostics from lower-priority collections).
* 2. The extension tool diagnostic publisher (which handles build, vet, and lint results,
* filtering against higher-priority sources and evicting lower-priority ones).
*
* Both locations adhere to the same unified priority order.
*/
import cp = require('child_process');
import path = require('path');
import vscode = require('vscode');
import { FeatureState, HandleDiagnosticsSignature, LanguageClient, StaticFeature } from 'vscode-languageclient/node';
import { GoExtensionContext } from '../context';
import { outputChannel } from '../goStatus';
import { getBinPath } from '../util';
import { fixDriveCasingInWindows } from '../utils/pathUtils';
import { killProcessTree } from '../utils/processUtils';
/**
* GoDiagnosticsFeature is a static feature that hooks into the language client's
* handleDiagnostics middleware to evict colliding diagnostics from lower-priority
* collections (build, vet, lint) whenever gopls publishes diagnostics.
*/
export class GoDiagnosticsFeature implements StaticFeature {
constructor(
private readonly client: LanguageClient,
private readonly goCtx: GoExtensionContext
) {
this.addMiddleware();
}
public fillClientCapabilities(): void {}
public clear(): void {}
public getState(): FeatureState {
return { kind: 'static' };
}
public initialize(): void {}
private addMiddleware(): void {
const middleware = this.client.clientOptions.middleware ?? {};
const original = middleware.handleDiagnostics;
middleware.handleDiagnostics = (
uri: vscode.Uri,
diagnostics: vscode.Diagnostic[],
next: HandleDiagnosticsSignature
) => {
for (const collection of [
this.goCtx.buildDiagnosticCollection,
this.goCtx.vetDiagnosticCollection,
this.goCtx.lintDiagnosticCollection
]) {
if (collection?.has(uri)) {
collection.set(uri, filterDiags(collection.get(uri)!, diagnostics));
}
}
return original ? original(uri, diagnostics, next) : next(uri, diagnostics);
};
this.client.clientOptions.middleware = middleware;
}
}
export interface ICheckResult {
file: string;
line: number;
col?: number;
msg: string;
severity: string;
}
/**
* Processes and publishes tool diagnostics to the given diagnostic collection,
* applying upstream filtering against higher-priority collections and downstream
* eviction on lower-priority collections according to the priority hierarchy.
*
* TODO(hxjiang): is that possible to make GoExtensionContext holding type
* `vscode.DiagnosticCollection` instead of `vscode.DiagnosticCollection | undefined`.
*/
export function handleErrors(
goCtx: GoExtensionContext,
document: vscode.TextDocument | undefined,
errors: ICheckResult[],
collection?: vscode.DiagnosticCollection
) {
if (!collection) {
return;
}
const docMap: Map<string, vscode.TextDocument> = new Map();
{
if (document) {
docMap.set(document.uri.toString(), document);
}
// Also add other open .go files known to vscode for fast lookup.
for (const doc of vscode.workspace.textDocuments) {
const fileName = doc.uri.toString();
if (!fileName.endsWith('.go')) {
continue;
}
docMap.set(fileName, doc);
}
}
const diagsMap: Map<string, vscode.Diagnostic[]> = new Map();
for (const error of errors) {
const uri = vscode.Uri.file(error.file).toString();
// Some tools output only the line number or the start position.
// If the file content is available, adjust the diagnostic range so
// the squiggly underline for the error message is more visible.
let range: vscode.Range;
{
let startColumn = error.col ? error.col - 1 : 0;
let endColumn = startColumn + 1;
const doc = docMap.get(uri);
if (doc) {
const tempRange = new vscode.Range(
error.line - 1,
0,
error.line - 1,
doc.lineAt(error.line - 1).range.end.character + 1 // end of the line
);
const text = doc.getText(tempRange);
const [, leading, trailing] = /^(\s*).*(\s*)$/.exec(text)!;
if (!error.col) {
startColumn = leading.length; // beginning of the non-white space.
} else {
startColumn = error.col - 1; // range is 0-indexed
}
endColumn = text.length - trailing.length;
}
range = new vscode.Range(error.line - 1, startColumn, error.line - 1, endColumn);
}
let severity: vscode.DiagnosticSeverity = vscode.DiagnosticSeverity.Error;
if (error.severity === 'warning') {
severity = vscode.DiagnosticSeverity.Warning;
}
const diag = new vscode.Diagnostic(range, error.msg, severity);
diag.source = collection.name; // vscode uses source for deduping diagnostics.
let diags = diagsMap.get(uri);
if (!diags) {
diags = [];
}
diags.push(diag);
diagsMap.set(uri, diags);
}
collection.clear();
// Ordered from highest priority (#0) to lowest priority (#3)
const prioritized: (vscode.DiagnosticCollection | undefined)[] = [
goCtx.languageClient?.diagnostics, // #0 Gopls (Highest)
goCtx.buildDiagnosticCollection, // #1 Build
goCtx.vetDiagnosticCollection, // #2 Vet
goCtx.lintDiagnosticCollection // #3 Lint (Lowest)
];
// Find the current collection's rank.
const rank = prioritized.indexOf(collection);
if (rank === -1) {
return; // the collection is disposed, no longer managed by the go extension.
}
for (const [uriStr, fileDiags] of diagsMap) {
let diags = fileDiags;
const uri = vscode.Uri.parse(uriStr);
for (let i = 0; i < prioritized.length; i++) {
const other = prioritized[i];
if (!other) {
continue;
}
if (i < rank) {
// Upstream: filter incoming diagnostics against higher-priority source
if (other.has(uri)) {
diags = filterDiags(diags, other.get(uri)!);
}
} else if (i === rank) {
// Publish: all higher-priority filtering is done, publish now
collection.set(uri, diags);
} else {
// Downstream: evict colliding diagnostics from lower-priority collection
if (other.has(uri)) {
other.set(uri, filterDiags(other.get(uri)!, diags));
}
}
}
}
}
/**
* Returns targetDiags with any diagnostics that coincide on the same line
* with a diagnostic in maskingDiags of equal or higher severity removed.
*
* Diagnostics from targetDiags that are strictly more severe than all masking
* diagnostics on the same line are preserved.
*/
export function filterDiags(
targetDiags: readonly vscode.Diagnostic[],
maskingDiags: readonly vscode.Diagnostic[]
): vscode.Diagnostic[] {
// Max severity for each line number.
const maxSeverity = new Map<number, vscode.DiagnosticSeverity>();
for (const diag of maskingDiags) {
const line = diag.range.start.line;
const current = maxSeverity.get(line);
// Lower numerical values represent higher severity (0 = Error, 1 = Warning, etc.).
if (current === undefined || diag.severity < current) {
maxSeverity.set(line, diag.severity);
}
}
const deduped: vscode.Diagnostic[] = [];
for (const diag of targetDiags) {
const maxMaskingSeverity = maxSeverity.get(diag.range.start.line);
if (maxMaskingSeverity === undefined || diag.severity < maxMaskingSeverity) {
deduped.push(diag);
}
}
return deduped;
}
/**
* Runs given Go tool and returns errors/warnings that can be fed to the Problems Matcher
* @param args Arguments to be passed while running given tool
* @param cwd cwd that will passed in the env object while running given tool
* @param severity error or warning
* @param useStdErr If true, the stderr of the output of the given tool will be used, else stdout will be used
* @param toolName The name of the Go tool to run. If none is provided, the go runtime itself is used
* @param printUnexpectedOutput If true, then output that doesnt match expected format is printed to the output channel
*/
export function runTool(
args: string[],
cwd: string,
severity: string,
useStdErr: boolean,
toolName: string,
env: any,
printUnexpectedOutput: boolean,
token?: vscode.CancellationToken
): Promise<ICheckResult[]> {
let cmd: string;
if (toolName) {
cmd = getBinPath(toolName);
} else {
const goRuntimePath = getBinPath('go');
if (!goRuntimePath) {
return Promise.reject(new Error('Cannot find "go" binary. Update PATH or GOROOT appropriately'));
}
cmd = goRuntimePath;
}
let p: cp.ChildProcess;
if (token) {
token.onCancellationRequested(() => {
if (p) {
void killProcessTree(p);
}
});
}
cwd = fixDriveCasingInWindows(cwd);
return new Promise((resolve, reject) => {
p = cp.execFile(cmd, args, { env, cwd }, (err, stdout, stderr) => {
try {
if (err && (<any>err).code === 'ENOENT') {
// Since the tool is run on save which can be frequent
// we avoid sending explicit notification if tool is missing
console.log(`Cannot find ${toolName ? toolName : 'go'}`);
return resolve([]);
}
if (err && stderr && !useStdErr) {
outputChannel.error(['Error while running tool:', cmd, ...args].join(' '));
outputChannel.error(stderr);
return resolve([]);
}
const lines = (useStdErr ? stderr : stdout).toString().split('\n');
outputChannel.info([cwd + '>Finished running tool:', cmd, ...args].join(' '));
const ret: ICheckResult[] = [];
let unexpectedOutput = false;
let atLeastSingleMatch = false;
for (const l of lines) {
if (l[0] === '\t' && ret.length > 0) {
ret[ret.length - 1].msg += '\n' + l;
continue;
}
const match = /^([^:]*: )?((.:)?[^:]*):(\d+)(:(\d+)?)?:(?:\w+:)? (.*)$/.exec(l);
if (!match) {
if (printUnexpectedOutput && useStdErr && stderr) {
unexpectedOutput = true;
}
continue;
}
atLeastSingleMatch = true;
const [, , file, , lineStr, , colStr, msg] = match;
const line = +lineStr;
const col = colStr ? +colStr : undefined;
// Building skips vendor folders,
// But vet and lint take in directories and not import paths, so no way to skip them
// So prune out the results from vendor folders here.
if (
!path.isAbsolute(file) &&
(file.startsWith(`vendor${path.sep}`) || file.indexOf(`${path.sep}vendor${path.sep}`) > -1)
) {
continue;
}
const filePath = path.resolve(cwd, file);
ret.push({ file: filePath, line, col, msg, severity });
outputChannel.info(`${filePath}:${line}:${col ?? ''} ${msg}`);
}
if (!atLeastSingleMatch && unexpectedOutput && vscode.window.activeTextEditor) {
outputChannel.error(stderr);
if (err) {
ret.push({
file: vscode.window.activeTextEditor.document.fileName,
line: 1,
col: 1,
msg: stderr,
severity: 'error'
});
}
}
outputChannel.info('');
resolve(ret);
} catch (e) {
reject(e);
}
});
});
}