Skip to content
Merged
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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions apps/oxfmt/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# `oxfmt` looks for the nearest `.editorconfig` from the `cwd`.
# Therefore, during test execution, it may inadvertently refer to the `.editorconfig` at the repository root.
# To prevent this, we place an empty `.editorconfig` here.
1 change: 1 addition & 0 deletions apps/oxfmt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ oxc_span = { workspace = true }

bpaf = { workspace = true, features = ["autocomplete", "bright-color", "derive"] }
cow-utils = { workspace = true }
editorconfig-parser = "0.0.2"
ignore = { workspace = true, features = ["simd-accel"] }
json-strip-comments = { workspace = true }
miette = { workspace = true }
Expand Down
12 changes: 9 additions & 3 deletions apps/oxfmt/src/cli/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ use super::{
service::{FormatService, SuccessResult},
walk::Walk,
};
use crate::core::{ConfigResolver, SourceFormatter, resolve_config_path, utils};
use crate::core::{
ConfigResolver, SourceFormatter, resolve_editorconfig_path, resolve_oxfmtrc_path, utils,
};

#[derive(Debug)]
pub struct FormatRunner {
Expand Down Expand Up @@ -67,8 +69,12 @@ impl FormatRunner {
// NOTE: Currently, we only load single config file.
// - from `--config` if specified
// - else, search nearest for the nearest `.oxfmtrc.json` from cwd upwards
let config_path = resolve_config_path(&cwd, config_options.config.as_deref());
let mut config_resolver = match ConfigResolver::from_config_path(config_path.as_deref()) {
let oxfmtrc_path = resolve_oxfmtrc_path(&cwd, config_options.config.as_deref());
let editorconfig_path = resolve_editorconfig_path(&cwd);
let mut config_resolver = match ConfigResolver::from_config_paths(
oxfmtrc_path.as_deref(),
editorconfig_path.as_deref(),
) {
Ok(r) => r,
Err(err) => {
utils::print_and_flush(
Expand Down
193 changes: 151 additions & 42 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
use std::path::{Path, PathBuf};

use editorconfig_parser::{
EditorConfig, EditorConfigProperties, EditorConfigProperty, EndOfLine, IndentStyle,
MaxLineLength,
};
use serde_json::Value;

use oxc_formatter::{
FormatOptions,
oxfmtrc::{OxfmtOptions, Oxfmtrc},
oxfmtrc::{EndOfLineConfig, OxfmtOptions, Oxfmtrc},
};

use super::{FormatFileStrategy, utils};

/// Resolve config file path from cwd and optional explicit path.
pub fn resolve_config_path(cwd: &Path, config_path: Option<&Path>) -> Option<PathBuf> {
pub fn resolve_oxfmtrc_path(cwd: &Path, config_path: Option<&Path>) -> Option<PathBuf> {
// If `--config` is explicitly specified, use that path
if let Some(config_path) = config_path {
return Some(if config_path.is_absolute() {
Expand All @@ -33,6 +37,13 @@ pub fn resolve_config_path(cwd: &Path, config_path: Option<&Path>) -> Option<Pat
})
}

pub fn resolve_editorconfig_path(cwd: &Path) -> Option<PathBuf> {
// Search the nearest `.editorconfig` from cwd upwards
cwd.ancestors().map(|dir| dir.join(".editorconfig")).find(|p| p.exists())
}

// ---

/// Resolved options for each file type.
/// Each variant contains only the options needed for that formatter.
pub enum ResolvedOptions {
Expand All @@ -52,26 +63,27 @@ pub enum ResolvedOptions {

/// Configuration resolver that derives all config values from a single `serde_json::Value`.
///
/// Priority order: `Oxfmtrc::default()` → (TODO: editorconfig) → user's oxfmtrc
/// Priority order: `Oxfmtrc::default()` → `.editorconfig` → user's `.oxfmtrc`
pub struct ConfigResolver {
/// User's raw config as JSON value.
/// It contains every possible field, even those not recognized by `Oxfmtrc`.
/// e.g. `printWidth`: recognized by both `Oxfmtrc` and Prettier
/// e.g. `vueIndentScriptAndStyle`: not recognized by `Oxfmtrc`, but used by Prettier
/// e.g. `svelteSortAttributes`: not recognized by Prettier by default
raw_config: Value,
/// Parsed `.editorconfig`, if any.
editorconfig: Option<EditorConfig>,
/// Cached parsed options after validation.
/// Used to avoid re-parsing during per-file resolution, if `.editorconfig` is not used.
/// NOTE: Currently, only `.editorconfig` provides per-file overrides, `.oxfmtrc` does not.
cached_options: Option<(FormatOptions, OxfmtOptions, Value)>,
// TODO: Add editorconfig support
}

impl ConfigResolver {
/// Create a new resolver from a raw JSON config value.
#[cfg(feature = "napi")]
pub fn from_value(raw_config: Value) -> Self {
Self { raw_config, cached_options: None }
Self { raw_config, editorconfig: None, cached_options: None }
}

/// Create a resolver by loading config from a file path.
Expand All @@ -80,15 +92,16 @@ impl ConfigResolver {
/// Returns error if:
/// - Config file is specified but not found or invalid
/// - Config file parsing fails
pub fn from_config_path(config_path: Option<&Path>) -> Result<Self, String> {
pub fn from_config_paths(
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 config_path {
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 config {}: File not found", path.display())
})?;
.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())
Expand All @@ -102,53 +115,67 @@ impl ConfigResolver {
let raw_config: Value = serde_json::from_str(&json_string)
.map_err(|err| format!("Failed to parse config: {err}"))?;

Ok(Self { raw_config, cached_options: None })
let editorconfig = match editorconfig_path {
Some(path) => {
let str = utils::read_to_string(path)
.map_err(|_| format!("Failed to read {}: File not found", path.display()))?;

Some(EditorConfig::parse(&str))
}
None => None,
};

Ok(Self { raw_config, editorconfig, cached_options: None })
}

/// Validate config and return ignore patterns for file walking.
///
/// Validated options are cached for fast path resolution.
/// See also [`ConfigResolver::resolve_with_overrides`] for per-file overrides.
///
/// # Errors
/// Returns error if config deserialization fails.
pub fn build_and_validate(&mut self) -> Result<Vec<String>, String> {
let oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone())
let mut oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone())
.map_err(|err| format!("Failed to deserialize Oxfmtrc: {err}"))?;

// TODO: Apply editorconfig settings
// if let Some(editorconfig) = &self.editorconfig {
// // Priority: oxfmtrc default < editorconfig < user's oxfmtrc
// if oxfmtrc.print_width.is_none() && let Some(max_line_length) = editorconfig.get_max_line_length() {
// oxfmtrc.print_width = Some(max_line_length);
// }
// // ... others
// }
// If `.editorconfig` is used, apply its root section first
// If there are per-file overrides, they will be applied during `resolve()`
if let Some(editorconfig) = &self.editorconfig
&& let Some(props) =
editorconfig.sections().iter().find(|s| s.name == "*").map(|s| &s.properties)
{
apply_editorconfig(&mut oxfmtrc, props);
}

// If not specified, default options are resolved here
let (format_options, oxfmt_options) = oxfmtrc
.into_options()
.map_err(|err| format!("Failed to parse configuration.\n{err}"))?;

// Apply our defaults for Prettier options too
// Apply our resolved defaults to Prettier options too
// e.g. set `printWidth: 100` if not specified (= Prettier default: 80)
let mut external_options = self.raw_config.clone();
Oxfmtrc::populate_prettier_config(&format_options, &mut external_options);

let ignore_patterns = oxfmt_options.ignore_patterns.clone();
let ignore_patterns_clone = oxfmt_options.ignore_patterns.clone();

// NOTE: Save cache for fast path: no per-file overrides
self.cached_options = Some((format_options, oxfmt_options, external_options));

Ok(ignore_patterns)
Ok(ignore_patterns_clone)
}

/// Resolve format options for a specific file.
pub fn resolve(&self, strategy: &FormatFileStrategy) -> ResolvedOptions {
// TODO: Check if editorconfig has any overrides for this file
let has_editorconfig_and_overrides = false;

#[cfg_attr(not(feature = "napi"), allow(unused_variables))]
let (format_options, oxfmt_options, external_options) = if has_editorconfig_and_overrides {
self.resolve_with_overrides(strategy)
#[cfg_attr(not(feature = "napi"), expect(unused_variables))]
let (format_options, oxfmt_options, external_options) = if let Some(editorconfig) =
&self.editorconfig
&& let Some(props) = get_editorconfig_overrides(editorconfig, strategy.path())
{
self.resolve_with_overrides(&props)
} else {
// Resolve format options for a specific file.
// Fast path: no per-file overrides
// Either:
// - `.editorconfig` is NOT used
// - or used but per-file overrides do NOT exist for this file
Expand Down Expand Up @@ -179,23 +206,16 @@ impl ConfigResolver {
}
}

/// Resolve format options for a specific file.
/// Since `.editorconfig` may contain per-file patterns, options are resolved per-file.
/// Resolve format options for a specific file with `.editorconfig` overrides.
/// This is the slow path, for fast path, see [`ConfigResolver::build_and_validate`].
fn resolve_with_overrides(
&self,
_strategy: &FormatFileStrategy,
props: &EditorConfigProperties,
) -> (FormatOptions, OxfmtOptions, Value) {
let oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone())
let mut oxfmtrc: Oxfmtrc = serde_json::from_value(self.raw_config.clone())
.expect("`build_and_validate()` should catch this before `resolve()`");

// TODO: Apply base + per-file editorconfig settings
// if let Some(editorconfig) = &self.editorconfig.resolve(strategy.path()) {
// // Priority: oxfmtrc default < editorconfig < user's oxfmtrc
// if oxfmtrc.print_width.is_none() && let Some(max_line_length) = editorconfig.get_max_line_length() {
// oxfmtrc.print_width = Some(max_line_length);
// }
// // ... others
// }
apply_editorconfig(&mut oxfmtrc, props);

let (format_options, oxfmt_options) = oxfmtrc
.into_options()
Expand All @@ -209,3 +229,92 @@ impl ConfigResolver {
(format_options, oxfmt_options, external_options)
}
}

// ---

/// Check if `.editorconfig` has per-file overrides for this path.
///
/// Returns `Some(props)` if the resolved properties differ from the root `[*]` section.
/// Returns `None` if no overrides.
///
/// Currently, only the following properties are considered for overrides:
/// - max_line_length
/// - end_of_line
/// - indent_style
/// - indent_size
fn get_editorconfig_overrides(
editorconfig: &EditorConfig,
path: &Path,
) -> Option<EditorConfigProperties> {
let sections = editorconfig.sections();

// No sections, or only root `[*]` section → no overrides
if sections.is_empty() || matches!(sections, [s] if s.name == "*") {
return None;
}

let resolved = editorconfig.resolve(path);

// Get the root `[*]` section properties
let root_props = sections.iter().find(|s| s.name == "*").map(|s| &s.properties);

// Compare only the properties we care about
let has_overrides = match root_props {
Some(root) => {
resolved.max_line_length != root.max_line_length
|| resolved.end_of_line != root.end_of_line
|| resolved.indent_style != root.indent_style
|| resolved.indent_size != root.indent_size
}
// No `[*]` section means any resolved property is an override
None => {
resolved.max_line_length != EditorConfigProperty::Unset
|| resolved.end_of_line != EditorConfigProperty::Unset
|| resolved.indent_style != EditorConfigProperty::Unset
|| resolved.indent_size != EditorConfigProperty::Unset
}
};

if has_overrides { Some(resolved) } else { None }
}

/// Apply `.editorconfig` properties to `Oxfmtrc`.
///
/// Only applies values that are not already set in oxfmtrc.
/// Priority: oxfmtrc default < editorconfig < user's oxfmtrc
///
/// Only properties checked by [`get_editorconfig_overrides`] are applied here.
fn apply_editorconfig(oxfmtrc: &mut Oxfmtrc, props: &EditorConfigProperties) {
#[expect(clippy::cast_possible_truncation)]
if oxfmtrc.print_width.is_none()
&& let EditorConfigProperty::Value(MaxLineLength::Number(v)) = props.max_line_length
{
oxfmtrc.print_width = Some(v as u16);
}

if oxfmtrc.end_of_line.is_none()
&& let EditorConfigProperty::Value(eol) = props.end_of_line
{
oxfmtrc.end_of_line = Some(match eol {
EndOfLine::Lf => EndOfLineConfig::Lf,
EndOfLine::Cr => EndOfLineConfig::Cr,
EndOfLine::Crlf => EndOfLineConfig::Crlf,
});
}

if oxfmtrc.use_tabs.is_none()
&& let EditorConfigProperty::Value(style) = props.indent_style
{
oxfmtrc.use_tabs = Some(match style {
IndentStyle::Tab => true,
IndentStyle::Space => false,
});
}

#[expect(clippy::cast_possible_truncation)]
if oxfmtrc.tab_width.is_none()
&& let EditorConfigProperty::Value(size) = props.indent_size
{
oxfmtrc.tab_width = Some(size as u8);
}
}
4 changes: 3 additions & 1 deletion apps/oxfmt/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ pub mod utils;
#[cfg(feature = "napi")]
mod external_formatter;

pub use config::{ConfigResolver, ResolvedOptions, resolve_config_path};
pub use config::{
ConfigResolver, ResolvedOptions, resolve_editorconfig_path, resolve_oxfmtrc_path,
};
pub use format::{FormatResult, SourceFormatter};
pub use support::FormatFileStrategy;

Expand Down
10 changes: 7 additions & 3 deletions apps/oxfmt/src/stdin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::{
use crate::cli::{CliRunResult, FormatCommand, Mode};
use crate::core::{
ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, SourceFormatter,
resolve_config_path, utils,
resolve_editorconfig_path, resolve_oxfmtrc_path, utils,
};

#[derive(Debug)]
Expand Down Expand Up @@ -63,8 +63,12 @@ impl StdinRunner {
}

// Load config
let config_path = resolve_config_path(&cwd, config_options.config.as_deref());
let mut config_resolver = match ConfigResolver::from_config_path(config_path.as_deref()) {
let oxfmtrc_path = resolve_oxfmtrc_path(&cwd, config_options.config.as_deref());
let editorconfig_path = resolve_editorconfig_path(&cwd);
let mut config_resolver = match ConfigResolver::from_config_paths(
oxfmtrc_path.as_deref(),
editorconfig_path.as_deref(),
) {
Ok(r) => r,
Err(err) => {
utils::print_and_flush(
Expand Down
Loading
Loading