[typescript-language-features] Region-based semantic diagnostics for TypeScript (#208713)

* WIP

* invalidate diagnostics in range

* check whether should use region based diagnostics

* add ts-expect-errors

* make region opt off by default

* bump to expected 5.6

* update comments to refer to 5.6

* make region diagnostics on by default for insiders
This commit is contained in:
Gabriela Araujo Britto 2024-06-19 15:12:57 -07:00 committed by GitHub
parent 7717059b2e
commit 878af0771b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 108 additions and 24 deletions

View file

@ -1315,6 +1315,12 @@
"markdownDescription": "%typescript.workspaceSymbols.excludeLibrarySymbols%", "markdownDescription": "%typescript.workspaceSymbols.excludeLibrarySymbols%",
"scope": "window" "scope": "window"
}, },
"typescript.tsserver.enableRegionDiagnostics": {
"type": "boolean",
"default": true,
"description": "%typescript.tsserver.enableRegionDiagnostics%",
"scope": "window"
},
"javascript.experimental.updateImportsOnPaste": { "javascript.experimental.updateImportsOnPaste": {
"scope": "window", "scope": "window",
"type": "boolean", "type": "boolean",

View file

@ -16,6 +16,7 @@
"typescript.tsserver.pluginPaths": "Additional paths to discover TypeScript Language Service plugins.", "typescript.tsserver.pluginPaths": "Additional paths to discover TypeScript Language Service plugins.",
"typescript.tsserver.pluginPaths.item": "Either an absolute or relative path. Relative path will be resolved against workspace folder(s).", "typescript.tsserver.pluginPaths.item": "Either an absolute or relative path. Relative path will be resolved against workspace folder(s).",
"typescript.tsserver.trace": "Enables tracing of messages sent to the TS server. This trace can be used to diagnose TS Server issues. The trace may contain file paths, source code, and other potentially sensitive information from your project.", "typescript.tsserver.trace": "Enables tracing of messages sent to the TS server. This trace can be used to diagnose TS Server issues. The trace may contain file paths, source code, and other potentially sensitive information from your project.",
"typescript.tsserver.enableRegionDiagnostics": "Enables region-based diagnostics in TypeScript. Requires using TypeScript 5.6+ in the workspace.",
"typescript.validate.enable": "Enable/disable TypeScript validation.", "typescript.validate.enable": "Enable/disable TypeScript validation.",
"typescript.format.enable": "Enable/disable default TypeScript formatter.", "typescript.format.enable": "Enable/disable default TypeScript formatter.",
"javascript.format.enable": "Enable/disable default JavaScript formatter.", "javascript.format.enable": "Enable/disable default JavaScript formatter.",

View file

@ -124,6 +124,7 @@ export interface TypeScriptServiceConfiguration {
readonly localNodePath: string | null; readonly localNodePath: string | null;
readonly globalNodePath: string | null; readonly globalNodePath: string | null;
readonly workspaceSymbolsExcludeLibrarySymbols: boolean; readonly workspaceSymbolsExcludeLibrarySymbols: boolean;
readonly enableRegionDiagnostics: boolean;
} }
export function areServiceConfigurationsEqual(a: TypeScriptServiceConfiguration, b: TypeScriptServiceConfiguration): boolean { export function areServiceConfigurationsEqual(a: TypeScriptServiceConfiguration, b: TypeScriptServiceConfiguration): boolean {
@ -162,6 +163,7 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu
localNodePath: this.readLocalNodePath(configuration), localNodePath: this.readLocalNodePath(configuration),
globalNodePath: this.readGlobalNodePath(configuration), globalNodePath: this.readGlobalNodePath(configuration),
workspaceSymbolsExcludeLibrarySymbols: this.readWorkspaceSymbolsExcludeLibrarySymbols(configuration), workspaceSymbolsExcludeLibrarySymbols: this.readWorkspaceSymbolsExcludeLibrarySymbols(configuration),
enableRegionDiagnostics: this.readEnableRegionDiagnostics(configuration),
}; };
} }
@ -267,4 +269,8 @@ export abstract class BaseServiceConfigurationProvider implements ServiceConfigu
private readWebTypeAcquisition(configuration: vscode.WorkspaceConfiguration): boolean { private readWebTypeAcquisition(configuration: vscode.WorkspaceConfiguration): boolean {
return configuration.get<boolean>('typescript.tsserver.web.typeAcquisition.enabled', false); return configuration.get<boolean>('typescript.tsserver.web.typeAcquisition.enabled', false);
} }
private readEnableRegionDiagnostics(configuration: vscode.WorkspaceConfiguration): boolean {
return configuration.get<boolean>('typescript.tsserver.enableRegionDiagnostics', true);
}
} }

View file

@ -34,6 +34,7 @@ export const enum DiagnosticKind {
Syntax, Syntax,
Semantic, Semantic,
Suggestion, Suggestion,
RegionSemantic,
} }
class FileDiagnostics { class FileDiagnostics {
@ -48,7 +49,8 @@ class FileDiagnostics {
public updateDiagnostics( public updateDiagnostics(
language: DiagnosticLanguage, language: DiagnosticLanguage,
kind: DiagnosticKind, kind: DiagnosticKind,
diagnostics: ReadonlyArray<vscode.Diagnostic> diagnostics: ReadonlyArray<vscode.Diagnostic>,
ranges: ReadonlyArray<vscode.Range> | undefined
): boolean { ): boolean {
if (language !== this.language) { if (language !== this.language) {
this._diagnostics.clear(); this._diagnostics.clear();
@ -61,6 +63,9 @@ class FileDiagnostics {
return false; return false;
} }
if (kind === DiagnosticKind.RegionSemantic) {
return this.updateRegionDiagnostics(diagnostics, ranges!);
}
this._diagnostics.set(kind, diagnostics); this._diagnostics.set(kind, diagnostics);
return true; return true;
} }
@ -83,6 +88,23 @@ class FileDiagnostics {
} }
} }
/**
* @param ranges The ranges whose diagnostics were updated.
*/
private updateRegionDiagnostics(
diagnostics: ReadonlyArray<vscode.Diagnostic>,
ranges: ReadonlyArray<vscode.Range>): boolean {
if (!this._diagnostics.get(DiagnosticKind.Semantic)) {
this._diagnostics.set(DiagnosticKind.Semantic, diagnostics);
return true;
}
const oldDiagnostics = this._diagnostics.get(DiagnosticKind.Semantic)!;
const newDiagnostics = oldDiagnostics.filter(diag => !ranges.some(range => diag.range.intersection(range)));
newDiagnostics.push(...diagnostics);
this._diagnostics.set(DiagnosticKind.Semantic, newDiagnostics);
return true;
}
private getSuggestionDiagnostics(settings: DiagnosticSettings) { private getSuggestionDiagnostics(settings: DiagnosticSettings) {
const enableSuggestions = settings.getEnableSuggestions(this.language); const enableSuggestions = settings.getEnableSuggestions(this.language);
return this.get(DiagnosticKind.Suggestion).filter(x => { return this.get(DiagnosticKind.Suggestion).filter(x => {
@ -284,15 +306,16 @@ export class DiagnosticsManager extends Disposable {
file: vscode.Uri, file: vscode.Uri,
language: DiagnosticLanguage, language: DiagnosticLanguage,
kind: DiagnosticKind, kind: DiagnosticKind,
diagnostics: ReadonlyArray<vscode.Diagnostic> diagnostics: ReadonlyArray<vscode.Diagnostic>,
ranges: ReadonlyArray<vscode.Range> | undefined,
): void { ): void {
let didUpdate = false; let didUpdate = false;
const entry = this._diagnostics.get(file); const entry = this._diagnostics.get(file);
if (entry) { if (entry) {
didUpdate = entry.updateDiagnostics(language, kind, diagnostics); didUpdate = entry.updateDiagnostics(language, kind, diagnostics, ranges);
} else if (diagnostics.length) { } else if (diagnostics.length) {
const fileDiagnostics = new FileDiagnostics(file, language); const fileDiagnostics = new FileDiagnostics(file, language);
fileDiagnostics.updateDiagnostics(language, kind, diagnostics); fileDiagnostics.updateDiagnostics(language, kind, diagnostics, ranges);
this._diagnostics.set(file, fileDiagnostics); this._diagnostics.set(file, fileDiagnostics);
didUpdate = true; didUpdate = true;
} }

View file

@ -138,7 +138,11 @@ export default class LanguageProvider extends Disposable {
this.client.bufferSyncSupport.requestAllDiagnostics(); this.client.bufferSyncSupport.requestAllDiagnostics();
} }
public diagnosticsReceived(diagnosticsKind: DiagnosticKind, file: vscode.Uri, diagnostics: (vscode.Diagnostic & { reportUnnecessary: any; reportDeprecated: any })[]): void { public diagnosticsReceived(
diagnosticsKind: DiagnosticKind,
file: vscode.Uri,
diagnostics: (vscode.Diagnostic & { reportUnnecessary: any; reportDeprecated: any })[],
ranges: vscode.Range[] | undefined): void {
if (diagnosticsKind !== DiagnosticKind.Syntax && !this.client.hasCapabilityForResource(file, ClientCapability.Semantic)) { if (diagnosticsKind !== DiagnosticKind.Syntax && !this.client.hasCapabilityForResource(file, ClientCapability.Semantic)) {
return; return;
} }
@ -175,7 +179,7 @@ export default class LanguageProvider extends Disposable {
} }
} }
return true; return true;
})); }), ranges);
} }
public configFileDiagnosticsReceived(file: vscode.Uri, diagnostics: vscode.Diagnostic[]): void { public configFileDiagnosticsReceived(file: vscode.Uri, diagnostics: vscode.Diagnostic[]): void {

View file

@ -275,12 +275,12 @@ class SyncedBufferMap extends ResourceMap<SyncedBuffer> {
} }
class PendingDiagnostics extends ResourceMap<number> { class PendingDiagnostics extends ResourceMap<number> {
public getOrderedFileSet(): ResourceMap<void> { public getOrderedFileSet(): ResourceMap<void | vscode.Range[]> {
const orderedResources = Array.from(this.entries()) const orderedResources = Array.from(this.entries())
.sort((a, b) => a.value - b.value) .sort((a, b) => a.value - b.value)
.map(entry => entry.resource); .map(entry => entry.resource);
const map = new ResourceMap<void>(this._normalizePath, this.config); const map = new ResourceMap<void | vscode.Range[]>(this._normalizePath, this.config);
for (const resource of orderedResources) { for (const resource of orderedResources) {
map.set(resource, undefined); map.set(resource, undefined);
} }
@ -292,7 +292,7 @@ class GetErrRequest {
public static executeGetErrRequest( public static executeGetErrRequest(
client: ITypeScriptServiceClient, client: ITypeScriptServiceClient,
files: ResourceMap<void>, files: ResourceMap<void | vscode.Range[]>,
onDone: () => void onDone: () => void
) { ) {
return new GetErrRequest(client, files, onDone); return new GetErrRequest(client, files, onDone);
@ -303,7 +303,7 @@ class GetErrRequest {
private constructor( private constructor(
private readonly client: ITypeScriptServiceClient, private readonly client: ITypeScriptServiceClient,
public readonly files: ResourceMap<void>, public readonly files: ResourceMap<void | vscode.Range[]>,
onDone: () => void onDone: () => void
) { ) {
if (!this.isErrorReportingEnabled()) { if (!this.isErrorReportingEnabled()) {
@ -313,19 +313,39 @@ class GetErrRequest {
} }
const supportsSyntaxGetErr = this.client.apiVersion.gte(API.v440); const supportsSyntaxGetErr = this.client.apiVersion.gte(API.v440);
const allFiles = coalesce(Array.from(files.entries()) const fileEntries = Array.from(files.entries()).filter(entry => supportsSyntaxGetErr || client.hasCapabilityForResource(entry.resource, ClientCapability.Semantic));
.filter(entry => supportsSyntaxGetErr || client.hasCapabilityForResource(entry.resource, ClientCapability.Semantic)) const allFiles = coalesce(fileEntries
.map(entry => client.toTsFilePath(entry.resource))); .map(entry => client.toTsFilePath(entry.resource)));
if (!allFiles.length) { if (!allFiles.length) {
this._done = true; this._done = true;
setImmediate(onDone); setImmediate(onDone);
} else { } else {
const request = this.areProjectDiagnosticsEnabled() let request;
if (this.areProjectDiagnosticsEnabled()) {
// Note that geterrForProject is almost certainly not the api we want here as it ends up computing far // Note that geterrForProject is almost certainly not the api we want here as it ends up computing far
// too many diagnostics // too many diagnostics
? client.executeAsync('geterrForProject', { delay: 0, file: allFiles[0] }, this._token.token) request = client.executeAsync('geterrForProject', { delay: 0, file: allFiles[0] }, this._token.token);
: client.executeAsync('geterr', { delay: 0, files: allFiles }, this._token.token); }
else {
let requestFiles;
if (this.areRegionDiagnosticsEnabled()) {
requestFiles = coalesce(fileEntries
.map(entry => {
const file = client.toTsFilePath(entry.resource);
const ranges = entry.value;
if (file && ranges) {
return typeConverters.Range.toFileRangesRequestArgs(file, ranges);
}
return file;
}));
}
else {
requestFiles = allFiles;
}
request = client.executeAsync('geterr', { delay: 0, files: requestFiles }, this._token.token);
}
request.finally(() => { request.finally(() => {
if (this._done) { if (this._done) {
@ -350,6 +370,10 @@ class GetErrRequest {
return this.client.configuration.enableProjectDiagnostics && this.client.capabilities.has(ClientCapability.Semantic); return this.client.configuration.enableProjectDiagnostics && this.client.capabilities.has(ClientCapability.Semantic);
} }
private areRegionDiagnosticsEnabled() {
return this.client.configuration.enableRegionDiagnostics && this.client.apiVersion.gte(API.v560);
}
public cancel(): any { public cancel(): any {
if (!this._done) { if (!this._done) {
this._token.cancel(); this._token.cancel();
@ -722,7 +746,9 @@ export default class BufferSyncSupport extends Disposable {
// Add all open TS buffers to the geterr request. They might be visible // Add all open TS buffers to the geterr request. They might be visible
for (const buffer of this.syncedBuffers.values()) { for (const buffer of this.syncedBuffers.values()) {
orderedFileSet.set(buffer.resource, undefined); const editors = vscode.window.visibleTextEditors.filter(editor => editor.document.uri.toString() === buffer.resource.toString());
const visibleRanges = editors.flatMap(editor => editor.visibleRanges);
orderedFileSet.set(buffer.resource, visibleRanges.length ? visibleRanges : undefined);
} }
for (const { resource } of orderedFileSet.entries()) { for (const { resource } of orderedFileSet.entries()) {

View file

@ -78,6 +78,7 @@ export enum EventName {
syntaxDiag = 'syntaxDiag', syntaxDiag = 'syntaxDiag',
semanticDiag = 'semanticDiag', semanticDiag = 'semanticDiag',
suggestionDiag = 'suggestionDiag', suggestionDiag = 'suggestionDiag',
regionSemanticDiag = 'regionSemanticDiag',
configFileDiag = 'configFileDiag', configFileDiag = 'configFileDiag',
telemetry = 'telemetry', telemetry = 'telemetry',
projectLanguageServiceState = 'projectLanguageServiceState', projectLanguageServiceState = 'projectLanguageServiceState',

View file

@ -26,14 +26,24 @@ export namespace Range {
Math.max(0, start.line - 1), Math.max(start.offset - 1, 0), Math.max(0, start.line - 1), Math.max(start.offset - 1, 0),
Math.max(0, end.line - 1), Math.max(0, end.offset - 1)); Math.max(0, end.line - 1), Math.max(0, end.offset - 1));
export const toFileRangeRequestArgs = (file: string, range: vscode.Range): Proto.FileRangeRequestArgs => ({ // @ts-expect-error until ts 5.6
file, export const toFileRange = (range: vscode.Range): Proto.FileRange => ({
startLine: range.start.line + 1, startLine: range.start.line + 1,
startOffset: range.start.character + 1, startOffset: range.start.character + 1,
endLine: range.end.line + 1, endLine: range.end.line + 1,
endOffset: range.end.character + 1 endOffset: range.end.character + 1
}); });
export const toFileRangeRequestArgs = (file: string, range: vscode.Range): Proto.FileRangeRequestArgs => ({
file,
...toFileRange(range)
});
// @ts-expect-error until ts 5.6
export const toFileRangesRequestArgs = (file: string, ranges: vscode.Range[]): Proto.FileRangesRequestArgs => ({
file,
ranges: ranges.map(toFileRange)
});
export const toFormattingRequestArgs = (file: string, range: vscode.Range): Proto.FormatRequestArgs => ({ export const toFormattingRequestArgs = (file: string, range: vscode.Range): Proto.FormatRequestArgs => ({
file, file,
line: range.start.line + 1, line: range.start.line + 1,

View file

@ -90,8 +90,8 @@ export default class TypeScriptServiceClientHost extends Disposable {
services, services,
allModeIds)); allModeIds));
this.client.onDiagnosticsReceived(({ kind, resource, diagnostics }) => { this.client.onDiagnosticsReceived(({ kind, resource, diagnostics, spans }) => {
this.diagnosticsReceived(kind, resource, diagnostics); this.diagnosticsReceived(kind, resource, diagnostics, spans);
}, null, this._disposables); }, null, this._disposables);
this.client.onConfigDiagnosticsReceived(diag => this.configFileDiagnosticsReceived(diag), null, this._disposables); this.client.onConfigDiagnosticsReceived(diag => this.configFileDiagnosticsReceived(diag), null, this._disposables);
@ -236,14 +236,16 @@ export default class TypeScriptServiceClientHost extends Disposable {
private async diagnosticsReceived( private async diagnosticsReceived(
kind: DiagnosticKind, kind: DiagnosticKind,
resource: vscode.Uri, resource: vscode.Uri,
diagnostics: Proto.Diagnostic[] diagnostics: Proto.Diagnostic[],
spans: Proto.TextSpan[] | undefined,
): Promise<void> { ): Promise<void> {
const language = await this.findLanguage(resource); const language = await this.findLanguage(resource);
if (language) { if (language) {
language.diagnosticsReceived( language.diagnosticsReceived(
kind, kind,
resource, resource,
this.createMarkerDatas(diagnostics, language.diagnosticSource)); this.createMarkerDatas(diagnostics, language.diagnosticSource),
spans?.map(span => typeConverters.Range.fromTextSpan(span)));
} }
} }

View file

@ -37,6 +37,7 @@ export interface TsDiagnostics {
readonly kind: DiagnosticKind; readonly kind: DiagnosticKind;
readonly resource: vscode.Uri; readonly resource: vscode.Uri;
readonly diagnostics: Proto.Diagnostic[]; readonly diagnostics: Proto.Diagnostic[];
readonly spans?: Proto.TextSpan[];
} }
interface ToCancelOnResourceChanged { interface ToCancelOnResourceChanged {
@ -947,7 +948,8 @@ export default class TypeScriptServiceClient extends Disposable implements IType
switch (event.event) { switch (event.event) {
case EventName.syntaxDiag: case EventName.syntaxDiag:
case EventName.semanticDiag: case EventName.semanticDiag:
case EventName.suggestionDiag: { case EventName.suggestionDiag:
case EventName.regionSemanticDiag: {
// This event also roughly signals that projects have been loaded successfully (since the TS server is synchronous) // This event also roughly signals that projects have been loaded successfully (since the TS server is synchronous)
this.loadingIndicator.reset(); this.loadingIndicator.reset();
@ -956,7 +958,9 @@ export default class TypeScriptServiceClient extends Disposable implements IType
this._onDiagnosticsReceived.fire({ this._onDiagnosticsReceived.fire({
kind: getDiagnosticsKind(event), kind: getDiagnosticsKind(event),
resource: this.toResource(diagnosticEvent.body.file), resource: this.toResource(diagnosticEvent.body.file),
diagnostics: diagnosticEvent.body.diagnostics diagnostics: diagnosticEvent.body.diagnostics,
// @ts-expect-error until ts 5.6
spans: diagnosticEvent.body.spans,
}); });
} }
break; break;
@ -1261,6 +1265,7 @@ function getDiagnosticsKind(event: Proto.Event) {
case 'syntaxDiag': return DiagnosticKind.Syntax; case 'syntaxDiag': return DiagnosticKind.Syntax;
case 'semanticDiag': return DiagnosticKind.Semantic; case 'semanticDiag': return DiagnosticKind.Semantic;
case 'suggestionDiag': return DiagnosticKind.Suggestion; case 'suggestionDiag': return DiagnosticKind.Suggestion;
case 'regionSemanticDiag': return DiagnosticKind.RegionSemantic;
} }
throw new Error('Unknown dignostics kind'); throw new Error('Unknown dignostics kind');
} }