Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 58 additions & 30 deletions packages/oxlint-config/src/plugin/import-order.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,11 +161,13 @@ function getOptions(context) {
}

function getImportItems(imports, options) {
return imports.map((node, index) => ({
index,
node,
rank: getRank(node, options)
}))
return imports
.filter(node => node.specifiers.length > 0)
.map((node, index) => ({
Comment on lines 163 to +166
index,
node,
rank: getRank(node, options)
}))
}

function compareImportItems(left, right) {
Expand All @@ -189,23 +191,36 @@ function getFirstUnorderedPair(items) {
return null
}

function hasInvalidNewlines(items, options) {
function hasBlankLineBetween(sourceCode, left, right) {
return sourceCode.text
.slice(left.node.range[1], right.node.range[0])
.split(/\r?\n/)
.slice(1, -1)
.some(line => line.trim() === '')
}

function getInvalidNewlinePair(sourceCode, items, options) {
if (options['newlines-between'] === 'ignore') {
return false
return null
}

return items.some((item, index) => {
if (index === 0) {
return false
}

for (let index = 1; index < items.length; index++) {
const previousItem = items[index - 1]
const hasEmptyLine = item.node.loc.start.line - previousItem.node.loc.end.line > 1
const item = items[index]
const hasBlankLine = hasBlankLineBetween(sourceCode, previousItem, item)
const invalid = options['newlines-between'] === 'never'
? hasBlankLine
: !hasBlankLine

return options['newlines-between'] === 'never'
? hasEmptyLine
: !hasEmptyLine
})
if (invalid) {
return [
previousItem,
item
]
}
}

return null
}

function hasInnerComments(sourceCode, items) {
Expand All @@ -216,13 +231,20 @@ function hasInnerComments(sourceCode, items) {
&& comment.range[1] < lastNode.range[1])
}

function hasSideEffectImports(items) {
return items.some(({ node }) => node.specifiers.length === 0)
function canFix(sourceCode, items) {
return !hasInnerComments(sourceCode, items)
}
Comment on lines 234 to 236

function canFix(sourceCode, items) {
return !hasSideEffectImports(items)
&& !hasInnerComments(sourceCode, items)
function hasSkippedImportsBetween(imports, items) {
const itemNodes = new Set(items.map(({ node }) => node))
const [
start,
end
] = getImportBlockRange(items)

return imports.some(node => !itemNodes.has(node)
&& node.range[0] > start
&& node.range[1] < end)
}

function getFixedImportText(sourceCode, items, options) {
Expand Down Expand Up @@ -316,21 +338,30 @@ export default {

imports.push(node)
},
'Program:exit'(node) {
'Program:exit'() {
if (imports.length < 2) {
return
}

const sourceCode = context.sourceCode
const items = getImportItems(imports, options)

if (items.length < 2) {
return
}

const hasSkippedImports = hasSkippedImportsBetween(imports, items)
const unorderedPair = getFirstUnorderedPair(items)
const invalidNewlines = hasInvalidNewlines(items, options)
const invalidNewlinePair = hasSkippedImports
? null
: getInvalidNewlinePair(sourceCode, items, options)

if (!unorderedPair && !invalidNewlines) {
if (!unorderedPair && !invalidNewlinePair) {
Comment on lines 354 to +359
return
}

const fix = canFix(sourceCode, items)
&& !hasSkippedImports
? fixer => fixer.replaceTextRange(
getImportBlockRange(items),
getFixedImportText(sourceCode, items, options)
Expand All @@ -339,13 +370,10 @@ export default {
const [
previousItem,
item
] = unorderedPair ?? [
items[0],
items[1]
]
] = unorderedPair ?? invalidNewlinePair

context.report({
node,
node: item.node,
message: unorderedPair
? getMessage(previousItem.rank, item.rank)
: 'Import declarations have invalid empty lines.',
Expand Down
4 changes: 3 additions & 1 deletion packages/oxlint-config/src/plugin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import importOrderRule from './import-order.js'
import memberOrderingRule from './member-ordering.js'
import namedImportOrderRule from './named-import-order.js'
import namingConventionRule from './naming-convention.js'
import typeImportStyleRule from './type-import-style.js'

export default {
meta: {
Expand All @@ -13,6 +14,7 @@ export default {
'import-order': importOrderRule,
'member-ordering': memberOrderingRule,
'named-import-order': namedImportOrderRule,
'naming-convention': namingConventionRule
'naming-convention': namingConventionRule,
'type-import-style': typeImportStyleRule
}
}
5 changes: 4 additions & 1 deletion packages/oxlint-config/src/plugin/named-import-order.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ function getTypeRank(node, specifier, options) {
}

function getPatternRank(name, patterns) {
const rank = patterns.findIndex(pattern => new RegExp(pattern).test(name))
const normalizedName = name.replace(/^\$+|\$+$/g, '')
const rank = patterns.findIndex(pattern => new RegExp(pattern).test(
normalizedName
))

return rank === -1 ? Number.POSITIVE_INFINITY : rank
}
Expand Down
166 changes: 166 additions & 0 deletions packages/oxlint-config/src/plugin/type-import-style.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
function shouldConvert(node) {
return node.importKind !== 'type'
&& !hasImportAttributes(node)
&& node.specifiers.length > 0
&& node.specifiers.every(specifier => specifier.type === 'ImportSpecifier'
&& specifier.importKind === 'type')
}
Comment on lines +1 to +7

function isNamedImport(node) {
return node.specifiers.length > 0
&& node.specifiers.every(specifier => specifier.type === 'ImportSpecifier')
}

function isTypeImport(node) {
return node.importKind === 'type'
|| (
isNamedImport(node)
&& node.specifiers.every(specifier => specifier.importKind === 'type')
)
}

function hasImportAttributes(node) {
return (node.attributes?.length ?? 0) > 0
|| (node.assertions?.length ?? 0) > 0
}

function hasCommentsBetween(sourceCode, left, right) {
return sourceCode.getAllComments().some(comment => comment.range[0] > left.range[1]
&& comment.range[1] < right.range[0])
}

function getLocalName(specifier) {
return specifier.local?.name ?? null
}

function hasOverlappingLocalNames(left, right) {
const leftNames = new Set(left.specifiers.map(getLocalName))

return right.specifiers.some(specifier => leftNames.has(getLocalName(specifier)))
}

function canMerge(sourceCode, typeNode, valueNode) {
return typeNode.source.value === valueNode.source.value
&& isTypeImport(typeNode)
&& isNamedImport(typeNode)
&& isNamedImport(valueNode)
&& !isTypeImport(valueNode)
&& !hasImportAttributes(typeNode)
&& !hasImportAttributes(valueNode)
&& !hasOverlappingLocalNames(typeNode, valueNode)
&& !hasCommentsBetween(sourceCode, typeNode, valueNode)
}
Comment on lines +42 to +52

function getMergePair(imports, sourceCode) {
for (let index = 1; index < imports.length; index++) {
const previousImport = imports[index - 1]
const importNode = imports[index]

if (canMerge(sourceCode, previousImport, importNode)) {
return [
previousImport,
importNode
]
}
}

return null
}

function getConvertNode(imports) {
return imports.find(shouldConvert)
}

function getTypeSpecifierText(specifier, sourceCode) {
const text = sourceCode.getText(specifier)

return text.startsWith('type ')
? text
: `type ${text}`
}

function getMergedText(typeNode, valueNode, sourceCode) {
const typeSpecifiers = typeNode.specifiers.map(specifier => getTypeSpecifierText(
specifier,
sourceCode
))
const valueSpecifiers = valueNode.specifiers.map(specifier => sourceCode.getText(
specifier
))
const source = sourceCode.getText(valueNode.source)

return `import { ${[
...typeSpecifiers,
...valueSpecifiers
].join(', ')} } from ${source}`
}

function getFixedText(node, sourceCode) {
return sourceCode.getText(node)
.replace(/^import\b/, 'import type')
.replace(/([,{]\s*)type\s+/g, '$1')
}

export default {
meta: {
type: 'layout',
fixable: 'code',
docs: {
description: 'Prefer import type and merge duplicate type/value imports.'
},
schema: []
},
create(context) {
const sourceCode = context.sourceCode
const imports = []

return {
ImportDeclaration(node) {
if (typeof node.source.value !== 'string') {
return
}

imports.push(node)
},
'Program:exit'() {
const mergePair = getMergePair(imports, sourceCode)

if (mergePair) {
const [
typeNode,
valueNode
] = mergePair

context.report({
node: valueNode.source,
message: 'Merge type and value imports from the same source.',
fix: fixer => fixer.replaceTextRange(
[
typeNode.range[0],
valueNode.range[1]
],
getMergedText(typeNode, valueNode, sourceCode)
)
})

return
}

const convertNode = getConvertNode(imports)

if (!convertNode) {
return
}

context.report({
node: convertNode.source,
message: 'Use import type when all named imports are type imports.',
fix: fixer => fixer.replaceTextRange(
convertNode.range,
getFixedText(convertNode, sourceCode)
)
})
}
}
}
}
5 changes: 5 additions & 0 deletions packages/oxlint-config/src/storybook.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ export default {
overrides: [
{
files: storiesFiles,
plugins: [
'import',
'react',
'typescript'
],
rules: {
'eslint/max-classes-per-file': 'off',
'eslint/no-magic-numbers': 'off',
Expand Down
16 changes: 12 additions & 4 deletions packages/oxlint-config/src/subconfigs/basic.js
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ export default {
'eslint/no-useless-call': 'error',
'eslint/no-useless-concat': 'error',
'eslint/no-useless-return': 'error',
'eslint/no-void': 'error',
'eslint/no-void': [
'error',
{
allowAsStatement: true
}
],
Comment on lines +164 to +169
'eslint/prefer-promise-reject-errors': 'error',
'eslint/prefer-regex-literals': 'error',
'eslint/preserve-caught-error': 'error',
Expand Down Expand Up @@ -276,15 +281,18 @@ export default {
},
{
selector: 'typeLike',
format: ['PascalCase']
format: ['PascalCase'],
trailingDollar: 'allow'
},
{
selector: 'interface',
format: ['PascalCase']
format: ['PascalCase'],
trailingDollar: 'allow'
},
{
selector: 'enumMember',
format: ['PascalCase']
format: ['PascalCase'],
trailingDollar: 'allow'
},
{
selector: 'classProperty',
Expand Down
1 change: 1 addition & 0 deletions packages/oxlint-config/src/subconfigs/configs.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export default {
overrides: [
{
files: configFiles,
plugins: ['import'],
rules: {
'import/no-default-export': 'off',
'import/no-anonymous-default-export': 'off'
Expand Down
Loading