Skip to content

Commit 1674a30

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 cd98641 commit 1674a30

File tree

6 files changed

+269
-7
lines changed

6 files changed

+269
-7
lines changed

integration/lsp/ivy_spec.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,6 +647,131 @@ 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+
describe('should work', () => {
697+
beforeEach(async () => {
698+
openTextDocument(client, FOO_COMPONENT, `
699+
import {Component, NgModule} from '@angular/core';
700+
@Component({
701+
template: '{{tite}}{{bannr}}',
702+
})
703+
export class AppComponent {
704+
title = '';
705+
banner = '';
706+
}
707+
`);
708+
const languageServiceEnabled = await waitForNgcc(client);
709+
expect(languageServiceEnabled).toBeTrue();
710+
});
711+
712+
it('for "fixSpelling"', async () => {
713+
const fixSpellingCodeAction = await client.sendRequest(lsp.CodeActionResolveRequest.type, {
714+
title: '',
715+
data: {
716+
fixId: 'fixSpelling',
717+
document: lsp.TextDocumentIdentifier.create(FOO_COMPONENT_URI),
718+
},
719+
});
720+
const expectedFixSpellingInTemplate = {
721+
'edit': {
722+
'changes': {
723+
[FOO_COMPONENT_URI]: [
724+
{
725+
'newText': 'title',
726+
'range': {
727+
'start': {'line': 3, 'character': 21},
728+
'end': {'line': 3, 'character': 25},
729+
},
730+
},
731+
{
732+
'newText': 'banner',
733+
'range':
734+
{'start': {'line': 3, 'character': 29}, 'end': {'line': 3, 'character': 34}}
735+
}
736+
]
737+
}
738+
}
739+
};
740+
expect(fixSpellingCodeAction)
741+
.toEqual(jasmine.objectContaining(expectedFixSpellingInTemplate));
742+
});
743+
744+
it('for "fixMissingMember"', async () => {
745+
const fixMissingMemberCodeAction =
746+
await client.sendRequest(lsp.CodeActionResolveRequest.type, {
747+
title: '',
748+
data: {
749+
fixId: 'fixMissingMember',
750+
document: lsp.TextDocumentIdentifier.create(FOO_COMPONENT_URI),
751+
},
752+
});
753+
const expectedFixMissingMemberInComponent = {
754+
'edit': {
755+
'changes': {
756+
[FOO_COMPONENT_URI]: [
757+
{
758+
'newText': 'tite: any;\n',
759+
'range': {'start': {'line': 8, 'character': 0}, 'end': {'line': 8, 'character': 0}}
760+
},
761+
{
762+
'newText': 'bannr: any;\n',
763+
'range': {'start': {'line': 8, 'character': 0}, 'end': {'line': 8, 'character': 0}}
764+
}
765+
]
766+
}
767+
}
768+
};
769+
expect(fixMissingMemberCodeAction)
770+
.toEqual(jasmine.objectContaining(expectedFixMissingMemberInComponent));
771+
});
772+
});
773+
});
774+
650775
function onNgccProgress(client: MessageConnection): Promise<string> {
651776
return new Promise(resolve => {
652777
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",
219+
"@angular/language-service": "14.2.0-next.0",
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.1.0",
18+
"@angular/language-service": "14.2.0-next.0",
1919
"vscode-jsonrpc": "6.0.0",
2020
"vscode-languageserver": "7.0.0",
2121
"vscode-uri": "3.0.3"

server/src/session.ts

Lines changed: 109 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;
@@ -48,6 +48,9 @@ enum NgccErrorMessageAction {
4848
showOutput,
4949
}
5050

51+
const defaultFormatOptions: ts.FormatCodeSettings = {};
52+
const defaultPreferences: ts.UserPreferences = {};
53+
5154
/**
5255
* Session is a wrapper around lsp.IConnection, with all the necessary protocol
5356
* handlers installed for Angular language service.
@@ -197,6 +200,67 @@ export class Session {
197200
conn.onCodeLens(p => this.onCodeLens(p));
198201
conn.onCodeLensResolve(p => this.onCodeLensResolve(p));
199202
conn.onSignatureHelp(p => this.onSignatureHelp(p));
203+
conn.onCodeAction(p => this.onCodeAction(p));
204+
conn.onCodeActionResolve(p => this.onCodeActionResolve(p));
205+
}
206+
207+
private onCodeAction(params: lsp.CodeActionParams): lsp.CodeAction[]|null {
208+
const filePath = uriToFilePath(params.textDocument.uri);
209+
const lsInfo = this.getLSAndScriptInfo(params.textDocument);
210+
if (!lsInfo) {
211+
return null;
212+
}
213+
const start = lspPositionToTsPosition(lsInfo.scriptInfo, params.range.start);
214+
const end = lspPositionToTsPosition(lsInfo.scriptInfo, params.range.end);
215+
const errorCodes = params.context.diagnostics.map(diag => diag.code)
216+
.filter((code): code is number => typeof code === 'number');
217+
218+
const codeActions = lsInfo.languageService.getCodeFixesAtPosition(
219+
filePath, start, end, errorCodes, defaultFormatOptions, defaultPreferences);
220+
const individualCodeFixes = codeActions.map<lsp.CodeAction>(codeAction => {
221+
return {
222+
title: codeAction.description,
223+
kind: lsp.CodeActionKind.QuickFix,
224+
diagnostics: params.context.diagnostics,
225+
edit: tsFileTextChangesToLspWorkspaceEdit(
226+
codeAction.changes, (path: string) => this.projectService.getScriptInfo(path)),
227+
};
228+
});
229+
const codeFixesAll = getCodeFixesAll(codeActions, params.textDocument);
230+
return [...individualCodeFixes, ...codeFixesAll];
231+
}
232+
233+
private onCodeActionResolve(param: lsp.CodeAction): lsp.CodeAction {
234+
const codeActionResolve = param.data as unknown as CodeActionResolveData;
235+
/**
236+
* Now `@angular/language-service` only support quick fix, so the `onCodeAction` will return the
237+
* `edit` of the `lsp.CodeAction` for the diagnostics in the range that the user selects except
238+
* the fix all code actions.
239+
*
240+
* And the function `getCombinedCodeFix` only cares about the `fixId` and the `document`.
241+
* https://github.com/microsoft/vscode/blob/8ba9963c2edb08d54f2b7221137d6f1de79ecc09/extensions/typescript-language-features/src/languageFeatures/quickFix.ts#L258
242+
*/
243+
const isCodeFixesAll = codeActionResolve.fixId !== undefined;
244+
if (!isCodeFixesAll) {
245+
return param;
246+
}
247+
const filePath = uriToFilePath(codeActionResolve.document.uri);
248+
const lsInfo = this.getLSAndScriptInfo(codeActionResolve.document);
249+
if (!lsInfo) {
250+
return param;
251+
}
252+
const fixesAllChanges = lsInfo.languageService.getCombinedCodeFix(
253+
{
254+
type: 'file',
255+
fileName: filePath,
256+
},
257+
codeActionResolve.fixId as {}, defaultFormatOptions, defaultPreferences);
258+
259+
return {
260+
title: param.title,
261+
edit: tsFileTextChangesToLspWorkspaceEdit(
262+
fixesAllChanges.changes, (path) => this.projectService.getScriptInfo(path)),
263+
};
200264
}
201265

202266
private isInAngularProject(params: IsInAngularProjectParams): boolean|null {
@@ -663,6 +727,10 @@ export class Session {
663727
workspace: {
664728
workspaceFolders: {supported: true},
665729
},
730+
codeActionProvider: this.ivy ? {
731+
resolveProvider: true,
732+
} :
733+
undefined,
666734
},
667735
serverOptions,
668736
};
@@ -1239,3 +1307,43 @@ function isTypeScriptFile(path: string): boolean {
12391307
function isExternalTemplate(path: string): boolean {
12401308
return !isTypeScriptFile(path);
12411309
}
1310+
1311+
interface CodeActionResolveData {
1312+
fixId?: string;
1313+
document: lsp.TextDocumentIdentifier;
1314+
}
1315+
1316+
/**
1317+
* Extract the fixAll action from `codeActions`
1318+
*
1319+
* When getting code fixes at the specified cursor position, the LS will return the code actions
1320+
* that tell the editor how to fix it. For each code action, if the document includes multi
1321+
* same-type errors, the `fixId` will append to it, because they are not `complete`. This function
1322+
* will extract them, and they will be resolved lazily in the `onCodeActionResolve` function.
1323+
*
1324+
* Now the client can only resolve the `edit` property.
1325+
* https://github.com/microsoft/vscode-languageserver-node/blob/f97bb73dbfb920af4bc8c13ecdcdc16359cdeda6/client/src/common/codeAction.ts#L45
1326+
*/
1327+
function getCodeFixesAll(
1328+
codeActions: readonly ts.CodeFixAction[],
1329+
document: lsp.TextDocumentIdentifier): lsp.CodeAction[] {
1330+
const seenFixId = new Set<string>();
1331+
const lspCodeActions: lsp.CodeAction[] = [];
1332+
for (const codeAction of codeActions) {
1333+
const fixId = codeAction.fixId as string | undefined;
1334+
if (fixId === undefined || codeAction.fixAllDescription === undefined || seenFixId.has(fixId)) {
1335+
continue;
1336+
}
1337+
seenFixId.add(fixId);
1338+
const codeActionResolveData: CodeActionResolveData = {
1339+
fixId,
1340+
document,
1341+
};
1342+
lspCodeActions.push({
1343+
title: codeAction.fixAllDescription,
1344+
kind: lsp.CodeActionKind.QuickFix,
1345+
data: codeActionResolveData,
1346+
});
1347+
}
1348+
return lspCodeActions;
1349+
}

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: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,10 @@
158158
uuid "^8.3.2"
159159
yargs "^17.0.0"
160160

161-
"@angular/language-service@14.1.0":
162-
version "14.1.0"
163-
resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-14.1.0.tgz#e5ca5f948d1930ebd5791795c983dd0887d0fd63"
164-
integrity sha512-ldL4xMDjXYZ93FCEIBVGipx9Qfgr7NuBNO+e25d+nWikXrUOnLfvF4UOL/TSUwSwqN4jxDI2KMNQIF6SecZfvQ==
161+
"@angular/language-service@14.2.0-next.0":
162+
version "14.2.0-next.0"
163+
resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-14.2.0-next.0.tgz#f1e60f00c5cbcd0b88b563837dfd01332b7d611f"
164+
integrity sha512-Uj2XGSS6Z7wb6zuZ39uaXxwlXNTN+ByhUr5qJ8Ya4GkxjKJLN1uQDvFhjGIwMyG75q2Wv4GOIGeGo+0Gts2b6A==
165165

166166
"@assemblyscript/loader@^0.10.1":
167167
version "0.10.1"

0 commit comments

Comments
 (0)