From 4b81340966730c6847c46ed9bc26b01b48f5c8fd Mon Sep 17 00:00:00 2001 From: tthornton3-chwy <83608949+tthornton3-chwy@users.noreply.github.com> Date: Sat, 11 Nov 2023 12:04:19 -0500 Subject: [PATCH] feat: add max line length option for multiline imports sorting --- .gitignore | 1 + docs/rules/sort-imports.md | 9 +++ package.json | 3 +- pnpm-lock.yaml | 3 + rules/sort-imports.ts | 41 +++++++++++ test/sort-imports.test.ts | 122 +++++++++++++++++++++++++++++++- test/utils/are-options-valid.ts | 60 ++++++++++++++++ typings/index.ts | 1 + utils/compare.ts | 23 +++++- 9 files changed, 260 insertions(+), 3 deletions(-) create mode 100644 test/utils/are-options-valid.ts diff --git a/.gitignore b/.gitignore index fa65a35a8..6125d01eb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Node.js node_modules/ +.nvmrc # Build dist/ 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 30038c632..7893f0e02 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@typescript-eslint/rule-tester": "^6.9.1", "@typescript-eslint/types": "^6.9.1", "@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", @@ -125,4 +126,4 @@ "vitest": "^0.34.6", "vue": "^3.3.7" } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd1b040b9..89d27eaa2 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..41d44f26a 100644 --- a/rules/sort-imports.ts +++ b/rules/sort-imports.ts @@ -59,6 +59,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 +82,7 @@ export default createEslintRule, MESSAGE_ID>({ fixable: 'code', schema: [ { + id: 'sort-imports', type: 'object', properties: { 'custom-groups': { @@ -132,8 +134,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 +343,10 @@ export default createEslintRule, MESSAGE_ID>({ return getGroup() } + let hasMultipleImportDeclarations = ( + node: TSESTree.ImportDeclaration, + ): boolean => node.specifiers.length > 1 + let registerNode = (node: ModuleDeclaration) => { let name: string @@ -333,6 +368,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..98bb2fa27 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/are-options-valid' import { SortOrder, SortType } from '../typings' describe(RULE_NAME, () => { @@ -3474,6 +3475,125 @@ 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 ThereIsTwoOfMe, { + SoWeShouldSplitUpSinceWeAreInDifferentSections + } from 'IWillDefinitelyBeSplitUp'; + `, + output: dedent` + import { + ICantBelieveHowLong, + ICantHandleHowLong, + KindaLong, + Long, + ThisIsTheLongestEver, + WowSoLong, + } from 'app/components/Short'; + import ThereIsTwoOfMe, { + SoWeShouldSplitUpSinceWeAreInDifferentSections + } from 'IWillDefinitelyBeSplitUp'; + import Short from 'app/components/LongName'; + import { ThisIsApprox, SeventyNine } from '~CharactersLongAndShouldNotBeSplit'; + import { EvenThoughThisIsLongItShouldNotGetSplitUpAsItThereIsOnlyOne } 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: 'IWillDefinitelyBeSplitUp', + }, + }, + ], + }, + ], + }) + + 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/are-options-valid.ts b/test/utils/are-options-valid.ts new file mode 100644 index 000000000..d75daff4a --- /dev/null +++ b/test/utils/are-options-valid.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)