Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add autofixer for order rule #908

Merged
merged 4 commits into from
Mar 28, 2018
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
This change log adheres to standards from [Keep a CHANGELOG](http://keepachangelog.com).

## [Unreleased]
- Autofixer for [`order`] rule ([#711], thanks [@tihonove])

## [2.9.0] - 2018-02-21
### Added
Expand Down Expand Up @@ -679,6 +680,7 @@ for info on changes for earlier releases.
[@mplewis]: https://github.com/mplewis
[@rosswarren]: https://github.com/rosswarren
[@alexgorbatchev]: https://github.com/alexgorbatchev
[@tihonove]: https://github.com/tihonove
[@robertrossmann]: https://github.com/robertrossmann
[@isiahmeadows]: https://github.com/isiahmeadows
[@graingert]: https://github.com/graingert
Expand Down
4 changes: 3 additions & 1 deletion docs/rules/order.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# import/order: Enforce a convention in module import order

Enforce a convention in the order of `require()` / `import` statements. The order is as shown in the following example:
Enforce a convention in the order of `require()` / `import` statements.
+(fixable) The `--fix` option on the [command line] automatically fixes problems reported by this rule.
The order is as shown in the following example:

```js
// 1. node "builtin" modules
Expand Down
247 changes: 230 additions & 17 deletions src/rules/order.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import docsUrl from '../docsUrl'

const defaultGroups = ['builtin', 'external', 'parent', 'sibling', 'index']

// REPORTING
// REPORTING AND FIXING

function reverse(array) {
return array.map(function (v) {
Expand All @@ -18,6 +18,60 @@ function reverse(array) {
}).reverse()
}

function getTokensOrCommentsAfter(sourceCode, node, count) {
let currentNodeOrToken = node
const result = []
for (let i = 0; i < count; i++) {
currentNodeOrToken = sourceCode.getTokenOrCommentAfter(currentNodeOrToken)
if (currentNodeOrToken == null) {
break
}
result.push(currentNodeOrToken)
}
return result
}

function getTokensOrCommentsBefore(sourceCode, node, count) {
let currentNodeOrToken = node
const result = []
for (let i = 0; i < count; i++) {
currentNodeOrToken = sourceCode.getTokenOrCommentBefore(currentNodeOrToken)
if (currentNodeOrToken == null) {
break
}
result.push(currentNodeOrToken)
}
return result.reverse()
}

function takeTokensAfterWhile(sourceCode, node, condition) {
const tokens = getTokensOrCommentsAfter(sourceCode, node, 100)
const result = []
for (let i = 0; i < tokens.length; i++) {
if (condition(tokens[i])) {
result.push(tokens[i])
}
else {
break
}
}
return result
}

function takeTokensBeforeWhile(sourceCode, node, condition) {
const tokens = getTokensOrCommentsBefore(sourceCode, node, 100)
const result = []
for (let i = tokens.length - 1; i >= 0; i--) {
if (condition(tokens[i])) {
result.push(tokens[i])
}
else {
break
}
}
return result.reverse()
}

function findOutOfOrder(imported) {
if (imported.length === 0) {
return []
Expand All @@ -32,13 +86,141 @@ function findOutOfOrder(imported) {
})
}

function findRootNode(node) {
let parent = node
while (parent.parent != null && parent.parent.body == null) {
parent = parent.parent
}
return parent
}

function findEndOfLineWithComments(sourceCode, node) {
const tokensToEndOfLine = takeTokensAfterWhile(sourceCode, node, commentOnSameLineAs(node))
let endOfTokens = tokensToEndOfLine.length > 0
? tokensToEndOfLine[tokensToEndOfLine.length - 1].end
: node.end
let result = endOfTokens
for (let i = endOfTokens; i < sourceCode.text.length; i++) {
if (sourceCode.text[i] === '\n') {
result = i + 1
break
}
if (sourceCode.text[i] !== ' ' && sourceCode.text[i] !== '\t' && sourceCode.text[i] !== '\r') {
break
}
result = i + 1
}
return result
}

function commentOnSameLineAs(node) {
return token => (token.type === 'Block' || token.type === 'Line') &&
token.loc.start.line === token.loc.end.line &&
token.loc.end.line === node.loc.end.line
}

function findStartOfLineWithComments(sourceCode, node) {
const tokensToEndOfLine = takeTokensBeforeWhile(sourceCode, node, commentOnSameLineAs(node))
let startOfTokens = tokensToEndOfLine.length > 0 ? tokensToEndOfLine[0].start : node.start
let result = startOfTokens
for (let i = startOfTokens - 1; i > 0; i--) {
if (sourceCode.text[i] !== ' ' && sourceCode.text[i] !== '\t') {
break
}
result = i
}
return result
}

function isPlainRequireModule(node) {
if (node.type !== 'VariableDeclaration') {
return false
}
if (node.declarations.length !== 1) {
return false
}
const decl = node.declarations[0]
const result = (decl.id != null && decl.id.type === 'Identifier') &&
decl.init != null &&
decl.init.type === 'CallExpression' &&
decl.init.callee != null &&
decl.init.callee.name === 'require' &&
decl.init.arguments != null &&
decl.init.arguments.length === 1 &&
decl.init.arguments[0].type === 'Literal'
return result
}

function isPlainImportModule(node) {
return node.type === 'ImportDeclaration' && node.specifiers != null && node.specifiers.length > 0
}

function canCrossNodeWhileReorder(node) {
return isPlainRequireModule(node) || isPlainImportModule(node)
}

function canReorderItems(firstNode, secondNode) {
const parent = firstNode.parent
const firstIndex = parent.body.indexOf(firstNode)
const secondIndex = parent.body.indexOf(secondNode)
const nodesBetween = parent.body.slice(firstIndex, secondIndex + 1)
for (var nodeBetween of nodesBetween) {
if (!canCrossNodeWhileReorder(nodeBetween)) {
return false
}
}
return true
}

function fixOutOfOrder(context, firstNode, secondNode, order) {
const sourceCode = context.getSourceCode()

const firstRoot = findRootNode(firstNode.node)
let firstRootStart = findStartOfLineWithComments(sourceCode, firstRoot)
const firstRootEnd = findEndOfLineWithComments(sourceCode, firstRoot)

const secondRoot = findRootNode(secondNode.node)
let secondRootStart = findStartOfLineWithComments(sourceCode, secondRoot)
let secondRootEnd = findEndOfLineWithComments(sourceCode, secondRoot)
const canFix = canReorderItems(firstRoot, secondRoot)

let newCode = sourceCode.text.substring(secondRootStart, secondRootEnd)
if (newCode[newCode.length - 1] !== '\n') {
newCode = newCode + '\n'
}

const message = '`' + secondNode.name + '` import should occur ' + order +
' import of `' + firstNode.name + '`'

if (order === 'before') {
context.report({
node: secondNode.node,
message: message,
fix: canFix && (fixer =>
fixer.replaceTextRange(
[firstRootStart, secondRootEnd],
newCode + sourceCode.text.substring(firstRootStart, secondRootStart)
)),
})
} else if (order === 'after') {
context.report({
node: secondNode.node,
message: message,
fix: canFix && (fixer =>
fixer.replaceTextRange(
[secondRootStart, firstRootEnd],
sourceCode.text.substring(secondRootEnd, firstRootEnd) + newCode
)),
})
}
}

function reportOutOfOrder(context, imported, outOfOrder, order) {
outOfOrder.forEach(function (imp) {
const found = imported.find(function hasHigherRank(importedItem) {
return importedItem.rank > imp.rank
})
context.report(imp.node, '`' + imp.name + '` import should occur ' + order +
' import of `' + found.name + '`')
fixOutOfOrder(context, found, imp, order)
})
}

Expand Down Expand Up @@ -109,6 +291,32 @@ function convertGroupsToRanks(groups) {
}, rankObject)
}

function fixNewLineAfterImport(context, previousImport) {
const prevRoot = findRootNode(previousImport.node)
const tokensToEndOfLine = takeTokensAfterWhile(
context.getSourceCode(), prevRoot, commentOnSameLineAs(prevRoot))

let endOfLine = prevRoot.end
if (tokensToEndOfLine.length > 0) {
endOfLine = tokensToEndOfLine[tokensToEndOfLine.length - 1].end
}
return (fixer) => fixer.insertTextAfterRange([prevRoot.start, endOfLine], '\n')
}

function removeNewLineAfterImport(context, currentImport, previousImport) {
const sourceCode = context.getSourceCode()
const prevRoot = findRootNode(previousImport.node)
const currRoot = findRootNode(currentImport.node)
const rangeToRemove = [
findEndOfLineWithComments(sourceCode, prevRoot),
findStartOfLineWithComments(sourceCode, currRoot),
]
if (/^\s*$/.test(sourceCode.text.substring(rangeToRemove[0], rangeToRemove[1]))) {
return (fixer) => fixer.removeRange(rangeToRemove)
}
return undefined
}

function makeNewlinesBetweenReport (context, imported, newlinesBetweenImports) {
const getNumberOfEmptyLinesBetween = (currentImport, previousImport) => {
const linesBetweenImports = context.getSourceCode().lines.slice(
Expand All @@ -125,23 +333,27 @@ function makeNewlinesBetweenReport (context, imported, newlinesBetweenImports) {

if (newlinesBetweenImports === 'always'
|| newlinesBetweenImports === 'always-and-inside-groups') {
if (currentImport.rank !== previousImport.rank && emptyLinesBetween === 0)
{
context.report(
previousImport.node, 'There should be at least one empty line between import groups'
)
if (currentImport.rank !== previousImport.rank && emptyLinesBetween === 0) {
context.report({
node: previousImport.node,
message: 'There should be at least one empty line between import groups',
fix: fixNewLineAfterImport(context, previousImport, currentImport),
})
} else if (currentImport.rank === previousImport.rank
&& emptyLinesBetween > 0
&& newlinesBetweenImports !== 'always-and-inside-groups')
{
context.report(
previousImport.node, 'There should be no empty line within import group'
)
}
} else {
if (emptyLinesBetween > 0) {
context.report(previousImport.node, 'There should be no empty line between import groups')
&& newlinesBetweenImports !== 'always-and-inside-groups') {
context.report({
node: previousImport.node,
message: 'There should be no empty line within import group',
fix: removeNewLineAfterImport(context, currentImport, previousImport),
})
}
} else if (emptyLinesBetween > 0) {
context.report({
node: previousImport.node,
message: 'There should be no empty line between import groups',
fix: removeNewLineAfterImport(context, currentImport, previousImport),
})
}

previousImport = currentImport
Expand All @@ -154,6 +366,7 @@ module.exports = {
url: docsUrl('order'),
},

fixable: 'code',
schema: [
{
type: 'object',
Expand Down