From a49ca8993f26b2946b0ff61a0994d7251913ff15 Mon Sep 17 00:00:00 2001 From: "hugo.prunaux" Date: Sun, 5 Jan 2025 23:40:09 +0400 Subject: [PATCH] feat(sort-maps): adds `groups`, `customGroups` and `newlinesBetween` --- docs/content/rules/sort-maps.mdx | 86 ++++ rules/sort-maps.ts | 139 ++++- rules/sort-maps/does-custom-group-match.ts | 34 ++ rules/sort-maps/types.ts | 38 ++ test/rules/sort-maps.test.ts | 559 +++++++++++++++++++++ 5 files changed, 830 insertions(+), 26 deletions(-) create mode 100644 rules/sort-maps/does-custom-group-match.ts diff --git a/docs/content/rules/sort-maps.mdx b/docs/content/rules/sort-maps.mdx index c3b3c36ad..2e0d2fd1f 100644 --- a/docs/content/rules/sort-maps.mdx +++ b/docs/content/rules/sort-maps.mdx @@ -177,6 +177,86 @@ new Map([ Each group of map members (separated by empty lines) is treated independently, and the order within each group is preserved. +### newlinesBetween + +default: `'ignore'` + +Specifies how new lines should be handled between map members. + +- `ignore` — Do not report errors related to new lines between map members. +- `always` — Enforce one new line between each group, and forbid new lines inside a group. +- `never` — No new lines are allowed in maps. + +This option is only applicable when `partitionByNewLine` is `false`. + +### groups + + + type: `Array` + +default: `[]` + +Allows you to specify a list of groups for sorting. Groups help organize elements into categories. + +Each element will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found). +The order of items in the `groups` option determines how groups are ordered. + +Within a given group, members will be sorted according to the `type`, `order`, `ignoreCase`, etc. options. + +Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter. +All members of the groups in the array will be sorted together as if they were part of a single group. + +### customGroups + + + type: `Array` + +default: `{}` + +You can define your own groups and use regexp patterns to match specific object type members. + +A custom group definition may follow one of the two following interfaces: + +```ts +interface CustomGroupDefinition { + groupName: string + type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' + order?: 'asc' | 'desc' + elementNamePattern?: string +} + +``` +An array element will match a `CustomGroupDefinition` group if it matches all the filters of the custom group's definition. + +or: + +```ts +interface CustomGroupAnyOfDefinition { + groupName: string + type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' + order?: 'asc' | 'desc' + anyOf: Array<{ + elementNamePattern?: string + }> +} +``` + +An array element will match a `CustomGroupAnyOfDefinition` group if it matches all the filters of at least one of the `anyOf` items. + +#### Attributes + +- `groupName`: The group's name, which needs to be put in the `groups` option. +- `elementNamePattern`: If entered, will check that the name of the element matches the pattern entered. +- `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group. +- `order`: Overrides the sort order for that custom group + +#### Match importance + +The `customGroups` list is ordered: +The first custom group definition that matches an element will be used. + +Custom groups have a higher priority than any predefined group. + ## Usage = { specialCharacters: 'keep', partitionByComment: false, partitionByNewLine: false, + newlinesBetween: 'ignore', type: 'alphabetical', ignoreCase: true, + customGroups: [], locales: 'en-US', alphabet: '', order: 'asc', + groups: [], } export default createEslintRule({ @@ -63,6 +80,12 @@ export default createEslintRule({ let settings = getSettings(context.settings) let options = complete(context.options.at(0), settings, defaultOptions) validateCustomSortConfiguration(options) + validateGeneratedGroupsConfiguration({ + customGroups: options.customGroups, + groups: options.groups, + selectors: [], + modifiers: [], + }) let sourceCode = getSourceCode(context) let eslintDisabledLines = getEslintDisabledLines({ @@ -104,12 +127,33 @@ export default createEslintRule({ } let lastSortingNode = formattedMembers.at(-1)?.at(-1) + + let { defineGroup, getGroup } = useGroups(options) + for (let customGroup of options.customGroups) { + if ( + doesCustomGroupMatch({ + elementName: name, + customGroup, + }) + ) { + defineGroup(customGroup.groupName, true) + /** + * If the custom group is not referenced in the `groups` option, it + * will be ignored + */ + if (getGroup() === customGroup.groupName) { + break + } + } + } + let sortingNode: SortingNode = { isEslintDisabled: isNodeEslintDisabled( element, eslintDisabledLines, ), size: rangeToDiff(element, sourceCode), + group: getGroup(), node: element, name, } @@ -136,7 +180,11 @@ export default createEslintRule({ let sortNodesExcludingEslintDisabled = ( ignoreEslintDisabledNodes: boolean, ): SortingNode[] => - sortNodes(nodes, options, { ignoreEslintDisabledNodes }) + sortNodesByGroups(nodes, options, { + getGroupCompareOptions: groupNumber => + getCustomGroupsCompareOptions(options, groupNumber), + ignoreEslintDisabledNodes, + }) let sortedNodes = sortNodesExcludingEslintDisabled(false) let sortedNodesExcludingEslintDisabled = sortNodesExcludingEslintDisabled(true) @@ -147,31 +195,59 @@ export default createEslintRule({ let leftIndex = nodeIndexMap.get(left)! let rightIndex = nodeIndexMap.get(right)! + let leftNumber = getGroupNumber(options.groups, left) + let rightNumber = getGroupNumber(options.groups, right) + let indexOfRightExcludingEslintDisabled = sortedNodesExcludingEslintDisabled.indexOf(right) + + let messageIds: MESSAGE_ID[] = [] + if ( - leftIndex < rightIndex && - leftIndex < indexOfRightExcludingEslintDisabled + leftIndex > rightIndex || + leftIndex >= indexOfRightExcludingEslintDisabled ) { - return + messageIds.push( + leftNumber === rightNumber + ? 'unexpectedMapElementsOrder' + : 'unexpectedMapElementsGroupOrder', + ) } - context.report({ - fix: fixer => - makeFixes({ - sortedNodes: sortedNodesExcludingEslintDisabled, - sourceCode, - options, - fixer, - nodes, - }), - data: { - right: toSingleLine(right.name), - left: toSingleLine(left.name), - }, - messageId: 'unexpectedMapElementsOrder', - node: right.node, - }) + messageIds = [ + ...messageIds, + ...getNewlinesErrors({ + missedSpacingError: 'missedSpacingBetweenMapElementsMembers', + extraSpacingError: 'extraSpacingBetweenMapElementsMembers', + rightNum: rightNumber, + leftNum: leftNumber, + sourceCode, + options, + right, + left, + }), + ] + + for (let messageId of messageIds) { + context.report({ + fix: fixer => + makeFixes({ + sortedNodes: sortedNodesExcludingEslintDisabled, + sourceCode, + options, + fixer, + nodes, + }), + data: { + right: toSingleLine(right.name), + left: toSingleLine(left.name), + rightGroup: right.group, + leftGroup: left.group, + }, + node: right.node, + messageId, + }) + } }) } } @@ -186,27 +262,38 @@ export default createEslintRule({ description: 'Allows you to use comments to separate the maps members into logical groups.', }, + customGroups: buildCustomGroupsArrayJsonSchema({ + singleCustomGroupJsonSchema, + }), partitionByNewLine: partitionByNewLineJsonSchema, specialCharacters: specialCharactersJsonSchema, + newlinesBetween: newlinesBetweenJsonSchema, ignoreCase: ignoreCaseJsonSchema, alphabet: alphabetJsonSchema, type: buildTypeJsonSchema(), locales: localesJsonSchema, + groups: groupsJsonSchema, order: orderJsonSchema, }, additionalProperties: false, type: 'object', }, ], + messages: { + unexpectedMapElementsGroupOrder: + 'Expected "{{right}}" ({{rightGroup}}) to come before "{{left}}" ({{leftGroup}}).', + missedSpacingBetweenMapElementsMembers: + 'Missed spacing between "{{left}}" and "{{right}}" members.', + extraSpacingBetweenMapElementsMembers: + 'Extra spacing between "{{left}}" and "{{right}}" members.', + unexpectedMapElementsOrder: + 'Expected "{{right}}" to come before "{{left}}".', + }, docs: { url: 'https://perfectionist.dev/rules/sort-maps', description: 'Enforce sorted Map elements.', recommended: true, }, - messages: { - unexpectedMapElementsOrder: - 'Expected "{{right}}" to come before "{{left}}".', - }, type: 'suggestion', fixable: 'code', }, diff --git a/rules/sort-maps/does-custom-group-match.ts b/rules/sort-maps/does-custom-group-match.ts new file mode 100644 index 000000000..20eace816 --- /dev/null +++ b/rules/sort-maps/does-custom-group-match.ts @@ -0,0 +1,34 @@ +import type { SingleCustomGroup, AnyOfCustomGroup } from './types' + +import { matches } from '../../utils/matches' + +interface DoesCustomGroupMatchProps { + customGroup: SingleCustomGroup | AnyOfCustomGroup + elementName: string +} + +export let doesCustomGroupMatch = ( + props: DoesCustomGroupMatchProps, +): boolean => { + if ('anyOf' in props.customGroup) { + // At least one subgroup must match + return props.customGroup.anyOf.some(subgroup => + doesCustomGroupMatch({ ...props, customGroup: subgroup }), + ) + } + + if ( + 'elementNamePattern' in props.customGroup && + props.customGroup.elementNamePattern + ) { + let matchesElementNamePattern: boolean = matches( + props.elementName, + props.customGroup.elementNamePattern, + ) + if (!matchesElementNamePattern) { + return false + } + } + + return true +} diff --git a/rules/sort-maps/types.ts b/rules/sort-maps/types.ts index 09f94aac5..5e4635537 100644 --- a/rules/sort-maps/types.ts +++ b/rules/sort-maps/types.ts @@ -1,3 +1,7 @@ +import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema' + +import { elementNamePatternJsonSchema } from '../../utils/common-json-schemas' + export type Options = Partial<{ partitionByComment: | { @@ -7,11 +11,45 @@ export type Options = Partial<{ | string[] | boolean | string + groups: ( + | { newlinesBetween: 'ignore' | 'always' | 'never' } + | Group[] + | Group + )[] type: 'alphabetical' | 'line-length' | 'natural' | 'custom' + newlinesBetween: 'ignore' | 'always' | 'never' specialCharacters: 'remove' | 'trim' | 'keep' locales: NonNullable partitionByNewLine: boolean + customGroups: CustomGroup[] order: 'desc' | 'asc' ignoreCase: boolean alphabet: string }>[] + +export interface SingleCustomGroup { + elementNamePattern?: string +} + +export interface AnyOfCustomGroup { + anyOf: SingleCustomGroup[] +} + +type CustomGroup = ( + | { + order?: Options[0]['order'] + type?: Options[0]['type'] + } + | { + type?: 'unsorted' + } +) & + (SingleCustomGroup | AnyOfCustomGroup) & { + groupName: string + } + +type Group = 'unknown' | string + +export let singleCustomGroupJsonSchema: Record = { + elementNamePattern: elementNamePatternJsonSchema, +} diff --git a/test/rules/sort-maps.test.ts b/test/rules/sort-maps.test.ts index 73846a215..b59911d45 100644 --- a/test/rules/sort-maps.test.ts +++ b/test/rules/sort-maps.test.ts @@ -792,6 +792,565 @@ describe(ruleName, () => { ], invalid: [], }) + + describe(`${ruleName}: custom groups`, () => { + ruleTester.run(`${ruleName}: filters on elementNamePattern`, rule, { + invalid: [ + { + errors: [ + { + data: { + rightGroup: 'keysStartingWithHello', + leftGroup: 'unknown', + right: "'helloKey'", + left: "'b'", + }, + messageId: 'unexpectedMapElementsGroupOrder', + }, + ], + options: [ + { + customGroups: [ + { + groupName: 'keysStartingWithHello', + elementNamePattern: 'hello*', + }, + ], + groups: ['keysStartingWithHello', 'unknown'], + }, + ], + output: dedent` + new Map([ + ['helloKey', 3], + ['a', 1], + ['b', 2] + ]) + `, + code: dedent` + new Map([ + ['a', 1], + ['b', 2], + ['helloKey', 3] + ]) + `, + }, + ], + valid: [], + }) + + ruleTester.run( + `${ruleName}: sort custom groups by overriding 'type' and 'order'`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + right: '_bb', + left: '_a', + }, + messageId: 'unexpectedMapElementsOrder', + }, + { + data: { + right: '_ccc', + left: '_bb', + }, + messageId: 'unexpectedMapElementsOrder', + }, + { + data: { + right: '_dddd', + left: '_ccc', + }, + messageId: 'unexpectedMapElementsOrder', + }, + { + data: { + rightGroup: 'reversedStartingWith_ByLineLength', + leftGroup: 'unknown', + right: '_eee', + left: 'm', + }, + messageId: 'unexpectedMapElementsGroupOrder', + }, + ], + options: [ + { + customGroups: [ + { + groupName: 'reversedStartingWith_ByLineLength', + elementNamePattern: '_', + type: 'line-length', + order: 'desc', + }, + ], + groups: ['reversedStartingWith_ByLineLength', 'unknown'], + type: 'alphabetical', + order: 'asc', + }, + ], + output: dedent` + new Map([ + [_dddd, null], + [_ccc, null], + [_eee, null], + [_bb, null], + [_ff, null], + [_a, null], + [_g, null], + [m, null], + [o, null], + [p, null] + ]) + `, + code: dedent` + new Map([ + [_a, null], + [_bb, null], + [_ccc, null], + [_dddd, null], + [m, null], + [_eee, null], + [_ff, null], + [_g, null], + [o, null], + [p, null] + ]) + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}: does not sort custom groups with 'unsorted' type`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + groupName: 'unsortedStartingWith_', + elementNamePattern: '_', + type: 'unsorted', + }, + ], + groups: ['unsortedStartingWith_', 'unknown'], + }, + ], + errors: [ + { + data: { + rightGroup: 'unsortedStartingWith_', + leftGroup: 'unknown', + right: "'_c'", + left: "'m'", + }, + messageId: 'unexpectedMapElementsGroupOrder', + }, + ], + output: dedent` + new Map([ + ['_b', null], + ['_a', null], + ['_d', null], + ['_e', null], + ['_c', null], + ['m', null] + ]) + `, + code: dedent` + new Map([ + ['_b', null], + ['_a', null], + ['_d', null], + ['_e', null], + ['m', null], + ['_c', null] + ]) + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run(`${ruleName}: sort custom group blocks`, rule, { + invalid: [ + { + options: [ + { + customGroups: [ + { + anyOf: [ + { + elementNamePattern: 'foo', + }, + { + elementNamePattern: 'Foo', + }, + ], + groupName: 'elementsIncludingFoo', + }, + ], + groups: ['elementsIncludingFoo', 'unknown'], + }, + ], + errors: [ + { + data: { + rightGroup: 'elementsIncludingFoo', + leftGroup: 'unknown', + right: "'...foo'", + left: "'a'", + }, + messageId: 'unexpectedMapElementsGroupOrder', + }, + ], + output: dedent` + new Map([ + ['...foo', null], + ['cFoo', null], + ['a', null] + ]) + `, + code: dedent` + new Map([ + ['a', null], + ['...foo', null], + ['cFoo', null] + ]) + `, + }, + ], + valid: [], + }) + + ruleTester.run( + `${ruleName}: allows to use regex for element names in custom groups`, + rule, + { + valid: [ + { + options: [ + { + customGroups: [ + { + elementNamePattern: '^(?!.*Foo).*$', + groupName: 'elementsWithoutFoo', + }, + ], + groups: ['unknown', 'elementsWithoutFoo'], + type: 'alphabetical', + }, + ], + code: dedent` + new Map([ + ['iHaveFooInMyName', null], + ['meTooIHaveFoo', null], + ['a', null], + ['b', null] + ]) + `, + }, + ], + invalid: [], + }, + ) + }) + + describe(`${ruleName}: newlinesBetween`, () => { + ruleTester.run( + `${ruleName}(${type}): removes newlines when never`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + right: 'y', + left: 'a', + }, + messageId: 'extraSpacingBetweenMapElementsMembers', + }, + { + data: { + right: 'b', + left: 'z', + }, + messageId: 'unexpectedMapElementsOrder', + }, + { + data: { + right: 'b', + left: 'z', + }, + messageId: 'extraSpacingBetweenMapElementsMembers', + }, + ], + options: [ + { + ...options, + customGroups: [ + { + elementNamePattern: 'a', + groupName: 'a', + }, + ], + groups: ['a', 'unknown'], + newlinesBetween: 'never', + }, + ], + code: dedent` + new Map([ + [a, null], + + + [y, null], + [z, null], + + [b, null] + ]) + `, + output: dedent` + new Map([ + [a, null], + [b, null], + [y, null], + [z, null] + ]) + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): keeps one newline when always`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + right: 'z', + left: 'a', + }, + messageId: 'extraSpacingBetweenMapElementsMembers', + }, + { + data: { + right: 'y', + left: 'z', + }, + messageId: 'unexpectedMapElementsOrder', + }, + { + data: { + right: 'b', + left: 'y', + }, + messageId: 'missedSpacingBetweenMapElementsMembers', + }, + ], + options: [ + { + ...options, + customGroups: [ + { + elementNamePattern: 'a', + groupName: 'a', + }, + { + elementNamePattern: 'b', + groupName: 'b', + }, + ], + groups: ['a', 'unknown', 'b'], + newlinesBetween: 'always', + }, + ], + output: dedent` + new Map([ + [a, null], + + [y, null], + [z, null], + + [b, null], + ]) + `, + code: dedent` + new Map([ + [a, null], + + + [z, null], + [y, null], + [b, null], + ]) + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): allows to use "newlinesBetween" inside groups`, + rule, + { + invalid: [ + { + options: [ + { + ...options, + customGroups: [ + { elementNamePattern: 'a', groupName: 'a' }, + { elementNamePattern: 'b', groupName: 'b' }, + { elementNamePattern: 'c', groupName: 'c' }, + { elementNamePattern: 'd', groupName: 'd' }, + { elementNamePattern: 'e', groupName: 'e' }, + ], + groups: [ + 'a', + { newlinesBetween: 'always' }, + 'b', + { newlinesBetween: 'always' }, + 'c', + { newlinesBetween: 'never' }, + 'd', + { newlinesBetween: 'ignore' }, + 'e', + ], + newlinesBetween: 'always', + }, + ], + errors: [ + { + data: { + right: 'b', + left: 'a', + }, + messageId: 'missedSpacingBetweenMapElementsMembers', + }, + { + data: { + right: 'c', + left: 'b', + }, + messageId: 'extraSpacingBetweenMapElementsMembers', + }, + { + data: { + right: 'd', + left: 'c', + }, + messageId: 'extraSpacingBetweenMapElementsMembers', + }, + ], + output: dedent` + new Map([ + [a, null], + + [b, null], + + [c, null], + [d, null], + + + [e, null] + ]) + `, + code: dedent` + new Map([ + [a, null], + [b, null], + + + [c, null], + + [d, null], + + + [e, null] + ]) + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): handles newlines and comment after fixes`, + rule, + { + invalid: [ + { + output: [ + dedent` + new Map([ + [a, null], // Comment after + [b, null], + + [c, null] + ]) + `, + dedent` + new Map([ + [a, null], // Comment after + + [b, null], + [c, null] + ]) + `, + ], + options: [ + { + customGroups: [ + { + elementNamePattern: 'b|c', + groupName: 'b|c', + }, + ], + groups: ['unknown', 'b|c'], + newlinesBetween: 'always', + }, + ], + errors: [ + { + data: { + rightGroup: 'unknown', + leftGroup: 'b|c', + right: 'a', + left: 'b', + }, + messageId: 'unexpectedMapElementsGroupOrder', + }, + ], + code: dedent` + new Map([ + [b, null], + [a, null], // Comment after + + [c, null] + ]) + `, + }, + ], + valid: [], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => {