diff --git a/apps/oxlint/src/command/lint.rs b/apps/oxlint/src/command/lint.rs index 40a4b7a90d1d2..d679851c8664f 100644 --- a/apps/oxlint/src/command/lint.rs +++ b/apps/oxlint/src/command/lint.rs @@ -110,8 +110,15 @@ impl LintCommand { } } +const CONFIG_FIELD_ERROR_MESSAGE: &str = "--config-field requires --config to be specified"; + +fn validate_basic_options(opts: &BasicOptions) -> bool { + opts.config_field.is_none() || opts.config.is_some() +} + /// Basic Configuration #[derive(Debug, Clone, Bpaf)] +#[bpaf(guard(validate_basic_options, CONFIG_FIELD_ERROR_MESSAGE))] pub struct BasicOptions { /// Oxlint configuration file /// * `.json` and `.jsonc` config files are supported in all runtimes @@ -123,6 +130,10 @@ pub struct BasicOptions { #[bpaf(long, short, argument("./.oxlintrc.json"))] pub config: Option, + /// Top-level key to extract from the config object (requires --config) + #[bpaf(long, argument("KEY"))] + pub config_field: Option, + /// TypeScript `tsconfig.json` path for reading path alias and project references for import plugin. /// If not provided, will look for `tsconfig.json` in the current working directory. #[bpaf(argument("./tsconfig.json"), hide_usage)] diff --git a/apps/oxlint/src/config_loader.rs b/apps/oxlint/src/config_loader.rs index b23e86e9e92be..9bf43e2a97484 100644 --- a/apps/oxlint/src/config_loader.rs +++ b/apps/oxlint/src/config_loader.rs @@ -274,6 +274,34 @@ impl<'a> ConfigLoader<'a> { &self, paths: &[PathBuf], ) -> Result, Vec> { + let raw_results = self.load_js_configs_raw(paths)?; + + let count = raw_results.len(); + let (configs, errors) = raw_results.into_iter().fold( + (Vec::with_capacity(count), Vec::::new()), + |(mut configs, mut errors), result| { + match js_config::deserialize_js_config(result.value, &result.path) { + Ok(config) => configs.push(config), + Err(err) => errors.push(ConfigLoadError::Diagnostic( + OxcDiagnostic::error(format!( + "Failed to parse config from {}", + result.path.display() + )) + .with_note(err.to_string()), + )), + } + (configs, errors) + }, + ); + + if errors.is_empty() { Ok(configs) } else { Err(errors) } + } + + /// Load raw JS/TS config values without deserializing into `Oxlintrc`. + fn load_js_configs_raw( + &self, + paths: &[PathBuf], + ) -> Result, Vec> { if paths.is_empty() { return Ok(Vec::new()); } @@ -292,7 +320,7 @@ impl<'a> ConfigLoader<'a> { paths.iter().map(|p| p.to_string_lossy().to_string()).collect(); match js_config_loader(paths_as_strings) { - Ok(results) => Ok(results.into_iter().map(|c| c.config).collect()), + Ok(results) => Ok(results), Err(diagnostics) => { Err(diagnostics.into_iter().map(ConfigLoadError::Diagnostic).collect()) } @@ -494,13 +522,14 @@ impl<'a> ConfigLoader<'a> { &self, cwd: &Path, config_path: Option<&PathBuf>, + config_field: Option<&str>, ) -> Result { if let Some(config_path) = config_path { let full_path = cwd.join(config_path); if is_js_config_path(&full_path) { - return self.load_root_js_config(&full_path); + return self.load_root_js_config_with_field(&full_path, config_field); } - return Oxlintrc::from_file(&full_path); + return Self::load_with_field(&full_path, config_field); } match self.try_load_config_from_dir(cwd)? { @@ -509,6 +538,18 @@ impl<'a> ConfigLoader<'a> { } } + /// Load a JSON/JSONC config file, optionally extracting a top-level field + /// before deserializing into `Oxlintrc`. + fn load_with_field(path: &Path, config_field: Option<&str>) -> Result { + let Some(field) = config_field else { + return Oxlintrc::from_file(path); + }; + + let json = Oxlintrc::read_to_json_value(path)?; + let extracted = extract_config_field(json, field, &path.display().to_string())?; + Oxlintrc::from_value(extracted, path) + } + /// Load root config by searching up parent directories. /// /// This is used by the LSP when a workspace folder is nested (e.g., `apps/app1`). @@ -524,14 +565,15 @@ impl<'a> ConfigLoader<'a> { &self, cwd: &Path, config_path: Option<&PathBuf>, + config_field: Option<&str>, ) -> Result { // If an explicit config path is provided, use it directly if let Some(config_path) = config_path { let full_path = cwd.join(config_path); if is_js_config_path(&full_path) { - return self.load_root_js_config(&full_path); + return self.load_root_js_config_with_field(&full_path, config_field); } - return Oxlintrc::from_file(&full_path); + return Self::load_with_field(&full_path, config_field); } // Search up the directory tree for a config file @@ -549,8 +591,28 @@ impl<'a> ConfigLoader<'a> { } fn load_root_js_config(&self, path: &Path) -> Result { - match self.load_js_configs(&[path.to_path_buf()]) { - Ok(mut configs) => Ok(configs.pop().unwrap_or_default()), + self.load_root_js_config_with_field(path, None) + } + + fn load_root_js_config_with_field( + &self, + path: &Path, + config_field: Option<&str>, + ) -> Result { + match self.load_js_configs_raw(&[path.to_path_buf()]) { + Ok(mut results) => { + let Some(result) = results.pop() else { + return Ok(Oxlintrc::default()); + }; + + let value = if let Some(field) = config_field { + extract_config_field(result.value, field, &result.path.display().to_string())? + } else { + result.value + }; + + js_config::deserialize_js_config(value, &result.path) + } Err(errors) => { if let Some(first) = errors.into_iter().next() { match first { @@ -558,8 +620,6 @@ impl<'a> ConfigLoader<'a> { Err(js_config_not_supported_diagnostic(path)) } ConfigLoadError::Diagnostic(diag) => Err(diag), - // `load_js_configs` only returns the two variants above, but keep this - // resilient if that changes. ConfigLoadError::Parse { error, .. } => Err(error), ConfigLoadError::Build { error, .. } => Err(OxcDiagnostic::error(error)), } @@ -589,10 +649,11 @@ impl<'a> ConfigLoader<'a> { &mut self, cwd: &Path, config_path: Option<&PathBuf>, + config_field: Option<&str>, paths: &[Arc], search_for_nested_configs: bool, ) -> Result { - let oxlintrc = match self.load_root_config(cwd, config_path) { + let oxlintrc = match self.load_root_config(cwd, config_path, config_field) { Ok(config) => config, Err(err) => return Err(CliConfigLoadError::RootConfig(err)), }; @@ -703,6 +764,20 @@ fn js_config_not_supported_diagnostic(path: &Path) -> OxcDiagnostic { .with_help("Run oxlint via the npm package, or use JSON config files (.oxlintrc.json or .oxlintrc.jsonc).") } +/// Extract a top-level field from a JSON config value. +/// +/// Used by both JSON/JSONC and JS/TS config loading paths to support `--config-field`. +pub(crate) fn extract_config_field( + mut value: serde_json::Value, + field: &str, + path: &str, +) -> Result { + value + .as_object_mut() + .and_then(|map| map.remove(field)) + .ok_or_else(|| OxcDiagnostic::error(format!("Field `{field}` not found in config {path}"))) +} + fn is_js_config_path(path: &Path) -> bool { matches!( path.extension().and_then(OsStr::to_str), @@ -758,12 +833,12 @@ mod test { use super::{ConfigLoadError, ConfigLoader, DiscoveredConfig, is_js_config_path}; #[cfg(feature = "napi")] - use crate::js_config::{JsConfigLoaderCb, JsConfigResult}; + use crate::js_config::{JsConfigLoaderCb, JsRawConfigResult}; #[cfg(feature = "napi")] fn make_js_loader(f: F) -> JsConfigLoaderCb where - F: Fn(Vec) -> Result, Vec> + F: Fn(Vec) -> Result, Vec> + Send + Sync + 'static, @@ -772,20 +847,15 @@ mod test { } #[cfg(feature = "napi")] - fn make_js_config( + fn make_js_raw_config( path: PathBuf, type_aware: Option, type_check: Option, - ) -> JsConfigResult { - let mut config: oxc_linter::Oxlintrc = serde_json::from_value(serde_json::json!({ + ) -> JsRawConfigResult { + let value = serde_json::json!({ "options": { "typeAware": type_aware, "typeCheck": type_check } - })) - .unwrap(); - config.path = path.clone(); - if let Some(config_dir) = path.parent() { - config.set_config_dir(config_dir); - } - JsConfigResult { path, config } + }); + JsRawConfigResult { path, value } } #[test] @@ -796,17 +866,17 @@ mod test { // Test case 1: Invalid path that should fail let invalid_config = PathBuf::from("child/../../fixtures/cli/linter/eslintrc.json"); - let result = loader.load_root_config(&cwd, Some(&invalid_config)); + let result = loader.load_root_config(&cwd, Some(&invalid_config), None); assert!(result.is_err(), "Expected config lookup to fail with invalid path"); // Test case 2: Valid path that should pass let valid_config = PathBuf::from("fixtures/cli/linter/eslintrc.json"); - let result = loader.load_root_config(&cwd, Some(&valid_config)); + let result = loader.load_root_config(&cwd, Some(&valid_config), None); assert!(result.is_ok(), "Expected config lookup to succeed with valid path"); // Test case 3: Valid path using parent directory (..) syntax that should pass let valid_parent_config = PathBuf::from("fixtures/cli/linter/../linter/eslintrc.json"); - let result = loader.load_root_config(&cwd, Some(&valid_parent_config)); + let result = loader.load_root_config(&cwd, Some(&valid_parent_config), None); assert!(result.is_ok(), "Expected config lookup to succeed with parent directory syntax"); // Verify the resolved path is correct @@ -829,7 +899,7 @@ mod test { // Uses fixture: ancestor_search/apps/app1 -> should find ancestor_search/.oxlintrc.json let nested_dir = cwd.join("apps/oxlint/fixtures/cli/ancestor_search/apps/app1"); if nested_dir.exists() { - let result = loader.load_root_config_with_ancestor_search(&nested_dir, None); + let result = loader.load_root_config_with_ancestor_search(&nested_dir, None, None); assert!(result.is_ok(), "Expected ancestor search to find config or return default"); // Verify the config was actually found (not just default) @@ -846,13 +916,13 @@ mod test { // Uses dedicated fixture with .oxlintrc.json let valid_config = PathBuf::from("fixtures/cli/ancestor_search_explicit_config/.oxlintrc.json"); - let result = loader.load_root_config_with_ancestor_search(&cwd, Some(&valid_config)); + let result = loader.load_root_config_with_ancestor_search(&cwd, Some(&valid_config), None); assert!(result.is_ok(), "Expected config lookup to succeed with explicit path"); // Test case 3: When no config exists in any ancestor, should return default let temp_dir = std::env::temp_dir().join("oxc_test_no_config"); std::fs::create_dir_all(&temp_dir).expect("Failed to create temporary test directory"); - let result = loader.load_root_config_with_ancestor_search(&temp_dir, None); + let result = loader.load_root_config_with_ancestor_search(&temp_dir, None, None); assert!(result.is_ok(), "Expected default config when no config found"); std::fs::remove_dir_all(&temp_dir).expect("Failed to cleanup temporary test directory"); } @@ -929,13 +999,13 @@ mod test { let js_loader = make_js_loader(move |paths| { Ok(paths .into_iter() - .map(|path| make_js_config(PathBuf::from(path), Some(true), None)) + .map(|path| make_js_raw_config(PathBuf::from(path), Some(true), None)) .collect()) }); let loader = loader.with_js_config_loader(Some(&js_loader)); let config = loader - .load_root_config(root_dir.path(), Some(&PathBuf::from("oxlint.config.ts"))) + .load_root_config(root_dir.path(), Some(&PathBuf::from("oxlint.config.ts")), None) .unwrap(); assert_eq!(config.options.type_aware, Some(true)); @@ -954,13 +1024,13 @@ mod test { let js_loader = make_js_loader(move |paths| { Ok(paths .into_iter() - .map(|path| make_js_config(PathBuf::from(path), None, Some(true))) + .map(|path| make_js_raw_config(PathBuf::from(path), None, Some(true))) .collect()) }); let loader = loader.with_js_config_loader(Some(&js_loader)); let config = loader - .load_root_config(root_dir.path(), Some(&PathBuf::from("oxlint.config.ts"))) + .load_root_config(root_dir.path(), Some(&PathBuf::from("oxlint.config.ts")), None) .unwrap(); assert_eq!(config.options.type_check, Some(true)); @@ -980,7 +1050,7 @@ mod test { let js_loader = make_js_loader(move |paths| { Ok(paths .into_iter() - .map(|path| make_js_config(PathBuf::from(path), Some(false), None)) + .map(|path| make_js_raw_config(PathBuf::from(path), Some(false), None)) .collect()) }); loader = loader.with_js_config_loader(Some(&js_loader)); @@ -1005,7 +1075,7 @@ mod test { let js_loader = make_js_loader(move |paths| { Ok(paths .into_iter() - .map(|path| make_js_config(PathBuf::from(path), None, Some(false))) + .map(|path| make_js_raw_config(PathBuf::from(path), None, Some(false))) .collect()) }); loader = loader.with_js_config_loader(Some(&js_loader)); @@ -1030,11 +1100,9 @@ mod test { let js_loader = make_js_loader(move |paths| { Ok(paths .into_iter() - .map(|path| { - let path = PathBuf::from(path); - let mut config = make_js_config(path.clone(), None, None).config; - config.options.deny_warnings = Some(true); - JsConfigResult { path, config } + .map(|path| JsRawConfigResult { + path: PathBuf::from(path), + value: serde_json::json!({ "options": { "denyWarnings": true } }), }) .collect()) }); @@ -1060,16 +1128,11 @@ mod test { let js_loader = make_js_loader(move |paths| { Ok(paths .into_iter() - .map(|path| { - let path = PathBuf::from(path); - let mut config = make_js_config(path.clone(), None, None).config; - config.extends_configs = vec![ - serde_json::from_value( - serde_json::json!({ "options": { "typeAware": true } }), - ) - .unwrap(), - ]; - JsConfigResult { path, config } + .map(|path| JsRawConfigResult { + path: PathBuf::from(path), + value: serde_json::json!({ + "extends": [{ "options": { "typeAware": true } }] + }), }) .collect()) }); @@ -1095,16 +1158,11 @@ mod test { let js_loader = make_js_loader(move |paths| { Ok(paths .into_iter() - .map(|path| { - let path = PathBuf::from(path); - let mut config = make_js_config(path.clone(), None, None).config; - config.extends_configs = vec![ - serde_json::from_value( - serde_json::json!({ "options": { "typeCheck": true } }), - ) - .unwrap(), - ]; - JsConfigResult { path, config } + .map(|path| JsRawConfigResult { + path: PathBuf::from(path), + value: serde_json::json!({ + "extends": [{ "options": { "typeCheck": true } }] + }), }) .collect()) }); @@ -1126,7 +1184,7 @@ mod test { let mut external_plugin_store = ExternalPluginStore::new(false); let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None); - let result = loader.load_root_config(root_dir.path(), None); + let result = loader.load_root_config(root_dir.path(), None, None); assert!(result.is_ok(), "Expected .oxlintrc.jsonc to be discovered and loaded"); let config = result.unwrap(); assert!( @@ -1147,7 +1205,7 @@ mod test { let mut external_plugin_store = ExternalPluginStore::new(false); let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None); - let result = loader.load_root_config(root_dir.path(), None); + let result = loader.load_root_config(root_dir.path(), None, None); assert!( result.is_err(), "Expected an error when both .oxlintrc.json and .oxlintrc.jsonc exist" @@ -1163,7 +1221,7 @@ mod test { let mut external_plugin_store = ExternalPluginStore::new(false); let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None); - let result = loader.load_root_config(root_dir.path(), None); + let result = loader.load_root_config(root_dir.path(), None, None); assert!(result.is_err(), "Expected an error when both JSON and TS configs exist"); } @@ -1177,7 +1235,7 @@ mod test { let mut external_plugin_store = ExternalPluginStore::new(false); let loader = ConfigLoader::new(None, &mut external_plugin_store, &[], None); - let result = loader.load_root_config(root_dir.path(), None); + let result = loader.load_root_config(root_dir.path(), None, None); assert!(result.is_err(), "Expected an error when both JSONC and TS configs exist"); } } diff --git a/apps/oxlint/src/js_config.rs b/apps/oxlint/src/js_config.rs index af1f45c373c48..c6e027a3bdbfe 100644 --- a/apps/oxlint/src/js_config.rs +++ b/apps/oxlint/src/js_config.rs @@ -7,14 +7,19 @@ use oxc_linter::Oxlintrc; use crate::run::JsLoadJsConfigsCb; /// Callback type for loading JavaScript/TypeScript config files. +/// +/// Returns raw `serde_json::Value` per config file. The caller is responsible +/// for any field extraction (`--config-field`) and deserialization into `Oxlintrc`. pub type JsConfigLoaderCb = - Box) -> Result, Vec> + Send + Sync>; + Box) -> Result, Vec> + Send + Sync>; /// Result of loading a single JavaScript/TypeScript config file. +/// +/// Contains the raw JSON value before deserialization into `Oxlintrc`. #[derive(Debug, Clone)] -pub struct JsConfigResult { +pub struct JsRawConfigResult { pub path: PathBuf, - pub config: Oxlintrc, + pub value: serde_json::Value, } /// Response from JS side when loading JS configs. @@ -68,7 +73,14 @@ pub fn create_js_config_loader(cb: JsLoadJsConfigsCb) -> JsConfigLoaderCb { }) } -fn parse_js_oxlintrc(mut value: serde_json::Value) -> Result { +/// Deserialize a raw JS config `serde_json::Value` into `Oxlintrc`. +/// +/// Handles the JS-config-specific `extends` field (which contains inline config +/// objects rather than file paths). +pub(crate) fn deserialize_js_config( + mut value: serde_json::Value, + path: &Path, +) -> Result { let Some(map) = value.as_object_mut() else { return Err(OxcDiagnostic::error( "Configuration file must have a default export that is an object.", @@ -92,7 +104,7 @@ fn parse_js_oxlintrc(mut value: serde_json::Value) -> Result, _>>()? } else { @@ -102,54 +114,28 @@ fn parse_js_oxlintrc(mut value: serde_json::Value) -> Result Result, Vec> { +use std::path::Path; + +/// Parse the JSON response from JS side into raw config results. +fn parse_js_config_response(json: &str) -> Result, Vec> { let response: LoadJsConfigsResponse = serde_json::from_str(json).map_err(|e| { vec![OxcDiagnostic::error(format!("Failed to parse JS config response: {e}"))] })?; match response { - LoadJsConfigsResponse::Success { success } => { - let count = success.len(); - let (configs, errors) = success.into_iter().fold( - (Vec::with_capacity(count), Vec::new()), - |(mut configs, mut errors), entry| { - let path = PathBuf::from(&entry.path); - let mut oxlintrc = match parse_js_oxlintrc(entry.config) { - Ok(config) => config, - Err(err) => { - errors.push( - OxcDiagnostic::error(format!( - "Failed to parse config from {}", - entry.path - )) - .with_note(err.to_string()), - ); - return (configs, errors); - } - }; - oxlintrc.path.clone_from(&path); - - let Some(config_dir_parent) = oxlintrc.path.parent() else { - errors.push(OxcDiagnostic::error(format!( - "Config path has no parent directory: {}", - entry.path - ))); - return (configs, errors); - }; - let config_dir = config_dir_parent.to_path_buf(); - oxlintrc.set_config_dir(&config_dir); - configs.push(JsConfigResult { path, config: oxlintrc }); - - (configs, errors) - }, - ); - - if errors.is_empty() { Ok(configs) } else { Err(errors) } - } + LoadJsConfigsResponse::Success { success } => Ok(success + .into_iter() + .map(|entry| JsRawConfigResult { path: PathBuf::from(entry.path), value: entry.config }) + .collect()), LoadJsConfigsResponse::Failure { failures } => Err(failures .into_iter() .map(|failure| { diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs index 35aa3a1f88fc7..5249cdce67313 100644 --- a/apps/oxlint/src/lint.rs +++ b/apps/oxlint/src/lint.rs @@ -214,6 +214,7 @@ impl CliRunner { config_loader.load_root_and_nested( &self.cwd, basic_options.config.as_ref(), + basic_options.config_field.as_deref(), &paths, search_for_nested_configs, ) diff --git a/apps/oxlint/src/lsp/options.rs b/apps/oxlint/src/lsp/options.rs index 53de9ce1f2707..6a028289685da 100644 --- a/apps/oxlint/src/lsp/options.rs +++ b/apps/oxlint/src/lsp/options.rs @@ -28,6 +28,8 @@ pub struct LintOptions { #[serde(skip_serializing_if = "Option::is_none")] pub config_path: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub config_field: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub ts_config_path: Option, pub unused_disable_directives: Option, pub type_aware: Option, @@ -109,6 +111,9 @@ impl TryFrom for LintOptions { config_path: object .get("configPath") .and_then(|config_path| serde_json::from_value::(config_path.clone()).ok()), + config_field: object.get("configField").and_then(|config_field| { + serde_json::from_value::(config_field.clone()).ok() + }), ts_config_path: object .get("tsConfigPath") .and_then(|config_path| serde_json::from_value::(config_path.clone()).ok()), diff --git a/apps/oxlint/src/lsp/server_linter.rs b/apps/oxlint/src/lsp/server_linter.rs index 07e956448a10b..f2d0bc351af98 100644 --- a/apps/oxlint/src/lsp/server_linter.rs +++ b/apps/oxlint/src/lsp/server_linter.rs @@ -105,6 +105,7 @@ impl ServerLinterBuilder { }; let config_path = options.config_path.as_ref().filter(|p| !p.is_empty()).map(PathBuf::from); + let config_field = options.config_field.as_deref().filter(|s| !s.is_empty()); let loader = ConfigLoader::new( external_linter, &mut external_plugin_store, @@ -114,14 +115,17 @@ impl ServerLinterBuilder { #[cfg(feature = "napi")] let loader = loader.with_js_config_loader(self.js_config_loader.as_ref()); - let oxlintrc = - match loader.load_root_config_with_ancestor_search(&root_path, config_path.as_ref()) { - Ok(config) => config, - Err(e) => { - warn!("Failed to load config: {e}"); - Oxlintrc::default() - } - }; + let oxlintrc = match loader.load_root_config_with_ancestor_search( + &root_path, + config_path.as_ref(), + config_field, + ) { + Ok(config) => config, + Err(e) => { + warn!("Failed to load config: {e}"); + Oxlintrc::default() + } + }; let base_patterns = oxlintrc.ignore_patterns.clone(); @@ -845,6 +849,7 @@ impl ServerLinter { fn needs_restart(old_options: &LSPLintOptions, new_options: &LSPLintOptions) -> bool { old_options.config_path != new_options.config_path + || old_options.config_field != new_options.config_field || old_options.ts_config_path != new_options.ts_config_path || old_options.use_nested_configs() != new_options.use_nested_configs() || old_options.fix_kind != new_options.fix_kind diff --git a/crates/oxc_language_server/README.md b/crates/oxc_language_server/README.md index c3a3699ffa105..5d8f37f70423a 100644 --- a/crates/oxc_language_server/README.md +++ b/crates/oxc_language_server/README.md @@ -31,6 +31,7 @@ These options can be passed with [initialize](#initialize), [workspace/didChange | Option Key | Value(s) | Default | Description | | ------------------------- | --------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | `configPath` | `` \| `null` | `null` | Path to a oxlint configuration file, passing a string will disable nested configuration | +| `configField` | `` \| `null` | `null` | Top-level key to extract from the config object (requires `configPath`) | | `tsConfigPath` | `` \| `null` | `null` | Path to a TypeScript configuration file. If your `tsconfig.json` is not at the root, alias paths will not be resolve correctly for the `import` plugin | | `unusedDisableDirectives` | `"allow" \| "warn"` \| "deny"` | `"allow"` | Define how directive comments like `// oxlint-disable-line` should be reported, when no errors would have been reported on that line anyway | | `typeAware` | `` \| `null` | `null` | Enables type-aware linting. When unset (`null`), uses the root config's `options.typeAware` value. | @@ -72,6 +73,7 @@ The client can pass the workspace options like following: "options": { "run": "onType", "configPath": null, + "configField": null, "tsConfigPath": null, "unusedDisableDirectives": "allow", "typeAware": false, @@ -112,6 +114,7 @@ The client can pass the workspace options like following: "options": { "run": "onType", "configPath": null, + "configField": null, "tsConfigPath": null, "unusedDisableDirectives": "allow", "typeAware": false, @@ -225,6 +228,7 @@ The client can return a response like: { "run": "onType", "configPath": null, + "configField": null, "tsConfigPath": null, "unusedDisableDirectives": "allow", "typeAware": false, diff --git a/crates/oxc_linter/src/config/oxlintrc.rs b/crates/oxc_linter/src/config/oxlintrc.rs index a6b5094bf5d35..8b5d54bf4ec5a 100644 --- a/crates/oxc_linter/src/config/oxlintrc.rs +++ b/crates/oxc_linter/src/config/oxlintrc.rs @@ -272,6 +272,22 @@ impl Oxlintrc { /// /// * Parse Failure pub fn from_file(path: &Path) -> Result { + let json = Self::read_to_json_value(path)?; + Self::from_value(json, path) + } + + /// Read a JSON/JSONC config file and return the parsed `serde_json::Value`. + /// + /// This handles reading the file, stripping JSONC comments, and parsing to JSON. + /// Useful when you need to pre-process the JSON value before deserializing + /// (e.g., extracting a specific field via `--config-field`). + /// + /// # Errors + /// + /// * File read failure + /// * JSONC comment stripping failure + /// * JSON parse failure + pub fn read_to_json_value(path: &Path) -> Result { let mut string = read_to_string(path).map_err(|e| { OxcDiagnostic::error(format!( "Failed to parse config {} with error {e:?}", @@ -284,7 +300,7 @@ impl Oxlintrc { OxcDiagnostic::error(format!("Failed to parse jsonc file {}: {err:?}", path.display())) })?; - let json = serde_json::from_str::(&string).map_err(|err| { + serde_json::from_str::(&string).map_err(|err| { let ext = path.extension().and_then(OsStr::to_str); let err = match ext { // syntax error @@ -300,8 +316,18 @@ impl Oxlintrc { "Failed to parse oxlint config {}.\n{err}", path.display() )) - })?; + }) + } + /// Create an `Oxlintrc` from a pre-parsed JSON value and a file path. + /// + /// This is useful when the JSON value has been pre-processed (e.g., extracting + /// a specific field via `--config-field`). + /// + /// # Errors + /// + /// * Parse Failure + pub fn from_value(json: serde_json::Value, path: &Path) -> Result { let mut config = Self::deserialize(&json).map_err(|err| { OxcDiagnostic::error(format!("Failed to parse config with error {err:?}")) })?; diff --git a/tasks/website_linter/src/snapshots/cli.snap b/tasks/website_linter/src/snapshots/cli.snap index 1847c368a3615..ad21e0db1fb7d 100644 --- a/tasks/website_linter/src/snapshots/cli.snap +++ b/tasks/website_linter/src/snapshots/cli.snap @@ -8,7 +8,7 @@ search: false ## Usage - **`oxlint`** \[**`-c`**=_`<./.oxlintrc.json>`_\] \[_`PATH`_\]... + **`oxlint`** \[**`-c`**=_`<./.oxlintrc.json>`_\] \[**`--config-field`**=_`KEY`_\] \[_`PATH`_\]... ## Basic Configuration - **`-c`**, **`--config`**=_`<./.oxlintrc.json>`_ — @@ -19,6 +19,8 @@ search: false * tries to be compatible with ESLint v8's format If not provided, Oxlint will look for a `.oxlintrc.json`, `.oxlintrc.jsonc`, or `oxlint.config.ts` file in the current working directory. +- **` --config-field`**=_`KEY`_ — + Top-level key to extract from the config object (requires --config) - **` --tsconfig`**=_`<./tsconfig.json>`_ — TypeScript `tsconfig.json` path for reading path alias and project references for import plugin. If not provided, will look for `tsconfig.json` in the current working directory. - **` --init`** — diff --git a/tasks/website_linter/src/snapshots/cli_terminal.snap b/tasks/website_linter/src/snapshots/cli_terminal.snap index 1e20f72daf306..6bfd05dd7dd55 100644 --- a/tasks/website_linter/src/snapshots/cli_terminal.snap +++ b/tasks/website_linter/src/snapshots/cli_terminal.snap @@ -2,7 +2,7 @@ source: tasks/website_linter/src/cli.rs expression: snapshot --- -Usage: [-c=<./.oxlintrc.json>] [PATH]... +Usage: [-c=<./.oxlintrc.json>] [--config-field=KEY] [PATH]... Basic Configuration -c, --config=<./.oxlintrc.json> Oxlint configuration file @@ -11,6 +11,7 @@ Basic Configuration running via Node.js * you can use comments in configuration files. * tries to be compatible with ESLint v8's format + --config-field=KEY Top-level key to extract from the config object (requires --config) --tsconfig=<./tsconfig.json> TypeScript `tsconfig.json` path for reading path alias and project references for import plugin. If not provided, will look for `tsconfig.json` in the current working directory.