diff --git a/apps/oxlint/src-js/index.ts b/apps/oxlint/src-js/index.ts index 217bb1c16dd5f..02a99887d7836 100644 --- a/apps/oxlint/src-js/index.ts +++ b/apps/oxlint/src-js/index.ts @@ -8,6 +8,7 @@ export type * as ESTree from "./generated/types.d.ts"; // Plugin types export type { Context, LanguageOptions } from "./plugins/context.ts"; export type { Fix, Fixer, FixFn } from "./plugins/fix.ts"; +export type { Globals, Envs } from "./plugins/globals.ts"; export type { CreateOnceRule, CreateRule, Plugin, Rule } from "./plugins/load.ts"; export type { Options, RuleOptionsSchema } from "./plugins/options.ts"; export type { Diagnostic, DiagnosticData, Suggestion } from "./plugins/report.ts"; diff --git a/apps/oxlint/src-js/package/rule_tester.ts b/apps/oxlint/src-js/package/rule_tester.ts index ae10788f9c17f..c678174f1e927 100644 --- a/apps/oxlint/src-js/package/rule_tester.ts +++ b/apps/oxlint/src-js/package/rule_tester.ts @@ -119,6 +119,7 @@ interface Config { interface LanguageOptions { sourceType?: SourceType; globals?: Globals; + env?: Envs; parserOptions?: ParserOptions; } @@ -165,6 +166,11 @@ type GlobalValue = */ type Globals = Record; +/** + * Environments for the file being linted. + */ +export type Envs = Record; + /** * Parser options config. */ @@ -1074,25 +1080,24 @@ function getParseOptions(test: TestCase): ParseOptions { } /** - * Get globals as JSON for test case. - * - * Normalizes values to "readonly", "writable", or "off", same as Rust side does. + * Get globals and envs as JSON for test case. * + * Normalizes globals values to "readonly", "writable", or "off", same as Rust side does. * `null` is only supported in ESLint compatibility mode. * + * Removes envs which are false, same as Rust side does. + * * @param test - Test case - * @returns Globals as JSON string + * @returns Globals and envs as JSON string of form `{ "globals": { ... }, "envs": { ... } }` */ function getGlobalsJson(test: TestCase): string { - const globals = test.languageOptions?.globals; - if (globals == null) return "{}"; - - // Normalize values to `readonly`, `writable`, or `off` - same as Rust side does - const cloned = { ...globals }, + // Get globals. + // Normalize values to `readonly`, `writable`, or `off` - same as Rust side does. + const globals = { ...test.languageOptions?.globals }, eslintCompat = !!test.eslintCompat; - for (const key in cloned) { - let value = cloned[key]; + for (const key in globals) { + let value = globals[key]; switch (value) { case "readonly": @@ -1127,10 +1132,31 @@ function getGlobalsJson(test: TestCase): string { ); } - cloned[key] = value; + globals[key] = value; + } + + // TODO: Tests for `env` in `RuleTester` tests + + // Get envs. + // Remove properties which are `false` - same as Rust side does. + const originalEnvs = test.languageOptions?.env; + const envs: Envs = {}; + if (originalEnvs != null) { + for (const [key, value] of Object.entries(originalEnvs)) { + if (value === false) continue; + + // Use `Object.defineProperty` to handle if `key` is "__proto__" + Object.defineProperty(envs, key, { + value: true, + writable: true, + enumerable: true, + configurable: true, + }); + } } - return JSON.stringify(cloned); + // Serialize globals + envs to JSON + return JSON.stringify({ globals, envs }); } /** @@ -1434,6 +1460,8 @@ function isSerializablePrimitiveOrPlainObject(value: unknown): boolean { // Add types to `RuleTester` namespace type _Config = Config; type _LanguageOptions = LanguageOptions; +type _Globals = Globals; +type _Envs = Envs; type _ParserOptions = ParserOptions; type _SourceType = SourceType; type _Language = Language; @@ -1448,6 +1476,8 @@ type _Error = Error; export namespace RuleTester { export type Config = _Config; export type LanguageOptions = _LanguageOptions; + export type Globals = _Globals; + export type Envs = _Envs; export type ParserOptions = _ParserOptions; export type SourceType = _SourceType; export type Language = _Language; diff --git a/apps/oxlint/src-js/plugins/context.ts b/apps/oxlint/src-js/plugins/context.ts index 922142ce3cd28..963ea8fa96ba5 100644 --- a/apps/oxlint/src-js/plugins/context.ts +++ b/apps/oxlint/src-js/plugins/context.ts @@ -31,8 +31,9 @@ import { report } from "./report.ts"; import { settings, initSettings } from "./settings.ts"; import visitorKeys from "../generated/keys.ts"; import { debugAssertIsNonNull } from "../utils/asserts.ts"; -import { Globals, globals, initGlobals } from "./globals.ts"; +import { envs, globals, initGlobals } from "./globals.ts"; +import type { Globals, Envs } from "./globals.ts"; import type { RuleDetails } from "./load.ts"; import type { Options } from "./options.ts"; import type { Diagnostic } from "./report.ts"; @@ -190,6 +191,16 @@ const LANGUAGE_OPTIONS = { debugAssertIsNonNull(globals); return globals; }, + + /** + * Environments defined for the file being linted. + */ + get env(): Readonly { + // This is a property which ESLint does not have - it uses `ecmaVersion` instead for preset environments + if (envs === null) initGlobals(); + debugAssertIsNonNull(envs); + return envs; + }, }; // In conformance build, replace `LANGUAGE_OPTIONS.ecmaVersion` with a getter which returns value of local var. diff --git a/apps/oxlint/src-js/plugins/globals.ts b/apps/oxlint/src-js/plugins/globals.ts index 32a2293403dbb..7a05f97c3a922 100644 --- a/apps/oxlint/src-js/plugins/globals.ts +++ b/apps/oxlint/src-js/plugins/globals.ts @@ -12,15 +12,19 @@ import { debugAssert, debugAssertIsNonNull } from "../utils/asserts.ts"; */ export type Globals = Record; -// Empty globals object. -// When globals are empty, we use this singleton object to avoid allocating a new object each time. -const EMPTY_GLOBALS: Globals = Object.freeze({}); +/** + * Environments for the file being linted. + * + * Only includes environments that are enabled, so all properties are `true`. + */ +export type Envs = Record; -// Globals for current file. +// Globals and envs for current file. // `globalsJSON` is set before linting a file by `setGlobalsForFile`. -// `globals` is deserialized from `globalsJSON` lazily upon first access. +// `globals` and `envs` are deserialized from `globalsJSON` lazily upon first access. let globalsJSON: string | null = null; export let globals: Readonly | null = null; +export let envs: Readonly | null = null; /** * Updates the globals for the file. @@ -42,22 +46,19 @@ export function setGlobalsForFile(globalsJSONInput: string): undefined { export function initGlobals(): void { debugAssertIsNonNull(globalsJSON); - if (globalsJSON === "{}") { - // Re-use a single object for empty globals as an optimization - globals = EMPTY_GLOBALS; - } else { - globals = JSON.parse(globalsJSON); - - // Freeze the globals object, to prevent any mutation of `globals` by plugins. - // No need to deep freeze since all keys are just strings. - Object.freeze(globals); - } + ({ globals, envs } = JSON.parse(globalsJSON)); - debugAssertIsNonNull(globals); debugAssert( - typeof globals === "object" && !Array.isArray(globals), + typeof globals === "object" && globals !== null && !Array.isArray(globals), "`globals` should be an object", ); + debugAssert( + typeof envs === "object" && envs !== null && !Array.isArray(envs), + "`envs` should be an object", + ); + + Object.freeze(globals); + Object.freeze(envs); } /** @@ -65,5 +66,6 @@ export function initGlobals(): void { */ export function resetGlobals(): undefined { globals = null; + envs = null; globalsJSON = null; } diff --git a/apps/oxlint/src-js/utils/globals.ts b/apps/oxlint/src-js/utils/globals.ts index 502bd05774c45..45ba6d8049503 100644 --- a/apps/oxlint/src-js/utils/globals.ts +++ b/apps/oxlint/src-js/utils/globals.ts @@ -20,6 +20,7 @@ export const { assign: ObjectAssign, getPrototypeOf: ObjectGetPrototypeOf, setPrototypeOf: ObjectSetPrototypeOf, + entries: ObjectEntries, } = Object; export const { prototype: ArrayPrototype, isArray: ArrayIsArray, from: ArrayFrom } = Array; diff --git a/apps/oxlint/test/fixtures/globals/.oxlintrc.json b/apps/oxlint/test/fixtures/globals/.oxlintrc.json index c7f19ac253aa8..138d5d51c13b3 100644 --- a/apps/oxlint/test/fixtures/globals/.oxlintrc.json +++ b/apps/oxlint/test/fixtures/globals/.oxlintrc.json @@ -18,11 +18,23 @@ }, "overrides": [ { - "files": ["files/nested/**"], + "files": ["files/nested/*.js"], + "env": { + "browser": true, + "node": true + } + }, + { + "files": ["files/nested/2.js"], "globals": { "React": "writable", "process": "off", "customGlobal": "readonly" + }, + "env": { + "browser": false, + "astro": true, + "chai": false } } ] diff --git a/apps/oxlint/test/fixtures/globals/files/nested/index.js b/apps/oxlint/test/fixtures/globals/files/nested/1.js similarity index 100% rename from apps/oxlint/test/fixtures/globals/files/nested/index.js rename to apps/oxlint/test/fixtures/globals/files/nested/1.js diff --git a/apps/oxlint/test/fixtures/globals/files/nested/2.js b/apps/oxlint/test/fixtures/globals/files/nested/2.js new file mode 100644 index 0000000000000..a5c1e6463879f --- /dev/null +++ b/apps/oxlint/test/fixtures/globals/files/nested/2.js @@ -0,0 +1 @@ +let y; diff --git a/apps/oxlint/test/fixtures/globals/output.snap.md b/apps/oxlint/test/fixtures/globals/output.snap.md index 17da86fcd4f48..1f7c5826d486d 100644 --- a/apps/oxlint/test/fixtures/globals/output.snap.md +++ b/apps/oxlint/test/fixtures/globals/output.snap.md @@ -3,7 +3,8 @@ # stdout ``` - x globals-plugin(globals): { + x globals-plugin(globals): + | globals: { | "React": "readonly", | "console": "readonly", | "baz": "writable", @@ -12,13 +13,38 @@ | "bar": "readonly", | "qux": "readonly", | "window": "off" + | } + | env: { + | "builtin": true | } ,-[files/index.js:1:1] 1 | debugger; : ^ `---- - x globals-plugin(globals): { + x globals-plugin(globals): + | globals: { + | "React": "readonly", + | "console": "readonly", + | "baz": "writable", + | "foo": "writable", + | "process": "writable", + | "bar": "readonly", + | "qux": "readonly", + | "window": "off" + | } + | env: { + | "browser": true, + | "node": true, + | "builtin": true + | } + ,-[files/nested/1.js:1:1] + 1 | let x; + : ^ + `---- + + x globals-plugin(globals): + | globals: { | "React": "writable", | "console": "readonly", | "baz": "writable", @@ -29,13 +55,18 @@ | "customGlobal": "readonly", | "window": "off" | } - ,-[files/nested/index.js:1:1] - 1 | let x; + | env: { + | "astro": true, + | "builtin": true, + | "node": true + | } + ,-[files/nested/2.js:1:1] + 1 | let y; : ^ `---- -Found 0 warnings and 2 errors. -Finished in Xms on 2 files using X threads. +Found 0 warnings and 3 errors. +Finished in Xms on 3 files using X threads. ``` # stderr diff --git a/apps/oxlint/test/fixtures/globals/plugin.ts b/apps/oxlint/test/fixtures/globals/plugin.ts index 4fb68167992ca..9d696497de43f 100644 --- a/apps/oxlint/test/fixtures/globals/plugin.ts +++ b/apps/oxlint/test/fixtures/globals/plugin.ts @@ -17,8 +17,11 @@ const plugin: Plugin = { rules: { globals: { create(context) { + const { languageOptions } = context; context.report({ - message: JSON.stringify(context.languageOptions.globals, null, 2), + message: + `\nglobals: ${JSON.stringify(languageOptions.globals, null, 2)}\n` + + `env: ${JSON.stringify(languageOptions.env, null, 2)}`, node: SPAN, }); return {}; diff --git a/apps/oxlint/test/fixtures/languageOptions/output.snap.md b/apps/oxlint/test/fixtures/languageOptions/output.snap.md index 107ada8823377..89994c97d0800 100644 --- a/apps/oxlint/test/fixtures/languageOptions/output.snap.md +++ b/apps/oxlint/test/fixtures/languageOptions/output.snap.md @@ -8,6 +8,7 @@ | ecmaVersion: 2026 | parserOptions: {"sourceType":"script"} | globals: {} + | env: {"builtin":true} ,-[files/index.cjs:1:1] 1 | let x; : ^ @@ -795,6 +796,7 @@ | ecmaVersion: 2026 | parserOptions: {"sourceType":"module"} | globals: {} + | env: {"builtin":true} ,-[files/index.js:1:1] 1 | let x; : ^ @@ -805,6 +807,7 @@ | ecmaVersion: 2026 | parserOptions: {"sourceType":"module"} | globals: {} + | env: {"builtin":true} ,-[files/index.mjs:1:1] 1 | let x; : ^ diff --git a/apps/oxlint/test/fixtures/languageOptions/plugin.ts b/apps/oxlint/test/fixtures/languageOptions/plugin.ts index 8f147b7a69c40..94556cab9a5d6 100644 --- a/apps/oxlint/test/fixtures/languageOptions/plugin.ts +++ b/apps/oxlint/test/fixtures/languageOptions/plugin.ts @@ -29,7 +29,8 @@ const plugin: Plugin = { `sourceType: ${languageOptions.sourceType}\n` + `ecmaVersion: ${languageOptions.ecmaVersion}\n` + `parserOptions: ${JSON.stringify(languageOptions.parserOptions)}\n` + - `globals: ${JSON.stringify(languageOptions.globals)}`, + `globals: ${JSON.stringify(languageOptions.globals)}\n` + + `env: ${JSON.stringify(languageOptions.env)}`, node: SPAN, }); diff --git a/crates/oxc_linter/src/context/host.rs b/crates/oxc_linter/src/context/host.rs index fb96de3833f85..e67fd54f65b79 100644 --- a/crates/oxc_linter/src/context/host.rs +++ b/crates/oxc_linter/src/context/host.rs @@ -13,7 +13,7 @@ use oxc_span::{SourceType, Span}; use crate::{ AllowWarnDeny, FrameworkFlags, - config::{LintConfig, LintPlugins, OxlintGlobals, OxlintSettings}, + config::{LintConfig, LintPlugins, OxlintEnv, OxlintGlobals, OxlintSettings}, disable_directives::{DisableDirectives, DisableDirectivesBuilder, RuleCommentType}, fixer::{Fix, FixKind, Message, PossibleFixes}, frameworks::{self, FrameworkOptions}, @@ -264,6 +264,11 @@ impl<'a> ContextHost<'a> { &self.config.globals } + #[inline] + pub fn env(&self) -> &OxlintEnv { + &self.config.env + } + /// Add a diagnostic message to the end of the list of diagnostics. Can be used /// by any rule to report issues. #[inline] diff --git a/crates/oxc_linter/src/external_linter.rs b/crates/oxc_linter/src/external_linter.rs index c972f2aacca35..37e1a5928263a 100644 --- a/crates/oxc_linter/src/external_linter.rs +++ b/crates/oxc_linter/src/external_linter.rs @@ -1,9 +1,14 @@ use std::fmt::Debug; -use serde::Deserialize; +use serde::{Deserialize, Serialize, Serializer, ser::SerializeMap}; use oxc_allocator::Allocator; +use crate::{ + config::{OxlintEnv, OxlintGlobals}, + context::ContextHost, +}; + pub type ExternalLinterLoadPluginCb = Box< dyn Fn( // File URL to load plugin from @@ -79,3 +84,33 @@ impl Debug for ExternalLinter { f.debug_struct("ExternalLinter").finish() } } + +/// Struct for serializing globals and envs to send to JS plugins. +/// +/// Serializes as `{ "globals": { "React": "readonly" }, "envs": { "browser": true } }`. +/// `envs` only includes the environments that are enabled, so all properties are `true`. +#[derive(Serialize)] +pub struct GlobalsAndEnvs<'c> { + globals: &'c OxlintGlobals, + envs: EnabledEnvs<'c>, +} + +impl<'c> GlobalsAndEnvs<'c> { + pub fn new(ctx_host: &'c ContextHost<'_>) -> Self { + Self { globals: ctx_host.globals(), envs: EnabledEnvs(ctx_host.env()) } + } +} + +struct EnabledEnvs<'c>(&'c OxlintEnv); + +impl Serialize for EnabledEnvs<'_> { + fn serialize(&self, serializer: S) -> Result { + let mut map = serializer.serialize_map(None)?; + + for env_name in self.0.iter() { + map.serialize_entry(env_name, &true)?; + } + + map.end() + } +} diff --git a/crates/oxc_linter/src/lib.rs b/crates/oxc_linter/src/lib.rs index bdeef6bed1215..b702829e236d7 100644 --- a/crates/oxc_linter/src/lib.rs +++ b/crates/oxc_linter/src/lib.rs @@ -79,6 +79,7 @@ pub use crate::{ use crate::{ config::{LintConfig, OxlintEnv, OxlintGlobals, OxlintSettings}, context::ContextHost, + external_linter::GlobalsAndEnvs, fixer::{CompositeFix, Fixer}, loader::LINT_PARTIAL_LOADER_EXTENSIONS, rules::RuleEnum, @@ -566,7 +567,8 @@ impl Linter { None => "{}".to_string(), }; - let globals_json = serde_json::to_string(ctx_host.globals()).unwrap_or_else(|e| { + let globals_and_envs = GlobalsAndEnvs::new(ctx_host); + let globals_json = serde_json::to_string(&globals_and_envs).unwrap_or_else(|e| { let message = format!("Error serializing globals.\nFile path: {path}\n{e}"); ctx_host .push_diagnostic(Message::new(OxcDiagnostic::error(message), PossibleFixes::None));