Skip to content

refactor: port ITypeScriptServiceClient #782

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Nov 1, 2023
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
Loading