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`, () => {