diff --git a/apps/oxlint/src-js/plugins/load.ts b/apps/oxlint/src-js/plugins/load.ts index afb9a57b991da..d397ae0232d6f 100644 --- a/apps/oxlint/src-js/plugins/load.ts +++ b/apps/oxlint/src-js/plugins/load.ts @@ -189,17 +189,7 @@ export function registerPlugin( // ESLint treats empty `defaultOptions` the same as no `defaultOptions`, // and does not validate against schema if (inputDefaultOptions.length !== 0) { - // Serialize to JSON and deserialize again. - // This is the simplest way to make sure that `defaultOptions` does not contain any `undefined` values, - // or circular references. It may also be the fastest, as `JSON.parse` and `JSON.serialize` are native code. - // If we move to doing options merging on Rust side, we'll need to convert to JSON anyway. - try { - defaultOptions = JSON.parse(JSON.stringify(inputDefaultOptions)) as Options; - } catch (err) { - throw new Error( - `\`rule.meta.defaultOptions\` must be JSON-serializable: ${getErrorMessage(err)}`, - ); - } + defaultOptions = conformDefaultOptions(inputDefaultOptions); // Validate default options against schema, if schema was provided. // This also applies any defaults from schema. @@ -368,6 +358,61 @@ function normalizePluginName(name: string): string { return name; } +/** + * Serialize default options to JSON and deserialize again. + * + * This is the simplest way to make sure that `defaultOptions` does not contain any `undefined` values, + * or circular references. It may also be the fastest, as `JSON.parse` and `JSON.stringify` are native code. + * If we move to doing options merging on Rust side, we'll need to convert to JSON anyway. + * + * Special handling for `Infinity` / `-Infinity` values, to ensure they survive the round trip. + * Without this, they would be converted to `null`. + * + * @param defaultOptions - Default options array + * @returns Conformed default options array + */ +function conformDefaultOptions(defaultOptions: Options): Options { + let json, + containsInfinity = false; + try { + json = JSON.stringify(defaultOptions, (key, value) => { + if (value === Infinity || value === -Infinity) { + containsInfinity = true; + return value === Infinity ? POS_INFINITY_PLACEHOLDER : NEG_INFINITY_PLACEHOLDER; + } + return value; + }); + } catch (err) { + throw new Error( + `\`rule.meta.defaultOptions\` must be JSON-serializable: ${getErrorMessage(err)}`, + ); + } + + if (containsInfinity) { + const plainJson = JSON.stringify(defaultOptions); + if ( + plainJson.includes(POS_INFINITY_PLACEHOLDER) || + plainJson.includes(NEG_INFINITY_PLACEHOLDER) + ) { + throw new Error( + `\`rule.meta.defaultOptions\` cannot contain the strings "${POS_INFINITY_PLACEHOLDER}" or "${NEG_INFINITY_PLACEHOLDER}"`, + ); + } + + // `JSON.parse` will convert these back to `Infinity` / `-Infinity` + json = json + .replaceAll(POS_INFINITY_PLACEHOLDER_STR, "1e+400") + .replaceAll(NEG_INFINITY_PLACEHOLDER_STR, "-1e+400"); + } + + return JSON.parse(json); +} + +const POS_INFINITY_PLACEHOLDER = "$_$_$_POS_INFINITY_$_$_$"; +const NEG_INFINITY_PLACEHOLDER = "$_$_$_NEG_INFINITY_$_$_$"; +const POS_INFINITY_PLACEHOLDER_STR = JSON.stringify(POS_INFINITY_PLACEHOLDER); +const NEG_INFINITY_PLACEHOLDER_STR = JSON.stringify(NEG_INFINITY_PLACEHOLDER); + /** * Validate and conform `before` / `after` hook function. * @param hookFn - Hook function, or `null` / `undefined` diff --git a/apps/oxlint/test/fixtures/options/output.snap.md b/apps/oxlint/test/fixtures/options/output.snap.md index d60338840e3f5..36cdb2c820250 100644 --- a/apps/oxlint/test/fixtures/options/output.snap.md +++ b/apps/oxlint/test/fixtures/options/output.snap.md @@ -10,7 +10,9 @@ | true, | { | "toBe": false, - | "notToBe": true + | "notToBe": true, + | "inf": "", + | "negInf": "<-Infinity>" | }, | { | "deep": [ @@ -58,7 +60,9 @@ | }, | "fromDefault": 3 | }, - | "fromDefault": 1 + | "fromDefault": 1, + | "inf": "", + | "negInf": "<-Infinity>" | }, | 15, | true, @@ -149,7 +153,9 @@ | true, | { | "toBe": false, - | "notToBe": true + | "notToBe": true, + | "inf": "", + | "negInf": "<-Infinity>" | }, | { | "deep": [ @@ -190,7 +196,9 @@ | "nested": { | "fromDefault": 3, | "overrideDefault": 4 - | } + | }, + | "inf": "", + | "negInf": "<-Infinity>" | }, | { | "fromConfig": 22, diff --git a/apps/oxlint/test/fixtures/options/plugin.ts b/apps/oxlint/test/fixtures/options/plugin.ts index a328518364460..38bc658f3ed7b 100644 --- a/apps/oxlint/test/fixtures/options/plugin.ts +++ b/apps/oxlint/test/fixtures/options/plugin.ts @@ -19,7 +19,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -34,7 +34,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -48,7 +48,7 @@ const plugin: Plugin = { "string", 123, true, - { toBe: false, notToBe: true }, + { toBe: false, notToBe: true, inf: Infinity, negInf: -Infinity }, { deep: [{ deeper: { evenDeeper: [{ soDeep: { soSoDeep: true } }] } }] }, ], schema: false, @@ -56,7 +56,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -67,7 +67,13 @@ const plugin: Plugin = { "merge-options": { meta: { defaultOptions: [ - { fromDefault: 1, overrideDefault: 2, nested: { fromDefault: 3, overrideDefault: 4 } }, + { + fromDefault: 1, + overrideDefault: 2, + nested: { fromDefault: 3, overrideDefault: 4 }, + inf: Infinity, + negInf: -Infinity, + }, { fromDefault: 5 }, { fromDefault: 6 }, 7, @@ -77,7 +83,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -93,7 +99,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -123,7 +129,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -160,7 +166,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -189,7 +195,7 @@ const plugin: Plugin = { create(context) { context.report({ message: - `\noptions: ${JSON.stringify(context.options, null, 2)}\n` + + `\noptions: ${stringifyOptions(context.options)}\n` + `isDeepFrozen: ${isDeepFrozen(context.options)}`, node: SPAN, }); @@ -201,6 +207,18 @@ const plugin: Plugin = { export default plugin; +function stringifyOptions(options: unknown): string { + return JSON.stringify( + options, + (key, value) => { + if (value === Infinity) return ""; + if (value === -Infinity) return "<-Infinity>"; + return value; + }, + 2, + ); +} + function isDeepFrozen(value: unknown): boolean { if (value === null || typeof value !== "object") return true; if (!Object.isFrozen(value)) return false;