From a748aaa455b42636bd46d182b311ba03f10543b0 Mon Sep 17 00:00:00 2001 From: Hugo <60015232+hugop95@users.noreply.github.com> Date: Fri, 25 Oct 2024 00:45:24 +0200 Subject: [PATCH] feat: adds newlines between option --- docs/content/rules/sort-interfaces.mdx | 14 ++ .../content/rules/sort-intersection-types.mdx | 18 ++- docs/content/rules/sort-object-types.mdx | 14 ++ docs/content/rules/sort-objects.mdx | 14 ++ docs/content/rules/sort-union-types.mdx | 18 ++- rules/sort-imports.ts | 122 ++++----------- rules/sort-interfaces.ts | 78 ++++++++-- rules/sort-intersection-types.ts | 66 ++++++++- rules/sort-object-types.ts | 66 ++++++++- rules/sort-objects.ts | 97 ++++++++---- rules/sort-union-types.ts | 70 +++++++-- test/sort-imports.test.ts | 140 ++++++++++++++++++ test/sort-interfaces.test.ts | 133 ++++++++++++++++- test/sort-intersection-types.test.ts | 121 +++++++++++++++ test/sort-object-types.test.ts | 129 ++++++++++++++++ test/sort-objects.test.ts | 131 +++++++++++++++- test/sort-union-types.test.ts | 121 +++++++++++++++ ...wlines-and-partition-configuration.test.ts | 52 +++++++ utils/get-newlines-errors.ts | 51 +++++++ utils/make-newlines-fixes.ts | 95 ++++++++++++ utils/sort-nodes-by-dependencies.ts | 10 +- ...te-newlines-and-partition-configuration.ts | 15 ++ 22 files changed, 1404 insertions(+), 171 deletions(-) create mode 100644 test/validate-newlines-and-partition-configuration.test.ts create mode 100644 utils/get-newlines-errors.ts create mode 100644 utils/make-newlines-fixes.ts create mode 100644 utils/validate-newlines-and-partition-configuration.ts diff --git a/docs/content/rules/sort-interfaces.mdx b/docs/content/rules/sort-interfaces.mdx index d066448a..fc1c7cd9 100644 --- a/docs/content/rules/sort-interfaces.mdx +++ b/docs/content/rules/sort-interfaces.mdx @@ -214,6 +214,18 @@ interface User { Each group of members (separated by empty lines) is treated independently, and the order within each group is preserved. +### newlinesBetween + +default: `'ignore'` + +Specifies how new lines should be handled between interface groups. + +- `ignore` — Do not report errors related to new lines between interface groups. +- `always` — Enforce one new line between each group, and forbid new lines inside a group. +- `never` — No new lines are allowed between interface members. + +This options is only applicable when `partitionByNewLine` is `false`. + ### groupKind default: `'mixed'` @@ -363,6 +375,7 @@ Determines the matcher used for patterns in the `partitionByComment`, `ignorePat specialCharacters: 'keep', ignorePattern: [], partitionByNewLine: false, + newlinesBetween: 'ignore', optionalityOrder: 'ignore', matcher: 'minimatch', groups: [], @@ -393,6 +406,7 @@ Determines the matcher used for patterns in the `partitionByComment`, `ignorePat specialCharacters: 'keep', ignorePattern: [], partitionByNewLine: false, + newlinesBetween: 'ignore', optionalityOrder: 'ignore', matcher: 'minimatch', groups: [], diff --git a/docs/content/rules/sort-intersection-types.mdx b/docs/content/rules/sort-intersection-types.mdx index 944a2ae2..d7a8bb1e 100644 --- a/docs/content/rules/sort-intersection-types.mdx +++ b/docs/content/rules/sort-intersection-types.mdx @@ -145,6 +145,18 @@ type Employee = Each group of intersection types (separated by empty lines) is treated independently, and the order within each group is preserved. +### newlinesBetween + +default: `'ignore'` + +Specifies how new lines should be handled between intersection type groups. + +- `ignore` — Do not report errors related to new lines between intersection type groups. +- `always` — Enforce one new line between each group, and forbid new lines inside a group. +- `never` — No new lines are allowed in intersection types. + +This options is only applicable when `partitionByNewLine` is `false`. + ### groups @@ -294,8 +306,9 @@ Determines the matcher used for patterns in the `partitionByComment` option. order: 'asc', ignoreCase: true, specialCharacters: 'keep', + partitionByComment: false, partitionByNewLine: false, - partitionByNewLine: false, + newlinesBetween: 'ignore', matcher: 'minimatch', groups: [], }, @@ -322,8 +335,9 @@ Determines the matcher used for patterns in the `partitionByComment` option. order: 'asc', ignoreCase: true, specialCharacters: 'keep', - partitionByNewLine: false, partitionByComment: false, + partitionByNewLine: false, + newlinesBetween: 'ignore', matcher: 'minimatch', groups: [], }, diff --git a/docs/content/rules/sort-object-types.mdx b/docs/content/rules/sort-object-types.mdx index ff784009..7b62a13a 100644 --- a/docs/content/rules/sort-object-types.mdx +++ b/docs/content/rules/sort-object-types.mdx @@ -171,6 +171,18 @@ type User = { Each group of members (separated by empty lines) is treated independently, and the order within each group is preserved. +### newlinesBetween + +default: `'ignore'` + +Specifies how new lines should be handled between object type groups. + +- `ignore` — Do not report errors related to new lines between object type groups. +- `always` — Enforce one new line between each group, and forbid new lines inside a group. +- `never` — No new lines are allowed in object types. + +This options is only applicable when `partitionByNewLine` is `false`. + ### groupKind default: `'mixed'` @@ -320,6 +332,7 @@ Determines the matcher used for patterns in the `partitionByComment` and `custom specialCharacters: 'keep', partitionByComment: false, partitionByNewLine: false, + newlinesBetween: 'ignore', matcher: 'minimatch', groups: [], customGroups: {}, @@ -349,6 +362,7 @@ Determines the matcher used for patterns in the `partitionByComment` and `custom specialCharacters: 'keep', partitionByComment: false, partitionByNewLine: false, + newlinesBetween: 'ignore', matcher: 'minimatch', groups: [], customGroups: {}, diff --git a/docs/content/rules/sort-objects.mdx b/docs/content/rules/sort-objects.mdx index ca042af3..94c7ac22 100644 --- a/docs/content/rules/sort-objects.mdx +++ b/docs/content/rules/sort-objects.mdx @@ -226,6 +226,18 @@ const user = { Each group of keys (separated by empty lines) is treated independently, and the order within each group is preserved. +### newlinesBetween + +default: `'ignore'` + +Specifies how new lines should be handled between object groups. + +- `ignore` — Do not report errors related to new lines between object groups. +- `always` — Enforce one new line between each group, and forbid new lines inside a group. +- `never` — No new lines are allowed in objects. + +This options is only applicable when `partitionByNewLine` is `false`. + ### styledComponents default: `true` @@ -362,6 +374,7 @@ Determines the matcher used for patterns in the `partitionByComment`, `ignorePat specialCharacters: 'keep', partitionByComment: false, partitionByNewLine: false, + newlinesBetween: 'ignore', styledComponents: true, ignorePattern: [], matcher: 'minimatch', @@ -393,6 +406,7 @@ Determines the matcher used for patterns in the `partitionByComment`, `ignorePat specialCharacters: 'keep', partitionByComment: false, partitionByNewLine: false, + newlinesBetween: 'ignore', styledComponents: true, ignorePattern: [], matcher: 'minimatch', diff --git a/docs/content/rules/sort-union-types.mdx b/docs/content/rules/sort-union-types.mdx index afa3a6df..e057dc31 100644 --- a/docs/content/rules/sort-union-types.mdx +++ b/docs/content/rules/sort-union-types.mdx @@ -165,6 +165,18 @@ type CarBrand = Each group of union types (separated by empty lines) is treated independently, and the order within each group is preserved. +### newlinesBetween + +default: `'ignore'` + +Specifies how new lines should be handled between union type groups. + +- `ignore` — Do not report errors related to new lines between union type groups. +- `always` — Enforce one new line between each group, and forbid new lines inside a group. +- `never` — No new lines are allowed in union types. + +This options is only applicable when `partitionByNewLine` is `false`. + ### groups @@ -314,8 +326,9 @@ Determines the matcher used for patterns in the `partitionByComment` option. order: 'asc', ignoreCase: true, specialCharacters: 'keep', - partitionByNewLine: false, partitionByComment: false, + partitionByNewLine: false, + newlinesBetween: 'ignore', matcher: 'minimatch', groups: [], }, @@ -342,8 +355,9 @@ Determines the matcher used for patterns in the `partitionByComment` option. order: 'asc', ignoreCase: true, specialCharacters: 'keep', - partitionByNewLine: false, partitionByComment: false, + partitionByNewLine: false, + newlinesBetween: 'ignore', matcher: 'minimatch', groups: [], }, diff --git a/rules/sort-imports.ts b/rules/sort-imports.ts index 1e91dbe6..90ec0614 100644 --- a/rules/sort-imports.ts +++ b/rules/sort-imports.ts @@ -1,5 +1,4 @@ import type { TSESTree } from '@typescript-eslint/types' -import type { TSESLint } from '@typescript-eslint/utils' import { builtinModules } from 'node:module' @@ -9,11 +8,11 @@ import { validateGroupsConfiguration } from '../utils/validate-groups-configurat import { getOptionsWithCleanGroups } from '../utils/get-options-with-clean-groups' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' import { getCommentsBefore } from '../utils/get-comments-before' +import { makeNewlinesFixes } from '../utils/make-newlines-fixes' +import { getNewlinesErrors } from '../utils/get-newlines-errors' 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 { getNodeRange } from '../utils/get-node-range' import { rangeToDiff } from '../utils/range-to-diff' import { getSettings } from '../utils/get-settings' import { useGroups } from '../utils/use-groups' @@ -596,28 +595,19 @@ export default createEslintRule, MESSAGE_ID>({ ) } - let numberOfEmptyLinesBetween = getLinesBetween( - sourceCode, - left, - right, - ) - if ( - options.newlinesBetween === 'never' && - numberOfEmptyLinesBetween > 0 - ) { - messageIds.push('extraSpacingBetweenImports') - } - - if (options.newlinesBetween === 'always') { - if (leftNum < rightNum && numberOfEmptyLinesBetween === 0) { - messageIds.push('missedSpacingBetweenImports') - } else if ( - numberOfEmptyLinesBetween > 1 || - (leftNum === rightNum && numberOfEmptyLinesBetween > 0) - ) { - messageIds.push('extraSpacingBetweenImports') - } - } + messageIds = [ + ...messageIds, + ...getNewlinesErrors({ + left, + leftNum, + right, + rightNum, + sourceCode, + missedSpacingError: 'missedSpacingBetweenImports', + extraSpacingError: 'extraSpacingBetweenImports', + options, + }), + ] for (let messageId of messageIds) { context.report({ @@ -629,78 +619,16 @@ export default createEslintRule, MESSAGE_ID>({ rightGroup: right.group, }, node: right.node, - fix: fixer => { - let newlinesFixes: TSESLint.RuleFix[] = [] - - for (let max = sortedNodes.length, i = 0; i < max; i++) { - let node = sortedNodes.at(i)! - let nextNode = sortedNodes.at(i + 1) - - if (options.newlinesBetween === 'ignore' || !nextNode) { - continue - } - - let nodeGroupNumber = getGroupNumber(options.groups, node) - let nextNodeGroupNumber = getGroupNumber( - options.groups, - nextNode, - ) - let currentNodeRange = getNodeRange( - nodeList.at(i)!.node, - sourceCode, - options, - ) - let nextNodeRange = - getNodeRange( - nodeList.at(i + 1)!.node, - sourceCode, - options, - ).at(0)! - 1 - - let linesBetweenImports = getLinesBetween( - sourceCode, - nodeList.at(i)!, - nodeList.at(i + 1)!, - ) - - if ( - (options.newlinesBetween === 'always' && - nodeGroupNumber === nextNodeGroupNumber && - linesBetweenImports !== 0) || - (options.newlinesBetween === 'never' && - linesBetweenImports > 0) - ) { - newlinesFixes.push( - fixer.removeRange([ - currentNodeRange.at(1)!, - nextNodeRange, - ]), - ) - } - if ( - options.newlinesBetween === 'always' && - nodeGroupNumber !== nextNodeGroupNumber - ) { - if (linesBetweenImports > 1) { - newlinesFixes.push( - fixer.replaceTextRange( - [currentNodeRange.at(1)!, nextNodeRange], - '\n', - ), - ) - } else if (linesBetweenImports === 0) { - newlinesFixes.push( - fixer.insertTextAfterRange(currentNodeRange, '\n'), - ) - } - } - } - - return [ - ...makeFixes(fixer, nodeList, sortedNodes, sourceCode), - ...newlinesFixes, - ] - }, + fix: fixer => [ + ...makeFixes(fixer, nodeList, sortedNodes, sourceCode), + ...makeNewlinesFixes( + fixer, + nodeList, + sortedNodes, + sourceCode, + options, + ), + ], }) } }) diff --git a/rules/sort-interfaces.ts b/rules/sort-interfaces.ts index 23d303a2..325da910 100644 --- a/rules/sort-interfaces.ts +++ b/rules/sort-interfaces.ts @@ -1,15 +1,17 @@ import type { SortingNode } from '../typings' +import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' import { validateGroupsConfiguration } from '../utils/validate-groups-configuration' import { hasPartitionComment } from '../utils/is-partition-comment' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' import { getCommentsBefore } from '../utils/get-comments-before' +import { makeNewlinesFixes } from '../utils/make-newlines-fixes' +import { getNewlinesErrors } from '../utils/get-newlines-errors' import { createEslintRule } from '../utils/create-eslint-rule' import { isMemberOptional } from '../utils/is-member-optional' 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 { useGroups } from '../utils/use-groups' @@ -20,16 +22,19 @@ import { matches } from '../utils/matches' type MESSAGE_ID = | 'unexpectedInterfacePropertiesGroupOrder' + | 'missedSpacingBetweenInterfaceMembers' + | 'extraSpacingBetweenInterfaceMembers' | 'unexpectedInterfacePropertiesOrder' type Group = 'multiline' | 'unknown' | T[number] | 'method' -type Options = [ +export type Options = [ Partial<{ groupKind: 'optional-first' | 'required-first' | 'mixed' customGroups: { [key: string]: string[] | string } type: 'alphabetical' | 'line-length' | 'natural' partitionByComment: string[] | boolean | string + newlinesBetween: 'ignore' | 'always' | 'never' specialCharacters: 'remove' | 'trim' | 'keep' groups: (Group[] | Group)[] matcher: 'minimatch' | 'regex' @@ -110,6 +115,12 @@ export default createEslintRule, MESSAGE_ID>({ 'Allows to use spaces to separate the nodes into logical groups.', type: 'boolean', }, + newlinesBetween: { + description: + 'Specifies how new lines should be handled between object types groups.', + enum: ['ignore', 'always', 'never'], + type: 'string', + }, groupKind: { description: 'Specifies the order of optional and required nodes.', enum: ['mixed', 'optional-first', 'required-first'], @@ -158,6 +169,10 @@ export default createEslintRule, MESSAGE_ID>({ 'Expected "{{right}}" ({{rightGroup}}) to come before "{{left}}" ({{leftGroup}}).', unexpectedInterfacePropertiesOrder: 'Expected "{{right}}" to come before "{{left}}".', + missedSpacingBetweenInterfaceMembers: + 'Missed spacing between "{{left}}" and "{{right}}" interfaces.', + extraSpacingBetweenInterfaceMembers: + 'Extra spacing between "{{left}}" and "{{right}}" interfaces.', }, }, defaultOptions: [ @@ -187,6 +202,7 @@ export default createEslintRule, MESSAGE_ID>({ matcher: 'minimatch', ignorePattern: [], ignoreCase: true, + newlinesBetween: 'ignore', specialCharacters: 'keep', customGroups: {}, order: 'asc', @@ -198,6 +214,7 @@ export default createEslintRule, MESSAGE_ID>({ ['multiline', 'method', 'unknown'], Object.keys(options.customGroups), ) + validateNewlinesAndPartitionConfiguration(options) let sourceCode = getSourceCode(context) let partitionComment = options.partitionByComment @@ -315,25 +332,62 @@ export default createEslintRule, MESSAGE_ID>({ } pairwise(nodes, (left, right) => { + let leftNum = getGroupNumber(options.groups, left) + let rightNum = getGroupNumber(options.groups, right) + let indexOfLeft = sortedNodes.indexOf(left) let indexOfRight = sortedNodes.indexOf(right) + + let messageIds: MESSAGE_ID[] = [] + if (indexOfLeft > indexOfRight) { - let leftNum = getGroupNumber(options.groups, left) - let rightNum = getGroupNumber(options.groups, right) + messageIds.push( + leftNum !== rightNum + ? 'unexpectedInterfacePropertiesGroupOrder' + : 'unexpectedInterfacePropertiesOrder', + ) + } + + messageIds = [ + ...messageIds, + ...getNewlinesErrors({ + left, + leftNum, + right, + rightNum, + sourceCode, + missedSpacingError: 'missedSpacingBetweenInterfaceMembers', + extraSpacingError: 'extraSpacingBetweenInterfaceMembers', + options, + }), + ] + + for (let messageId of messageIds) { context.report({ - messageId: - leftNum !== rightNum - ? 'unexpectedInterfacePropertiesGroupOrder' - : 'unexpectedInterfacePropertiesOrder', + messageId, data: { - left: toSingleLine(left.name), + left: left.name, leftGroup: left.group, - right: toSingleLine(right.name), + right: right.name, rightGroup: right.group, }, node: right.node, - fix: fixer => - makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + fix: fixer => [ + ...makeFixes( + fixer, + nodes, + sortedNodes, + sourceCode, + options, + ), + ...makeNewlinesFixes( + fixer, + nodes, + sortedNodes, + sourceCode, + options, + ), + ], }) } }) diff --git a/rules/sort-intersection-types.ts b/rules/sort-intersection-types.ts index a911dcb7..5d01f61f 100644 --- a/rules/sort-intersection-types.ts +++ b/rules/sort-intersection-types.ts @@ -1,9 +1,12 @@ import type { SortingNode } from '../typings' +import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' import { validateGroupsConfiguration } from '../utils/validate-groups-configuration' import { hasPartitionComment } from '../utils/is-partition-comment' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' import { getCommentsBefore } from '../utils/get-comments-before' +import { makeNewlinesFixes } from '../utils/make-newlines-fixes' +import { getNewlinesErrors } from '../utils/get-newlines-errors' import { createEslintRule } from '../utils/create-eslint-rule' import { getLinesBetween } from '../utils/get-lines-between' import { getGroupNumber } from '../utils/get-group-number' @@ -17,7 +20,9 @@ import { complete } from '../utils/complete' import { pairwise } from '../utils/pairwise' type MESSAGE_ID = + | 'missedSpacingBetweenIntersectionTypes' | 'unexpectedIntersectionTypesGroupOrder' + | 'extraSpacingBetweenIntersectionTypes' | 'unexpectedIntersectionTypesOrder' type Group = @@ -39,6 +44,7 @@ type Options = [ Partial<{ type: 'alphabetical' | 'line-length' | 'natural' partitionByComment: string[] | boolean | string + newlinesBetween: 'ignore' | 'always' | 'never' specialCharacters: 'remove' | 'trim' | 'keep' matcher: 'minimatch' | 'regex' groups: (Group[] | Group)[] @@ -127,6 +133,12 @@ export default createEslintRule({ 'Allows to use spaces to separate the nodes into logical groups.', type: 'boolean', }, + newlinesBetween: { + description: + 'Specifies how new lines should be handled between object types groups.', + enum: ['ignore', 'always', 'never'], + type: 'string', + }, }, additionalProperties: false, }, @@ -136,6 +148,10 @@ export default createEslintRule({ 'Expected "{{right}}" ({{rightGroup}}) to come before "{{left}}" ({{leftGroup}}).', unexpectedIntersectionTypesOrder: 'Expected "{{right}}" to come before "{{left}}".', + missedSpacingBetweenIntersectionTypes: + 'Missed spacing between "{{left}}" and "{{right}}" types.', + extraSpacingBetweenIntersectionTypes: + 'Extra spacing between "{{left}}" and "{{right}}" types.', }, }, defaultOptions: [ @@ -160,6 +176,7 @@ export default createEslintRule({ specialCharacters: 'keep', order: 'asc', matcher: 'minimatch', + newlinesBetween: 'ignore', partitionByComment: false, partitionByNewLine: false, groups: [], @@ -184,6 +201,7 @@ export default createEslintRule({ ], [], ) + validateNewlinesAndPartitionConfiguration(options) let sourceCode = getSourceCode(context) let partitionComment = options.partitionByComment @@ -280,17 +298,41 @@ export default createEslintRule({ for (let nodes of formattedMembers) { let sortedNodes = sortNodesByGroups(nodes, options) + pairwise(nodes, (left, right) => { + let leftNum = getGroupNumber(options.groups, left) + let rightNum = getGroupNumber(options.groups, right) + let indexOfLeft = sortedNodes.indexOf(left) let indexOfRight = sortedNodes.indexOf(right) + + let messageIds: MESSAGE_ID[] = [] + if (indexOfLeft > indexOfRight) { - let leftNum = getGroupNumber(options.groups, left) - let rightNum = getGroupNumber(options.groups, right) + messageIds.push( + leftNum !== rightNum + ? 'unexpectedIntersectionTypesGroupOrder' + : 'unexpectedIntersectionTypesOrder', + ) + } + + messageIds = [ + ...messageIds, + ...getNewlinesErrors({ + left, + leftNum, + right, + rightNum, + sourceCode, + missedSpacingError: 'missedSpacingBetweenIntersectionTypes', + extraSpacingError: 'extraSpacingBetweenIntersectionTypes', + options, + }), + ] + + for (let messageId of messageIds) { context.report({ - messageId: - leftNum !== rightNum - ? 'unexpectedIntersectionTypesGroupOrder' - : 'unexpectedIntersectionTypesOrder', + messageId, data: { left: toSingleLine(left.name), leftGroup: left.group, @@ -298,8 +340,16 @@ export default createEslintRule({ rightGroup: right.group, }, node: right.node, - fix: fixer => - makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + fix: fixer => [ + ...makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + ...makeNewlinesFixes( + fixer, + nodes, + sortedNodes, + sourceCode, + options, + ), + ], }) } }) diff --git a/rules/sort-object-types.ts b/rules/sort-object-types.ts index 20eb81f0..22fd8150 100644 --- a/rules/sort-object-types.ts +++ b/rules/sort-object-types.ts @@ -2,10 +2,13 @@ import type { TSESTree } from '@typescript-eslint/types' import type { SortingNode } from '../typings' +import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' import { validateGroupsConfiguration } from '../utils/validate-groups-configuration' import { hasPartitionComment } from '../utils/is-partition-comment' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' import { getCommentsBefore } from '../utils/get-comments-before' +import { makeNewlinesFixes } from '../utils/make-newlines-fixes' +import { getNewlinesErrors } from '../utils/get-newlines-errors' import { createEslintRule } from '../utils/create-eslint-rule' import { getLinesBetween } from '../utils/get-lines-between' import { getGroupNumber } from '../utils/get-group-number' @@ -19,6 +22,8 @@ import { complete } from '../utils/complete' import { pairwise } from '../utils/pairwise' type MESSAGE_ID = + | 'missedSpacingBetweenObjectTypeMembers' + | 'extraSpacingBetweenObjectTypeMembers' | 'unexpectedObjectTypesGroupOrder' | 'unexpectedObjectTypesOrder' @@ -30,6 +35,7 @@ type Options = [ customGroups: { [key in T[number]]: string[] | string } type: 'alphabetical' | 'line-length' | 'natural' partitionByComment: string[] | boolean | string + newlinesBetween: 'ignore' | 'always' | 'never' specialCharacters: 'remove' | 'trim' | 'keep' groups: (Group[] | Group)[] matcher: 'minimatch' | 'regex' @@ -103,6 +109,12 @@ export default createEslintRule, MESSAGE_ID>({ 'Allows to use spaces to separate the nodes into logical groups.', type: 'boolean', }, + newlinesBetween: { + description: + 'Specifies how new lines should be handled between object types groups.', + enum: ['ignore', 'always', 'never'], + type: 'string', + }, groupKind: { description: 'Specifies top-level groups.', type: 'string', @@ -151,6 +163,10 @@ export default createEslintRule, MESSAGE_ID>({ 'Expected "{{right}}" ({{rightGroup}}) to come before "{{left}}" ({{leftGroup}}).', unexpectedObjectTypesOrder: 'Expected "{{right}}" to come before "{{left}}".', + missedSpacingBetweenObjectTypeMembers: + 'Missed spacing between "{{left}}" and "{{right}}" types.', + extraSpacingBetweenObjectTypeMembers: + 'Extra spacing between "{{left}}" and "{{right}}" types.', }, }, defaultOptions: [ @@ -160,6 +176,7 @@ export default createEslintRule, MESSAGE_ID>({ ignoreCase: true, specialCharacters: 'keep', matcher: 'minimatch', + newlinesBetween: 'ignore', partitionByComment: false, partitionByNewLine: false, groupKind: 'mixed', @@ -179,6 +196,7 @@ export default createEslintRule, MESSAGE_ID>({ groupKind: 'mixed', matcher: 'minimatch', ignoreCase: true, + newlinesBetween: 'ignore', specialCharacters: 'keep', customGroups: {}, order: 'asc', @@ -190,6 +208,7 @@ export default createEslintRule, MESSAGE_ID>({ ['multiline', 'method', 'unknown'], Object.keys(options.customGroups), ) + validateNewlinesAndPartitionConfiguration(options) let sourceCode = getSourceCode(context) let partitionComment = options.partitionByComment @@ -313,16 +332,39 @@ export default createEslintRule, MESSAGE_ID>({ } pairwise(nodes, (left, right) => { + let leftNum = getGroupNumber(options.groups, left) + let rightNum = getGroupNumber(options.groups, right) + let indexOfLeft = sortedNodes.indexOf(left) let indexOfRight = sortedNodes.indexOf(right) + + let messageIds: MESSAGE_ID[] = [] + if (indexOfLeft > indexOfRight) { - let leftNum = getGroupNumber(options.groups, left) - let rightNum = getGroupNumber(options.groups, right) + messageIds.push( + leftNum !== rightNum + ? 'unexpectedObjectTypesGroupOrder' + : 'unexpectedObjectTypesOrder', + ) + } + + messageIds = [ + ...messageIds, + ...getNewlinesErrors({ + left, + leftNum, + right, + rightNum, + sourceCode, + missedSpacingError: 'missedSpacingBetweenObjectTypeMembers', + extraSpacingError: 'extraSpacingBetweenObjectTypeMembers', + options, + }), + ] + + for (let messageId of messageIds) { context.report({ - messageId: - leftNum !== rightNum - ? 'unexpectedObjectTypesGroupOrder' - : 'unexpectedObjectTypesOrder', + messageId, data: { left: toSingleLine(left.name), leftGroup: left.group, @@ -330,8 +372,16 @@ export default createEslintRule, MESSAGE_ID>({ rightGroup: right.group, }, node: right.node, - fix: fixer => - makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + fix: fixer => [ + ...makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + ...makeNewlinesFixes( + fixer, + nodes, + sortedNodes, + sourceCode, + options, + ), + ], }) } }) diff --git a/rules/sort-objects.ts b/rules/sort-objects.ts index 5bd9d735..5ea018cc 100644 --- a/rules/sort-objects.ts +++ b/rules/sort-objects.ts @@ -1,5 +1,4 @@ import type { TSESTree } from '@typescript-eslint/types' -import type { TSESLint } from '@typescript-eslint/utils' import type { SortingNodeWithDependencies } from '../utils/sort-nodes-by-dependencies' @@ -7,16 +6,18 @@ import { getFirstUnorderedNodeDependentOn, sortNodesByDependencies, } from '../utils/sort-nodes-by-dependencies' +import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' import { validateGroupsConfiguration } from '../utils/validate-groups-configuration' import { hasPartitionComment } from '../utils/is-partition-comment' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' import { getCommentsBefore } from '../utils/get-comments-before' +import { makeNewlinesFixes } from '../utils/make-newlines-fixes' +import { getNewlinesErrors } from '../utils/get-newlines-errors' 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 { getNodeParent } from '../utils/get-node-parent' -import { toSingleLine } from '../utils/to-single-line' import { rangeToDiff } from '../utils/range-to-diff' import { getSettings } from '../utils/get-settings' import { useGroups } from '../utils/use-groups' @@ -26,7 +27,9 @@ import { pairwise } from '../utils/pairwise' import { matches } from '../utils/matches' type MESSAGE_ID = + | 'missedSpacingBetweenObjectMembers' | 'unexpectedObjectsDependencyOrder' + | 'extraSpacingBetweenObjectMembers' | 'unexpectedObjectsGroupOrder' | 'unexpectedObjectsOrder' @@ -37,6 +40,7 @@ type Options = [ customGroups: { [key: string]: string[] | string } type: 'alphabetical' | 'line-length' | 'natural' partitionByComment: string[] | boolean | string + newlinesBetween: 'ignore' | 'always' | 'never' specialCharacters: 'remove' | 'trim' | 'keep' matcher: 'minimatch' | 'regex' groups: (Group[] | Group)[] @@ -111,6 +115,12 @@ export default createEslintRule({ 'Allows to use spaces to separate the nodes into logical groups.', type: 'boolean', }, + newlinesBetween: { + description: + 'Specifies how new lines should be handled between object types groups.', + enum: ['ignore', 'always', 'never'], + type: 'string', + }, styledComponents: { description: 'Controls whether to sort styled components.', type: 'boolean', @@ -171,6 +181,10 @@ export default createEslintRule({ unexpectedObjectsOrder: 'Expected "{{right}}" to come before "{{left}}".', unexpectedObjectsDependencyOrder: 'Expected dependency "{{right}}" to come before "{{nodeDependentOnRight}}".', + missedSpacingBetweenObjectMembers: + 'Missed spacing between "{{left}}" and "{{right}}" objects.', + extraSpacingBetweenObjectMembers: + 'Extra spacing between "{{left}}" and "{{right}}" objects.', }, }, defaultOptions: [ @@ -204,6 +218,7 @@ export default createEslintRule({ ignorePattern: [], matcher: 'minimatch', ignoreCase: true, + newlinesBetween: 'ignore', specialCharacters: 'keep', customGroups: {}, order: 'asc', @@ -215,6 +230,7 @@ export default createEslintRule({ ['multiline', 'method', 'unknown'], Object.keys(options.customGroups), ) + validateNewlinesAndPartitionConfiguration(options) let shouldIgnore = false @@ -389,17 +405,6 @@ export default createEslintRule({ let comments = getCommentsBefore(prop, sourceCode) let lastProp = accumulator.at(-1)?.at(-1) - if ( - options.partitionByComment && - hasPartitionComment( - options.partitionByComment, - comments, - options.matcher, - ) - ) { - accumulator.push([]) - } - let name: string let dependencies: string[] = [] @@ -440,9 +445,15 @@ export default createEslintRule({ } if ( - options.partitionByNewLine && - lastProp && - getLinesBetween(sourceCode, lastProp, propSortingNode) + (options.partitionByNewLine && + lastProp && + getLinesBetween(sourceCode, lastProp, propSortingNode)) || + (options.partitionByComment && + hasPartitionComment( + options.partitionByComment, + comments, + options.matcher, + )) ) { accumulator.push([]) } @@ -461,36 +472,68 @@ export default createEslintRule({ .flat(), ) let nodes = formattedMembers.flat() + pairwise(nodes, (left, right) => { + let leftNum = getGroupNumber(options.groups, left) + let rightNum = getGroupNumber(options.groups, right) + let indexOfLeft = sortedNodes.indexOf(left) let indexOfRight = sortedNodes.indexOf(right) + let messageIds: MESSAGE_ID[] = [] + let firstUnorderedNodeDependentOnRight: + | SortingNodeWithDependencies + | undefined + if (indexOfLeft > indexOfRight) { - let firstUnorderedNodeDependentOnRight = + firstUnorderedNodeDependentOnRight = getFirstUnorderedNodeDependentOn(right, nodes) - let leftNum = getGroupNumber(options.groups, left) - let rightNum = getGroupNumber(options.groups, right) - let messageId: MESSAGE_ID if (firstUnorderedNodeDependentOnRight) { - messageId = 'unexpectedObjectsDependencyOrder' + messageIds.push('unexpectedObjectsDependencyOrder') } else { - messageId = + messageIds.push( leftNum !== rightNum ? 'unexpectedObjectsGroupOrder' - : 'unexpectedObjectsOrder' + : 'unexpectedObjectsOrder', + ) } + } + + messageIds = [ + ...messageIds, + ...getNewlinesErrors({ + left, + leftNum, + right, + rightNum, + sourceCode, + missedSpacingError: 'missedSpacingBetweenObjectMembers', + extraSpacingError: 'extraSpacingBetweenObjectMembers', + options, + }), + ] + + for (let messageId of messageIds) { context.report({ messageId, data: { - left: toSingleLine(left.name), + left: left.name, leftGroup: left.group, - right: toSingleLine(right.name), + right: right.name, rightGroup: right.group, nodeDependentOnRight: firstUnorderedNodeDependentOnRight?.name, }, node: right.node, - fix: (fixer: TSESLint.RuleFixer) => - makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + fix: fixer => [ + ...makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + ...makeNewlinesFixes( + fixer, + nodes, + sortedNodes, + sourceCode, + options, + ), + ], }) } }) diff --git a/rules/sort-union-types.ts b/rules/sort-union-types.ts index 4532526e..c79feaa1 100644 --- a/rules/sort-union-types.ts +++ b/rules/sort-union-types.ts @@ -1,9 +1,12 @@ import type { SortingNode } from '../typings' +import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' import { validateGroupsConfiguration } from '../utils/validate-groups-configuration' import { hasPartitionComment } from '../utils/is-partition-comment' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' import { getCommentsBefore } from '../utils/get-comments-before' +import { makeNewlinesFixes } from '../utils/make-newlines-fixes' +import { getNewlinesErrors } from '../utils/get-newlines-errors' import { createEslintRule } from '../utils/create-eslint-rule' import { getLinesBetween } from '../utils/get-lines-between' import { getGroupNumber } from '../utils/get-group-number' @@ -16,7 +19,11 @@ import { makeFixes } from '../utils/make-fixes' import { complete } from '../utils/complete' import { pairwise } from '../utils/pairwise' -type MESSAGE_ID = 'unexpectedUnionTypesGroupOrder' | 'unexpectedUnionTypesOrder' +type MESSAGE_ID = + | 'missedSpacingBetweenUnionTypes' + | 'unexpectedUnionTypesGroupOrder' + | 'extraSpacingBetweenUnionTypes' + | 'unexpectedUnionTypesOrder' type Group = | 'intersection' @@ -37,6 +44,7 @@ type Options = [ Partial<{ type: 'alphabetical' | 'line-length' | 'natural' partitionByComment: string[] | boolean | string + newlinesBetween: 'ignore' | 'always' | 'never' specialCharacters: 'remove' | 'trim' | 'keep' matcher: 'minimatch' | 'regex' groups: (Group[] | Group)[] @@ -125,6 +133,12 @@ export default createEslintRule({ 'Allows to use spaces to separate the nodes into logical groups.', type: 'boolean', }, + newlinesBetween: { + description: + 'Specifies how new lines should be handled between object types groups.', + enum: ['ignore', 'always', 'never'], + type: 'string', + }, }, additionalProperties: false, }, @@ -134,6 +148,10 @@ export default createEslintRule({ 'Expected "{{right}}" ({{rightGroup}}) to come before "{{left}}" ({{leftGroup}}).', unexpectedUnionTypesOrder: 'Expected "{{right}}" to come before "{{left}}".', + missedSpacingBetweenUnionTypes: + 'Missed spacing between "{{left}}" and "{{right}}" types.', + extraSpacingBetweenUnionTypes: + 'Extra spacing between "{{left}}" and "{{right}}" types.', }, }, defaultOptions: [ @@ -159,6 +177,7 @@ export default createEslintRule({ order: 'asc', groups: [], matcher: 'minimatch', + newlinesBetween: 'ignore', partitionByNewLine: false, partitionByComment: false, } as const) @@ -182,6 +201,7 @@ export default createEslintRule({ ], [], ) + validateNewlinesAndPartitionConfiguration(options) let sourceCode = getSourceCode(context) let partitionComment = options.partitionByComment @@ -277,17 +297,41 @@ export default createEslintRule({ for (let nodes of formattedMembers) { let sortedNodes = sortNodesByGroups(nodes, options) + pairwise(nodes, (left, right) => { + let leftNum = getGroupNumber(options.groups, left) + let rightNum = getGroupNumber(options.groups, right) + let indexOfLeft = sortedNodes.indexOf(left) let indexOfRight = sortedNodes.indexOf(right) + + let messageIds: MESSAGE_ID[] = [] + if (indexOfLeft > indexOfRight) { - let leftNum = getGroupNumber(options.groups, left) - let rightNum = getGroupNumber(options.groups, right) + messageIds.push( + leftNum !== rightNum + ? 'unexpectedUnionTypesGroupOrder' + : 'unexpectedUnionTypesOrder', + ) + } + + messageIds = [ + ...messageIds, + ...getNewlinesErrors({ + left, + leftNum, + right, + rightNum, + sourceCode, + missedSpacingError: 'missedSpacingBetweenUnionTypes', + extraSpacingError: 'extraSpacingBetweenUnionTypes', + options, + }), + ] + + for (let messageId of messageIds) { context.report({ - messageId: - leftNum !== rightNum - ? 'unexpectedUnionTypesGroupOrder' - : 'unexpectedUnionTypesOrder', + messageId, data: { left: toSingleLine(left.name), leftGroup: left.group, @@ -295,8 +339,16 @@ export default createEslintRule({ rightGroup: right.group, }, node: right.node, - fix: fixer => - makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + fix: fixer => [ + ...makeFixes(fixer, nodes, sortedNodes, sourceCode, options), + ...makeNewlinesFixes( + fixer, + nodes, + sortedNodes, + sourceCode, + options, + ), + ], }) } }) diff --git a/test/sort-imports.test.ts b/test/sort-imports.test.ts index bbbc2c37..6252a028 100644 --- a/test/sort-imports.test.ts +++ b/test/sort-imports.test.ts @@ -1920,6 +1920,146 @@ describe(ruleName, () => { invalid: [], }, ) + + describe(`${ruleName}: newlinesBetween`, () => { + ruleTester.run( + `${ruleName}(${type}): removes newlines when never`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + import { A } from 'a' + + + import y from '~/y' + import z from '~/z' + + import b from '~/b' + `, + output: dedent` + import { A } from 'a' + import b from '~/b' + import y from '~/y' + import z from '~/z' + `, + options: [ + { + ...options, + newlinesBetween: 'never', + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenImports', + data: { + left: 'a', + right: '~/y', + }, + }, + { + messageId: 'unexpectedImportsOrder', + data: { + left: '~/z', + right: '~/b', + }, + }, + { + messageId: 'extraSpacingBetweenImports', + data: { + left: '~/z', + right: '~/b', + }, + }, + ], + }, + ], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): keeps one newline when always`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + import c from 'c'; import a from '~/a' + `, + output: dedent` + import c from 'c'; + + import a from '~/a' + `, + options: [ + { + ...options, + newlinesBetween: 'always', + }, + ], + errors: [ + { + messageId: 'missedSpacingBetweenImports', + data: { + left: 'c', + right: '~/a', + }, + }, + ], + }, + { + code: dedent` + import { A } from 'a' + + + import c from '~/c' + import b from '~/b' + + import d from '~/d' + `, + output: dedent` + import { A } from 'a' + + import b from '~/b' + import c from '~/c' + import d from '~/d' + `, + options: [ + { + ...options, + newlinesBetween: 'always', + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenImports', + data: { + left: 'a', + right: '~/c', + }, + }, + { + messageId: 'unexpectedImportsOrder', + data: { + left: '~/c', + right: '~/b', + }, + }, + { + messageId: 'extraSpacingBetweenImports', + data: { + left: '~/b', + right: '~/d', + }, + }, + ], + }, + ], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => { diff --git a/test/sort-interfaces.test.ts b/test/sort-interfaces.test.ts index 50e8c9df..62ec87a2 100644 --- a/test/sort-interfaces.test.ts +++ b/test/sort-interfaces.test.ts @@ -2,6 +2,8 @@ import { RuleTester } from '@typescript-eslint/rule-tester' import { afterAll, describe, it } from 'vitest' import { dedent } from 'ts-dedent' +import type { Options } from '../rules/sort-interfaces' + import rule from '../rules/sort-interfaces' let ruleName = 'sort-interfaces' @@ -19,7 +21,7 @@ describe(ruleName, () => { describe(`${ruleName}: sorting by alphabetical order`, () => { let type = 'alphabetical-order' - let options = { + let options: Options[0] = { type: 'alphabetical', ignoreCase: true, order: 'asc', @@ -1047,6 +1049,135 @@ describe(ruleName, () => { ], invalid: [], }) + + describe(`${ruleName}: newlinesBetween`, () => { + ruleTester.run( + `${ruleName}(${type}): removes newlines when never`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + interface Interface { + a: () => null, + + + y: "y", + z: "z", + + b: "b", + } + `, + output: dedent` + interface Interface { + a: () => null, + b: "b", + y: "y", + z: "z", + } + `, + options: [ + { + ...options, + newlinesBetween: 'never', + groups: ['method', 'unknown'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenInterfaceMembers', + data: { + left: 'a', + right: 'y', + }, + }, + { + messageId: 'unexpectedInterfacePropertiesOrder', + data: { + left: 'z', + right: 'b', + }, + }, + { + messageId: 'extraSpacingBetweenInterfaceMembers', + data: { + left: 'z', + right: 'b', + }, + }, + ], + }, + ], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): keeps one newline when always`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + interface Interface { + a: () => null, + + + z: "z", + y: "y", + b: { + // Newline stuff + }, + } + `, + output: dedent` + interface Interface { + a: () => null, + + y: "y", + z: "z", + + b: { + // Newline stuff + }, + } + `, + options: [ + { + ...options, + newlinesBetween: 'always', + groups: ['method', 'unknown', 'multiline'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenInterfaceMembers', + data: { + left: 'a', + right: 'z', + }, + }, + { + messageId: 'unexpectedInterfacePropertiesOrder', + data: { + left: 'z', + right: 'y', + }, + }, + { + messageId: 'missedSpacingBetweenInterfaceMembers', + data: { + left: 'y', + right: 'b', + }, + }, + ], + }, + ], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => { diff --git a/test/sort-intersection-types.test.ts b/test/sort-intersection-types.test.ts index 62963018..be73e321 100644 --- a/test/sort-intersection-types.test.ts +++ b/test/sort-intersection-types.test.ts @@ -691,6 +691,127 @@ describe(ruleName, () => { invalid: [], }, ) + + describe(`${ruleName}: newlinesBetween`, () => { + ruleTester.run( + `${ruleName}(${type}): removes newlines when never`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + type T = + (() => null) + + + & Y + & Z + + & B + `, + output: dedent` + type T = + (() => null) + & B + & Y + & Z + `, + options: [ + { + ...options, + newlinesBetween: 'never', + groups: ['function', 'unknown'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenIntersectionTypes', + data: { + left: '() => null', + right: 'Y', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'Z', + right: 'B', + }, + }, + { + messageId: 'extraSpacingBetweenIntersectionTypes', + data: { + left: 'Z', + right: 'B', + }, + }, + ], + }, + ], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): keeps one newline when always`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + type T = + (() => null) + + + & Z + & Y + & "A" + `, + output: dedent` + type T = + (() => null) + + & Y + & Z + + & "A" + `, + options: [ + { + ...options, + newlinesBetween: 'always', + groups: ['function', 'unknown', 'literal'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenIntersectionTypes', + data: { + left: '() => null', + right: 'Z', + }, + }, + { + messageId: 'unexpectedIntersectionTypesOrder', + data: { + left: 'Z', + right: 'Y', + }, + }, + { + messageId: 'missedSpacingBetweenIntersectionTypes', + data: { + left: 'Y', + right: '"A"', + }, + }, + ], + }, + ], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => { diff --git a/test/sort-object-types.test.ts b/test/sort-object-types.test.ts index dad6f3e4..34e30dc1 100644 --- a/test/sort-object-types.test.ts +++ b/test/sort-object-types.test.ts @@ -889,6 +889,135 @@ describe(ruleName, () => { ], invalid: [], }) + + describe(`${ruleName}: newlinesBetween`, () => { + ruleTester.run( + `${ruleName}(${type}): removes newlines when never`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + type Type = { + a: () => null, + + + y: "y", + z: "z", + + b: "b", + } + `, + output: dedent` + type Type = { + a: () => null, + b: "b", + y: "y", + z: "z", + } + `, + options: [ + { + ...options, + newlinesBetween: 'never', + groups: ['method', 'unknown'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenObjectTypeMembers', + data: { + left: 'a', + right: 'y', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + left: 'z', + right: 'b', + }, + }, + { + messageId: 'extraSpacingBetweenObjectTypeMembers', + data: { + left: 'z', + right: 'b', + }, + }, + ], + }, + ], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): keeps one newline when always`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + type Type = { + a: () => null, + + + z: "z", + y: "y", + b: { + // Newline stuff + }, + } + `, + output: dedent` + type Type = { + a: () => null, + + y: "y", + z: "z", + + b: { + // Newline stuff + }, + } + `, + options: [ + { + ...options, + newlinesBetween: 'always', + groups: ['method', 'unknown', 'multiline'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenObjectTypeMembers', + data: { + left: 'a', + right: 'z', + }, + }, + { + messageId: 'unexpectedObjectTypesOrder', + data: { + left: 'z', + right: 'y', + }, + }, + { + messageId: 'missedSpacingBetweenObjectTypeMembers', + data: { + left: 'y', + right: 'b', + }, + }, + ], + }, + ], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => { diff --git a/test/sort-objects.test.ts b/test/sort-objects.test.ts index 65454dbb..4b5fe8ad 100644 --- a/test/sort-objects.test.ts +++ b/test/sort-objects.test.ts @@ -332,7 +332,7 @@ describe(ruleName, () => { iHaveFooInMyName: string, meTooIHaveFoo: string, a: string, - b: string, + b: "b", } `, options: [ @@ -1646,6 +1646,135 @@ describe(ruleName, () => { invalid: [], }, ) + + describe(`${ruleName}: newlinesBetween`, () => { + ruleTester.run( + `${ruleName}(${type}): removes newlines when never`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + let Obj = { + a: () => null, + + + y: "y", + z: "z", + + b: "b", + } + `, + output: dedent` + let Obj = { + a: () => null, + b: "b", + y: "y", + z: "z", + } + `, + options: [ + { + ...options, + newlinesBetween: 'never', + groups: ['method', 'unknown'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenObjectMembers', + data: { + left: 'a', + right: 'y', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'z', + right: 'b', + }, + }, + { + messageId: 'extraSpacingBetweenObjectMembers', + data: { + left: 'z', + right: 'b', + }, + }, + ], + }, + ], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): keeps one newline when always`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + let Obj = { + a: () => null, + + + z: "z", + y: "y", + b: { + // Newline stuff + }, + } + `, + output: dedent` + let Obj = { + a: () => null, + + y: "y", + z: "z", + + b: { + // Newline stuff + }, + } + `, + options: [ + { + ...options, + newlinesBetween: 'always', + groups: ['method', 'unknown', 'multiline'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenObjectMembers', + data: { + left: 'a', + right: 'z', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'z', + right: 'y', + }, + }, + { + messageId: 'missedSpacingBetweenObjectMembers', + data: { + left: 'y', + right: 'b', + }, + }, + ], + }, + ], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => { diff --git a/test/sort-union-types.test.ts b/test/sort-union-types.test.ts index 923e917b..c65b91ff 100644 --- a/test/sort-union-types.test.ts +++ b/test/sort-union-types.test.ts @@ -694,6 +694,127 @@ describe(ruleName, () => { invalid: [], }, ) + + describe(`${ruleName}: newlinesBetween`, () => { + ruleTester.run( + `${ruleName}(${type}): removes newlines when never`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + type T = + (() => null) + + + | Y + | Z + + | B + `, + output: dedent` + type T = + (() => null) + | B + | Y + | Z + `, + options: [ + { + ...options, + newlinesBetween: 'never', + groups: ['function', 'unknown'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenUnionTypes', + data: { + left: '() => null', + right: 'Y', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'Z', + right: 'B', + }, + }, + { + messageId: 'extraSpacingBetweenUnionTypes', + data: { + left: 'Z', + right: 'B', + }, + }, + ], + }, + ], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): keeps one newline when always`, + rule, + { + valid: [], + invalid: [ + { + code: dedent` + type T = + (() => null) + + + | Z + | Y + | "A" + `, + output: dedent` + type T = + (() => null) + + | Y + | Z + + | "A" + `, + options: [ + { + ...options, + newlinesBetween: 'always', + groups: ['function', 'unknown', 'literal'], + }, + ], + errors: [ + { + messageId: 'extraSpacingBetweenUnionTypes', + data: { + left: '() => null', + right: 'Z', + }, + }, + { + messageId: 'unexpectedUnionTypesOrder', + data: { + left: 'Z', + right: 'Y', + }, + }, + { + messageId: 'missedSpacingBetweenUnionTypes', + data: { + left: 'Y', + right: '"A"', + }, + }, + ], + }, + ], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => { diff --git a/test/validate-newlines-and-partition-configuration.test.ts b/test/validate-newlines-and-partition-configuration.test.ts new file mode 100644 index 00000000..c19d40e8 --- /dev/null +++ b/test/validate-newlines-and-partition-configuration.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' + +describe('validate-newlines-and-partition-configuration', () => { + let partitionByCommentValues: (string[] | boolean | string)[] = [ + true, + 'partitionComment', + ['partition1', 'partition2'], + ] + + it("throws an error when 'partitionComment' is enabled and 'newlinesBetween' is not 'ignore'", () => { + let newlinesBetweenValues = ['always', 'never'] as const + + for (let newlinesBetween of newlinesBetweenValues) { + for (let partitionByComment of partitionByCommentValues) { + expect(() => { + validateNewlinesAndPartitionConfiguration({ + newlinesBetween, + partitionByNewLine: partitionByComment, + }) + }).toThrow( + "The 'partitionByNewLine' and 'newlinesBetween' options cannot be used together", + ) + } + } + }) + + it("allows 'partitionComment' when 'newlinesBetween' is 'ignore'", () => { + for (let partitionByComment of partitionByCommentValues) { + expect(() => { + validateNewlinesAndPartitionConfiguration({ + newlinesBetween: 'ignore', + partitionByNewLine: partitionByComment, + }) + }).not.toThrow() + } + }) + + it("allows 'newlinesBetween' when 'partitionByComment' is 'false'", () => { + let newlinesBetweenValues = ['always', 'never', 'ignore'] as const + + for (let newlinesBetween of newlinesBetweenValues) { + expect(() => { + validateNewlinesAndPartitionConfiguration({ + newlinesBetween, + partitionByNewLine: false, + }) + }).not.toThrow() + } + }) +}) diff --git a/utils/get-newlines-errors.ts b/utils/get-newlines-errors.ts new file mode 100644 index 00000000..fa83bbb7 --- /dev/null +++ b/utils/get-newlines-errors.ts @@ -0,0 +1,51 @@ +import type { TSESLint } from '@typescript-eslint/utils' + +import type { SortingNode } from '../typings' + +import { getLinesBetween } from './get-lines-between' + +interface Options { + newlinesBetween: 'ignore' | 'always' | 'never' +} + +interface Props { + sourceCode: TSESLint.SourceCode + missedSpacingError: T + extraSpacingError: T + right: SortingNode + left: SortingNode + rightNum: number + options: Options + leftNum: number +} + +export let getNewlinesErrors = ({ + missedSpacingError, + extraSpacingError, + sourceCode, + rightNum, + leftNum, + options, + right, + left, +}: Props) => { + let errors: T[] = [] + + let numberOfEmptyLinesBetween = getLinesBetween(sourceCode, left, right) + if (options.newlinesBetween === 'never' && numberOfEmptyLinesBetween > 0) { + errors.push(extraSpacingError) + } + + if (options.newlinesBetween === 'always') { + if (leftNum < rightNum && numberOfEmptyLinesBetween === 0) { + errors.push(missedSpacingError) + } else if ( + numberOfEmptyLinesBetween > 1 || + (leftNum === rightNum && numberOfEmptyLinesBetween > 0) + ) { + errors.push(extraSpacingError) + } + } + + return errors +} diff --git a/utils/make-newlines-fixes.ts b/utils/make-newlines-fixes.ts new file mode 100644 index 00000000..8bbf4360 --- /dev/null +++ b/utils/make-newlines-fixes.ts @@ -0,0 +1,95 @@ +import type { TSESLint } from '@typescript-eslint/utils' + +import type { SortingNode } from '../typings' + +import { getLinesBetween } from './get-lines-between' +import { getGroupNumber } from './get-group-number' +import { getNodeRange } from './get-node-range' + +export const makeNewlinesFixes = ( + fixer: TSESLint.RuleFixer, + nodes: SortingNode[], + sortedNodes: SortingNode[], + source: TSESLint.SourceCode, + options: { + newlinesBetween: 'ignore' | 'always' | 'never' + groups: (string[] | string)[] + }, +) => { + 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) + + if (options.newlinesBetween === 'ignore' || !nextSortingNode) { + continue + } + + let nodeGroupNumber = getGroupNumber(options.groups, sortingNode) + let nextNodeGroupNumber = getGroupNumber(options.groups, nextSortingNode) + let currentNodeRange = getNodeRange(nodes.at(i)!.node, source) + let nextNodeRangeStart = getNodeRange(nodes.at(i + 1)!.node, source).at(0)! + let rangeToReplace: [number, number] = [ + currentNodeRange.at(1)!, + nextNodeRangeStart, + ] + let textBetweenNodes = source.text.slice( + currentNodeRange.at(1), + nextNodeRangeStart, + ) + + let linesBetweenMembers = getLinesBetween( + source, + nodes.at(i)!, + nodes.at(i + 1)!, + ) + + let rangeReplacement: undefined | string + if ( + (options.newlinesBetween === 'always' && + nodeGroupNumber === nextNodeGroupNumber && + linesBetweenMembers !== 0) || + (options.newlinesBetween === 'never' && linesBetweenMembers > 0) + ) { + rangeReplacement = getStringWithoutInvalidNewlines(textBetweenNodes) + } + + if ( + options.newlinesBetween === 'always' && + nodeGroupNumber !== nextNodeGroupNumber && + linesBetweenMembers !== 1 + ) { + rangeReplacement = addNewlineBeforeFirstNewline( + linesBetweenMembers > 1 + ? getStringWithoutInvalidNewlines(textBetweenNodes) + : textBetweenNodes, + ) + let isOnSameLine = + linesBetweenMembers === 0 && + nodes.at(i)!.node.loc.end.line === nodes.at(i + 1)!.node.loc.start.line + if (isOnSameLine) { + rangeReplacement = addNewlineBeforeFirstNewline(rangeReplacement) + } + } + + if (rangeReplacement) { + fixes.push(fixer.replaceTextRange(rangeToReplace, rangeReplacement)) + } + } + + return fixes +} + +const getStringWithoutInvalidNewlines = (value: string) => + value.replaceAll(/\n+\s*\n+/g, '\n').replaceAll(/\n+/g, '\n') + +const addNewlineBeforeFirstNewline = (value: string) => { + let firstNewlineIndex = value.indexOf('\n') + if (firstNewlineIndex === -1) { + return value + '\n' + } + return ( + value.slice(0, firstNewlineIndex) + '\n' + value.slice(firstNewlineIndex) + ) +} diff --git a/utils/sort-nodes-by-dependencies.ts b/utils/sort-nodes-by-dependencies.ts index e26d735c..a6a9adab 100644 --- a/utils/sort-nodes-by-dependencies.ts +++ b/utils/sort-nodes-by-dependencies.ts @@ -53,10 +53,12 @@ export let sortNodesByDependencies = ( * Returns the first node that is dependent on the given node, but is not * ordered before it */ -export let getFirstUnorderedNodeDependentOn = ( - node: SortingNodeWithDependencies, - currentlyOrderedNodes: SortingNodeWithDependencies[], -): SortingNodeWithDependencies | undefined => { +export let getFirstUnorderedNodeDependentOn = < + T extends SortingNodeWithDependencies, +>( + node: T, + currentlyOrderedNodes: T[], +): undefined | T => { let nodesDependentOnNode = currentlyOrderedNodes.filter( currentlyOrderedNode => currentlyOrderedNode.dependencies.includes( diff --git a/utils/validate-newlines-and-partition-configuration.ts b/utils/validate-newlines-and-partition-configuration.ts new file mode 100644 index 00000000..b2561228 --- /dev/null +++ b/utils/validate-newlines-and-partition-configuration.ts @@ -0,0 +1,15 @@ +interface Options { + partitionByNewLine: string[] | boolean | string + newlinesBetween: 'ignore' | 'always' | 'never' +} + +export const validateNewlinesAndPartitionConfiguration = ({ + partitionByNewLine, + newlinesBetween, +}: Options): void => { + if (!!partitionByNewLine && newlinesBetween !== 'ignore') { + throw new Error( + "The 'partitionByNewLine' and 'newlinesBetween' options cannot be used together", + ) + } +}