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
JavaScript
'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