eslint-plugin-sort-keys
Version:
Fork of eslint rule that sorts keys in objects (https://eslint.org/docs/rules/sort-keys) with autofix enabled
263 lines (251 loc) • 7.74 kB
JavaScript
const naturalCompare = require('natural-compare')
module.exports = {
meta: {
type: 'suggestion',
fixable: 'code',
docs: {
description: 'require object keys to be sorted',
category: 'Stylistic Issues',
recommended: false,
url: 'https://github.com/namnm/eslint-plugin-sort-keys',
},
schema: [
{
enum: ['asc', 'desc'],
},
{
type: 'object',
properties: {
caseSensitive: {
type: 'boolean',
default: true,
},
natural: {
type: 'boolean',
default: false,
},
minKeys: {
type: 'integer',
minimum: 2,
default: 2,
},
},
additionalProperties: false,
},
],
messages: {
sortKeys:
"Expected object keys to be in {{natural}}{{insensitive}}{{order}}ending order. '{{thisName}}' should be before '{{prevName}}'.",
},
},
create(ctx) {
// Parse options
const order = ctx.options[0] || 'asc'
const options = ctx.options[1]
const insensitive = (options && options.caseSensitive) === false
const natural = Boolean(options && options.natural)
const isValidOrder =
isValidOrders[order + (insensitive ? 'I' : '') + (natural ? 'N' : '')]
const minKeys = Number(options && options.minKeys) || 2
// The stack to save the previous property's name for each object literals
let stack = null
// Shared SpreadElement for ExperimentalSpreadProperty
const SpreadElement = node => {
if (node.parent.type === 'ObjectExpression') {
stack.prevName = null
}
}
return {
ExperimentalSpreadProperty: SpreadElement,
SpreadElement,
ObjectExpression() {
stack = {
upper: stack,
prevName: null,
prevNode: null,
}
},
'ObjectExpression:exit'() {
stack = stack.upper
},
Property(node) {
if (node.parent.type === 'ObjectPattern') {
return
}
if (node.parent.properties.length < minKeys) {
return
}
const prevName = stack.prevName
const prevNode = stack.prevNode
const thisName = getPropertyName(node)
if (thisName !== null) {
stack.prevName = thisName
stack.prevNode = node || prevNode
}
if (prevName === null || thisName === null) {
return
}
if (!isValidOrder(prevName, thisName)) {
ctx.report({
node,
loc: node.key.loc,
messageId: 'sortKeys',
data: {
thisName,
prevName,
order,
insensitive: insensitive ? 'insensitive ' : '',
natural: natural ? 'natural ' : '',
},
fix(fixer) {
// Check if already sorted
if (
node.parent.__alreadySorted ||
node.parent.properties.__alreadySorted
) {
return []
}
node.parent.__alreadySorted = true
node.parent.properties.__alreadySorted = true
//
const src = ctx.getSourceCode()
const props = node.parent.properties
// Split into parts on each spread operator (empty key)
const parts = []
let part = []
props.forEach(p => {
if (!p.key) {
parts.push(part)
part = []
} else {
part.push(p)
}
})
parts.push(part)
// Sort all parts
parts.forEach(part => {
part.sort((p1, p2) => {
const n1 = getPropertyName(p1)
const n2 = getPropertyName(p2)
if (insensitive && n1.toLowerCase() === n2.toLowerCase()) {
return 0
}
return isValidOrder(n1, n2) ? -1 : 1
})
})
// Perform fixes
const fixes = []
let newIndex = 0
parts.forEach(part => {
part.forEach(p => {
moveProperty(p, props[newIndex], fixer, src).forEach(f =>
fixes.push(f),
)
newIndex++
})
newIndex++
})
return fixes
},
})
}
},
}
},
}
const moveProperty = (thisNode, toNode, fixer, src) => {
if (thisNode === toNode) {
return []
}
const fixes = []
// Move property
fixes.push(fixer.replaceText(toNode, src.getText(thisNode)))
// Move comments on top of this property, but do not move comments
// on the same line with the previous property
const prev = findTokenPrevLine(thisNode, src)
const cond = c => !prev || prev.loc.end.line !== c.loc.start.line
const commentsBefore = src.getCommentsBefore(thisNode).filter(cond)
if (commentsBefore.length) {
const prevComments = src.getCommentsBefore(thisNode).filter(c => !cond(c))
const b = prevComments.length
? prevComments[prevComments.length - 1].range[1]
: prev
? prev.range[1]
: commentsBefore[0].range[0]
const e = commentsBefore[commentsBefore.length - 1].range[1]
fixes.push(fixer.replaceTextRange([b, e], ''))
const toPrev = src.getTokenBefore(toNode, { includeComments: true })
const txt = src.text.substring(b, e)
fixes.push(fixer.insertTextAfter(toPrev, txt))
}
// Move comments on the same line with this property
const next = findCommaSameLine(thisNode, src) || thisNode
const commentsAfter = src
.getCommentsAfter(next)
.filter(c => thisNode.loc.end.line === c.loc.start.line)
if (commentsAfter.length) {
const b = next.range[1]
const e = commentsAfter[commentsAfter.length - 1].range[1]
fixes.push(fixer.replaceTextRange([b, e], ''))
const toNext = findCommaSameLine(toNode, src) || toNode
const txt = src.text.substring(b, e)
fixes.push(fixer.insertTextAfter(toNext, txt))
}
//
return fixes
}
const findTokenPrevLine = (node, src) => {
let t = src.getTokenBefore(node)
while (true) {
if (!t || t.range[0] < node.parent.range[0]) {
return null
}
if (t.loc.end.line < node.loc.start.line) {
return t
}
t = src.getTokenBefore(t)
}
}
const findCommaSameLine = (node, src) => {
const t = src.getTokenAfter(node)
return t && t.value === ',' && node.loc.end.line === t.loc.start.line
? t
: null
}
const isValidOrders = {
asc: (a, b) => a <= b,
ascI: (a, b) => a.toLowerCase() <= b.toLowerCase(),
ascN: (a, b) => naturalCompare(a, b) <= 0,
ascIN: (a, b) => naturalCompare(a.toLowerCase(), b.toLowerCase()) <= 0,
desc: (a, b) => isValidOrders.asc(b, a),
descI: (a, b) => isValidOrders.ascI(b, a),
descN: (a, b) => isValidOrders.ascN(b, a),
descIN: (a, b) => isValidOrders.ascIN(b, a),
}
const getPropertyName = node => {
let prop
switch (node && node.type) {
case 'Property':
case 'MethodDefinition':
prop = node.key
break
case 'MemberExpression':
prop = node.property
break
}
switch (prop && prop.type) {
case 'Literal':
return String(prop.value)
case 'TemplateLiteral':
if (prop.expressions.length === 0 && prop.quasis.length === 1) {
return prop.quasis[0].value.cooked
}
break
case 'Identifier':
if (!node.computed) {
return prop.name
}
break
}
return (node.key && node.key.name) || null
}