diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 075d4247460e9..17a54328b378f 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -3500,8 +3500,6 @@ "category": "Message", "code": 90021 }, - - "Octal literal types must use ES2015 syntax. Use the syntax '{0}'.": { "category": "Error", "code": 8017 @@ -3513,5 +3511,9 @@ "Report errors in .js files.": { "category": "Message", "code": 8019 + }, + "Convert function '{0}' to ES6 class.": { + "category": "CodeFix", + "code": 100000 } } diff --git a/src/compiler/types.ts b/src/compiler/types.ts index e79c4cea5d547..e2bcf54aa6d1e 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -3349,7 +3349,7 @@ namespace ts { Warning, Error, Message, - Refactor + CodeFix } export enum ModuleResolutionKind { diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index 551b22325fb3f..4e5de9e0eb3ed 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -481,16 +481,26 @@ namespace FourSlash { private getDiagnostics(fileName: string): ts.Diagnostic[] { const syntacticErrors = this.languageService.getSyntacticDiagnostics(fileName); const semanticErrors = this.languageService.getSemanticDiagnostics(fileName); + const codeFixDiagnostics = this.getCodeFixDiagnostics(fileName); const diagnostics: ts.Diagnostic[] = []; diagnostics.push.apply(diagnostics, syntacticErrors); diagnostics.push.apply(diagnostics, semanticErrors); + diagnostics.push.apply(diagnostics, codeFixDiagnostics); return diagnostics; } private getCodeFixDiagnostics(fileName: string): ts.Diagnostic[] { - return this.languageService.getCodeFixDiagnostics(fileName); + let result: ts.Diagnostic[]; + + try { + result = this.languageService.getCodeFixDiagnostics(fileName); + } + catch(e) { + result = []; + } + return result; } private getAllDiagnostics(): ts.Diagnostic[] { diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index 84fcbe437a80e..956514c7dac22 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -3631,7 +3631,7 @@ namespace ts.projectSystem { host.runQueuedImmediateCallbacks(); assert.equal(host.getOutput().length, 2, "expect 2 messages"); const e3 = getMessage(0); - assert.equal(e3.event, "refactorDiag"); + assert.equal(e3.event, "codeFixDiag"); verifyRequestCompleted(getErrId, 1); cancellationToken.resetToken(); diff --git a/src/server/session.ts b/src/server/session.ts index 9180ffb46d57c..34cb6ca78caae 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -506,7 +506,7 @@ namespace ts.server { next.immediate(() => { this.semanticCheck(checkSpec.fileName, checkSpec.project); next.immediate(() => { - this.refactorDiagnosticsCheck(checkSpec.fileName, checkSpec.project); + this.codeFixDiagnosticsCheck(checkSpec.fileName, checkSpec.project); if (checkList.length > index) { next.delay(followMs, checkOne); } @@ -1435,11 +1435,11 @@ namespace ts.server { return ts.getSupportedCodeFixes(); } - private refactorDiagnosticsCheck(file: NormalizedPath, project: Project): void { - const refactorDiags = project.getLanguageService().getCodeFixDiagnostics(file); - const diagnostics = refactorDiags.map(d => formatDiag(file, project, d)); + private codeFixDiagnosticsCheck(file: NormalizedPath, project: Project): void { + const codeFixDiags = project.getLanguageService().getCodeFixDiagnostics(file); + const diagnostics = codeFixDiags.map(d => formatDiag(file, project, d)); - this.event({ file, diagnostics }, "refactorDiag"); + this.event({ file, diagnostics }, "codeFixDiag"); } private isLocation(locationOrSpan: protocol.FileLocationOrRangeRequestArgs): locationOrSpan is protocol.FileLocationRequestArgs { diff --git a/src/services/codefixes/convertFunctionToEs6Class.ts b/src/services/codefixes/convertFunctionToEs6Class.ts new file mode 100644 index 0000000000000..3e3f7ac62bf6e --- /dev/null +++ b/src/services/codefixes/convertFunctionToEs6Class.ts @@ -0,0 +1,191 @@ +/* @internal */ +namespace ts.codefix { + registerCodeFix({ + errorCodes: [Diagnostics.Convert_function_0_to_ES6_class.code], + getCodeActions, + createCodeFixDiagnosticIfApplicable + }); + + function createCodeFixDiagnosticIfApplicable(node: Node, context: CodeFixDiagnoseContext): Diagnostic | undefined { + if (!isSourceFileJavaScript(context.boundSourceFile)) { + return undefined; + } + + const checker = context.program.getTypeChecker(); + const symbol = checker.getSymbolAtLocation(node); + if (isClassLikeSymbol(symbol)) { + return createDiagnosticForNode(node, Diagnostics.Convert_function_0_to_ES6_class, symbol.name); + } + + function isClassLikeSymbol(symbol: Symbol) { + if (!symbol || !symbol.valueDeclaration) { + return false; + } + + let targetSymbol: Symbol; + if (symbol.valueDeclaration.kind === SyntaxKind.FunctionDeclaration) { + targetSymbol = symbol; + } + else if (isDeclarationOfFunctionOrClassExpression(symbol)) { + targetSymbol = (symbol.valueDeclaration as VariableDeclaration).initializer.symbol; + } + + // if there is a prototype property assignment like: + // foo.prototype.method = function () { } + // then the symbol for "foo" will have a member + return targetSymbol && targetSymbol.members && targetSymbol.members.size > 0; + } + } + + function getCodeActions(context: CodeFixContext): CodeAction[] { + const sourceFile = context.sourceFile; + const checker = context.program.getTypeChecker(); + const token = getTokenAtPosition(sourceFile, context.span.start); + const ctorSymbol = checker.getSymbolAtLocation(token); + + const deletes: (() => any)[] = []; + + if (!(ctorSymbol.flags & (SymbolFlags.Function | SymbolFlags.Variable))) { + return []; + } + + const ctorDeclaration = ctorSymbol.valueDeclaration; + const changeTracker = textChanges.ChangeTracker.fromCodeFixContext(context); + + let precedingNode: Node; + let newClassDeclaration: ClassDeclaration; + switch (ctorDeclaration.kind) { + case SyntaxKind.FunctionDeclaration: + precedingNode = ctorDeclaration; + deletes.push(() => changeTracker.deleteNode(sourceFile, ctorDeclaration)); + newClassDeclaration = createClassFromFunctionDeclaration(ctorDeclaration as FunctionDeclaration); + break; + + case SyntaxKind.VariableDeclaration: + precedingNode = ctorDeclaration.parent.parent; + if ((ctorDeclaration.parent).declarations.length === 1) { + deletes.push(() => changeTracker.deleteNode(sourceFile, precedingNode)); + } + else { + deletes.push(() => changeTracker.deleteNodeInList(sourceFile, ctorDeclaration)); + } + newClassDeclaration = createClassFromVariableDeclaration(ctorDeclaration as VariableDeclaration); + break; + } + + if (!newClassDeclaration) { + return []; + } + + // Because the preceding node could be touched, we need to insert nodes before delete nodes. + changeTracker.insertNodeAfter(sourceFile, precedingNode, newClassDeclaration, { suffix: "\n" }); + for (const deleteCallback of deletes) { + deleteCallback(); + } + + return [{ + description: `Convert function ${ctorSymbol.name} to ES6 class`, + changes: changeTracker.getChanges() + }]; + + function createClassElementsFromSymbol(symbol: Symbol) { + const memberElements: ClassElement[] = []; + // all instance members are stored in the "member" array of symbol + if (symbol.members) { + symbol.members.forEach(member => { + const memberElement = createClassElement(member, /*modifiers*/ undefined); + if (memberElement) { + memberElements.push(memberElement); + } + }); + } + + // all static members are stored in the "exports" array of symbol + if (symbol.exports) { + symbol.exports.forEach(member => { + const memberElement = createClassElement(member, [createToken(SyntaxKind.StaticKeyword)]); + if (memberElement) { + memberElements.push(memberElement); + } + }); + } + + return memberElements; + + function createClassElement(symbol: Symbol, modifiers: Modifier[]): ClassElement { + // both properties and methods are bound as property symbols + if (!(symbol.flags & SymbolFlags.Property)) { + return; + } + + const memberDeclaration = symbol.valueDeclaration as PropertyAccessExpression; + const assignmentBinaryExpression = memberDeclaration.parent as BinaryExpression; + + // delete the entire statement if this expression is the sole expression to take care of the semicolon at the end + const nodeToDelete = assignmentBinaryExpression.parent && assignmentBinaryExpression.parent.kind === SyntaxKind.ExpressionStatement + ? assignmentBinaryExpression.parent : assignmentBinaryExpression; + deletes.push(() => changeTracker.deleteNode(sourceFile, nodeToDelete)); + + if (!assignmentBinaryExpression.right) { + return createProperty([], modifiers, symbol.name, /*questionToken*/ undefined, + /*type*/ undefined, /*initializer*/ undefined); + } + + switch (assignmentBinaryExpression.right.kind) { + case SyntaxKind.FunctionExpression: + const functionExpression = assignmentBinaryExpression.right as FunctionExpression; + return createMethodDeclaration(/*decorators*/ undefined, modifiers, /*asteriskToken*/ undefined, memberDeclaration.name, /*questionToken*/ undefined, + /*typeParameters*/ undefined, functionExpression.parameters, /*type*/ undefined, functionExpression.body); + + case SyntaxKind.ArrowFunction: + const arrowFunction = assignmentBinaryExpression.right as ArrowFunction; + const arrowFunctionBody = arrowFunction.body; + let bodyBlock: Block; + + // case 1: () => { return [1,2,3] } + if (arrowFunctionBody.kind === SyntaxKind.Block) { + bodyBlock = arrowFunctionBody as Block; + } + // case 2: () => [1,2,3] + else { + const expression = arrowFunctionBody as Expression; + bodyBlock = createBlock([createReturn(expression)]); + } + return createMethodDeclaration(/*decorators*/ undefined, modifiers, /*asteriskToken*/ undefined, memberDeclaration.name, /*questionToken*/ undefined, + /*typeParameters*/ undefined, arrowFunction.parameters, /*type*/ undefined, bodyBlock); + default: + return createProperty(/*decorators*/ undefined, modifiers, memberDeclaration.name, /*questionToken*/ undefined, + /*type*/ undefined, assignmentBinaryExpression.right); + } + } + } + + function createClassFromVariableDeclaration(node: VariableDeclaration): ClassDeclaration { + const initializer = node.initializer as FunctionExpression; + if (!initializer || initializer.kind !== SyntaxKind.FunctionExpression) { + return undefined; + } + + if (node.name.kind !== SyntaxKind.Identifier) { + return undefined; + } + + const memberElements = createClassElementsFromSymbol(initializer.symbol); + if (initializer.body) { + memberElements.unshift(createConstructor(/*decorators*/ undefined, /*modifiers*/ undefined, initializer.parameters, initializer.body)); + } + + return createClassDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, node.name, + /*typeParameters*/ undefined, /*heritageClauses*/ undefined, memberElements); + } + + function createClassFromFunctionDeclaration(node: FunctionDeclaration): ClassDeclaration { + const memberElements = createClassElementsFromSymbol(ctorSymbol); + if (node.body) { + memberElements.unshift(createConstructor(/*decorators*/ undefined, /*modifiers*/ undefined, node.parameters, node.body)); + } + return createClassDeclaration(/*decorators*/ undefined, /*modifiers*/ undefined, node.name, + /*typeParameters*/ undefined, /*heritageClauses*/ undefined, memberElements); + } + } +} \ No newline at end of file diff --git a/src/services/codefixes/fixes.ts b/src/services/codefixes/fixes.ts index ae1643dfa3baa..6f58f16ea0e73 100644 --- a/src/services/codefixes/fixes.ts +++ b/src/services/codefixes/fixes.ts @@ -9,3 +9,4 @@ /// /// /// +/// diff --git a/src/services/tsconfig.json b/src/services/tsconfig.json index 372e51b20de00..72a88a5c63b36 100644 --- a/src/services/tsconfig.json +++ b/src/services/tsconfig.json @@ -93,6 +93,7 @@ "codefixes/helpers.ts", "codefixes/importFixes.ts", "codefixes/unusedIdentifierFixes.ts", - "codefixes/disableJsDiagnostics.ts" + "codefixes/disableJsDiagnostics.ts", + "codefixes/convertFunctionToEs6Class.ts" ] } \ No newline at end of file diff --git a/tests/cases/fourslash/convertFunctionToEs6Class1.ts b/tests/cases/fourslash/convertFunctionToEs6Class1.ts new file mode 100644 index 0000000000000..1ffc536731453 --- /dev/null +++ b/tests/cases/fourslash/convertFunctionToEs6Class1.ts @@ -0,0 +1,27 @@ +/// + +// @allowNonTsExtensions: true +// @Filename: test123.js +//// [|function foo() { } +//// foo.prototype.instanceMethod1 = function() { return "this is name"; }; +//// foo.prototype.instanceMethod2 = () => { return "this is name"; }; +//// foo.prototype.instanceProp1 = "hello"; +//// foo.prototype.instanceProp2 = undefined; +//// foo.staticProp = "world"; +//// foo.staticMethod1 = function() { return "this is static name"; }; +//// foo.staticMethod2 = () => "this is static name";|] + + +verify.codeFixAvailable(); +verify.rangeAfterCodeFix( +`class foo { + constructor() { } + instanceMethod1() { return "this is name"; } + instanceMethod2() { return "this is name"; } + instanceProp1 = "hello"; + instanceProp2 = undefined; + static staticProp = "world"; + static staticMethod1() { return "this is static name"; } + static staticMethod2() { return "this is static name"; } +} +`, /*includeWhiteSpace*/ true, /*errorCode*/ undefined, /*index*/ 0); \ No newline at end of file diff --git a/tests/cases/fourslash/convertFunctionToEs6Class2.ts b/tests/cases/fourslash/convertFunctionToEs6Class2.ts new file mode 100644 index 0000000000000..ceda60a87667f --- /dev/null +++ b/tests/cases/fourslash/convertFunctionToEs6Class2.ts @@ -0,0 +1,27 @@ +/// + +// @allowNonTsExtensions: true +// @Filename: test123.js +//// [|var foo = function() { }; +//// foo.prototype.instanceMethod1 = function() { return "this is name"; }; +//// foo.prototype.instanceMethod2 = () => { return "this is name"; }; +//// foo.prototype.instanceProp1 = "hello"; +//// foo.prototype.instanceProp2 = undefined; +//// foo.staticProp = "world"; +//// foo.staticMethod1 = function() { return "this is static name"; }; +//// foo.staticMethod2 = () => "this is static name";|] + + +verify.codeFixAvailable(); +verify.rangeAfterCodeFix( +`class foo { + constructor() { } + instanceMethod1() { return "this is name"; } + instanceMethod2() { return "this is name"; } + instanceProp1 = "hello"; + instanceProp2 = undefined; + static staticProp = "world"; + static staticMethod1() { return "this is static name"; } + static staticMethod2() { return "this is static name"; } +} +`, /*includeWhiteSpace*/ true, /*errorCode*/ undefined, /*index*/ 0); \ No newline at end of file diff --git a/tests/cases/fourslash/convertFunctionToEs6Class3.ts b/tests/cases/fourslash/convertFunctionToEs6Class3.ts new file mode 100644 index 0000000000000..cdbc0ccd05e1b --- /dev/null +++ b/tests/cases/fourslash/convertFunctionToEs6Class3.ts @@ -0,0 +1,28 @@ +/// + +// @allowNonTsExtensions: true +// @Filename: test123.js +//// [|var bar = 10, foo = function() { }; +//// foo.prototype.instanceMethod1 = function() { return "this is name"; }; +//// foo.prototype.instanceMethod2 = () => { return "this is name"; }; +//// foo.prototype.instanceProp1 = "hello"; +//// foo.prototype.instanceProp2 = undefined; +//// foo.staticProp = "world"; +//// foo.staticMethod1 = function() { return "this is static name"; }; +//// foo.staticMethod2 = () => "this is static name";|] + + +verify.codeFixAvailable(); +verify.rangeAfterCodeFix( +`var bar = 10; +class foo { + constructor() { } + instanceMethod1() { return "this is name"; } + instanceMethod2() { return "this is name"; } + instanceProp1 = "hello"; + instanceProp2 = undefined; + static staticProp = "world"; + static staticMethod1() { return "this is static name"; } + static staticMethod2() { return "this is static name"; } +} +`, /*includeWhiteSpace*/ true, /*errorCode*/ undefined, /*index*/ 0); \ No newline at end of file