From e142c395dee04dc41c5d6507e6a513e274759a6a Mon Sep 17 00:00:00 2001
From: chirokas <157580465+chirokas@users.noreply.github.com>
Date: Sun, 31 Mar 2024 21:08:43 +0800
Subject: [PATCH] feat(sort-interfaces): add `optionalityOrder` option
---
docs/rules/sort-interfaces.md | 8 +
rules/sort-interfaces.ts | 139 ++++-
test/sort-interfaces.test.ts | 1101 ++++++++++++++++++++++++++++++++-
typings/index.ts | 6 +
utils/is-member-optional.ts | 13 +
5 files changed, 1230 insertions(+), 37 deletions(-)
create mode 100644 utils/is-member-optional.ts
diff --git a/docs/rules/sort-interfaces.md b/docs/rules/sort-interfaces.md
index 7075e940..c7719b16 100644
--- a/docs/rules/sort-interfaces.md
+++ b/docs/rules/sort-interfaces.md
@@ -100,6 +100,7 @@ type Group = 'multiline' | CustomGroup
interface Options {
type?: 'alphabetical' | 'natural' | 'line-length'
+ optionalityOrder?: 'ignore' | 'optional-first' | 'required-first'
order?: 'asc' | 'desc'
'ignore-case'?: boolean
groups?: (Group | Group[])[]
@@ -117,6 +118,13 @@ interface Options {
- `natural` - sort in natural order.
- `line-length` - sort by code line length.
+### optionalityOrder
+
+(default: `'ignore'`)
+
+- `optional-first` - put all optional members first.
+- `required-first` - put all required members first.
+
### order
(default: `'asc'`)
diff --git a/rules/sort-interfaces.ts b/rules/sort-interfaces.ts
index 881fbcce..5420d282 100644
--- a/rules/sort-interfaces.ts
+++ b/rules/sort-interfaces.ts
@@ -2,13 +2,14 @@ import { minimatch } from 'minimatch'
import type { SortingNode } from '../typings'
+import { OptionalityOrder, SortOrder, SortType } from '../typings'
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 { toSingleLine } from '../utils/to-single-line'
import { rangeToDiff } from '../utils/range-to-diff'
import { isPositive } from '../utils/is-positive'
-import { SortOrder, SortType } from '../typings'
import { useGroups } from '../utils/use-groups'
import { sortNodes } from '../utils/sort-nodes'
import { makeFixes } from '../utils/make-fixes'
@@ -23,6 +24,7 @@ type Group = 'multiline' | 'unknown' | T[number]
type Options = [
Partial<{
'custom-groups': { [key: string]: string[] | string }
+ optionalityOrder: OptionalityOrder
groups: (Group[] | Group)[]
'partition-by-new-line': boolean
'ignore-pattern': string[]
@@ -49,6 +51,15 @@ export default createEslintRule, MESSAGE_ID>({
'custom-groups': {
type: 'object',
},
+ optionalityOrder: {
+ enum: [
+ OptionalityOrder.ignore,
+ OptionalityOrder['optional-first'],
+ OptionalityOrder['required-first'],
+ ],
+ default: OptionalityOrder.ignore,
+ type: 'string',
+ },
type: {
enum: [
SortType.alphabetical,
@@ -100,6 +111,7 @@ export default createEslintRule, MESSAGE_ID>({
TSInterfaceDeclaration: node => {
if (node.body.body.length > 1) {
let options = complete(context.options.at(0), {
+ optionalityOrder: OptionalityOrder.ignore,
'partition-by-new-line': false,
type: SortType.alphabetical,
'ignore-case': false,
@@ -194,16 +206,81 @@ export default createEslintRule, MESSAGE_ID>({
[[]],
)
- for (let nodes of formattedMembers) {
- pairwise(nodes, (left, right) => {
- let leftNum = getGroupNumber(options.groups, left)
- let rightNum = getGroupNumber(options.groups, right)
+ let toSorted = (nodes: SortingNode[]) => {
+ let grouped: {
+ [key: string]: SortingNode[]
+ } = {}
- if (
- leftNum > rightNum ||
- (leftNum === rightNum &&
- isPositive(compare(left, right, options)))
- ) {
+ for (let currentNode of nodes) {
+ let groupNum = getGroupNumber(options.groups, currentNode)
+
+ if (!(groupNum in grouped)) {
+ grouped[groupNum] = [currentNode]
+ } else {
+ grouped[groupNum] = sortNodes(
+ [...grouped[groupNum], currentNode],
+ options,
+ )
+ }
+ }
+
+ let sortedNodes: SortingNode[] = []
+
+ for (let group of Object.keys(grouped).sort(
+ (a, b) => Number(a) - Number(b),
+ )) {
+ sortedNodes.push(...sortNodes(grouped[group], options))
+ }
+
+ return sortedNodes
+ }
+
+ let checkGroupSort = (left: SortingNode, right: SortingNode) => {
+ let leftNum = getGroupNumber(options.groups, left)
+ let rightNum = getGroupNumber(options.groups, right)
+
+ return (
+ leftNum > rightNum ||
+ (leftNum === rightNum &&
+ isPositive(compare(left, right, options)))
+ )
+ }
+
+ let checkOrder = (
+ members: SortingNode[],
+ left: SortingNode,
+ right: SortingNode,
+ iteration: number,
+ ) => {
+ if (options.optionalityOrder === OptionalityOrder.ignore) {
+ return checkGroupSort(left, right)
+ }
+
+ let switchIndex = members.findIndex(
+ (_, i) =>
+ i &&
+ isMemberOptional(members[i - 1].node) !==
+ isMemberOptional(members[i].node),
+ )
+
+ if (iteration < switchIndex && iteration + 1 !== switchIndex) {
+ return checkGroupSort(left, right)
+ }
+
+ if (isMemberOptional(left.node) !== isMemberOptional(right.node)) {
+ return (
+ isMemberOptional(left.node) !==
+ (options.optionalityOrder ===
+ OptionalityOrder['optional-first'])
+ )
+ }
+
+ return checkGroupSort(left, right)
+ }
+
+ for (let nodes of formattedMembers) {
+ pairwise(nodes, (left, right, iteration) => {
+ if (checkOrder(nodes, left, right, iteration)) {
context.report({
messageId: 'unexpectedInterfacePropertiesOrder',
data: {
@@ -212,29 +289,29 @@ export default createEslintRule, MESSAGE_ID>({
},
node: right.node,
fix: fixer => {
- let grouped: {
- [key: string]: SortingNode[]
- } = {}
-
- for (let currentNode of nodes) {
- let groupNum = getGroupNumber(options.groups, currentNode)
-
- if (!(groupNum in grouped)) {
- grouped[groupNum] = [currentNode]
- } else {
- grouped[groupNum] = sortNodes(
- [...grouped[groupNum], currentNode],
- options,
- )
- }
- }
+ let sortedNodes
- let sortedNodes: SortingNode[] = []
+ if (options.optionalityOrder !== OptionalityOrder.ignore) {
+ let optionalNodes = nodes.filter(member =>
+ isMemberOptional(member.node),
+ )
+ let requiredNodes = nodes.filter(
+ member => !isMemberOptional(member.node),
+ )
- for (let group of Object.keys(grouped).sort(
- (a, b) => Number(a) - Number(b),
- )) {
- sortedNodes.push(...sortNodes(grouped[group], options))
+ sortedNodes =
+ options.optionalityOrder ===
+ OptionalityOrder['optional-first']
+ ? [
+ ...toSorted(optionalNodes),
+ ...toSorted(requiredNodes),
+ ]
+ : [
+ ...toSorted(requiredNodes),
+ ...toSorted(optionalNodes),
+ ]
+ } else {
+ sortedNodes = toSorted(nodes)
}
return makeFixes(
diff --git a/test/sort-interfaces.test.ts b/test/sort-interfaces.test.ts
index ed031a89..64cecaa9 100644
--- a/test/sort-interfaces.test.ts
+++ b/test/sort-interfaces.test.ts
@@ -2,8 +2,8 @@ import { RuleTester } from '@typescript-eslint/rule-tester'
import { afterAll, describe, it } from 'vitest'
import { dedent } from 'ts-dedent'
+import { OptionalityOrder, SortOrder, SortType } from '../typings'
import rule, { RULE_NAME } from '../rules/sort-interfaces'
-import { SortOrder, SortType } from '../typings'
describe(RULE_NAME, () => {
RuleTester.describeSkip = describe.skip
@@ -667,6 +667,369 @@ describe(RULE_NAME, () => {
],
},
)
+
+ describe(`${RULE_NAME}(${type}): sorting optional members first`, () => {
+ ruleTester.run('sorts interface properties', rule, {
+ valid: [
+ {
+ code: dedent`
+ interface X {
+ a?: string
+ [index: number]: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ onClick?(): void
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ label: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'label',
+ right: 'primary',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'size',
+ right: 'onClick?()',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to set groups for sorting', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ label: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ 'custom-groups': {
+ callback: 'on*',
+ },
+ groups: ['unknown', 'callback'],
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'label',
+ right: 'primary',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to use new line as partition', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface User {
+ email: string
+ firstName?: string
+ id: number
+ lastName?: string
+ password: string
+ username: string
+
+ biography?: string
+ avatarUrl?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ output: dedent`
+ interface User {
+ firstName?: string
+ lastName?: string
+ email: string
+ id: number
+ password: string
+ username: string
+
+ avatarUrl?: string
+ biography?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ 'partition-by-new-line': true,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'email',
+ right: 'firstName',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'id',
+ right: 'lastName',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'biography',
+ right: 'avatarUrl',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ describe(`${RULE_NAME}(${type}): sorting required members first`, () => {
+ ruleTester.run('sorts interface properties', rule, {
+ valid: [
+ {
+ code: dedent`
+ interface X {
+ [index: number]: string
+ a?: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ label: string
+ backgroundColor?: string
+ onClick?(): void
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'backgroundColor',
+ right: 'label',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'size',
+ right: 'onClick?()',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to set groups for sorting', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ label: string
+ backgroundColor?: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ options: [
+ {
+ ...options,
+ 'custom-groups': {
+ callback: 'on*',
+ },
+ groups: ['unknown', 'callback'],
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'backgroundColor',
+ right: 'label',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to use new line as partition', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface User {
+ email: string
+ firstName?: string
+ id: number
+ lastName?: string
+ password: string
+ username: string
+
+ biography?: string
+ avatarUrl?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ output: dedent`
+ interface User {
+ email: string
+ id: number
+ password: string
+ username: string
+ firstName?: string
+ lastName?: string
+
+ createdAt: Date
+ updatedAt: Date
+ avatarUrl?: string
+ biography?: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ 'partition-by-new-line': true,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'firstName',
+ right: 'id',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'lastName',
+ right: 'password',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'biography',
+ right: 'avatarUrl',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'avatarUrl',
+ right: 'createdAt',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
})
describe(`${RULE_NAME}: sorting by natural order`, () => {
@@ -1319,13 +1682,376 @@ describe(RULE_NAME, () => {
],
},
)
- })
- describe(`${RULE_NAME}: sorting by line length`, () => {
- let type = 'line-length-order'
+ describe(`${RULE_NAME}(${type}): sorting optional members first`, () => {
+ ruleTester.run('sorts interface properties', rule, {
+ valid: [
+ {
+ code: dedent`
+ interface X {
+ a?: string
+ [index: number]: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ onClick?(): void
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ label: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'label',
+ right: 'primary',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'size',
+ right: 'onClick?()',
+ },
+ },
+ ],
+ },
+ ],
+ })
- let options = {
- type: SortType['line-length'],
+ ruleTester.run('allows to set groups for sorting', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ label: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ 'custom-groups': {
+ callback: 'on*',
+ },
+ groups: ['unknown', 'callback'],
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'label',
+ right: 'primary',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to use new line as partition', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface User {
+ email: string
+ firstName?: string
+ id: number
+ lastName?: string
+ password: string
+ username: string
+
+ biography?: string
+ avatarUrl?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ output: dedent`
+ interface User {
+ firstName?: string
+ lastName?: string
+ email: string
+ id: number
+ password: string
+ username: string
+
+ avatarUrl?: string
+ biography?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ 'partition-by-new-line': true,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'email',
+ right: 'firstName',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'id',
+ right: 'lastName',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'biography',
+ right: 'avatarUrl',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ describe(`${RULE_NAME}(${type}): sorting required members first`, () => {
+ ruleTester.run('sorts interface properties', rule, {
+ valid: [
+ {
+ code: dedent`
+ interface X {
+ [index: number]: string
+ a?: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ label: string
+ backgroundColor?: string
+ onClick?(): void
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'backgroundColor',
+ right: 'label',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'size',
+ right: 'onClick?()',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to set groups for sorting', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ label: string
+ backgroundColor?: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ options: [
+ {
+ ...options,
+ 'custom-groups': {
+ callback: 'on*',
+ },
+ groups: ['unknown', 'callback'],
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'backgroundColor',
+ right: 'label',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to use new line as partition', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface User {
+ email: string
+ firstName?: string
+ id: number
+ lastName?: string
+ password: string
+ username: string
+
+ biography?: string
+ avatarUrl?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ output: dedent`
+ interface User {
+ email: string
+ id: number
+ password: string
+ username: string
+ firstName?: string
+ lastName?: string
+
+ createdAt: Date
+ updatedAt: Date
+ avatarUrl?: string
+ biography?: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ 'partition-by-new-line': true,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'firstName',
+ right: 'id',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'lastName',
+ right: 'password',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'biography',
+ right: 'avatarUrl',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'avatarUrl',
+ right: 'createdAt',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+ })
+
+ describe(`${RULE_NAME}: sorting by line length`, () => {
+ let type = 'line-length-order'
+
+ let options = {
+ type: SortType['line-length'],
order: SortOrder.desc,
}
@@ -1933,6 +2659,369 @@ describe(RULE_NAME, () => {
],
},
)
+
+ describe(`${RULE_NAME}(${type}): sorting optional members first`, () => {
+ ruleTester.run('sorts interface properties', rule, {
+ valid: [
+ {
+ code: dedent`
+ interface X {
+ a?: string
+ [index: number]: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ size?: 'large' | 'medium' | 'small'
+ backgroundColor?: string
+ primary?: boolean
+ onClick?(): void
+ label: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'label',
+ right: 'primary',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'primary',
+ right: 'size',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to set groups for sorting', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ size?: 'large' | 'medium' | 'small'
+ backgroundColor?: string
+ primary?: boolean
+ onClick?(): void
+ label: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ 'custom-groups': {
+ callback: 'on*',
+ },
+ groups: ['unknown', 'callback'],
+ optionalityOrder: OptionalityOrder['optional-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'label',
+ right: 'primary',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'primary',
+ right: 'size',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to use new line as partition', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface User {
+ email: string
+ firstName?: string
+ id: number
+ lastName?: string
+ password: string
+ username: string
+
+ biography?: string
+ avatarUrl?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ output: dedent`
+ interface User {
+ firstName?: string
+ lastName?: string
+ password: string
+ username: string
+ email: string
+ id: number
+
+ biography?: string
+ avatarUrl?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['optional-first'],
+ 'partition-by-new-line': true,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'email',
+ right: 'firstName',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'id',
+ right: 'lastName',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
+
+ describe(`${RULE_NAME}(${type}): sorting required members first`, () => {
+ ruleTester.run('sorts interface properties', rule, {
+ valid: [
+ {
+ code: dedent`
+ interface X {
+ [index: number]: string
+ a?: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ },
+ ],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ label: string
+ size?: 'large' | 'medium' | 'small'
+ backgroundColor?: string
+ primary?: boolean
+ onClick?(): void
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'backgroundColor',
+ right: 'label',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'primary',
+ right: 'size',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to set groups for sorting', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface ButtonProps {
+ backgroundColor?: string
+ label: string
+ primary?: boolean
+ size?: 'large' | 'medium' | 'small'
+ onClick?(): void
+ }
+ `,
+ output: dedent`
+ interface ButtonProps {
+ label: string
+ size?: 'large' | 'medium' | 'small'
+ backgroundColor?: string
+ primary?: boolean
+ onClick?(): void
+ }
+ `,
+ options: [
+ {
+ ...options,
+ 'custom-groups': {
+ callback: 'on*',
+ },
+ groups: ['unknown', 'callback'],
+ optionalityOrder: OptionalityOrder['required-first'],
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'backgroundColor',
+ right: 'label',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'primary',
+ right: 'size',
+ },
+ },
+ ],
+ },
+ ],
+ })
+
+ ruleTester.run('allows to use new line as partition', rule, {
+ valid: [],
+ invalid: [
+ {
+ code: dedent`
+ interface User {
+ email: string
+ firstName?: string
+ id: number
+ lastName?: string
+ password: string
+ username: string
+
+ biography?: string
+ avatarUrl?: string
+ createdAt: Date
+ updatedAt: Date
+ }
+ `,
+ output: dedent`
+ interface User {
+ password: string
+ username: string
+ email: string
+ id: number
+ firstName?: string
+ lastName?: string
+
+ createdAt: Date
+ updatedAt: Date
+ biography?: string
+ avatarUrl?: string
+ }
+ `,
+ options: [
+ {
+ ...options,
+ optionalityOrder: OptionalityOrder['required-first'],
+ 'partition-by-new-line': true,
+ },
+ ],
+ errors: [
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'firstName',
+ right: 'id',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'lastName',
+ right: 'password',
+ },
+ },
+ {
+ messageId: 'unexpectedInterfacePropertiesOrder',
+ data: {
+ left: 'avatarUrl',
+ right: 'createdAt',
+ },
+ },
+ ],
+ },
+ ],
+ })
+ })
})
describe(`${RULE_NAME}: misc`, () => {
diff --git a/typings/index.ts b/typings/index.ts
index 5787d2af..76349314 100644
--- a/typings/index.ts
+++ b/typings/index.ts
@@ -17,6 +17,12 @@ export enum GroupKind {
'mixed' = 'mixed',
}
+export enum OptionalityOrder {
+ 'optional-first' = 'optional-first',
+ 'required-first' = 'required-first',
+ 'ignore' = 'ignore',
+}
+
export type PartitionComment = string[] | boolean | string
export interface SortingNode {
diff --git a/utils/is-member-optional.ts b/utils/is-member-optional.ts
new file mode 100644
index 00000000..25dc5f8e
--- /dev/null
+++ b/utils/is-member-optional.ts
@@ -0,0 +1,13 @@
+import type { TSESTree } from '@typescript-eslint/types'
+
+import { AST_NODE_TYPES } from '@typescript-eslint/types'
+
+export let isMemberOptional = (node: TSESTree.Node): boolean => {
+ switch (node.type) {
+ case AST_NODE_TYPES.TSMethodSignature:
+ case AST_NODE_TYPES.TSPropertySignature:
+ return node.optional
+ }
+
+ return false
+}