Repository: azat-io/eslint-plugin-perfectionist
Pull request #443 (2025-01-12 14:05:34)

feat(sort-maps): adds `groups`, `customGroups` and `newlinesBetween`
As usual, most lines added are tests and documentation 🙂

### Description

This PR adds the following features to `sort-maps`, to look very similar to `sort-array-includes`:
- `groups` option (no predefined group).
- `newlinesBetween` option.
- Array-based `customGroups`.
- `useConfigurationIf.allNamesMatchPattern`.

### What is the purpose of this pull request? <!-- (put an "X" next to an item) -->

- [x] New Feature


azat-io: Nice, thank you!

Patch:
diff --git a/docs/content/rules/sort-array-includes.mdx b/docs/content/rules/sort-array-includes.mdx
index fb0c786..dcbf5a3 100644
--- a/docs/content/rules/sort-array-includes.mdx
+++ b/docs/content/rules/sort-array-includes.mdx
@@ -239,11 +239,11 @@ Each group of elements (separated by empty lines) is treated independently, and
 </sub>
 <sub>default: `{}`</sub>
 
-Allows you to specify filters to match a particular options configuration for a given object.
+Allows you to specify filters to match a particular options configuration for a given array.
 
 The first matching options configuration will be used. If no configuration matches, the default options configuration will be used.
 
-- `allNamesMatchPattern` — A regexp pattern that all object keys must match.
+- `allNamesMatchPattern` — A regexp pattern that all array keys must match.
 
 Example configuration:
 ```ts
diff --git a/docs/content/rules/sort-maps.mdx b/docs/content/rules/sort-maps.mdx
index c3b3c36..a4f7546 100644
--- a/docs/content/rules/sort-maps.mdx
+++ b/docs/content/rules/sort-maps.mdx
@@ -89,6 +89,7 @@ Specifies the sorting method.
 - `'natural'` — Sort items in a [natural](https://github.com/yobacca/natural-orderby) order (e.g., “item2” < “item10”).
 - `'line-length'` — Sort items by the length of the code line (shorter lines first).
 - `'custom'` — Sort items using the alphabet entered in the [`alphabet`](#alphabet) option.
+- `'unsorted'` — Do not sort items. To be used with the [`useConfigurationIf`](#useconfigurationif) option.
 
 ### order
 
@@ -177,6 +178,131 @@ new Map([
 
 Each group of map members (separated by empty lines) is treated independently, and the order within each group is preserved.
 
+### newlinesBetween
+
+<sub>default: `'ignore'`</sub>
+
+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`.
+
+### useConfigurationIf
+
+<sub>
+  type: `{ allNamesMatchPattern?: string }`
+</sub>
+<sub>default: `{}`</sub>
+
+Allows you to specify filters to match a particular options configuration for a given map.
+
+The first matching options configuration will be used. If no configuration matches, the default options configuration will be used.
+
+- `allNamesMatchPattern` — A regexp pattern that all map keys must match.
+
+Example configuration:
+```ts
+{
+  'perfectionist/sort-maps': [
+    'error',
+    {
+      groups: ['r', 'g', 'b'], // Sort colors by RGB
+      customGroups: [
+        {
+          elementNamePattern: '^r$',
+          groupName: 'r',
+        },
+        {
+          elementNamePattern: '^g$',
+          groupName: 'g',
+        },
+        {
+          elementNamePattern: '^b$',
+          groupName: 'b',
+        },
+      ],
+      useConfigurationIf: {
+        allNamesMatchPattern: '^r|g|b$',
+      },
+    },
+    {
+      type: 'alphabetical' // Fallback configuration
+    }
+  ],
+}
+```
+
+### groups
+
+<sub>
+  type: `Array<string | string[]>`
+</sub>
+<sub>default: `[]`</sub>
+
+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
+
+<sub>
+  type: `Array<CustomGroupDefinition | CustomGroupAnyOfDefinition>`
+</sub>
+<sub>default: `{}`</sub>
+
+You can define your own groups and use regexp patterns to match specific elements.
+
+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
 
 <CodeTabs
@@ -201,6 +327,10 @@ Each group of map members (separated by empty lines) is treated independently, a
                   specialCharacters: 'keep',
                   partitionByNewLine: false,
                   partitionByComment: false,
+                  newlinesBetween: false,
+                  useConfigurationIf: {},
+                  groups: [],
+                  customGroups: [],
                 },
               ],
             },
@@ -227,6 +357,10 @@ Each group of map members (separated by empty lines) is treated independently, a
                 specialCharacters: 'keep',
                 partitionByNewLine: false,
                 partitionByComment: false,
+                newlinesBetween: false,
+                useConfigurationIf: {},
+                groups: [],
+                customGroups: [],
               },
             ],
           },
diff --git a/rules/sort-maps.ts b/rules/sort-maps.ts
index 79f9719..e660465 100644
--- a/rules/sort-maps.ts
+++ b/rules/sort-maps.ts
@@ -1,66 +1,69 @@
-import type { TSESTree } from '@typescript-eslint/types'
+import type { TSESLint } from '@typescript-eslint/utils'
+
+import { TSESTree } from '@typescript-eslint/types'
 
 import type { SortingNode } from '../types/sorting-node'
+import type { Options } from './sort-maps/types'
 
 import {
+  buildUseConfigurationIfJsonSchema,
+  buildCustomGroupsArrayJsonSchema,
   partitionByCommentJsonSchema,
   partitionByNewLineJsonSchema,
   specialCharactersJsonSchema,
+  newlinesBetweenJsonSchema,
   ignoreCaseJsonSchema,
   buildTypeJsonSchema,
   alphabetJsonSchema,
   localesJsonSchema,
+  groupsJsonSchema,
   orderJsonSchema,
 } from '../utils/common-json-schemas'
+import { validateGeneratedGroupsConfiguration } from '../utils/validate-generated-groups-configuration'
 import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration'
+import { getCustomGroupsCompareOptions } from '../utils/get-custom-groups-compare-options'
+import { getMatchingContextOptions } from '../utils/get-matching-context-options'
 import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines'
+import { doesCustomGroupMatch } from './sort-maps/does-custom-group-match'
 import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled'
 import { hasPartitionComment } from '../utils/has-partition-comment'
 import { createNodeIndexMap } from '../utils/create-node-index-map'
+import { sortNodesByGroups } from '../utils/sort-nodes-by-groups'
 import { getCommentsBefore } from '../utils/get-comments-before'
+import { getNewlinesErrors } from '../utils/get-newlines-errors'
+import { singleCustomGroupJsonSchema } from './sort-maps/types'
 import { createEslintRule } from '../utils/create-eslint-rule'
 import { getLinesBetween } from '../utils/get-lines-between'
+import { getGroupNumber } from '../utils/get-group-number'
 import { getSourceCode } from '../utils/get-source-code'
 import { toSingleLine } from '../utils/to-single-line'
 import { rangeToDiff } from '../utils/range-to-diff'
 import { getSettings } from '../utils/get-settings'
 import { isSortable } from '../utils/is-sortable'
 import { makeFixes } from '../utils/make-fixes'
-import { sortNodes } from '../utils/sort-nodes'
+import { useGroups } from '../utils/use-groups'
 import { complete } from '../utils/complete'
 import { pairwise } from '../utils/pairwise'
 
-type Options = [
-  Partial<{
-    partitionByComment:
-      | {
-          block?: string[] | boolean | string
-          line?: string[] | boolean | string
-        }
-      | string[]
-      | boolean
-      | string
-    type: 'alphabetical' | 'line-length' | 'natural' | 'custom'
-    specialCharacters: 'remove' | 'trim' | 'keep'
-    locales: NonNullable<Intl.LocalesArgument>
-    partitionByNewLine: boolean
-    order: 'desc' | 'asc'
-    ignoreCase: boolean
-    alphabet: string
-  }>,
-]
-
-type MESSAGE_ID = 'unexpectedMapElementsOrder'
+type MESSAGE_ID =
+  | 'missedSpacingBetweenMapElementsMembers'
+  | 'extraSpacingBetweenMapElementsMembers'
+  | 'unexpectedMapElementsGroupOrder'
+  | 'unexpectedMapElementsOrder'
 
 let defaultOptions: Required<Options[0]> = {
   specialCharacters: 'keep',
   partitionByComment: false,
   partitionByNewLine: false,
+  newlinesBetween: 'ignore',
+  useConfigurationIf: {},
   type: 'alphabetical',
   ignoreCase: true,
+  customGroups: [],
   locales: 'en-US',
   alphabet: '',
   order: 'asc',
+  groups: [],
 }
 
 export default createEslintRule<Options, MESSAGE_ID>({
@@ -79,11 +82,29 @@ export default createEslintRule<Options, MESSAGE_ID>({
         return
       }
 
+      let sourceCode = getSourceCode(context)
       let settings = getSettings(context.settings)
-      let options = complete(context.options.at(0), settings, defaultOptions)
+
+      let matchedContextOptions = getMatchingContextOptions({
+        nodeNames: elements
+          .filter(
+            element =>
+              element !== null &&
+              element.type !== TSESTree.AST_NODE_TYPES.SpreadElement,
+          )
+          .map(element => getNodeName({ sourceCode, element })),
+        contextOptions: context.options,
+      })
+
+      let options = complete(matchedContextOptions[0], settings, defaultOptions)
       validateCustomSortConfiguration(options)
+      validateGeneratedGroupsConfiguration({
+        customGroups: options.customGroups,
+        groups: options.groups,
+        selectors: [],
+        modifiers: [],
+      })
 
-      let sourceCode = getSourceCode(context)
       let eslintDisabledLines = getEslintDisabledLines({
         ruleName: context.id,
         sourceCode,
@@ -106,29 +127,39 @@ export default createEslintRule<Options, MESSAGE_ID>({
       for (let part of parts) {
         let formattedMembers: SortingNode[][] = [[]]
         for (let element of part) {
-          let name: string
+          let name: string = getNodeName({
+            sourceCode,
+            element,
+          })
 
-          if (element.type === 'ArrayExpression') {
-            let [left] = element.elements
+          let lastSortingNode = formattedMembers.at(-1)?.at(-1)
 
-            if (!left) {
-              name = `${left}`
-            } else if (left.type === 'Literal') {
-              name = left.raw
-            } else {
-              name = sourceCode.getText(left)
+          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
+              }
             }
-          } else {
-            name = sourceCode.getText(element)
           }
 
-          let lastSortingNode = formattedMembers.at(-1)?.at(-1)
           let sortingNode: SortingNode = {
             isEslintDisabled: isNodeEslintDisabled(
               element,
               eslintDisabledLines,
             ),
             size: rangeToDiff(element, sourceCode),
+            group: getGroup(),
             node: element,
             name,
           }
@@ -155,7 +186,11 @@ export default createEslintRule<Options, MESSAGE_ID>({
           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)
@@ -166,69 +201,131 @@ export default createEslintRule<Options, MESSAGE_ID>({
             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,
+              })
+            }
           })
         }
       }
     },
   }),
   meta: {
-    schema: [
-      {
+    schema: {
+      items: {
         properties: {
           partitionByComment: {
             ...partitionByCommentJsonSchema,
             description:
               'Allows you to use comments to separate the maps members into logical groups.',
           },
+          customGroups: buildCustomGroupsArrayJsonSchema({
+            singleCustomGroupJsonSchema,
+          }),
+          useConfigurationIf: buildUseConfigurationIfJsonSchema(),
           partitionByNewLine: partitionByNewLineJsonSchema,
           specialCharacters: specialCharactersJsonSchema,
+          newlinesBetween: newlinesBetweenJsonSchema,
           ignoreCase: ignoreCaseJsonSchema,
           alphabet: alphabetJsonSchema,
           type: buildTypeJsonSchema(),
           locales: localesJsonSchema,
+          groups: groupsJsonSchema,
           order: orderJsonSchema,
         },
         additionalProperties: false,
         type: 'object',
       },
-    ],
-    docs: {
-      url: 'https://perfectionist.dev/rules/sort-maps',
-      description: 'Enforce sorted Map elements.',
-      recommended: true,
+      uniqueItems: true,
+      type: 'array',
     },
     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,
+    },
     type: 'suggestion',
     fixable: 'code',
   },
   defaultOptions: [defaultOptions],
   name: 'sort-maps',
 })
+
+let getNodeName = ({
+  sourceCode,
+  element,
+}: {
+  sourceCode: TSESLint.SourceCode
+  element: TSESTree.Expression
+}): string => {
+  if (element.type === 'ArrayExpression') {
+    let [left] = element.elements
+
+    if (!left) {
+      return `${left}`
+    } else if (left.type === 'Literal') {
+      return left.raw
+    }
+    return sourceCode.getText(left)
+  }
+  return sourceCode.getText(element)
+}
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 0000000..20eace8
--- /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
new file mode 100644
index 0000000..8946f21
--- /dev/null
+++ b/rules/sort-maps/types.ts
@@ -0,0 +1,58 @@
+import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema'
+
+import { elementNamePatternJsonSchema } from '../../utils/common-json-schemas'
+
+export type Options = Partial<{
+  partitionByComment:
+    | {
+        block?: string[] | boolean | string
+        line?: string[] | boolean | string
+      }
+    | string[]
+    | boolean
+    | string
+  groups: (
+    | { newlinesBetween: 'ignore' | 'always' | 'never' }
+    | Group[]
+    | Group
+  )[]
+  useConfigurationIf: {
+    allNamesMatchPattern?: string
+  }
+  type: 'alphabetical' | 'line-length' | 'natural' | 'custom'
+  newlinesBetween: 'ignore' | 'always' | 'never'
+  specialCharacters: 'remove' | 'trim' | 'keep'
+  locales: NonNullable<Intl.LocalesArgument>
+  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<string, JSONSchema4> = {
+  elementNamePattern: elementNamePatternJsonSchema,
+}


Test patch:
diff --git a/test/rules/sort-maps.test.ts b/test/rules/sort-maps.test.ts
index 73846a2..45dc1a2 100644
--- a/test/rules/sort-maps.test.ts
+++ b/test/rules/sort-maps.test.ts
@@ -792,6 +792,642 @@ 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}(${type}): allows to use 'useConfigurationIf'`, () => {
+      ruleTester.run(
+        `${ruleName}(${type}): allows to use 'allNamesMatchPattern'`,
+        rule,
+        {
+          invalid: [
+            {
+              options: [
+                {
+                  ...options,
+                  useConfigurationIf: {
+                    allNamesMatchPattern: 'foo',
+                  },
+                },
+                {
+                  ...options,
+                  customGroups: [
+                    {
+                      elementNamePattern: '^r$',
+                      groupName: 'r',
+                    },
+                    {
+                      elementNamePattern: '^g$',
+                      groupName: 'g',
+                    },
+                    {
+                      elementNamePattern: '^b$',
+                      groupName: 'b',
+                    },
+                  ],
+                  useConfigurationIf: {
+                    allNamesMatchPattern: '^r|g|b$',
+                  },
+                  groups: ['r', 'g', 'b'],
+                },
+              ],
+              errors: [
+                {
+                  data: {
+                    rightGroup: 'g',
+                    leftGroup: 'b',
+                    right: 'g',
+                    left: 'b',
+                  },
+                  messageId: 'unexpectedMapElementsGroupOrder',
+                },
+                {
+                  data: {
+                    rightGroup: 'r',
+                    leftGroup: 'g',
+                    right: 'r',
+                    left: 'g',
+                  },
+                  messageId: 'unexpectedMapElementsGroupOrder',
+                },
+              ],
+              output: dedent`
+                new Map([
+                  [r, null],
+                  [g, null],
+                  [b, null]
+                ])
+              `,
+              code: dedent`
+                new Map([
+                  [b, null],
+                  [g, null],
+                  [r, null]
+                ])
+              `,
+            },
+          ],
+          valid: [],
+        },
+      )
+    })
   })
 
   describe(`${ruleName}: sorting by natural order`, () => {