Skip to content

Commit 48d3669

Browse files
authored
feat: add new allowLineSeparatedGroups option to the jsonc/sort-keys rule (#243)
1 parent 8da0774 commit 48d3669

File tree

4 files changed

+563
-30
lines changed

4 files changed

+563
-30
lines changed

.changeset/lovely-donkeys-pay.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"eslint-plugin-jsonc": minor
3+
---
4+
5+
feat: add new `allowLineSeparatedGroups` option to the `jsonc/sort-keys` rule

docs/rules/sort-keys.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ The option receives multiple objects with the following properties:
103103
- `caseSensitive` ... If `true`, enforce properties to be in case-sensitive order. Default is `true`.
104104
- `natural` ... If `true`, enforce properties to be in natural order. Default is `false`.
105105
- `minKeys` ... Specifies the minimum number of keys that an object should have in order for the object's unsorted keys to produce an error. Default is `2`, which means by default all objects with unsorted keys will result in lint errors.
106+
- `allowLineSeparatedGroups` ... If `true`, the rule allows to group object keys through line breaks. In other words, a blank line after a property will reset the sorting of keys. Default is `false`.
106107

107108
You can also define options in the same format as the [sort-keys] rule.
108109

@@ -113,7 +114,8 @@ You can also define options in the same format as the [sort-keys] rule.
113114
{
114115
"caseSensitive": true,
115116
"natural": false,
116-
"minKeys": 2
117+
"minKeys": 2,
118+
"allowLineSeparatedGroups": false
117119
}
118120
]
119121
}

lib/rules/sort-keys.ts

+100-29
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type CompatibleWithESLintOptions =
2020
caseSensitive?: boolean;
2121
natural?: boolean;
2222
minKeys?: number;
23+
allowLineSeparatedGroups?: boolean;
2324
}
2425
];
2526
type PatternOption = {
@@ -35,6 +36,7 @@ type PatternOption = {
3536
}
3637
)[];
3738
minKeys?: number;
39+
allowLineSeparatedGroups?: boolean;
3840
};
3941
type OrderObject = {
4042
type?: OrderTypeOption;
@@ -45,6 +47,7 @@ type ParsedOption = {
4547
isTargetObject: (node: JSONObjectData) => boolean;
4648
ignore: (data: JSONPropertyData) => boolean;
4749
isValidOrder: Validator;
50+
allowLineSeparatedGroups: boolean;
4851
orderText: string;
4952
};
5053
type Validator = (a: JSONPropertyData, b: JSONPropertyData) => boolean;
@@ -86,6 +89,11 @@ class JSONPropertyData {
8689
public get name() {
8790
return (this.cachedName ??= getPropertyName(this.node));
8891
}
92+
93+
public getPrev(): JSONPropertyData | null {
94+
const prevIndex = this.index - 1;
95+
return prevIndex >= 0 ? this.object.properties[prevIndex] : null;
96+
}
8997
}
9098
class JSONObjectData {
9199
public readonly node: AST.JSONObjectExpression;
@@ -101,6 +109,36 @@ class JSONObjectData {
101109
(e, index) => new JSONPropertyData(this, e, index)
102110
));
103111
}
112+
113+
public getPath(): string {
114+
let path = "";
115+
let curr: AST.JSONExpression = this.node;
116+
let p: AST.JSONNode | null = curr.parent;
117+
while (p) {
118+
if (p.type === "JSONProperty") {
119+
const name = getPropertyName(p);
120+
if (/^[$_a-z][\w$]*$/iu.test(name)) {
121+
path = `.${name}${path}`;
122+
} else {
123+
path = `[${JSON.stringify(name)}]${path}`;
124+
}
125+
curr = p.parent;
126+
} else if (p.type === "JSONArrayExpression") {
127+
const index = p.elements.indexOf(curr);
128+
path = `[${index}]${path}`;
129+
curr = p;
130+
} else if (p.type === "JSONExpressionStatement") {
131+
break;
132+
} else {
133+
curr = p;
134+
}
135+
p = curr.parent;
136+
}
137+
if (path.startsWith(".")) {
138+
path = path.slice(1);
139+
}
140+
return path;
141+
}
104142
}
105143

106144
/**
@@ -153,6 +191,7 @@ function parseOptions(options: UserOptions): ParsedOption[] {
153191
const insensitive = obj.caseSensitive === false;
154192
const natural = Boolean(obj.natural);
155193
const minKeys: number = obj.minKeys ?? 2;
194+
const allowLineSeparatedGroups = obj.allowLineSeparatedGroups || false;
156195
return [
157196
{
158197
isTargetObject: (node) => node.properties.length >= minKeys,
@@ -161,6 +200,7 @@ function parseOptions(options: UserOptions): ParsedOption[] {
161200
orderText: `${natural ? "natural " : ""}${
162201
insensitive ? "insensitive " : ""
163202
}${type}ending`,
203+
allowLineSeparatedGroups,
164204
},
165205
];
166206
}
@@ -170,6 +210,7 @@ function parseOptions(options: UserOptions): ParsedOption[] {
170210
const pathPattern = new RegExp(opt.pathPattern);
171211
const hasProperties = opt.hasProperties ?? [];
172212
const minKeys: number = opt.minKeys ?? 2;
213+
const allowLineSeparatedGroups = opt.allowLineSeparatedGroups || false;
173214
if (!Array.isArray(order)) {
174215
const type: OrderTypeOption = order.type ?? "asc";
175216
const insensitive = order.caseSensitive === false;
@@ -182,6 +223,7 @@ function parseOptions(options: UserOptions): ParsedOption[] {
182223
orderText: `${natural ? "natural " : ""}${
183224
insensitive ? "insensitive " : ""
184225
}${type}ending`,
226+
allowLineSeparatedGroups,
185227
};
186228
}
187229
const parsedOrder: {
@@ -227,6 +269,7 @@ function parseOptions(options: UserOptions): ParsedOption[] {
227269
return false;
228270
},
229271
orderText: "specified",
272+
allowLineSeparatedGroups,
230273
};
231274

232275
/**
@@ -242,29 +285,7 @@ function parseOptions(options: UserOptions): ParsedOption[] {
242285
return false;
243286
}
244287
}
245-
246-
let path = "";
247-
let curr: AST.JSONNode = data.node;
248-
let p: AST.JSONNode | null = curr.parent;
249-
while (p) {
250-
if (p.type === "JSONProperty") {
251-
const name = getPropertyName(p);
252-
if (/^[$_a-z][\w$]*$/iu.test(name)) {
253-
path = `.${name}${path}`;
254-
} else {
255-
path = `[${JSON.stringify(name)}]${path}`;
256-
}
257-
} else if (p.type === "JSONArrayExpression") {
258-
const index = p.elements.indexOf(curr as never);
259-
path = `[${index}]${path}`;
260-
}
261-
curr = p;
262-
p = curr.parent;
263-
}
264-
if (path.startsWith(".")) {
265-
path = path.slice(1);
266-
}
267-
return pathPattern.test(path);
288+
return pathPattern.test(data.getPath());
268289
}
269290
});
270291
}
@@ -339,6 +360,9 @@ export default createRule("sort-keys", {
339360
type: "integer",
340361
minimum: 2,
341362
},
363+
allowLineSeparatedGroups: {
364+
type: "boolean",
365+
},
342366
},
343367
required: ["pathPattern", "order"],
344368
additionalProperties: false,
@@ -365,6 +389,9 @@ export default createRule("sort-keys", {
365389
type: "integer",
366390
minimum: 2,
367391
},
392+
allowLineSeparatedGroups: {
393+
type: "boolean",
394+
},
368395
},
369396
additionalProperties: false,
370397
},
@@ -387,17 +414,30 @@ export default createRule("sort-keys", {
387414
// Parse options.
388415
const parsedOptions = parseOptions(context.options);
389416

417+
const sourceCode = context.getSourceCode();
418+
390419
/**
391420
* Verify for property
392421
*/
393422
function verifyProperty(data: JSONPropertyData, option: ParsedOption) {
394423
if (option.ignore(data)) {
395424
return;
396425
}
397-
const prevList = data.object.properties
398-
.slice(0, data.index)
399-
.reverse()
400-
.filter((d) => !option.ignore(d));
426+
const prevList: JSONPropertyData[] = [];
427+
let currTarget = data;
428+
let prevTarget;
429+
while ((prevTarget = currTarget.getPrev())) {
430+
if (option.allowLineSeparatedGroups) {
431+
if (hasBlankLine(prevTarget, currTarget)) {
432+
break;
433+
}
434+
}
435+
436+
if (!option.ignore(prevTarget)) {
437+
prevList.push(prevTarget);
438+
}
439+
currTarget = prevTarget;
440+
}
401441

402442
if (prevList.length === 0) {
403443
return;
@@ -413,7 +453,6 @@ export default createRule("sort-keys", {
413453
orderText: option.orderText,
414454
},
415455
*fix(fixer) {
416-
const sourceCode = context.getSourceCode();
417456
let moveTarget = prevList[0];
418457
for (const prev of prevList) {
419458
if (option.isValidOrder(prev, data)) {
@@ -441,14 +480,46 @@ export default createRule("sort-keys", {
441480
const insertTarget = sourceCode.getTokenBefore(
442481
moveTarget.node as never
443482
)!;
444-
yield fixer.insertTextAfterRange(insertTarget.range, insertCode);
483+
let insertRange = insertTarget.range;
484+
const insertNext = sourceCode.getTokenAfter(insertTarget, {
485+
includeComments: true,
486+
})!;
487+
if (insertNext.loc!.start.line - insertTarget.loc.end.line > 1) {
488+
const offset = sourceCode.getIndexFromLoc({
489+
line: insertNext.loc!.start.line - 1,
490+
column: 0,
491+
});
492+
insertRange = [offset, offset];
493+
}
494+
yield fixer.insertTextAfterRange(insertRange, insertCode);
445495

446496
yield fixer.removeRange([removeStart, codeEnd]);
447497
},
448498
});
449499
}
450500
}
451501

502+
/**
503+
* Checks whether the given two properties have a blank line between them.
504+
*/
505+
function hasBlankLine(prev: JSONPropertyData, next: JSONPropertyData) {
506+
const tokenOrNodes = [
507+
...sourceCode.getTokensBetween(prev.node as never, next.node as never, {
508+
includeComments: true,
509+
}),
510+
next.node,
511+
];
512+
let prevLoc = prev.node.loc;
513+
for (const t of tokenOrNodes) {
514+
const loc = t.loc!;
515+
if (loc.start.line - prevLoc.end.line > 1) {
516+
return true;
517+
}
518+
prevLoc = loc;
519+
}
520+
return false;
521+
}
522+
452523
return {
453524
JSONObjectExpression(node: AST.JSONObjectExpression) {
454525
const data = new JSONObjectData(node);

0 commit comments

Comments
 (0)