Repository: azat-io/eslint-plugin-perfectionist
Pull request #428 (2024-12-21 12:18:21)

feat: adds `customGroup.newlinesInside ` option
First PR aiming to partially resolve the issue https://github.com/azat-io/eslint-plugin-perfectionist/issues/401.
- ⚙️ **PR 1 (this)**: Add `customGroup.newlinesInside` option. We will take the opportunity to create a `getNewlinesBetweenOption` method that will help us later to precisely customize newline behavior between two specific groups. 
- ❌ **PR 2**: Add a way to choose a specific newline behavior between two groups. See the proposals [here](https://github.com/azat-io/eslint-plugin-perfectionist/issues/401#issuecomment-2558581331).

# Description

We currently have the `newlinesBetween` option, which applies the same newlines-related rules between all groups.
This PR adds a sub-option for the new array-based `customGroups`:
- `newlinesInside?: 'always' | 'never'`

Here is the list of rules with this new addition:
- `sort-classes`
- `sort-modules`
- `sort-object-types`
- `sort-interfaces`

# New option

## `customGroup.newlinesInside?: 'ignore' | 'always' | 'never'`

Newlines rule that should be applied between two consecutive nodes of the **same** custom group.

Example: Enforce a newline between all class methods.
```ts
{
  customGroups: [
    {
      groupName: "methodsWithNewlinesBetween",
      selector: "method",
      newlinesInside: "always"
    }
  ]
}
```

# Other changes

- Unifies the way spacing errors are reported:
  - The `leftNum < rightNum` check is currently only made if `numberOfEmptyLinesBetween === 0` (as confirmed by passing tests).
  - With `newlinesBetween: always` and if `numberOfEmptyLinesBetween > 1`, we were not checking if `leftNum < rightNum`.
  - The `leftNum < rightNum` check is not made when `newlinesBetween: never` either (as confirmed by passing tests).
  - => Always check if `leftNum < rightNum`.
  - The few tests impacted can be found in those commits: 04132ee4bd1bbbebf2b0c27f802cf77e8aa829ea, 252873c1a821ce92ae7307396d499f6b8df29452.
    - Only some spacing errors reported were removed, outputs are unchanged.

### What is the purpose of this pull request?

- [x] New Feature

hugop95: @OlivierZal 👋 Feel free to review this if you want 🙂

Patch:
diff --git a/docs/content/rules/sort-classes.mdx b/docs/content/rules/sort-classes.mdx
index c423ce3..793bf2d 100644
--- a/docs/content/rules/sort-classes.mdx
+++ b/docs/content/rules/sort-classes.mdx
@@ -599,6 +599,7 @@ interface CustomGroupDefinition {
   groupName: string
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
+  newlinesInside?: 'always' | 'never'
   selector?: string
   modifiers?: string[]
   elementNamePattern?: string
@@ -615,6 +616,7 @@ interface CustomGroupAnyOfDefinition {
   groupName: string
   type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted'
   order?: 'asc' | 'desc'
+  newlinesInside?: 'always' | 'never'
   anyOf: Array<{
       selector?: string
       modifiers?: string[]
@@ -637,6 +639,7 @@ A class member will match a `CustomGroupAnyOfDefinition` group if it matches all
 - `decoratorNamePattern`: If entered, will check that at least one `decorator` 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
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/docs/content/rules/sort-interfaces.mdx b/docs/content/rules/sort-interfaces.mdx
index 1106b6d..63dde50 100644
--- a/docs/content/rules/sort-interfaces.mdx
+++ b/docs/content/rules/sort-interfaces.mdx
@@ -509,6 +509,7 @@ An interface member will match a `CustomGroupAnyOfDefinition` group if it matche
 - `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
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/docs/content/rules/sort-modules.mdx b/docs/content/rules/sort-modules.mdx
index e3d0a10..0b28d3a 100644
--- a/docs/content/rules/sort-modules.mdx
+++ b/docs/content/rules/sort-modules.mdx
@@ -435,6 +435,7 @@ A module member will match a `CustomGroupAnyOfDefinition` group if it matches al
 - `decoratorNamePattern`: If entered, will check that at least one `decorator` 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
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/docs/content/rules/sort-object-types.mdx b/docs/content/rules/sort-object-types.mdx
index b44781e..9cf3ef8 100644
--- a/docs/content/rules/sort-object-types.mdx
+++ b/docs/content/rules/sort-object-types.mdx
@@ -474,6 +474,7 @@ An object type will match a `CustomGroupAnyOfDefinition` group if it matches all
 - `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
+- `newlinesInside`: Enforces a specific newline behavior between elements of the group.
 
 #### Match importance
 
diff --git a/rules/sort-classes/types.ts b/rules/sort-classes/types.ts
index 624e07c..f468dee 100644
--- a/rules/sort-classes/types.ts
+++ b/rules/sort-classes/types.ts
@@ -36,6 +36,19 @@ export type SingleCustomGroup =
   | BaseSingleCustomGroup<ConstructorSelector>
   | AdvancedSingleCustomGroup<MethodSelector>
 
+export type CustomGroup = (
+  | {
+      order?: SortClassesOptions[0]['order']
+      type?: SortClassesOptions[0]['type']
+    }
+  | {
+      type?: 'unsorted'
+    }
+) & {
+  newlinesInside?: 'always' | 'never'
+  groupName: string
+} & (SingleCustomGroup | AnyOfCustomGroup)
+
 export type NonDeclarePropertyGroup = JoinWithDash<
   [
     PublicOrProtectedOrPrivateModifier,
@@ -202,19 +215,6 @@ type Group =
   | 'unknown'
   | string
 
-type CustomGroup = (
-  | {
-      order?: SortClassesOptions[0]['order']
-      type?: SortClassesOptions[0]['type']
-    }
-  | {
-      type?: 'unsorted'
-    }
-) &
-  (SingleCustomGroup | AnyOfCustomGroup) & {
-    groupName: string
-  }
-
 type AdvancedSingleCustomGroup<T extends Selector> = {
   decoratorNamePattern?: string
   elementValuePattern?: string
diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts
index c39296c..68b6024 100644
--- a/rules/sort-imports.ts
+++ b/rules/sort-imports.ts
@@ -537,12 +537,15 @@ export default createEslintRule<Options<string[]>, MESSAGE_ID>({
             messageIds = [
               ...messageIds,
               ...getNewlinesErrors({
+                options: {
+                  ...options,
+                  customGroups: [],
+                },
                 missedSpacingError: 'missedSpacingBetweenImports',
                 extraSpacingError: 'extraSpacingBetweenImports',
                 rightNum: rightNumber,
                 leftNum: leftNumber,
                 sourceCode,
-                options,
                 right,
                 left,
               }),
@@ -559,10 +562,13 @@ export default createEslintRule<Options<string[]>, MESSAGE_ID>({
                     fixer,
                   }),
                   ...makeNewlinesFixes({
+                    options: {
+                      ...options,
+                      customGroups: [],
+                    },
                     sortedNodes: sortedNodesExcludingEslintDisabled,
                     nodes: nodeList,
                     sourceCode,
-                    options,
                     fixer,
                   }),
                 ],
diff --git a/rules/sort-modules/types.ts b/rules/sort-modules/types.ts
index d4872fc..cc987b4 100644
--- a/rules/sort-modules/types.ts
+++ b/rules/sort-modules/types.ts
@@ -75,10 +75,10 @@ type CustomGroup = (
   | {
       type?: 'unsorted'
     }
-) &
-  (SingleCustomGroup | AnyOfCustomGroup) & {
-    groupName: string
-  }
+) & {
+  newlinesInside?: 'always' | 'never'
+  groupName: string
+} & (SingleCustomGroup | AnyOfCustomGroup)
 /**
  * Only used in code, so I don't know if it's worth maintaining this.
  */
diff --git a/rules/sort-object-types/types.ts b/rules/sort-object-types/types.ts
index 3d0b2c6..fb9a40f 100644
--- a/rules/sort-object-types/types.ts
+++ b/rules/sort-object-types/types.ts
@@ -75,10 +75,10 @@ type CustomGroup = (
   | {
       type?: 'unsorted'
     }
-) &
-  (SingleCustomGroup | AnyOfCustomGroup) & {
-    groupName: string
-  }
+) & {
+  newlinesInside?: 'always' | 'never'
+  groupName: string
+} & (SingleCustomGroup | AnyOfCustomGroup)
 
 type IndexSignatureGroup = JoinWithDash<
   [
diff --git a/utils/common-json-schemas.ts b/utils/common-json-schemas.ts
index e2b3e50..9495694 100644
--- a/utils/common-json-schemas.ts
+++ b/utils/common-json-schemas.ts
@@ -113,8 +113,7 @@ export let partitionByNewLineJsonSchema: JSONSchema4 = {
 }
 
 export let newlinesBetweenJsonSchema: JSONSchema4 = {
-  description:
-    'Specifies how new lines should be handled between modules members groups.',
+  description: 'Specifies how new lines should be handled between groups.',
   enum: ['ignore', 'always', 'never'],
   type: 'string',
 }
@@ -154,6 +153,15 @@ let customGroupNameJsonSchema: Record<string, JSONSchema4> = {
   },
 }
 
+let customGroupNewlinesInsideJsonSchema: Record<string, JSONSchema4> = {
+  newlinesInside: {
+    description:
+      'Specifies how new lines should be handled between members of the custom group.',
+    enum: ['always', 'never'],
+    type: 'string',
+  },
+}
+
 export let buildCustomGroupsArrayJsonSchema = ({
   singleCustomGroupJsonSchema,
 }: {
@@ -165,6 +173,7 @@ export let buildCustomGroupsArrayJsonSchema = ({
         properties: {
           ...customGroupNameJsonSchema,
           ...customGroupSortJsonSchema,
+          ...customGroupNewlinesInsideJsonSchema,
           anyOf: {
             items: {
               properties: {
@@ -185,6 +194,7 @@ export let buildCustomGroupsArrayJsonSchema = ({
         properties: {
           ...customGroupNameJsonSchema,
           ...customGroupSortJsonSchema,
+          ...customGroupNewlinesInsideJsonSchema,
           ...singleCustomGroupJsonSchema,
         },
         description: 'Custom group.',
diff --git a/utils/get-newlines-between-option.ts b/utils/get-newlines-between-option.ts
new file mode 100644
index 0000000..4aefb81
--- /dev/null
+++ b/utils/get-newlines-between-option.ts
@@ -0,0 +1,93 @@
+import type { SortingNode } from '../types/sorting-node'
+
+import { getGroupNumber } from './get-group-number'
+
+export interface GetNewlinesBetweenOptionParameters {
+  nextSortingNode: SortingNode
+  sortingNode: SortingNode
+  options: Options
+}
+
+interface Options {
+  customGroups?: Record<string, string[] | string> | CustomGroup[]
+  newlinesBetween: 'ignore' | 'always' | 'never'
+  groups: (string[] | string)[]
+}
+
+interface CustomGroup {
+  newlinesInside?: 'always' | 'never'
+  groupName: string
+}
+
+/**
+ * Get the `newlinesBetween` option to use between two consecutive nodes.
+ * The result is based on the global `newlinesBetween` option and the custom
+ * groups, which can override the global option.
+ * - If the two nodes are in the same custom group, the `newlinesInside` option
+ * of the group is used.
+ * @param {GetNewlinesBetweenOptionParameters} props - The function arguments
+ * @param {SortingNode} props.nextSortingNode - The next node to sort
+ * @param {SortingNode} props.sortingNode - The current node to sort
+ * @param {Options} props.options - Newlines between related options
+ * @returns {'ignore' | 'always' | 'never'} - The `newlinesBetween` option to
+ * use
+ */
+export let getNewlinesBetweenOption = ({
+  nextSortingNode,
+  sortingNode,
+  options,
+}: GetNewlinesBetweenOptionParameters): 'ignore' | 'always' | 'never' => {
+  let nodeGroupNumber = getGroupNumber(options.groups, sortingNode)
+  let nextNodeGroupNumber = getGroupNumber(options.groups, nextSortingNode)
+  let globalNewlinesBetweenOption = getGlobalNewlinesBetweenOption({
+    newlinesBetween: options.newlinesBetween,
+    nextNodeGroupNumber,
+    nodeGroupNumber,
+  })
+
+  if (!options.customGroups || !Array.isArray(options.customGroups)) {
+    return globalNewlinesBetweenOption
+  }
+
+  let nodeGroup = options.groups[nodeGroupNumber]
+  let nextNodeGroup = options.groups[nextNodeGroupNumber]
+
+  if (Array.isArray(nodeGroup) || Array.isArray(nextNodeGroup)) {
+    return globalNewlinesBetweenOption
+  }
+
+  let nodeCustomGroup = options.customGroups.find(
+    customGroup => customGroup.groupName === nodeGroup,
+  )
+  let nextNodeCustomGroup = options.customGroups.find(
+    customGroup => customGroup.groupName === nextNodeGroup,
+  )
+
+  if (
+    nodeCustomGroup &&
+    nextNodeCustomGroup &&
+    nodeCustomGroup.groupName === nextNodeCustomGroup.groupName
+  ) {
+    return nodeCustomGroup.newlinesInside ?? globalNewlinesBetweenOption
+  }
+
+  return globalNewlinesBetweenOption
+}
+
+let getGlobalNewlinesBetweenOption = ({
+  nextNodeGroupNumber,
+  newlinesBetween,
+  nodeGroupNumber,
+}: {
+  newlinesBetween: 'ignore' | 'always' | 'never'
+  nextNodeGroupNumber: number
+  nodeGroupNumber: number
+}): 'always' | 'ignore' | 'never' => {
+  if (newlinesBetween === 'ignore') {
+    return 'ignore'
+  }
+  if (newlinesBetween === 'never') {
+    return 'never'
+  }
+  return nodeGroupNumber === nextNodeGroupNumber ? 'never' : 'always'
+}
diff --git a/utils/get-newlines-errors.ts b/utils/get-newlines-errors.ts
index 17bfea2..9ba0919 100644
--- a/utils/get-newlines-errors.ts
+++ b/utils/get-newlines-errors.ts
@@ -2,21 +2,27 @@ import type { TSESLint } from '@typescript-eslint/utils'
 
 import type { SortingNode } from '../types/sorting-node'
 
+import { getNewlinesBetweenOption } from './get-newlines-between-option'
 import { getLinesBetween } from './get-lines-between'
 
-interface Props<T extends string> {
+interface GetNewlinesErrorsParameters<T extends string> {
+  options: {
+    customGroups?: Record<string, string[] | string> | CustomGroup[]
+    newlinesBetween: 'ignore' | 'always' | 'never'
+    groups: (string[] | string)[]
+  }
   sourceCode: TSESLint.SourceCode
   missedSpacingError: T
   extraSpacingError: T
   right: SortingNode
   left: SortingNode
   rightNum: number
-  options: Options
   leftNum: number
 }
 
-interface Options {
-  newlinesBetween: 'ignore' | 'always' | 'never'
+interface CustomGroup {
+  newlinesInside?: 'always' | 'never'
+  groupName: string
 }
 
 export let getNewlinesErrors = <T extends string>({
@@ -28,24 +34,27 @@ export let getNewlinesErrors = <T extends string>({
   options,
   right,
   left,
-}: Props<T>): T[] => {
-  let errors: T[] = []
-
-  let numberOfEmptyLinesBetween = getLinesBetween(sourceCode, left, right)
-  if (options.newlinesBetween === 'never' && numberOfEmptyLinesBetween > 0) {
-    errors.push(extraSpacingError)
+}: GetNewlinesErrorsParameters<T>): T[] => {
+  let newlinesBetween = getNewlinesBetweenOption({
+    nextSortingNode: right,
+    sortingNode: left,
+    options,
+  })
+  if (leftNum > rightNum) {
+    return []
   }
-
-  if (options.newlinesBetween === 'always') {
-    if (leftNum < rightNum && numberOfEmptyLinesBetween === 0) {
-      errors.push(missedSpacingError)
-    } else if (
-      numberOfEmptyLinesBetween > 1 ||
-      (leftNum === rightNum && numberOfEmptyLinesBetween > 0)
-    ) {
-      errors.push(extraSpacingError)
-    }
+  let numberOfEmptyLinesBetween = getLinesBetween(sourceCode, left, right)
+  switch (newlinesBetween) {
+    case 'ignore':
+      return []
+    case 'never':
+      return numberOfEmptyLinesBetween > 0 ? [extraSpacingError] : []
+    case 'always':
+      if (numberOfEmptyLinesBetween === 0) {
+        return [missedSpacingError]
+      } else if (numberOfEmptyLinesBetween > 1) {
+        return [extraSpacingError]
+      }
   }
-
-  return errors
+  return []
 }
diff --git a/utils/make-newlines-fixes.ts b/utils/make-newlines-fixes.ts
index 2a7f08f..cca9f99 100644
--- a/utils/make-newlines-fixes.ts
+++ b/utils/make-newlines-fixes.ts
@@ -2,19 +2,27 @@ import type { TSESLint } from '@typescript-eslint/utils'
 
 import type { SortingNode } from '../types/sorting-node'
 
+import { getNewlinesBetweenOption } from './get-newlines-between-option'
 import { getLinesBetween } from './get-lines-between'
-import { getGroupNumber } from './get-group-number'
 import { getNodeRange } from './get-node-range'
 
 interface MakeNewlinesFixesParameters {
-  options: {
-    newlinesBetween: 'ignore' | 'always' | 'never'
-    groups: (string[] | string)[]
-  }
   sourceCode: TSESLint.SourceCode
   sortedNodes: SortingNode[]
   fixer: TSESLint.RuleFixer
   nodes: SortingNode[]
+  options: Options
+}
+
+interface Options {
+  customGroups?: Record<string, string[] | string> | CustomGroup[]
+  newlinesBetween: 'ignore' | 'always' | 'never'
+  groups: (string[] | string)[]
+}
+
+interface CustomGroup {
+  newlinesInside?: 'always' | 'never'
+  groupName: string
 }
 
 export let makeNewlinesFixes = ({
@@ -26,22 +34,28 @@ export let makeNewlinesFixes = ({
 }: MakeNewlinesFixesParameters): TSESLint.RuleFix[] => {
   let fixes: TSESLint.RuleFix[] = []
 
-  for (let max = sortedNodes.length, i = 0; i < max; i++) {
-    let sortingNode = sortedNodes.at(i)!
-    let nextSortingNode = sortedNodes.at(i + 1)
+  for (let i = 0; i < sortedNodes.length - 1; i++) {
+    let sortingNode = nodes.at(i)!
+    let nextSortingNode = nodes.at(i + 1)!
+    let sortedSortingNode = sortedNodes.at(i)!
+    let nextSortedSortingNode = sortedNodes.at(i + 1)!
+
+    let newlinesBetween = getNewlinesBetweenOption({
+      nextSortingNode: nextSortedSortingNode,
+      sortingNode: sortedSortingNode,
+      options,
+    })
 
-    if (options.newlinesBetween === 'ignore' || !nextSortingNode) {
+    if (newlinesBetween === 'ignore') {
       continue
     }
 
-    let nodeGroupNumber = getGroupNumber(options.groups, sortingNode)
-    let nextNodeGroupNumber = getGroupNumber(options.groups, nextSortingNode)
     let currentNodeRange = getNodeRange({
-      node: nodes.at(i)!.node,
+      node: sortingNode.node,
       sourceCode,
     })
     let nextNodeRangeStart = getNodeRange({
-      node: nodes.at(i + 1)!.node,
+      node: nextSortingNode.node,
       sourceCode,
     }).at(0)!
     let rangeToReplace: [number, number] = [
@@ -55,25 +69,16 @@ export let makeNewlinesFixes = ({
 
     let linesBetweenMembers = getLinesBetween(
       sourceCode,
-      nodes.at(i)!,
-      nodes.at(i + 1)!,
+      sortingNode,
+      nextSortingNode,
     )
 
     let rangeReplacement: undefined | string
-    if (
-      (options.newlinesBetween === 'always' &&
-        nodeGroupNumber === nextNodeGroupNumber &&
-        linesBetweenMembers !== 0) ||
-      (options.newlinesBetween === 'never' && linesBetweenMembers > 0)
-    ) {
+    if (newlinesBetween === 'never' && linesBetweenMembers !== 0) {
       rangeReplacement = getStringWithoutInvalidNewlines(textBetweenNodes)
     }
 
-    if (
-      options.newlinesBetween === 'always' &&
-      nodeGroupNumber !== nextNodeGroupNumber &&
-      linesBetweenMembers !== 1
-    ) {
+    if (newlinesBetween === 'always' && linesBetweenMembers !== 1) {
       rangeReplacement = addNewlineBeforeFirstNewline(
         linesBetweenMembers > 1
           ? getStringWithoutInvalidNewlines(textBetweenNodes)
@@ -81,7 +86,7 @@ export let makeNewlinesFixes = ({
       )
       let isOnSameLine =
         linesBetweenMembers === 0 &&
-        nodes.at(i)!.node.loc.end.line === nodes.at(i + 1)!.node.loc.start.line
+        sortingNode.node.loc.end.line === nextSortingNode.node.loc.start.line
       if (isOnSameLine) {
         rangeReplacement = addNewlineBeforeFirstNewline(rangeReplacement)
       }


Test patch:
diff --git a/test/rules/sort-classes.test.ts b/test/rules/sort-classes.test.ts
index 2b97c94..65a04f5 100644
--- a/test/rules/sort-classes.test.ts
+++ b/test/rules/sort-classes.test.ts
@@ -7787,6 +7787,114 @@ describe(ruleName, () => {
         invalid: [],
       },
     )
+
+    describe('newlinesInside', () => {
+      ruleTester.run(
+        `${ruleName}: allows to use newlinesInside: always`,
+        rule,
+        {
+          invalid: [
+            {
+              errors: [
+                {
+                  data: {
+                    right: 'c',
+                    left: 'b',
+                  },
+                  messageId: 'missedSpacingBetweenClassMembers',
+                },
+                {
+                  data: {
+                    right: 'd',
+                    left: 'c',
+                  },
+                  messageId: 'extraSpacingBetweenClassMembers',
+                },
+              ],
+              options: [
+                {
+                  customGroups: [
+                    {
+                      groupName: 'methodsWithNewlinesInside',
+                      newlinesInside: 'always',
+                      selector: 'method',
+                    },
+                  ],
+                  groups: ['unknown', 'methodsWithNewlinesInside'],
+                },
+              ],
+              code: dedent`
+                class Class {
+                  a
+                  b() {}
+                  c() {}
+
+
+                  d() {}
+                }
+              `,
+              output: dedent`
+                class Class {
+                  a
+                  b() {}
+
+                  c() {}
+
+                  d() {}
+                }
+            `,
+            },
+          ],
+          valid: [],
+        },
+      )
+
+      ruleTester.run(`${ruleName}: allows to use newlinesInside: never`, rule, {
+        invalid: [
+          {
+            options: [
+              {
+                customGroups: [
+                  {
+                    groupName: 'methodsWithoutNewlinesInside',
+                    newlinesInside: 'never',
+                    selector: 'method',
+                  },
+                ],
+                groups: ['unknown', 'methodsWithoutNewlinesInside'],
+              },
+            ],
+            errors: [
+              {
+                data: {
+                  right: 'd',
+                  left: 'c',
+                },
+                messageId: 'extraSpacingBetweenClassMembers',
+              },
+            ],
+            output: dedent`
+              class Class {
+                a
+
+                c() {}
+                d() {}
+              }
+            `,
+            code: dedent`
+              class Class {
+                a
+
+                c() {}
+
+                d() {}
+              }
+            `,
+          },
+        ],
+        valid: [],
+      })
+    })
   })
 
   describe(`${ruleName}: misc`, () => {
diff --git a/test/rules/sort-imports.test.ts b/test/rules/sort-imports.test.ts
index e24640f..497e54b 100644
--- a/test/rules/sort-imports.test.ts
+++ b/test/rules/sort-imports.test.ts
@@ -290,13 +290,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: '~/c',
-                right: 't',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
             {
               data: {
                 rightGroup: 'internal',
@@ -306,13 +299,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: '../../e',
-                right: '~/b',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
           ],
           options: [
             {
@@ -899,13 +885,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: './b',
-                right: 'c',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
           ],
           options: [
             {
@@ -2630,13 +2609,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: '~/c',
-                right: 't',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
             {
               data: {
                 rightGroup: 'internal',
@@ -2646,13 +2618,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: '../../e',
-                right: '~/b',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
           ],
           options: [
             {
@@ -3239,13 +3204,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: './b',
-                right: 'c',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
           ],
           options: [
             {
@@ -4251,13 +4209,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: '~/c',
-                right: 't',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
             {
               data: {
                 rightGroup: 'internal',
@@ -4267,13 +4218,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: '../../e',
-                right: '~/b',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
           ],
           options: [
             {
@@ -4830,13 +4774,6 @@ describe(ruleName, () => {
               },
               messageId: 'unexpectedImportsGroupOrder',
             },
-            {
-              data: {
-                left: './b',
-                right: 'c',
-              },
-              messageId: 'extraSpacingBetweenImports',
-            },
           ],
           options: [
             {
diff --git a/test/rules/sort-interfaces.test.ts b/test/rules/sort-interfaces.test.ts
index a983f47..defbedf 100644
--- a/test/rules/sort-interfaces.test.ts
+++ b/test/rules/sort-interfaces.test.ts
@@ -1345,6 +1345,101 @@ describe(ruleName, () => {
           invalid: [],
         },
       )
+
+      describe('newlinesInside', () => {
+        ruleTester.run(
+          `${ruleName}: allows to use newlinesInside: always`,
+          rule,
+          {
+            invalid: [
+              {
+                options: [
+                  {
+                    customGroups: [
+                      {
+                        newlinesInside: 'always',
+                        selector: 'property',
+                        groupName: 'group1',
+                      },
+                    ],
+                    groups: ['group1'],
+                  },
+                ],
+                errors: [
+                  {
+                    data: {
+                      right: 'b',
+                      left: 'a',
+                    },
+                    messageId: 'missedSpacingBetweenInterfaceMembers',
+                  },
+                ],
+                output: dedent`
+                  interface Interface {
+                    a
+
+                    b
+                  }
+                `,
+                code: dedent`
+                  interface Interface {
+                    a
+                    b
+                  }
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+
+        ruleTester.run(
+          `${ruleName}: allows to use newlinesInside: never`,
+          rule,
+          {
+            invalid: [
+              {
+                options: [
+                  {
+                    customGroups: [
+                      {
+                        newlinesInside: 'never',
+                        selector: 'property',
+                        groupName: 'group1',
+                      },
+                    ],
+                    type: 'alphabetical',
+                    groups: ['group1'],
+                  },
+                ],
+                errors: [
+                  {
+                    data: {
+                      right: 'b',
+                      left: 'a',
+                    },
+                    messageId: 'extraSpacingBetweenInterfaceMembers',
+                  },
+                ],
+                output: dedent`
+                  interface Interface {
+                    a
+                    b
+                  }
+                `,
+                code: dedent`
+                  interface Interface {
+                    a
+
+                    b
+                  }
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+      })
     })
 
     ruleTester.run(
diff --git a/test/rules/sort-modules.test.ts b/test/rules/sort-modules.test.ts
index ef2698f..8b00b0e 100644
--- a/test/rules/sort-modules.test.ts
+++ b/test/rules/sort-modules.test.ts
@@ -801,6 +801,93 @@ describe(ruleName, () => {
           invalid: [],
         },
       )
+
+      describe('newlinesInside', () => {
+        ruleTester.run(
+          `${ruleName}: allows to use newlinesInside: always`,
+          rule,
+          {
+            invalid: [
+              {
+                options: [
+                  {
+                    customGroups: [
+                      {
+                        newlinesInside: 'always',
+                        groupName: 'group1',
+                        selector: 'type',
+                      },
+                    ],
+                    groups: ['group1'],
+                  },
+                ],
+                errors: [
+                  {
+                    data: {
+                      right: 'B',
+                      left: 'A',
+                    },
+                    messageId: 'missedSpacingBetweenModulesMembers',
+                  },
+                ],
+                output: dedent`
+                  type A = {}
+
+                  type B = {}
+              `,
+                code: dedent`
+                  type A = {}
+                  type B = {}
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+
+        ruleTester.run(
+          `${ruleName}: allows to use newlinesInside: never`,
+          rule,
+          {
+            invalid: [
+              {
+                options: [
+                  {
+                    customGroups: [
+                      {
+                        newlinesInside: 'never',
+                        groupName: 'group1',
+                        selector: 'type',
+                      },
+                    ],
+                    type: 'alphabetical',
+                    groups: ['group1'],
+                  },
+                ],
+                errors: [
+                  {
+                    data: {
+                      right: 'B',
+                      left: 'A',
+                    },
+                    messageId: 'extraSpacingBetweenModulesMembers',
+                  },
+                ],
+                output: dedent`
+                  type A = {}
+                  type B = {}
+                `,
+                code: dedent`
+                  type A = {}
+
+                  type B = {}
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+      })
     })
 
     describe(`${ruleName}(${type}): interface modifiers priority`, () => {
@@ -1918,13 +2005,6 @@ describe(ruleName, () => {
                   },
                   messageId: 'unexpectedModulesGroupOrder',
                 },
-                {
-                  data: {
-                    right: 'y',
-                    left: 'A',
-                  },
-                  messageId: 'extraSpacingBetweenModulesMembers',
-                },
                 {
                   data: {
                     right: 'b',
diff --git a/test/rules/sort-object-types.test.ts b/test/rules/sort-object-types.test.ts
index d05124c..346cf4f 100644
--- a/test/rules/sort-object-types.test.ts
+++ b/test/rules/sort-object-types.test.ts
@@ -1086,6 +1086,101 @@ describe(ruleName, () => {
           invalid: [],
         },
       )
+
+      describe('newlinesInside', () => {
+        ruleTester.run(
+          `${ruleName}: allows to use newlinesInside: always`,
+          rule,
+          {
+            invalid: [
+              {
+                options: [
+                  {
+                    customGroups: [
+                      {
+                        newlinesInside: 'always',
+                        selector: 'property',
+                        groupName: 'group1',
+                      },
+                    ],
+                    groups: ['group1'],
+                  },
+                ],
+                errors: [
+                  {
+                    data: {
+                      right: 'b',
+                      left: 'a',
+                    },
+                    messageId: 'missedSpacingBetweenObjectTypeMembers',
+                  },
+                ],
+                output: dedent`
+                  type type = {
+                    a
+
+                    b
+                  }
+                `,
+                code: dedent`
+                  type type = {
+                    a
+                    b
+                  }
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+
+        ruleTester.run(
+          `${ruleName}: allows to use newlinesInside: never`,
+          rule,
+          {
+            invalid: [
+              {
+                options: [
+                  {
+                    customGroups: [
+                      {
+                        newlinesInside: 'never',
+                        selector: 'property',
+                        groupName: 'group1',
+                      },
+                    ],
+                    type: 'alphabetical',
+                    groups: ['group1'],
+                  },
+                ],
+                errors: [
+                  {
+                    data: {
+                      right: 'b',
+                      left: 'a',
+                    },
+                    messageId: 'extraSpacingBetweenObjectTypeMembers',
+                  },
+                ],
+                output: dedent`
+                  type type = {
+                    a
+                    b
+                  }
+                `,
+                code: dedent`
+                  type type = {
+                    a
+
+                    b
+                  }
+                `,
+              },
+            ],
+            valid: [],
+          },
+        )
+      })
     })
 
     ruleTester.run(
diff --git a/test/utils/get-newlines-between-option.test.ts b/test/utils/get-newlines-between-option.test.ts
new file mode 100644
index 0000000..46f77c4
--- /dev/null
+++ b/test/utils/get-newlines-between-option.test.ts
@@ -0,0 +1,205 @@
+import { describe, expect, it } from 'vitest'
+
+import type { GetNewlinesBetweenOptionParameters } from '../../utils/get-newlines-between-option'
+import type { SortingNode } from '../../types/sorting-node'
+
+import { getNewlinesBetweenOption } from '../../utils/get-newlines-between-option'
+
+describe('get-newlines-between-option', () => {
+  describe('global "newlinesBetween" option', () => {
+    it('should return the global option if "customGroups" is not defined', () => {
+      expect(
+        getNewlinesBetweenOption(
+          buildParameters({
+            newlinesBetween: 'ignore',
+          }),
+        ),
+      ).toBe('ignore')
+    })
+
+    it('should return the global option if "customGroups" is not an array', () => {
+      expect(
+        getNewlinesBetweenOption(
+          buildParameters({
+            newlinesBetween: 'ignore',
+            customGroups: {},
+          }),
+        ),
+      ).toBe('ignore')
+    })
+
+    it('should return "ignore" if "newlinesBetween" is "ignore"', () => {
+      expect(
+        getNewlinesBetweenOption(
+          buildParameters({
+            newlinesBetween: 'ignore',
+          }),
+        ),
+      ).toBe('ignore')
+    })
+
+    it('should return "never" if "newlinesBetween" is "never"', () => {
+      expect(
+        getNewlinesBetweenOption(
+          buildParameters({
+            newlinesBetween: 'never',
+          }),
+        ),
+      ).toBe('never')
+    })
+
+    it('should return "always" if "newlinesBetween" is "always" and nodeGroupNumber !== nextNodeGroupNumber', () => {
+      expect(
+        getNewlinesBetweenOption({
+          options: {
+            groups: ['group1', 'group2'],
+            newlinesBetween: 'always',
+          },
+          nextSortingNode: generateSortingNodeWithGroup('group1'),
+          sortingNode: generateSortingNodeWithGroup('group2'),
+        }),
+      ).toBe('always')
+    })
+
+    it('should return "never" if "newlinesBetween" is "always" and nodeGroupNumber === nextNodeGroupNumber', () => {
+      expect(
+        getNewlinesBetweenOption({
+          options: {
+            newlinesBetween: 'always',
+            groups: ['group1'],
+          },
+          nextSortingNode: generateSortingNodeWithGroup('group1'),
+          sortingNode: generateSortingNodeWithGroup('group1'),
+        }),
+      ).toBe('never')
+    })
+
+    it("should return the global option if the node's group is within an array", () => {
+      expect(
+        getNewlinesBetweenOption(
+          buildParameters({
+            customGroups: [
+              {
+                groupName: 'group1',
+              },
+            ],
+            groups: [['group1', 'group3'], 'group2'],
+            newlinesBetween: 'never',
+          }),
+        ),
+      ).toBe('never')
+    })
+
+    it("should return the global option if the next node's group is within an array", () => {
+      expect(
+        getNewlinesBetweenOption(
+          buildParameters({
+            customGroups: [
+              {
+                groupName: 'group1',
+              },
+            ],
+            groups: ['group1', ['group2', 'group3']],
+            newlinesBetween: 'never',
+          }),
+        ),
+      ).toBe('never')
+    })
+  })
+
+  describe('custom groups "newlinesBetween" option', () => {
+    describe('when the node and next node belong to the same custom group', () => {
+      let parameters = {
+        customGroups: [
+          {
+            newlinesInside: 'always',
+            groupName: 'group1',
+          },
+        ],
+        nextSortingNodeGroup: 'group1',
+        sortingNodeGroup: 'group1',
+        newlinesBetween: 'never',
+      } as const
+
+      it('should return the "newlinesInside" option if defined', () => {
+        expect(
+          getNewlinesBetweenOption(
+            buildParameters({
+              ...parameters,
+              customGroups: [
+                {
+                  newlinesInside: 'always',
+                  groupName: 'group1',
+                },
+              ],
+            }),
+          ),
+        ).toBe('always')
+      })
+
+      it('should return the global option if the "newlinesInside" option is not defined', () => {
+        expect(
+          getNewlinesBetweenOption(
+            buildParameters({
+              ...parameters,
+              customGroups: [
+                {
+                  groupName: 'group1',
+                },
+              ],
+            }),
+          ),
+        ).toBe('never')
+      })
+    })
+
+    describe('when the node and next node do not belong to the same custom group', () => {
+      it('should return the global option', () => {
+        expect(
+          getNewlinesBetweenOption(
+            buildParameters({
+              customGroups: [
+                {
+                  groupName: 'group1',
+                },
+                {
+                  groupName: 'group2',
+                },
+              ],
+              newlinesBetween: 'never',
+            }),
+          ),
+        ).toBe('never')
+      })
+    })
+  })
+
+  let buildParameters = ({
+    nextSortingNodeGroup,
+    sortingNodeGroup,
+    newlinesBetween,
+    customGroups,
+    groups,
+  }: {
+    newlinesBetween: GetNewlinesBetweenOptionParameters['options']['newlinesBetween']
+    customGroups?: GetNewlinesBetweenOptionParameters['options']['customGroups']
+    groups?: GetNewlinesBetweenOptionParameters['options']['groups']
+    nextSortingNodeGroup?: string
+    sortingNodeGroup?: string
+  }): GetNewlinesBetweenOptionParameters => ({
+    options: {
+      groups: groups ?? ['group1', 'group2'],
+      newlinesBetween,
+      customGroups,
+    },
+    nextSortingNode: generateSortingNodeWithGroup(
+      nextSortingNodeGroup ?? 'group2',
+    ),
+    sortingNode: generateSortingNodeWithGroup(sortingNodeGroup ?? 'group1'),
+  })
+
+  let generateSortingNodeWithGroup = (group: string): SortingNode =>
+    ({
+      group,
+    }) as SortingNode
+})