Skip to content
This repository was archived by the owner on Oct 16, 2020. It is now read-only.

Commit 5cca7fa

Browse files
tomv564felixfbecker
authored andcommitted
Implement Code Actions
1 parent 14a08f7 commit 5cca7fa

File tree

4 files changed

+280
-3
lines changed

4 files changed

+280
-3
lines changed

src/lang-handler.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { FORMAT_TEXT_MAP, Span } from 'opentracing';
33
import { inspect } from 'util';
44
import { isReponseMessage, Message, NotificationMessage, RequestMessage, ResponseMessage } from 'vscode-jsonrpc/lib/messages';
55
import {
6+
ApplyWorkspaceEditParams,
7+
ApplyWorkspaceEditResponse,
68
LogMessageParams,
79
PublishDiagnosticsParams,
810
TextDocumentIdentifier,
@@ -56,6 +58,13 @@ export interface LanguageClient {
5658
* @param params The diagnostics to send to the client
5759
*/
5860
textDocumentPublishDiagnostics(params: PublishDiagnosticsParams): void;
61+
62+
/**
63+
* Requests a set of text changes to be applied to documents in the workspace
64+
* Can occur as as a result of rename or executeCommand (code action).
65+
* @param params The edits to apply to the workspace
66+
*/
67+
workspaceApplyEdit(params: ApplyWorkspaceEditParams): Promise<ApplyWorkspaceEditResponse>;
5968
}
6069

6170
/**
@@ -186,4 +195,13 @@ export class RemoteLanguageClient {
186195
textDocumentPublishDiagnostics(params: PublishDiagnosticsParams): void {
187196
this.notify('textDocument/publishDiagnostics', params);
188197
}
198+
199+
/**
200+
* Requests a set of text changes to be applied to documents in the workspace
201+
* Can occur as as a result of rename or executeCommand (code action).
202+
* @param params The edits to apply to the workspace
203+
*/
204+
workspaceApplyEdit(params: ApplyWorkspaceEditParams): Promise<ApplyWorkspaceEditResponse> {
205+
return this.request('workspace/applyEdit', params).toPromise();
206+
}
189207
}

src/project-manager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,12 @@ export class InMemoryLanguageServiceHost implements ts.LanguageServiceHost {
622622
return '' + this.projectVersion;
623623
}
624624

625+
getNewLine(): string {
626+
// Although this is optional, language service was sending edits with carriage returns if not specified.
627+
// TODO: combine with the FormatOptions defaults.
628+
return '\n';
629+
}
630+
625631
/**
626632
* Incrementing current project version, telling TS compiler to invalidate internal data
627633
*/

src/test/typescript-service-helpers.ts

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import * as chai from 'chai';
22
import * as sinon from 'sinon';
33
import * as ts from 'typescript';
4-
import { CompletionItemKind, CompletionList, TextDocumentIdentifier, TextDocumentItem } from 'vscode-languageserver';
5-
import { Hover, Location, SignatureHelp, SymbolInformation, SymbolKind, WorkspaceEdit } from 'vscode-languageserver-types';
4+
import { CompletionItemKind, CompletionList, DiagnosticSeverity, TextDocumentIdentifier, TextDocumentItem, WorkspaceEdit } from 'vscode-languageserver';
5+
import { Hover, Location, SignatureHelp, SymbolInformation, SymbolKind } from 'vscode-languageserver-types';
66
import { LanguageClient, RemoteLanguageClient } from '../lang-handler';
77
import { TextDocumentContentParams, WorkspaceFilesParams } from '../request-type';
88
import { SymbolLocationInformation } from '../request-type';
@@ -16,7 +16,7 @@ const assert = chai.assert;
1616
/**
1717
* Enforcing strict mode to make tests pass on Windows
1818
*/
19-
import { setStrict } from '../util';
19+
import { setStrict, uri2path } from '../util';
2020
setStrict(true);
2121

2222
export interface TestContext {
@@ -53,6 +53,7 @@ export const initializeTypeScriptService = (createService: TypeScriptServiceFact
5353
return Array.from(files.keys()).map(uri => ({ uri }));
5454
});
5555
this.client.xcacheGet.returns(null);
56+
this.client.workspaceApplyEdit.returns(Promise.resolve({applied: true}));
5657
this.service = createService(this.client);
5758

5859
await this.service.initialize({
@@ -2310,6 +2311,137 @@ export function describeTypeScriptService(createService: TypeScriptServiceFactor
23102311
});
23112312
});
23122313

2314+
describe('textDocumentCodeAction()', function (this: TestContext) {
2315+
beforeEach(initializeTypeScriptService(createService, rootUri, new Map([
2316+
[rootUri + 'package.json', '{ "name": "mypkg" }'],
2317+
[rootUri + 'a.ts', [
2318+
'class A {',
2319+
' constructor() {',
2320+
' missingThis = 33;',
2321+
' }',
2322+
'}',
2323+
'const a = new A();'
2324+
].join('\n')]
2325+
])) as any);
2326+
2327+
afterEach(shutdownService as any);
2328+
2329+
it('suggests a missing this', async function (this: TestContext) {
2330+
await this.service.textDocumentDidOpen({
2331+
textDocument: {
2332+
uri: rootUri + 'a.ts',
2333+
languageId: 'typescript',
2334+
text: [
2335+
'class A {',
2336+
' missingThis: number;',
2337+
' constructor() {',
2338+
' missingThis = 33;',
2339+
' }',
2340+
'}',
2341+
'const a = new A();'
2342+
].join('\n'),
2343+
version: 1
2344+
}
2345+
});
2346+
2347+
const firstDiagnostic = {
2348+
range: {
2349+
start: { line: 3, character: 4 },
2350+
end: { line: 3, character: 15 }
2351+
},
2352+
message: 'Cannot find name \'missingThis\'. Did you mean the instance member \'this.missingThis\'?',
2353+
severity: DiagnosticSeverity.Error,
2354+
code: 2663,
2355+
source: 'ts'
2356+
};
2357+
const actions = await this.service.textDocumentCodeAction({
2358+
textDocument: {
2359+
uri: rootUri + 'a.ts'
2360+
},
2361+
range: firstDiagnostic.range,
2362+
context: {
2363+
diagnostics: [firstDiagnostic]
2364+
}
2365+
}).toArray().map(patches => apply(null, patches)).toPromise();
2366+
assert.lengthOf(actions, 1);
2367+
assert.sameDeepMembers(actions, [
2368+
{
2369+
title: 'Add \'this.\' to unresolved variable.',
2370+
command: 'codeFix',
2371+
arguments: [
2372+
{
2373+
fileName: uri2path(rootUri + 'a.ts'),
2374+
textChanges: [
2375+
{
2376+
span: { start: 50, length: 15 },
2377+
newText: '\t this.missingThis'
2378+
}
2379+
]
2380+
}
2381+
]
2382+
}
2383+
]);
2384+
2385+
} as any);
2386+
} as any);
2387+
2388+
describe('workspaceExecuteCommand()', function (this: TestContext) {
2389+
beforeEach(initializeTypeScriptService(createService, rootUri, new Map([
2390+
[rootUri + 'package.json', '{ "name": "mypkg" }'],
2391+
[rootUri + 'a.ts', [
2392+
'class A {',
2393+
' constructor() {',
2394+
' missingThis = 33;',
2395+
' }',
2396+
'}',
2397+
'const a = new A();'
2398+
].join('\n')]
2399+
])) as any);
2400+
2401+
afterEach(shutdownService as any);
2402+
2403+
it('should return edits for a codeFix command', async function (this: TestContext) {
2404+
const result = await this.service.workspaceExecuteCommand({
2405+
command: 'codeFix',
2406+
arguments: [
2407+
{
2408+
fileName: uri2path(rootUri + 'a.ts'),
2409+
textChanges: [
2410+
{
2411+
span: { start: 50, length: 15 },
2412+
newText: '\t this.missingThis'
2413+
}
2414+
]
2415+
}
2416+
]
2417+
}).toArray().map(patches => apply(null, patches)).toPromise();
2418+
2419+
assert.isUndefined(result);
2420+
2421+
sinon.assert.called(this.client.workspaceApplyEdit);
2422+
const workspaceEdit = this.client.workspaceApplyEdit.lastCall.args[0];
2423+
assert.deepEqual(workspaceEdit, {
2424+
edit: {
2425+
changes: {
2426+
[rootUri + 'a.ts']: [{
2427+
newText: '\t this.missingThis',
2428+
range: {
2429+
end: {
2430+
character: 9,
2431+
line: 5
2432+
},
2433+
start: {
2434+
character: 0,
2435+
line: 3
2436+
}
2437+
}
2438+
}]
2439+
}
2440+
}
2441+
});
2442+
} as any);
2443+
} as any);
2444+
23132445
describe('Special file names', function (this: TestContext) {
23142446

23152447
beforeEach(initializeTypeScriptService(createService, rootUri, new Map([

src/typescript-service.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { Span } from 'opentracing';
66
import * as path from 'path';
77
import * as ts from 'typescript';
88
import {
9+
ApplyWorkspaceEditParams,
10+
CodeActionParams,
11+
Command,
912
CompletionItem,
1013
CompletionItemKind,
1114
CompletionList,
@@ -14,10 +17,12 @@ import {
1417
DidOpenTextDocumentParams,
1518
DidSaveTextDocumentParams,
1619
DocumentSymbolParams,
20+
ExecuteCommandParams,
1721
Hover,
1822
Location,
1923
MarkedString,
2024
ParameterInformation,
25+
Range as LSRange,
2126
ReferenceParams,
2227
RenameParams,
2328
SignatureHelp,
@@ -70,6 +75,29 @@ export interface TypeScriptServiceOptions {
7075

7176
export type TypeScriptServiceFactory = (client: LanguageClient, options?: TypeScriptServiceOptions) => TypeScriptService;
7277

78+
// defaults from https://github.com/Microsoft/vscode/blob/master/tsfmt.json
79+
// A formattingProvider could be implemented to read editorconfig etc.
80+
const formatSettings: ts.FormatCodeSettings = {
81+
indentSize: 4,
82+
tabSize: 4,
83+
newLineCharacter: '\n',
84+
convertTabsToSpaces: false,
85+
indentStyle: ts.IndentStyle.Smart,
86+
insertSpaceAfterCommaDelimiter: true,
87+
insertSpaceAfterSemicolonInForStatements: true,
88+
insertSpaceBeforeAndAfterBinaryOperators: true,
89+
insertSpaceAfterKeywordsInControlFlowStatements: true,
90+
insertSpaceAfterFunctionKeywordForAnonymousFunctions: false,
91+
insertSpaceBeforeFunctionParenthesis: false,
92+
insertSpaceAfterOpeningAndBeforeClosingNonemptyParenthesis: false,
93+
insertSpaceAfterOpeningAndBeforeClosingNonemptyBrackets: false,
94+
insertSpaceAfterOpeningAndBeforeClosingNonemptyBraces: true,
95+
insertSpaceAfterOpeningAndBeforeClosingTemplateStringBraces: false,
96+
insertSpaceAfterOpeningAndBeforeClosingJsxExpressionBraces: false,
97+
placeOpenBraceOnNewLineForFunctions: false,
98+
placeOpenBraceOnNewLineForControlBlocks: false
99+
};
100+
73101
/**
74102
* Handles incoming requests and return responses. There is a one-to-one-to-one
75103
* correspondence between TCP connection, TypeScriptService instance, and
@@ -211,6 +239,11 @@ export class TypeScriptService {
211239
resolveProvider: false,
212240
triggerCharacters: ['.']
213241
},
242+
codeActionProvider: true,
243+
renameProvider: true,
244+
executeCommandProvider: {
245+
commands: []
246+
},
214247
xpackagesProvider: true
215248
}
216249
};
@@ -990,6 +1023,94 @@ export class TypeScriptService {
9901023
.map(signatureHelp => ({ op: 'add', path: '', value: signatureHelp }) as AddPatch);
9911024
}
9921025

1026+
textDocumentCodeAction(params: CodeActionParams, span = new Span()): Observable<OpPatch> {
1027+
return this.projectManager.ensureReferencedFiles(params.textDocument.uri, undefined, undefined, span)
1028+
.toArray()
1029+
.map((): Command[] => {
1030+
const filePath = uri2path(params.textDocument.uri);
1031+
const configuration = this.projectManager.getConfiguration(filePath);
1032+
configuration.ensureBasicFiles(span);
1033+
1034+
const sourceFile = this._getSourceFile(configuration, filePath, span);
1035+
if (!sourceFile) {
1036+
throw new Error(`expected source file ${filePath} to exist in configuration`);
1037+
}
1038+
1039+
const start: number = ts.getPositionOfLineAndCharacter(sourceFile, params.range.start.line, params.range.start.character);
1040+
const end: number = ts.getPositionOfLineAndCharacter(sourceFile, params.range.end.line, params.range.end.character);
1041+
const errorCodes: number[] = [];
1042+
for (const diagnostic of params.context.diagnostics) {
1043+
if (typeof(diagnostic.code) === 'number') {
1044+
errorCodes.push(diagnostic.code);
1045+
}
1046+
}
1047+
1048+
const fixes: ts.CodeAction[] = configuration.getService().getCodeFixesAtPosition(filePath, start, end, errorCodes, formatSettings);
1049+
if (!fixes) {
1050+
return [];
1051+
}
1052+
1053+
return fixes.map(fix => Command.create(fix.description, 'codeFix', ...fix.changes));
1054+
})
1055+
.map(command => ({ op: 'add', path: '', value: command }) as AddPatch);
1056+
}
1057+
1058+
workspaceExecuteCommand(params: ExecuteCommandParams, span = new Span()): Observable<OpPatch> {
1059+
switch (params.command) {
1060+
case 'codeFix':
1061+
if (params.arguments && params.arguments.length === 1) {
1062+
return Observable
1063+
.fromPromise(this.executeCodeFixCommand(params.arguments, span))
1064+
.map(result => ({op: 'add', path: '', value: result }) as OpPatch);
1065+
} else {
1066+
throw new Error(`Command ${params.command} requires arguments`);
1067+
}
1068+
default:
1069+
throw new Error(`Unknown command ${params.command}`);
1070+
}
1071+
}
1072+
1073+
convertTextChange(textChange: ts.TextChange, sourceFile: ts.SourceFile): TextEdit {
1074+
const start = ts.getLineAndCharacterOfPosition(sourceFile, textChange.span.start);
1075+
if (textChange.span.length) {
1076+
const end = ts.getLineAndCharacterOfPosition(sourceFile, textChange.span.start + textChange.span.length);
1077+
const range = LSRange.create(start, end);
1078+
if (textChange.newText) {
1079+
return TextEdit.replace(range, textChange.newText);
1080+
} else {
1081+
return TextEdit.del(range);
1082+
}
1083+
} else {
1084+
return TextEdit.insert(start, textChange.newText);
1085+
}
1086+
}
1087+
1088+
async executeCodeFixCommand(fileTextChanges: ts.FileTextChanges[], span = new Span()): Promise<void> {
1089+
if (fileTextChanges.length === 0) {
1090+
throw new Error('No changes supplied for code fix command');
1091+
}
1092+
1093+
await this.projectManager.ensureOwnFiles(span);
1094+
const configuration = this.projectManager.getConfiguration(fileTextChanges[0].fileName);
1095+
configuration.ensureBasicFiles(span);
1096+
const fileToEdits: {[uri: string]: TextEdit[]} = {};
1097+
for (const change of fileTextChanges) {
1098+
const sourceFile = this._getSourceFile(configuration, change.fileName, span);
1099+
if (!sourceFile) {
1100+
throw new Error(`expected source file ${change.fileName} to exist in configuration`);
1101+
}
1102+
fileToEdits[path2uri(this.root, change.fileName)] =
1103+
change.textChanges.map(tc => this.convertTextChange(tc, sourceFile));
1104+
}
1105+
1106+
const params: ApplyWorkspaceEditParams = {
1107+
edit: {
1108+
changes: fileToEdits
1109+
}
1110+
};
1111+
return this.client.workspaceApplyEdit(params).then(r => undefined);
1112+
}
1113+
9931114
/**
9941115
* The rename request is sent from the client to the server to perform a workspace-wide rename of a symbol.
9951116
*

0 commit comments

Comments
 (0)