Skip to content

Commit

Permalink
feat: Add sort-keys rule (#76)
Browse files Browse the repository at this point in the history
* add sort-keys

* adding tests for eslint/sort-keys

* made eslint/sort-keys tests work with json

* account for line skips

* more semantic sort-keys code

* Handle when object members are joined by a comment

* formatting

* add allowLineSeparatedGroups option

* Update src/rules/sort-keys.js

Co-authored-by: Nicholas C. Zakas <[email protected]>

* Update readme

* Add tests for nesting

* Add type fix for sort-keys meta.type

* Added some tests for json5

* mv natural-compare to dependencies

* rm types from sort-keys because it makes dist grumpy

* rm unneeded nullish check

* make sort-keys handle multiline comments

* tabs => space

* Update README.md

Co-authored-by: Nicholas C. Zakas <[email protected]>

* stylistic updates

* Commas do not count as group-separating lines

* forget about commas, just check if empty line outside of comment

* rm unused languageOptions

---------

Co-authored-by: Nicholas C. Zakas <[email protected]>
  • Loading branch information
RobertAKARobin and nzakas authored Jan 19, 2025
1 parent d09d8cf commit e68c247
Show file tree
Hide file tree
Showing 5 changed files with 2,216 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export default [
like integers but are too large, and
[subnormal numbers](https://en.wikipedia.org/wiki/Subnormal_number).
- `no-unnormalized-keys` - warns on keys containing [unnormalized characters](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/normalize#description). You can optionally specify the normalization form via `{ form: "form_name" }`, where `form_name` can be any of `"NFC"`, `"NFD"`, `"NFKC"`, or `"NFKD"`.
- `sort-keys` - warns when keys are not in the specified order. Based on the ESLint [`sort-keys`](https://eslint.org/docs/latest/rules/sort-keys) rule.
- `top-level-interop` - warns when the top-level item in the document is neither an array nor an object. This can be enabled to ensure maximal interoperability with the oldest JSON parsers.

## Configuration Comments
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"dependencies": {
"@eslint/core": "^0.10.0",
"@eslint/plugin-kit": "^0.2.5",
"@humanwhocodes/momoa": "^3.3.4"
"@humanwhocodes/momoa": "^3.3.4",
"natural-compare": "^1.4.0"
},
"devDependencies": {
"@types/eslint": "^8.56.10",
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import noDuplicateKeys from "./rules/no-duplicate-keys.js";
import noEmptyKeys from "./rules/no-empty-keys.js";
import noUnsafeValues from "./rules/no-unsafe-values.js";
import noUnnormalizedKeys from "./rules/no-unnormalized-keys.js";
import sortKeys from "./rules/sort-keys.js";
import topLevelInterop from "./rules/top-level-interop.js";

//-----------------------------------------------------------------------------
Expand All @@ -34,6 +35,7 @@ const plugin = {
"no-empty-keys": noEmptyKeys,
"no-unsafe-values": noUnsafeValues,
"no-unnormalized-keys": noUnnormalizedKeys,
"sort-keys": sortKeys,
"top-level-interop": topLevelInterop,
},
configs: {
Expand Down
178 changes: 178 additions & 0 deletions src/rules/sort-keys.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* @fileoverview Rule to require JSON object keys to be sorted. Copied largely from https://github.com/eslint/eslint/blob/main/lib/rules/sort-keys.js
* @author Robin Thomas
*/

import naturalCompare from "natural-compare";

const hasNonWhitespace = /\S/u;

const comparators = {
ascending: {
alphanumeric: {
sensitive: (a, b) => a <= b,
insensitive: (a, b) => a.toLowerCase() <= b.toLowerCase(),
},
natural: {
sensitive: (a, b) => naturalCompare(a, b) <= 0,
insensitive: (a, b) =>
naturalCompare(a.toLowerCase(), b.toLowerCase()) <= 0,
},
},
descending: {
alphanumeric: {
sensitive: (a, b) =>
comparators.ascending.alphanumeric.sensitive(b, a),
insensitive: (a, b) =>
comparators.ascending.alphanumeric.insensitive(b, a),
},
natural: {
sensitive: (a, b) => comparators.ascending.natural.sensitive(b, a),
insensitive: (a, b) =>
comparators.ascending.natural.insensitive(b, a),
},
},
};

function getKey(member) {
return member.name.type === `Identifier`
? member.name.name
: member.name.value;
}

export default {
meta: {
type: /** @type {const} */ ("suggestion"),

defaultOptions: [
"asc",
{
allowLineSeparatedGroups: false,
caseSensitive: true,
minKeys: 2,
natural: false,
},
],

docs: {
description: `Require JSON object keys to be sorted`,
},

messages: {
sortKeys:
"Expected object keys to be in {{sortName}} case-{{sensitivity}} {{direction}} order. '{{thisName}}' should be before '{{prevName}}'.",
},

schema: [
{
enum: ["asc", "desc"],
},
{
type: "object",
properties: {
caseSensitive: {
type: "boolean",
},
natural: {
type: "boolean",
},
minKeys: {
type: "integer",
minimum: 2,
},
allowLineSeparatedGroups: {
type: "boolean",
},
},
additionalProperties: false,
},
],
},

create(context) {
const [
directionShort,
{ allowLineSeparatedGroups, caseSensitive, natural, minKeys },
] = context.options;

const direction = directionShort === "asc" ? "ascending" : "descending";
const sortName = natural ? "natural" : "alphanumeric";
const sensitivity = caseSensitive ? "sensitive" : "insensitive";
const isValidOrder = comparators[direction][sortName][sensitivity];

// Note that @humanwhocodes/momoa doesn't include comments in the object.members tree, so we can't just see if a member is preceded by a comment
const commentLineNums = new Set();
for (const comment of context.sourceCode.comments) {
for (
let lineNum = comment.loc.start.line;
lineNum <= comment.loc.end.line;
lineNum += 1
) {
commentLineNums.add(lineNum);
}
}

// Note that there can be comments *inside* members, e.g. `{"foo: /* comment *\/ "bar"}`, but these are ignored when calculating line-separated groups
function isLineSeparated(prevMember, member) {
const prevMemberEndLine = prevMember.loc.end.line;
const thisStartLine = member.loc.start.line;
if (thisStartLine - prevMemberEndLine < 2) {
return false;
}

for (
let lineNum = prevMemberEndLine + 1;
lineNum < thisStartLine;
lineNum += 1
) {
if (
!commentLineNums.has(lineNum) &&
!hasNonWhitespace.test(
context.sourceCode.lines[lineNum - 1],
)
) {
return true;
}
}

return false;
}

return {
Object(node) {
let prevMember;
let prevName;

if (node.members.length < minKeys) {
return;
}

for (const member of node.members) {
const thisName = getKey(member);

if (
prevMember &&
!isValidOrder(prevName, thisName) &&
(!allowLineSeparatedGroups ||
!isLineSeparated(prevMember, member))
) {
context.report({
loc: member.name.loc,
messageId: "sortKeys",
data: {
thisName,
prevName,
direction,
sensitivity,
sortName,
},
});
}

prevMember = member;
prevName = thisName;
}
},
};
},
};
Loading

0 comments on commit e68c247

Please sign in to comment.