@eslint/json
Version:
JSON linting plugin for ESLint
222 lines (221 loc) • 9.76 kB
JavaScript
/**
* @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
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import naturalCompare from "natural-compare";
import { getKey, getRawKey } from "../util.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
* @import { JSONRuleDefinition } from "../types.js";
* @import { MemberNode } from "@humanwhocodes/momoa";
* @typedef {Object} SortOptions
* @property {boolean} caseSensitive Whether key comparisons are case-sensitive.
* @property {boolean} natural Whether to use natural sort order instead of purely alphanumeric.
* @property {number} minKeys Minimum number of keys in an object before enforcing sorting.
* @property {boolean} allowLineSeparatedGroups Whether a blank line between properties starts a new group that is independently sorted.
* @typedef {"sortKeys"} SortKeysMessageIds
* @typedef {"asc"|"desc"} SortDirection
* @typedef {[SortDirection, SortOptions]} SortKeysRuleOptions
* @typedef {JSONRuleDefinition<{ RuleOptions: SortKeysRuleOptions, MessageIds: SortKeysMessageIds }>} SortKeysRuleDefinition
* @typedef {(a:string,b:string) => boolean} Comparator
* @typedef {"ascending"|"descending"} DirectionName
* @typedef {"alphanumeric"|"natural"} SortName
* @typedef {"sensitive"|"insensitive"} Sensitivity
* @typedef {Record<DirectionName, Record<SortName, Record<Sensitivity, Comparator>>>} ComparatorMap
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const hasNonWhitespace = /\S/u;
const commentTypes = new Set(["LineComment", "BlockComment"]);
/** @type {ComparatorMap} */
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),
},
},
};
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {SortKeysRuleDefinition} */
const rule = {
meta: {
type: "suggestion",
languages: ["json/json", "json/jsonc", "json/json5"],
fixable: "code",
defaultOptions: [
"asc",
{
allowLineSeparatedGroups: false,
caseSensitive: true,
minKeys: 2,
natural: false,
},
],
docs: {
recommended: false,
description: `Require JSON object keys to be sorted`,
dialects: ["JSON", "JSONC", "JSON5"],
url: "https://github.com/eslint/json/tree/main/docs/rules/sort-keys.md",
},
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 { sourceCode } = context;
const [directionShort, { allowLineSeparatedGroups, caseSensitive, natural, minKeys },] = context.options;
/** @type {DirectionName} */
const direction = directionShort === "asc" ? "ascending" : "descending";
/** @type {SortName} */
const sortName = natural ? "natural" : "alphanumeric";
/** @type {Sensitivity} */
const sensitivity = caseSensitive ? "sensitive" : "insensitive";
/** @type {Comparator} */
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 sourceCode.comments) {
for (let lineNum = comment.loc.start.line; lineNum <= comment.loc.end.line; lineNum += 1) {
commentLineNums.add(lineNum);
}
}
/**
* Checks if two members are line-separated.
* @param {MemberNode} prevMember The previous member.
* @param {MemberNode} member The current member.
* @returns {boolean} True if the members are separated by at least one blank line (ignoring comment-only lines).
*/
function isLineSeparated(prevMember, member) {
// Note that there can be comments *inside* members, e.g. `{"foo: /* comment *\/ "bar"}`, but these are ignored when calculating line-separated groups
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(sourceCode.lines[lineNum - 1])) {
return true;
}
}
return false;
}
/**
* Checks if a member has a comment before or after it.
* @param {MemberNode} member The member to check.
* @returns {boolean} True if a comment is adjacent to the member.
*/
function hasAdjacentComment(member) {
const before = sourceCode.getTokenBefore(member, {
includeComments: true,
});
let after = sourceCode.getTokenAfter(member, {
includeComments: true,
});
if (after.type === "Comma") {
after = sourceCode.getTokenAfter(after, {
includeComments: true,
});
}
return (commentTypes.has(before.type) || commentTypes.has(after.type));
}
return {
Object(node) {
/** @type {MemberNode} */
let prevMember;
/** @type {string} */
let prevName;
/** @type {string} */
let prevRawName;
if (node.members.length < minKeys) {
return;
}
for (const member of node.members) {
const thisName = getKey(member);
const thisRawName = getRawKey(member, sourceCode);
// Capture `prevMember` for this iteration so the fixer closure uses the
// intended node even though `prevMember` is reassigned in the loop.
const prevMemberNode = prevMember;
if (prevMember &&
!isValidOrder(prevName, thisName) &&
(!allowLineSeparatedGroups ||
!isLineSeparated(prevMember, member))) {
context.report({
loc: member.name.loc,
messageId: "sortKeys",
data: {
thisName: thisRawName,
prevName: prevRawName,
direction,
sensitivity,
sortName,
},
fix(fixer) {
if (hasAdjacentComment(member) ||
hasAdjacentComment(prevMemberNode)) {
return null;
}
return [
fixer.replaceText(member, sourceCode.getText(prevMemberNode)),
fixer.replaceText(prevMemberNode, sourceCode.getText(member)),
];
},
});
}
prevMember = member;
prevName = thisName;
prevRawName = thisRawName;
}
},
};
},
};
export default rule;