Repository: azat-io/eslint-plugin-perfectionist
Pull request #463 (2025-02-07 19:27:44)

feat: adds missing `newlinesBetween` validation in `groups`
### Description

In [`0cf5540` (#435)](https://github.com/azat-io/eslint-plugin-perfectionist/pull/435/commits/0cf5540a56c7e0e1a18ff6bef96890603529884b), we added a validation step to ensure that users would not enter consecutive `newlinesBetween` inside the `groups` option.

This works, but there are cases where this validation was not applied:

- ✅ `validateGroupsConfiguration` validates this (`sort-imports`, `sort-decorators`, etc).
- ❌ `validateGeneratedGroupsConfiguration` doesn't (`sort-classes`, `sort-objects`, etc).

### Changes

The first 2 commits refactor the code to extract a `validateNewlinesBetweenInsideGroups` function that we will then use in `validateGeneratedGroupsConfiguration`.

### What is the purpose of this pull request?

- [x] Other




Patch:
diff --git a/rules/sort-decorators.ts b/rules/sort-decorators.ts
index 691498d..8e851e4 100644
--- a/rules/sort-decorators.ts
+++ b/rules/sort-decorators.ts
@@ -78,11 +78,11 @@ export default createEslintRule<Options, MESSAGE_ID>({
 
     let options = complete(context.options.at(0), settings, defaultOptions)
     validateCustomSortConfiguration(options)
-    validateGroupsConfiguration(
-      options.groups,
-      ['unknown'],
-      Object.keys(options.customGroups),
-    )
+    validateGroupsConfiguration({
+      allowedCustomGroups: Object.keys(options.customGroups),
+      allowedPredefinedGroups: ['unknown'],
+      options,
+    })
 
     return {
       Decorator: decorator => {
diff --git a/rules/sort-heritage-clauses.ts b/rules/sort-heritage-clauses.ts
index c9fbc81..4d28e2d 100644
--- a/rules/sort-heritage-clauses.ts
+++ b/rules/sort-heritage-clauses.ts
@@ -83,11 +83,11 @@ export default createEslintRule<Options, MESSAGE_ID>({
 
     let options = complete(context.options.at(0), settings, defaultOptions)
     validateCustomSortConfiguration(options)
-    validateGroupsConfiguration(
-      options.groups,
-      ['unknown'],
-      Object.keys(options.customGroups),
-    )
+    validateGroupsConfiguration({
+      allowedCustomGroups: Object.keys(options.customGroups),
+      allowedPredefinedGroups: ['unknown'],
+      options,
+    })
 
     return {
       TSInterfaceDeclaration: declaration =>
diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts
index a986406..9b5f9c0 100644
--- a/rules/sort-imports.ts
+++ b/rules/sort-imports.ts
@@ -135,9 +135,8 @@ export default createEslintRule<Options, MESSAGE_ID>({
       } as const),
     )
 
-    validateGroupsConfiguration(
-      options.groups,
-      [
+    validateGroupsConfiguration({
+      allowedPredefinedGroups: [
         'side-effect-style',
         'external-type',
         'internal-type',
@@ -157,11 +156,12 @@ export default createEslintRule<Options, MESSAGE_ID>({
         'style',
         'type',
       ],
-      [
+      allowedCustomGroups: [
         ...Object.keys(options.customGroups.type ?? {}),
         ...Object.keys(options.customGroups.value ?? {}),
       ],
-    )
+      options,
+    })
     validateCustomSortConfiguration(options)
     validateNewlinesAndPartitionConfiguration(options)
 
diff --git a/rules/sort-jsx-props.ts b/rules/sort-jsx-props.ts
index ed0957b..5c64a13 100644
--- a/rules/sort-jsx-props.ts
+++ b/rules/sort-jsx-props.ts
@@ -84,11 +84,11 @@ export default createEslintRule<Options, MESSAGE_ID>({
       let settings = getSettings(context.settings)
       let options = complete(context.options.at(0), settings, defaultOptions)
       validateCustomSortConfiguration(options)
-      validateGroupsConfiguration(
-        options.groups,
-        ['multiline', 'shorthand', 'unknown'],
-        Object.keys(options.customGroups),
-      )
+      validateGroupsConfiguration({
+        allowedPredefinedGroups: ['multiline', 'shorthand', 'unknown'],
+        allowedCustomGroups: Object.keys(options.customGroups),
+        options,
+      })
       validateNewlinesAndPartitionConfiguration(options)
 
       let sourceCode = getSourceCode(context)
diff --git a/rules/sort-union-types.ts b/rules/sort-union-types.ts
index fa37160..176893f 100644
--- a/rules/sort-union-types.ts
+++ b/rules/sort-union-types.ts
@@ -155,9 +155,8 @@ export let sortUnionOrIntersectionTypes = <MessageIds extends string>({
 
   let options = complete(context.options.at(0), settings, defaultOptions)
   validateCustomSortConfiguration(options)
-  validateGroupsConfiguration(
-    options.groups,
-    [
+  validateGroupsConfiguration({
+    allowedPredefinedGroups: [
       'intersection',
       'conditional',
       'function',
@@ -172,8 +171,9 @@ export let sortUnionOrIntersectionTypes = <MessageIds extends string>({
       'tuple',
       'union',
     ],
-    [],
-  )
+    allowedCustomGroups: [],
+    options,
+  })
   validateNewlinesAndPartitionConfiguration(options)
 
   let sourceCode = getSourceCode(context)
diff --git a/utils/validate-generated-groups-configuration.ts b/utils/validate-generated-groups-configuration.ts
index 00c1a0f..d7d349f 100644
--- a/utils/validate-generated-groups-configuration.ts
+++ b/utils/validate-generated-groups-configuration.ts
@@ -4,7 +4,8 @@ import type {
   GroupsOptions,
 } from '../types/common-options'
 
-import { validateNoDuplicatedGroups } from './validate-groups-configuration'
+import { validateNewlinesBetweenInsideGroups } from './validate-newlines-between-inside-groups'
+import { validateNoDuplicatedGroups } from './validate-no-duplicated-groups'
 
 interface ValidateGenerateGroupsConfigurationParameters {
   options: {
@@ -36,7 +37,8 @@ export let validateGeneratedGroupsConfiguration = ({
   if (invalidGroups.length > 0) {
     throw new Error(`Invalid group(s): ${invalidGroups.join(', ')}`)
   }
-  validateNoDuplicatedGroups(options.groups)
+  validateNoDuplicatedGroups(options)
+  validateNewlinesBetweenInsideGroups(options)
 }
 
 let isPredefinedGroup = (
diff --git a/utils/validate-groups-configuration.ts b/utils/validate-groups-configuration.ts
index 2ed1549..81f10be 100644
--- a/utils/validate-groups-configuration.ts
+++ b/utils/validate-groups-configuration.ts
@@ -1,79 +1,58 @@
-import type { NewlinesBetweenOption } from '../types/common-options'
+import type { GroupsOptions } from '../types/common-options'
 
+import { validateNewlinesBetweenInsideGroups } from './validate-newlines-between-inside-groups'
+import { validateNoDuplicatedGroups } from './validate-no-duplicated-groups'
 import { isNewlinesBetweenOption } from './is-newlines-between-option'
 
-type Group = { newlinesBetween: NewlinesBetweenOption } | string[] | string
+interface ValidateGroupsConfigurationParameters {
+  options: {
+    groups: GroupsOptions<string>
+  }
+  allowedPredefinedGroups: string[]
+  allowedCustomGroups: string[]
+}
 
 /**
  * Throws an error if one of the following conditions is met:
  * - One or more groups specified in `groups` are not predefined nor specified
  * in `customGroups`
  * - A group is specified in `groups` more than once
- * @param {Group[]} groups - The groups to validate.
- * @param {string[]} allowedPredefinedGroups - An array of predefined group
+ * @param {object} parameters - Parameters object.
+ * @param {object} parameters.options - Options containing the groups to validate.
+ * @param {string[]} parameters.allowedPredefinedGroups - An array of predefined
+ * group names that are considered valid.
+ * @param {string[]} parameters.allowedCustomGroups - An array of custom group
  * names that are considered valid.
- * @param {string[]} allowedCustomGroups - An array of custom group names that
- * are considered valid.
- * @throws Will throw an error if invalid or duplicated groups are found.
+ * @throws Error Will throw an error if invalid or duplicated groups are found.
  */
-export let validateGroupsConfiguration = (
-  groups: Group[],
-  allowedPredefinedGroups: string[],
-  allowedCustomGroups: string[],
-): void => {
+export let validateGroupsConfiguration = ({
+  allowedPredefinedGroups,
+  allowedCustomGroups,
+  options,
+}: ValidateGroupsConfigurationParameters): void => {
   let allowedGroupsSet = new Set([
     ...allowedPredefinedGroups,
     ...allowedCustomGroups,
   ])
   let invalidGroups: string[] = []
-  let isPreviousElementNewlinesBetween = false
-  for (let groupElement of groups) {
+
+  for (let groupElement of options.groups) {
     if (isNewlinesBetweenOption(groupElement)) {
-      // There should not be two consecutive `newlinesBetween` objects
-      if (isPreviousElementNewlinesBetween) {
-        throw new Error("Consecutive 'newlinesBetween' objects are not allowed")
-      }
-      isPreviousElementNewlinesBetween = true
-    } else {
-      isPreviousElementNewlinesBetween = false
-      let groupElements = Array.isArray(groupElement)
-        ? groupElement
-        : [groupElement]
-      for (let group of groupElements) {
-        if (!allowedGroupsSet.has(group)) {
-          invalidGroups.push(group)
-        }
+      continue
+    }
+    let groupElements = Array.isArray(groupElement)
+      ? groupElement
+      : [groupElement]
+    for (let group of groupElements) {
+      if (!allowedGroupsSet.has(group)) {
+        invalidGroups.push(group)
       }
     }
   }
   if (invalidGroups.length > 0) {
     throw new Error(`Invalid group(s): ${invalidGroups.join(', ')}`)
   }
-  validateNoDuplicatedGroups(groups)
-}
-
-/**
- * Throws an error if a group is specified more than once
- * @param {Group[]} groups - The groups to check for duplicates.
- * @throws Will throw an error if duplicated groups are found.
- */
-export let validateNoDuplicatedGroups = (groups: Group[]): void => {
-  let flattenGroups = groups.flat()
-  let seenGroups = new Set<string>()
-  let duplicatedGroups = new Set<string>()
-
-  for (let group of flattenGroups) {
-    if (isNewlinesBetweenOption(group)) {
-      continue
-    }
-    if (seenGroups.has(group)) {
-      duplicatedGroups.add(group)
-    } else {
-      seenGroups.add(group)
-    }
-  }
 
-  if (duplicatedGroups.size > 0) {
-    throw new Error(`Duplicated group(s): ${[...duplicatedGroups].join(', ')}`)
-  }
+  validateNoDuplicatedGroups(options)
+  validateNewlinesBetweenInsideGroups(options)
 }
diff --git a/utils/validate-newlines-between-inside-groups.ts b/utils/validate-newlines-between-inside-groups.ts
new file mode 100644
index 0000000..81b9f3a
--- /dev/null
+++ b/utils/validate-newlines-between-inside-groups.ts
@@ -0,0 +1,22 @@
+import type { GroupsOptions } from '../types/common-options'
+
+import { isNewlinesBetweenOption } from './is-newlines-between-option'
+
+export let validateNewlinesBetweenInsideGroups = ({
+  groups,
+}: {
+  groups: GroupsOptions<string>
+}): void => {
+  let isPreviousElementNewlinesBetween = false
+  for (let groupElement of groups) {
+    if (!isNewlinesBetweenOption(groupElement)) {
+      isPreviousElementNewlinesBetween = false
+      continue
+    }
+    // There should not be two consecutive `newlinesBetween` objects
+    if (isPreviousElementNewlinesBetween) {
+      throw new Error("Consecutive 'newlinesBetween' objects are not allowed")
+    }
+    isPreviousElementNewlinesBetween = true
+  }
+}
diff --git a/utils/validate-no-duplicated-groups.ts b/utils/validate-no-duplicated-groups.ts
new file mode 100644
index 0000000..3a94fd3
--- /dev/null
+++ b/utils/validate-no-duplicated-groups.ts
@@ -0,0 +1,34 @@
+import type { GroupsOptions } from '../types/common-options'
+
+import { isNewlinesBetweenOption } from './is-newlines-between-option'
+
+/**
+ * Throws an error if a group is specified more than once
+ * @param {object} parameters - Parameters object.
+ * @param {GroupsOptions} parameters.groups - The groups to check for duplicates.
+ * @throws Error Will throw an error if duplicated groups are found.
+ */
+export let validateNoDuplicatedGroups = ({
+  groups,
+}: {
+  groups: GroupsOptions<string>
+}): void => {
+  let flattenGroups = groups.flat()
+  let seenGroups = new Set<string>()
+  let duplicatedGroups = new Set<string>()
+
+  for (let group of flattenGroups) {
+    if (isNewlinesBetweenOption(group)) {
+      continue
+    }
+    if (seenGroups.has(group)) {
+      duplicatedGroups.add(group)
+    } else {
+      seenGroups.add(group)
+    }
+  }
+
+  if (duplicatedGroups.size > 0) {
+    throw new Error(`Duplicated group(s): ${[...duplicatedGroups].join(', ')}`)
+  }
+}


Test patch:
diff --git a/test/utils/validate-generated-groups-configuration.test.ts b/test/utils/validate-generated-groups-configuration.test.ts
index 4c75277..40d4d86 100644
--- a/test/utils/validate-generated-groups-configuration.test.ts
+++ b/test/utils/validate-generated-groups-configuration.test.ts
@@ -87,6 +87,22 @@ describe('validate-generated-groups-configuration', () => {
       }),
     ).toThrow('Invalid group(s): myCustomGroup')
   })
+
+  it('throws an error with consecutive newlines objects', () => {
+    expect(() => {
+      validateGeneratedGroupsConfiguration({
+        options: {
+          groups: [
+            { newlinesBetween: 'always' },
+            { newlinesBetween: 'always' },
+          ],
+          customGroups: [],
+        },
+        selectors: [],
+        modifiers: [],
+      })
+    }).toThrow("Consecutive 'newlinesBetween' objects are not allowed")
+  })
 })
 
 let getAllNonEmptyCombinations = (array: string[]): string[][] => {
diff --git a/test/utils/validate-groups-configuration.test.ts b/test/utils/validate-groups-configuration.test.ts
index 50ffa47..dfa1808 100644
--- a/test/utils/validate-groups-configuration.test.ts
+++ b/test/utils/validate-groups-configuration.test.ts
@@ -14,36 +14,46 @@ import { validateGroupsConfiguration } from '../../utils/validate-groups-configu
 describe('validate-groups-configuration', () => {
   it('throws an error when an invalid group is provided', () => {
     expect(() => {
-      validateGroupsConfiguration(
-        ['predefinedGroup', ['customGroup', 'invalidGroup1'], 'invalidGroup2'],
-        ['predefinedGroup'],
-        ['customGroup'],
-      )
+      validateGroupsConfiguration({
+        options: {
+          groups: [
+            'predefinedGroup',
+            ['customGroup', 'invalidGroup1'],
+            'invalidGroup2',
+          ],
+        },
+        allowedPredefinedGroups: ['predefinedGroup'],
+        allowedCustomGroups: ['customGroup'],
+      })
     }).toThrow('Invalid group(s): invalidGroup1, invalidGroup2')
   })
 
   it('throws an error when a duplicate group is provided', () => {
     expect(() => {
-      validateGroupsConfiguration(
-        ['predefinedGroup', 'predefinedGroup'],
-        ['predefinedGroup'],
-        [],
-      )
+      validateGroupsConfiguration({
+        options: {
+          groups: ['predefinedGroup', 'predefinedGroup'],
+        },
+        allowedPredefinedGroups: ['predefinedGroup'],
+        allowedCustomGroups: [],
+      })
     }).toThrow('Duplicated group(s): predefinedGroup')
   })
 
   it('throws an error with consecutive newlines objects', () => {
     expect(() => {
-      validateGroupsConfiguration(
-        [
-          'a',
-          { newlinesBetween: 'always' },
-          { newlinesBetween: 'always' },
-          'b',
-        ],
-        ['a', 'b'],
-        [],
-      )
+      validateGroupsConfiguration({
+        options: {
+          groups: [
+            'a',
+            { newlinesBetween: 'always' },
+            { newlinesBetween: 'always' },
+            'b',
+          ],
+        },
+        allowedPredefinedGroups: ['a', 'b'],
+        allowedCustomGroups: [],
+      })
     }).toThrow("Consecutive 'newlinesBetween' objects are not allowed")
   })
 })
diff --git a/test/utils/validate-newlines-between-inside-groups.test.ts b/test/utils/validate-newlines-between-inside-groups.test.ts
new file mode 100644
index 0000000..ee56515
--- /dev/null
+++ b/test/utils/validate-newlines-between-inside-groups.test.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it } from 'vitest'
+
+import { validateNewlinesBetweenInsideGroups } from '../../utils/validate-newlines-between-inside-groups'
+
+describe('validate-newlines-between-inside-groups', () => {
+  it('throws an error with consecutive newlines objects', () => {
+    expect(() => {
+      validateNewlinesBetweenInsideGroups({
+        groups: [{ newlinesBetween: 'always' }, { newlinesBetween: 'always' }],
+      })
+    }).toThrow("Consecutive 'newlinesBetween' objects are not allowed")
+  })
+})