Skip to content
Open
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
20 changes: 18 additions & 2 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ const OXFMT_JS_CONFIG_NAME: &str = "oxfmt.config.ts";
#[cfg(feature = "napi")]
const VITE_PLUS_CONFIG_NAME: &str = "vite.config.ts";

/// Returns `true` when running inside Vite+ mode (`VITE_PLUS_VERSION` env var is set).
/// When true, only `vite.config.ts` is used as a config source.
/// When false, `vite.config.ts` is ignored and only oxfmt-specific configs are used.
#[cfg(feature = "napi")]
fn is_vp() -> bool {
std::env::var_os("VITE_PLUS_VERSION").is_some()
}

fn is_js_config_file(path: &Path) -> bool {
path.extension().and_then(|e| e.to_str()).is_some_and(|ext| JS_CONFIG_EXTENSIONS.contains(&ext))
}
Expand All @@ -45,17 +53,25 @@ fn is_vite_plus_config(path: &Path) -> bool {
}

/// Returns an iterator of all supported config file names, in priority order.
///
/// In VP mode (`VITE_PLUS_VERSION` env var set), only `vite.config.ts` is returned.
/// In non-VP mode, `vite.config.ts` is excluded.
pub fn all_config_file_names() -> impl Iterator<Item = String> {
#[cfg(feature = "napi")]
{
if is_vp() {
return vec![VITE_PLUS_CONFIG_NAME.to_string()].into_iter();
}
JSON_CONFIG_FILES
.iter()
.copied()
.chain([OXFMT_JS_CONFIG_NAME, VITE_PLUS_CONFIG_NAME])
.chain([OXFMT_JS_CONFIG_NAME])
.map(ToString::to_string)
.collect::<Vec<_>>()
.into_iter()
}
#[cfg(not(feature = "napi"))]
JSON_CONFIG_FILES.iter().map(|f| (*f).to_string())
JSON_CONFIG_FILES.iter().map(|f| (*f).to_string()).collect::<Vec<_>>().into_iter()
}

pub fn resolve_editorconfig_path(cwd: &Path) -> Option<PathBuf> {
Expand Down
11 changes: 8 additions & 3 deletions apps/oxfmt/test/cli/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ declare global {

const CLI_PATH = join(import.meta.dirname, "..", "..", "dist", "cli.js");

export function runCli(cwd: string, args: string[]) {
export function runCli(cwd: string, args: string[], env: Record<string, string> = {}) {
return execa("node", [CLI_PATH, ...args, "--threads=1"], {
cwd,
reject: false,
timeout: 5000,
env,
});
}

Expand All @@ -30,10 +31,14 @@ export function runCliStdin(input: string, filepath: string, pipe?: string) {
}

// Test function for running the CLI with various arguments
export async function runAndSnapshot(cwd: string, testCases: string[][]): Promise<string> {
export async function runAndSnapshot(
cwd: string,
testCases: string[][],
env?: Record<string, string>,
): Promise<string> {
const snapshot = [];
for (const args of testCases) {
const result = await runCli(cwd, args);
const result = await runCli(cwd, args, env);
snapshot.push(formatSnapshot(cwd, args, result));
}
return normalizeOutput(snapshot.join("\n"), cwd);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,37 @@ Expected a \`fmt\` field in the default export of <cwd>/vite.config.ts
--------------------"
`;

exports[`vite_config > priority: oxfmt.config.ts takes precedence over vite.config.ts 1`] = `
exports[`vite_config > ignored: not auto-discovered vite.config.ts that fails to load 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/error_load_failure/child
exit code: 0
--- STDOUT ---------
Checking formatting...

All matched files use the correct format.
Finished in <variable>ms on 1 files using 1 threads.
--- STDERR ---------

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

exports[`vite_config > ignored: vite.config.ts is not used without VITE_PLUS_VERSION 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/basic
exit code: 0
--- STDOUT ---------
Checking formatting...

All matched files use the correct format.
Finished in <variable>ms on 1 files using 1 threads.
--- STDERR ---------

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

exports[`vite_config > oxfmt: vite.config.ts is ignored, oxfmt.config.ts is used 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/priority
Expand Down Expand Up @@ -107,17 +137,30 @@ Finished in <variable>ms on 1 files using 1 threads.
--------------------"
`;

exports[`vite_config > skip: parent config is found when vite.config.ts without fmt is skipped 1`] = `
exports[`vite_config > vp: oxfmt.config.ts is ignored, vite.config.ts is used 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/skip_finds_parent/child
exit code: 1
working directory: vite_config/fixtures/priority
exit code: 0
--- STDOUT ---------
Checking formatting...

test.ts (<variable>ms)
All matched files use the correct format.
Finished in <variable>ms on 1 files using 1 threads.
--- STDERR ---------

Format issues found in above 1 files. Run without \`--check\` to fix.
--------------------"
`;

exports[`vite_config > vp: parent oxfmt config is not used when vite.config.ts without fmt is skipped 1`] = `
"--------------------
arguments: --check test.ts
working directory: vite_config/fixtures/skip_finds_parent/child
exit code: 0
--- STDOUT ---------
Checking formatting...

All matched files use the correct format.
Finished in <variable>ms on 1 files using 1 threads.
--- STDERR ---------

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
const a = 1;
const a = 1
66 changes: 49 additions & 17 deletions apps/oxfmt/test/cli/vite_config/vite_config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,59 +4,91 @@ import { runAndSnapshot } from "../utils";

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

const VP_ENV = { VITE_PLUS_VERSION: "1" };

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"]], VP_ENV);
expect(snapshot).toMatchSnapshot();
});

it("ignored: vite.config.ts is not used without VITE_PLUS_VERSION", async () => {
// Same fixture as "basic" but without VITE_PLUS_VERSION env
// vite.config.ts has semi: false, but should be ignored → defaults (semi: true) → check passes
const cwd = join(fixturesDir, "basic");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
expect(snapshot).toMatchSnapshot();
});

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

it("skip: auto-discovered vite.config.ts without fmt field uses defaults", async () => {
const cwd = join(fixturesDir, "no_fmt_field");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]], VP_ENV);
expect(snapshot).toMatchSnapshot();
});

it("error: explicit --config vite.config.ts that fails to load", async () => {
const cwd = join(fixturesDir, "error_load_failure", "child");
const snapshot = await runAndSnapshot(cwd, [
["--check", "--config", "vite.config.ts", "test.ts"],
]);
const snapshot = await runAndSnapshot(
cwd,
[["--check", "--config", "vite.config.ts", "test.ts"]],
VP_ENV,
);
expect(snapshot).toMatchSnapshot();
});

it("error: auto-discovered vite.config.ts that fails to load", async () => {
const cwd = join(fixturesDir, "error_load_failure", "child");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]], VP_ENV);
expect(snapshot).toMatchSnapshot();
});

it("skip: auto-discovered vite.config.ts without fmt field uses defaults", async () => {
const cwd = join(fixturesDir, "no_fmt_field");
it("ignored: not auto-discovered vite.config.ts that fails to load", async () => {
// Without VITE_PLUS_VERSION, broken vite.config.ts is completely ignored
// Parent .oxfmtrc.json (semi: false) is found instead → `const a = 1` (no semi) matches → check passes
const cwd = join(fixturesDir, "error_load_failure", "child");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
expect(snapshot).toMatchSnapshot();
});

it("skip: parent config is found when vite.config.ts without fmt is skipped", async () => {
it("vp: parent oxfmt config is not used when vite.config.ts without fmt is skipped", async () => {
// child/ has vite.config.ts without .fmt → skipped
// parent has .oxfmtrc.json with semi: false
// So `const a = 1;` (with semicolon) should be flagged as mismatch
// parent has .oxfmtrc.json with semi: false, but it's ignored in VP mode
// So defaults (semi: true) apply → `const a = 1;` check passes
const cwd = join(fixturesDir, "skip_finds_parent", "child");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]], VP_ENV);
expect(snapshot).toMatchSnapshot();
});

it("skip: auto-discovered vite.config.ts with function export uses defaults", async () => {
const cwd = join(fixturesDir, "skip_fn_export");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]], VP_ENV);
expect(snapshot).toMatchSnapshot();
});

it("vp: oxfmt.config.ts is ignored, vite.config.ts is used", async () => {
// `vite.config.ts` has `semi: true`, `oxfmt.config.ts` has `semi: false`
// In VP mode, oxfmt.config.ts is ignored and vite.config.ts is used
// So `const a = 1;` (with semicolon) matches semi: true → check passes
const cwd = join(fixturesDir, "priority");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]], VP_ENV);
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
it("oxfmt: vite.config.ts is ignored, oxfmt.config.ts is used", async () => {
// `vite.config.ts` has `semi: true`, `oxfmt.config.ts` has `semi: false`
// In non-VP mode, vite.config.ts is ignored and oxfmt.config.ts is used
// So `const a = 1;` (with semicolon) does not match semi: false → check fails
const cwd = join(fixturesDir, "priority");
const snapshot = await runAndSnapshot(cwd, [["--check", "test.ts"]]);
expect(snapshot).toMatchSnapshot();
Expand Down
11 changes: 10 additions & 1 deletion apps/oxfmt/test/lsp/format/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ 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 All @@ -36,6 +35,16 @@ describe("LSP formatting", () => {
])("should apply config from %s", async (path, languageId) => {
expect(await formatFixture(FIXTURES_DIR, path, languageId)).toMatchSnapshot();
});

it("should apply config from config-vite-semi/test.ts", async () => {
await using client = createLspConnection({ VITE_PLUS_VERSION: "1" });
const path = "config-vite-semi/test.ts";
const dirPath = dirname(join(FIXTURES_DIR, path));
await client.initialize([{ uri: pathToFileURL(dirPath).href, name: "test" }], {}, [
{ workspaceUri: pathToFileURL(dirPath).href, options: null },
]);
expect(await formatFixture(FIXTURES_DIR, path, "typescript", client)).toMatchSnapshot();
});
});

describe("config options in nested workspace folders", () => {
Expand Down
7 changes: 2 additions & 5 deletions apps/oxfmt/test/lsp/init/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,10 @@ describe("LSP initialization", () => {
});

it.each([
[
undefined,
[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "vite.config.ts", ".editorconfig"],
],
[undefined, [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", ".editorconfig"]],
[
{ "fmt.configPath": "" },
[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "vite.config.ts", ".editorconfig"],
[".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", ".editorconfig"],
],
[{ "fmt.configPath": "./custom-config.json" }, ["./custom-config.json", ".editorconfig"]],
])(
Expand Down
3 changes: 2 additions & 1 deletion apps/oxfmt/test/lsp/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ import type {

const CLI_PATH = join(import.meta.dirname, "..", "..", "dist", "cli.js");

export function createLspConnection() {
export function createLspConnection(env: Record<string, string> = {}) {
const proc = spawn("node", [CLI_PATH, "--lsp"], {
env: { ...process.env, ...env },
// env: { ...process.env, OXC_LOG: "info" }, for debugging
});

Expand Down
23 changes: 15 additions & 8 deletions apps/oxlint/src/config_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};

use crate::{
DEFAULT_JSONC_OXLINTRC_NAME, DEFAULT_OXLINTRC_NAME, DEFAULT_TS_OXLINTRC_NAME, VITE_CONFIG_NAME,
is_vp,
};

#[cfg(feature = "napi")]
Expand Down Expand Up @@ -146,6 +147,7 @@ fn to_discovered_config(entry: &DirEntry) -> Option<DiscoveredConfig> {
return None;
}
let file_name = entry.path().file_name()?;

if file_name == DEFAULT_OXLINTRC_NAME {
Some(DiscoveredConfig::Json(entry.path().to_path_buf()))
} else if file_name == DEFAULT_JSONC_OXLINTRC_NAME {
Expand Down Expand Up @@ -464,7 +466,18 @@ impl<'a> ConfigLoader<'a> {
///
/// Checks for both `.oxlintrc.json` and `oxlint.config.ts` files in the given directory.
/// Returns `Ok(Some(config))` if found, `Ok(None)` if not found, or `Err` on error.
///
/// In VP mode (`VITE_PLUS_VERSION` env var set), only `vite.config.ts` is checked.
/// In non-VP mode, only oxlint-specific config files are checked.
fn try_load_config_from_dir(&self, dir: &Path) -> Result<Option<Oxlintrc>, OxcDiagnostic> {
if is_vp() {
let vite_config_path = dir.join(VITE_CONFIG_NAME);
if vite_config_path.is_file() {
return self.load_root_js_config(&vite_config_path);
}
return Ok(None);
}

let json_path = dir.join(DEFAULT_OXLINTRC_NAME);
let jsonc_path = dir.join(DEFAULT_JSONC_OXLINTRC_NAME);
let ts_path = dir.join(DEFAULT_TS_OXLINTRC_NAME);
Expand Down Expand Up @@ -494,13 +507,6 @@ impl<'a> ConfigLoader<'a> {
return Oxlintrc::from_file(&jsonc_path).map(Some);
}

// Fallback: check for vite.config.ts with .lint field (lowest priority)
// If .lint field is missing, `load_root_js_config` returns `Ok(None)` to skip.
let vite_config_path = dir.join(VITE_CONFIG_NAME);
if vite_config_path.is_file() {
return self.load_root_js_config(&vite_config_path);
}

Ok(None)
}

Expand Down Expand Up @@ -624,7 +630,8 @@ impl<'a> ConfigLoader<'a> {
Err(err) => return Err(CliConfigLoadError::RootConfig(err)),
};

if !search_for_nested_configs {
// NOTE: For now, nested config support for Vite+ is unstated
if !search_for_nested_configs || is_vp() {
return Ok(LoadedConfigs {
root: oxlintrc,
nested: FxHashMap::default(),
Expand Down
7 changes: 7 additions & 0 deletions apps/oxlint/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ const DEFAULT_TS_OXLINTRC_NAME: &str = "oxlint.config.ts";
/// Vite config file that may contain oxlint config under a `.lint` field.
const VITE_CONFIG_NAME: &str = "vite.config.ts";

/// Returns `true` when running inside Vite+ mode (`VITE_PLUS_VERSION` env var is set).
/// When true, only `vite.config.ts` is used as a config source.
/// When false, `vite.config.ts` is ignored and only oxlint-specific configs are used.
fn is_vp() -> bool {
std::env::var_os("VITE_PLUS_VERSION").is_some()
}

/// Return a JSON blob containing metadata for all available oxlint rules.
///
/// This uses the internal JSON output formatter to generate the full list.
Expand Down
Loading
Loading