Skip to content

Commit

Permalink
refactor: port ITypeScriptServiceClient (#782)
Browse files Browse the repository at this point in the history
  • Loading branch information
rchl committed Nov 1, 2023
1 parent 0df87c2 commit ab22e52
Show file tree
Hide file tree
Showing 39 changed files with 1,968 additions and 1,126 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
jobs:
tests:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
node-version: [18.x, 20.x]
Expand Down
6 changes: 5 additions & 1 deletion rollup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export default defineConfig({
format: 'es',
generatedCode: 'es2015',
plugins: [
terser(),
terser({
compress: false,
mangle: false,
format: { beautify: true, quote_style: 1, indent_level: 2 },
}),
],
sourcemap: true,
},
Expand Down
71 changes: 35 additions & 36 deletions src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@

import * as lsp from 'vscode-languageserver';
import { LspDocument } from './document.js';
import { toTextEdit, normalizePath } from './protocol-translation.js';
import { toTextEdit } from './protocol-translation.js';
import { Commands } from './commands.js';
import { TspClient } from './tsp-client.js';
import { type WorkspaceConfigurationCompletionOptions } from './features/fileConfigurationManager.js';
import { TsClient } from './ts-client.js';
import { CommandTypes, KindModifiers, ScriptElementKind, SupportedFeatures, SymbolDisplayPartKind, toSymbolDisplayPartKind } from './ts-protocol.js';
import type { ts } from './ts-protocol.js';
import * as Previewer from './utils/previewer.js';
import { IFilePathToResourceConverter } from './utils/previewer.js';
import SnippetString from './utils/SnippetString.js';
import { Range, Position } from './utils/typeConverters.js';
import type { WorkspaceConfigurationCompletionOptions } from './configuration-manager.js';

interface ParameterListParts {
readonly parts: ReadonlyArray<ts.server.protocol.SymbolDisplayPart>;
Expand Down Expand Up @@ -350,25 +350,25 @@ function asCommitCharacters(kind: ScriptElementKind): string[] | undefined {
export async function asResolvedCompletionItem(
item: lsp.CompletionItem,
details: ts.server.protocol.CompletionEntryDetails,
document: LspDocument | undefined,
client: TspClient,
filePathConverter: IFilePathToResourceConverter,
document: LspDocument,
client: TsClient,
options: WorkspaceConfigurationCompletionOptions,
features: SupportedFeatures,
): Promise<lsp.CompletionItem> {
item.detail = asDetail(details, filePathConverter);
item.detail = asDetail(details, client);
const { documentation, tags } = details;
item.documentation = Previewer.markdownDocumentation(documentation, tags, filePathConverter);
const filepath = normalizePath(item.data.file);
item.documentation = Previewer.markdownDocumentation(documentation, tags, client);

if (details.codeActions?.length) {
item.additionalTextEdits = asAdditionalTextEdits(details.codeActions, filepath);
item.command = asCommand(details.codeActions, item.data.file);
const { additionalTextEdits, command } = getCodeActions(details.codeActions, document.filepath, client);
item.additionalTextEdits = additionalTextEdits;
item.command = command;
}

if (document && features.completionSnippets && canCreateSnippetOfFunctionCall(item.kind, options)) {
const { line, offset } = item.data;
const position = Position.fromLocation({ line, offset });
const shouldCompleteFunction = await isValidFunctionCompletionContext(filepath, position, client, document);
const shouldCompleteFunction = await isValidFunctionCompletionContext(position, client, document);
if (shouldCompleteFunction) {
createSnippetOfFunctionCall(item, details);
}
Expand All @@ -377,12 +377,12 @@ export async function asResolvedCompletionItem(
return item;
}

async function isValidFunctionCompletionContext(filepath: string, position: lsp.Position, client: TspClient, document: LspDocument): Promise<boolean> {
async function isValidFunctionCompletionContext(position: lsp.Position, client: TsClient, document: LspDocument): Promise<boolean> {
// Workaround for https://github.com/Microsoft/TypeScript/issues/12677
// Don't complete function calls inside of destructive assigments or imports
try {
const args: ts.server.protocol.FileLocationRequestArgs = Position.toFileLocationRequestArgs(filepath, position);
const response = await client.request(CommandTypes.Quickinfo, args);
const args: ts.server.protocol.FileLocationRequestArgs = Position.toFileLocationRequestArgs(document.filepath, position);
const response = await client.execute(CommandTypes.Quickinfo, args);
if (response.type === 'response' && response.body) {
switch (response.body.kind) {
case 'var':
Expand Down Expand Up @@ -491,45 +491,39 @@ function appendJoinedPlaceholders(snippet: SnippetString, parts: ReadonlyArray<t
}
}

function asAdditionalTextEdits(codeActions: ts.server.protocol.CodeAction[], filepath: string): lsp.TextEdit[] | undefined {
function getCodeActions(
codeActions: ts.server.protocol.CodeAction[],
filepath: string,
client: TsClient,
): {
additionalTextEdits: lsp.TextEdit[] | undefined;
command: lsp.Command | undefined;
} {
// Try to extract out the additionalTextEdits for the current file.
const additionalTextEdits: lsp.TextEdit[] = [];
for (const tsAction of codeActions) {
// Apply all edits in the current file using `additionalTextEdits`
if (tsAction.changes) {
for (const change of tsAction.changes) {
if (change.fileName === filepath) {
for (const textChange of change.textChanges) {
additionalTextEdits.push(toTextEdit(textChange));
}
}
}
}
}
return additionalTextEdits.length ? additionalTextEdits : undefined;
}

function asCommand(codeActions: ts.server.protocol.CodeAction[], filepath: string): lsp.Command | undefined {
let hasRemainingCommandsOrEdits = false;
for (const tsAction of codeActions) {
if (tsAction.commands) {
hasRemainingCommandsOrEdits = true;
break;
}

// Apply all edits in the current file using `additionalTextEdits`
if (tsAction.changes) {
for (const change of tsAction.changes) {
if (change.fileName !== filepath) {
const tsFileName = client.toResource(change.fileName).fsPath;
if (tsFileName === filepath) {
additionalTextEdits.push(...change.textChanges.map(toTextEdit));
} else {
hasRemainingCommandsOrEdits = true;
break;
}
}
}
}

let command: lsp.Command | undefined = undefined;
if (hasRemainingCommandsOrEdits) {
// Create command that applies all edits not in the current file.
return {
command = {
title: '',
command: Commands.APPLY_COMPLETION_CODE_ACTION,
arguments: [filepath, codeActions.map(codeAction => ({
Expand All @@ -539,6 +533,11 @@ function asCommand(codeActions: ts.server.protocol.CodeAction[], filepath: strin
}))],
};
}

return {
command,
additionalTextEdits: additionalTextEdits.length ? additionalTextEdits : undefined,
};
}

function asDetail(
Expand Down
34 changes: 34 additions & 0 deletions src/configuration/fileSchemes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Copyright (C) 2023 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

export const file = 'file';
export const untitled = 'untitled';
export const git = 'git';
export const github = 'github';
export const azurerepos = 'azurerepos';

/** Live share scheme */
export const vsls = 'vsls';
export const walkThroughSnippet = 'walkThroughSnippet';
export const vscodeNotebookCell = 'vscode-notebook-cell';
export const memFs = 'memfs';
export const vscodeVfs = 'vscode-vfs';
export const officeScript = 'office-script';

/**
* File scheme for which JS/TS language feature should be disabled
*/
export const disabledSchemes = new Set([
git,
vsls,
github,
azurerepos,
]);
33 changes: 33 additions & 0 deletions src/configuration/languageIds.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright (C) 2023 TypeFox and others.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
*/
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { type LspDocument } from '../document.js';

export const typescript = 'typescript';
export const typescriptreact = 'typescriptreact';
export const javascript = 'javascript';
export const javascriptreact = 'javascriptreact';
export const jsxTags = 'jsx-tags';

export const jsTsLanguageModes = [
javascript,
javascriptreact,
typescript,
typescriptreact,
];

export function isSupportedLanguageMode(doc: LspDocument): boolean {
return [typescript, typescriptreact, javascript, javascriptreact].includes(doc.languageId);
}

export function isTypeScriptDocument(doc: LspDocument): boolean {
return [typescript, typescriptreact].includes(doc.languageId);
}
76 changes: 60 additions & 16 deletions src/diagnostic-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,64 @@
import * as lsp from 'vscode-languageserver';
import debounce from 'p-debounce';
import { Logger } from './utils/logger.js';
import { pathToUri, toDiagnostic } from './protocol-translation.js';
import { toDiagnostic } from './protocol-translation.js';
import { SupportedFeatures } from './ts-protocol.js';
import type { ts } from './ts-protocol.js';
import { LspDocuments } from './document.js';
import { DiagnosticKind, TspClient } from './tsp-client.js';
import { DiagnosticKind, type TsClient } from './ts-client.js';
import { ClientCapability } from './typescriptService.js';

class FileDiagnostics {
private closed = false;
private readonly diagnosticsPerKind = new Map<DiagnosticKind, ts.server.protocol.Diagnostic[]>();
private readonly firePublishDiagnostics = debounce(() => this.publishDiagnostics(), 50);

constructor(
protected readonly uri: string,
protected readonly publishDiagnostics: (params: lsp.PublishDiagnosticsParams) => void,
protected readonly documents: LspDocuments,
protected readonly onPublishDiagnostics: (params: lsp.PublishDiagnosticsParams) => void,
protected readonly client: TsClient,
protected readonly features: SupportedFeatures,
) { }

update(kind: DiagnosticKind, diagnostics: ts.server.protocol.Diagnostic[]): void {
public update(kind: DiagnosticKind, diagnostics: ts.server.protocol.Diagnostic[]): void {
this.diagnosticsPerKind.set(kind, diagnostics);
this.firePublishDiagnostics();
}
protected readonly firePublishDiagnostics = debounce(() => {

private publishDiagnostics() {
if (this.closed || !this.features.diagnosticsSupport) {
return;
}
const diagnostics = this.getDiagnostics();
this.publishDiagnostics({ uri: this.uri, diagnostics });
}, 50);
this.onPublishDiagnostics({ uri: this.uri, diagnostics });
}

public getDiagnostics(): lsp.Diagnostic[] {
const result: lsp.Diagnostic[] = [];
for (const diagnostics of this.diagnosticsPerKind.values()) {
for (const diagnostic of diagnostics) {
result.push(toDiagnostic(diagnostic, this.documents, this.features));
result.push(toDiagnostic(diagnostic, this.client, this.features));
}
}
return result;
}

public onDidClose(): void {
this.publishDiagnostics();
this.diagnosticsPerKind.clear();
this.closed = true;
}

public async waitForDiagnosticsForTesting(): Promise<void> {
return new Promise(resolve => {
const interval = setInterval(() => {
if (this.diagnosticsPerKind.size === 3) { // Must include all types of `DiagnosticKind`.
clearInterval(interval);
this.publishDiagnostics();
resolve();
}
}, 50);
});
}
}

export class DiagnosticEventQueue {
Expand All @@ -51,22 +74,21 @@ export class DiagnosticEventQueue {

constructor(
protected readonly publishDiagnostics: (params: lsp.PublishDiagnosticsParams) => void,
protected readonly documents: LspDocuments,
protected readonly client: TsClient,
protected readonly features: SupportedFeatures,
protected readonly logger: Logger,
private readonly tspClient: TspClient,
) { }

updateDiagnostics(kind: DiagnosticKind, file: string, diagnostics: ts.server.protocol.Diagnostic[]): void {
if (kind !== DiagnosticKind.Syntax && !this.tspClient.hasCapabilityForResource(this.documents.toResource(file), ClientCapability.Semantic)) {
if (kind !== DiagnosticKind.Syntax && !this.client.hasCapabilityForResource(this.client.toResource(file), ClientCapability.Semantic)) {
return;
}

if (this.ignoredDiagnosticCodes.size) {
diagnostics = diagnostics.filter(diagnostic => !this.isDiagnosticIgnored(diagnostic));
}
const uri = pathToUri(file, this.documents);
const diagnosticsForFile = this.diagnostics.get(uri) || new FileDiagnostics(uri, this.publishDiagnostics, this.documents, this.features);
const uri = this.client.toResource(file).toString();
const diagnosticsForFile = this.diagnostics.get(uri) || new FileDiagnostics(uri, this.publishDiagnostics, this.client, this.features);
diagnosticsForFile.update(kind, diagnostics);
this.diagnostics.set(uri, diagnosticsForFile);
}
Expand All @@ -76,10 +98,32 @@ export class DiagnosticEventQueue {
}

public getDiagnosticsForFile(file: string): lsp.Diagnostic[] {
const uri = pathToUri(file, this.documents);
const uri = this.client.toResource(file).toString();
return this.diagnostics.get(uri)?.getDiagnostics() || [];
}

public onDidCloseFile(file: string): void {
const uri = this.client.toResource(file).toString();
const diagnosticsForFile = this.diagnostics.get(uri);
diagnosticsForFile?.onDidClose();
}

/**
* A testing function to clear existing file diagnostics, request fresh ones and wait for all to arrive.
*/
public async waitForDiagnosticsForTesting(file: string): Promise<void> {
const uri = this.client.toResource(file).toString();
let diagnosticsForFile = this.diagnostics.get(uri);
if (diagnosticsForFile) {
diagnosticsForFile.onDidClose();
}
diagnosticsForFile = new FileDiagnostics(uri, this.publishDiagnostics, this.client, this.features);
this.diagnostics.set(uri, diagnosticsForFile);
// Normally diagnostics are delayed by 300ms. This will trigger immediate request.
this.client.requestDiagnosticsForTesting();
await diagnosticsForFile.waitForDiagnosticsForTesting();
}

private isDiagnosticIgnored(diagnostic: ts.server.protocol.Diagnostic) : boolean {
return diagnostic.code !== undefined && this.ignoredDiagnosticCodes.has(diagnostic.code);
}
Expand Down

0 comments on commit ab22e52

Please sign in to comment.