Skip to content

Commit

Permalink
feat(server): support code action
Browse files Browse the repository at this point in the history
In ts service, the
`codeFixes`([getCodeFixesAtPosition](https://github.com/microsoft/TypeScript/blob/7584e6aad6b21f7334562bfd9d8c3c80aafed064/src/services/services.ts#L2689))
and
refactors([getApplicableRefactors](https://github.com/microsoft/TypeScript/blob/7584e6aad6b21f7334562bfd9d8c3c80aafed064/src/services/services.ts#L2699))
are resolved in different request. But in LSP they are resolved in the `CodeAction` request.
Now, this PR only handles the `codeFixes` because the `@angular/language-service` only supports it.
  • Loading branch information
ivanwonder committed Jul 23, 2022
1 parent 7e91830 commit d32ae3b
Show file tree
Hide file tree
Showing 6 changed files with 239 additions and 7 deletions.
116 changes: 116 additions & 0 deletions integration/lsp/ivy_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,122 @@ describe('insert snippet text', () => {
});
});

describe('code fixes', () => {
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; /* 10 seconds */

let client: MessageConnection;
beforeEach(async () => {
client = createConnection({
ivy: true,
includeCompletionsWithSnippetText: true,
});
// If debugging, set to
// - lsp.Trace.Messages to inspect request/response/notification, or
// - lsp.Trace.Verbose to inspect payload
client.trace(lsp.Trace.Off, createTracer());
client.listen();
await initializeServer(client);
});

afterEach(() => {
client.dispose();
});

it('should fix error when property does not exist on type', async () => {
openTextDocument(client, FOO_TEMPLATE, `{{titl}}`);
const languageServiceEnabled = await waitForNgcc(client);
expect(languageServiceEnabled).toBeTrue();
const diags = await getDiagnosticsForFile(client, FOO_TEMPLATE);
const codeActions = await client.sendRequest(lsp.CodeActionRequest.type, {
textDocument: {
uri: FOO_TEMPLATE_URI,
},
range: lsp.Range.create(lsp.Position.create(0, 3), lsp.Position.create(0, 3)),
context: lsp.CodeActionContext.create(diags),
}) as lsp.CodeAction[];
const expectedCodeActionInTemplate = {
'edit': {
'changes': {
[FOO_TEMPLATE_URI]: [{
'newText': 'title',
'range': {'start': {'line': 0, 'character': 2}, 'end': {'line': 0, 'character': 6}}
}]
}
}
};
expect(codeActions).toContain(jasmine.objectContaining(expectedCodeActionInTemplate));
});

it('should fix all errors when property does not exist on type', async () => {
openTextDocument(client, FOO_COMPONENT, `
import {Component, NgModule} from '@angular/core';
@Component({
template: '{{tite}}{{bannr}}',
})
export class AppComponent {
title = '';
banner = '';
}
`);
const languageServiceEnabled = await waitForNgcc(client);
expect(languageServiceEnabled).toBeTrue();

const fixSpellingCodeAction = await client.sendRequest(lsp.CodeActionResolveRequest.type, {
title: '',
data: {
fixId: 'fixSpelling',
document: lsp.TextDocumentIdentifier.create(FOO_COMPONENT_URI),
},
});
const expectedFixSpellingInTemplate = {
'edit': {
'changes': {
[FOO_COMPONENT_URI]: [
{
'newText': 'title',
'range': {
'start': {'line': 3, 'character': 21},
'end': {'line': 3, 'character': 25},
},
},
{
'newText': 'banner',
'range': {'start': {'line': 3, 'character': 29}, 'end': {'line': 3, 'character': 34}}
}
]
}
}
};
expect(fixSpellingCodeAction).toEqual(jasmine.objectContaining(expectedFixSpellingInTemplate));

const fixMissingMemberCodeAction = await client.sendRequest(lsp.CodeActionResolveRequest.type, {
title: '',
data: {
fixId: 'fixMissingMember',
document: lsp.TextDocumentIdentifier.create(FOO_COMPONENT_URI),
},
});
const expectedFixMissingMemberInComponent = {
'edit': {
'changes': {
[FOO_COMPONENT_URI]: [
{
'newText': 'tite: any;\n',
'range': {'start': {'line': 8, 'character': 0}, 'end': {'line': 8, 'character': 0}}
},
{
'newText': 'bannr: any;\n',
'range': {'start': {'line': 8, 'character': 0}, 'end': {'line': 8, 'character': 0}}
}
]
}
}
};
expect(fixMissingMemberCodeAction)
.toEqual(jasmine.objectContaining(expectedFixMissingMemberInComponent));
});
});

function onNgccProgress(client: MessageConnection): Promise<string> {
return new Promise(resolve => {
client.onNotification(NgccProgressEnd, (params) => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@
"test:syntaxes": "yarn compile:syntaxes-test && yarn build:syntaxes && jasmine dist/syntaxes/test/driver.js"
},
"dependencies": {
"@angular/language-service": "14.1.0-next.0",
"@angular/language-service": "https://output.circle-artifacts.com/output/job/92620949-f999-4d6c-8d8e-9ee22083dc7d/artifacts/0/angular/language-service-pr46764-04c55d1bf3.tgz",
"typescript": "4.7.4",
"vscode-jsonrpc": "6.0.0",
"vscode-languageclient": "7.0.0",
Expand Down
2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"ngserver": "./bin/ngserver"
},
"dependencies": {
"@angular/language-service": "14.0.0-next.0",
"@angular/language-service": "https://output.circle-artifacts.com/output/job/92620949-f999-4d6c-8d8e-9ee22083dc7d/artifacts/0/angular/language-service-pr46764-04c55d1bf3.tgz",
"vscode-jsonrpc": "6.0.0",
"vscode-languageserver": "7.0.0",
"vscode-uri": "3.0.3"
Expand Down
90 changes: 89 additions & 1 deletion server/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {readNgCompletionData, tsCompletionEntryToLspCompletionItem} from './comp
import {tsDiagnosticToLspDiagnostic} from './diagnostic';
import {resolveAndRunNgcc} from './ngcc';
import {ServerHost} from './server_host';
import {filePathToUri, getMappedDefinitionInfo, isConfiguredProject, isDebugMode, lspPositionToTsPosition, lspRangeToTsPositions, MruTracker, tsDisplayPartsToText, tsTextSpanToLspRange, uriToFilePath} from './utils';
import {filePathToUri, getMappedDefinitionInfo, isConfiguredProject, isDebugMode, lspPositionToTsPosition, lspRangeToTsPositions, MruTracker, tsDisplayPartsToText, tsFileTextChangesToLspWorkspaceEdit, tsTextSpanToLspRange, uriToFilePath} from './utils';

export interface SessionOptions {
host: ServerHost;
Expand Down Expand Up @@ -197,6 +197,59 @@ export class Session {
conn.onCodeLens(p => this.onCodeLens(p));
conn.onCodeLensResolve(p => this.onCodeLensResolve(p));
conn.onSignatureHelp(p => this.onSignatureHelp(p));
conn.onCodeAction(p => this.codeAction(p));
conn.onCodeActionResolve(p => this.onCodeActionResolve(p));
}

private codeAction(params: lsp.CodeActionParams): lsp.CodeAction[]|null {
const filePath = uriToFilePath(params.textDocument.uri);
const lsInfo = this.getLSAndScriptInfo(params.textDocument);
if (!lsInfo) {
return null;
}
const start = lspPositionToTsPosition(lsInfo.scriptInfo, params.range.start);
const end = lspPositionToTsPosition(lsInfo.scriptInfo, params.range.end);
const errorCodes = params.context.diagnostics.map(diag => diag.code)
.filter((code): code is number => typeof code === 'number');

const codeActions =
lsInfo.languageService.getCodeFixesAtPosition(filePath, start, end, errorCodes, {}, {});
return codeActions
.map<lsp.CodeAction>(codeAction => {
return {
title: codeAction.description,
kind: lsp.CodeActionKind.QuickFix,
diagnostics: params.context.diagnostics,
edit: tsFileTextChangesToLspWorkspaceEdit(
codeAction.changes, (path: string) => this.projectService.getScriptInfo(path)),
};
})
.concat(getCodeFixesAll(codeActions, params.textDocument));
}

private onCodeActionResolve(param: lsp.CodeAction): lsp.CodeAction {
const codeActionResolve = param.data as unknown as CodeActionResolveData;
const isCodeFixesAll = codeActionResolve.fixId !== undefined;
if (!isCodeFixesAll) {
return param;
}
const filePath = uriToFilePath(codeActionResolve.document.uri);
const lsInfo = this.getLSAndScriptInfo(codeActionResolve.document);
if (!lsInfo) {
return param;
}
const fixesAllChanges = lsInfo.languageService.getCombinedCodeFix(
{
type: 'file',
fileName: filePath,
},
codeActionResolve.fixId as {}, {}, {});

return {
title: param.title,
edit: tsFileTextChangesToLspWorkspaceEdit(
fixesAllChanges.changes, (path) => this.projectService.getScriptInfo(path)),
};
}

private isInAngularProject(params: IsInAngularProjectParams): boolean|null {
Expand Down Expand Up @@ -663,6 +716,10 @@ export class Session {
workspace: {
workspaceFolders: {supported: true},
},
codeActionProvider: this.ivy ? {
resolveProvider: true,
} :
undefined,
},
serverOptions,
};
Expand Down Expand Up @@ -1239,3 +1296,34 @@ function isTypeScriptFile(path: string): boolean {
function isExternalTemplate(path: string): boolean {
return !isTypeScriptFile(path);
}

interface CodeActionResolveData {
fixId?: string;
document: lsp.TextDocumentIdentifier;
}

/**
* Extract the fixAll action from `codeActions`
*/
function getCodeFixesAll(
codeActions: readonly ts.CodeFixAction[],
document: lsp.TextDocumentIdentifier): lsp.CodeAction[] {
const seenFixId = new Set<string>();
const lspCodeActions: lsp.CodeAction[] = [];
for (const codeAction of codeActions) {
const fixId = codeAction.fixId as string | undefined;
if (fixId === undefined || codeAction.fixAllDescription === undefined || seenFixId.has(fixId)) {
continue;
}
seenFixId.add(fixId);
const codeActionResolve: CodeActionResolveData = {
fixId,
document,
};
lspCodeActions.push({
title: codeAction.fixAllDescription,
data: codeActionResolve,
});
}
return lspCodeActions;
}
29 changes: 29 additions & 0 deletions server/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,35 @@ export function filePathToUri(filePath: string): lsp.DocumentUri {
return URI.file(filePath).toString();
}

/**
* Converts ts.FileTextChanges to lsp.WorkspaceEdit.
*/
export function tsFileTextChangesToLspWorkspaceEdit(
changes: readonly ts.FileTextChanges[],
getScriptInfo: (path: string) => ts.server.ScriptInfo | undefined): lsp.WorkspaceEdit {
const workspaceChanges: {[uri: string]: lsp.TextEdit[]} = {};
for (const change of changes) {
const scriptInfo = getScriptInfo(change.fileName);
const uri = filePathToUri(change.fileName);
if (scriptInfo === undefined) {
continue;
}
if (!workspaceChanges[uri]) {
workspaceChanges[uri] = [];
}
for (const textChange of change.textChanges) {
const textEdit: lsp.TextEdit = {
newText: textChange.newText,
range: tsTextSpanToLspRange(scriptInfo, textChange.span),
};
workspaceChanges[uri].push(textEdit);
}
}
return {
changes: workspaceChanges,
};
}

/**
* Convert ts.TextSpan to lsp.TextSpan. TypeScript keeps track of offset using
* 1-based index whereas LSP uses 0-based index.
Expand Down
7 changes: 3 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,9 @@
uuid "^8.3.2"
yargs "^17.0.0"

"@angular/language-service@14.1.0-next.0":
version "14.1.0-next.0"
resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-14.1.0-next.0.tgz#2cc7c30a7fe641ee2d255f03ef23028e8e574ab5"
integrity sha512-tMsrL/Ug35hnH14BjpLYdjy44F3Tzrfqem38vxWUyATwV1YLiqJrvzsUhvzMffyBAkMMgdfykrb50wyguUg/fQ==
"@angular/language-service@https://output.circle-artifacts.com/output/job/92620949-f999-4d6c-8d8e-9ee22083dc7d/artifacts/0/angular/language-service-pr46764-04c55d1bf3.tgz":
version "14.2.0-next.0"
resolved "https://output.circle-artifacts.com/output/job/92620949-f999-4d6c-8d8e-9ee22083dc7d/artifacts/0/angular/language-service-pr46764-04c55d1bf3.tgz#3721d554e96949c847a9f3230bb0db8d195b5fc7"

"@assemblyscript/loader@^0.10.1":
version "0.10.1"
Expand Down

0 comments on commit d32ae3b

Please sign in to comment.