UNPKG

eslint-plugin-perfectionist

Version:

ESLint plugin for sorting various data such as objects, imports, types, enums, JSX props, etc.

547 lines (546 loc) 18.3 kB
'use strict' const node_module = require('node:module') const commonJsonSchemas = require('../utils/common-json-schemas.js') const reportErrors = require('../utils/report-errors.js') const validateNewlinesAndPartitionConfiguration = require('../utils/validate-newlines-and-partition-configuration.js') const validateCustomSortConfiguration = require('../utils/validate-custom-sort-configuration.js') const readClosestTsConfigByPath = require('./sort-imports/read-closest-ts-config-by-path.js') const validateGroupsConfiguration = require('../utils/validate-groups-configuration.js') const getOptionsWithCleanGroups = require('../utils/get-options-with-clean-groups.js') const isNewlinesBetweenOption = require('../utils/is-newlines-between-option.js') const getEslintDisabledLines = require('../utils/get-eslint-disabled-lines.js') const getTypescriptImport = require('./sort-imports/get-typescript-import.js') const isNodeEslintDisabled = require('../utils/is-node-eslint-disabled.js') const sortNodesByGroups = require('../utils/sort-nodes-by-groups.js') const createEslintRule = require('../utils/create-eslint-rule.js') const reportAllErrors = require('../utils/report-all-errors.js') const shouldPartition = require('../utils/should-partition.js') const rangeToDiff = require('../utils/range-to-diff.js') const getSettings = require('../utils/get-settings.js') const isSortable = require('../utils/is-sortable.js') const useGroups = require('../utils/use-groups.js') const complete = require('../utils/complete.js') const matches = require('../utils/matches.js') const sortImports = createEslintRule.createEslintRule({ create: context => { let settings = getSettings.getSettings(context.settings) let userOptions = context.options.at(0) let options = getOptionsWithCleanGroups.getOptionsWithCleanGroups( complete.complete(userOptions, settings, { groups: [ 'type', ['builtin', 'external'], 'internal-type', 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'object', 'unknown', ], customGroups: { value: {}, type: {} }, fallbackSort: { type: 'unsorted' }, internalPattern: ['^~/.+'], partitionByComment: false, partitionByNewLine: false, newlinesBetween: 'always', specialCharacters: 'keep', sortSideEffects: false, type: 'alphabetical', environment: 'node', ignoreCase: true, locales: 'en-US', alphabet: '', order: 'asc', }), ) validateGroupsConfiguration.validateGroupsConfiguration({ allowedPredefinedGroups: [ 'side-effect-style', 'external-type', 'internal-type', 'builtin-type', 'sibling-type', 'parent-type', 'side-effect', 'index-type', 'internal', 'external', 'sibling', 'unknown', 'builtin', 'parent', 'object', 'index', 'style', 'type', ], allowedCustomGroups: [ ...Object.keys(options.customGroups.type ?? {}), ...Object.keys(options.customGroups.value ?? {}), ], options, }) validateCustomSortConfiguration.validateCustomSortConfiguration(options) validateNewlinesAndPartitionConfiguration.validateNewlinesAndPartitionConfiguration( options, ) let tsConfigOutput = options.tsconfigRootDir ? readClosestTsConfigByPath.readClosestTsConfigByPath({ tsconfigRootDir: options.tsconfigRootDir, filePath: context.physicalFilename, contextCwd: context.cwd, }) : null let isSideEffectOnlyGroup = group => { if (!group || isNewlinesBetweenOption.isNewlinesBetweenOption(group)) { return false } if (typeof group === 'string') { return group === 'side-effect' || group === 'side-effect-style' } return group.every(isSideEffectOnlyGroup) } if (!options.sortSideEffects) { let hasInvalidGroup = options.groups .filter(group => Array.isArray(group)) .some( nestedGroup => !isSideEffectOnlyGroup(nestedGroup) && nestedGroup.some( subGroup => subGroup === 'side-effect' || subGroup === 'side-effect-style', ), ) if (hasInvalidGroup) { throw new Error( "Side effect groups cannot be nested with non side effect groups when 'sortSideEffects' is 'false'.", ) } } let { sourceCode, filename, id } = context let eslintDisabledLines = getEslintDisabledLines.getEslintDisabledLines({ ruleName: id, sourceCode, }) let sortingNodes = [] let isSideEffectImport = node => node.type === 'ImportDeclaration' && node.specifiers.length === 0 /* Avoid matching on named imports without specifiers */ && !/\}\s*from\s+/u.test(sourceCode.getText(node)) let styleExtensions = [ '.less', '.scss', '.sass', '.styl', '.pcss', '.css', '.sss', ] let isStyle = value => { let [cleanedValue] = value.split('?') return styleExtensions.some(extension => cleanedValue == null ? void 0 : cleanedValue.endsWith(extension), ) } let flatGroups = new Set(options.groups.flat()) let shouldRegroupSideEffectNodes = flatGroups.has('side-effect') let shouldRegroupSideEffectStyleNodes = flatGroups.has('side-effect-style') let registerNode = node => { let name if (node.type === 'ImportDeclaration') { name = node.source.value } else if (node.type === 'TSImportEqualsDeclaration') { if (node.moduleReference.type === 'TSExternalModuleReference') { name = node.moduleReference.expression.value } else { name = sourceCode.getText(node.moduleReference) } } else { let decl = node.declarations[0].init let { value } = decl.arguments[0] name = value.toString() } let isIndex = value => [ './index.d.js', './index.d.ts', './index.js', './index.ts', './index', './', '.', ].includes(value) let isParent = value => value.startsWith('..') let isSibling = value => value.startsWith('./') let { setCustomGroups, defineGroup, getGroup } = useGroups.useGroups(options) let matchesInternalPattern = value => options.internalPattern.some(pattern => matches.matches(value, pattern)) let isCoreModule = value => { let bunModules = [ 'bun', 'bun:ffi', 'bun:jsc', 'bun:sqlite', 'bun:test', 'bun:wrap', 'detect-libc', 'undici', 'ws', ] let builtinPrefixOnlyModules = ['sea', 'sqlite', 'test'] let valueToCheck = value.startsWith('node:') ? value.split('node:')[1] : value return ( (!!valueToCheck && node_module.builtinModules.includes(valueToCheck)) || builtinPrefixOnlyModules.some( module2 => `node:${module2}` === value, ) || (options.environment === 'bun' ? bunModules.includes(value) : false) ) } let getInternalOrExternalGroup = value => { var _a let typescriptImport = getTypescriptImport.getTypescriptImport() if (!typescriptImport) { return !value.startsWith('.') && !value.startsWith('/') ? 'external' : null } let isRelativeImport = typescriptImport.isExternalModuleNameRelative(value) if (isRelativeImport) { return null } if (!tsConfigOutput) { return 'external' } let resolution = typescriptImport.resolveModuleName( value, filename, tsConfigOutput.compilerOptions, typescriptImport.sys, tsConfigOutput.cache, ) if ( typeof ((_a = resolution.resolvedModule) == null ? void 0 : _a.isExternalLibraryImport) !== 'boolean' ) { return 'external' } return resolution.resolvedModule.isExternalLibraryImport ? 'external' : 'internal' } if (node.type !== 'VariableDeclaration' && node.importKind === 'type') { if (node.type === 'ImportDeclaration') { setCustomGroups(options.customGroups.type, node.source.value) let internalExternalGroup = matchesInternalPattern(node.source.value) ? 'internal' : getInternalOrExternalGroup(node.source.value) if (isIndex(node.source.value)) { defineGroup('index-type') } if (isSibling(node.source.value)) { defineGroup('sibling-type') } if (isParent(node.source.value)) { defineGroup('parent-type') } if (internalExternalGroup === 'internal') { defineGroup('internal-type') } if (isCoreModule(node.source.value)) { defineGroup('builtin-type') } if (internalExternalGroup === 'external') { defineGroup('external-type') } } defineGroup('type') } let isSideEffect = isSideEffectImport(node) let isStyleSideEffect = false if ( node.type === 'ImportDeclaration' || node.type === 'VariableDeclaration' ) { let value if (node.type === 'ImportDeclaration') { ;({ value } = node.source) } else { let decl = node.declarations[0].init let declValue = decl.arguments[0].value value = declValue.toString() } let internalExternalGroup = matchesInternalPattern(value) ? 'internal' : getInternalOrExternalGroup(value) let isStyleValue = isStyle(value) isStyleSideEffect = isSideEffect && isStyleValue setCustomGroups(options.customGroups.value, value) if (isStyleSideEffect) { defineGroup('side-effect-style') } if (isSideEffect) { defineGroup('side-effect') } if (isStyleValue) { defineGroup('style') } if (isIndex(value)) { defineGroup('index') } if (isSibling(value)) { defineGroup('sibling') } if (isParent(value)) { defineGroup('parent') } if (internalExternalGroup === 'internal') { defineGroup('internal') } if (isCoreModule(value)) { defineGroup('builtin') } if (internalExternalGroup === 'external') { defineGroup('external') } } sortingNodes.push({ isIgnored: !options.sortSideEffects && isSideEffect && !shouldRegroupSideEffectNodes && (!isStyleSideEffect || !shouldRegroupSideEffectStyleNodes), isEslintDisabled: isNodeEslintDisabled.isNodeEslintDisabled( node, eslintDisabledLines, ), size: rangeToDiff.rangeToDiff(node, sourceCode), addSafetySemicolonWhenInline: true, group: getGroup(), node, name, ...(options.type === 'line-length' && options.maxLineLength && { hasMultipleImportDeclarations: isSortable.isSortable( node.specifiers, ), }), }) } return { 'Program:exit': () => { let hasContentBetweenNodes = (left, right) => sourceCode.getTokensBetween(left.node, right.node, { includeComments: false, }).length > 0 let formattedMembers = [[]] for (let sortingNode of sortingNodes) { let lastGroup = formattedMembers.at(-1) let lastSortingNode = lastGroup == null ? void 0 : lastGroup.at(-1) if ( shouldPartition.shouldPartition({ lastSortingNode, sortingNode, sourceCode, options, }) || (lastSortingNode && hasContentBetweenNodes(lastSortingNode, sortingNode)) ) { lastGroup = [] formattedMembers.push(lastGroup) } lastGroup.push(sortingNode) } for (let nodes of formattedMembers) { let sortNodesExcludingEslintDisabled = ignoreEslintDisabledNodes => sortNodesByGroups.sortNodesByGroups({ getOptionsByGroupNumber: groupNumber => { if (options.sortSideEffects) { return { options, } } return { options: { ...options, type: isSideEffectOnlyGroup(options.groups[groupNumber]) ? 'unsorted' : options.type, }, } }, isNodeIgnored: node => node.isIgnored, ignoreEslintDisabledNodes, groups: options.groups, nodes, }) reportAllErrors.reportAllErrors({ availableMessageIds: { missedSpacingBetweenMembers: 'missedSpacingBetweenImports', extraSpacingBetweenMembers: 'extraSpacingBetweenImports', unexpectedGroupOrder: 'unexpectedImportsGroupOrder', unexpectedOrder: 'unexpectedImportsOrder', }, options: { ...options, customGroups: [], }, sortNodesExcludingEslintDisabled, sourceCode, context, nodes, }) } }, VariableDeclaration: node => { var _a if ( node.declarations[0].init && node.declarations[0].init.type === 'CallExpression' && node.declarations[0].init.callee.type === 'Identifier' && node.declarations[0].init.callee.name === 'require' && ((_a = node.declarations[0].init.arguments[0]) == null ? void 0 : _a.type) === 'Literal' ) { registerNode(node) } }, TSImportEqualsDeclaration: registerNode, ImportDeclaration: registerNode, } }, meta: { schema: [ { properties: { ...commonJsonSchemas.commonJsonSchemas, customGroups: { properties: { value: { description: 'Specifies custom groups for value imports.', type: 'object', }, type: { description: 'Specifies custom groups for type imports.', type: 'object', }, }, description: 'Specifies custom groups.', additionalProperties: false, type: 'object', }, maxLineLength: { description: 'Specifies the maximum line length.', exclusiveMinimum: true, type: 'integer', minimum: 0, }, sortSideEffects: { description: 'Controls whether side-effect imports should be sorted.', type: 'boolean', }, environment: { description: 'Specifies the environment.', enum: ['node', 'bun'], type: 'string', }, tsconfigRootDir: { description: 'Specifies the tsConfig root directory.', type: 'string', }, partitionByComment: commonJsonSchemas.partitionByCommentJsonSchema, partitionByNewLine: commonJsonSchemas.partitionByNewLineJsonSchema, newlinesBetween: commonJsonSchemas.newlinesBetweenJsonSchema, internalPattern: commonJsonSchemas.regexJsonSchema, groups: commonJsonSchemas.groupsJsonSchema, }, definitions: { 'max-line-length-requires-line-length-type': { anyOf: [ { not: { required: ['maxLineLength'], type: 'object', }, type: 'object', }, { $ref: '#/definitions/is-line-length', }, ], }, 'is-line-length': { properties: { type: { enum: ['line-length'], type: 'string' }, }, required: ['type'], type: 'object', }, }, allOf: [ { $ref: '#/definitions/max-line-length-requires-line-length-type', }, ], dependencies: { maxLineLength: ['type'], }, additionalProperties: false, id: 'sort-imports', type: 'object', }, ], messages: { missedSpacingBetweenImports: reportErrors.MISSED_SPACING_ERROR, extraSpacingBetweenImports: reportErrors.EXTRA_SPACING_ERROR, unexpectedImportsGroupOrder: reportErrors.GROUP_ORDER_ERROR, unexpectedImportsOrder: reportErrors.ORDER_ERROR, }, docs: { url: 'https://perfectionist.dev/rules/sort-imports', description: 'Enforce sorted imports.', recommended: true, }, type: 'suggestion', fixable: 'code', }, defaultOptions: [ { groups: [ 'type', ['builtin', 'external'], 'internal-type', 'internal', ['parent-type', 'sibling-type', 'index-type'], ['parent', 'sibling', 'index'], 'object', 'unknown', ], customGroups: { value: {}, type: {} }, internalPattern: ['^~/.+'], partitionByComment: false, partitionByNewLine: false, specialCharacters: 'keep', newlinesBetween: 'always', sortSideEffects: false, type: 'alphabetical', environment: 'node', ignoreCase: true, locales: 'en-US', alphabet: '', order: 'asc', }, ], name: 'sort-imports', }) module.exports = sortImports