diff --git a/apps/oxfmt/src/cli/command.rs b/apps/oxfmt/src/cli/command.rs index 2af84ad26a3a1..901b01ea36be0 100644 --- a/apps/oxfmt/src/cli/command.rs +++ b/apps/oxfmt/src/cli/command.rs @@ -140,12 +140,22 @@ pub enum MigrateSource { // --- +const CONFIG_FIELD_ERROR_MESSAGE: &str = "--config-field requires --config to be specified"; + +fn validate_config_options(opts: &ConfigOptions) -> bool { + opts.config_field.is_none() || opts.config.is_some() +} + /// Config Options #[derive(Debug, Clone, Bpaf)] +#[bpaf(guard(validate_config_options, CONFIG_FIELD_ERROR_MESSAGE))] pub struct ConfigOptions { /// Path to the configuration file (.json, .jsonc, .ts, .mts, .cts, .js, .mjs, .cjs) #[bpaf(short, long, argument("PATH"))] pub config: Option, + /// Top-level key to extract from the config object (requires --config) + #[bpaf(long, argument("KEY"), hide)] + pub config_field: Option, } /// Ignore Options diff --git a/apps/oxfmt/src/cli/format.rs b/apps/oxfmt/src/cli/format.rs index 8e2a3628e10a5..cdadd733b6dee 100644 --- a/apps/oxfmt/src/cli/format.rs +++ b/apps/oxfmt/src/cli/format.rs @@ -87,6 +87,7 @@ impl FormatRunner { &cwd, oxfmtrc_path.as_deref(), editorconfig_path.as_deref(), + config_options.config_field.as_deref(), #[cfg(feature = "napi")] self.js_config_loader.as_ref(), ) { diff --git a/apps/oxfmt/src/core/config.rs b/apps/oxfmt/src/core/config.rs index 289a2054ef766..062fcbe90c896 100644 --- a/apps/oxfmt/src/core/config.rs +++ b/apps/oxfmt/src/core/config.rs @@ -220,12 +220,17 @@ impl ConfigResolver { /// - With `napi` feature: evaluates it via the provided `js_config_loader` callback. /// - Without `napi` feature: returns an error (requires the Node.js CLI). /// + /// If `config_field` is provided, + /// the specified top-level key is extracted from the config object. + /// This requires `--config` to be explicitly specified. + /// /// # Errors /// Returns error if config file loading or parsing fails. pub fn from_config( cwd: &Path, oxfmtrc_path: Option<&Path>, editorconfig_path: Option<&Path>, + config_field: Option<&str>, #[cfg(feature = "napi")] js_config_loader: Option<&JsConfigLoaderCb>, ) -> Result { // Uses extension-based matching so that both auto-discovered files (e.g. `oxfmt.config.ts`) @@ -237,7 +242,7 @@ impl ConfigResolver { JS_CONFIG_FILES.iter().any(|f| f.ends_with(ext)) }; - if let Some(path) = oxfmtrc_path + let mut raw_config = if let Some(path) = oxfmtrc_path && is_js_config_path(path) { #[cfg(not(feature = "napi"))] @@ -247,59 +252,32 @@ impl ConfigResolver { // Call `import(oxfmtrc_path)` via NAPI #[cfg(feature = "napi")] - { - let raw_config = js_config_loader - .expect("JS config loader must be set when `napi` feature is enabled")( - path.to_string_lossy().into_owned(), + js_config_loader + .expect("JS config loader must be set when `napi` feature is enabled")( + path.to_string_lossy().into_owned(), + ) + .map_err(|_| { + format!( + "{}\nEnsure the file has a valid default export of a JSON-serializable configuration object.", + path.display() ) - .map_err(|_| { - format!( - "{}\nEnsure the file has a valid default export of a JSON-serializable configuration object.", - path.display() - ) - })?; - let config_dir = path.parent().map(Path::to_path_buf); - let editorconfig = load_editorconfig(cwd, editorconfig_path)?; - - return Ok(Self::new(raw_config, config_dir, editorconfig)); - } - } - - Self::from_json_config(cwd, oxfmtrc_path, editorconfig_path) - } - - /// Create a resolver by loading JSON/JSONC config from a file path. - /// - /// Also used as the default (empty config) fallback when no config file is found. - #[instrument(level = "debug", name = "oxfmt::config::from_json_config", skip_all)] - pub(crate) fn from_json_config( - cwd: &Path, - oxfmtrc_path: Option<&Path>, - editorconfig_path: Option<&Path>, - ) -> Result { - // Read and parse config file, or use empty JSON if not found - let json_string = match oxfmtrc_path { - Some(path) => { - let mut json_string = utils::read_to_string(path) - // Do not include OS error, it differs between platforms - .map_err(|_| format!("Failed to read {}: File not found", path.display()))?; - // Strip comments (JSONC support) - json_strip_comments::strip(&mut json_string).map_err(|err| { - format!("Failed to strip comments from {}: {err}", path.display()) - })?; - json_string - } - None => "{}".to_string(), + })? + } else { + load_json_config(oxfmtrc_path)? }; - // Parse as raw JSON value - let raw_config: Value = - serde_json::from_str(&json_string).map_err(|err| err.to_string())?; - // Store the config directory for override path resolution - let config_dir = oxfmtrc_path.and_then(|p| p.parent().map(Path::to_path_buf)); - let editorconfig = load_editorconfig(cwd, editorconfig_path)?; + if let Some(field) = config_field { + raw_config = raw_config + .as_object_mut() + .and_then(|map| map.remove(field)) + .ok_or_else(|| format!("Field `{field}` not found in config object"))?; + } - Ok(Self::new(raw_config, config_dir, editorconfig)) + Ok(Self::new( + raw_config, + oxfmtrc_path.and_then(|p| p.parent().map(Path::to_path_buf)), + load_editorconfig(cwd, editorconfig_path)?, + )) } /// Validate config and return ignore patterns (= non-formatting option) for file walking. @@ -496,6 +474,24 @@ struct OxfmtrcOverrideEntry { // --- +/// Load JSON/JSONC config from a file path. +#[instrument(level = "debug", name = "oxfmt::config::load_json_config", skip_all)] +fn load_json_config(oxfmtrc_path: Option<&Path>) -> Result { + let json_string = match oxfmtrc_path { + Some(path) => { + let mut json_string = utils::read_to_string(path) + .map_err(|_| format!("Failed to read {}: File not found", path.display()))?; + json_strip_comments::strip(&mut json_string).map_err(|err| { + format!("Failed to strip comments from {}: {err}", path.display()) + })?; + json_string + } + None => "{}".to_string(), + }; + + serde_json::from_str(&json_string).map_err(|err| err.to_string()) +} + /// Load `.editorconfig` from a path if provided. fn load_editorconfig( cwd: &Path, diff --git a/apps/oxfmt/src/lsp/options.rs b/apps/oxfmt/src/lsp/options.rs index 2a48148b6ef9f..cdef798e14f5d 100644 --- a/apps/oxfmt/src/lsp/options.rs +++ b/apps/oxfmt/src/lsp/options.rs @@ -5,6 +5,7 @@ use serde_json::Value; #[serde(rename_all = "camelCase")] pub struct FormatOptions { pub config_path: Option, + pub config_field: Option, } impl<'de> Deserialize<'de> for FormatOptions { @@ -29,6 +30,9 @@ impl TryFrom for FormatOptions { config_path: object .get("fmt.configPath") .and_then(|config_path| serde_json::from_value::(config_path.clone()).ok()), + config_field: object.get("fmt.configField").and_then(|config_field| { + serde_json::from_value::(config_field.clone()).ok() + }), }) } } diff --git a/apps/oxfmt/src/lsp/server_formatter.rs b/apps/oxfmt/src/lsp/server_formatter.rs index f68226e07942c..ed24bd3e1423f 100644 --- a/apps/oxfmt/src/lsp/server_formatter.rs +++ b/apps/oxfmt/src/lsp/server_formatter.rs @@ -53,14 +53,17 @@ impl ServerFormatterBuilder { debug!("root_path = {:?}", root_path.display()); // Build `ConfigResolver` from config paths - let (config_resolver, ignore_patterns) = - match self.build_config_resolver(&root_path, options.config_path.as_ref()) { - Ok((resolver, patterns)) => (resolver, patterns), - Err(err) => { - warn!("Failed to build config resolver: {err}, falling back to default config"); - Self::default_config_resolver() - } - }; + let (config_resolver, ignore_patterns) = match self.build_config_resolver( + &root_path, + options.config_path.as_ref(), + options.config_field.as_ref(), + ) { + Ok((resolver, patterns)) => (resolver, patterns), + Err(err) => { + warn!("Failed to build config resolver: {err}, falling back to default config"); + Self::default_config_resolver() + } + }; let gitignore_glob = match Self::create_ignore_globs(&root_path, &ignore_patterns) { Ok(glob) => Some(glob), @@ -116,15 +119,22 @@ impl ServerFormatterBuilder { &self, root_path: &Path, config_path: Option<&String>, + config_field: Option<&String>, ) -> Result<(ConfigResolver, Vec), String> { - let oxfmtrc_path = - resolve_oxfmtrc_path(root_path, config_path.filter(|s| !s.is_empty()).map(Path::new)); + let config_path = config_path.filter(|s| !s.is_empty()); + let config_field = config_field.filter(|s| !s.is_empty()); + + let oxfmtrc_path = resolve_oxfmtrc_path(root_path, config_path.map(Path::new)); let editorconfig_path = resolve_editorconfig_path(root_path); + if config_field.is_some() && config_path.is_none() { + return Err("configField requires configPath to be specified".to_string()); + } let mut resolver = ConfigResolver::from_config( root_path, oxfmtrc_path.as_deref(), editorconfig_path.as_deref(), + config_field.map(String::as_str), Some(&self.js_config_loader), )?; @@ -136,7 +146,7 @@ impl ServerFormatterBuilder { /// Create a default `ConfigResolver` when config loading fails. fn default_config_resolver() -> (ConfigResolver, Vec) { - let mut resolver = ConfigResolver::from_json_config(Path::new("."), None, None) + let mut resolver = ConfigResolver::from_config(Path::new("."), None, None, None, None) .expect("Default ConfigResolver should never fail"); let ignore_patterns = resolver .build_and_validate() diff --git a/apps/oxfmt/src/stdin/mod.rs b/apps/oxfmt/src/stdin/mod.rs index 30fb6116d50fc..b7013461d99fe 100644 --- a/apps/oxfmt/src/stdin/mod.rs +++ b/apps/oxfmt/src/stdin/mod.rs @@ -62,6 +62,7 @@ impl StdinRunner { &cwd, oxfmtrc_path.as_deref(), editorconfig_path.as_deref(), + config_options.config_field.as_deref(), Some(&self.js_config_loader), ) { Ok(r) => r, diff --git a/apps/oxfmt/test/cli/config_field/__snapshots__/config_field.test.ts.snap b/apps/oxfmt/test/cli/config_field/__snapshots__/config_field.test.ts.snap new file mode 100644 index 0000000000000..1c1276c43a2b2 --- /dev/null +++ b/apps/oxfmt/test/cli/config_field/__snapshots__/config_field.test.ts.snap @@ -0,0 +1,65 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`config_field > should handle --config-field flag 1`] = ` +"-------------------- +arguments: --check -c config.json --config-field fmt input.js +working directory: config_field/fixtures +exit code: 1 +--- STDOUT --------- +Checking formatting... + +input.js (ms) + +Format issues found in above 1 files. Run without \`--check\` to fix. +Finished in ms on 1 files using 1 threads. +--- STDERR --------- + +-------------------- +-------------------- +arguments: --check -c config.json --config-field other input.js +working directory: config_field/fixtures +exit code: 1 +--- STDOUT --------- +Checking formatting... + +input.js (ms) + +Format issues found in above 1 files. Run without \`--check\` to fix. +Finished in ms on 1 files using 1 threads. +--- STDERR --------- + +-------------------- +-------------------- +arguments: --check -c config.ts --config-field fmt input.js +working directory: config_field/fixtures +exit code: 1 +--- STDOUT --------- +Checking formatting... + +input.js (ms) + +Format issues found in above 1 files. Run without \`--check\` to fix. +Finished in ms on 1 files using 1 threads. +--- STDERR --------- + +-------------------- +-------------------- +arguments: --check -c config.json --config-field nonexistent input.js +working directory: config_field/fixtures +exit code: 1 +--- STDOUT --------- + +--- STDERR --------- +Failed to load configuration file. +Field \`nonexistent\` not found in config object +-------------------- +-------------------- +arguments: --check --config-field fmt input.js +working directory: config_field/fixtures +exit code: 1 +--- STDOUT --------- + +--- STDERR --------- +Error: check failed: --config-field requires --config to be specified +--------------------" +`; diff --git a/apps/oxfmt/test/cli/config_field/config_field.test.ts b/apps/oxfmt/test/cli/config_field/config_field.test.ts new file mode 100644 index 0000000000000..8cee491515e2d --- /dev/null +++ b/apps/oxfmt/test/cli/config_field/config_field.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; +import { runAndSnapshot } from "../utils"; + +const fixturesDir = join(import.meta.dirname, "fixtures"); + +describe("config_field", () => { + it("should handle --config-field flag", async () => { + const snapshot = await runAndSnapshot(fixturesDir, [ + // JSON: `fmt` field has `semi: false` + ["--check", "-c", "config.json", "--config-field", "fmt", "input.js"], + // JSON: `other` field has `semi: true` (default behavior) + ["--check", "-c", "config.json", "--config-field", "other", "input.js"], + // TS config + ["--check", "-c", "config.ts", "--config-field", "fmt", "input.js"], + // ERR: Missing field + ["--check", "-c", "config.json", "--config-field", "nonexistent", "input.js"], + // ERR: Without --config + ["--check", "--config-field", "fmt", "input.js"], + ]); + expect(snapshot).toMatchSnapshot(); + }); +}); diff --git a/apps/oxfmt/test/cli/config_field/fixtures/config.json b/apps/oxfmt/test/cli/config_field/fixtures/config.json new file mode 100644 index 0000000000000..14f976aa4e0d6 --- /dev/null +++ b/apps/oxfmt/test/cli/config_field/fixtures/config.json @@ -0,0 +1,4 @@ +{ + "fmt": { "semi": false }, + "other": { "semi": true } +} diff --git a/apps/oxfmt/test/cli/config_field/fixtures/config.ts b/apps/oxfmt/test/cli/config_field/fixtures/config.ts new file mode 100644 index 0000000000000..42a7376f47f27 --- /dev/null +++ b/apps/oxfmt/test/cli/config_field/fixtures/config.ts @@ -0,0 +1,3 @@ +export default { + fmt: { semi: false }, +}; diff --git a/apps/oxfmt/test/cli/config_field/fixtures/input.js b/apps/oxfmt/test/cli/config_field/fixtures/input.js new file mode 100644 index 0000000000000..a989d9a9b0ea3 --- /dev/null +++ b/apps/oxfmt/test/cli/config_field/fixtures/input.js @@ -0,0 +1 @@ +const x=1 diff --git a/crates/oxc_language_server/README.md b/crates/oxc_language_server/README.md index c3a3699ffa105..75d7e3df2f135 100644 --- a/crates/oxc_language_server/README.md +++ b/crates/oxc_language_server/README.md @@ -37,6 +37,7 @@ These options can be passed with [initialize](#initialize), [workspace/didChange | `disableNestedConfig` | `false` \| `true` | `false` | Disabled nested configuration and searches only for `configPath`. | | `fixKind` | [fixKind values](#fixkind-values) | `safe_fix` | The level of a possible fix for a diagnostic, will be applied for the complete workspace (diagnostic, code action, commands and more). | | `fmt.configPath` | `` \| `null` | `null` | Path to a oxfmt configuration file, when `null` is passed, the server will use `.oxfmtrc.json` and the workspace root | +| `fmt.configField` | `` \| `null` | `null` | Top-level key to extract from the config object (requires `fmt.configPath`) | | Diagnostic Pull Mode | | | | | `run` | `"onSave" \| "onType"` | `"onType"` | Should the server lint the files when the user is typing or saving. In Pull Mode, the editor requests the diagnostic. | | Deprecated | | | | @@ -77,7 +78,8 @@ The client can pass the workspace options like following: "typeAware": false, "disableNestedConfig": false, "fixKind": "safe_fix", - "fmt.configPath": null + "fmt.configPath": null, + "fmt.configField": null } } ] @@ -117,7 +119,8 @@ The client can pass the workspace options like following: "typeAware": false, "disableNestedConfig": false, "fixKind": "safe_fix", - "fmt.configPath": null + "fmt.configPath": null, + "fmt.configField": null } } ] @@ -230,7 +233,8 @@ The client can return a response like: "typeAware": false, "disableNestedConfig": false, "fixKind": "safe_fix", - "fmt.configPath": null + "fmt.configPath": null, + "fmt.configField": null } ] ```