config.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. /**
  2. * @fileoverview The `Config` class
  3. * @author Nicholas C. Zakas
  4. */
  5. "use strict";
  6. //-----------------------------------------------------------------------------
  7. // Requirements
  8. //-----------------------------------------------------------------------------
  9. const { deepMergeArrays } = require("../shared/deep-merge-arrays");
  10. const { flatConfigSchema, hasMethod } = require("./flat-config-schema");
  11. const { ObjectSchema } = require("@eslint/config-array");
  12. const ajvImport = require("../shared/ajv");
  13. const ajv = ajvImport();
  14. const ruleReplacements = require("../../conf/replacements.json");
  15. //-----------------------------------------------------------------------------
  16. // Typedefs
  17. //-----------------------------------------------------------------------------
  18. /**
  19. * @import { RuleDefinition } from "@eslint/core";
  20. * @import { Linter } from "eslint";
  21. */
  22. //-----------------------------------------------------------------------------
  23. // Private Members
  24. //------------------------------------------------------------------------------
  25. // JSON schema that disallows passing any options
  26. const noOptionsSchema = Object.freeze({
  27. type: "array",
  28. minItems: 0,
  29. maxItems: 0,
  30. });
  31. const severities = new Map([
  32. [0, 0],
  33. [1, 1],
  34. [2, 2],
  35. ["off", 0],
  36. ["warn", 1],
  37. ["error", 2],
  38. ]);
  39. /**
  40. * A collection of compiled validators for rules that have already
  41. * been validated.
  42. * @type {WeakMap}
  43. */
  44. const validators = new WeakMap();
  45. //-----------------------------------------------------------------------------
  46. // Helpers
  47. //-----------------------------------------------------------------------------
  48. /**
  49. * Throws a helpful error when a rule cannot be found.
  50. * @param {Object} ruleId The rule identifier.
  51. * @param {string} ruleId.pluginName The ID of the rule to find.
  52. * @param {string} ruleId.ruleName The ID of the rule to find.
  53. * @param {Object} config The config to search in.
  54. * @throws {TypeError} For missing plugin or rule.
  55. * @returns {void}
  56. */
  57. function throwRuleNotFoundError({ pluginName, ruleName }, config) {
  58. const ruleId = pluginName === "@" ? ruleName : `${pluginName}/${ruleName}`;
  59. const errorMessageHeader = `Key "rules": Key "${ruleId}"`;
  60. let errorMessage = `${errorMessageHeader}: Could not find plugin "${pluginName}" in configuration.`;
  61. const missingPluginErrorMessage = errorMessage;
  62. // if the plugin exists then we need to check if the rule exists
  63. if (config.plugins && config.plugins[pluginName]) {
  64. const replacementRuleName = ruleReplacements.rules[ruleName];
  65. if (pluginName === "@" && replacementRuleName) {
  66. errorMessage = `${errorMessageHeader}: Rule "${ruleName}" was removed and replaced by "${replacementRuleName}".`;
  67. } else {
  68. errorMessage = `${errorMessageHeader}: Could not find "${ruleName}" in plugin "${pluginName}".`;
  69. // otherwise, let's see if we can find the rule name elsewhere
  70. for (const [otherPluginName, otherPlugin] of Object.entries(
  71. config.plugins,
  72. )) {
  73. if (otherPlugin.rules && otherPlugin.rules[ruleName]) {
  74. errorMessage += ` Did you mean "${otherPluginName}/${ruleName}"?`;
  75. break;
  76. }
  77. }
  78. }
  79. // falls through to throw error
  80. }
  81. const error = new TypeError(errorMessage);
  82. if (errorMessage === missingPluginErrorMessage) {
  83. error.messageTemplate = "config-plugin-missing";
  84. error.messageData = { pluginName, ruleId };
  85. }
  86. throw error;
  87. }
  88. /**
  89. * The error type when a rule has an invalid `meta.schema`.
  90. */
  91. class InvalidRuleOptionsSchemaError extends Error {
  92. /**
  93. * Creates a new instance.
  94. * @param {string} ruleId Id of the rule that has an invalid `meta.schema`.
  95. * @param {Error} processingError Error caught while processing the `meta.schema`.
  96. */
  97. constructor(ruleId, processingError) {
  98. super(
  99. `Error while processing options validation schema of rule '${ruleId}': ${processingError.message}`,
  100. { cause: processingError },
  101. );
  102. this.code = "ESLINT_INVALID_RULE_OPTIONS_SCHEMA";
  103. }
  104. }
  105. /**
  106. * Parses a ruleId into its plugin and rule parts.
  107. * @param {string} ruleId The rule ID to parse.
  108. * @returns {{pluginName:string,ruleName:string}} The plugin and rule
  109. * parts of the ruleId;
  110. */
  111. function parseRuleId(ruleId) {
  112. let pluginName, ruleName;
  113. // distinguish between core rules and plugin rules
  114. if (ruleId.includes("/")) {
  115. // mimic scoped npm packages
  116. if (ruleId.startsWith("@")) {
  117. pluginName = ruleId.slice(0, ruleId.lastIndexOf("/"));
  118. } else {
  119. pluginName = ruleId.slice(0, ruleId.indexOf("/"));
  120. }
  121. ruleName = ruleId.slice(pluginName.length + 1);
  122. } else {
  123. pluginName = "@";
  124. ruleName = ruleId;
  125. }
  126. return {
  127. pluginName,
  128. ruleName,
  129. };
  130. }
  131. /**
  132. * Retrieves a rule instance from a given config based on the ruleId.
  133. * @param {string} ruleId The rule ID to look for.
  134. * @param {Linter.Config} config The config to search.
  135. * @returns {RuleDefinition|undefined} The rule if found
  136. * or undefined if not.
  137. */
  138. function getRuleFromConfig(ruleId, config) {
  139. const { pluginName, ruleName } = parseRuleId(ruleId);
  140. return config.plugins?.[pluginName]?.rules?.[ruleName];
  141. }
  142. /**
  143. * Gets a complete options schema for a rule.
  144. * @param {RuleDefinition} rule A rule object
  145. * @throws {TypeError} If `meta.schema` is specified but is not an array, object or `false`.
  146. * @returns {Object|null} JSON Schema for the rule's options. `null` if `meta.schema` is `false`.
  147. */
  148. function getRuleOptionsSchema(rule) {
  149. if (!rule.meta) {
  150. return { ...noOptionsSchema }; // default if `meta.schema` is not specified
  151. }
  152. const schema = rule.meta.schema;
  153. if (typeof schema === "undefined") {
  154. return { ...noOptionsSchema }; // default if `meta.schema` is not specified
  155. }
  156. // `schema:false` is an allowed explicit opt-out of options validation for the rule
  157. if (schema === false) {
  158. return null;
  159. }
  160. if (typeof schema !== "object" || schema === null) {
  161. throw new TypeError("Rule's `meta.schema` must be an array or object");
  162. }
  163. // ESLint-specific array form needs to be converted into a valid JSON Schema definition
  164. if (Array.isArray(schema)) {
  165. if (schema.length) {
  166. return {
  167. type: "array",
  168. items: schema,
  169. minItems: 0,
  170. maxItems: schema.length,
  171. };
  172. }
  173. // `schema:[]` is an explicit way to specify that the rule does not accept any options
  174. return { ...noOptionsSchema };
  175. }
  176. // `schema:<object>` is assumed to be a valid JSON Schema definition
  177. return schema;
  178. }
  179. /**
  180. * Splits a plugin identifier in the form a/b/c into two parts: a/b and c.
  181. * @param {string} identifier The identifier to parse.
  182. * @returns {{objectName: string, pluginName: string}} The parts of the plugin
  183. * name.
  184. */
  185. function splitPluginIdentifier(identifier) {
  186. const parts = identifier.split("/");
  187. return {
  188. objectName: parts.pop(),
  189. pluginName: parts.join("/"),
  190. };
  191. }
  192. /**
  193. * Returns the name of an object in the config by reading its `meta` key.
  194. * @param {Object} object The object to check.
  195. * @returns {string?} The name of the object if found or `null` if there
  196. * is no name.
  197. */
  198. function getObjectId(object) {
  199. // first check old-style name
  200. let name = object.name;
  201. if (!name) {
  202. if (!object.meta) {
  203. return null;
  204. }
  205. name = object.meta.name;
  206. if (!name) {
  207. return null;
  208. }
  209. }
  210. // now check for old-style version
  211. let version = object.version;
  212. if (!version) {
  213. version = object.meta && object.meta.version;
  214. }
  215. // if there's a version then append that
  216. if (version) {
  217. return `${name}@${version}`;
  218. }
  219. return name;
  220. }
  221. /**
  222. * Asserts that a value is not a function.
  223. * @param {any} value The value to check.
  224. * @param {string} key The key of the value in the object.
  225. * @param {string} objectKey The key of the object being checked.
  226. * @returns {void}
  227. * @throws {TypeError} If the value is a function.
  228. */
  229. function assertNotFunction(value, key, objectKey) {
  230. if (typeof value === "function") {
  231. const error = new TypeError(
  232. `Cannot serialize key "${key}" in "${objectKey}": Function values are not supported.`,
  233. );
  234. error.messageTemplate = "config-serialize-function";
  235. error.messageData = { key, objectKey };
  236. throw error;
  237. }
  238. }
  239. /**
  240. * Converts a languageOptions object to a JSON representation.
  241. * @param {Record<string, any>} languageOptions The options to create a JSON
  242. * representation of.
  243. * @param {string} objectKey The key of the object being converted.
  244. * @returns {Record<string, any>} The JSON representation of the languageOptions.
  245. * @throws {TypeError} If a function is found in the languageOptions.
  246. */
  247. function languageOptionsToJSON(languageOptions, objectKey = "languageOptions") {
  248. if (typeof languageOptions.toJSON === "function") {
  249. const result = languageOptions.toJSON();
  250. assertNotFunction(result, "toJSON", objectKey);
  251. return result;
  252. }
  253. const result = {};
  254. for (const [key, value] of Object.entries(languageOptions)) {
  255. if (value) {
  256. if (typeof value === "object") {
  257. const name = getObjectId(value);
  258. if (typeof value.toJSON === "function") {
  259. result[key] = value.toJSON();
  260. assertNotFunction(result[key], key, objectKey);
  261. } else if (name && hasMethod(value)) {
  262. result[key] = name;
  263. } else {
  264. result[key] = languageOptionsToJSON(value, key);
  265. }
  266. continue;
  267. }
  268. assertNotFunction(value, key, objectKey);
  269. }
  270. result[key] = value;
  271. }
  272. return result;
  273. }
  274. /**
  275. * Gets or creates a validator for a rule.
  276. * @param {Object} rule The rule to get a validator for.
  277. * @param {string} ruleId The ID of the rule (for error reporting).
  278. * @returns {Function|null} A validation function or null if no validation is needed.
  279. * @throws {InvalidRuleOptionsSchemaError} If a rule's `meta.schema` is invalid.
  280. */
  281. function getOrCreateValidator(rule, ruleId) {
  282. if (!validators.has(rule)) {
  283. try {
  284. const schema = getRuleOptionsSchema(rule);
  285. if (schema) {
  286. validators.set(rule, ajv.compile(schema));
  287. }
  288. } catch (err) {
  289. throw new InvalidRuleOptionsSchemaError(ruleId, err);
  290. }
  291. }
  292. return validators.get(rule);
  293. }
  294. //-----------------------------------------------------------------------------
  295. // Exports
  296. //-----------------------------------------------------------------------------
  297. /**
  298. * Represents a normalized configuration object.
  299. */
  300. class Config {
  301. /**
  302. * The name to use for the language when serializing to JSON.
  303. * @type {string|undefined}
  304. */
  305. #languageName;
  306. /**
  307. * The name to use for the processor when serializing to JSON.
  308. * @type {string|undefined}
  309. */
  310. #processorName;
  311. /**
  312. * Creates a new instance.
  313. * @param {Object} config The configuration object.
  314. */
  315. constructor(config) {
  316. const { plugins, language, languageOptions, processor, ...otherKeys } =
  317. config;
  318. // Validate config object
  319. const schema = new ObjectSchema(flatConfigSchema);
  320. schema.validate(config);
  321. // first, copy all the other keys over
  322. Object.assign(this, otherKeys);
  323. // ensure that a language is specified
  324. if (!language) {
  325. throw new TypeError("Key 'language' is required.");
  326. }
  327. // copy the rest over
  328. this.plugins = plugins;
  329. this.language = language;
  330. // Check language value
  331. const {
  332. pluginName: languagePluginName,
  333. objectName: localLanguageName,
  334. } = splitPluginIdentifier(language);
  335. this.#languageName = language;
  336. if (
  337. !plugins ||
  338. !plugins[languagePluginName] ||
  339. !plugins[languagePluginName].languages ||
  340. !plugins[languagePluginName].languages[localLanguageName]
  341. ) {
  342. throw new TypeError(
  343. `Key "language": Could not find "${localLanguageName}" in plugin "${languagePluginName}".`,
  344. );
  345. }
  346. this.language =
  347. plugins[languagePluginName].languages[localLanguageName];
  348. if (this.language.defaultLanguageOptions ?? languageOptions) {
  349. this.languageOptions = flatConfigSchema.languageOptions.merge(
  350. this.language.defaultLanguageOptions,
  351. languageOptions,
  352. );
  353. } else {
  354. this.languageOptions = {};
  355. }
  356. // Validate language options
  357. try {
  358. this.language.validateLanguageOptions(this.languageOptions);
  359. } catch (error) {
  360. throw new TypeError(`Key "languageOptions": ${error.message}`, {
  361. cause: error,
  362. });
  363. }
  364. // Normalize language options if necessary
  365. if (this.language.normalizeLanguageOptions) {
  366. this.languageOptions = this.language.normalizeLanguageOptions(
  367. this.languageOptions,
  368. );
  369. }
  370. // Check processor value
  371. if (processor) {
  372. this.processor = processor;
  373. if (typeof processor === "string") {
  374. const { pluginName, objectName: localProcessorName } =
  375. splitPluginIdentifier(processor);
  376. this.#processorName = processor;
  377. if (
  378. !plugins ||
  379. !plugins[pluginName] ||
  380. !plugins[pluginName].processors ||
  381. !plugins[pluginName].processors[localProcessorName]
  382. ) {
  383. throw new TypeError(
  384. `Key "processor": Could not find "${localProcessorName}" in plugin "${pluginName}".`,
  385. );
  386. }
  387. this.processor =
  388. plugins[pluginName].processors[localProcessorName];
  389. } else if (typeof processor === "object") {
  390. this.#processorName = getObjectId(processor);
  391. this.processor = processor;
  392. } else {
  393. throw new TypeError(
  394. "Key 'processor' must be a string or an object.",
  395. );
  396. }
  397. }
  398. // Process the rules
  399. if (this.rules) {
  400. this.#normalizeRulesConfig();
  401. this.validateRulesConfig(this.rules);
  402. }
  403. }
  404. /**
  405. * Converts the configuration to a JSON representation.
  406. * @returns {Record<string, any>} The JSON representation of the configuration.
  407. * @throws {Error} If the configuration cannot be serialized.
  408. */
  409. toJSON() {
  410. if (this.processor && !this.#processorName) {
  411. throw new Error(
  412. "Could not serialize processor object (missing 'meta' object).",
  413. );
  414. }
  415. if (!this.#languageName) {
  416. throw new Error(
  417. "Could not serialize language object (missing 'meta' object).",
  418. );
  419. }
  420. return {
  421. ...this,
  422. plugins: Object.entries(this.plugins).map(([namespace, plugin]) => {
  423. const pluginId = getObjectId(plugin);
  424. if (!pluginId) {
  425. return namespace;
  426. }
  427. return `${namespace}:${pluginId}`;
  428. }),
  429. language: this.#languageName,
  430. languageOptions: languageOptionsToJSON(this.languageOptions),
  431. processor: this.#processorName,
  432. };
  433. }
  434. /**
  435. * Gets a rule configuration by its ID.
  436. * @param {string} ruleId The ID of the rule to get.
  437. * @returns {RuleDefinition|undefined} The rule definition from the plugin, or `undefined` if the rule is not found.
  438. */
  439. getRuleDefinition(ruleId) {
  440. return getRuleFromConfig(ruleId, this);
  441. }
  442. /**
  443. * Normalizes the rules configuration. Ensures that each rule config is
  444. * an array and that the severity is a number. Applies meta.defaultOptions.
  445. * This function modifies `this.rules`.
  446. * @returns {void}
  447. */
  448. #normalizeRulesConfig() {
  449. for (const [ruleId, originalConfig] of Object.entries(this.rules)) {
  450. // ensure rule config is an array
  451. let ruleConfig = Array.isArray(originalConfig)
  452. ? originalConfig
  453. : [originalConfig];
  454. // normalize severity
  455. ruleConfig[0] = severities.get(ruleConfig[0]);
  456. const rule = getRuleFromConfig(ruleId, this);
  457. // apply meta.defaultOptions
  458. const slicedOptions = ruleConfig.slice(1);
  459. const mergedOptions = deepMergeArrays(
  460. rule?.meta?.defaultOptions,
  461. slicedOptions,
  462. );
  463. if (mergedOptions.length) {
  464. ruleConfig = [ruleConfig[0], ...mergedOptions];
  465. }
  466. this.rules[ruleId] = ruleConfig;
  467. }
  468. }
  469. /**
  470. * Validates all of the rule configurations in the given rules config
  471. * against the plugins in this instance. This is used primarily to
  472. * validate inline configuration rules while inting.
  473. * @param {Object} rulesConfig The rules config to validate.
  474. * @returns {void}
  475. * @throws {Error} If a rule's configuration does not match its schema.
  476. * @throws {TypeError} If the rulesConfig is not provided or is invalid.
  477. * @throws {InvalidRuleOptionsSchemaError} If a rule's `meta.schema` is invalid.
  478. * @throws {TypeError} If a rule is not found in the plugins.
  479. */
  480. validateRulesConfig(rulesConfig) {
  481. if (!rulesConfig) {
  482. throw new TypeError("Config is required for validation.");
  483. }
  484. for (const [ruleId, ruleOptions] of Object.entries(rulesConfig)) {
  485. // check for edge case
  486. if (ruleId === "__proto__") {
  487. continue;
  488. }
  489. /*
  490. * If a rule is disabled, we don't do any validation. This allows
  491. * users to safely set any value to 0 or "off" without worrying
  492. * that it will cause a validation error.
  493. *
  494. * Note: ruleOptions is always an array at this point because
  495. * this validation occurs after FlatConfigArray has merged and
  496. * normalized values.
  497. */
  498. if (ruleOptions[0] === 0) {
  499. continue;
  500. }
  501. const rule = getRuleFromConfig(ruleId, this);
  502. if (!rule) {
  503. throwRuleNotFoundError(parseRuleId(ruleId), this);
  504. }
  505. const validateRule = getOrCreateValidator(rule, ruleId);
  506. if (validateRule) {
  507. validateRule(ruleOptions.slice(1));
  508. if (validateRule.errors) {
  509. throw new Error(
  510. `Key "rules": Key "${ruleId}":\n${validateRule.errors
  511. .map(error => {
  512. if (
  513. error.keyword === "additionalProperties" &&
  514. error.schema === false &&
  515. typeof error.parentSchema?.properties ===
  516. "object" &&
  517. typeof error.params?.additionalProperty ===
  518. "string"
  519. ) {
  520. const expectedProperties = Object.keys(
  521. error.parentSchema.properties,
  522. ).map(property => `"${property}"`);
  523. return `\tValue ${JSON.stringify(error.data)} ${error.message}.\n\t\tUnexpected property "${error.params.additionalProperty}". Expected properties: ${expectedProperties.join(", ")}.\n`;
  524. }
  525. return `\tValue ${JSON.stringify(error.data)} ${error.message}.\n`;
  526. })
  527. .join("")}`,
  528. );
  529. }
  530. }
  531. }
  532. }
  533. /**
  534. * Gets a complete options schema for a rule.
  535. * @param {RuleDefinition} ruleDefinition A rule definition object.
  536. * @throws {TypeError} If `meta.schema` is specified but is not an array, object or `false`.
  537. * @returns {Object|null} JSON Schema for the rule's options. `null` if `meta.schema` is `false`.
  538. */
  539. static getRuleOptionsSchema(ruleDefinition) {
  540. return getRuleOptionsSchema(ruleDefinition);
  541. }
  542. /**
  543. * Normalizes the severity value of a rule's configuration to a number
  544. * @param {(number|string|[number, ...*]|[string, ...*])} ruleConfig A rule's configuration value, generally
  545. * received from the user. A valid config value is either 0, 1, 2, the string "off" (treated the same as 0),
  546. * the string "warn" (treated the same as 1), the string "error" (treated the same as 2), or an array
  547. * whose first element is one of the above values. Strings are matched case-insensitively.
  548. * @returns {(0|1|2)} The numeric severity value if the config value was valid, otherwise 0.
  549. */
  550. static getRuleNumericSeverity(ruleConfig) {
  551. const severityValue = Array.isArray(ruleConfig)
  552. ? ruleConfig[0]
  553. : ruleConfig;
  554. if (severities.has(severityValue)) {
  555. return severities.get(severityValue);
  556. }
  557. if (typeof severityValue === "string") {
  558. return severities.get(severityValue.toLowerCase()) ?? 0;
  559. }
  560. return 0;
  561. }
  562. }
  563. module.exports = { Config };