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),
+ ),
),
)