From 25c54b791cbd8a300914d28a640a67c4ac98e403 Mon Sep 17 00:00:00 2001 From: Taylore Date: Thu, 26 Oct 2023 22:45:56 -0400 Subject: [PATCH] feat(sort-imports): max line length --- .gitignore | 2 + docs/rules/sort-imports.md | 9 +++ package.json | 1 + pnpm-lock.yaml | 3 + rules/sort-imports.ts | 48 +++++++++++++- test/sort-imports.test.ts | 118 +++++++++++++++++++++++++++++++++- test/utils/areOptionsValid.ts | 60 +++++++++++++++++ typings/index.ts | 1 + utils/compare.ts | 23 ++++++- 9 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 test/utils/areOptionsValid.ts diff --git a/.gitignore b/.gitignore index fa65a35a8..91c0944b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Node.js node_modules/ +.nvmrc # Build dist/ @@ -11,6 +12,7 @@ dist/ # VitePress docs/.vitepress/cache/ docs/.vitepress/temp/ +.vitepress/ # Test coverage/ diff --git a/docs/rules/sort-imports.md b/docs/rules/sort-imports.md index c9ea7709f..b14f98475 100644 --- a/docs/rules/sort-imports.md +++ b/docs/rules/sort-imports.md @@ -127,6 +127,7 @@ interface Options { } 'internal-pattern'?: string[] 'newlines-between'?: 'always' | 'ignore' | 'never' + 'max-line-length'?: number ``` ### type @@ -244,6 +245,14 @@ The [minimatch](https://github.com/isaacs/minimatch) library is used for pattern - `always` - one new line between each group will be enforced, and new lines inside a group will be forbidden. - `never` - no new lines are allowed in the entire import section. +### max-line-length\* + +(default: `undefined`) + +You can use this to sort by the import name only, excluding the elements, when the line length is greater than this number. + +**\*Note:** Only available for `line-length` type. + ## ⚙️ Usage ::: code-group diff --git a/package.json b/package.json index 20421f3e1..d24e3be9b 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@typescript-eslint/rule-tester": "^6.9.0", "@typescript-eslint/types": "^6.9.0", "@vitest/coverage-v8": "^0.34.6", + "ajv": "^6.12.6", "astro-eslint-parser": "^0.16.0", "changelogen": "^0.5.5", "clean-publish": "^4.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 830b90de0..83c3c4b36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ devDependencies: '@vitest/coverage-v8': specifier: ^0.34.6 version: 0.34.6(vitest@0.34.6) + ajv: + specifier: ^6.12.6 + version: 6.12.6 astro-eslint-parser: specifier: ^0.16.0 version: 0.16.0 diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts index 292b1fe9e..64fa9901b 100644 --- a/rules/sort-imports.ts +++ b/rules/sort-imports.ts @@ -1,6 +1,7 @@ -import type { TSESTree } from '@typescript-eslint/types' import type { TSESLint } from '@typescript-eslint/utils' +import type { TSESTree } from '@typescript-eslint/types' +import { AST_NODE_TYPES } from '@typescript-eslint/types' import { builtinModules } from 'node:module' import { minimatch } from 'minimatch' @@ -59,6 +60,7 @@ type Options = [ 'newlines-between': NewlinesBetweenValue groups: (Group[] | Group)[] 'internal-pattern': string[] + 'max-line-length'?: number 'ignore-case': boolean order: SortOrder type: SortType @@ -81,6 +83,7 @@ export default createEslintRule, MESSAGE_ID>({ fixable: 'code', schema: [ { + id: 'sort-imports', type: 'object', properties: { 'custom-groups': { @@ -132,8 +135,37 @@ export default createEslintRule, MESSAGE_ID>({ default: NewlinesBetweenValue.always, type: 'string', }, + 'max-line-length': { + type: 'integer', + minimum: 0, + exclusiveMinimum: true, + }, }, + allOf: [ + { $ref: '#/definitions/max-line-length-requires-line-length-type' }, + ], additionalProperties: false, + dependencies: { + 'max-line-length': ['type'], + }, + definitions: { + 'is-line-length': { + properties: { + type: { enum: [SortType['line-length']], type: 'string' }, + }, + required: ['type'], + type: 'object', + }, + 'max-line-length-requires-line-length-type': { + anyOf: [ + { + not: { required: ['max-line-length'], type: 'object' }, + type: 'object', + }, + { $ref: '#/definitions/is-line-length' }, + ], + }, + }, }, ], messages: { @@ -312,6 +344,14 @@ export default createEslintRule, MESSAGE_ID>({ return getGroup() } + let hasMultipleImportDeclarations = ( + node: TSESTree.ImportDeclaration, + ): boolean => + node.specifiers.length > 1 && + node.specifiers.filter( + specifier => specifier.type === AST_NODE_TYPES.ImportSpecifier, + ).length > 1 + let registerNode = (node: ModuleDeclaration) => { let name: string @@ -333,6 +373,12 @@ export default createEslintRule, MESSAGE_ID>({ group: computeGroup(node), name, node, + ...(options.type === SortType['line-length'] && + options['max-line-length'] && { + hasMultipleImportDeclarations: hasMultipleImportDeclarations( + node as TSESTree.ImportDeclaration, + ), + }), }) } diff --git a/test/sort-imports.test.ts b/test/sort-imports.test.ts index 8286b09d6..e76a13643 100644 --- a/test/sort-imports.test.ts +++ b/test/sort-imports.test.ts @@ -1,8 +1,9 @@ import { RuleTester } from '@typescript-eslint/rule-tester' -import { afterAll, describe, it } from 'vitest' +import { afterAll, describe, expect, it } from 'vitest' import { dedent } from 'ts-dedent' import rule, { NewlinesBetweenValue, RULE_NAME } from '../rules/sort-imports' +import { areOptionsValid } from './utils/areOptionsValid' import { SortOrder, SortType } from '../typings' describe(RULE_NAME, () => { @@ -3474,6 +3475,121 @@ describe(RULE_NAME, () => { ], }, ) + + describe(`${RULE_NAME}(${type}): support max line length`, () => { + ruleTester.run('rule', rule, { + valid: [], + invalid: [ + { + code: dedent` + import { ThisIsApprox, SeventyNine } from '~CharactersLongAndShouldNotBeSplit'; + import { EvenThoughThisIsLongItShouldNotGetSplitUpAsItThereIsOnlyOne } from 'IWillNotBeSplitUp'; + import Short from 'app/components/LongName'; + import { + ICantBelieveHowLong, + ICantHandleHowLong, + KindaLong, + Long, + ThisIsTheLongestEver, + WowSoLong, + } from 'app/components/Short'; + import EvenThoughThisIsLongItShouldNotBePutOntoAnyNewLinesAsThereIsOnlyOne from 'IWillNotBePutOntoNewLines'; + import EvenThoughThereIsTwoOfMe, { WeWontBeSplitUpAsThereIsOnlyOneOfThese } from 'IWillNotBeSplitUp'; + `, + output: dedent` + import { + ICantBelieveHowLong, + ICantHandleHowLong, + KindaLong, + Long, + ThisIsTheLongestEver, + WowSoLong, + } from 'app/components/Short'; + import Short from 'app/components/LongName'; + import { ThisIsApprox, SeventyNine } from '~CharactersLongAndShouldNotBeSplit'; + import { EvenThoughThisIsLongItShouldNotGetSplitUpAsItThereIsOnlyOne } from 'IWillNotBeSplitUp'; + import EvenThoughThereIsTwoOfMe, { WeWontBeSplitUpAsThereIsOnlyOneOfThese } from 'IWillNotBeSplitUp'; + import EvenThoughThisIsLongItShouldNotBePutOntoAnyNewLinesAsThereIsOnlyOne from 'IWillNotBePutOntoNewLines'; + `, + options: [ + { + ...options, + order: SortOrder.asc, + 'max-line-length': 80, + groups: [ + 'type', + ['builtin', 'external'], + 'internal-type', + 'internal', + ['parent-type', 'sibling-type', 'index-type'], + ['parent', 'sibling', 'index'], + 'object', + 'unknown', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedImportsOrder', + data: { + left: 'IWillNotBeSplitUp', + right: 'app/components/LongName', + }, + }, + { + messageId: 'unexpectedImportsOrder', + data: { + left: 'app/components/LongName', + right: 'app/components/Short', + }, + }, + { + messageId: 'unexpectedImportsOrder', + data: { + left: 'IWillNotBePutOntoNewLines', + right: 'IWillNotBeSplitUp', + }, + }, + ], + }, + ], + }) + + let subType = 'schema' + + it(`${subType} -- type must be set if 'max-line-length' is`, () => { + expect( + areOptionsValid(rule, { + ...options, + type: undefined, + 'max-line-length': 80, + }), + ).toBe( + 'data[0] should have property type when property max-line-length is present', + ) + }) + + it(`${subType} -- type must be set to 'line-length' if 'max-line-length' is set`, () => { + expect( + areOptionsValid(rule, { + ...options, + type: SortType.alphabetical, + 'max-line-length': 80, + }), + ).toBe( + 'data[0] should NOT be valid, data[0].type should be equal to one of the allowed values, data[0] should match some schema in anyOf', + ) + }) + + it(`${subType} -- if it's set, 'max-line-length' must be greater than 0`, () => { + expect( + areOptionsValid(rule, { + ...options, + 'max-line-length': 0, + }), + ).toBe("data[0]['max-line-length'] should be > 0") + }) + }) }) describe(`${RULE_NAME}: misc`, () => { diff --git a/test/utils/areOptionsValid.ts b/test/utils/areOptionsValid.ts new file mode 100644 index 000000000..d75daff4a --- /dev/null +++ b/test/utils/areOptionsValid.ts @@ -0,0 +1,60 @@ +import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema' +import type { RuleModule } from '@typescript-eslint/utils/ts-eslint' + +import * as jsonSpecv4 from 'ajv/lib/refs/json-schema-draft-04.json' +import { TSUtils } from '@typescript-eslint/utils' +import Ajv from 'ajv' + +// Lovingly borrowed from: https://github.com/typescript-eslint/typescript-eslint/pull/6947/files +// TODO: It would be nicer to have https://github.com/eslint/eslint/pull/16823, but it's back in draft. +const ajv = new Ajv({ + schemaId: 'auto', + async: false, + meta: false, +}) +ajv.addMetaSchema(jsonSpecv4) +// @ts-ignore +ajv._opts.defaultMeta = jsonSpecv4.id +// @ts-ignore +ajv._refs['http://json-schema.org/schema'] = + 'http://json-schema.org/draft-04/schema' + +export let areOptionsValid = ( + rule: RuleModule, + options: unknown, +): boolean | string => { + let normalizedSchema = normalizeSchema(rule.meta.schema) + + let validate = ajv.compile(normalizedSchema) + + let valid = validate([options]) + if (typeof valid !== 'boolean') { + // Schema could not validate options synchronously. This is not allowed for ESLint rules. + return false + } + + return valid || ajv.errorsText(validate.errors) +} + +let normalizeSchema = ( + schema: readonly JSONSchema4[] | JSONSchema4, +): JSONSchema4 => { + if (!TSUtils.isArray(schema)) { + return schema + } + + if (schema.length === 0) { + return { + type: 'array', + minItems: 0, + maxItems: 0, + } + } + + return { + items: schema as JSONSchema4[], + maxItems: schema.length, + type: 'array', + minItems: 0, + } +} diff --git a/typings/index.ts b/typings/index.ts index aea0eb6fc..ac2ae42c9 100644 --- a/typings/index.ts +++ b/typings/index.ts @@ -14,6 +14,7 @@ export enum SortOrder { export type PartitionComment = string[] | boolean | string export interface SortingNode { + hasMultipleImportDeclarations?: boolean dependencies?: string[] node: TSESTree.Node group?: string diff --git a/utils/compare.ts b/utils/compare.ts index 01dd8af9c..49fa0e97d 100644 --- a/utils/compare.ts +++ b/utils/compare.ts @@ -8,6 +8,7 @@ export let compare = ( a: SortingNode, b: SortingNode, options: { + 'max-line-length'?: number 'ignore-case'?: boolean order: SortOrder type: SortType @@ -32,7 +33,27 @@ export let compare = ( sortingFunction = (aNode, bNode) => naturalCompare(formatString(aNode.name), formatString(bNode.name)) } else { - sortingFunction = (aNode, bNode) => aNode.size - bNode.size + sortingFunction = (aNode, bNode) => { + let aSize = aNode.size + let bSize = bNode.size + + let maxLineLength = options['max-line-length'] + + if (maxLineLength) { + let isTooLong = (size: number, node: SortingNode) => + size > maxLineLength! && node.hasMultipleImportDeclarations + + if (isTooLong(aSize, aNode)) { + aSize = aNode.name.length + 10 + } + + if (isTooLong(bSize, bNode)) { + bSize = bNode.name.length + 10 + } + } + + return aSize - bSize + } } return orderCoefficient * sortingFunction(a, b)