diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2d38f01 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[{*.json,*.yml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..c931c8c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +!.eslintrc.base.js +!.eslintrc.js +!.prettierrc.js +!jest.config.js +node_modules/ +dist/ diff --git a/.eslintrc.base.js b/.eslintrc.base.js new file mode 100644 index 0000000..f81ff17 --- /dev/null +++ b/.eslintrc.base.js @@ -0,0 +1,30 @@ +module.exports = { + env: { + node: true, + }, + extends: [ + 'eslint:recommended', + ], + parserOptions: { + ecmaVersion: 8, + sourceType: 'module', + }, + plugins: [ + 'prettier', + ], + settings: { + 'import/resolver': { + 'typescript': {}, + 'node': { + 'extensions': ['.js', '.ts'], + 'paths': ['node_modules/', 'node_modules/@types'], + }, + }, + }, + rules: { + // 'import/no-extraneous-dependencies': [2, { 'devDependencies': ['**/test.ts'] }], + 'array-bracket-spacing': ['error', 'never'], + 'object-curly-spacing': ['error', 'always'], + 'quotes': ['error', 'single'], + }, +}; diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..c8d2831 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ['./.eslintrc.base.js'], + rules: { + 'no-prototype-builtins': 'off', + }, +}; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5fdd313 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [ dev ] + pull_request: + branches: [ dev ] + +jobs: + + unit: + name: Unit tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '12.x' + - run: npm install + - run: npm run test + + coverage: + name: Jest coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '12.x' + - run: npm install + - uses: dkershner6/jest-coverage-commenter-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + test_command: npm run test:ci diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..763159a --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +## @public-js/eslint-plugin + +/.git +/.svn +/.idea +/.vscode +.gitattributes +.npmrc +/dist + +/node_modules +/jspm_packages +/.yarn +/.pnp +/vendor +.pnp.js +package-lock.json + +.nyc_output +.grunt +/coverage + +# logs +.firebase +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# misc +*~ +*.lnk +.DS_Store +[Dd]esktop.ini +Thumbs.db* +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..c01df68 --- /dev/null +++ b/.npmignore @@ -0,0 +1,49 @@ +## @public-js/eslint-plugin + +.editorconfig +.eslintignore +.eslintrc.base.js +.eslintrc.js +.gitignore +.npmignore +.prettierrc.js +jest.config.js +tsconfig.base.json +tsconfig.json +CODE_OF_CONDUCT.md +CONTRIBUTING.md + +/.git +/.svn +/.idea +/.vscode +/.github +.gitattributes +.npmrc + +/node_modules +/jspm_packages +/.yarn +/.pnp +/vendor +.pnp.js +package-lock.json + +.nyc_output +.grunt +/coverage + +# logs +.firebase +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# misc +*~ +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..ea91f29 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,11 @@ +module.exports = { + semi: true, + trailingComma: 'es5', + singleQuote: true, + printWidth: 110, + proseWrap: 'never', + endOfLine: 'lf', + tabWidth: 4, + useTabs: false, + bracketSpacing: true +}; diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9d07f9 --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# @public-js/pluralize + +[![CI](https://img.shields.io/github/workflow/status/public-js/eslint-plugin/CI?style=flat-square)](https://github.com/public-js/pluralize/actions?query=workflow%3ACI) +[![Downloads](https://img.shields.io/npm/dm/@public-js/eslint-plugin?style=flat-square)](https://www.npmjs.com/package/@public-js/pluralize) +[![Version](https://img.shields.io/npm/v/@public-js/eslint-plugin?style=flat-square)](https://www.npmjs.com/package/@public-js/pluralize) +[![License](https://img.shields.io/npm/l/@public-js/eslint-plugin?style=flat-square)](https://www.npmjs.com/package/@public-js/pluralize) + +--- + +Helpful ESLint rules especially for those using TypeScript. + + +## Getting Started + +Add the required packages to your project by running: +```shell +npm install eslint typescript @typescript-eslint/parser @public-js/eslint-plugin --save-dev +``` + +Then configure ESLint as you wish. + +And finally add the following to your `eslintrc` file (or just modify the required properties): +```javascript +// ... +parser: '@typescript-eslint/parser' +// ... +parserOptions: { + ecmaFeatures: { jsx: true } +} +// ... +plugins: ['@public-js'] // append this plugin, don't replace everything +// ... +``` + + +## Rules + +#### rn-stylesheet-rational +Sorting React Native Stylesheet's properties in rational order + +Usage: +```javascript +'@public-js/rn-stylesheet-rational': ['warn', { borderInBoxModel: false }] +``` diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..cce8d6c --- /dev/null +++ b/jest.config.js @@ -0,0 +1,20 @@ +'use strict'; + +// @ts-check +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + globals: { + 'ts-jest': { + isolatedModules: true, + }, + }, + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + testRegex: './tests/.+\\.test\\.ts$', + collectCoverage: false, + collectCoverageFrom: ['src/**/*.{js,jsx,ts,tsx}'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + coverageReporters: ['text-summary', 'lcov'], +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..56844d6 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "@public-js/eslint-plugin", + "version": "0.0.1", + "description": "Helpful ESLint rules especially for those using TypeScript", + "scripts": { + "build": "npm run clean && tsc -b tsconfig.build.json", + "clean": "rimraf dist", + "test:ci": "jest --ci --maxWorkers=3 --coverage --json --outputFile=jest.results.json", + "test": "jest", + "ts:watch": "tsc -w --preserveWatchOutput", + "npm-pub:test": "npm run build && npm publish --access public --dry-run", + "npm-pub": "npm run build && npm publish --access public" + }, + "main": "dist/index.js", + "dependencies": { + "eslint-plugin-react-native-globals": ">=0.1.1", + "tsutils": "^3.17.1" + }, + "devDependencies": { + "@types/jest": "^26.0.10", + "@types/marked": "^1.1.0", + "@types/node": "^14.6.0", + "@types/prettier": "*", + "@typescript-eslint/experimental-utils": "^4.8.2", + "chalk": "^4.0.0", + "eslint": "^7.8.1", + "eslint-config-prettier": "^6.11.0", + "eslint-plugin-import": "^2.22.0", + "eslint-plugin-json": "^2.1.2", + "eslint-plugin-prettier": "^3.1.4", + "marked": "^1.0.0", + "prettier": "^2.1.1", + "rimraf": "^3.0.0", + "typescript": "~4.1.2" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^4.0.0", + "eslint": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "files": [ + "dist" + ], + "license": "MIT", + "author": "Public JS (https://github.com/public-js/public-js)", + "bugs": { + "url": "https://github.com/public-js/eslint-plugin/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/public-js/eslint-plugin.git" + }, + "homepage": "https://github.com/public-js/eslint-plugin", + "engines": { + "node": ">=10.3.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..05c7c84 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,3 @@ +import rules from './rules'; + +export = { rules }; diff --git a/src/rules/index.ts b/src/rules/index.ts new file mode 100644 index 0000000..2556034 --- /dev/null +++ b/src/rules/index.ts @@ -0,0 +1,5 @@ +import rnStylesheetRational from './rn-stylesheet-rational'; + +export default { + 'rn-stylesheet-rational': rnStylesheetRational, +}; diff --git a/src/rules/rational-order-groups.ts b/src/rules/rational-order-groups.ts new file mode 100644 index 0000000..28e041c --- /dev/null +++ b/src/rules/rational-order-groups.ts @@ -0,0 +1,287 @@ +type TOrderGroup = string[]; + +/* 1. Positioning */ +const positioning: TOrderGroup = [ + 'position', + 'top', + 'right', + 'bottom', + 'left', + 'zIndex' +]; + +/* Border */ +const borderProps: TOrderGroup = [ + 'border', + 'borderColor', + 'borderStyle', + 'borderWidth', + 'borderTop', + 'borderTopColor', + 'borderTopWidth', + 'borderTopStyle', + 'borderRight', + 'borderRightColor', + 'borderRightWidth', + 'borderRightStyle', + 'borderBottom', + 'borderBottomColor', + 'borderBottomWidth', + 'borderBottomStyle', + 'borderLeft', + 'borderLeftColor', + 'borderLeftWidth', + 'borderLeftStyle', + 'borderRadius', + 'borderTopLeftRadius', + 'borderTopRightRadius', + 'borderBottomRightRadius', + 'borderBottomLeftRadius', + 'borderImage', + 'borderImageSource', + 'borderImageSlice', + 'borderImageWidth', + 'borderImageOutset', + 'borderImageRepeat', + 'borderCollapse', + 'borderSpacing', +]; + +/* 2. Box Model */ +const boxModelPart1: TOrderGroup = [ + 'display', + 'flex', + 'flexBasis', + 'flexDirection', + 'flexFlow', + 'flexGrow', + 'flexShrink', + 'flexWrap', + 'grid', + 'gridArea', + 'gridAutoRows', + 'gridAutoColumns', + 'gridAutoFlow', + 'gridGap', + 'gridRow', + 'gridRowStart', + 'gridRowEnd', + 'gridRowGap', + 'gridColumn', + 'gridColumnStart', + 'gridColumnEnd', + 'gridColumnGap', + 'gridTemplate', + 'gridTemplateAreas', + 'gridTemplateRows', + 'gridTemplateColumns', + 'gap', + 'alignContent', + 'alignItems', + 'alignSelf', + 'justifyContent', + 'justifyItems', + 'justifySelf', + 'order', + 'float', + 'clear', + 'boxSizing', + 'width', + 'minWidth', + 'maxWidth', + 'height', + 'minHeight', + 'maxHeight', + 'margin', + 'marginTop', + 'marginRight', + 'marginBottom', + 'marginLeft', + 'padding', + 'paddingVertical', + 'paddingHorizontal', + 'paddingStart', + 'paddingEnd', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', +]; +const boxModelPart2: TOrderGroup = [ + 'objectFit', + 'objectPosition', + 'overflow', + 'overflowX', + 'overflowY' +]; +const boxModel = ({ border }: { border: boolean }): TOrderGroup => [ + ...boxModelPart1, + ...(border ? borderProps : []), + ...boxModelPart2 +]; + +/* 3. Typography */ +const typography: TOrderGroup = [ + 'color', + 'font', + 'fontWeight', + 'fontSize', + 'fontFamily', + 'fontStyle', + 'fontVariant', + 'fontSizeAdjust', + 'fontStretch', + 'fontEffect', + 'fontEmphasize', + 'fontEmphasizePosition', + 'fontEmphasizeStyle', + 'fontSmooth', + 'lineHeight', + 'direction', + 'letterSpacing', + 'whiteSpace', + 'textAlign', + 'textAlignLast', + 'textTransform', + 'textDecoration', + 'textEmphasis', + 'textEmphasisColor', + 'textEmphasisStyle', + 'textEmphasisPosition', + 'textIndent', + 'textJustify', + 'textOutline', + 'textWrap', + 'textOverflow', + 'textOverflowEllipsis', + 'textOverflowMode', + 'textOrientation', + 'textShadow', + 'verticalAlign', + 'wordWrap', + 'wordBreak', + 'wordSpacing', + 'overflowWrap', + 'tabSize', + 'hyphens', + 'unicodeBidi', + 'columns', + 'columnCount', + 'columnFill', + 'columnGap', + 'columnRule', + 'columnRuleColor', + 'columnRuleStyle', + 'columnRuleWidth', + 'columnSpan', + 'columnWidth', + 'pageBreakAfter', + 'pageBreakBefore', + 'pageBreakInside', + 'src', +]; + +/* 4. Visual */ +const visualPart1: TOrderGroup = [ + 'listStyle', + 'listStylePosition', + 'listStyleType', + 'listStyleImage', + 'tableLayout', + 'emptyCells', + 'captionSide', + 'background', + 'backgroundColor', + 'backgroundImage', + 'backgroundRepeat', + 'backgroundPosition', + 'backgroundPositionX', + 'backgroundPositionY', + 'backgroundSize', + 'backgroundClip', + 'backgroundOrigin', + 'backgroundAttachment', + 'backgroundBlendMode', +]; +const visualPart2: TOrderGroup = [ + 'outline', + 'outlineWidth', + 'outlineStyle', + 'outlineColor', + 'outlineOffset', + 'boxShadow', + 'boxDecorationBreak', + 'transform', + 'transformOrigin', + 'transformStyle', + 'backfaceVisibility', + 'perspective', + 'perspectiveOrigin', + 'visibility', + 'cursor', + 'opacity', + 'filter', + 'isolation', + 'backdropFilter', + 'mixBlendMode', +]; +const visual = ({ border }: { border: boolean }): TOrderGroup => [ + ...visualPart1, + ...(border ? borderProps : []), + ...visualPart2 +]; + +/* 5. Animation */ +const animation: TOrderGroup = [ + 'transition', + 'transitionDelay', + 'transitionTimingFunction', + 'transitionDuration', + 'transitionProperty', + 'animation', + 'animationName', + 'animationDuration', + 'animationPlayState', + 'animationTimingFunction', + 'animationDelay', + 'animationIterationCount', + 'animationDirection', + 'animationFillMode', +]; + +/* 6. Miscellaneous */ +const misc: TOrderGroup = [ + 'appearance', + 'content', + 'clip', + 'clipPath', + 'counterReset', + 'counterIncrement', + 'resize', + 'userSelect', + 'navIndex', + 'navUp', + 'navRight', + 'navDown', + 'navLeft', + 'pointerEvents', + 'quotes', + 'touchAction', + 'willChange', + 'zoom', + 'fill', + 'fillRule', + 'clipRule', + 'stroke', +]; + +/* combine */ +const orderGroups = (borderInBoxModel = false): TOrderGroup => [ + ...positioning, + ...boxModel({ border: borderInBoxModel }), + ...typography, + ...visual({ border: !borderInBoxModel }), + ...animation, + ...misc, +]; +export default orderGroups; diff --git a/src/rules/rn-stylesheet-rational.ts b/src/rules/rn-stylesheet-rational.ts new file mode 100644 index 0000000..f1dc676 --- /dev/null +++ b/src/rules/rn-stylesheet-rational.ts @@ -0,0 +1,160 @@ +import { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils'; +import { RuleContext } from '@typescript-eslint/experimental-utils/dist/ts-eslint/Rule'; +import * as util from '../util'; + +import orderGroups from './rational-order-groups'; + +const ruleCreate = (context: Readonly>, [options]: Readonly) => { + // const ignoreClassNames = options.ignoreClassNames ?? false; + // const ignoreStyleProperties = options.ignoreStyleProperties ?? false; + const rationalGroups = orderGroups(options.borderInBoxModel ?? false); + const sourceCode = context.getSourceCode(); + + const findIx = (prop: string): number => { + const ix = rationalGroups.indexOf(prop); + return ix >= 0 ? ix : 99999; + }; + const isValidOrder = (a: string, b: string): boolean => findIx(a) <= findIx(b); + + function sort(array: TSESTree.Node[]) { + return [...array].sort((a, b) => { + const identifierA = findIx(util.astHelpersRN.getStylePropertyIdentifier(a)); + const identifierB = findIx(util.astHelpersRN.getStylePropertyIdentifier(b)); + if (identifierA < identifierB) { + return -1; + } else if (identifierA > identifierB) { + return 1; + } + return 0; + }); + } + + // @ts-ignore + function report(array: TSESTree.Node[], type: any, node: any, prev: TSESTree.Property, current: TSESTree.Property) { + const currentName = util.astHelpersRN.getStylePropertyIdentifier(current); + const prevName = util.astHelpersRN.getStylePropertyIdentifier(prev); + + const hasComments = array + .map((prop: TSESTree.Node) => + sourceCode.getComments(prop)) + .reduce((hasComment: boolean, comment) => + hasComment || comment.leading.length > 0 || comment.trailing.length > 0, // trailing length ? + false); + + context.report({ + node, + messageId: 'propsOrder', + data: { + currentName, + prevName, + }, + fix: hasComments + ? undefined + : (fixer): TSESLint.RuleFix[] => { + const sortedArray = sort(array); + return array + .map((item: TSESTree.Node, i: number) => + item === sortedArray[i] + ? null + : fixer.replaceText(item, sourceCode.getText(sortedArray[i]))) + .filter(item => !!item) as TSESLint.RuleFix[]; + }, + }); + } + + function checkIsSorted(array: TSESTree.Node[], arrayName: string, node: any) { + for (let i = 1; i < array.length; i += 1) { + const previous = array[i - 1]; + const current = array[i]; + + if (previous.type !== 'Property' || current.type !== 'Property') { + return; + } + + const prevName = util.astHelpersRN.getStylePropertyIdentifier(previous); + const currentName = util.astHelpersRN.getStylePropertyIdentifier(current); + + if (!isValidOrder(prevName, currentName)) { + return report(array, arrayName, node, previous, current); + } + } + } + + return { + CallExpression: function (node: TSESTree.Node) { + if (!util.astHelpersRN.isStyleSheetDeclaration(node, context.settings)) { + return; + } + const classDefinitionsChunks = util.astHelpersRN.getStyleDeclarationsChunks(node); + + // if (!ignoreClassNames) { + // classDefinitionsChunks.forEach((classDefinitions) => { + // checkIsSorted(classDefinitions, 'class names', node); + // }); + // } + + // if (ignoreStyleProperties) { + // return; + // } + + classDefinitionsChunks.forEach((classDefinitions) => { + classDefinitions.forEach((classDefinition) => { + // @ts-ignore + const styleProperties = classDefinition.value.properties; + if (!styleProperties || styleProperties.length < 2) { + return; + } + const stylePropertyChunks = util.astHelpersRN.getPropertiesChunks(styleProperties); + stylePropertyChunks.forEach((stylePropertyChunk) => { + checkIsSorted(stylePropertyChunk, 'style properties', node); + }); + }); + }); + }, + }; +}; + +export type Options = [{ + borderInBoxModel?: boolean; + // ignoreClassNames?: boolean; + // ignoreStyleProperties?: boolean; +}]; + +export type MessageIds = 'propsOrder'; + +export default util.createRule({ + name: 'rn-stylesheet-rational', + meta: { + type: 'layout', + docs: { + description: 'Bans specific types from being used', + category: 'Best Practices', + recommended: 'warn', + }, + fixable: 'code', + messages: { + propsOrder: "Property `{{currentName}}` should be before `{{prevName}}`.", + }, + schema: [{ + type: 'object', + properties: { + borderInBoxModel: { + type: 'boolean' + }, + // ignoreClassNames: { + // type: 'boolean' + // }, + // ignoreStyleProperties: { + // type: 'boolean' + // } + }, + additionalProperties: false + }] + }, + defaultOptions: [{ + borderInBoxModel: false, + // ignoreClassNames: false, + // ignoreStyleProperties: false + }], + create: ruleCreate, +}); diff --git a/src/util/astHelpersRN.ts b/src/util/astHelpersRN.ts new file mode 100644 index 0000000..b1a5ef0 --- /dev/null +++ b/src/util/astHelpersRN.ts @@ -0,0 +1,393 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/experimental-utils'; + +export type ESTreeProp = TSESTree.ObjectLiteralElementLike | TSESTree.Property | TSESTree.RestElement; + +// @ts-ignore +let currentContent; +// @ts-ignore +const getSourceCode = (node: TSESTree.ConditionalExpression) => currentContent + .getSourceCode(node) + .getText(node); + +// @ts-ignore +const getStyleSheetObjectNames = (settings) => settings['react-native/style-sheet-object-names'] || ['StyleSheet']; + +// @ts-ignore +export const astHelpersRN = { + containsStyleSheetObject: function ( + node: TSESTree.Node, + objectNames: Array + ): boolean { + return Boolean( + node + && node.type === AST_NODE_TYPES.CallExpression + && node.callee + && 'object' in node.callee && node.callee.object + && 'name' in node.callee.object && node.callee.object.name + && objectNames.includes(node.callee.object.name) + ); + }, + + containsCreateCall: function (node: TSESTree.Node): boolean { + return Boolean( + node + && 'callee' in node && node.callee + && 'property' in node.callee && node.callee.property + && 'name' in node.callee.property && node.callee.property.name + && node.callee.property.name === 'create' + ); + }, + + // @ts-ignore + isStyleSheetDeclaration: function (node: TSESTree.Node, settings): boolean { + const objectNames = getStyleSheetObjectNames(settings); + return Boolean( + astHelpersRN.containsStyleSheetObject(node, objectNames) + && astHelpersRN.containsCreateCall(node) + ); + }, + + // getStyleSheetName: function (node) { + // if (node && node.parent && node.parent.id) { + // return node.parent.id.name; + // } + // }, + + // getStyleDeclarations: function (node) { + // if ( + // node + // && node.type === 'CallExpression' + // && node.arguments + // && node.arguments[0] + // && node.arguments[0].properties + // ) { + // return node.arguments[0].properties.filter((property) => property.type === 'Property'); + // } + // return []; + // }, + + getStyleDeclarationsChunks: function (node: TSESTree.Node): ESTreeProp[][] { + if ( + node + && node.type === AST_NODE_TYPES.CallExpression + && node.arguments + && node.arguments[0] + && 'properties' in node.arguments[0] && node.arguments[0].properties + ) { + const { properties } = node.arguments[0]; + const result: ESTreeProp[][] = []; + let chunk: ESTreeProp[] = []; + for (let i = 0; i < properties.length; i += 1) { + const property = properties[i]; + if (property.type === AST_NODE_TYPES.Property) { + chunk.push(property); + } else if (chunk.length) { + result.push(chunk); + chunk = []; + } + } + if (chunk.length) { + result.push(chunk); + } + return result; + } + return []; + }, + + // @ts-ignore + getPropertiesChunks: function (properties: ESTreeProp[]): ESTreeProp[][] { + const result: ESTreeProp[][] = []; + let chunk: ESTreeProp[] = []; + for (let i = 0; i < properties.length; i += 1) { + const property = properties[i]; + if (property.type === AST_NODE_TYPES.Property) { + chunk.push(property); + } else if (chunk.length) { + result.push(chunk); + chunk = []; + } + } + if (chunk.length) { + result.push(chunk); + } + return result; + }, + + getExpressionIdentifier: function (node: TSESTree.Node): string { + if (node) { + switch (node.type) { + case AST_NODE_TYPES.Identifier: + return node.name; + case AST_NODE_TYPES.Literal: + return node.value ? node.value.toString() : ''; + case AST_NODE_TYPES.TemplateLiteral: + return node.quasis.reduce((result, quasi, index) => result + + quasi.value.cooked + + astHelpersRN.getExpressionIdentifier(node.expressions[index]), + ''); + default: + return ''; + } + } + return ''; + }, + + getStylePropertyIdentifier: function (node: TSESTree.Node): string { + if (node && 'key' in node && node.key) { + return astHelpersRN.getExpressionIdentifier(node.key); + } + return ''; // fallback ? + }, + + // isStyleAttribute: function (node) { + // return Boolean( + // node.type === 'JSXAttribute' + // && node.name + // && node.name.name + // && node.name.name.toLowerCase().includes('style') + // ); + // }, + + // collectStyleObjectExpressions: function (node, context) { + // currentContent = context; + // if (astHelpersRN.hasArrayOfStyleReferences(node)) { + // const styleReferenceContainers = node.expression.elements; + // return astHelpersRN.collectStyleObjectExpressionFromContainers(styleReferenceContainers); + // } if (node && node.expression) { + // return astHelpersRN.getStyleObjectExpressionFromNode(node.expression); + // } + // return []; + // }, + + // collectColorLiterals: function (node, context) { + // if (!node) { + // return []; + // } + // currentContent = context; + // if (astHelpersRN.hasArrayOfStyleReferences(node)) { + // const styleReferenceContainers = node.expression.elements; + // return astHelpersRN.collectColorLiteralsFromContainers(styleReferenceContainers); + // } + // if (node.type === 'ObjectExpression') { + // return astHelpersRN.getColorLiteralsFromNode(node); + // } + // return astHelpersRN.getColorLiteralsFromNode(node.expression); + // }, + + // collectStyleObjectExpressionFromContainers: function (nodes) { + // let objectExpressions = []; + // nodes.forEach((node) => { + // objectExpressions = objectExpressions + // .concat(astHelpersRN.getStyleObjectExpressionFromNode(node)); + // }); + // return objectExpressions; + // }, + + // collectColorLiteralsFromContainers: function (nodes) { + // let colorLiterals = []; + // nodes.forEach((node) => { + // colorLiterals = colorLiterals + // .concat(astHelpersRN.getColorLiteralsFromNode(node)); + // }); + // return colorLiterals; + // }, + + // getStyleReferenceFromNode: function (node: TSESTree.Node): string[] { + // if (!node) { return []; } + // let styleReference: string; + // let leftStyleReferences: string[]; + // let rightStyleReferences: string[]; + // switch (node.type) { + // case AST_NODE_TYPES.MemberExpression: + // styleReference = astHelpersRN.getStyleReferenceFromExpression(node); + // return [styleReference]; + // case AST_NODE_TYPES.LogicalExpression: + // leftStyleReferences = astHelpersRN.getStyleReferenceFromNode(node.left); + // rightStyleReferences = astHelpersRN.getStyleReferenceFromNode(node.right); + // return [...leftStyleReferences, ...rightStyleReferences]; + // case AST_NODE_TYPES.ConditionalExpression: + // leftStyleReferences = astHelpersRN.getStyleReferenceFromNode(node.consequent); + // rightStyleReferences = astHelpersRN.getStyleReferenceFromNode(node.alternate); + // return [...leftStyleReferences, ...rightStyleReferences]; + // default: + // return []; + // } + // }, + + // getStyleObjectExpressionFromNode: function (node: TSESTree.Node): string[] { + // if (!node) { return []; } + // let leftStyleObjectExpression; + // let rightStyleObjectExpression; + // if (node.type === AST_NODE_TYPES.ObjectExpression) { + // return [astHelpersRN.getStyleObjectFromExpression(node)]; + // } + // switch (node.type) { + // case AST_NODE_TYPES.LogicalExpression: + // leftStyleObjectExpression = astHelpersRN.getStyleObjectExpressionFromNode(node.left); + // rightStyleObjectExpression = astHelpersRN.getStyleObjectExpressionFromNode(node.right); + // return [...leftStyleObjectExpression, ...rightStyleObjectExpression]; + // case AST_NODE_TYPES.ConditionalExpression: + // leftStyleObjectExpression = astHelpersRN.getStyleObjectExpressionFromNode(node.consequent); + // rightStyleObjectExpression = astHelpersRN.getStyleObjectExpressionFromNode(node.alternate); + // return [...leftStyleObjectExpression, ...rightStyleObjectExpression]; + // default: + // return []; + // } + // }, + + // getColorLiteralsFromNode: function (node: TSESTree.Node) { + // if (!node) { return []; } + // let leftColorLiterals; + // let rightColorLiterals; + // if (node.type === AST_NODE_TYPES.ObjectExpression) { + // return [astHelpersRN.getColorLiteralsFromExpression(node)]; + // } + // switch (node.type) { + // case AST_NODE_TYPES.LogicalExpression: + // leftColorLiterals = astHelpersRN.getColorLiteralsFromNode(node.left); + // rightColorLiterals = astHelpersRN.getColorLiteralsFromNode(node.right); + // return [...leftColorLiterals, ...rightColorLiterals]; + // case AST_NODE_TYPES.ConditionalExpression: + // leftColorLiterals = astHelpersRN.getColorLiteralsFromNode(node.consequent); + // rightColorLiterals = astHelpersRN.getColorLiteralsFromNode(node.alternate); + // return [...leftColorLiterals, ...rightColorLiterals]; + // default: + // return []; + // } + // }, + + // hasArrayOfStyleReferences: function (node) { + // return node + // && Boolean( + // node.type === 'JSXExpressionContainer' + // && node.expression + // && node.expression.type === 'ArrayExpression' + // ); + // }, + + // @ts-ignore + // getStyleReferenceFromExpression: function (node: TSESTree.Node): string { + // const result: string[] = []; + // const name = astHelpersRN.getObjectName(node); + // if (name) { + // result.push(name); + // } + // const property = astHelpersRN.getPropertyName(node); + // if (property) { + // result.push(property); + // } + // return result.join('.'); + // }, + + // @ts-ignore + // getStyleObjectFromExpression: function (node): string { + // const obj = {}; + // let invalid = false; + // if (node.properties && node.properties.length) { + // // @ts-ignore + // node.properties.forEach((p) => { + // if (!p.value || !p.key) { + // return; + // } + // if (p.value.type === AST_NODE_TYPES.Literal) { + // invalid = true; + // // @ts-ignore + // obj[p.key.name] = p.value.value; + // } else if (p.value.type === AST_NODE_TYPES.ConditionalExpression) { + // const innerNode = p.value; + // if ( + // innerNode.consequent.type === AST_NODE_TYPES.Literal + // || innerNode.alternate.type === AST_NODE_TYPES.Literal + // ) { + // invalid = true; + // // @ts-ignore + // obj[p.key.name] = getSourceCode(innerNode); + // } + // } else if ( + // p.value.type === AST_NODE_TYPES.UnaryExpression + // && p.value.operator === '-' + // && p.value.argument.type === AST_NODE_TYPES.Literal + // ) { + // invalid = true; + // // @ts-ignore + // obj[p.key.name] = -1 * p.value.argument.value; + // } else if ( + // p.value.type === AST_NODE_TYPES.UnaryExpression + // && p.value.operator === '+' + // && p.value.argument.type === AST_NODE_TYPES.Literal + // ) { + // invalid = true; + // // @ts-ignore + // obj[p.key.name] = p.value.argument.value; + // } + // }); + // } + // return invalid ? { expression: obj, node: node } : undefined; + // }, + + // @ts-ignore + getColorLiteralsFromExpression: function (node) { + const obj = {}; + let invalid = false; + if (node.properties && node.properties.length) { + // @ts-ignore + node.properties.forEach((p) => { + if (p.key && p.key.name && p.key.name.toLowerCase().indexOf('color') !== -1) { + if (p.value.type === AST_NODE_TYPES.Literal) { + invalid = true; + // @ts-ignore + obj[p.key.name] = p.value.value; + } else if (p.value.type === AST_NODE_TYPES.ConditionalExpression) { + const innerNode = p.value; + if ( + innerNode.consequent.type === AST_NODE_TYPES.Literal + || innerNode.alternate.type === AST_NODE_TYPES.Literal + ) { + invalid = true; + // @ts-ignore + obj[p.key.name] = getSourceCode(innerNode); + } + } + } + }); + } + return invalid ? { expression: obj, node: node } : undefined; + }, + + getObjectName: function (node: TSESTree.Node): string | void { + if (node && 'object' in node && node.object && 'name' in node.object && node.object.name) { + return node.object.name.toString(); + } + }, + + getPropertyName: function (node: TSESTree.Node): string | void { + if (node && 'property' in node && node.property && 'name' in node.property && node.property.name) { + return node.property.name.toString(); + } + }, + + // getPotentialStyleReferenceFromMemberExpression: function (node) { + // if ( + // node + // && node.object + // && node.object.type === 'Identifier' + // && node.object.name + // && node.property + // && node.property.type === 'Identifier' + // && node.property.name + // && node.parent.type !== 'MemberExpression' + // ) { + // return [node.object.name, node.property.name].join('.'); + // } + // }, + + // isEitherShortHand: function (property1, property2) { + // const shorthands = ['margin', 'padding', 'border', 'flex']; + // if (shorthands.includes(property1)) { + // return property2.startsWith(property1); + // } if (shorthands.includes(property2)) { + // return property1.startsWith(property2); + // } + // return false; + // }, +}; diff --git a/src/util/astUtils.ts b/src/util/astUtils.ts new file mode 100644 index 0000000..12f003e --- /dev/null +++ b/src/util/astUtils.ts @@ -0,0 +1,2 @@ +// deeply re-export, for convenience +export * from '@typescript-eslint/experimental-utils/dist/ast-utils'; diff --git a/src/util/createRule.ts b/src/util/createRule.ts new file mode 100644 index 0000000..5982f04 --- /dev/null +++ b/src/util/createRule.ts @@ -0,0 +1,9 @@ +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; + +// note - cannot migrate this to an import statement because it will make TSC copy the package.json to the dist folder +const version = require('../../package.json').version; + +export const createRule = ESLintUtils.RuleCreator( + name => + `https://github.com/typescript-eslint/typescript-eslint/blob/v${version}/packages/eslint-plugin/docs/rules/${name}.md`, +); diff --git a/src/util/explicitReturnTypeUtils.ts b/src/util/explicitReturnTypeUtils.ts new file mode 100644 index 0000000..d2c3a68 --- /dev/null +++ b/src/util/explicitReturnTypeUtils.ts @@ -0,0 +1,356 @@ +import { + TSESTree, + AST_NODE_TYPES, + TSESLint, + AST_TOKEN_TYPES, +} from '@typescript-eslint/experimental-utils'; +import { isTypeAssertion, isConstructor, isSetter } from './astUtils'; +import { nullThrows, NullThrowsReasons } from './nullThrows'; + +type FunctionExpression = + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionExpression; +type FunctionNode = FunctionExpression | TSESTree.FunctionDeclaration; + +/** + * Creates a report location for the given function. + * The location only encompasses the "start" of the function, and not the body + * + * eg. + * function foo(args) {} + * ^^^^^^^^^^^^^^^^^^ + * + * get y(args) {} + * ^^^^^^^^^^^ + * + * const x = (args) => {} + * ^^^^^^^^^ + */ +function getReporLoc( + node: FunctionNode, + sourceCode: TSESLint.SourceCode, +): TSESTree.SourceLocation { + /** + * Returns start column position + * @param node + */ + function getLocStart(): TSESTree.LineAndColumnData { + /* highlight method name */ + const parent = node.parent; + if ( + parent && + (parent.type === AST_NODE_TYPES.MethodDefinition || + (parent.type === AST_NODE_TYPES.Property && parent.method)) + ) { + return parent.loc.start; + } + + return node.loc.start; + } + + /** + * Returns end column position + * @param node + */ + function getLocEnd(): TSESTree.LineAndColumnData { + /* highlight `=>` */ + if (node.type === AST_NODE_TYPES.ArrowFunctionExpression) { + return sourceCode.getTokenBefore( + node.body, + token => + token.type === AST_TOKEN_TYPES.Punctuator && token.value === '=>', + )!.loc.end; + } + + return sourceCode.getTokenBefore(node.body)!.loc.end; + } + + return { + start: getLocStart(), + end: getLocEnd(), + }; +} + +/** + * Checks if a node is a variable declarator with a type annotation. + * ``` + * const x: Foo = ... + * ``` + */ +function isVariableDeclaratorWithTypeAnnotation( + node: TSESTree.Node, +): node is TSESTree.VariableDeclarator { + return ( + node.type === AST_NODE_TYPES.VariableDeclarator && !!node.id.typeAnnotation + ); +} + +/** + * Checks if a node is a class property with a type annotation. + * ``` + * public x: Foo = ... + * ``` + */ +function isClassPropertyWithTypeAnnotation( + node: TSESTree.Node, +): node is TSESTree.ClassProperty { + return node.type === AST_NODE_TYPES.ClassProperty && !!node.typeAnnotation; +} + +/** + * Checks if a node belongs to: + * ``` + * new Foo(() => {}) + * ^^^^^^^^ + * ``` + */ +function isConstructorArgument( + node: TSESTree.Node, +): node is TSESTree.NewExpression { + return node.type === AST_NODE_TYPES.NewExpression; +} + +/** + * Checks if a node belongs to: + * ``` + * const x: Foo = { prop: () => {} } + * const x = { prop: () => {} } as Foo + * const x = { prop: () => {} } + * ``` + */ +function isPropertyOfObjectWithType( + property: TSESTree.Node | undefined, +): boolean { + if (!property || property.type !== AST_NODE_TYPES.Property) { + return false; + } + const objectExpr = property.parent; // this shouldn't happen, checking just in case + /* istanbul ignore if */ if ( + !objectExpr || + objectExpr.type !== AST_NODE_TYPES.ObjectExpression + ) { + return false; + } + + const parent = objectExpr.parent; // this shouldn't happen, checking just in case + /* istanbul ignore if */ if (!parent) { + return false; + } + + return ( + isTypeAssertion(parent) || + isClassPropertyWithTypeAnnotation(parent) || + isVariableDeclaratorWithTypeAnnotation(parent) || + isFunctionArgument(parent) + ); +} + +/** + * Checks if a function belongs to: + * ``` + * () => () => ... + * () => function () { ... } + * () => { return () => ... } + * () => { return function () { ... } } + * function fn() { return () => ... } + * function fn() { return function() { ... } } + * ``` + */ +function doesImmediatelyReturnFunctionExpression({ + body, +}: FunctionNode): boolean { + // Should always have a body; really checking just in case + /* istanbul ignore if */ if (!body) { + return false; + } + + // Check if body is a block with a single statement + if (body.type === AST_NODE_TYPES.BlockStatement && body.body.length === 1) { + const [statement] = body.body; + + // Check if that statement is a return statement with an argument + if ( + statement.type === AST_NODE_TYPES.ReturnStatement && + !!statement.argument + ) { + // If so, check that returned argument as body + body = statement.argument; + } + } + + // Check if the body being returned is a function expression + return ( + body.type === AST_NODE_TYPES.ArrowFunctionExpression || + body.type === AST_NODE_TYPES.FunctionExpression + ); +} + +/** + * Checks if a node belongs to: + * ``` + * foo(() => 1) + * ``` + */ +function isFunctionArgument( + parent: TSESTree.Node, + callee?: FunctionExpression, +): parent is TSESTree.CallExpression { + return ( + parent.type === AST_NODE_TYPES.CallExpression && + // make sure this isn't an IIFE + parent.callee !== callee + ); +} + +/** + * Checks if a function belongs to: + * ``` + * () => ({ action: 'xxx' } as const) + * ``` + */ +function returnsConstAssertionDirectly( + node: TSESTree.ArrowFunctionExpression, +): boolean { + const { body } = node; + if (isTypeAssertion(body)) { + const { typeAnnotation } = body; + if (typeAnnotation.type === AST_NODE_TYPES.TSTypeReference) { + const { typeName } = typeAnnotation; + if ( + typeName.type === AST_NODE_TYPES.Identifier && + typeName.name === 'const' + ) { + return true; + } + } + } + + return false; +} + +interface Options { + allowExpressions?: boolean; + allowTypedFunctionExpressions?: boolean; + allowHigherOrderFunctions?: boolean; + allowDirectConstAssertionInArrowFunctions?: boolean; +} + +/** + * True when the provided function expression is typed. + */ +function isTypedFunctionExpression( + node: FunctionExpression, + options: Options, +): boolean { + const parent = nullThrows(node.parent, NullThrowsReasons.MissingParent); + + if (!options.allowTypedFunctionExpressions) { + return false; + } + + return ( + isTypeAssertion(parent) || + isVariableDeclaratorWithTypeAnnotation(parent) || + isClassPropertyWithTypeAnnotation(parent) || + isPropertyOfObjectWithType(parent) || + isFunctionArgument(parent, node) || + isConstructorArgument(parent) + ); +} + +/** + * Check whether the function expression return type is either typed or valid + * with the provided options. + */ +function isValidFunctionExpressionReturnType( + node: FunctionExpression, + options: Options, +): boolean { + if (isTypedFunctionExpression(node, options)) { + return true; + } + + const parent = nullThrows(node.parent, NullThrowsReasons.MissingParent); + if ( + options.allowExpressions && + parent.type !== AST_NODE_TYPES.VariableDeclarator && + parent.type !== AST_NODE_TYPES.MethodDefinition && + parent.type !== AST_NODE_TYPES.ExportDefaultDeclaration && + parent.type !== AST_NODE_TYPES.ClassProperty + ) { + return true; + } + + // https://github.com/typescript-eslint/typescript-eslint/issues/653 + if ( + options.allowDirectConstAssertionInArrowFunctions && + node.type === AST_NODE_TYPES.ArrowFunctionExpression && + returnsConstAssertionDirectly(node) + ) { + return true; + } + + return false; +} + +/** + * Check that the function expression or declaration is valid. + */ +function isValidFunctionReturnType( + node: FunctionNode, + options: Options, +): boolean { + if ( + options.allowHigherOrderFunctions && + doesImmediatelyReturnFunctionExpression(node) + ) { + return true; + } + + if (node.returnType || isConstructor(node.parent) || isSetter(node.parent)) { + return true; + } + + return false; +} + +/** + * Checks if a function declaration/expression has a return type. + */ +function checkFunctionReturnType( + node: FunctionNode, + options: Options, + sourceCode: TSESLint.SourceCode, + report: (loc: TSESTree.SourceLocation) => void, +): void { + if (isValidFunctionReturnType(node, options)) { + return; + } + + report(getReporLoc(node, sourceCode)); +} + +/** + * Checks if a function declaration/expression has a return type. + */ +function checkFunctionExpressionReturnType( + node: FunctionExpression, + options: Options, + sourceCode: TSESLint.SourceCode, + report: (loc: TSESTree.SourceLocation) => void, +): void { + if (isValidFunctionExpressionReturnType(node, options)) { + return; + } + + checkFunctionReturnType(node, options, sourceCode, report); +} + +export { + checkFunctionExpressionReturnType, + checkFunctionReturnType, + doesImmediatelyReturnFunctionExpression, + FunctionExpression, + FunctionNode, + isTypedFunctionExpression, +}; diff --git a/src/util/index.ts b/src/util/index.ts new file mode 100644 index 0000000..8cf5bc5 --- /dev/null +++ b/src/util/index.ts @@ -0,0 +1,32 @@ +import { ESLintUtils } from '@typescript-eslint/experimental-utils'; + +export * from './astHelpersRN'; +// export * from './astUtils'; +export * from './createRule'; +// export * from './isTypeReadonly'; +// export * from './misc'; +// export * from './nullThrows'; +// export * from './objectIterators'; +// export * from './propertyTypes'; +// export * from './types'; + +// this is done for convenience - saves migrating all of the old rules +const { + applyDefault, + deepMerge, + isObjectNotArray, + getParserServices, +} = ESLintUtils; +type InferMessageIdsTypeFromRule = ESLintUtils.InferMessageIdsTypeFromRule< + T +>; +type InferOptionsTypeFromRule = ESLintUtils.InferOptionsTypeFromRule; + +export { + applyDefault, + deepMerge, + isObjectNotArray, + getParserServices, + InferMessageIdsTypeFromRule, + InferOptionsTypeFromRule, +}; diff --git a/src/util/isTypeReadonly.ts b/src/util/isTypeReadonly.ts new file mode 100644 index 0000000..7558d32 --- /dev/null +++ b/src/util/isTypeReadonly.ts @@ -0,0 +1,192 @@ +import { + isObjectType, + isPropertyReadonlyInType, + isUnionOrIntersectionType, + isUnionType, + unionTypeParts +} from 'tsutils'; +import * as ts from 'typescript'; +import { nullThrows, NullThrowsReasons } from './nullThrows'; +import { getTypeOfPropertyOfType } from './propertyTypes'; + +const enum Readonlyness { + /** the type cannot be handled by the function */ + UnknownType = 1, + /** the type is mutable */ + Mutable = 2, + /** the type is readonly */ + Readonly = 3, +} + +function isTypeReadonlyArrayOrTuple( + checker: ts.TypeChecker, + type: ts.Type, + seenTypes: Set, +): Readonlyness { + function checkTypeArguments(arrayType: ts.TypeReference): Readonlyness { + const typeArguments = checker.getTypeArguments(arrayType); + // this shouldn't happen in reality as: + // - tuples require at least 1 type argument + // - ReadonlyArray requires at least 1 type argument + /* istanbul ignore if */ if (typeArguments.length === 0) { + return Readonlyness.Readonly; + } + + // validate the element types are also readonly + if ( + typeArguments.some( + typeArg => + isTypeReadonlyRecurser(checker, typeArg, seenTypes) === + Readonlyness.Mutable, + ) + ) { + return Readonlyness.Mutable; + } + return Readonlyness.Readonly; + } + + if (checker.isArrayType(type)) { + const symbol = nullThrows( + type.getSymbol(), + NullThrowsReasons.MissingToken('symbol', 'array type'), + ); + const escapedName = symbol.getEscapedName(); + if (escapedName === 'Array') { + return Readonlyness.Mutable; + } + + return checkTypeArguments(type); + } + + if (checker.isTupleType(type)) { + if (!type.target.readonly) { + return Readonlyness.Mutable; + } + + return checkTypeArguments(type); + } + + return Readonlyness.UnknownType; +} + +function isTypeReadonlyObject( + checker: ts.TypeChecker, + type: ts.Type, + seenTypes: Set, +): Readonlyness { + function checkIndexSignature(kind: ts.IndexKind): Readonlyness { + const indexInfo = checker.getIndexInfoOfType(type, kind); + if (indexInfo) { + return indexInfo.isReadonly + ? Readonlyness.Readonly + : Readonlyness.Mutable; + } + + return Readonlyness.UnknownType; + } + + const properties = type.getProperties(); + if (properties.length) { + // ensure the properties are marked as readonly + for (const property of properties) { + if (!isPropertyReadonlyInType(type, property.getEscapedName(), checker)) { + return Readonlyness.Mutable; + } + } + + // all properties were readonly + // now ensure that all of the values are readonly also. + + // do this after checking property readonly-ness as a perf optimization, + // as we might be able to bail out early due to a mutable property before + // doing this deep, potentially expensive check. + for (const property of properties) { + const propertyType = nullThrows( + getTypeOfPropertyOfType(checker, type, property), + NullThrowsReasons.MissingToken(`property "${property.name}"`, 'type'), + ); + + // handle recursive types. + // we only need this simple check, because a mutable recursive type will break via the above prop readonly check + if (seenTypes.has(propertyType)) { + continue; + } + + if ( + isTypeReadonlyRecurser(checker, propertyType, seenTypes) === + Readonlyness.Mutable + ) { + return Readonlyness.Mutable; + } + } + } + + const isStringIndexSigReadonly = checkIndexSignature(ts.IndexKind.String); + if (isStringIndexSigReadonly === Readonlyness.Mutable) { + return isStringIndexSigReadonly; + } + + const isNumberIndexSigReadonly = checkIndexSignature(ts.IndexKind.Number); + if (isNumberIndexSigReadonly === Readonlyness.Mutable) { + return isNumberIndexSigReadonly; + } + + return Readonlyness.Readonly; +} + +// a helper function to ensure the seenTypes map is always passed down, except by the external caller +function isTypeReadonlyRecurser( + checker: ts.TypeChecker, + type: ts.Type, + seenTypes: Set, +): Readonlyness.Readonly | Readonlyness.Mutable { + seenTypes.add(type); + + if (isUnionType(type)) { + // all types in the union must be readonly + const result = unionTypeParts(type).every(t => + isTypeReadonlyRecurser(checker, t, seenTypes), + ); + const readonlyness = result ? Readonlyness.Readonly : Readonlyness.Mutable; + return readonlyness; + } + + // all non-object, non-intersection types are readonly. + // this should only be primitive types + if (!isObjectType(type) && !isUnionOrIntersectionType(type)) { + return Readonlyness.Readonly; + } + + // pure function types are readonly + if ( + type.getCallSignatures().length > 0 && + type.getProperties().length === 0 + ) { + return Readonlyness.Readonly; + } + + const isReadonlyArray = isTypeReadonlyArrayOrTuple(checker, type, seenTypes); + if (isReadonlyArray !== Readonlyness.UnknownType) { + return isReadonlyArray; + } + + const isReadonlyObject = isTypeReadonlyObject(checker, type, seenTypes); + /* istanbul ignore else */ if ( + isReadonlyObject !== Readonlyness.UnknownType + ) { + return isReadonlyObject; + } + + throw new Error('Unhandled type'); +} + +/** + * Checks if the given type is readonly + */ +function isTypeReadonly(checker: ts.TypeChecker, type: ts.Type): boolean { + return ( + isTypeReadonlyRecurser(checker, type, new Set()) === Readonlyness.Readonly + ); +} + +export { isTypeReadonly }; diff --git a/src/util/misc.ts b/src/util/misc.ts new file mode 100644 index 0000000..2e6c471 --- /dev/null +++ b/src/util/misc.ts @@ -0,0 +1,116 @@ +/** + * @fileoverview Really small utility functions that didn't deserve their own files + */ + +import { + AST_NODE_TYPES, + TSESLint, + TSESTree, +} from '@typescript-eslint/experimental-utils'; + +/** + * Check if the context file name is *.d.ts or *.d.tsx + */ +function isDefinitionFile(fileName: string): boolean { + return /\.d\.tsx?$/i.test(fileName || ''); +} + +/** + * Upper cases the first character or the string + */ +function upperCaseFirst(str: string): string { + return str[0].toUpperCase() + str.slice(1); +} + +/** Return true if both parameters are equal. */ +type Equal = (a: T, b: T) => boolean; + +function arraysAreEqual( + a: T[] | undefined, + b: T[] | undefined, + eq: (a: T, b: T) => boolean, +): boolean { + return ( + a === b || + (a !== undefined && + b !== undefined && + a.length === b.length && + a.every((x, idx) => eq(x, b[idx]))) + ); +} + +/** Returns the first non-`undefined` result. */ +function findFirstResult( + inputs: T[], + getResult: (t: T) => U | undefined, +): U | undefined { + for (const element of inputs) { + const result = getResult(element); + if (result !== undefined) { + return result; + } + } + return undefined; +} + +/** + * Gets a string representation of the name of the index signature. + */ +function getNameFromIndexSignature(node: TSESTree.TSIndexSignature): string { + const propName: TSESTree.PropertyName | undefined = node.parameters.find( + (parameter: TSESTree.Parameter): parameter is TSESTree.Identifier => + parameter.type === AST_NODE_TYPES.Identifier, + ); + return propName ? propName.name : '(index signature)'; +} + +/** + * Gets a string name representation of the name of the given MethodDefinition + * or ClassProperty node, with handling for computed property names. + */ +function getNameFromMember( + member: + | TSESTree.MethodDefinition + | TSESTree.TSMethodSignature + | TSESTree.TSAbstractMethodDefinition + | TSESTree.ClassProperty + | TSESTree.TSAbstractClassProperty + | TSESTree.Property + | TSESTree.TSPropertySignature, + sourceCode: TSESLint.SourceCode, +): string { + if (member.key.type === AST_NODE_TYPES.Identifier) { + return member.key.name; + } + if (member.key.type === AST_NODE_TYPES.Literal) { + return `${member.key.value}`; + } + + return sourceCode.text.slice(...member.key.range); +} + +type ExcludeKeys< + TObj extends Record, + TKeys extends keyof TObj +> = { [k in Exclude]: TObj[k] }; +type RequireKeys< + TObj extends Record, + TKeys extends keyof TObj +> = ExcludeKeys & { [k in TKeys]-?: Exclude }; + +function getEnumNames(myEnum: Record): T[] { + return Object.keys(myEnum).filter(x => isNaN(parseInt(x))) as T[]; +} + +export { + arraysAreEqual, + Equal, + ExcludeKeys, + findFirstResult, + getEnumNames, + getNameFromIndexSignature, + getNameFromMember, + isDefinitionFile, + RequireKeys, + upperCaseFirst, +}; diff --git a/src/util/nullThrows.ts b/src/util/nullThrows.ts new file mode 100644 index 0000000..df644c2 --- /dev/null +++ b/src/util/nullThrows.ts @@ -0,0 +1,28 @@ +/** + * A set of common reasons for calling nullThrows + */ +const NullThrowsReasons = { + MissingParent: 'Expected node to have a parent.', + MissingToken: (token: string, thing: string) => + `Expected to find a ${token} for the ${thing}.`, +} as const; + +/** + * Assert that a value must not be null or undefined. + * This is a nice explicit alternative to the non-null assertion operator. + */ +function nullThrows(value: T | null | undefined, message: string): T { + // this function is primarily used to keep types happy in a safe way + // i.e. is used when we expect that a value is never nullish + // this means that it's pretty much impossible to test the below if... + + // so ignore it in coverage metrics. + /* istanbul ignore if */ + if (value === null || value === undefined) { + throw new Error(`Non-null Assertion Failed: ${message}`); + } + + return value; +} + +export { nullThrows, NullThrowsReasons }; diff --git a/src/util/objectIterators.ts b/src/util/objectIterators.ts new file mode 100644 index 0000000..474d643 --- /dev/null +++ b/src/util/objectIterators.ts @@ -0,0 +1,34 @@ +function objectForEachKey>( + obj: T, + callback: (key: keyof T) => void, +): void { + const keys = Object.keys(obj); + for (const key of keys) { + callback(key); + } +} + +function objectMapKey, TReturn>( + obj: T, + callback: (key: keyof T) => TReturn, +): TReturn[] { + const values: TReturn[] = []; + objectForEachKey(obj, key => { + values.push(callback(key)); + }); + return values; +} + +function objectReduceKey, TAccumulator>( + obj: T, + callback: (acc: TAccumulator, key: keyof T) => TAccumulator, + initial: TAccumulator, +): TAccumulator { + let accumulator = initial; + objectForEachKey(obj, key => { + accumulator = callback(accumulator, key); + }); + return accumulator; +} + +export { objectForEachKey, objectMapKey, objectReduceKey }; diff --git a/src/util/propertyTypes.ts b/src/util/propertyTypes.ts new file mode 100644 index 0000000..5e2f105 --- /dev/null +++ b/src/util/propertyTypes.ts @@ -0,0 +1,36 @@ +import * as ts from 'typescript'; + +export function getTypeOfPropertyOfName( + checker: ts.TypeChecker, + type: ts.Type, + name: string, + escapedName?: ts.__String, +): ts.Type | undefined { + // Most names are directly usable in the checker and aren't different from escaped names + if (!escapedName || !name.startsWith('__')) { + return checker.getTypeOfPropertyOfType(type, name); + } + + // Symbolic names may differ in their escaped name compared to their human-readable name + // https://github.com/typescript-eslint/typescript-eslint/issues/2143 + const escapedProperty = type + .getProperties() + .find(property => property.escapedName === escapedName); + + return escapedProperty + ? checker.getDeclaredTypeOfSymbol(escapedProperty) + : undefined; +} + +export function getTypeOfPropertyOfType( + checker: ts.TypeChecker, + type: ts.Type, + property: ts.Symbol, +): ts.Type | undefined { + return getTypeOfPropertyOfName( + checker, + type, + property.getName(), + property.getEscapedName(), + ); +} diff --git a/src/util/types.ts b/src/util/types.ts new file mode 100644 index 0000000..d29349f --- /dev/null +++ b/src/util/types.ts @@ -0,0 +1,512 @@ +import debug from 'debug'; +import { + isCallExpression, + isJsxExpression, + isIdentifier, + isNewExpression, + isParameterDeclaration, + isPropertyDeclaration, + isTypeReference, + isUnionOrIntersectionType, + isVariableDeclaration, + unionTypeParts, + isPropertyAssignment, +} from 'tsutils'; +import * as ts from 'typescript'; + +const log = debug('typescript-eslint:eslint-plugin:utils:types'); + +/** + * Checks if the given type is either an array type, + * or a union made up solely of array types. + */ +export function isTypeArrayTypeOrUnionOfArrayTypes( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + for (const t of unionTypeParts(type)) { + if (!checker.isArrayType(t)) { + return false; + } + } + + return true; +} + +/** + * @param type Type being checked by name. + * @param allowedNames Symbol names checking on the type. + * @returns Whether the type is, extends, or contains all of the allowed names. + */ +export function containsAllTypesByName( + type: ts.Type, + allowAny: boolean, + allowedNames: Set, +): boolean { + if (isTypeFlagSet(type, ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return !allowAny; + } + + if (isTypeReference(type)) { + type = type.target; + } + + const symbol = type.getSymbol(); + if (symbol && allowedNames.has(symbol.name)) { + return true; + } + + if (isUnionOrIntersectionType(type)) { + return type.types.every(t => + containsAllTypesByName(t, allowAny, allowedNames), + ); + } + + const bases = type.getBaseTypes(); + return ( + typeof bases !== 'undefined' && + bases.length > 0 && + bases.every(t => containsAllTypesByName(t, allowAny, allowedNames)) + ); +} + +/** + * Get the type name of a given type. + * @param typeChecker The context sensitive TypeScript TypeChecker. + * @param type The type to get the name of. + */ +export function getTypeName( + typeChecker: ts.TypeChecker, + type: ts.Type, +): string { + // It handles `string` and string literal types as string. + if ((type.flags & ts.TypeFlags.StringLike) !== 0) { + return 'string'; + } + + // If the type is a type parameter which extends primitive string types, + // but it was not recognized as a string like. So check the constraint + // type of the type parameter. + if ((type.flags & ts.TypeFlags.TypeParameter) !== 0) { + // `type.getConstraint()` method doesn't return the constraint type of + // the type parameter for some reason. So this gets the constraint type + // via AST. + const symbol = type.getSymbol(); + const decls = symbol?.getDeclarations(); + const typeParamDecl = decls?.[0] as ts.TypeParameterDeclaration; + if ( + ts.isTypeParameterDeclaration(typeParamDecl) && + typeParamDecl.constraint != null + ) { + return getTypeName( + typeChecker, + typeChecker.getTypeFromTypeNode(typeParamDecl.constraint), + ); + } + } + + // If the type is a union and all types in the union are string like, + // return `string`. For example: + // - `"a" | "b"` is string. + // - `string | string[]` is not string. + if ( + type.isUnion() && + type.types + .map(value => getTypeName(typeChecker, value)) + .every(t => t === 'string') + ) { + return 'string'; + } + + // If the type is an intersection and a type in the intersection is string + // like, return `string`. For example: `string & {__htmlEscaped: void}` + if ( + type.isIntersection() && + type.types + .map(value => getTypeName(typeChecker, value)) + .some(t => t === 'string') + ) { + return 'string'; + } + + return typeChecker.typeToString(type); +} + +/** + * Resolves the given node's type. Will resolve to the type's generic constraint, if it has one. + */ +export function getConstrainedTypeAtLocation( + checker: ts.TypeChecker, + node: ts.Node, +): ts.Type { + const nodeType = checker.getTypeAtLocation(node); + const constrained = checker.getBaseConstraintOfType(nodeType); + + return constrained ?? nodeType; +} + +/** + * Checks if the given type is (or accepts) nullable + * @param isReceiver true if the type is a receiving type (i.e. the type of a called function's parameter) + */ +export function isNullableType( + type: ts.Type, + { + isReceiver = false, + allowUndefined = true, + }: { isReceiver?: boolean; allowUndefined?: boolean } = {}, +): boolean { + const flags = getTypeFlags(type); + + if (isReceiver && flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return true; + } + + if (allowUndefined) { + return (flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0; + } else { + return (flags & ts.TypeFlags.Null) !== 0; + } +} + +/** + * Gets the declaration for the given variable + */ +export function getDeclaration( + checker: ts.TypeChecker, + node: ts.Expression, +): ts.Declaration | null { + const symbol = checker.getSymbolAtLocation(node); + if (!symbol) { + return null; + } + const declarations = symbol.getDeclarations(); + return declarations?.[0] ?? null; +} + +/** + * Gets all of the type flags in a type, iterating through unions automatically + */ +export function getTypeFlags(type: ts.Type): ts.TypeFlags { + let flags: ts.TypeFlags = 0; + for (const t of unionTypeParts(type)) { + flags |= t.flags; + } + return flags; +} + +/** + * Checks if the given type is (or accepts) the given flags + * @param isReceiver true if the type is a receiving type (i.e. the type of a called function's parameter) + */ +export function isTypeFlagSet( + type: ts.Type, + flagsToCheck: ts.TypeFlags, + isReceiver?: boolean, +): boolean { + const flags = getTypeFlags(type); + + if (isReceiver && flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) { + return true; + } + + return (flags & flagsToCheck) !== 0; +} + +/** + * @returns Whether a type is an instance of the parent type, including for the parent's base types. + */ +export function typeIsOrHasBaseType( + type: ts.Type, + parentType: ts.Type, +): boolean { + const parentSymbol = parentType.getSymbol(); + if (!type.getSymbol() || !parentSymbol) { + return false; + } + + const typeAndBaseTypes = [type]; + const ancestorTypes = type.getBaseTypes(); + + if (ancestorTypes) { + typeAndBaseTypes.push(...ancestorTypes); + } + + for (const baseType of typeAndBaseTypes) { + const baseSymbol = baseType.getSymbol(); + if (baseSymbol && baseSymbol.name === parentSymbol.name) { + return true; + } + } + + return false; +} + +/** + * Gets the source file for a given node + */ +export function getSourceFileOfNode(node: ts.Node): ts.SourceFile { + while (node && node.kind !== ts.SyntaxKind.SourceFile) { + node = node.parent; + } + return node as ts.SourceFile; +} + +export function getTokenAtPosition( + sourceFile: ts.SourceFile, + position: number, +): ts.Node { + const queue: ts.Node[] = [sourceFile]; + let current: ts.Node; + while (queue.length > 0) { + current = queue.shift()!; + // find the child that contains 'position' + for (const child of current.getChildren(sourceFile)) { + const start = child.getFullStart(); + if (start > position) { + // If this child begins after position, then all subsequent children will as well. + return current; + } + + const end = child.getEnd(); + if ( + position < end || + (position === end && child.kind === ts.SyntaxKind.EndOfFileToken) + ) { + queue.push(child); + break; + } + } + } + return current!; +} + +export interface EqualsKind { + isPositive: boolean; + isStrict: boolean; +} + +export function getEqualsKind(operator: string): EqualsKind | undefined { + switch (operator) { + case '==': + return { + isPositive: true, + isStrict: false, + }; + + case '===': + return { + isPositive: true, + isStrict: true, + }; + + case '!=': + return { + isPositive: false, + isStrict: false, + }; + + case '!==': + return { + isPositive: false, + isStrict: true, + }; + + default: + return undefined; + } +} + +export function getTypeArguments( + type: ts.TypeReference, + checker: ts.TypeChecker, +): readonly ts.Type[] { + // getTypeArguments was only added in TS3.7 + if (checker.getTypeArguments) { + return checker.getTypeArguments(type); + } + + return type.typeArguments ?? []; +} + +/** + * @returns true if the type is `unknown` + */ +export function isTypeUnknownType(type: ts.Type): boolean { + return isTypeFlagSet(type, ts.TypeFlags.Unknown); +} + +/** + * @returns true if the type is `any` + */ +export function isTypeAnyType(type: ts.Type): boolean { + if (isTypeFlagSet(type, ts.TypeFlags.Any)) { + if (type.intrinsicName === 'error') { + log('Found an "error" any type'); + } + return true; + } + return false; +} + +/** + * @returns true if the type is `any[]` + */ +export function isTypeAnyArrayType( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + return ( + checker.isArrayType(type) && + isTypeAnyType( + // getTypeArguments was only added in TS3.7 + getTypeArguments(type, checker)[0], + ) + ); +} + +/** + * @returns true if the type is `unknown[]` + */ +export function isTypeUnknownArrayType( + type: ts.Type, + checker: ts.TypeChecker, +): boolean { + return ( + checker.isArrayType(type) && + isTypeUnknownType( + // getTypeArguments was only added in TS3.7 + getTypeArguments(type, checker)[0], + ) + ); +} + +export const enum AnyType { + Any, + AnyArray, + Safe, +} +/** + * @returns `AnyType.Any` if the type is `any`, `AnyType.AnyArray` if the type is `any[]` or `readonly any[]`, + * otherwise it returns `AnyType.Safe`. + */ +export function isAnyOrAnyArrayTypeDiscriminated( + node: ts.Node, + checker: ts.TypeChecker, +): AnyType { + const type = checker.getTypeAtLocation(node); + if (isTypeAnyType(type)) { + return AnyType.Any; + } + if (isTypeAnyArrayType(type, checker)) { + return AnyType.AnyArray; + } + return AnyType.Safe; +} + +/** + * Does a simple check to see if there is an any being assigned to a non-any type. + * + * This also checks generic positions to ensure there's no unsafe sub-assignments. + * Note: in the case of generic positions, it makes the assumption that the two types are the same. + * + * @example See tests for examples + * + * @returns false if it's safe, or an object with the two types if it's unsafe + */ +export function isUnsafeAssignment( + type: ts.Type, + receiver: ts.Type, + checker: ts.TypeChecker, +): false | { sender: ts.Type; receiver: ts.Type } { + if (isTypeAnyType(type)) { + // Allow assignment of any ==> unknown. + if (isTypeUnknownType(receiver)) { + return false; + } + + if (!isTypeAnyType(receiver)) { + return { sender: type, receiver }; + } + } + + if (isTypeReference(type) && isTypeReference(receiver)) { + // TODO - figure out how to handle cases like this, + // where the types are assignable, but not the same type + /* + function foo(): ReadonlySet { return new Set(); } + + // and + + type Test = { prop: T } + type Test2 = { prop: string } + declare const a: Test; + const b: Test2 = a; + */ + + if (type.target !== receiver.target) { + // if the type references are different, assume safe, as we won't know how to compare the two types + // the generic positions might not be equivalent for both types + return false; + } + + const typeArguments = type.typeArguments ?? []; + const receiverTypeArguments = receiver.typeArguments ?? []; + + for (let i = 0; i < typeArguments.length; i += 1) { + const arg = typeArguments[i]; + const receiverArg = receiverTypeArguments[i]; + + const unsafe = isUnsafeAssignment(arg, receiverArg, checker); + if (unsafe) { + return { sender: type, receiver }; + } + } + + return false; + } + + return false; +} + +/** + * Returns the contextual type of a given node. + * Contextual type is the type of the target the node is going into. + * i.e. the type of a called function's parameter, or the defined type of a variable declaration + */ +export function getContextualType( + checker: ts.TypeChecker, + node: ts.Expression, +): ts.Type | undefined { + const parent = node.parent; + if (!parent) { + return; + } + + if (isCallExpression(parent) || isNewExpression(parent)) { + if (node === parent.expression) { + // is the callee, so has no contextual type + return; + } + } else if ( + isVariableDeclaration(parent) || + isPropertyDeclaration(parent) || + isParameterDeclaration(parent) + ) { + return parent.type ? checker.getTypeFromTypeNode(parent.type) : undefined; + } else if (isJsxExpression(parent)) { + return checker.getContextualType(parent); + } else if (isPropertyAssignment(parent) && isIdentifier(node)) { + return checker.getContextualType(node); + } else if ( + ![ts.SyntaxKind.TemplateSpan, ts.SyntaxKind.JsxExpression].includes( + parent.kind, + ) + ) { + // parent is not something we know we can get the contextual type of + return; + } + // TODO - support return statement checking + + return checker.getContextualType(node); +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ef5fcf3 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowUnreachableCode": false, + "declaration": true, + "importHelpers": true, + "module": "commonjs", + "moduleResolution": "node", + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "pretty": true, + "strict": true, + "target": "es2017", + "allowJs": false, + "checkJs": false, + "skipLibCheck": false, + "noImplicitAny": true, + "types": [], + "lib": [ + "es2017" + ] + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..50b42a4 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,32 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "declaration": false, + "declarationMap": false, + "importHelpers": false, + "noFallthroughCasesInSwitch": false, + "noImplicitAny": false, + "outDir": "./dist/", + "types": [ + "jest", + "node" + ], + "allowSyntheticDefaultImports": true, + "allowUnusedLabels": false, + "esModuleInterop": true, + "noImplicitReturns": true, + "noUnusedParameters": true, + "sourceMap": true, + "rootDir": "./src", + "resolveJsonModule": true + }, + "include": [ + "src", + "typings" + ], + "exclude": [ + "node_modules", + "src/**/*.test.ts", + "src/**/__tests__/*.ts" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..016397a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "composite": false, + "rootDir": "." + } +} diff --git a/typings/node.d.ts b/typings/node.d.ts new file mode 100644 index 0000000..6caf4f0 --- /dev/null +++ b/typings/node.d.ts @@ -0,0 +1,8 @@ +// augment nodejs global with ES2015+ things +declare namespace NodeJS { + interface Global { + Atomics: typeof Atomics; + Proxy: typeof Proxy; + Reflect: typeof Reflect; + } +} diff --git a/typings/typescript.d.ts b/typings/typescript.d.ts new file mode 100644 index 0000000..7330415 --- /dev/null +++ b/typings/typescript.d.ts @@ -0,0 +1,33 @@ +import 'typescript'; + +declare module 'typescript' { + interface TypeChecker { + // internal TS APIs + + /** + * @returns `true` if the given type is an array type: + * - `Array` + * - `ReadonlyArray` + * - `foo[]` + * - `readonly foo[]` + */ + isArrayType(type: Type): type is TypeReference; + /** + * @returns `true` if the given type is a tuple type: + * - `[foo]` + * - `readonly [foo]` + */ + isTupleType(type: Type): type is TupleTypeReference; + /** + * Return the type of the given property in the given type, or undefined if no such property exists + */ + getTypeOfPropertyOfType(type: Type, propertyName: string): Type | undefined; + } + + interface Type { + /** + * If the type is `any`, and this is set to "error", then TS was unable to resolve the type + */ + intrinsicName?: string; + } +}