From afdda90167a6f42d1f38708497b74dbdb4cf2d02 Mon Sep 17 00:00:00 2001 From: Hugo <60015232+hugop95@users.noreply.github.com> Date: Sun, 12 Jan 2025 14:37:27 +0100 Subject: [PATCH] feat(sort-objects): add array-based custom groups option --- docs/content/rules/sort-array-includes.mdx | 2 +- docs/content/rules/sort-imports.mdx | 2 +- docs/content/rules/sort-interfaces.mdx | 2 + docs/content/rules/sort-jsx-props.mdx | 2 +- docs/content/rules/sort-modules.mdx | 2 + docs/content/rules/sort-object-types.mdx | 4 +- docs/content/rules/sort-objects.mdx | 220 +++++- docs/content/rules/sort-sets.mdx | 2 +- rules/sort-classes/types.ts | 6 +- rules/sort-objects.ts | 168 +++-- rules/sort-objects/does-custom-group-match.ts | 76 ++ rules/sort-objects/types.ts | 165 +++++ test/rules/sort-objects.test.ts | 659 ++++++++++++++++++ utils/common-json-schemas.ts | 5 + 14 files changed, 1202 insertions(+), 113 deletions(-) create mode 100644 rules/sort-objects/does-custom-group-match.ts create mode 100644 rules/sort-objects/types.ts diff --git a/docs/content/rules/sort-array-includes.mdx b/docs/content/rules/sort-array-includes.mdx index 687387cd8..fb0c786b4 100644 --- a/docs/content/rules/sort-array-includes.mdx +++ b/docs/content/rules/sort-array-includes.mdx @@ -308,7 +308,7 @@ Predefined groups are characterized by a selector. default: `{}` -You can define your own groups and use regexp pattern to match specific object type members. +You can define your own groups and use regexp patterns to match specific object type members. A custom group definition may follow one of the two following interfaces: diff --git a/docs/content/rules/sort-imports.mdx b/docs/content/rules/sort-imports.mdx index 3ee465067..19a0551ab 100644 --- a/docs/content/rules/sort-imports.mdx +++ b/docs/content/rules/sort-imports.mdx @@ -369,7 +369,7 @@ This feature is only applicable when `partitionByNewLine` is false. default: `{ value: {}, type: {} }` -You can define your own groups and use regexp pattern to match specific imports. +You can define your own groups and use regexp patterns to match specific imports. Each key of the `value` or `type` fields 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 import matching the value will be marked as part of the group referenced by the key. diff --git a/docs/content/rules/sort-interfaces.mdx b/docs/content/rules/sort-interfaces.mdx index 2fbe08dd5..3448d2c7b 100644 --- a/docs/content/rules/sort-interfaces.mdx +++ b/docs/content/rules/sort-interfaces.mdx @@ -477,6 +477,7 @@ interface CustomGroupDefinition { groupName: string type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' order?: 'asc' | 'desc' + newlinesInside?: 'always' | 'never' selector?: string modifiers?: string[] elementNamePattern?: string @@ -492,6 +493,7 @@ interface CustomGroupAnyOfDefinition { groupName: string type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' order?: 'asc' | 'desc' + newlinesInside?: 'always' | 'never' anyOf: Array<{ selector?: string modifiers?: string[] diff --git a/docs/content/rules/sort-jsx-props.mdx b/docs/content/rules/sort-jsx-props.mdx index 9a09dc380..eb13df08f 100644 --- a/docs/content/rules/sort-jsx-props.mdx +++ b/docs/content/rules/sort-jsx-props.mdx @@ -232,7 +232,7 @@ All members of the groups in the array will be sorted together as if they were p default: `{}` -You can define your own groups and use regexp pattern to match specific JSX attributes. +You can define your own groups and use regexp patterns to match specific JSX attributes. 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` — A JSX prop's name matching the value will be marked as part of the group referenced by the key. diff --git a/docs/content/rules/sort-modules.mdx b/docs/content/rules/sort-modules.mdx index aee5345e6..7de87d00f 100644 --- a/docs/content/rules/sort-modules.mdx +++ b/docs/content/rules/sort-modules.mdx @@ -401,6 +401,7 @@ interface CustomGroupDefinition { groupName: string type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' order?: 'asc' | 'desc' + newlinesInside?: 'always' | 'never' selector?: string modifiers?: string[] elementNamePattern?: string @@ -416,6 +417,7 @@ interface CustomGroupAnyOfDefinition { groupName: string type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' order?: 'asc' | 'desc' + newlinesInside?: 'always' | 'never' anyOf: Array<{ selector?: string modifiers?: string[] diff --git a/docs/content/rules/sort-object-types.mdx b/docs/content/rules/sort-object-types.mdx index b90dabd42..2390df58b 100644 --- a/docs/content/rules/sort-object-types.mdx +++ b/docs/content/rules/sort-object-types.mdx @@ -433,7 +433,7 @@ Current API: default: `[]` -You can define your own groups and use regexp pattern to match specific object type members. +You can define your own groups and use regexp patterns to match specific object type members. A custom group definition may follow one of the two following interfaces: @@ -442,6 +442,7 @@ interface CustomGroupDefinition { groupName: string type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' order?: 'asc' | 'desc' + newlinesInside?: 'always' | 'never' selector?: string modifiers?: string[] elementNamePattern?: string @@ -457,6 +458,7 @@ interface CustomGroupAnyOfDefinition { groupName: string type?: 'alphabetical' | 'natural' | 'line-length' | 'unsorted' order?: 'asc' | 'desc' + newlinesInside?: 'always' | 'never' anyOf: Array<{ selector?: string modifiers?: string[] diff --git a/docs/content/rules/sort-objects.mdx b/docs/content/rules/sort-objects.mdx index 7165f45bf..351409189 100644 --- a/docs/content/rules/sort-objects.mdx +++ b/docs/content/rules/sort-objects.mdx @@ -365,15 +365,7 @@ Example configuration: 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. -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,186 @@ 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: "john_doe", // 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; + } +} +``` + +`multilineMethod` 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' + newlinesInside?: 'always' | 'never' + selector?: string + modifiers?: string[] + elementNamePattern?: string + elementValuePattern?: 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' + newlinesInside?: 'always' | 'never' + anyOf: Array<{ + selector?: string + modifiers?: string[] + elementNamePattern?: string + elementValuePattern?: 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. +- `elementValuePattern`: Only for non-function properties. If entered, will check that the value of the property 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 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: { // 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 +561,22 @@ const user = { ```js { groups: [ -+ 'top', // [!code ++] - ['multiline', 'method'], // [!code ++] - ['unknown'], // [!code ++] - 'bottom' // [!code ++] ++ 'top', // [!code ++] + 'unknown', ++ ['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 +611,7 @@ const user = { ignorePattern: [], useConfigurationIf: {}, groups: [], - customGroups: {}, + customGroups: [], }, ], }, @@ -499,7 +645,7 @@ const user = { ignorePattern: [], useConfigurationIf: {}, groups: [], - customGroups: {}, + customGroups: [], }, ], }, diff --git a/docs/content/rules/sort-sets.mdx b/docs/content/rules/sort-sets.mdx index 8251bd376..3f7290823 100644 --- a/docs/content/rules/sort-sets.mdx +++ b/docs/content/rules/sort-sets.mdx @@ -266,7 +266,7 @@ Predefined groups are characterized by a selector. default: `{}` -You can define your own groups and use regexp pattern to match specific object type members. +You can define your own groups and use regexp patterns to match specific object type members. A custom group definition may follow one of the two following interfaces: diff --git a/rules/sort-classes/types.ts b/rules/sort-classes/types.ts index c83a8b68d..d25d8e497 100644 --- a/rules/sort-classes/types.ts +++ b/rules/sort-classes/types.ts @@ -5,6 +5,7 @@ import type { JoinWithDash } from '../../types/join-with-dash' import { buildCustomGroupModifiersJsonSchema, buildCustomGroupSelectorJsonSchema, + elementValuePatternJsonSchema, elementNamePatternJsonSchema, } from '../../utils/common-json-schemas' @@ -319,15 +320,12 @@ export let allModifiers: Modifier[] = [ * that users do not enter invalid modifiers for a given selector */ export let singleCustomGroupJsonSchema: Record = { - elementValuePattern: { - description: 'Element value pattern filter for properties.', - type: 'string', - }, decoratorNamePattern: { description: 'Decorator name pattern filter.', type: 'string', }, modifiers: buildCustomGroupModifiersJsonSchema(allModifiers), selector: buildCustomGroupSelectorJsonSchema(allSelectors), + elementValuePattern: elementValuePatternJsonSchema, elementNamePattern: elementNamePatternJsonSchema, } diff --git a/rules/sort-objects.ts b/rules/sort-objects.ts index 7544603d4..7f31e0aa6 100644 --- a/rules/sort-objects.ts +++ b/rules/sort-objects.ts @@ -1,9 +1,12 @@ 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 { buildUseConfigurationIfJsonSchema, + buildCustomGroupsArrayJsonSchema, partitionByCommentJsonSchema, partitionByNewLineJsonSchema, specialCharactersJsonSchema, @@ -21,15 +24,20 @@ 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 { getCustomGroupsCompareOptions } from '../utils/get-custom-groups-compare-options' 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 { singleCustomGroupJsonSchema } from './sort-objects/types' 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' @@ -47,42 +55,10 @@ import { complete } from '../utils/complete' import { pairwise } from '../utils/pairwise' import { matches } from '../utils/matches' -type Options = Partial<{ - partitionByComment: - | { - block?: string[] | boolean | string - line?: string[] | boolean | string - } - | string[] - | boolean - | string - useConfigurationIf: { - callingFunctionNamePattern?: string - allNamesMatchPattern?: string - } - groups: ( - | { newlinesBetween: 'ignore' | 'always' | 'never' } - | Group[] - | Group - )[] - type: 'alphabetical' | 'line-length' | 'unsorted' | 'natural' | 'custom' - destructuredObjects: { groups: boolean } | boolean - customGroups: Record - newlinesBetween: 'ignore' | 'always' | 'never' - specialCharacters: 'remove' | 'trim' | 'keep' - locales: NonNullable - partitionByNewLine: boolean - objectDeclarations: boolean - styledComponents: boolean - /** - * @deprecated for {@link `destructuredObjects`} and {@link `objectDeclarations`} - */ - destructureOnly: boolean - ignorePattern: string[] - order: 'desc' | 'asc' - ignoreCase: boolean - alphabet: string -}>[] +/** + * Cache computed groups by modifiers and selectors for performance + */ +let cachedGroupsByModifiersAndSelectors = new Map() type MESSAGE_ID = | 'missedSpacingBetweenObjectMembers' @@ -91,8 +67,6 @@ type MESSAGE_ID = | 'unexpectedObjectsGroupOrder' | 'unexpectedObjectsOrder' -type Group = 'multiline' | 'unknown' | 'method' | string - let defaultOptions: Required = { partitionByNewLine: false, partitionByComment: false, @@ -131,8 +105,12 @@ export default createEslintRule({ }) let matchedContextOptions = getMatchingContextOptions({ nodeNames: nodeObject.properties - .map(property => getNodeName({ sourceCode, property })) - .filter(nodeName => nodeName !== null), + .filter( + property => + property.type !== 'SpreadElement' && + property.type !== 'RestElement', + ) + .map(property => getNodeName({ sourceCode, property })), contextOptions: context.options, }).find(options => { if (!options.useConfigurationIf?.callingFunctionNamePattern) { @@ -164,11 +142,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' @@ -337,34 +316,72 @@ export default createEslintRule({ let lastProperty = accumulator.at(-1)?.at(-1) - let name: string let dependencies: string[] = [] let { setCustomGroups, defineGroup, getGroup } = useGroups(options) - if (property.key.type === 'Identifier') { - ;({ name } = property.key) - } else if (property.key.type === 'Literal') { - name = `${property.key.value}` - } else { - name = sourceCode.getText(property.key) - } + let selectors: Selector[] = [] + let modifiers: Modifier[] = [] if (property.value.type === 'AssignmentPattern') { 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) + } + + let name = getNodeName({ sourceCode, property }) + if (Array.isArray(options.customGroups)) { + for (let customGroup of options.customGroups) { + if ( + doesCustomGroupMatch({ + elementValue: getNodeValue({ + sourceCode, + property, + }), + 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 = { @@ -418,6 +435,8 @@ export default createEslintRule({ sortNodesByDependencies( formattedMembers.flatMap(nodes => nodesSortingFunction(nodes, options, { + getGroupCompareOptions: groupNumber => + getCustomGroupsCompareOptions(options, groupNumber), ignoreEslintDisabledNodes, }), ), @@ -561,6 +580,12 @@ export default createEslintRule({ description: 'Allows you to use comments to separate the keys of objects into logical groups.', }, + customGroups: { + oneOf: [ + customGroupsJsonSchema, + buildCustomGroupsArrayJsonSchema({ singleCustomGroupJsonSchema }), + ], + }, destructureOnly: { description: 'Controls whether to sort only destructured objects.', type: 'boolean', @@ -577,7 +602,6 @@ export default createEslintRule({ partitionByNewLine: partitionByNewLineJsonSchema, specialCharacters: specialCharactersJsonSchema, newlinesBetween: newlinesBetweenJsonSchema, - customGroups: customGroupsJsonSchema, ignoreCase: ignoreCaseJsonSchema, alphabet: alphabetJsonSchema, locales: localesJsonSchema, @@ -617,15 +641,9 @@ let getNodeName = ({ sourceCode, property, }: { - property: - | TSESTree.ObjectLiteralElement - | TSESTree.RestElement - | TSESTree.Property sourceCode: ReturnType -}): string | null => { - if (property.type === 'SpreadElement' || property.type === 'RestElement') { - return null - } + property: TSESTree.Property +}): string => { if (property.key.type === 'Identifier') { return property.key.name } else if (property.key.type === 'Literal') { @@ -634,6 +652,22 @@ let getNodeName = ({ return sourceCode.getText(property.key) } +let getNodeValue = ({ + sourceCode, + property, +}: { + sourceCode: ReturnType + property: TSESTree.Property +}): string | null => { + if ( + property.value.type === 'ArrowFunctionExpression' || + property.value.type === 'FunctionExpression' + ) { + return null + } + return sourceCode.getText(property.value) +} + let getObjectParent = ({ onlyFirstParent, node, 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..c7d1207c8 --- /dev/null +++ b/rules/sort-objects/does-custom-group-match.ts @@ -0,0 +1,76 @@ +import type { + SingleCustomGroup, + AnyOfCustomGroup, + Modifier, + Selector, +} from './types' + +import { matches } from '../../utils/matches' + +interface DoesCustomGroupMatchProps { + customGroup: SingleCustomGroup | AnyOfCustomGroup + elementValue: string | null + 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 + } + } + + if ( + 'elementValuePattern' in props.customGroup && + props.customGroup.elementValuePattern + ) { + let matchesElementValuePattern: boolean = matches( + props.elementValue ?? '', + props.customGroup.elementValuePattern, + ) + if (!matchesElementValuePattern) { + return false + } + } + + return true +} diff --git a/rules/sort-objects/types.ts b/rules/sort-objects/types.ts new file mode 100644 index 000000000..102fd5f64 --- /dev/null +++ b/rules/sort-objects/types.ts @@ -0,0 +1,165 @@ +import type { JSONSchema4 } from '@typescript-eslint/utils/json-schema' + +import type { JoinWithDash } from '../../types/join-with-dash' + +import { + buildCustomGroupModifiersJsonSchema, + buildCustomGroupSelectorJsonSchema, + elementValuePatternJsonSchema, + elementNamePatternJsonSchema, +} from '../../utils/common-json-schemas' + +export type Options = Partial<{ + partitionByComment: + | { + block?: string[] | boolean | string + line?: string[] | boolean | string + } + | string[] + | boolean + | string + useConfigurationIf: { + callingFunctionNamePattern?: string + allNamesMatchPattern?: string + } + groups: ( + | { newlinesBetween: 'ignore' | 'always' | 'never' } + | Group[] + | Group + )[] + type: 'alphabetical' | 'line-length' | 'unsorted' | 'natural' | 'custom' + customGroups: Record | CustomGroup[] + destructuredObjects: { groups: boolean } | boolean + newlinesBetween: 'ignore' | 'always' | 'never' + specialCharacters: 'remove' | 'trim' | 'keep' + locales: NonNullable + partitionByNewLine: boolean + objectDeclarations: boolean + styledComponents: boolean + /** + * @deprecated for {@link `destructuredObjects`} and {@link `objectDeclarations`} + */ + destructureOnly: boolean + ignorePattern: string[] + order: 'desc' | 'asc' + ignoreCase: boolean + alphabet: string +}>[] + +export type SingleCustomGroup = ( + | BaseSingleCustomGroup + | BaseSingleCustomGroup + | BaseSingleCustomGroup + | BaseSingleCustomGroup +) & { + elementValuePattern?: string + elementNamePattern?: string +} + +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] +> + +/** + * @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), + elementValuePattern: elementValuePatternJsonSchema, + elementNamePattern: elementNamePatternJsonSchema, +} diff --git a/test/rules/sort-objects.test.ts b/test/rules/sort-objects.test.ts index 3f5510d82..4f878dd65 100644 --- a/test/rules/sort-objects.test.ts +++ b/test/rules/sort-objects.test.ts @@ -1888,6 +1888,139 @@ describe(ruleName, () => { invalid: [], }) + describe(`${ruleName}(${type}): selectors priority`, () => { + ruleTester.run( + `${ruleName}(${type}): prioritize method over multiline`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + left: 'multilineProperty', + leftGroup: 'multiline', + rightGroup: 'method', + right: 'method', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + output: dedent` + let obj = { + method() {}, + multilineProperty: { + // Some multiline stuff + }, + } + `, + code: dedent` + let obj = { + multilineProperty: { + // Some multiline stuff + }, + method() {}, + } + `, + options: [ + { + ...options, + groups: ['method', 'multiline'], + }, + ], + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): prioritize property over multiline`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + left: 'multilineFunction', + leftGroup: 'multiline', + rightGroup: 'property', + right: 'property', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + output: dedent` + let obj = { + property, + multilineFunction() { + // Some multiline stuff + }, + } + `, + code: dedent` + let obj = { + multilineFunction() { + // Some multiline stuff + }, + property, + } + `, + options: [ + { + ...options, + groups: ['property', 'multiline'], + }, + ], + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}(${type}): prioritize property over member`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + rightGroup: 'property', + leftGroup: 'member', + right: 'property', + left: 'method', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + options: [ + { + ...options, + groups: ['property', 'member'], + }, + ], + output: dedent` + let obj = { + property, + method() {}, + } + `, + code: dedent` + let obj = { + method() {}, + property, + } + `, + }, + ], + valid: [], + }, + ) + }) + ruleTester.run( `${ruleName}(${type}): allows to set groups for sorting`, rule, @@ -2361,6 +2494,532 @@ describe(ruleName, () => { }, ) }) + + describe(`${ruleName}: custom groups`, () => { + ruleTester.run(`${ruleName}: filters on selector and modifiers`, rule, { + invalid: [ + { + options: [ + { + customGroups: [ + { + groupName: 'unusedCustomGroup', + modifiers: ['multiline'], + selector: 'method', + }, + { + groupName: 'multilinePropertyGroup', + modifiers: ['multiline'], + selector: 'property', + }, + { + groupName: 'propertyGroup', + selector: 'property', + }, + ], + groups: ['propertyGroup', 'multilinePropertyGroup'], + }, + ], + errors: [ + { + data: { + leftGroup: 'multilinePropertyGroup', + rightGroup: 'propertyGroup', + right: 'c', + left: 'b', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + output: dedent` + let obj = { + c, + a: { + // Multiline + }, + b: { + // Multiline + }, + } + `, + code: dedent` + let obj = { + a: { + // Multiline + }, + b: { + // Multiline + }, + c, + } + `, + }, + ], + valid: [], + }) + + ruleTester.run(`${ruleName}: filters on elementNamePattern`, rule, { + invalid: [ + { + options: [ + { + customGroups: [ + { + groupName: 'propertiesStartingWithHello', + elementNamePattern: 'hello*', + selector: 'property', + }, + ], + groups: ['propertiesStartingWithHello', 'unknown'], + }, + ], + errors: [ + { + data: { + rightGroup: 'propertiesStartingWithHello', + right: 'helloProperty', + leftGroup: 'unknown', + left: 'method', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + output: dedent` + let obj = { + helloProperty, + a, + b, + method() {}, + } + `, + code: dedent` + let obj = { + a, + b, + method() {}, + helloProperty, + } + `, + }, + ], + valid: [], + }) + + ruleTester.run(`${ruleName}: filters on elementValuePattern`, rule, { + invalid: [ + { + options: [ + { + customGroups: [ + { + elementValuePattern: 'inject*', + groupName: 'inject', + }, + { + elementValuePattern: 'computed*', + groupName: 'computed', + }, + ], + groups: ['computed', 'inject', 'unknown'], + }, + ], + errors: [ + { + data: { + rightGroup: 'computed', + leftGroup: 'inject', + right: 'z', + left: 'y', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + output: dedent` + let obj = { + a: computed(A), + z: computed(Z), + b: inject(B), + y: inject(Y), + c() {}, + } + `, + code: dedent` + let obj = { + a: computed(A), + b: inject(B), + y: inject(Y), + z: computed(Z), + c() {}, + } + `, + }, + ], + valid: [], + }) + + ruleTester.run( + `${ruleName}: sort custom groups by overriding 'type' and 'order'`, + rule, + { + invalid: [ + { + errors: [ + { + data: { + right: 'bb', + left: 'a', + }, + messageId: 'unexpectedObjectsOrder', + }, + { + data: { + right: 'ccc', + left: 'bb', + }, + messageId: 'unexpectedObjectsOrder', + }, + { + data: { + right: 'dddd', + left: 'ccc', + }, + messageId: 'unexpectedObjectsOrder', + }, + { + data: { + rightGroup: 'reversedPropertiesByLineLength', + leftGroup: 'unknown', + left: 'method', + right: 'eee', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + options: [ + { + customGroups: [ + { + groupName: 'reversedPropertiesByLineLength', + selector: 'property', + type: 'line-length', + order: 'desc', + }, + ], + groups: ['reversedPropertiesByLineLength', 'unknown'], + type: 'alphabetical', + order: 'asc', + }, + ], + output: dedent` + let obj = { + dddd, + ccc, + eee, + bb, + ff, + a, + g, + anotherMethod() {}, + method() {}, + yetAnotherMethod() {}, + } + `, + code: dedent` + let obj = { + a, + bb, + ccc, + dddd, + method() {}, + eee, + ff, + g, + anotherMethod() {}, + yetAnotherMethod() {}, + } + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}: does not sort custom groups with 'unsorted' type`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + groupName: 'unsortedProperties', + selector: 'property', + type: 'unsorted', + }, + ], + groups: ['unsortedProperties', 'unknown'], + }, + ], + errors: [ + { + data: { + rightGroup: 'unsortedProperties', + leftGroup: 'unknown', + left: 'method', + right: 'c', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + output: dedent` + let obj = { + b, + a, + d, + e, + c, + method() {}, + } + `, + code: dedent` + let obj = { + b, + a, + d, + e, + method() {}, + c, + } + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run(`${ruleName}: sort custom group blocks`, rule, { + invalid: [ + { + options: [ + { + customGroups: [ + { + anyOf: [ + { + modifiers: ['multiline'], + selector: 'property', + }, + { + modifiers: ['multiline'], + selector: 'method', + }, + ], + groupName: 'multilinePropertiesAndMultilineMethods', + }, + ], + groups: ['multilinePropertiesAndMultilineMethods', 'unknown'], + }, + ], + errors: [ + { + data: { + rightGroup: 'multilinePropertiesAndMultilineMethods', + leftGroup: 'unknown', + right: 'b', + left: 'a', + }, + messageId: 'unexpectedObjectsGroupOrder', + }, + ], + output: dedent` + let obj = { + b() { + // Multiline + }, + c: { + // Multiline + }, + a, + d() {}, + e, + } + `, + code: dedent` + let obj = { + a, + b() { + // Multiline + }, + c: { + // Multiline + }, + d() {}, + e, + } + `, + }, + ], + valid: [], + }) + + ruleTester.run( + `${ruleName}: allows to use regex for element names in custom groups`, + rule, + { + valid: [ + { + options: [ + { + customGroups: [ + { + elementNamePattern: '^(?!.*Foo).*$', + groupName: 'elementsWithoutFoo', + }, + ], + groups: ['unknown', 'elementsWithoutFoo'], + type: 'alphabetical', + }, + ], + code: dedent` + let obj = { + iHaveFooInMyName, + meTooIHaveFoo, + a, + b, + } + `, + }, + ], + invalid: [], + }, + ) + + describe('newlinesInside', () => { + ruleTester.run( + `${ruleName}: allows to use newlinesInside: always`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + newlinesInside: 'always', + selector: 'property', + groupName: 'group1', + }, + ], + groups: ['group1'], + }, + ], + errors: [ + { + data: { + right: 'b', + left: 'a', + }, + messageId: 'missedSpacingBetweenObjectMembers', + }, + ], + output: dedent` + let obj = { + a, + + b, + } + `, + code: dedent` + let obj = { + a, + b, + } + `, + }, + ], + valid: [], + }, + ) + + ruleTester.run( + `${ruleName}: allows to use newlinesInside: never`, + rule, + { + invalid: [ + { + options: [ + { + customGroups: [ + { + newlinesInside: 'never', + selector: 'property', + groupName: 'group1', + }, + ], + type: 'alphabetical', + groups: ['group1'], + }, + ], + errors: [ + { + data: { + right: 'b', + left: 'a', + }, + messageId: 'extraSpacingBetweenObjectMembers', + }, + ], + output: dedent` + let obj = { + a, + b, + } + `, + code: dedent` + let obj = { + a, + + b, + } + `, + }, + ], + valid: [], + }, + ) + }) + + ruleTester.run( + `${ruleName}(${type}): allows to use regex for custom groups`, + rule, + { + valid: [ + { + options: [ + { + ...options, + customGroups: { + elementsWithoutFoo: '^(?!.*Foo).*$', + }, + groups: ['unknown', 'elementsWithoutFoo'], + }, + ], + code: dedent` + let obj = { + iHaveFooInMyName, + meTooIHaveFoo, + a, + b, + } + `, + }, + ], + invalid: [], + }, + ) + }) }) describe(`${ruleName}: sorting by natural order`, () => { diff --git a/utils/common-json-schemas.ts b/utils/common-json-schemas.ts index 817fb1aaa..e42de3bac 100644 --- a/utils/common-json-schemas.ts +++ b/utils/common-json-schemas.ts @@ -250,3 +250,8 @@ export let elementNamePatternJsonSchema: JSONSchema4 = { description: 'Element name pattern filter.', type: 'string', } + +export let elementValuePatternJsonSchema: JSONSchema4 = { + description: 'Element value pattern filter.', + type: 'string', +}