Skip to content

Commit e482161

Browse files
authored
Add autofix to jsonc/sort-keys rule (#17)
1 parent d7ce4f8 commit e482161

File tree

5 files changed

+321
-11
lines changed

5 files changed

+321
-11
lines changed

lib/rules/sort-keys.ts

Lines changed: 177 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,52 @@
1-
import coreRule from "eslint/lib/rules/sort-keys"
2-
import { createRule, defineWrapperListener } from "../utils"
1+
/**
2+
* Taken with https://github.com/eslint/eslint/blob/master/lib/rules/sort-keys.js
3+
*/
4+
import naturalCompare from "natural-compare"
5+
import { createRule } from "../utils"
6+
import { isCommaToken } from "eslint-utils"
7+
import type { JSONProperty, JSONObjectExpression } from "../parser/ast"
8+
import { getStaticJSONValue } from "../utils/ast"
9+
10+
//------------------------------------------------------------------------------
11+
// Helpers
12+
//------------------------------------------------------------------------------
13+
14+
/**
15+
* Gets the property name of the given `Property` node.
16+
*/
17+
function getPropertyName(node: JSONProperty): string {
18+
const prop = node.key
19+
if (prop.type === "JSONIdentifier") {
20+
return prop.name
21+
}
22+
return String(getStaticJSONValue(prop))
23+
}
24+
25+
/**
26+
* Build function which check that the given 2 names are in specific order.
27+
*/
28+
function buildValidator(order: Option, insensitive: boolean, natural: boolean) {
29+
let compare = natural
30+
? ([a, b]: string[]) => naturalCompare(a, b) <= 0
31+
: ([a, b]: string[]) => a <= b
32+
if (insensitive) {
33+
const baseCompare = compare
34+
compare = ([a, b]: string[]) =>
35+
baseCompare([a.toLowerCase(), b.toLowerCase()])
36+
}
37+
if (order === "desc") {
38+
const baseCompare = compare
39+
compare = (args: string[]) => baseCompare(args.reverse())
40+
}
41+
return (a: string, b: string) => compare([a, b])
42+
}
43+
44+
const allowOptions = ["asc", "desc"] as const
45+
type Option = typeof allowOptions[number]
46+
47+
//------------------------------------------------------------------------------
48+
// Rule Definition
49+
//------------------------------------------------------------------------------
350

451
export default createRule("sort-keys", {
552
meta: {
@@ -8,12 +55,135 @@ export default createRule("sort-keys", {
855
recommended: null,
956
extensionRule: true,
1057
},
11-
fixable: coreRule.meta?.fixable,
12-
schema: coreRule.meta?.schema!,
13-
messages: coreRule.meta?.messages!,
14-
type: coreRule.meta?.type!,
58+
fixable: "code",
59+
schema: [
60+
{
61+
enum: allowOptions,
62+
},
63+
{
64+
type: "object",
65+
properties: {
66+
caseSensitive: {
67+
type: "boolean",
68+
default: true,
69+
},
70+
natural: {
71+
type: "boolean",
72+
default: false,
73+
},
74+
minKeys: {
75+
type: "integer",
76+
minimum: 2,
77+
default: 2,
78+
},
79+
},
80+
additionalProperties: false,
81+
},
82+
],
83+
messages: {
84+
sortKeys:
85+
"Expected object keys to be in {{natural}}{{insensitive}}{{order}}ending order. '{{thisName}}' should be before '{{prevName}}'.",
86+
},
87+
type: "suggestion",
1588
},
1689
create(context) {
17-
return defineWrapperListener(coreRule, context, context.options)
90+
// Parse options.
91+
const order: Option = context.options[0] || "asc"
92+
const options = context.options[1]
93+
const insensitive: boolean = options && options.caseSensitive === false
94+
const natural: boolean = options && options.natural
95+
const minKeys: number = options && options.minKeys
96+
const isValidOrder = buildValidator(order, insensitive, natural)
97+
type Stack = {
98+
upper: Stack | null
99+
prevList: { name: string; node: JSONProperty }[]
100+
numKeys: number
101+
}
102+
let stack: Stack = { upper: null, prevList: [], numKeys: 0 }
103+
104+
return {
105+
JSONObjectExpression(node: JSONObjectExpression) {
106+
stack = {
107+
upper: stack,
108+
prevList: [],
109+
numKeys: node.properties.length,
110+
}
111+
},
112+
113+
"JSONObjectExpression:exit"() {
114+
stack = stack.upper!
115+
},
116+
117+
JSONProperty(node: JSONProperty) {
118+
const prevList = stack.prevList
119+
const numKeys = stack.numKeys
120+
const thisName = getPropertyName(node)
121+
122+
stack.prevList = [
123+
{
124+
name: thisName,
125+
node,
126+
},
127+
...prevList,
128+
]
129+
if (prevList.length === 0 || numKeys < minKeys) {
130+
return
131+
}
132+
const prevName = prevList[0].name
133+
if (!isValidOrder(prevName, thisName)) {
134+
context.report({
135+
loc: node.key.loc,
136+
messageId: "sortKeys",
137+
data: {
138+
thisName,
139+
prevName,
140+
order,
141+
insensitive: insensitive ? "insensitive " : "",
142+
natural: natural ? "natural " : "",
143+
},
144+
*fix(fixer) {
145+
const sourceCode = context.getSourceCode()
146+
let moveTarget = prevList[0].node
147+
for (const prev of prevList) {
148+
if (isValidOrder(prev.name, thisName)) {
149+
break
150+
} else {
151+
moveTarget = prev.node
152+
}
153+
}
154+
155+
const beforeToken = sourceCode.getTokenBefore(
156+
node as never,
157+
)!
158+
const afterToken = sourceCode.getTokenAfter(
159+
node as never,
160+
)!
161+
const hasAfterComma = isCommaToken(afterToken)
162+
const codeStart = beforeToken.range[1] // to include comments
163+
const codeEnd = hasAfterComma
164+
? afterToken.range[1] // |/**/ key: value,|
165+
: node.range[1] // |/**/ key: value|
166+
const removeStart = hasAfterComma
167+
? codeStart // |/**/ key: value,|
168+
: beforeToken.range[0] // |,/**/ key: value|
169+
170+
const insertCode =
171+
sourceCode.text.slice(codeStart, codeEnd) +
172+
(hasAfterComma ? "" : ",")
173+
174+
const insertTarget = sourceCode.getTokenBefore(
175+
moveTarget as never,
176+
)!
177+
yield fixer.insertTextAfterRange(
178+
insertTarget.range,
179+
insertCode,
180+
)
181+
182+
yield fixer.removeRange([removeStart, codeEnd])
183+
},
184+
})
185+
}
186+
},
187+
}
18188
},
19189
})

lib/utils/ast.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type {
1616
JSONKeywordLiteral,
1717
JSONRegExpLiteral,
1818
JSONBigIntLiteral,
19+
JSONLiteral,
1920
} from "../parser/ast"
2021

2122
/**
@@ -85,6 +86,9 @@ export function getStaticJSONValue(node: JSONNumberLiteral): number
8586
export function getStaticJSONValue(node: JSONKeywordLiteral): boolean | null
8687
export function getStaticJSONValue(node: JSONRegExpLiteral): RegExp
8788
export function getStaticJSONValue(node: JSONBigIntLiteral): bigint
89+
export function getStaticJSONValue(
90+
node: JSONLiteral,
91+
): string | number | boolean | RegExp | bigint | null
8892

8993
export function getStaticJSONValue(node: JSONObjectExpression): JSONObjectValue
9094
export function getStaticJSONValue(node: JSONArrayExpression): JSONValue[]
@@ -126,10 +130,18 @@ export function getStaticJSONValue(
126130
}
127131
if (node.type === "JSONLiteral") {
128132
if (node.regex) {
129-
return new RegExp(node.regex.pattern, node.regex.flags)
133+
try {
134+
return new RegExp(node.regex.pattern, node.regex.flags)
135+
} catch {
136+
return `/${node.regex.pattern}/${node.regex.flags}`
137+
}
130138
}
131139
if (node.bigint != null) {
132-
return BigInt(node.bigint)
140+
try {
141+
return BigInt(node.bigint)
142+
} catch {
143+
return `${node.bigint}`
144+
}
133145
}
134146
return node.value
135147
}

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
"@types/eslint": "^7.2.0",
6666
"@types/estree": "0.0.44",
6767
"@types/mocha": "^7.0.2",
68+
"@types/natural-compare": "^1.4.0",
6869
"@types/node": "^14.0.13",
6970
"@types/semver": "^7.3.1",
7071
"babel-eslint": "^10.1.0",
@@ -85,5 +86,8 @@
8586
"vue-eslint-editor": "^1.1.0",
8687
"vuepress": "^1.5.2"
8788
},
88-
"dependencies": {}
89+
"dependencies": {
90+
"eslint-utils": "^2.1.0",
91+
"natural-compare": "^1.4.0"
92+
}
8993
}

tests/lib/rules/sort-keys.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,92 @@ const tester = new RuleTester({
66
})
77

88
tester.run("sort-keys", rule as any, {
9-
valid: ['{"a": 1, "b": 2, "c": 3}'],
9+
valid: [
10+
'{"a": 1, "b": 2, "c": 3}',
11+
12+
// natural
13+
{ code: "{$:1, _:2, A:3, a:4}", options: ["asc", { natural: true }] },
14+
15+
// caseSensitive: false
16+
{
17+
code: "{a:1, A: 2, B:3, b:4}",
18+
options: ["asc", { caseSensitive: false }],
19+
},
20+
21+
{
22+
code: '{"c": 1, "b": 2, "a": 3}',
23+
options: ["desc"],
24+
},
25+
],
1026
invalid: [
1127
{
1228
code: '{"a": 1, "c": 3, "b": 2}',
29+
output: '{"a": 1, "b": 2, "c": 3}',
30+
errors: [
31+
"Expected object keys to be in ascending order. 'b' should be before 'c'.",
32+
],
33+
},
34+
{
35+
code: '{a: 1, c: 3, d: 4, "b": 2}',
36+
output: '{a: 1, "b": 2, c: 3, d: 4}',
37+
errors: [
38+
"Expected object keys to be in ascending order. 'b' should be before 'd'.",
39+
],
40+
},
41+
{
42+
code: '{f: {a: 1, c: 3, d: 4, "b": 2}, e: 5}',
43+
output: '{ e: 5,f: {a: 1, c: 3, d: 4, "b": 2}}',
44+
errors: [
45+
"Expected object keys to be in ascending order. 'b' should be before 'd'.",
46+
"Expected object keys to be in ascending order. 'e' should be before 'f'.",
47+
],
48+
},
49+
{
50+
code: '{"a": 1, /*c*/"c": 3,/*b*/ "b": 2,}',
51+
output: '{"a": 1,/*b*/ "b": 2, /*c*/"c": 3,}',
1352
errors: [
1453
"Expected object keys to be in ascending order. 'b' should be before 'c'.",
1554
],
1655
},
56+
{
57+
code: "{$:1, _:2, A:3, a:4}",
58+
output: "{$:1, A:3, _:2, a:4}",
59+
errors: [
60+
"Expected object keys to be in ascending order. 'A' should be before '_'.",
61+
],
62+
},
63+
{
64+
code: "{$:1, A:3, _:2, a:4}",
65+
output: "{$:1, _:2, A:3, a:4}",
66+
options: ["asc", { natural: true }],
67+
errors: [
68+
"Expected object keys to be in natural ascending order. '_' should be before 'A'.",
69+
],
70+
},
71+
72+
{
73+
code: "{a:1, A: 2, B:3, b:4}",
74+
output: "{ A: 2,a:1, B:3, b:4}",
75+
errors: [
76+
"Expected object keys to be in ascending order. 'A' should be before 'a'.",
77+
],
78+
},
79+
{
80+
code: "{a:1, B:3, b:4, A: 2, c: 5, C: 6}",
81+
output: "{a:1, A: 2, B:3, b:4, c: 5, C: 6}",
82+
options: ["asc", { caseSensitive: false }],
83+
errors: [
84+
"Expected object keys to be in insensitive ascending order. 'A' should be before 'b'.",
85+
],
86+
},
87+
{
88+
code: '{"a": 1, "b": 2, "c": 3}',
89+
output: '{ "b": 2,"a": 1, "c": 3}',
90+
options: ["desc"],
91+
errors: [
92+
"Expected object keys to be in descending order. 'b' should be before 'a'.",
93+
"Expected object keys to be in descending order. 'c' should be before 'b'.",
94+
],
95+
},
1796
],
1897
})

typings/eslint-utils/index.d.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { Comment } from "estree"
2+
import type { AST } from "eslint"
3+
4+
declare module "eslint-utils" {
5+
export const findVariable: unknown
6+
export const getFunctionHeadLocation: unknown
7+
export const getFunctionNameWithKind: unknown
8+
export const getInnermostScope: unknown
9+
export const getPropertyName: unknown
10+
export const getStaticValue: unknown
11+
export const getStringIfConstant: unknown
12+
export const hasSideEffect: unknown
13+
export const isArrowToken: unknown
14+
export const isClosingBraceToken: unknown
15+
export const isClosingBracketToken: unknown
16+
export const isClosingParenToken: unknown
17+
export const isColonToken: unknown
18+
export const isCommaToken: (
19+
token: AST.Token | Comment,
20+
) => token is AST.Token & { type: "Punctuator"; value: "," }
21+
export const isCommentToken: unknown
22+
export const isNotArrowToken: unknown
23+
export const isNotClosingBraceToken: unknown
24+
export const isNotClosingBracketToken: unknown
25+
export const isNotClosingParenToken: unknown
26+
export const isNotColonToken: unknown
27+
export const isNotCommaToken: unknown
28+
export const isNotCommentToken: unknown
29+
export const isNotOpeningBraceToken: unknown
30+
export const isNotOpeningBracketToken: unknown
31+
export const isNotOpeningParenToken: unknown
32+
export const isNotSemicolonToken: unknown
33+
export const isOpeningBraceToken: unknown
34+
export const isOpeningBracketToken: unknown
35+
export const isOpeningParenToken: unknown
36+
export const isParenthesized: unknown
37+
export const isSemicolonToken: unknown
38+
export const PatternMatcher: unknown
39+
export const ReferenceTracker: {
40+
READ: never
41+
CALL: never
42+
CONSTRUCT: never
43+
new (): never
44+
}
45+
}

0 commit comments

Comments
 (0)