blob: 423f814bf03263e2b0e6d83bd2107dea8c4d015b [file] [edit]
/*---------------------------------------------------------
* Copyright 2026 The Go Authors. All rights reserved.
* Licensed under the MIT License. See LICENSE in the project root for license information.
*--------------------------------------------------------*/
import assert from 'assert';
import os = require('os');
import * as path from 'path';
import sinon from 'sinon';
import * as vscode from 'vscode';
import * as config from '../../src/config';
import { GoExtensionContext } from '../../src/context';
import { handleErrors, ICheckResult } from '../../src/diagnostics/diagnostics';
import { lintCode } from '../../src/diagnostics/goLint';
import { MockWorkspaceConfiguration } from '../integration/mocks/configuration';
import { Env } from './goplsTestEnv.utils';
interface expectedDiagnostic {
line: number;
source: string;
severity: vscode.DiagnosticSeverity;
}
function compareDiags(a: vscode.Diagnostic, b: vscode.Diagnostic): number {
if (a.range.start.line !== b.range.start.line) {
return a.range.start.line - b.range.start.line;
}
if (a.range.start.character !== b.range.start.character) {
return a.range.start.character - b.range.start.character;
}
if (a.severity !== b.severity) {
return a.severity - b.severity;
}
return (a.source ?? '').localeCompare(b.source ?? '');
}
suite('Diagnostic consolidation - unit', () => {
let goCtx: GoExtensionContext;
const fileURI = vscode.Uri.file(path.join(os.tmpdir(), 'diagnostic_priority_test.go')); // fake file
const filePath = fileURI.fsPath;
interface TestDiagnosticTestCase {
name: string;
diags: {
gopls?: ICheckResult[];
build?: ICheckResult[];
vet?: ICheckResult[];
lint?: ICheckResult[];
};
want: expectedDiagnostic[];
}
const testCases: TestDiagnosticTestCase[] = [
{
name: 'Symmetric priority masking (Gopls > Build > Vet > Lint)',
diags: {
gopls: [{ file: filePath, line: 10, msg: 'unmasked - highest priority', severity: 'warning' }],
build: [
{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
{ file: filePath, line: 20, msg: 'unmasked - no higher priority', severity: 'warning' }
],
vet: [
{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
{ file: filePath, line: 20, msg: 'masked by build', severity: 'warning' },
{ file: filePath, line: 30, msg: 'unmasked - no higher priority', severity: 'warning' }
],
lint: [
{ file: filePath, line: 10, msg: 'masked by gopls', severity: 'warning' },
{ file: filePath, line: 20, msg: 'masked by build', severity: 'warning' },
{ file: filePath, line: 30, msg: 'masked by vet', severity: 'warning' },
{ file: filePath, line: 40, msg: 'unmasked - no higher priority', severity: 'warning' }
]
},
want: [
{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Warning },
{ line: 20, source: 'build-test', severity: vscode.DiagnosticSeverity.Warning },
{ line: 30, source: 'vet-test', severity: vscode.DiagnosticSeverity.Warning },
{ line: 40, source: 'lint-test', severity: vscode.DiagnosticSeverity.Warning }
]
},
{
name: 'Diagnostics with columns and severity',
diags: {
gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'error' }],
build: [
{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'error' },
{ file: filePath, line: 20, col: 12, msg: 'unmasked - no higher priority', severity: 'error' }
],
vet: [
{ file: filePath, line: 20, col: 12, msg: 'masked by build', severity: 'warning' },
{ file: filePath, line: 30, col: 8, msg: 'unmasked - no higher priority', severity: 'warning' }
],
lint: [
{ file: filePath, line: 30, col: 8, msg: 'masked by vet', severity: 'warning' },
{ file: filePath, line: 40, col: 15, msg: 'unmasked - no higher priority', severity: 'warning' }
]
},
want: [
{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Error },
{ line: 20, source: 'build-test', severity: vscode.DiagnosticSeverity.Error },
{ line: 30, source: 'vet-test', severity: vscode.DiagnosticSeverity.Warning },
{ line: 40, source: 'lint-test', severity: vscode.DiagnosticSeverity.Warning }
]
},
{
name: 'Same line, different columns, same severity',
diags: {
gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'error' }],
build: [
{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'error' },
{ file: filePath, line: 10, col: 15, msg: 'unmasked - no higher priority', severity: 'error' }
],
vet: [{ file: filePath, line: 10, col: 25, msg: 'unmasked - no higher priority', severity: 'error' }],
lint: [{ file: filePath, line: 10, col: 35, msg: 'unmasked - no higher priority', severity: 'error' }]
},
want: [
{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Error },
{ line: 10, source: 'build-test', severity: vscode.DiagnosticSeverity.Error },
{ line: 10, source: 'vet-test', severity: vscode.DiagnosticSeverity.Error },
{ line: 10, source: 'lint-test', severity: vscode.DiagnosticSeverity.Error }
]
},
{
name: 'Same line and column, lower priority has higher severity',
diags: {
gopls: [{ file: filePath, line: 10, col: 5, msg: 'unmasked - highest priority', severity: 'warning' }],
lint: [
{ file: filePath, line: 10, col: 5, msg: 'masked by gopls', severity: 'warning' },
{
file: filePath,
line: 10,
col: 5,
msg: 'unmasked - higher severity than gopls warning',
severity: 'error'
}
]
},
want: [
{ line: 10, source: 'lint-test', severity: vscode.DiagnosticSeverity.Error },
{ line: 10, source: 'gopls-test', severity: vscode.DiagnosticSeverity.Warning }
]
}
];
setup(() => {
goCtx = {
languageClient: { diagnostics: vscode.languages.createDiagnosticCollection('gopls-test') } as any,
buildDiagnosticCollection: vscode.languages.createDiagnosticCollection('build-test'),
vetDiagnosticCollection: vscode.languages.createDiagnosticCollection('vet-test'),
lintDiagnosticCollection: vscode.languages.createDiagnosticCollection('lint-test')
};
});
teardown(() => {
goCtx.languageClient?.diagnostics?.dispose();
goCtx.buildDiagnosticCollection?.dispose();
goCtx.vetDiagnosticCollection?.dispose();
goCtx.lintDiagnosticCollection?.dispose();
});
for (const tc of testCases) {
for (let round = 1; round <= 5; round++) {
test(`${tc.name} (round ${round})`, async () => {
const collections = [
{ key: 'gopls' as const, collection: goCtx.languageClient!.diagnostics! },
{ key: 'build' as const, collection: goCtx.buildDiagnosticCollection! },
{ key: 'vet' as const, collection: goCtx.vetDiagnosticCollection! },
{ key: 'lint' as const, collection: goCtx.lintDiagnosticCollection! }
];
// Simulate 4 concurrent diagnostic providers reporting diags
// independently with random delays to verify eventual consistency.
const tasks = collections.map(({ key, collection }) => {
const errors = tc.diags[key] || [];
return new Promise<void>((resolve) => {
const delay = Math.floor(Math.random() * 15);
setTimeout(() => {
handleErrors(goCtx, undefined, errors, collection);
resolve();
}, delay);
});
});
// Wait for all concurrent diagnostic providers to finish reporting.
await Promise.all(tasks);
// Read diagnostics directly from the "PROBLEMS" tab.
const problems = vscode.languages.getDiagnostics(fileURI);
const sorted = [...problems].sort(compareDiags);
assert.strictEqual(
sorted.length,
tc.want.length,
`[${tc.name}] Expected ${tc.want.length} diagnostics, got ${sorted.length}: ${JSON.stringify(sorted.map((p) => ({ line: p.range.start.line + 1, source: p.source, msg: p.message })))}`
);
for (let i = 0; i < tc.want.length; i++) {
const want = tc.want[i];
const got = sorted[i];
assert.strictEqual(got.range.start.line, want.line - 1, `[${tc.name}] Line mismatch at index ${i}`);
assert.strictEqual(got.source, want.source, `[${tc.name}] Source mismatch at index ${i}`);
assert.strictEqual(got.severity, want.severity, `[${tc.name}] Severity mismatch at index ${i}`);
}
});
}
}
});
// Regression tests for golang/vscode-go#3511.
suite('Diagnostic consolidation - regression (#3511)', function () {
this.timeout(30000);
const projectDir = path.join(__dirname, '..', '..', '..');
const testdataDir = path.join(projectDir, 'test', 'testdata', 'diagnosticsTest');
let env: Env;
async function pollDiagnostics(uri: vscode.Uri, predicate: (diags: vscode.Diagnostic[]) => boolean): Promise<void> {
const start = Date.now();
// Polling deadline of 10s. Analyzing a module with only a few files
// should finish within 10s for both gopls and external linters.
while (Date.now() - start < 10000) {
const problems = vscode.languages.getDiagnostics(uri);
if (predicate(problems)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.fail(
`timed out waiting for expected diags on ${path.basename(uri.fsPath)}, got: ${JSON.stringify(
vscode.languages
.getDiagnostics(uri)
.map((p) => ({ line: p.range.start.line + 1, source: p.source, msg: p.message }))
)}`
);
}
suiteSetup(async () => {
env = new Env();
const goplsConfig = new MockWorkspaceConfiguration(
config.getGoplsConfig(),
new Map<string, any>([
['ui.diagnostic.staticcheck', true],
['ui.diagnostic.analyses', { ST1017: true }]
])
);
sinon.stub(config, 'getGoplsConfig').returns(goplsConfig);
const goConfig = new MockWorkspaceConfiguration(
config.getGoConfig(),
new Map<string, any>([
['lintTool', 'golangci-lint-v2'],
['lintOnSave', 'package']
])
);
sinon.stub(config, 'getGoConfig').returns(goConfig);
env.goCtx.lintDiagnosticCollection = vscode.languages.createDiagnosticCollection('go-lint');
// Start gopls with workspace
await env.startGopls(path.join(testdataDir, 'masked.go'), undefined, testdataDir);
// Open both documents to trigger gopls diagnostics
const { doc: coexistDoc } = await env.openDoc(path.join(testdataDir, 'coexist.go'));
const { doc: maskedDoc } = await env.openDoc(path.join(testdataDir, 'masked.go'));
await vscode.window.showTextDocument(coexistDoc);
// Run linter once for the package
lintCode('package')(undefined as any, env.goCtx)();
// Wait until diagnostics from both gopls and linter are ready
await pollDiagnostics(coexistDoc.uri, (diags) => diags.length >= 2);
await pollDiagnostics(maskedDoc.uri, (diags) => diags.length >= 1);
});
suiteTeardown(async () => {
sinon.restore();
env.goCtx.lintDiagnosticCollection?.dispose();
await env.teardown();
env.flushTrace(false);
});
interface DiagnosticTestCase {
name: string;
fileName: string;
want: expectedDiagnostic[];
}
const testCases: DiagnosticTestCase[] = [
{
name: 'coexist',
fileName: 'coexist.go',
// golangci-lint-v2 report a more severe diags so that persist.
want: [
{ line: 8, source: 'any', severity: vscode.DiagnosticSeverity.Hint },
{ line: 8, source: 'go-lint', severity: vscode.DiagnosticSeverity.Warning }
]
},
{
name: 'masked',
fileName: 'masked.go',
// golangci-lint-v2 will report the same diags but prefer gopls'.
want: [{ line: 4, source: 'ST1017', severity: vscode.DiagnosticSeverity.Warning }]
}
];
for (const tc of testCases) {
test(tc.name, () => {
const uri = vscode.Uri.file(path.join(testdataDir, tc.fileName));
const problems = vscode.languages.getDiagnostics(uri);
const sorted = [...problems].sort(compareDiags);
assert.strictEqual(
sorted.length,
tc.want.length,
`[${tc.name}] Expected ${tc.want.length} diagnostics, got ${sorted.length}: ${JSON.stringify(sorted.map((p) => ({ line: p.range.start.line + 1, source: p.source, msg: p.message })))}`
);
for (let i = 0; i < tc.want.length; i++) {
const want = tc.want[i];
const got = sorted[i];
assert.strictEqual(got.range.start.line, want.line - 1, `[${tc.name}] Line mismatch at index ${i}`);
assert.strictEqual(got.source, want.source, `[${tc.name}] Source mismatch at index ${i}`);
assert.strictEqual(got.severity, want.severity, `[${tc.name}] Severity mismatch at index ${i}`);
}
});
}
});