diff --git a/docs/content/rules/sort-objects.mdx b/docs/content/rules/sort-objects.mdx index 7165f45bf..cc13033e8 100644 --- a/docs/content/rules/sort-objects.mdx +++ b/docs/content/rules/sort-objects.mdx @@ -363,17 +363,9 @@ Example configuration: default: `[]` -Allows you to specify a list of object keys groups for sorting. Groups help organize object keys into categories, making your objects more readable and maintainable. +Allows you to specify a list of property groups for sorting. Groups help organize properties into categories, making your objects more readable and maintainable. -Predefined groups: - -- `'multiline'` — Properties with multiline definitions, such as methods or complex type declarations. -- `'method'` - Members that are methods. -- `'unknown'` — Properties that don’t fit into any group specified in the `groups` option. - -If the `unknown` group is not specified in the `groups` option, it will automatically be added to the end of the list. - -Each object member will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found). +Each property will be assigned a single group specified in the `groups` option (or the `unknown` group if no match is found). The order of items in the `groups` option determines how groups are ordered. Within a given group, members will be sorted according to the `type`, `order`, `ignoreCase`, etc. options. @@ -381,39 +373,181 @@ Within a given group, members will be sorted according to the `type`, `order`, ` Individual groups can be combined together by placing them in an array. The order of groups in that array does not matter. All members of the groups in the array will be sorted together as if they were part of a single group. +Predefined groups are characterized by a single selector and potentially multiple modifiers. You may enter modifiers in any order, but the selector must always come at the end. + +#### Example + +```ts +let user = { + firstName: "John", // unknown + lastName: "Doe", // unknown + username: "johndoe", // unknown + job: { // multiline-member + // Stuff about job + }, + localization: { // multiline-member + // Stuff about localization + } +} +``` + +`groups` option configuration: + +```js +{ + groups: [ + 'unknown', + 'method', + 'multiline-member', + ] +} + +``` + +#### Methods + +- Selectors: `method`, `member`. +- Modifiers: `multiline`. +- Example: `multiline-method`, `method`, `member`. + +#### Properties + +- Selectors: `property`, `member`. +- Modifiers: `multiline`. +- Example: `multiline-property`, `property`, `member`. + +##### The `unknown` group + +Members that don’t fit into any group specified in the `groups` option will be placed in the `unknown` group. If the `unknown` group is not specified in the `groups` option, +it will automatically be added to the end of the list. + +##### Behavior when multiple groups match an element + +The lists of modifiers above are sorted by importance, from most to least important. +In case of multiple groups matching an element, the following rules will be applied: + +1. The group with the most modifiers matching will be selected. +2. If modifiers quantity is the same, order will be chosen based on modifier importance as listed above. + +Example : + +```ts +interface Test { + multilineMethod: () => { + property: string; + } +} +``` + +`optionalMethod` can be matched by the following groups, from most to least important: +- `multiline-method`. +- `method`. +- `multiline-member`. +- `member`. +- `unknown`. + ### customGroups + +Support for the object-based `customGroups` option is deprecated. + +Migrating from the old to the current API is easy: + +Old API: +```ts +{ + "key1": "value1", + "key2": "value2" +} +``` + +Current API: +```ts +[ + { + "groupName": "key1", + "elementNamePattern": "value1" + }, + { + "groupName": "key2", + "elementNamePattern": "value2" + } +] +``` + + type: `{ [groupName: string]: string | string[] }` -default: `{}` +default: `[]` -You can define your own groups and use regexp pattern to match specific object keys. +You can define your own groups and use regexp patterns to match specific object keys. -Each key of `customGroups` represents a group name which you can then use in the `groups` option. The value for each key can either be of type: -- `string` — An object attribute's name matching the value will be marked as part of the group referenced by the key. -- `string[]` — An object attribute's name matching any of the values of the array will be marked as part of the group referenced by the key. -The order of values in the array does not matter. +A custom group definition may follow one of the two following interfaces: -Custom group matching takes precedence over predefined group matching. +```ts +interface CustomGroupDefinition { + groupName: string + type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' + order?: 'asc' | 'desc' + selector?: string + modifiers?: string[] + elementNamePattern?: string +} + +``` +An object will match a `CustomGroupDefinition` group if it matches all the filters of the custom group's definition. + +or: + +```ts +interface CustomGroupAnyOfDefinition { + groupName: string + type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' + order?: 'asc' | 'desc' + anyOf: Array<{ + selector?: string + modifiers?: string[] + elementNamePattern?: string + }> +} +``` + +An object will match a `CustomGroupAnyOfDefinition` group if it matches all the filters of at least one of the `anyOf` items. + +#### Attributes + +- `groupName`: The group's name, which needs to be put in the `groups` option. +- `selector`: Filter on the `selector` of the element. +- `modifiers`: Filter on the `modifiers` of the element. (All the modifiers of the element must be present in that list) +- `elementNamePattern`: If entered, will check that the name of the element matches the pattern entered. +- `type`: Overrides the sort type for that custom group. `unsorted` will not sort the group. +- `order`: Overrides the sort order for that custom group +- `newlinesInside`: Enforces a specific newline behavior between elements of the group. + +#### Match importance + +The `customGroups` list is ordered: +The first custom group definition that matches an element will be used. + +Custom groups have a higher priority than any predefined group. #### Example -Put all properties starting with `id` and `name` at the top, put metadata at the bottom. -Regroup multiline and in the middle, above unknown-matched properties. +Put all properties starting with `id` and `name` at the top, combine and sort metadata and optional multiline properties at the bottom. +Anything else is put in the middle. ```ts -const user = { - id: 'id', // top - name: 'John', // top - getEmail: () => null, // method - localization: { // multiline +let user = { + id: "id", // top + name: "John", // top + age: 42, // unknown + isAdmin: true, // unknown + lastUpdated_metadata: null, // bottom + localization: { // optional-multiline-member // Stuff about localization }, - age: 40, // unknown - isAdmin: false, // unknown - lastUpdated_metadata: null, // bottom - version_metadata: '1' // bottom + version_metadata: "1" // bottom } ``` @@ -422,15 +556,22 @@ const user = { ```js { groups: [ -+ 'top', // [!code ++] - ['multiline', 'method'], // [!code ++] - ['unknown'], // [!code ++] - 'bottom' // [!code ++] ++ 'top', // [!code ++] + 'unknown', ++ ['optional-multiline-member', 'bottom'] // [!code ++] ], -+ customGroups: { // [!code ++] -+ top: ['^id$', '^name$'] // [!code ++] -+ bottom: '.+_metadata$' // [!code ++] -+ } // [!code ++] ++ customGroups: [ // [!code ++] ++ { // [!code ++] ++ groupName: 'top', // [!code ++] ++ selector: 'property', // [!code ++] ++ elementNamePattern: '^(?:id|name)$', // [!code ++] ++ }, // [!code ++] ++ { // [!code ++] ++ groupName: 'bottom', // [!code ++] ++ selector: 'property', // [!code ++] ++ elementNamePattern: '.+_metadata$', // [!code ++] ++ } // [!code ++] ++ ] // [!code ++] } ``` @@ -465,7 +606,7 @@ const user = { ignorePattern: [], useConfigurationIf: {}, groups: [], - customGroups: {}, + customGroups: [], }, ], }, @@ -499,7 +640,7 @@ const user = { ignorePattern: [], useConfigurationIf: {}, groups: [], - customGroups: {}, + customGroups: [], }, ], }, diff --git a/rules/sort-objects.ts b/rules/sort-objects.ts index 910adf55b..71aeb7cc0 100644 --- a/rules/sort-objects.ts +++ b/rules/sort-objects.ts @@ -1,6 +1,7 @@ import { TSESTree } from '@typescript-eslint/types' import type { SortingNodeWithDependencies } from '../utils/sort-nodes-by-dependencies' +import type { Modifier, Selector } from './sort-objects/types' import type { Options } from './sort-objects/types' import { @@ -22,15 +23,18 @@ import { sortNodesByDependencies, } from '../utils/sort-nodes-by-dependencies' import { validateNewlinesAndPartitionConfiguration } from '../utils/validate-newlines-and-partition-configuration' +import { validateGeneratedGroupsConfiguration } from '../utils/validate-generated-groups-configuration' import { validateCustomSortConfiguration } from '../utils/validate-custom-sort-configuration' import { getFirstNodeParentWithType } from './sort-objects/get-first-node-parent-with-type' -import { validateGroupsConfiguration } from '../utils/validate-groups-configuration' import { getMatchingContextOptions } from '../utils/get-matching-context-options' +import { generatePredefinedGroups } from '../utils/generate-predefined-groups' +import { doesCustomGroupMatch } from './sort-objects/does-custom-group-match' import { getEslintDisabledLines } from '../utils/get-eslint-disabled-lines' import { isNodeEslintDisabled } from '../utils/is-node-eslint-disabled' import { hasPartitionComment } from '../utils/has-partition-comment' import { createNodeIndexMap } from '../utils/create-node-index-map' import { sortNodesByGroups } from '../utils/sort-nodes-by-groups' +import { allModifiers, allSelectors } from './sort-objects/types' import { getCommentsBefore } from '../utils/get-comments-before' import { makeNewlinesFixes } from '../utils/make-newlines-fixes' import { getNewlinesErrors } from '../utils/get-newlines-errors' @@ -48,6 +52,11 @@ import { complete } from '../utils/complete' import { pairwise } from '../utils/pairwise' import { matches } from '../utils/matches' +/** + * Cache computed groups by modifiers and selectors for performance + */ +let cachedGroupsByModifiersAndSelectors = new Map() + type MESSAGE_ID = | 'missedSpacingBetweenObjectMembers' | 'unexpectedObjectsDependencyOrder' @@ -126,11 +135,12 @@ export default createEslintRule({ type, } validateCustomSortConfiguration(options) - validateGroupsConfiguration( - options.groups, - ['multiline', 'method', 'unknown'], - Object.keys(options.customGroups), - ) + validateGeneratedGroupsConfiguration({ + customGroups: options.customGroups, + selectors: allSelectors, + modifiers: allModifiers, + groups: options.groups, + }) validateNewlinesAndPartitionConfiguration(options) let isDestructuredObject = nodeObject.type === 'ObjectPattern' @@ -304,6 +314,9 @@ export default createEslintRule({ let { setCustomGroups, defineGroup, getGroup } = useGroups(options) + let selectors: Selector[] = [] + let modifiers: Modifier[] = [] + if (property.key.type === 'Identifier') { ;({ name } = property.key) } else if (property.key.type === 'Literal') { @@ -316,17 +329,56 @@ export default createEslintRule({ dependencies = extractDependencies(property.value) } - setCustomGroups(options.customGroups, name) - if ( property.value.type === 'ArrowFunctionExpression' || property.value.type === 'FunctionExpression' ) { - defineGroup('method') + selectors.push('method') + } else { + selectors.push('property') } + selectors.push('member') + if (property.loc.start.line !== property.loc.end.line) { - defineGroup('multiline') + modifiers.push('multiline') + selectors.push('multiline') + } + + let predefinedGroups = generatePredefinedGroups({ + cache: cachedGroupsByModifiersAndSelectors, + selectors, + modifiers, + }) + + for (let predefinedGroup of predefinedGroups) { + defineGroup(predefinedGroup) + } + + if (Array.isArray(options.customGroups)) { + for (let customGroup of options.customGroups) { + if ( + doesCustomGroupMatch({ + elementName: name, + customGroup, + selectors, + modifiers, + }) + ) { + defineGroup(customGroup.groupName, true) + /** + * If the custom group is not referenced in the `groups` option, it + * will be ignored + */ + if (getGroup() === customGroup.groupName) { + break + } + } + } + } else { + setCustomGroups(options.customGroups, name, { + override: true, + }) } let propertySortingNode: SortingNodeWithDependencies = { diff --git a/rules/sort-objects/does-custom-group-match.ts b/rules/sort-objects/does-custom-group-match.ts new file mode 100644 index 000000000..063b5d9eb --- /dev/null +++ b/rules/sort-objects/does-custom-group-match.ts @@ -0,0 +1,62 @@ +import type { + SingleCustomGroup, + AnyOfCustomGroup, + Modifier, + Selector, +} from './types' + +import { matches } from '../../utils/matches' + +interface DoesCustomGroupMatchProps { + customGroup: SingleCustomGroup | AnyOfCustomGroup + selectors: Selector[] + modifiers: Modifier[] + elementName: string +} + +/** + * Determines whether a custom group matches the given properties. + * @param {DoesCustomGroupMatchProps} props - The properties to compare with the + * custom group, including selectors, modifiers, and element name. + * @returns {boolean} `true` if the custom group matches the properties; + * otherwise, `false`. + */ +export let doesCustomGroupMatch = ( + props: DoesCustomGroupMatchProps, +): boolean => { + if ('anyOf' in props.customGroup) { + // At least one subgroup must match. + return props.customGroup.anyOf.some(subgroup => + doesCustomGroupMatch({ ...props, customGroup: subgroup }), + ) + } + if ( + props.customGroup.selector && + !props.selectors.includes(props.customGroup.selector) + ) { + return false + } + + if (props.customGroup.modifiers) { + for (let modifier of props.customGroup.modifiers) { + if (!props.modifiers.includes(modifier)) { + return false + } + } + } + + if ( + 'elementNamePattern' in props.customGroup && + props.customGroup.elementNamePattern + ) { + let matchesElementNamePattern: boolean = matches( + props.elementName, + props.customGroup.elementNamePattern, + ) + if (!matchesElementNamePattern) { + return false + } + } + + return true +} diff --git a/rules/sort-objects/types.ts b/rules/sort-objects/types.ts index b5172d2af..806014d38 100644 --- a/rules/sort-objects/types.ts +++ b/rules/sort-objects/types.ts @@ -1,3 +1,13 @@ +import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema' + +import type { JoinWithDash } from '../../types/join-with-dash' + +import { + buildCustomGroupModifiersJsonSchema, + buildCustomGroupSelectorJsonSchema, + elementNamePatternJsonSchema, +} from '../../utils/common-json-schemas' + export type Options = Partial<{ partitionByComment: | { @@ -17,8 +27,8 @@ export type Options = Partial<{ | Group )[] type: 'alphabetical' | 'line-length' | 'unsorted' | 'natural' | 'custom' + customGroups: Record | CustomGroup[] destructuredObjects: { groups: boolean } | boolean - customGroups: Record newlinesBetween: 'ignore' | 'always' | 'never' specialCharacters: 'remove' | 'trim' | 'keep' locales: NonNullable @@ -35,4 +45,121 @@ export type Options = Partial<{ alphabet: string }>[] -type Group = 'multiline' | 'unknown' | 'method' | string +export type SingleCustomGroup = ( + | BaseSingleCustomGroup + | BaseSingleCustomGroup + | BaseSingleCustomGroup + | BaseSingleCustomGroup +) & + ElementNamePatternFilterCustomGroup + +export type Selector = + | MultilineSelector + | PropertySelector + | MemberSelector + | MethodSelector + +export type Modifier = MultilineModifier | RequiredModifier | OptionalModifier + +export interface AnyOfCustomGroup { + anyOf: SingleCustomGroup[] +} + +/** + * Only used in code as well + */ +interface AllowedModifiersPerSelector { + property: MultilineModifier | OptionalModifier | RequiredModifier + member: MultilineModifier | OptionalModifier | RequiredModifier + method: MultilineModifier | OptionalModifier | RequiredModifier + multiline: OptionalModifier | RequiredModifier + 'index-signature': never +} + +type CustomGroup = ( + | { + order?: Options[0]['order'] + type?: Options[0]['type'] + } + | { + type?: 'unsorted' + } +) & { + newlinesInside?: 'always' | 'never' + groupName: string +} & (SingleCustomGroup | AnyOfCustomGroup) + +interface BaseSingleCustomGroup { + modifiers?: AllowedModifiersPerSelector[T][] + selector?: T +} + +type PropertyGroup = JoinWithDash< + [OptionalModifier, RequiredModifier, MultilineModifier, PropertySelector] +> + +type MemberGroup = JoinWithDash< + [OptionalModifier, RequiredModifier, MultilineModifier, MemberSelector] +> + +type MethodGroup = JoinWithDash< + [OptionalModifier, RequiredModifier, MultilineModifier, MethodSelector] +> + +/** + * Only used in code, so I don't know if it's worth maintaining this. + */ +type Group = + | MultilineGroup + | PropertyGroup + | MethodGroup + | MemberGroup + | 'unknown' + | string + +/** + * @deprecated For {@link `MultilineModifier`} + */ +type MultilineGroup = JoinWithDash< + [OptionalModifier, RequiredModifier, MultilineSelector] +> + +interface ElementNamePatternFilterCustomGroup { + elementNamePattern?: string +} + +/** + * @deprecated For {@link `MultilineModifier`} + */ +type MultilineSelector = 'multiline' + +type MultilineModifier = 'multiline' + +type RequiredModifier = 'required' + +type OptionalModifier = 'optional' + +type PropertySelector = 'property' + +type MemberSelector = 'member' + +type MethodSelector = 'method' + +export let allSelectors: Selector[] = [ + 'member', + 'method', + 'multiline', + 'property', +] + +export let allModifiers: Modifier[] = ['optional', 'required', 'multiline'] + +/** + * Ideally, we should generate as many schemas as there are selectors, and ensure + * that users do not enter invalid modifiers for a given selector + */ +export let singleCustomGroupJsonSchema: Record = { + modifiers: buildCustomGroupModifiersJsonSchema(allModifiers), + selector: buildCustomGroupSelectorJsonSchema(allSelectors), + elementNamePattern: elementNamePatternJsonSchema, +}