diff --git a/README.md b/README.md index 272bb43..f121f46 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ You can also enable all the recommended rules at once. Add `plugin:typescript/re | [`typescript/adjacent-overload-signatures`](./docs/rules/adjacent-overload-signatures.md) | Require that member overloads be consecutive (`adjacent-overload-signatures` from TSLint) | :heavy_check_mark: | | | [`typescript/array-type`](./docs/rules/array-type.md) | Requires using either `T[]` or `Array` for arrays (`array-type` from TSLint) | :heavy_check_mark: | :wrench: | | [`typescript/ban-types`](./docs/rules/ban-types.md) | Enforces that types will not to be used (`ban-types` from TSLint) | :heavy_check_mark: | :wrench: | +| [`typescript/callable-types`](./docs/rules/callable-types.md) | Use function types instead of interfaces with call signatures (`callable-types` from TSLint) | | :wrench: | | [`typescript/camelcase`](./docs/rules/camelcase.md) | Enforce camelCase naming convention | :heavy_check_mark: | | | [`typescript/class-name-casing`](./docs/rules/class-name-casing.md) | Require PascalCased class and interface names (`class-name` from TSLint) | :heavy_check_mark: | | | [`typescript/explicit-function-return-type`](./docs/rules/explicit-function-return-type.md) | Require explicit return types on functions and class methods | :heavy_check_mark: | | diff --git a/ROADMAP.md b/ROADMAP.md index a999117..8876489 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,4 +1,4 @@ -# Roadmap +# Roadmap ## TSLint rules @@ -126,7 +126,7 @@ | [`arrow-parens`] | 🌟 | [`arrow-parens`](https://eslint.org/docs/rules/arrow-parens) | | [`arrow-return-shorthand`] | 🌟 | [`arrow-body-style`](https://eslint.org/docs/rules/arrow-body-style) | | [`binary-expression-operand-order`] | 🌟 | [`yoda`](https://eslint.org/docs/rules/yoda) | -| [`callable-types`] | 🛑 | N/A | +| [`callable-types`] | ✅ | [`typescript/callable-types`] | | [`class-name`] | ✅ | [`typescript/class-name-casing`] | | [`comment-format`] | 🌟 | [`capitalized-comments`](https://eslint.org/docs/rules/capitalized-comments) & [`spaced-comment`](https://eslint.org/docs/rules/spaced-comment) | | [`completed-docs`] | 🔌 | [`eslint-plugin-jsdoc`](https://github.com/gajus/eslint-plugin-jsdoc) | diff --git a/docs/rules/callable-types.md b/docs/rules/callable-types.md new file mode 100644 index 0000000..716aca5 --- /dev/null +++ b/docs/rules/callable-types.md @@ -0,0 +1,57 @@ +# Use function types instead of interfaces with call signatures (callable-types) + +## Rule Details + +This rule suggests using a function type instead of an interface or object type literal with a single call signature. + +Examples of **incorrect** code for this rule: + +```ts +interface Foo { + (): string; +} +``` + +```ts +function foo(bar: { (): number }): number { + return bar(); +} +``` + +```ts +interface Foo extends Function { + (): void; +} +``` + +Examples of **correct** code for this rule: + +```ts +interface Foo { + (): void; + bar: number; +} +``` + +```ts +function foo(bar: { (): string; baz: number }): string { + return bar(); +} +``` + +```ts +interface Foo { + bar: string; +} +interface Bar extends Foo { + (): void; +} +``` + +## When Not To Use It + +If you specifically want to use an interface or type literal with a single call signature for stylistic reasons, you can disable this rule. + +## Further Reading + +- TSLint: [`callable-types`](https://palantir.github.io/tslint/rules/callable-types/) diff --git a/lib/rules/callable-types.js b/lib/rules/callable-types.js new file mode 100644 index 0000000..943ded0 --- /dev/null +++ b/lib/rules/callable-types.js @@ -0,0 +1,199 @@ +/** + * @fileoverview Use function types instead of interfaces with call signatures + * @author Benjamin Lichtman + */ +"use strict"; +const ts = require("typescript"); +const tsutils = require("tsutils"); +const util = require("../util"); + +/** + * @typedef {import("eslint").Rule.RuleModule} RuleModule + * @typedef {import("estree").Node} ESTreeNode + */ + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +/** + * @type {RuleModule} + */ +module.exports = { + meta: { + docs: { + description: + "Use function types instead of interfaces with call signatures", + category: "Style", + recommended: false, + extraDescription: [util.tslintRule("callable-types")], + url: util.metaDocsUrl("callable-types"), + }, + fixable: "code", // or "code" or "whitespace" + schema: [], + type: "suggestion", + }, + + create(context) { + // variables should be defined here + + //---------------------------------------------------------------------- + // Helpers + //---------------------------------------------------------------------- + + /** + * @param {string} type The incorrect type of callable construct + * @param {string} sigSuggestion The recommended type of callable construct + * @returns {string} The error message + */ + function failureMessage(type, sigSuggestion) { + return `${type} has only a call signature - use \`${sigSuggestion}\` instead.`; + } + + /** + * Checks if there is no supertype or if the supertype is 'Function' + * @param {ESTreeNode} node The node being checked + * @returns {boolean} Returns true iff there is no supertype or if the supertype is 'Function' + */ + function noSupertype(node) { + if (typeof node.heritage === "undefined") { + return true; + } + if (node.heritage.length !== 1) { + return node.heritage.length === 0; + } + const expr = node.heritage[0].id; + + return ( + util.esTreeNodeHasKind(expr, ts.SyntaxKind.Identifier) && + expr.name === "Function" + ); + } + + /** + * @param {ESTreeNode} parent The parent of the call signature causing the diagnostic + * @returns {boolean} true iff the parent node needs to be wrapped for readability + */ + function shouldWrapSuggestion(parent) { + switch (parent.type) { + case util.getESTreeType(ts.SyntaxKind.UnionType): + case util.getESTreeType(ts.SyntaxKind.IntersectionType): + case util.getESTreeType(ts.SyntaxKind.ArrayType): + return true; + default: + return false; + } + } + + /** + * @param {ESTreeNode} call The call signature causing the diagnostic + * @param {ESTreeNode} parent The parent of the call + * @returns {string} The suggestion to report + */ + function renderSuggestion(call, parent) { + const sourceCode = context.getSourceCode(); + const start = call.range[0]; + const colonPos = call.typeAnnotation.range[0] - start; + const text = sourceCode.getText().slice(start, call.range[1]); + + let suggestion = `${text.slice(0, colonPos)} =>${text.slice( + colonPos + 1 + )}`; + + if (shouldWrapSuggestion(parent.parent)) { + suggestion = `(${suggestion})`; + } + if ( + util.esTreeNodeHasKind( + parent, + ts.SyntaxKind.InterfaceDeclaration + ) + ) { + if (typeof parent.typeParameters !== "undefined") { + return `type${sourceCode + .getText() + .slice( + parent.name.pos, + parent.typeParameters.end + 1 + )} = ${suggestion}`; + } + return `type ${parent.id.name} = ${suggestion}`; + } + return suggestion.endsWith(";") + ? suggestion.slice(0, -1) + : suggestion; + } + + /** + * @param {ESTreeNode} member The potential call signature being checked + * @param {ESTreeNode} node The node being checked + * @returns {void} + */ + function checkMember(member, node) { + if ( + util.esTreeNodeHasKind(member, ts.SyntaxKind.CallSignature) && + typeof member.typeAnnotation !== "undefined" + ) { + const suggestion = renderSuggestion(member, node); + const fixStart = util.esTreeNodeHasKind( + node, + ts.SyntaxKind.TypeLiteral + ) + ? node.range[0] + : tsutils + .getChildOfKind( + context.parserServices.esTreeNodeToTSNodeMap.get( + node + ), + ts.SyntaxKind.InterfaceKeyword + ) + .getStart(); + + context.report({ + node: member, + message: failureMessage( + util.esTreeNodeHasKind(node, ts.SyntaxKind.TypeLiteral) + ? "Type literal" + : "Interface", + suggestion + ), + fix(fixer) { + return fixer.replaceTextRange( + [fixStart, node.range[1]], + suggestion + ); + }, + }); + } + } + + //---------------------------------------------------------------------- + // Public + //---------------------------------------------------------------------- + + return { + /** + * @param {ESTreeNode} node The node being checked + * @returns {void} + */ + TSInterfaceDeclaration(node) { + if (noSupertype(node) && node.body.body.length === 1) { + checkMember(node.body.body[0], node); + } + }, + /** + * Won't work until type annotations are visited + * @param {ESTreeNode} node The node being checked + * @returns {void} + */ + TSTypeLiteral(node) { + if ( + util.esTreeNodeHasKind(node, ts.SyntaxKind.TypeLiteral) && + node.members.length === 1 + ) { + checkMember(node.members[0], node); + } + }, + }; + }, +}; diff --git a/lib/util.js b/lib/util.js index 006a71e..a81d736 100644 --- a/lib/util.js +++ b/lib/util.js @@ -1,5 +1,7 @@ "use strict"; +const parser = require("typescript-eslint-parser"); +const ts = require("typescript"); const version = require("../package.json").version; exports.tslintRule = name => `\`${name}\` from TSLint`; @@ -126,3 +128,24 @@ exports.getParserServices = context => { } return context.parserServices; }; + +/** + * @param {ts.SyntaxKind} syntaxKind A TS syntax kind + * @returns {string} The corresponding string representation + */ +function getESTreeType(syntaxKind) { + const tsSyntaxKindName = ts.SyntaxKind[syntaxKind]; + + return parser.Syntax[tsSyntaxKindName] || `TS${tsSyntaxKindName}`; +} + +exports.getESTreeType = getESTreeType; + +/** + * @param {ESTreeNode} node An ESTree node + * @param {ts.SyntaxKind} kind A TS node kind + * @returns {boolean} Returns true iff the ESTree node has the corresponding TS kind + */ +exports.esTreeNodeHasKind = function(node, kind) { + return node.type === getESTreeType(kind); +}; diff --git a/package.json b/package.json index 5fdf6bb..d6b7fce 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,11 @@ }, "dependencies": { "requireindex": "^1.2.0", + "tsutils": "^3.6.0", "typescript-eslint-parser": "21.0.2" }, "devDependencies": { "eslint": "^5.9.0", - "nyc": "^13.1.0", "eslint-config-eslint": "^5.0.1", "eslint-config-prettier": "^3.3.0", "eslint-docs": "^0.2.6", @@ -42,6 +42,7 @@ "husky": "^1.2.0", "lint-staged": "^8.1.0", "mocha": "^5.2.0", + "nyc": "^13.1.0", "prettier-eslint-cli": "^4.7.1", "typescript": "~3.1.1" }, diff --git a/tests/lib/fixtures/tsconfig.json b/tests/lib/fixtures/tsconfig.json new file mode 100644 index 0000000..1eced77 --- /dev/null +++ b/tests/lib/fixtures/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "strict": true, + "esModuleInterop": true + } +} diff --git a/tests/lib/rules/callable-types.js b/tests/lib/rules/callable-types.js new file mode 100644 index 0000000..59ffe3a --- /dev/null +++ b/tests/lib/rules/callable-types.js @@ -0,0 +1,115 @@ +/** + * @fileoverview Use function types instead of interfaces with call signatures + * @author Benjamin Lichtman + */ +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require("../../../lib/rules/callable-types"), + RuleTester = require("eslint").RuleTester, + path = require("path"); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const rootDir = path.join(process.cwd(), "tests/lib/fixtures"); +const parserOptions = { + ecmaVersion: 2015, + tsconfigRootDir: rootDir, + project: "./tsconfig.json", +}; +const ruleTester = new RuleTester({ + parserOptions, + parser: "typescript-eslint-parser", +}); + +ruleTester.run("callable-types", rule, { + valid: [ + ` +interface Foo { + (): void; + bar: number; +}`, + ` +type Foo = { + (): void; + bar: number; +}`, + ` +function foo(bar: { (): string, baz: number }): string { + return bar(); +}`, + ` +interface Foo { + bar: string; +} +interface Bar extends Foo { + (): void; +}`, + ], + + invalid: [ + { + code: ` +interface Foo { + (): string; +}`, + errors: [ + { + message: `Interface has only a call signature - use \`type Foo = () => string;\` instead.`, + type: "TSCallSignature", + }, + ], + output: ` +type Foo = () => string;`, + }, + { + code: ` +type Foo = { + (): string; +}`, + errors: [ + { + message: `Type literal has only a call signature - use \`() => string\` instead.`, + type: "TSCallSignature", + }, + ], + output: ` +type Foo = () => string`, + }, + { + code: ` +function foo(bar: { (s: string): number }): number { + return bar("hello"); +}`, + errors: [ + { + message: `Type literal has only a call signature - use \`(s: string) => number\` instead.`, + type: "TSCallSignature", + }, + ], + output: ` +function foo(bar: (s: string) => number): number { + return bar("hello"); +}`, + }, + { + code: ` +interface Foo extends Function { + (): void; +}`, + errors: [ + { + message: `Interface has only a call signature - use \`type Foo = () => void;\` instead.`, + type: "TSCallSignature", + }, + ], + output: ` +type Foo = () => void;`, + }, + ], +}); diff --git a/yarn.lock b/yarn.lock index 08fbd50..e6c5331 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3355,11 +3355,18 @@ trim-right@^1.0.1: resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= -tslib@^1.9.0: +tslib@^1.8.1, tslib@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== +tsutils@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.6.0.tgz#33fc3ddb74abf5bba10789acd03eee6ea96c839c" + integrity sha512-hCG3lZz+uRmmiC4brr/kY6Yuypnl20PNe8t49DO4OUGlbxWkxYHF63EeG2XPSd0JcKiWmp9p55yQyrkxqSS5Dg== + dependencies: + tslib "^1.8.1" + type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72"