diff --git a/apps/oxfmt/src-js/cli.ts b/apps/oxfmt/src-js/cli.ts index 61a806c0d3890..8b0bb3e973a0f 100644 --- a/apps/oxfmt/src-js/cli.ts +++ b/apps/oxfmt/src-js/cli.ts @@ -1,12 +1,20 @@ import { format } from "./bindings.js"; import { setupConfig, formatEmbeddedCode, formatFile } from "./prettier-proxy.js"; +import { runInit } from "./migration/init.js"; -const args = process.argv.slice(2); +void (async () => { + const args = process.argv.slice(2); -// Call the Rust formatter with our JS callback -const success = await format(args, setupConfig, formatEmbeddedCode, formatFile); + // Handle `--init` command in JS + if (args.includes("--init")) { + return await runInit(); + } -// NOTE: It's recommended to set `process.exitCode` instead of calling `process.exit()`. -// `process.exit()` kills the process immediately and `stdout` may not be flushed before process dies. -// https://nodejs.org/api/process.html#processexitcode -if (!success) process.exitCode = 1; + // Call the Rust formatter with our JS callback + const success = await format(args, setupConfig, formatEmbeddedCode, formatFile); + + // NOTE: It's recommended to set `process.exitCode` instead of calling `process.exit()`. + // `process.exit()` kills the process immediately and `stdout` may not be flushed before process dies. + // https://nodejs.org/api/process.html#processexitcode + if (!success) process.exitCode = 1; +})(); diff --git a/apps/oxfmt/src-js/migration/init.ts b/apps/oxfmt/src-js/migration/init.ts new file mode 100644 index 0000000000000..7273cfff8bff9 --- /dev/null +++ b/apps/oxfmt/src-js/migration/init.ts @@ -0,0 +1,52 @@ +/* oxlint-disable no-console */ + +import { stat, writeFile } from "node:fs/promises"; + +async function isFile(path: string) { + try { + const stats = await stat(path); + return stats.isFile(); + } catch { + return false; + } +} + +/** + * Run the `--init` command to scaffold a default `.oxfmtrc.json` file. + */ +export async function runInit() { + // Check if config file already exists + if ((await isFile(".oxfmtrc.json")) || (await isFile(".oxfmtrc.jsonc"))) { + console.error("Configuration file already exists."); + process.exitCode = 1; + return; + } + + // Build config object + const schemaPath = "./node_modules/oxfmt/configuration_schema.json"; + + const config: Record = { + // Add `$schema` field at the top if schema file exists in `node_modules` + $schema: schemaPath, + // `ignorePatterns` is included to make visible and preferred over `.prettierignore` + ignorePatterns: [], + }; + + // Remove if this command is run with e.g. `npx` + // NOTE: To keep `$schema` field at the top, we delete it here instead of defining conditionally above + if (!(await isFile(schemaPath))) { + delete config.$schema; + } + + try { + const jsonStr = JSON.stringify(config, null, 2); + + // TODO: Call napi `validateConfig()` to ensure validity + + await writeFile(".oxfmtrc.json", jsonStr + "\n"); + console.log("Created `.oxfmtrc.json`."); + } catch { + console.error("Failed to write `.oxfmtrc.json`."); + process.exitCode = 1; + } +} diff --git a/apps/oxfmt/src/cli/command.rs b/apps/oxfmt/src/cli/command.rs index 9509c0d276dc5..4008acf5b7101 100644 --- a/apps/oxfmt/src/cli/command.rs +++ b/apps/oxfmt/src/cli/command.rs @@ -44,11 +44,13 @@ pub enum Mode { /// Default CLI mode run against files and directories Cli(OutputMode), #[cfg(feature = "napi")] - /// Initialize `.oxfmtrc.jsonc` with default values - Init, - #[cfg(feature = "napi")] /// Start language server protocol (LSP) server Lsp, + #[cfg(feature = "napi")] + /// Initialize `.oxfmtrc.json` with default values + // NOTE: This is handled by JS side before reaching Rust. + // Just to display help message correctly. + Init, } fn mode() -> impl bpaf::Parser { @@ -57,7 +59,7 @@ fn mode() -> impl bpaf::Parser { #[cfg(feature = "napi")] { let init = bpaf::long("init") - .help("Initialize `.oxfmtrc.jsonc` with default values") + .help("Initialize `.oxfmtrc.json` with default values") .req_flag(Mode::Init) .hide_usage(); let lsp = bpaf::long("lsp") diff --git a/apps/oxfmt/src/cli/result.rs b/apps/oxfmt/src/cli/result.rs index b1ee8924e4c30..402223f5136d4 100644 --- a/apps/oxfmt/src/cli/result.rs +++ b/apps/oxfmt/src/cli/result.rs @@ -5,25 +5,20 @@ pub enum CliRunResult { // Success None, FormatSucceeded, - InitSucceeded, // Warning error InvalidOptionConfig, FormatMismatch, - InitAborted, // Fatal error NoFilesFound, FormatFailed, - InitFailed, } impl Termination for CliRunResult { fn report(self) -> ExitCode { match self { - Self::None | Self::FormatSucceeded | Self::InitSucceeded => ExitCode::from(0), - Self::InvalidOptionConfig | Self::FormatMismatch | Self::InitAborted => { - ExitCode::from(1) - } - Self::NoFilesFound | Self::FormatFailed | Self::InitFailed => ExitCode::from(2), + Self::None | Self::FormatSucceeded => ExitCode::from(0), + Self::InvalidOptionConfig | Self::FormatMismatch => ExitCode::from(1), + Self::NoFilesFound | Self::FormatFailed => ExitCode::from(2), } } } diff --git a/apps/oxfmt/src/init/mod.rs b/apps/oxfmt/src/init/mod.rs deleted file mode 100644 index 61ada300d7e09..0000000000000 --- a/apps/oxfmt/src/init/mod.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::fs; -use std::io::BufWriter; -use std::path::Path; - -use oxc_formatter::Oxfmtrc; - -use crate::cli::CliRunResult; -use crate::core::utils; - -/// Run the `--init` command to scaffold a default configuration file. -pub fn run_init() -> CliRunResult { - let mut stdout = BufWriter::new(std::io::stdout()); - let mut stderr = BufWriter::new(std::io::stderr()); - - // Check if config file already exists - if Path::new(".oxfmtrc.json").exists() || Path::new(".oxfmtrc.jsonc").exists() { - utils::print_and_flush(&mut stderr, "Configuration file already exists.\n"); - return CliRunResult::InitAborted; - } - - // NOTE: Use `Oxfmtrc` struct to prevent typos and ensure field consistency - // All other fields are default `None` = not set - let config = Oxfmtrc { - // To make visible that this field exists - ignore_patterns: Some(vec![]), - ..Oxfmtrc::default() - }; - let Ok(mut json) = serde_json::to_value(&config) else { - utils::print_and_flush(&mut stderr, "Failed to generate configuration.\n"); - return CliRunResult::InitFailed; - }; - - // NOTE: `serde_json::Map` does not support inserting at the beginning, - // so we rebuild the object to place `$schema` at the top. - let schema_path = "./node_modules/oxfmt/configuration_schema.json"; - if Path::new(schema_path).is_file() - && let Some(obj) = json.as_object_mut() - { - let mut new_obj = serde_json::Map::new(); - new_obj.insert("$schema".to_string(), serde_json::Value::String(schema_path.to_string())); - for (k, v) in std::mem::take(obj) { - new_obj.insert(k, v); - } - json = serde_json::Value::Object(new_obj); - } - - let Ok(json_str) = serde_json::to_string_pretty(&json) else { - utils::print_and_flush(&mut stderr, "Failed to serialize configuration.\n"); - return CliRunResult::InitFailed; - }; - - if fs::write(".oxfmtrc.jsonc", format!("{json_str}\n")).is_ok() { - utils::print_and_flush(&mut stdout, "Created `.oxfmtrc.jsonc`.\n"); - CliRunResult::InitSucceeded - } else { - utils::print_and_flush(&mut stderr, "Failed to write `.oxfmtrc.jsonc`.\n"); - CliRunResult::InitFailed - } -} diff --git a/apps/oxfmt/src/lib.rs b/apps/oxfmt/src/lib.rs index 67a9ff3a21d21..5d33779668518 100644 --- a/apps/oxfmt/src/lib.rs +++ b/apps/oxfmt/src/lib.rs @@ -1,6 +1,5 @@ pub mod cli; mod core; -pub mod init; pub mod lsp; // Only include code to run formatter when the `napi` feature is enabled. diff --git a/apps/oxfmt/src/main_napi.rs b/apps/oxfmt/src/main_napi.rs index becd9a7663c1e..03048af485f48 100644 --- a/apps/oxfmt/src/main_napi.rs +++ b/apps/oxfmt/src/main_napi.rs @@ -10,7 +10,6 @@ use crate::{ CliRunResult, FormatRunner, Mode, format_command, init_miette, init_rayon, init_tracing, }, core::{ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb, JsSetupConfigCb}, - init::run_init, lsp::run_lsp, }; @@ -68,7 +67,7 @@ async fn format_impl( }; match command.mode { - Mode::Init => run_init(), + Mode::Init => unreachable!("`--init` should be handled by JS side"), Mode::Lsp => { run_lsp().await; CliRunResult::None diff --git a/apps/oxfmt/test/init.test.ts b/apps/oxfmt/test/init.test.ts new file mode 100644 index 0000000000000..9aee3242da1ed --- /dev/null +++ b/apps/oxfmt/test/init.test.ts @@ -0,0 +1,82 @@ +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import fs from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import { runCli } from "./utils"; + +describe("init", () => { + it("should create .oxfmtrc.json", async () => { + const tempDir = await fs.mkdtemp(join(tmpdir(), "oxfmt-init-test-")); + + try { + const result = await runCli(tempDir, ["--init"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Created `.oxfmtrc.json`."); + + const configPath = join(tempDir, ".oxfmtrc.json"); + const content = await fs.readFile(configPath, "utf8"); + const config = JSON.parse(content); + + expect(config.ignorePatterns).toEqual([]); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("should add $schema when node_modules/oxfmt exists", async () => { + const tempDir = await fs.mkdtemp(join(tmpdir(), "oxfmt-init-test-")); + + try { + // Create fake node_modules/oxfmt/configuration_schema.json + const schemaDir = join(tempDir, "node_modules", "oxfmt"); + await fs.mkdir(schemaDir, { recursive: true }); + await fs.writeFile(join(schemaDir, "configuration_schema.json"), "{}"); + + const result = await runCli(tempDir, ["--init"]); + + expect(result.exitCode).toBe(0); + + const configPath = join(tempDir, ".oxfmtrc.json"); + const content = await fs.readFile(configPath, "utf8"); + const config = JSON.parse(content); + + expect(config.$schema).toBe("./node_modules/oxfmt/configuration_schema.json"); + expect(Object.keys(config)[0]).toBe("$schema"); // $schema should be first + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("should abort if .oxfmtrc.json already exists", async () => { + const tempDir = await fs.mkdtemp(join(tmpdir(), "oxfmt-init-test-")); + + try { + // Create existing config file + await fs.writeFile(join(tempDir, ".oxfmtrc.json"), "{}"); + + const result = await runCli(tempDir, ["--init"]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Configuration file already exists."); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it("should abort if .oxfmtrc.jsonc already exists", async () => { + const tempDir = await fs.mkdtemp(join(tmpdir(), "oxfmt-init-test-")); + + try { + // Create existing config file + await fs.writeFile(join(tempDir, ".oxfmtrc.jsonc"), "{}"); + + const result = await runCli(tempDir, ["--init"]); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Configuration file already exists."); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/oxfmt/test/utils.ts b/apps/oxfmt/test/utils.ts index e1ab3424dcfa0..18e021ad301e4 100644 --- a/apps/oxfmt/test/utils.ts +++ b/apps/oxfmt/test/utils.ts @@ -62,13 +62,13 @@ ${afterContent} // --- -type RunResult = { +export type RunResult = { stdout: string; stderr: string; exitCode: number; }; -async function runCli(cwd: string, args: string[]): Promise { +export async function runCli(cwd: string, args: string[]): Promise { const cliPath = join(import.meta.dirname, "..", "dist", "cli.js"); const result = await execa("node", [cliPath, ...args], { diff --git a/tasks/website_formatter/src/snapshots/cli.snap b/tasks/website_formatter/src/snapshots/cli.snap index a63eacf7d6ff2..881293f3c9e3e 100644 --- a/tasks/website_formatter/src/snapshots/cli.snap +++ b/tasks/website_formatter/src/snapshots/cli.snap @@ -12,7 +12,7 @@ search: false ## Mode Options: - **` --init`** — - Initialize `.oxfmtrc.jsonc` with default values + Initialize `.oxfmtrc.json` with default values - **` --lsp`** — Start language server protocol (LSP) server diff --git a/tasks/website_formatter/src/snapshots/cli_terminal.snap b/tasks/website_formatter/src/snapshots/cli_terminal.snap index a33845cf6c801..3f6a86bf0ee63 100644 --- a/tasks/website_formatter/src/snapshots/cli_terminal.snap +++ b/tasks/website_formatter/src/snapshots/cli_terminal.snap @@ -5,7 +5,7 @@ expression: snapshot Usage: [-c=PATH] [PATH]... Mode Options: - --init Initialize `.oxfmtrc.jsonc` with default values + --init Initialize `.oxfmtrc.json` with default values --lsp Start language server protocol (LSP) server Output Options: