Skip to content

Commit d32ae3b

Browse files
committed
feat(server): support code action
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.
1 parent 7e91830 commit d32ae3b

File tree

6 files changed

+239
-7
lines changed

6 files changed

+239
-7
lines changed

integration/lsp/ivy_spec.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,122 @@ describe('insert snippet text', () => {
647647
});
648648
});
649649

650+
describe('code fixes', () => {
651+
jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000; /* 10 seconds */
652+
653+
let client: MessageConnection;
654+
beforeEach(async () => {
655+
client = createConnection({
656+
ivy: true,
657+
includeCompletionsWithSnippetText: true,
658+
});
659+
// If debugging, set to
660+
// - lsp.Trace.Messages to inspect request/response/notification, or
661+
// - lsp.Trace.Verbose to inspect payload
662+
client.trace(lsp.Trace.Off, createTracer());
663+
client.listen();
664+
await initializeServer(client);
665+
});
666+
667+
afterEach(() => {
668+
client.dispose();
669+
});
670+
671+
it('should fix error when property does not exist on type', async () => {
672+
openTextDocument(client, FOO_TEMPLATE, `{{titl}}`);
673+
const languageServiceEnabled = await waitForNgcc(client);
674+
expect(languageServiceEnabled).toBeTrue();
675+
const diags = await getDiagnosticsForFile(client, FOO_TEMPLATE);
676+
const codeActions = await client.sendRequest(lsp.CodeActionRequest.type, {
677+
textDocument: {
678+
uri: FOO_TEMPLATE_URI,
679+
},
680+
range: lsp.Range.create(lsp.Position.create(0, 3), lsp.Position.create(0, 3)),
681+
context: lsp.CodeActionContext.create(diags),
682+
}) as lsp.CodeAction[];
683+
const expectedCodeActionInTemplate = {
684+
'edit': {
685+
'changes': {
686+
[FOO_TEMPLATE_URI]: [{
687+
'newText': 'title',
688+
'range': {'start': {'line': 0, 'character': 2}, 'end': {'line': 0, 'character': 6}}
689+
}]
690+
}
691+
}
692+
};
693+
expect(codeActions).toContain(jasmine.objectContaining(expectedCodeActionInTemplate));
694+
});
695+
696+
it('should fix all errors when property does not exist on type', async () => {
697+
openTextDocument(client, FOO_COMPONENT, `
698+
import {Component, NgModule} from '@angular/core';
699+
@Component({
700+
template: '{{tite}}{{bannr}}',
701+
})
702+
export class AppComponent {
703+
title = '';
704+
banner = '';
705+
}
706+
`);
707+
const languageServiceEnabled = await waitForNgcc(client);
708+
expect(languageServiceEnabled).toBeTrue();
709+
710+
const fixSpellingCodeAction = await client.sendRequest(lsp.CodeActionResolveRequest.type, {
711+
title: '',
712+
data: {
713+
fixId: 'fixSpelling',
714+
document: lsp.TextDocumentIdentifier.create(FOO_COMPONENT_URI),
715+
},
716+
});
717+
const expectedFixSpellingInTemplate = {
718+
'edit': {
719+
'changes': {
720+
[FOO_COMPONENT_URI]: [
721+
{
722+
'newText': 'title',
723+
'range': {
724+
'start': {'line': 3, 'character': 21},
725+
'end': {'line': 3, 'character': 25},
726+
},
727+
},
728+
{
729+
'newText': 'banner',
730+
'range': {'start': {'line': 3, 'character': 29}, 'end': {'line': 3, 'character': 34}}
731+
}
732+
]
733+
}
734+
}
735+
};
736+
expect(fixSpellingCodeAction).toEqual(jasmine.objectContaining(expectedFixSpellingInTemplate));
737+
738+
const fixMissingMemberCodeAction = await client.sendRequest(lsp.CodeActionResolveRequest.type, {
739+
title: '',
740+
data: {
741+
fixId: 'fixMissingMember',
742+
document: lsp.TextDocumentIdentifier.create(FOO_COMPONENT_URI),
743+
},
744+
});
745+
const expectedFixMissingMemberInComponent = {
746+
'edit': {
747+
'changes': {
748+
[FOO_COMPONENT_URI]: [
749+
{
750+
'newText': 'tite: any;\n',
751+
'range': {'start': {'line': 8, 'character': 0}, 'end': {'line': 8, 'character': 0}}
752+
},
753+
{
754+
'newText': 'bannr: any;\n',
755+
'range': {'start': {'line': 8, 'character': 0}, 'end': {'line': 8, 'character': 0}}
756+
}
757+
]
758+
}
759+
}
760+
};
761+
expect(fixMissingMemberCodeAction)
762+
.toEqual(jasmine.objectContaining(expectedFixMissingMemberInComponent));
763+
});
764+
});
765+
650766
function onNgccProgress(client: MessageConnection): Promise<string> {
651767
return new Promise(resolve => {
652768
client.onNotification(NgccProgressEnd, (params) => {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@
216216
"test:syntaxes": "yarn compile:syntaxes-test && yarn build:syntaxes && jasmine dist/syntaxes/test/driver.js"
217217
},
218218
"dependencies": {
219-
"@angular/language-service": "14.1.0-next.0",
219+
"@angular/language-service": "https://output.circle-artifacts.com/output/job/92620949-f999-4d6c-8d8e-9ee22083dc7d/artifacts/0/angular/language-service-pr46764-04c55d1bf3.tgz",
220220
"typescript": "4.7.4",
221221
"vscode-jsonrpc": "6.0.0",
222222
"vscode-languageclient": "7.0.0",

server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"ngserver": "./bin/ngserver"
1616
},
1717
"dependencies": {
18-
"@angular/language-service": "14.0.0-next.0",
18+
"@angular/language-service": "https://output.circle-artifacts.com/output/job/92620949-f999-4d6c-8d8e-9ee22083dc7d/artifacts/0/angular/language-service-pr46764-04c55d1bf3.tgz",
1919
"vscode-jsonrpc": "6.0.0",
2020
"vscode-languageserver": "7.0.0",
2121
"vscode-uri": "3.0.3"

server/src/session.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {readNgCompletionData, tsCompletionEntryToLspCompletionItem} from './comp
1919
import {tsDiagnosticToLspDiagnostic} from './diagnostic';
2020
import {resolveAndRunNgcc} from './ngcc';
2121
import {ServerHost} from './server_host';
22-
import {filePathToUri, getMappedDefinitionInfo, isConfiguredProject, isDebugMode, lspPositionToTsPosition, lspRangeToTsPositions, MruTracker, tsDisplayPartsToText, tsTextSpanToLspRange, uriToFilePath} from './utils';
22+
import {filePathToUri, getMappedDefinitionInfo, isConfiguredProject, isDebugMode, lspPositionToTsPosition, lspRangeToTsPositions, MruTracker, tsDisplayPartsToText, tsFileTextChangesToLspWorkspaceEdit, tsTextSpanToLspRange, uriToFilePath} from './utils';
2323

2424
export interface SessionOptions {
2525
host: ServerHost;
@@ -197,6 +197,59 @@ export class Session {
197197
conn.onCodeLens(p => this.onCodeLens(p));
198198
conn.onCodeLensResolve(p => this.onCodeLensResolve(p));
199199
conn.onSignatureHelp(p => this.onSignatureHelp(p));
200+
conn.onCodeAction(p => this.codeAction(p));
201+
conn.onCodeActionResolve(p => this.onCodeActionResolve(p));
202+
}
203+
204+
private codeAction(params: lsp.CodeActionParams): lsp.CodeAction[]|null {
205+
const filePath = uriToFilePath(params.textDocument.uri);
206+
const lsInfo = this.getLSAndScriptInfo(params.textDocument);
207+
if (!lsInfo) {
208+
return null;
209+
}
210+
const start = lspPositionToTsPosition(lsInfo.scriptInfo, params.range.start);
211+
const end = lspPositionToTsPosition(lsInfo.scriptInfo, params.range.end);
212+
const errorCodes = params.context.diagnostics.map(diag => diag.code)
213+
.filter((code): code is number => typeof code === 'number');
214+
215+
const codeActions =
216+
lsInfo.languageService.getCodeFixesAtPosition(filePath, start, end, errorCodes, {}, {});
217+
return codeActions
218+
.map<lsp.CodeAction>(codeAction => {
219+
return {
220+
title: codeAction.description,
221+
kind: lsp.CodeActionKind.QuickFix,
222+
diagnostics: params.context.diagnostics,
223+
edit: tsFileTextChangesToLspWorkspaceEdit(
224+
codeAction.changes, (path: string) => this.projectService.getScriptInfo(path)),
225+
};
226+
})
227+
.concat(getCodeFixesAll(codeActions, params.textDocument));
228+
}
229+
230+
private onCodeActionResolve(param: lsp.CodeAction): lsp.CodeAction {
231+
const codeActionResolve = param.data as unknown as CodeActionResolveData;
232+
const isCodeFixesAll = codeActionResolve.fixId !== undefined;
233+
if (!isCodeFixesAll) {
234+
return param;
235+
}
236+
const filePath = uriToFilePath(codeActionResolve.document.uri);
237+
const lsInfo = this.getLSAndScriptInfo(codeActionResolve.document);
238+
if (!lsInfo) {
239+
return param;
240+
}
241+
const fixesAllChanges = lsInfo.languageService.getCombinedCodeFix(
242+
{
243+
type: 'file',
244+
fileName: filePath,
245+
},
246+
codeActionResolve.fixId as {}, {}, {});
247+
248+
return {
249+
title: param.title,
250+
edit: tsFileTextChangesToLspWorkspaceEdit(
251+
fixesAllChanges.changes, (path) => this.projectService.getScriptInfo(path)),
252+
};
200253
}
201254

202255
private isInAngularProject(params: IsInAngularProjectParams): boolean|null {
@@ -663,6 +716,10 @@ export class Session {
663716
workspace: {
664717
workspaceFolders: {supported: true},
665718
},
719+
codeActionProvider: this.ivy ? {
720+
resolveProvider: true,
721+
} :
722+
undefined,
666723
},
667724
serverOptions,
668725
};
@@ -1239,3 +1296,34 @@ function isTypeScriptFile(path: string): boolean {
12391296
function isExternalTemplate(path: string): boolean {
12401297
return !isTypeScriptFile(path);
12411298
}
1299+
1300+
interface CodeActionResolveData {
1301+
fixId?: string;
1302+
document: lsp.TextDocumentIdentifier;
1303+
}
1304+
1305+
/**
1306+
* Extract the fixAll action from `codeActions`
1307+
*/
1308+
function getCodeFixesAll(
1309+
codeActions: readonly ts.CodeFixAction[],
1310+
document: lsp.TextDocumentIdentifier): lsp.CodeAction[] {
1311+
const seenFixId = new Set<string>();
1312+
const lspCodeActions: lsp.CodeAction[] = [];
1313+
for (const codeAction of codeActions) {
1314+
const fixId = codeAction.fixId as string | undefined;
1315+
if (fixId === undefined || codeAction.fixAllDescription === undefined || seenFixId.has(fixId)) {
1316+
continue;
1317+
}
1318+
seenFixId.add(fixId);
1319+
const codeActionResolve: CodeActionResolveData = {
1320+
fixId,
1321+
document,
1322+
};
1323+
lspCodeActions.push({
1324+
title: codeAction.fixAllDescription,
1325+
data: codeActionResolve,
1326+
});
1327+
}
1328+
return lspCodeActions;
1329+
}

server/src/utils.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,35 @@ export function filePathToUri(filePath: string): lsp.DocumentUri {
3939
return URI.file(filePath).toString();
4040
}
4141

42+
/**
43+
* Converts ts.FileTextChanges to lsp.WorkspaceEdit.
44+
*/
45+
export function tsFileTextChangesToLspWorkspaceEdit(
46+
changes: readonly ts.FileTextChanges[],
47+
getScriptInfo: (path: string) => ts.server.ScriptInfo | undefined): lsp.WorkspaceEdit {
48+
const workspaceChanges: {[uri: string]: lsp.TextEdit[]} = {};
49+
for (const change of changes) {
50+
const scriptInfo = getScriptInfo(change.fileName);
51+
const uri = filePathToUri(change.fileName);
52+
if (scriptInfo === undefined) {
53+
continue;
54+
}
55+
if (!workspaceChanges[uri]) {
56+
workspaceChanges[uri] = [];
57+
}
58+
for (const textChange of change.textChanges) {
59+
const textEdit: lsp.TextEdit = {
60+
newText: textChange.newText,
61+
range: tsTextSpanToLspRange(scriptInfo, textChange.span),
62+
};
63+
workspaceChanges[uri].push(textEdit);
64+
}
65+
}
66+
return {
67+
changes: workspaceChanges,
68+
};
69+
}
70+
4271
/**
4372
* Convert ts.TextSpan to lsp.TextSpan. TypeScript keeps track of offset using
4473
* 1-based index whereas LSP uses 0-based index.

yarn.lock

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,9 @@
158158
uuid "^8.3.2"
159159
yargs "^17.0.0"
160160

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

166165
"@assemblyscript/loader@^0.10.1":
167166
version "0.10.1"

0 commit comments

Comments
 (0)