Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/oxfmt/src/cli/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
/// Top-level key to extract from the config object (requires --config)
#[bpaf(long, argument("KEY"), hide)]
pub config_field: Option<String>,
}

/// Ignore Options
Expand Down
1 change: 1 addition & 0 deletions apps/oxfmt/src/cli/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
) {
Expand Down
96 changes: 46 additions & 50 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, String> {
// Uses extension-based matching so that both auto-discovered files (e.g. `oxfmt.config.ts`)
Expand All @@ -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"))]
Expand All @@ -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<Self, String> {
// 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.
Expand Down Expand Up @@ -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<Value, String> {
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,
Expand Down
4 changes: 4 additions & 0 deletions apps/oxfmt/src/lsp/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use serde_json::Value;
#[serde(rename_all = "camelCase")]
pub struct FormatOptions {
pub config_path: Option<String>,
pub config_field: Option<String>,
}

impl<'de> Deserialize<'de> for FormatOptions {
Expand All @@ -29,6 +30,9 @@ impl TryFrom<Value> for FormatOptions {
config_path: object
.get("fmt.configPath")
.and_then(|config_path| serde_json::from_value::<String>(config_path.clone()).ok()),
config_field: object.get("fmt.configField").and_then(|config_field| {
serde_json::from_value::<String>(config_field.clone()).ok()
}),
})
}
}
Expand Down
32 changes: 21 additions & 11 deletions apps/oxfmt/src/lsp/server_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -116,15 +119,22 @@ impl ServerFormatterBuilder {
&self,
root_path: &Path,
config_path: Option<&String>,
config_field: Option<&String>,
) -> Result<(ConfigResolver, Vec<String>), 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),
)?;

Expand All @@ -136,7 +146,7 @@ impl ServerFormatterBuilder {

/// Create a default `ConfigResolver` when config loading fails.
fn default_config_resolver() -> (ConfigResolver, Vec<String>) {
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()
Expand Down
1 change: 1 addition & 0 deletions apps/oxfmt/src/stdin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (<variable>ms)

Format issues found in above 1 files. Run without \`--check\` to fix.
Finished in <variable>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 (<variable>ms)

Format issues found in above 1 files. Run without \`--check\` to fix.
Finished in <variable>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 (<variable>ms)

Format issues found in above 1 files. Run without \`--check\` to fix.
Finished in <variable>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
--------------------"
`;
23 changes: 23 additions & 0 deletions apps/oxfmt/test/cli/config_field/config_field.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions apps/oxfmt/test/cli/config_field/fixtures/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"fmt": { "semi": false },
"other": { "semi": true }
}
3 changes: 3 additions & 0 deletions apps/oxfmt/test/cli/config_field/fixtures/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
fmt: { semi: false },
};
1 change: 1 addition & 0 deletions apps/oxfmt/test/cli/config_field/fixtures/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const x=1
10 changes: 7 additions & 3 deletions crates/oxc_language_server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | `<string>` \| `null` | `null` | Path to a oxfmt configuration file, when `null` is passed, the server will use `.oxfmtrc.json` and the workspace root |
| `fmt.configField` | `<string>` \| `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 | | | |
Expand Down Expand Up @@ -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
}
}
]
Expand Down Expand Up @@ -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
}
}
]
Expand Down Expand Up @@ -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
}
]
```
Loading