@eslint/json
Version:
JSON linting plugin for ESLint
1,268 lines (1,087 loc) • 32.4 kB
JavaScript
/**
* @import { DocumentNode, AnyNode, Token, MemberNode } from "@humanwhocodes/momoa"
* @import { SourceLocation, FileProblem, DirectiveType, RulesConfig, Language, OkParseResult, ParseResult, File } from "@eslint/core"
* @import { JSONSyntaxElement, JSONRuleDefinition } from "./types.ts"
*/
;
Object.defineProperty(exports, '__esModule', { value: true });
var momoa = require('@humanwhocodes/momoa');
var pluginKit = require('@eslint/plugin-kit');
var naturalCompare = require('natural-compare');
/**
* @fileoverview The JSONSourceCode class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const commentParser = new pluginKit.ConfigCommentParser();
const INLINE_CONFIG =
/^\s*eslint(?:-enable|-disable(?:(?:-next)?-line)?)?(?:\s|$)/u;
/**
* A class to represent a step in the traversal process.
*/
class JSONTraversalStep extends pluginKit.VisitNodeStep {
/**
* The target of the step.
* @type {AnyNode}
*/
target = undefined;
/**
* Creates a new instance.
* @param {Object} options The options for the step.
* @param {AnyNode} options.target The target of the step.
* @param {1|2} options.phase The phase of the step.
* @param {Array<any>} options.args The arguments of the step.
*/
constructor({ target, phase, args }) {
super({ target, phase, args });
this.target = target;
}
}
/**
* Processes tokens to extract comments and their starting tokens.
* @param {Array<Token>} tokens The tokens to process.
* @returns {{ comments: Array<Token>, starts: Map<number, number>, ends: Map<number, number>}}
* An object containing an array of comments, a map of starting token range to token index, and
* a map of ending token range to token index.
*/
function processTokens(tokens) {
/** @type {Array<Token>} */
const comments = [];
/** @type {Map<number, number>} */
const starts = new Map();
/** @type {Map<number, number>} */
const ends = new Map();
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type.endsWith("Comment")) {
comments.push(token);
}
starts.set(token.range[0], i);
ends.set(token.range[1], i);
}
return { comments, starts, ends };
}
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* JSON Source Code Object
* @extends {TextSourceCodeBase<{LangOptions: JSONLanguageOptions, RootNode: DocumentNode, SyntaxElementWithLoc: JSONSyntaxElement, ConfigNode: Token}>}
*/
class JSONSourceCode extends pluginKit.TextSourceCodeBase {
/**
* Cached traversal steps.
* @type {Array<JSONTraversalStep>|undefined}
*/
#steps;
/**
* Cache of parent nodes.
* @type {WeakMap<AnyNode, AnyNode>}
*/
#parents = new WeakMap();
/**
* Collection of inline configuration comments.
* @type {Array<Token>}
*/
#inlineConfigComments;
/**
* The AST of the source code.
* @type {DocumentNode}
*/
ast = undefined;
/**
* The comment tokens in the source code.
* @type {Array<Token>|undefined}
*/
comments;
/**
* A map of token start positions to their corresponding index.
* @type {Map<number, number>}
*/
#tokenStarts;
/**
* A map of token end positions to their corresponding index.
* @type {Map<number, number>}
*/
#tokenEnds;
/**
* Creates a new instance.
* @param {Object} options The options for the instance.
* @param {string} options.text The source code text.
* @param {DocumentNode} options.ast The root AST node.
*/
constructor({ text, ast }) {
super({ text, ast });
this.ast = ast;
const { comments, starts, ends } = processTokens(this.ast.tokens ?? []);
this.comments = comments;
this.#tokenStarts = starts;
this.#tokenEnds = ends;
}
/**
* Returns the value of the given comment.
* @param {Token} comment The comment to get the value of.
* @returns {string} The value of the comment.
* @throws {Error} When an unexpected comment type is passed.
*/
#getCommentValue(comment) {
if (comment.type === "LineComment") {
return this.getText(comment).slice(2); // strip leading `//`
}
if (comment.type === "BlockComment") {
return this.getText(comment).slice(2, -2); // strip leading `/*` and trailing `*/`
}
throw new Error(`Unexpected comment type '${comment.type}'`);
}
/**
* Returns an array of all inline configuration nodes found in the
* source code.
* @returns {Array<Token>} An array of all inline configuration nodes.
*/
getInlineConfigNodes() {
if (!this.#inlineConfigComments) {
this.#inlineConfigComments = this.comments.filter(comment =>
INLINE_CONFIG.test(this.#getCommentValue(comment)),
);
}
return this.#inlineConfigComments ?? [];
}
/**
* Returns directives that enable or disable rules along with any problems
* encountered while parsing the directives.
* @returns {{problems:Array<FileProblem>,directives:Array<Directive>}} Information
* that ESLint needs to further process the directives.
*/
getDisableDirectives() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<Directive>} */
const directives = [];
this.getInlineConfigNodes().forEach(comment => {
const { label, value, justification } =
commentParser.parseDirective(this.#getCommentValue(comment));
// `eslint-disable-line` directives are not allowed to span multiple lines as it would be confusing to which lines they apply
if (
label === "eslint-disable-line" &&
comment.loc.start.line !== comment.loc.end.line
) {
const message = `${label} comment should not span multiple lines.`;
problems.push({
ruleId: null,
message,
loc: comment.loc,
});
return;
}
switch (label) {
case "eslint-disable":
case "eslint-enable":
case "eslint-disable-next-line":
case "eslint-disable-line": {
const directiveType = label.slice("eslint-".length);
directives.push(
new pluginKit.Directive({
type: /** @type {DirectiveType} */ (directiveType),
node: comment,
value,
justification,
}),
);
}
// no default
}
});
return { problems, directives };
}
/**
* Returns inline rule configurations along with any problems
* encountered while parsing the configurations.
* @returns {{problems:Array<FileProblem>,configs:Array<{config:{rules:RulesConfig},loc:SourceLocation}>}} Information
* that ESLint needs to further process the rule configurations.
*/
applyInlineConfig() {
/** @type {Array<FileProblem>} */
const problems = [];
/** @type {Array<{config:{rules:RulesConfig},loc:SourceLocation}>} */
const configs = [];
this.getInlineConfigNodes().forEach(comment => {
const { label, value } = commentParser.parseDirective(
this.#getCommentValue(comment),
);
if (label === "eslint") {
const parseResult = commentParser.parseJSONLikeConfig(value);
if (parseResult.ok) {
configs.push({
config: {
rules: parseResult.config,
},
loc: comment.loc,
});
} else {
problems.push({
ruleId: null,
message:
/** @type {{ok: false, error: { message: string }}} */ (
parseResult
).error.message,
loc: comment.loc,
});
}
}
});
return {
configs,
problems,
};
}
/**
* Returns the parent of the given node.
* @param {AnyNode} node The node to get the parent of.
* @returns {AnyNode|undefined} The parent of the node.
*/
getParent(node) {
return this.#parents.get(node);
}
/**
* Traverse the source code and return the steps that were taken.
* @returns {Iterable<JSONTraversalStep>} The steps that were taken while traversing the source code.
*/
traverse() {
// Because the AST doesn't mutate, we can cache the steps
if (this.#steps) {
return this.#steps.values();
}
/** @type {Array<JSONTraversalStep>} */
const steps = (this.#steps = []);
for (const { node, parent, phase } of momoa.iterator(this.ast)) {
if (parent) {
this.#parents.set(
/** @type {AnyNode} */ (node),
/** @type {AnyNode} */ (parent),
);
}
steps.push(
new JSONTraversalStep({
target: /** @type {AnyNode} */ (node),
phase: phase === "enter" ? 1 : 2,
args: [node, parent],
}),
);
}
return steps;
}
/**
* Gets the token before the given node or token, optionally including comments.
* @param {AnyNode|Token} nodeOrToken The node or token to get the previous token for.
* @param {Object} [options] Options object.
* @param {boolean} [options.includeComments] If true, return comments when they are present.
* @returns {Token|null} The previous token or comment, or null if there is none.
*/
getTokenBefore(nodeOrToken, { includeComments = false } = {}) {
const index = this.#tokenStarts.get(nodeOrToken.range[0]);
if (index === undefined) {
return null;
}
let previousIndex = index - 1;
if (previousIndex < 0) {
return null;
}
const tokens = this.ast.tokens;
let tokenOrComment = tokens[previousIndex];
if (includeComments) {
return tokenOrComment;
}
// skip comments
while (tokenOrComment?.type.endsWith("Comment")) {
previousIndex--;
if (previousIndex < 0) {
return null;
}
tokenOrComment = tokens[previousIndex];
}
return tokenOrComment;
}
/**
* Gets the token after the given node or token, skipping any comments unless includeComments is true.
* @param {AnyNode|Token} nodeOrToken The node or token to get the next token for.
* @param {Object} [options] Options object.
* @param {boolean} [options.includeComments=false] If true, return comments when they are present.
* @returns {Token|null} The next token or comment, or null if there is none.
*/
getTokenAfter(nodeOrToken, { includeComments = false } = {}) {
const index = this.#tokenEnds.get(nodeOrToken.range[1]);
if (index === undefined) {
return null;
}
let nextIndex = index + 1;
const tokens = this.ast.tokens;
if (nextIndex >= tokens.length) {
return null;
}
let tokenOrComment = tokens[nextIndex];
if (includeComments) {
return tokenOrComment;
}
// skip comments
while (tokenOrComment?.type.endsWith("Comment")) {
nextIndex++;
if (nextIndex >= tokens.length) {
return null;
}
tokenOrComment = tokens[nextIndex];
}
return tokenOrComment;
}
}
/**
* @fileoverview The JSONLanguage class.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/**
*
* @typedef {OkParseResult<DocumentNode>} JSONOkParseResult
* @typedef {ParseResult<DocumentNode>} JSONParseResult
*
* @typedef {Object} JSONLanguageOptions
* @property {boolean} [allowTrailingCommas] Whether to allow trailing commas in JSONC mode.
*/
//-----------------------------------------------------------------------------
// Exports
//-----------------------------------------------------------------------------
/**
* JSON Language Object
* @implements {Language<{ LangOptions: JSONLanguageOptions; Code: JSONSourceCode; RootNode: DocumentNode; Node: AnyNode }>}
*/
class JSONLanguage {
/**
* The type of file to read.
* @type {"text"}
*/
fileType = "text";
/**
* The line number at which the parser starts counting.
* @type {0|1}
*/
lineStart = 1;
/**
* The column number at which the parser starts counting.
* @type {0|1}
*/
columnStart = 1;
/**
* The name of the key that holds the type of the node.
* @type {string}
*/
nodeTypeKey = "type";
/**
* The parser mode.
* @type {"json"|"jsonc"|"json5"}
*/
#mode = "json";
/**
* The visitor keys.
* @type {Record<string, string[]>}
*/
visitorKeys = Object.fromEntries([...momoa.visitorKeys]);
/**
* Creates a new instance.
* @param {Object} options The options to use for this instance.
* @param {"json"|"jsonc"|"json5"} options.mode The parser mode to use.
*/
constructor({ mode }) {
this.#mode = mode;
}
/**
* Validates the language options.
* @param {JSONLanguageOptions} languageOptions The language options to validate.
* @returns {void}
* @throws {Error} When the language options are invalid.
*/
validateLanguageOptions(languageOptions) {
if (languageOptions.allowTrailingCommas !== undefined) {
if (typeof languageOptions.allowTrailingCommas !== "boolean") {
throw new Error(
"allowTrailingCommas must be a boolean if provided.",
);
}
// we know that allowTrailingCommas is a boolean here
// only allowed in JSONC mode
if (this.#mode !== "jsonc") {
throw new Error(
"allowTrailingCommas option is only available in JSONC.",
);
}
}
}
/**
* Parses the given file into an AST.
* @param {File} file The virtual file to parse.
* @param {{languageOptions: JSONLanguageOptions}} context The options to use for parsing.
* @returns {JSONParseResult} The result of parsing.
*/
parse(file, context) {
// Note: BOM already removed
const text = /** @type {string} */ (file.body);
const allowTrailingCommas =
context?.languageOptions?.allowTrailingCommas;
/*
* Check for parsing errors first. If there's a parsing error, nothing
* else can happen. However, a parsing error does not throw an error
* from this method - it's just considered a fatal error message, a
* problem that ESLint identified just like any other.
*/
try {
const root = momoa.parse(text, {
mode: this.#mode,
ranges: true,
tokens: true,
allowTrailingCommas,
});
return {
ok: true,
ast: root,
};
} catch (ex) {
// error messages end with (line:column) so we strip that off for ESLint
const message = ex.message
.slice(0, ex.message.lastIndexOf("("))
.trim();
return {
ok: false,
errors: [
{
...ex,
message,
},
],
};
}
}
/* eslint-disable class-methods-use-this -- Required to complete interface. */
/**
* Creates a new `JSONSourceCode` object from the given information.
* @param {File} file The virtual file to create a `JSONSourceCode` object from.
* @param {JSONOkParseResult} parseResult The result returned from `parse()`.
* @returns {JSONSourceCode} The new `JSONSourceCode` object.
*/
createSourceCode(file, parseResult) {
return new JSONSourceCode({
text: /** @type {string} */ (file.body),
ast: parseResult.ast,
});
}
/* eslint-enable class-methods-use-this -- Required to complete interface. */
}
const rules$1 = /** @type {const} */ ({
"json/no-duplicate-keys": "error",
"json/no-empty-keys": "error",
"json/no-unnormalized-keys": "error",
"json/no-unsafe-values": "error"
});
/**
* @fileoverview Rule to prevent duplicate keys in JSON.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
*
* @typedef {"duplicateKey"} NoDuplicateKeysMessageIds
* @typedef {JSONRuleDefinition<{ MessageIds: NoDuplicateKeysMessageIds }>} NoDuplicateKeysRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {NoDuplicateKeysRuleDefinition} */
const rule$5 = {
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow duplicate keys in JSON objects",
url: "https://github.com/eslint/json/tree/main/docs/rules/no-duplicate-keys.md",
},
messages: {
duplicateKey: 'Duplicate key "{{key}}" found.',
},
},
create(context) {
/** @type {Array<Map<string, MemberNode>|undefined>} */
const objectKeys = [];
/** @type {Map<string, MemberNode>|undefined} */
let keys;
return {
Object() {
objectKeys.push(keys);
keys = new Map();
},
Member(node) {
const key =
node.name.type === "String"
? node.name.value
: node.name.name;
if (keys.has(key)) {
context.report({
loc: node.name.loc,
messageId: "duplicateKey",
data: {
key,
},
});
} else {
keys.set(key, node);
}
},
"Object:exit"() {
keys = objectKeys.pop();
},
};
},
};
/**
* @fileoverview Rule to prevent empty keys in JSON.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
*
* @typedef {"emptyKey"} NoEmptyKeysMessageIds
* @typedef {JSONRuleDefinition<{ MessageIds: NoEmptyKeysMessageIds }>} NoEmptyKeysRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {NoEmptyKeysRuleDefinition} */
const rule$4 = {
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow empty keys in JSON objects",
url: "https://github.com/eslint/json/tree/main/docs/rules/no-empty-keys.md",
},
messages: {
emptyKey: "Empty key found.",
},
},
create(context) {
return {
Member(node) {
const key =
node.name.type === "String"
? node.name.value
: node.name.name;
if (key.trim() === "") {
context.report({
loc: node.name.loc,
messageId: "emptyKey",
});
}
},
};
},
};
/**
* @fileoverview Rule to detect unnormalized keys in JSON.
* @author Bradley Meck Farias
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
*
* @typedef {"unnormalizedKey"} NoUnnormalizedKeysMessageIds
* @typedef {{ form: string }} NoUnnormalizedKeysOptions
* @typedef {JSONRuleDefinition<{ RuleOptions: [NoUnnormalizedKeysOptions], MessageIds: NoUnnormalizedKeysMessageIds }>} NoUnnormalizedKeysRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {NoUnnormalizedKeysRuleDefinition} */
const rule$3 = {
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow JSON keys that are not normalized",
url: "https://github.com/eslint/json/tree/main/docs/rules/no-unnormalized-keys.md",
},
messages: {
unnormalizedKey: "Unnormalized key '{{key}}' found.",
},
schema: [
{
type: "object",
properties: {
form: {
enum: ["NFC", "NFD", "NFKC", "NFKD"],
},
},
additionalProperties: false,
},
],
},
create(context) {
const form = context.options.length
? context.options[0].form
: undefined;
return {
Member(node) {
const key =
node.name.type === "String"
? node.name.value
: node.name.name;
if (key.normalize(form) !== key) {
context.report({
loc: node.name.loc,
messageId: "unnormalizedKey",
data: {
key,
},
});
}
},
};
},
};
/**
* @fileoverview Rule to detect unsafe values in JSON.
* @author Bradley Meck Farias
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
*
* @typedef {"unsafeNumber"|"unsafeInteger"|"unsafeZero"|"subnormal"|"loneSurrogate"} NoUnsafeValuesMessageIds
* @typedef {JSONRuleDefinition<{ MessageIds: NoUnsafeValuesMessageIds }>} NoUnsafeValuesRuleDefinition
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/*
* This rule is based on the JSON grammar from RFC 8259, section 6.
* https://tools.ietf.org/html/rfc8259#section-6
*
* We separately capture the integer and fractional parts of a number, so that
* we can check for unsafe numbers that will evaluate to Infinity.
*/
const NUMBER = /^-?(?<int>0|([1-9]\d*))(?:\.(?<frac>\d+))?(?:e[+-]?\d+)?$/iu;
const NON_ZERO = /[1-9]/u;
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {NoUnsafeValuesRuleDefinition} */
const rule$2 = {
meta: {
type: "problem",
docs: {
recommended: true,
description: "Disallow JSON values that are unsafe for interchange",
url: "https://github.com/eslint/json/tree/main/docs/rules/no-unsafe-values.md",
},
messages: {
unsafeNumber: "The number '{{ value }}' will evaluate to Infinity.",
unsafeInteger:
"The integer '{{ value }}' is outside the safe integer range.",
unsafeZero: "The number '{{ value }}' will evaluate to zero.",
subnormal:
"Unexpected subnormal number '{{ value }}' found, which may cause interoperability issues.",
loneSurrogate: "Lone surrogate '{{ surrogate }}' found.",
},
},
create(context) {
return {
Number(node) {
const value = context.sourceCode.getText(node);
if (Number.isFinite(node.value) !== true) {
context.report({
loc: node.loc,
messageId: "unsafeNumber",
data: { value },
});
} else {
// Also matches -0, intentionally
if (node.value === 0) {
// If the value has been rounded down to 0, but there was some
// fraction or non-zero part before the e-, this is a very small
// number that doesn't fit inside an f64.
const match = value.match(NUMBER);
// assert(match, "If the regex is right, match is always truthy")
// If any part of the number other than the exponent has a
// non-zero digit in it, this number was not intended to be
// evaluated down to a zero.
if (
NON_ZERO.test(match.groups.int) ||
NON_ZERO.test(match.groups.frac)
) {
context.report({
loc: node.loc,
messageId: "unsafeZero",
data: { value },
});
}
} else if (!/[.e]/iu.test(value)) {
// Intended to be an integer
if (
node.value > Number.MAX_SAFE_INTEGER ||
node.value < Number.MIN_SAFE_INTEGER
) {
context.report({
loc: node.loc,
messageId: "unsafeInteger",
data: { value },
});
}
} else {
// Floating point. Check for subnormal.
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setFloat64(0, node.value, false);
const asBigInt = view.getBigUint64(0, false);
// Subnormals have an 11-bit exponent of 0 and a non-zero mantissa.
if ((asBigInt & 0x7ff0000000000000n) === 0n) {
context.report({
loc: node.loc,
messageId: "subnormal",
// Value included so that it's seen in scientific notation
data: {
value,
},
});
}
}
}
},
String(node) {
if (node.value.isWellFormed) {
if (node.value.isWellFormed()) {
return;
}
}
// match any high surrogate and, if it exists, a paired low surrogate
// match any low surrogate not already matched
const surrogatePattern =
/[\uD800-\uDBFF][\uDC00-\uDFFF]?|[\uDC00-\uDFFF]/gu;
let match = surrogatePattern.exec(node.value);
while (match) {
// only need to report non-paired surrogates
if (match[0].length < 2) {
context.report({
loc: node.loc,
messageId: "loneSurrogate",
data: {
surrogate: JSON.stringify(match[0]).slice(
1,
-1,
),
},
});
}
match = surrogatePattern.exec(node.value);
}
},
};
},
};
/**
* @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
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
*
* @typedef {Object} SortOptions
* @property {boolean} caseSensitive
* @property {boolean} natural
* @property {number} minKeys
* @property {boolean} allowLineSeparatedGroups
*
* @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
*/
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const hasNonWhitespace = /\S/u;
const comparators = {
ascending: {
alphanumeric: {
/** @type {Comparator} */
sensitive: (a, b) => a <= b,
/** @type {Comparator} */
insensitive: (a, b) => a.toLowerCase() <= b.toLowerCase(),
},
natural: {
/** @type {Comparator} */
sensitive: (a, b) => naturalCompare(a, b) <= 0,
/** @type {Comparator} */
insensitive: (a, b) =>
naturalCompare(a.toLowerCase(), b.toLowerCase()) <= 0,
},
},
descending: {
alphanumeric: {
/** @type {Comparator} */
sensitive: (a, b) =>
comparators.ascending.alphanumeric.sensitive(b, a),
/** @type {Comparator} */
insensitive: (a, b) =>
comparators.ascending.alphanumeric.insensitive(b, a),
},
natural: {
/** @type {Comparator} */
sensitive: (a, b) => comparators.ascending.natural.sensitive(b, a),
/** @type {Comparator} */
insensitive: (a, b) =>
comparators.ascending.natural.insensitive(b, a),
},
},
};
/**
* Gets the MemberNode's string key value.
* @param {MemberNode} member
* @return {string}
*/
function getKey(member) {
return member.name.type === "Identifier"
? member.name.name
: member.name.value;
}
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {SortKeysRuleDefinition} */
const rule$1 = {
meta: {
type: "suggestion",
defaultOptions: [
"asc",
{
allowLineSeparatedGroups: false,
caseSensitive: true,
minKeys: 2,
natural: false,
},
],
docs: {
recommended: false,
description: `Require JSON object keys to be sorted`,
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 [
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);
}
}
/**
* Checks if two members are line-separated.
* @param {MemberNode} prevMember The previous member.
* @param {MemberNode} member The current member.
* @return {boolean}
*/
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(
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;
}
},
};
},
};
/**
* @fileoverview Rule to ensure top-level items are either an array or object.
* @author Joe Hildebrand
*/
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/**
*
* @typedef {"topLevel"} TopLevelInteropMessageIds
* @typedef {JSONRuleDefinition<{ MessageIds: TopLevelInteropMessageIds }>} TopLevelInteropRuleDefinition
*/
//-----------------------------------------------------------------------------
// Rule Definition
//-----------------------------------------------------------------------------
/** @type {TopLevelInteropRuleDefinition} */
const rule = {
meta: {
type: "problem",
docs: {
recommended: false,
description:
"Require the JSON top-level value to be an array or object",
url: "https://github.com/eslint/json/tree/main/docs/rules/top-level-interop.md",
},
messages: {
topLevel:
"Top level item should be array or object, got '{{type}}'.",
},
},
create(context) {
return {
Document(node) {
const { type } = node.body;
if (type !== "Object" && type !== "Array") {
context.report({
loc: node.loc,
messageId: "topLevel",
data: { type },
});
}
},
};
},
};
var rules = {
"no-duplicate-keys": rule$5,
"no-empty-keys": rule$4,
"no-unnormalized-keys": rule$3,
"no-unsafe-values": rule$2,
"sort-keys": rule$1,
"top-level-interop": rule,
};
/**
* @fileoverview JSON plugin.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Plugin
//-----------------------------------------------------------------------------
const plugin = {
meta: {
name: "@eslint/json",
version: "0.13.2", // x-release-please-version
},
languages: {
json: new JSONLanguage({ mode: "json" }),
jsonc: new JSONLanguage({ mode: "jsonc" }),
json5: new JSONLanguage({ mode: "json5" }),
},
rules,
configs: {
recommended: {
plugins: {},
rules: rules$1,
},
},
};
// eslint-disable-next-line no-lone-blocks -- The block syntax { ... } ensures that TypeScript does not get confused about the type of `plugin`.
{
plugin.configs.recommended.plugins.json = plugin;
}
exports.JSONLanguage = JSONLanguage;
exports.JSONSourceCode = JSONSourceCode;
exports.default = plugin;