diff --git a/docs/rules/sort-objects.md b/docs/rules/sort-objects.md index c9761101..777bd8c9 100644 --- a/docs/rules/sort-objects.md +++ b/docs/rules/sort-objects.md @@ -84,6 +84,7 @@ interface Options { order?: 'asc' | 'desc' 'ignore-case'?: boolean 'always-on-top'?: string[] + 'partition-by-comment': string[] | string | boolean } ``` @@ -114,6 +115,14 @@ Only affects alphabetical and natural sorting. When `true` the rule ignores the You can set a list of key names that will always go at the beginning of the object. For example: `['id', 'name']` +### partition-by-comment + +(default: `false`) + +You can set comments that would separate the properties of objects into logical parts. If set to `true`, all object property comments will be treated as delimiters. + +The [minimatch](https://github.com/isaacs/minimatch) library is used for pattern matching. + ## ⚙️ Usage ::: code-group @@ -127,7 +136,9 @@ You can set a list of key names that will always go at the beginning of the obje "error", { "type": "natural", - "order": "asc" + "order": "asc", + "always-on-top": ["id", "name"], + "partition-by-comment": "Part:**" } ] } @@ -149,6 +160,8 @@ export default [ { type: 'natural', order: 'asc', + 'always-on-top': ['id', 'name'], + 'partition-by-comment': 'Part:**', }, ], }, diff --git a/index.ts b/index.ts index 619deb38..b80ea9e4 100644 --- a/index.ts +++ b/index.ts @@ -77,24 +77,25 @@ let createConfigWithOptions = (options: { callback: 'ignore', }, ], - [sortArrayIncludesName]: [ + [sortObjectsName]: [ 'error', { - 'spread-last': true, + 'partition-by-comment': false, + 'always-on-top': [], }, ], - [sortObjectsName]: [ + [sortArrayIncludesName]: [ 'error', { - 'always-on-top': [], + 'spread-last': true, }, ], - [sortNamedImportsName]: ['error'], [sortNamedExportsName]: ['error'], - [sortObjectTypesName]: ['error'], + [sortNamedImportsName]: ['error'], [sortMapElementsName]: ['error'], - [sortUnionTypesName]: ['error'], + [sortObjectTypesName]: ['error'], [sortInterfacesName]: ['error'], + [sortUnionTypesName]: ['error'], [sortExportsName]: ['error'], [sortEnumsName]: ['error'], } diff --git a/rules/sort-objects.ts b/rules/sort-objects.ts index db5bd6ad..f27a4ac4 100644 --- a/rules/sort-objects.ts +++ b/rules/sort-objects.ts @@ -1,18 +1,20 @@ -import type { TSESLint } from '@typescript-eslint/utils' import type { TSESTree } from '@typescript-eslint/types' +import type { TSESLint } from '@typescript-eslint/utils' import { AST_NODE_TYPES } from '@typescript-eslint/types' -import type { SortingNode } from '../typings' +import type { PartitionComment, SortingNode } from '../typings' +import { isPartitionComment } from '../utils/is-partition-comment' +import { getCommentBefore } from '../utils/get-comment-before' import { createEslintRule } from '../utils/create-eslint-rule' import { toSingleLine } from '../utils/to-single-line' import { rangeToDiff } from '../utils/range-to-diff' import { SortOrder, SortType } from '../typings' -import { sortNodes } from '../utils/sort-nodes' import { makeFixes } from '../utils/make-fixes' -import { pairwise } from '../utils/pairwise' +import { sortNodes } from '../utils/sort-nodes' import { complete } from '../utils/complete' +import { pairwise } from '../utils/pairwise' import { groupBy } from '../utils/group-by' import { compare } from '../utils/compare' @@ -29,6 +31,7 @@ type SortingNodeWithPosition = SortingNode & { type Options = [ Partial<{ + 'partition-by-comment': PartitionComment 'always-on-top': string[] 'ignore-case': boolean order: SortOrder @@ -51,6 +54,10 @@ export default createEslintRule({ { type: 'object', properties: { + 'partition-by-comment': { + type: ['boolean', 'string', 'array'], + default: false, + }, type: { enum: [ SortType.alphabetical, @@ -91,6 +98,7 @@ export default createEslintRule({ ) => { if (node.properties.length > 1) { let options = complete(context.options.at(0), { + 'partition-by-comment': false, type: SortType.alphabetical, 'ignore-case': false, order: SortOrder.asc, @@ -116,6 +124,19 @@ export default createEslintRule({ return accumulator } + let comment = getCommentBefore(prop, source) + + if ( + options['partition-by-comment'] && + comment && + isPartitionComment( + options['partition-by-comment'], + comment.value, + ) + ) { + accumulator.push([]) + } + let name: string let position: Position = Position.ignore let dependencies: string[] = [] @@ -230,7 +251,9 @@ export default createEslintRule({ sortNodes(getGroup(Position.ignore), options), ].flat() - return makeFixes(fixer, nodes, sortedNodes, source) + return makeFixes(fixer, nodes, sortedNodes, source, { + partitionComment: options['partition-by-comment'], + }) } context.report({ diff --git a/test/sort-objects.test.ts b/test/sort-objects.test.ts index 94869cb7..be57ca63 100644 --- a/test/sort-objects.test.ts +++ b/test/sort-objects.test.ts @@ -600,6 +600,159 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to use partition comments`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + let heroAssociation = { + // Part: S-Class + blast: 'Blast', + tatsumaki: 'Tatsumaki', + // Atomic Samurai + kamikaze: 'Kamikaze', + // Part: A-Class + sweet: 'Sweet Mask', + iaian: 'Iaian', + // Part: B-Class + 'mountain-ape': 'Mountain Ape', + // Member of the Blizzard Group + eyelashes: 'Eyelashes', + } + `, + output: dedent` + let heroAssociation = { + // Part: S-Class + blast: 'Blast', + // Atomic Samurai + kamikaze: 'Kamikaze', + tatsumaki: 'Tatsumaki', + // Part: A-Class + iaian: 'Iaian', + sweet: 'Sweet Mask', + // Part: B-Class + // Member of the Blizzard Group + eyelashes: 'Eyelashes', + 'mountain-ape': 'Mountain Ape', + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + 'partition-by-comment': 'Part**', + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'tatsumaki', + right: 'kamikaze', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'sweet', + right: 'iaian', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'mountain-ape', + right: 'eyelashes', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to use all comments as parts`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let brothers = { + // Older brother + edward: 'Edward Elric', + // Younger brother + alphonse: 'Alphonse Elric', + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + 'partition-by-comment': true, + }, + ], + }, + ], + invalid: [], + }) + }) + + it(`${RULE_NAME}(${type}): allows to use multiple partition comments`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + let psychoPass = { + /* Public Safety Bureau */ + // Crime Coefficient: Low + tsunemori: 'Akane Tsunemori', + // Crime Coefficient: High + kogami: 'Shinya Kogami', + ginoza: 'Nobuchika Ginoza', + masaoka: 'Tomomi Masaoka', + /* Victims */ + makishima: 'Shogo Makishima', + } + `, + output: dedent` + let psychoPass = { + /* Public Safety Bureau */ + // Crime Coefficient: Low + tsunemori: 'Akane Tsunemori', + // Crime Coefficient: High + ginoza: 'Nobuchika Ginoza', + kogami: 'Shinya Kogami', + masaoka: 'Tomomi Masaoka', + /* Victims */ + makishima: 'Shogo Makishima', + } + `, + options: [ + { + type: SortType.alphabetical, + order: SortOrder.asc, + 'partition-by-comment': [ + 'Public Safety Bureau', + 'Crime Coefficient: *', + 'Victims', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'kogami', + right: 'ginoza', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: sorting by natural order`, () => { @@ -1192,6 +1345,159 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to use partition comments`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + let heroAssociation = { + // Part: S-Class + blast: 'Blast', + tatsumaki: 'Tatsumaki', + // Atomic Samurai + kamikaze: 'Kamikaze', + // Part: A-Class + sweet: 'Sweet Mask', + iaian: 'Iaian', + // Part: B-Class + 'mountain-ape': 'Mountain Ape', + // Member of the Blizzard Group + eyelashes: 'Eyelashes', + } + `, + output: dedent` + let heroAssociation = { + // Part: S-Class + blast: 'Blast', + // Atomic Samurai + kamikaze: 'Kamikaze', + tatsumaki: 'Tatsumaki', + // Part: A-Class + iaian: 'Iaian', + sweet: 'Sweet Mask', + // Part: B-Class + // Member of the Blizzard Group + eyelashes: 'Eyelashes', + 'mountain-ape': 'Mountain Ape', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + 'partition-by-comment': 'Part**', + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'tatsumaki', + right: 'kamikaze', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'sweet', + right: 'iaian', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'mountain-ape', + right: 'eyelashes', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to use all comments as parts`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let brothers = { + // Older brother + edward: 'Edward Elric', + // Younger brother + alphonse: 'Alphonse Elric', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + 'partition-by-comment': true, + }, + ], + }, + ], + invalid: [], + }) + }) + + it(`${RULE_NAME}(${type}): allows to use multiple partition comments`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + let psychoPass = { + /* Public Safety Bureau */ + // Crime Coefficient: Low + tsunemori: 'Akane Tsunemori', + // Crime Coefficient: High + kogami: 'Shinya Kogami', + ginoza: 'Nobuchika Ginoza', + masaoka: 'Tomomi Masaoka', + /* Victims */ + makishima: 'Shogo Makishima', + } + `, + output: dedent` + let psychoPass = { + /* Public Safety Bureau */ + // Crime Coefficient: Low + tsunemori: 'Akane Tsunemori', + // Crime Coefficient: High + ginoza: 'Nobuchika Ginoza', + kogami: 'Shinya Kogami', + masaoka: 'Tomomi Masaoka', + /* Victims */ + makishima: 'Shogo Makishima', + } + `, + options: [ + { + type: SortType.natural, + order: SortOrder.asc, + 'partition-by-comment': [ + 'Public Safety Bureau', + 'Crime Coefficient: *', + 'Victims', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'kogami', + right: 'ginoza', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: sorting by line length`, () => { @@ -1791,6 +2097,145 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to use partition comments`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + let heroAssociation = { + // Part: S-Class + blast: 'Blast', + tatsumaki: 'Tatsumaki', + // Atomic Samurai + kamikaze: 'Kamikaze', + // Part: A-Class + sweet: 'Sweet Mask', + iaian: 'Iaian', + // Part: B-Class + 'mountain-ape': 'Mountain Ape', + // Member of the Blizzard Group + eyelashes: 'Eyelashes', + } + `, + output: dedent` + let heroAssociation = { + // Part: S-Class + tatsumaki: 'Tatsumaki', + // Atomic Samurai + kamikaze: 'Kamikaze', + blast: 'Blast', + // Part: A-Class + sweet: 'Sweet Mask', + iaian: 'Iaian', + // Part: B-Class + 'mountain-ape': 'Mountain Ape', + // Member of the Blizzard Group + eyelashes: 'Eyelashes', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + 'partition-by-comment': 'Part**', + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'blast', + right: 'tatsumaki', + }, + }, + ], + }, + ], + }) + }) + + it(`${RULE_NAME}(${type}): allows to use all comments as parts`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let brothers = { + // Older brother + edward: 'Edward Elric', + // Younger brother + alphonse: 'Alphonse Elric', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + 'partition-by-comment': true, + }, + ], + }, + ], + invalid: [], + }) + }) + + it(`${RULE_NAME}(${type}): allows to use multiple partition comments`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [], + invalid: [ + { + code: dedent` + let psychoPass = { + /* Public Safety Bureau */ + // Crime Coefficient: Low + tsunemori: 'Akane Tsunemori', + // Crime Coefficient: High + kogami: 'Shinya Kogami', + ginoza: 'Nobuchika Ginoza', + masaoka: 'Tomomi Masaoka', + /* Victims */ + makishima: 'Shogo Makishima', + } + `, + output: dedent` + let psychoPass = { + /* Public Safety Bureau */ + // Crime Coefficient: Low + tsunemori: 'Akane Tsunemori', + // Crime Coefficient: High + ginoza: 'Nobuchika Ginoza', + masaoka: 'Tomomi Masaoka', + kogami: 'Shinya Kogami', + /* Victims */ + makishima: 'Shogo Makishima', + } + `, + options: [ + { + type: SortType['line-length'], + order: SortOrder.desc, + 'partition-by-comment': [ + 'Public Safety Bureau', + 'Crime Coefficient: *', + 'Victims', + ], + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + left: 'kogami', + right: 'ginoza', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: misc`, () => { diff --git a/typings/index.ts b/typings/index.ts index 9592d839..9ee13914 100644 --- a/typings/index.ts +++ b/typings/index.ts @@ -11,6 +11,8 @@ export enum SortOrder { 'asc' = 'asc', } +export type PartitionComment = string[] | boolean | string + export interface SortingNode { dependencies?: string[] node: TSESTree.Node diff --git a/utils/get-node-range.ts b/utils/get-node-range.ts index 0a271090..1c4a9e85 100644 --- a/utils/get-node-range.ts +++ b/utils/get-node-range.ts @@ -1,13 +1,19 @@ -import type { TSESLint } from '@typescript-eslint/utils' import type { TSESTree } from '@typescript-eslint/types' +import type { TSESLint } from '@typescript-eslint/utils' import { ASTUtils } from '@typescript-eslint/utils' +import type { PartitionComment } from '../typings' + +import { isPartitionComment } from './is-partition-comment' import { getCommentBefore } from './get-comment-before' export let getNodeRange = ( node: TSESTree.Node, sourceCode: TSESLint.SourceCode, + additionalOptions?: { + partitionComment?: PartitionComment + }, ): TSESTree.Range => { let start = node.range.at(0)! let end = node.range.at(1)! @@ -42,7 +48,13 @@ export let getNodeRange = ( } } - if (comment) { + if ( + comment && + !isPartitionComment( + additionalOptions?.partitionComment ?? false, + comment.value, + ) + ) { start = comment.range.at(0)! } diff --git a/utils/is-partition-comment.ts b/utils/is-partition-comment.ts new file mode 100644 index 00000000..887387dc --- /dev/null +++ b/utils/is-partition-comment.ts @@ -0,0 +1,13 @@ +import { minimatch } from 'minimatch' + +import type { PartitionComment } from '../typings' + +export let isPartitionComment = ( + partitionComment: PartitionComment, + comment: string, +) => + (Array.isArray(partitionComment) && + partitionComment.some(pattern => minimatch(comment.trim(), pattern))) || + (typeof partitionComment === 'string' && + minimatch(comment.trim(), partitionComment)) || + partitionComment === true diff --git a/utils/make-fixes.ts b/utils/make-fixes.ts index 6494c48b..af71026a 100644 --- a/utils/make-fixes.ts +++ b/utils/make-fixes.ts @@ -1,7 +1,7 @@ import type { TSESTree } from '@typescript-eslint/types' import type { TSESLint } from '@typescript-eslint/utils' -import type { SortingNode } from '../typings' +import type { PartitionComment, SortingNode } from '../typings' import { getCommentAfter } from './get-comment-after' import { getNodeRange } from './get-node-range' @@ -11,14 +11,19 @@ export let makeFixes = ( nodes: SortingNode[], sortedNodes: SortingNode[], source: TSESLint.SourceCode, + additionalOptions?: { + partitionComment?: PartitionComment + }, ) => { let fixes: TSESLint.RuleFix[] = [] nodes.forEach(({ node }, index) => { fixes.push( fixer.replaceTextRange( - getNodeRange(node, source), - source.text.slice(...getNodeRange(sortedNodes[index].node, source)), + getNodeRange(node, source, additionalOptions), + source.text.slice( + ...getNodeRange(sortedNodes[index].node, source, additionalOptions), + ), ), )