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 +}