UNPKG

@eslint/json

Version:

JSON linting plugin for ESLint

222 lines (221 loc) 9.76 kB
/** * @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;