Skip to content

Commit

Permalink
feat: implements array-based custom groups option
Browse files Browse the repository at this point in the history
  • Loading branch information
hugop95 committed Jan 3, 2025
1 parent cfa2176 commit fc73e4b
Show file tree
Hide file tree
Showing 4 changed files with 432 additions and 50 deletions.
217 changes: 179 additions & 38 deletions docs/content/rules/sort-objects.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -363,57 +363,191 @@ Example configuration:
</sub>
<sub>default: `[]`</sub>

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.

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

<Important title="Migrating from the old API">
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"
}
]
```
</Important>

<sub>
type: `{ [groupName: string]: string | string[] }`
</sub>
<sub>default: `{}`</sub>
<sub>default: `[]`</sub>

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

Expand All @@ -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 ++]
}
```

Expand Down Expand Up @@ -465,7 +606,7 @@ const user = {
ignorePattern: [],
useConfigurationIf: {},
groups: [],
customGroups: {},
customGroups: [],
},
],
},
Expand Down Expand Up @@ -499,7 +640,7 @@ const user = {
ignorePattern: [],
useConfigurationIf: {},
groups: [],
customGroups: {},
customGroups: [],
},
],
},
Expand Down
72 changes: 62 additions & 10 deletions rules/sort-objects.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'
Expand All @@ -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<string, string[]>()

type MESSAGE_ID =
| 'missedSpacingBetweenObjectMembers'
| 'unexpectedObjectsDependencyOrder'
Expand Down Expand Up @@ -126,11 +135,12 @@ export default createEslintRule<Options, MESSAGE_ID>({
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'
Expand Down Expand Up @@ -304,6 +314,9 @@ export default createEslintRule<Options, MESSAGE_ID>({

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') {
Expand All @@ -316,17 +329,56 @@ export default createEslintRule<Options, MESSAGE_ID>({
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 = {
Expand Down
Loading

0 comments on commit fc73e4b

Please sign in to comment.