diff --git a/docs/rules/sort-objects.md b/docs/rules/sort-objects.md index a9a4129d..fb4a39bc 100644 --- a/docs/rules/sort-objects.md +++ b/docs/rules/sort-objects.md @@ -71,6 +71,7 @@ interface Options { type?: 'alphabetical' | 'natural' | 'natural' order?: 'asc' | 'desc' 'ignore-case'?: boolean + 'always-on-top'?: string[] } ``` @@ -95,6 +96,12 @@ interface Options { Only affects alphabetical and natural sorting. When `true` the rule ignores the case-sensitivity of the order. +### always-on-top + +(default: `[]`) + +You can set a list of key names that will always go at the beginning of the object. For example: `['id', 'name']` + ## ⚙️ Usage ### Legacy Config diff --git a/index.ts b/index.ts index 491b91e2..6eb83a12 100644 --- a/index.ts +++ b/index.ts @@ -66,7 +66,12 @@ let createConfigWithOptions = (options: { [sortMapElementsName]: ['error'], [sortNamedExportsName]: ['error'], [sortNamedImportsName]: ['error'], - [sortObjectsName]: ['error'], + [sortObjectsName]: [ + 'error', + { + 'always-on-top': [], + }, + ], [sortUnionTypesName]: ['error'], } return { diff --git a/rules/sort-objects.ts b/rules/sort-objects.ts index a40d84dc..c4542ee1 100644 --- a/rules/sort-objects.ts +++ b/rules/sort-objects.ts @@ -10,12 +10,21 @@ import { sortNodes } from '../utils/sort-nodes' import { makeFixes } from '../utils/make-fixes' import { complete } from '../utils/complete' import { pairwise } from '../utils/pairwise' +import { groupBy } from '../utils/group-by' import { compare } from '../utils/compare' type MESSAGE_ID = 'unexpectedObjectsOrder' +export enum Position { + 'exception' = 'exception', + 'ignore' = 'ignore', +} + +type SortingNodeWithPosition = SortingNode & { position: Position } + type Options = [ Partial<{ + 'always-on-top': string[] 'ignore-case': boolean order: SortOrder type: SortType @@ -53,6 +62,10 @@ export default createEslintRule({ type: 'boolean', default: false, }, + 'always-on-top': { + type: 'array', + default: [], + }, }, additionalProperties: false, }, @@ -75,21 +88,23 @@ export default createEslintRule({ type: SortType.alphabetical, 'ignore-case': false, order: SortOrder.asc, + 'always-on-top': [], }) let source = context.getSourceCode() let formatProperties = ( props: TSESTree.ObjectLiteralElement[], - ): SortingNode[][] => + ): SortingNodeWithPosition[][] => props.reduce( - (accumulator: SortingNode[][], prop) => { + (accumulator: SortingNodeWithPosition[][], prop) => { if (prop.type === AST_NODE_TYPES.SpreadElement) { accumulator.push([]) return accumulator } let name: string + let position: Position = Position.ignore if (prop.key.type === AST_NODE_TYPES.Identifier) { ;({ name } = prop.key) @@ -99,9 +114,17 @@ export default createEslintRule({ name = source.text.slice(...prop.key.range) } + if ( + prop.key.type === AST_NODE_TYPES.Identifier && + options['always-on-top'].includes(prop.key.name) + ) { + position = Position.exception + } + let value = { size: rangeToDiff(prop.range), node: prop, + position, name, } @@ -114,7 +137,28 @@ export default createEslintRule({ formatProperties(node.properties).forEach(nodes => { pairwise(nodes, (first, second) => { - if (compare(first, second, options)) { + let comparison: boolean + + if ( + first.position === Position.exception && + second.position === Position.exception + ) { + comparison = + options['always-on-top'].indexOf(first.name) > + options['always-on-top'].indexOf(second.name) + } else if (first.position === second.position) { + comparison = compare(first, second, options) + } else { + let positionPower = { + [Position.exception]: 1, + [Position.ignore]: 0, + } + + comparison = + positionPower[first.position] < positionPower[second.position] + } + + if (comparison) { context.report({ messageId: 'unexpectedObjectsOrder', data: { @@ -122,8 +166,23 @@ export default createEslintRule({ second: second.name, }, node: second.node, - fix: fixer => - makeFixes(fixer, nodes, sortNodes(nodes, options), source), + fix: fixer => { + let groups = groupBy(nodes, ({ position }) => position) + + let getGroup = (index: string) => + index in groups ? groups[index] : [] + + let sortedNodes = [ + getGroup(Position.exception).sort( + (aNode, bNode) => + options['always-on-top'].indexOf(aNode.name) - + options['always-on-top'].indexOf(bNode.name), + ), + sortNodes(getGroup(Position.ignore), options), + ].flat() + + return makeFixes(fixer, nodes, sortedNodes, source) + }, }) } }) diff --git a/test/sort-objects.test.ts b/test/sort-objects.test.ts index ab7b6789..0eff3be4 100644 --- a/test/sort-objects.test.ts +++ b/test/sort-objects.test.ts @@ -293,6 +293,79 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to set priority keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let terrorInResonance = { + name: 'Terror in Resonance', + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + episodes: 11, + genres: ['drama', 'mystery', 'psychological', 'thriller'], + romaji: 'Zankyou no Terror', + studio: 'Mappa' + } + `, + options: [ + { + 'always-on-top': ['name', 'id'], + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let terrorInResonance = { + episodes: 11, + genres: ['drama', 'mystery', 'psychological', 'thriller'], + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + name: 'Terror in Resonance', + romaji: 'Zankyou no Terror', + studio: 'Mappa' + } + `, + output: dedent` + let terrorInResonance = { + name: 'Terror in Resonance', + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + episodes: 11, + genres: ['drama', 'mystery', 'psychological', 'thriller'], + romaji: 'Zankyou no Terror', + studio: 'Mappa' + } + `, + options: [ + { + 'always-on-top': ['name', 'id'], + type: SortType.alphabetical, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + first: 'genres', + second: 'id', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + first: 'id', + second: 'name', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: sorting by natural order`, () => { @@ -578,6 +651,79 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to set priority keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let terrorInResonance = { + name: 'Terror in Resonance', + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + episodes: 11, + genres: ['drama', 'mystery', 'psychological', 'thriller'], + romaji: 'Zankyou no Terror', + studio: 'Mappa' + } + `, + options: [ + { + 'always-on-top': ['name', 'id'], + type: SortType.natural, + order: SortOrder.asc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let terrorInResonance = { + episodes: 11, + genres: ['drama', 'mystery', 'psychological', 'thriller'], + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + name: 'Terror in Resonance', + romaji: 'Zankyou no Terror', + studio: 'Mappa' + } + `, + output: dedent` + let terrorInResonance = { + name: 'Terror in Resonance', + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + episodes: 11, + genres: ['drama', 'mystery', 'psychological', 'thriller'], + romaji: 'Zankyou no Terror', + studio: 'Mappa' + } + `, + options: [ + { + 'always-on-top': ['name', 'id'], + type: SortType.natural, + order: SortOrder.asc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + first: 'genres', + second: 'id', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + first: 'id', + second: 'name', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: sorting by line length`, () => { @@ -863,6 +1009,86 @@ describe(RULE_NAME, () => { ], }) }) + + it(`${RULE_NAME}(${type}): allows to set priority keys`, () => { + ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: dedent` + let terrorInResonance = { + name: 'Terror in Resonance', + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + genres: ['drama', 'mystery', 'psychological', 'thriller'], + romaji: 'Zankyou no Terror', + studio: 'Mappa', + episodes: 11, + } + `, + options: [ + { + 'always-on-top': ['name', 'id'], + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + }, + ], + invalid: [ + { + code: dedent` + let terrorInResonance = { + episodes: 11, + genres: ['drama', 'mystery', 'psychological', 'thriller'], + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + name: 'Terror in Resonance', + romaji: 'Zankyou no Terror', + studio: 'Mappa' + } + `, + output: dedent` + let terrorInResonance = { + name: 'Terror in Resonance', + id: 'de4d12c2-200c-49bf-a2c8-14f5b4576299', + genres: ['drama', 'mystery', 'psychological', 'thriller'], + romaji: 'Zankyou no Terror', + studio: 'Mappa', + episodes: 11 + } + `, + options: [ + { + 'always-on-top': ['name', 'id'], + type: SortType['line-length'], + order: SortOrder.desc, + }, + ], + errors: [ + { + messageId: 'unexpectedObjectsOrder', + data: { + first: 'episodes', + second: 'genres', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + first: 'genres', + second: 'id', + }, + }, + { + messageId: 'unexpectedObjectsOrder', + data: { + first: 'id', + second: 'name', + }, + }, + ], + }, + ], + }) + }) }) describe(`${RULE_NAME}: misc`, () => {