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
1 change: 1 addition & 0 deletions apps/oxfmt/src/cli/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ impl FormatRunner {
);
// Config stats: only show when no config is found
if oxfmtrc_path.is_none() && editorconfig_path.is_none() {
#[cfg(feature = "napi")]
let hint = "No config found, using defaults. Please add a config file or try `oxfmt --init` if needed.\n";
#[cfg(not(feature = "napi"))]
let hint =
Expand Down
109 changes: 66 additions & 43 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,24 @@ use super::{
};

/// JSON/JSONC config file names, in order of preference.
pub const JSON_CONFIG_FILES: &[&str] = &[".oxfmtrc.json", ".oxfmtrc.jsonc"];
/// JS/TS config file names, in order of preference.
pub const JS_CONFIG_FILES: &[&str] = &[
"oxfmt.config.ts",
"oxfmt.config.mts",
"oxfmt.config.cts",
"oxfmt.config.js",
"oxfmt.config.mjs",
"oxfmt.config.cjs",
];
const JSON_CONFIG_FILES: &[&str] = &[".oxfmtrc.json", ".oxfmtrc.jsonc"];
/// JS/TS config file extensions.
const JS_CONFIG_EXTENSIONS: &[&str] = &["ts", "mts", "cts", "js", "mjs", "cjs"];
/// Oxfmt JS/TS config file prefix.
const OXFMT_JS_CONFIG_PREFIX: &str = "oxfmt.config.";
/// Vite+ config file prefix that may contain Oxfmt config under a `.fmt` field.
const VITE_PLUS_JS_CONFIG_PREFIX: &str = "vite.config.";
#[cfg(feature = "napi")]
const VITE_PLUS_OXFMT_CONFIG_FIELD: &str = "fmt";

/// Returns an iterator of all supported config file names, in priority order.
pub fn all_config_file_names() -> impl Iterator<Item = String> {
let json = JSON_CONFIG_FILES.iter().map(|f| (*f).to_string());
let oxfmt_js = JS_CONFIG_EXTENSIONS.iter().map(|ext| format!("{OXFMT_JS_CONFIG_PREFIX}{ext}"));
let vite_plus =
JS_CONFIG_EXTENSIONS.iter().map(|ext| format!("{VITE_PLUS_JS_CONFIG_PREFIX}{ext}"));
json.chain(oxfmt_js).chain(vite_plus)
}

/// Resolve config file path from cwd and optional explicit path.
pub fn resolve_oxfmtrc_path(cwd: &Path, config_path: Option<&Path>) -> Option<PathBuf> {
Expand All @@ -41,12 +49,12 @@ pub fn resolve_oxfmtrc_path(cwd: &Path, config_path: Option<&Path>) -> Option<Pa
return Some(utils::normalize_relative_path(cwd, config_path));
}

// If `--config` is not specified, search the nearest config file from cwd upwards
// Support JSON, JSONC, and JS/TS config files
// Prefer JSON/JSONC over JS/TS if both exist in the same directory
// If `--config` is not specified, search the nearest config file from cwd upwards.
// Support JSON, JSONC, and JS/TS config files.
// Prefer Oxfmt JSON/JSONC over JS/TS over Vite+ config if multiple exist in the same directory.
cwd.ancestors().find_map(|dir| {
for filename in JSON_CONFIG_FILES.iter().chain(JS_CONFIG_FILES.iter()) {
let config_path = dir.join(filename);
for filename in all_config_file_names() {
let config_path = dir.join(&filename);
if config_path.exists() {
return Some(config_path);
}
Expand Down Expand Up @@ -230,39 +238,54 @@ impl ConfigResolver {
) -> Result<Self, String> {
// Uses extension-based matching so that both auto-discovered files (e.g. `oxfmt.config.ts`)
// and explicitly specified files (e.g. `--config ./my-config.ts`) are handled.
let is_js_config_path = |path: &Path| {
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
JS_CONFIG_FILES.iter().any(|f| f.ends_with(ext))
};
let is_js_config = oxfmtrc_path.is_some_and(|path| {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|ext| JS_CONFIG_EXTENSIONS.contains(&ext))
});

if let Some(path) = oxfmtrc_path
&& is_js_config_path(path)
{
#[cfg(not(feature = "napi"))]
return Err(format!(
#[cfg(not(feature = "napi"))]
if is_js_config {
return Err(
"JS/TS config files are not supported in pure Rust CLI.\nUse JSON/JSONC instead."
));
.to_string(),
);
}

// 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(),
// Call `import(oxfmtrc_path)` via NAPI
#[cfg(feature = "napi")]
if let Some(path) = oxfmtrc_path
&& is_js_config
{
let raw_config = 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)?;
})?;

// Vite+ config files (e.g. `vite.config.ts`),
// under a `.fmt` field instead of the default export directly.
let is_vite_plus = path
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|name| name.starts_with(VITE_PLUS_JS_CONFIG_PREFIX));
let raw_config = if is_vite_plus {
raw_config.get(VITE_PLUS_OXFMT_CONFIG_FIELD).cloned().ok_or_else(|| {
format!("{}\nExpected a `{VITE_PLUS_OXFMT_CONFIG_FIELD}` field in the default export.", path.display())
})?
} else {
raw_config
};

return Ok(Self::new(raw_config, config_dir, editorconfig));
}
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)
Expand Down
4 changes: 2 additions & 2 deletions apps/oxfmt/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ mod external_formatter;
#[cfg(feature = "napi")]
mod js_config;

#[cfg(feature = "napi")]
pub use config::all_config_file_names;
#[cfg(feature = "napi")]
pub use config::resolve_options_from_value;
pub use config::{
ConfigResolver, ResolvedOptions, resolve_editorconfig_path, resolve_oxfmtrc_path,
};
#[cfg(feature = "napi")]
pub use config::{JS_CONFIG_FILES, JSON_CONFIG_FILES};
pub use format::{FormatResult, SourceFormatter};
pub use support::FormatFileStrategy;

Expand Down
12 changes: 4 additions & 8 deletions apps/oxfmt/src/lsp/server_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@ use oxc_data_structures::rope::{Rope, get_line_column};
use oxc_language_server::{Capabilities, LanguageId, Tool, ToolBuilder, ToolRestartChanges};

use crate::core::{
ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, JS_CONFIG_FILES,
JSON_CONFIG_FILES, JsConfigLoaderCb, SourceFormatter, resolve_editorconfig_path,
resolve_oxfmtrc_path, utils,
ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, JsConfigLoaderCb,
SourceFormatter, all_config_file_names, resolve_editorconfig_path, resolve_oxfmtrc_path, utils,
};
use crate::lsp::create_fake_file_path_from_language_id;
use crate::lsp::options::FormatOptions as LSPFormatOptions;
Expand Down Expand Up @@ -234,11 +233,8 @@ impl Tool for ServerFormatter {
if let Some(config_path) = options.config_path.as_ref().filter(|s| !s.is_empty()) {
vec![config_path.clone()]
} else {
JSON_CONFIG_FILES
.iter()
.chain(JS_CONFIG_FILES.iter())
.map(|file| (*file).to_string())
.collect()
// TODO: This can be glob patterns?
all_config_file_names().collect()
};

patterns.push(".editorconfig".to_string());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`vite_config > basic: reads fmt field from vite.config.ts 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/basic
exit code: 1
--- STDOUT ---------
Checking formatting...

test.ts (<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 ---------

--------------------"
`;

exports[`vite_config > error: no fmt field in vite.config.ts 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/error_no_fmt_field
exit code: 1
--- STDOUT ---------

--- STDERR ---------
Failed to load configuration file.
<cwd>/vite.config.ts
Expected a \`fmt\` field in the default export.
--------------------"
`;

exports[`vite_config > priority: oxfmt.config.ts takes precedence over vite.config.ts 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/priority
exit code: 1
--- STDOUT ---------
Checking formatting...

test.ts (<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 ---------

--------------------"
`;
1 change: 1 addition & 0 deletions apps/oxfmt/test/cli/vite_config/fixtures/basic/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const a = 1;
5 changes: 5 additions & 0 deletions apps/oxfmt/test/cli/vite_config/fixtures/basic/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
fmt: {
semi: false,
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const a = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
plugins: [],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
semi: false,
};
1 change: 1 addition & 0 deletions apps/oxfmt/test/cli/vite_config/fixtures/priority/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const a = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
fmt: {
semi: true,
},
};
27 changes: 27 additions & 0 deletions apps/oxfmt/test/cli/vite_config/vite_config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { join } from "node:path";
import { runAndSnapshot } from "../utils";

const fixturesDir = join(import.meta.dirname, "fixtures");

describe("vite_config", () => {
it("basic: reads fmt field from vite.config.ts", async () => {
const cwd = join(fixturesDir, "basic");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
expect(snapshot).toMatchSnapshot();
});

it("error: no fmt field in vite.config.ts", async () => {
const cwd = join(fixturesDir, "error_no_fmt_field");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
expect(snapshot).toMatchSnapshot();
});

it("priority: oxfmt.config.ts takes precedence over vite.config.ts", async () => {
// `oxfmt.config.ts` has `semi: false`, `vite.config.ts` has `semi: true`
// oxfmt.config.ts should win, so `const a = 1;` (with semicolon) should be flagged
const cwd = join(fixturesDir, "priority");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
expect(snapshot).toMatchSnapshot();
});
});
12 changes: 12 additions & 0 deletions apps/oxfmt/test/lsp/format/__snapshots__/format.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ file://<fixture>/config-sort-tailwindcss/test.vue
--------------------"
`;

exports[`LSP formatting > config options > should apply config from config-vite-semi/test.ts 1`] = `
"--- URI -----------
file://<fixture>/config-vite-semi/test.ts
--- BEFORE ---------
const x = 1;

--- AFTER ----------
const x = 1

--------------------"
`;

exports[`LSP formatting > config options > should apply config from config-vue-indent/test.vue 1`] = `
"--- URI -----------
file://<fixture>/config-vue-indent/test.vue
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
const x = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
fmt: {
semi: false,
},
};
1 change: 1 addition & 0 deletions apps/oxfmt/test/lsp/format/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe("LSP formatting", () => {
it.each([
["config-semi/test.ts", "typescript"],
["config-js-semi/test.ts", "typescript"],
["config-vite-semi/test.ts", "typescript"],
["config-no-sort-package-json/package.json", "json"],
["config-vue-indent/test.vue", "vue"],
["config-sort-imports/test.js", "javascript"],
Expand Down
12 changes: 12 additions & 0 deletions apps/oxfmt/test/lsp/init/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ describe("LSP initialization", () => {
"oxfmt.config.js",
"oxfmt.config.mjs",
"oxfmt.config.cjs",
"vite.config.ts",
"vite.config.mts",
"vite.config.cts",
"vite.config.js",
"vite.config.mjs",
"vite.config.cjs",
".editorconfig",
],
],
Expand All @@ -41,6 +47,12 @@ describe("LSP initialization", () => {
"oxfmt.config.js",
"oxfmt.config.mjs",
"oxfmt.config.cjs",
"vite.config.ts",
"vite.config.mts",
"vite.config.cts",
"vite.config.js",
"vite.config.mjs",
"vite.config.cjs",
".editorconfig",
],
],
Expand Down
Loading