diff --git a/Cargo.lock b/Cargo.lock index eb38ce443e9d5..3d83c36d8a0c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,6 +1945,9 @@ dependencies = [ "cow-utils", "insta", "json-strip-comments", + "napi", + "napi-build", + "napi-derive", "natord", "oxc-schemars", "oxc_allocator", @@ -2538,6 +2541,7 @@ dependencies = [ "oxc_diagnostics", "oxc_formatter", "oxc_language_server", + "oxc_napi", "oxc_parser", "oxc_span", "phf", diff --git a/apps/oxfmt/Cargo.toml b/apps/oxfmt/Cargo.toml index d6fcd7316ca7f..23addd98881c0 100644 --- a/apps/oxfmt/Cargo.toml +++ b/apps/oxfmt/Cargo.toml @@ -31,6 +31,7 @@ oxc_allocator = { workspace = true, features = ["pool"] } oxc_diagnostics = { workspace = true } oxc_formatter = { workspace = true } oxc_language_server = { workspace = true, default-features = false, features = ["formatter"] } +oxc_napi = { workspace = true } oxc_parser = { workspace = true } oxc_span = { workspace = true } @@ -66,7 +67,7 @@ mimalloc-safe = { workspace = true, optional = true, features = ["skip_collect_o [features] default = ["napi"] -napi = ["dep:napi", "dep:napi-derive"] +napi = ["dep:napi", "dep:napi-derive", "oxc_formatter/napi"] allocator = ["dep:mimalloc-safe"] detect_code_removal = ["oxc_formatter/detect_code_removal"] diff --git a/apps/oxfmt/src-js/bindings.d.ts b/apps/oxfmt/src-js/bindings.d.ts index 290d0d776876c..8f5f3ec54a8ac 100644 --- a/apps/oxfmt/src-js/bindings.d.ts +++ b/apps/oxfmt/src-js/bindings.d.ts @@ -1,7 +1,153 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +export declare const enum ArrowParensConfig { + Always = 'Always', + Avoid = 'Avoid' +} + +export declare const enum EmbeddedLanguageFormattingConfig { + Auto = 'Auto', + Off = 'Off' +} + +export declare const enum EndOfLineConfig { + Lf = 'Lf', + Crlf = 'Crlf', + Cr = 'Cr' +} + +export declare const enum ObjectWrapConfig { + Preserve = 'Preserve', + Collapse = 'Collapse', + Always = 'Always' +} + /** - * NAPI entry point. + * Configuration options for the Oxfmt. + * + * Most options are the same as Prettier's options. + * See also + * + * In addition, some options are our own extensions. + */ +export interface Oxfmtrc { + /** Use tabs for indentation or spaces. (Default: `false`) */ + useTabs?: boolean + /** Number of spaces per indentation level. (Default: `2`) */ + tabWidth?: number + /** Which end of line characters to apply. (Default: `"lf"`) */ + endOfLine?: EndOfLineConfig + /** The line length that the printer will wrap on. (Default: `100`) */ + printWidth?: number + /** Use single quotes instead of double quotes. (Default: `false`) */ + singleQuote?: boolean + /** Use single quotes instead of double quotes in JSX. (Default: `false`) */ + jsxSingleQuote?: boolean + /** Change when properties in objects are quoted. (Default: `"as-needed"`) */ + quoteProps?: QuotePropsConfig + /** Print trailing commas wherever possible. (Default: `"all"`) */ + trailingComma?: TrailingCommaConfig + /** Print semicolons at the ends of statements. (Default: `true`) */ + semi?: boolean + /** Include parentheses around a sole arrow function parameter. (Default: `"always"`) */ + arrowParens?: ArrowParensConfig + /** Print spaces between brackets in object literals. (Default: `true`) */ + bracketSpacing?: boolean + /** + * Put the `>` of a multi-line JSX element at the end of the last line + * instead of being alone on the next line. (Default: `false`) + */ + bracketSameLine?: boolean + /** + * How to wrap object literals when they could fit on one line or span multiple lines. (Default: `"preserve"`) + * NOTE: In addition to Prettier's `"preserve"` and `"collapse"`, we also support `"always"`. + */ + objectWrap?: ObjectWrapConfig + /** Put each attribute on a new line in JSX. (Default: `false`) */ + singleAttributePerLine?: boolean + experimentalOperatorPosition?: boolean + experimentalTernaries?: boolean + /** Control whether formats quoted code embedded in the file. (Default: `"auto"`) */ + embeddedLanguageFormatting?: EmbeddedLanguageFormattingConfig + /** Experimental: Sort import statements. Disabled by default. */ + experimentalSortImports?: SortImportsConfig + /** Experimental: Sort `package.json` keys. (Default: `true`) */ + experimentalSortPackageJson?: boolean + /** Ignore files matching these glob patterns. Current working directory is used as the root. */ + ignorePatterns?: Array +} + +export declare const enum QuotePropsConfig { + AsNeeded = 'AsNeeded', + Consistent = 'Consistent', + Preserve = 'Preserve' +} + +export interface SortImportsConfig { + partitionByNewline: boolean + partitionByComment: boolean + sortSideEffects: boolean + order?: SortOrderConfig + ignoreCase: boolean + newlinesBetween: boolean + internalPattern?: Array + /** + * Custom groups configuration for organizing imports. + * Each array element represents a group, and multiple group names in the same array are treated as one. + * Accepts both `string` and `string[]` as group elements. + */ + groups?: Array> +} + +export declare const enum SortOrderConfig { + Asc = 'Asc', + Desc = 'Desc' +} + +export declare const enum TrailingCommaConfig { + All = 'All', + Es5 = 'Es5', + None = 'None' +} +export interface Comment { + type: 'Line' | 'Block' + value: string + start: number + end: number +} + +export interface ErrorLabel { + message: string | null + start: number + end: number +} + +export interface OxcError { + severity: Severity + message: string + labels: Array + helpMessage: string | null + codeframe: string | null +} + +export declare const enum Severity { + Error = 'Error', + Warning = 'Warning', + Advice = 'Advice' +} +/** Format source code asynchronously. */ +export declare function format(filename: string, sourceText: string, options: Oxfmtrc | undefined | null, setupConfigCb: (configJSON: string, numThreads: number) => Promise, formatEmbeddedCb: (tagName: string, code: string) => Promise, formatFileCb: (parserName: string, fileName: string, code: string) => Promise): Promise + +export interface FormatResult { + /** The formatted code. */ + code: string + /** Parse and format errors. */ + errors: Array +} + +/** + * NAPI based JS CLI entry point. + * For pure Rust CLI entry point, see `main.rs`. * * JS side passes in: * 1. `args`: Command line arguments (process.argv.slice(2)) diff --git a/apps/oxfmt/src-js/bindings.js b/apps/oxfmt/src-js/bindings.js index d077020e1791b..2d1e4c8630d71 100644 --- a/apps/oxfmt/src-js/bindings.js +++ b/apps/oxfmt/src-js/bindings.js @@ -575,5 +575,14 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { runCli } = nativeBinding +const { ArrowParensConfig, EmbeddedLanguageFormattingConfig, EndOfLineConfig, ObjectWrapConfig, QuotePropsConfig, SortOrderConfig, TrailingCommaConfig, Severity, format, runCli } = nativeBinding +export { ArrowParensConfig } +export { EmbeddedLanguageFormattingConfig } +export { EndOfLineConfig } +export { ObjectWrapConfig } +export { QuotePropsConfig } +export { SortOrderConfig } +export { TrailingCommaConfig } +export { Severity } +export { format } export { runCli } diff --git a/apps/oxfmt/src-js/index.ts b/apps/oxfmt/src-js/index.ts index a8ab01e7da50f..722974fa2ea3f 100644 --- a/apps/oxfmt/src-js/index.ts +++ b/apps/oxfmt/src-js/index.ts @@ -1,2 +1,29 @@ -export * from "./bindings.js"; -export { setupConfig, formatEmbeddedCode, formatFile } from "./prettier-proxy.js"; +import * as napi from "./bindings.js"; +import { setupConfig, formatEmbeddedCode, formatFile } from "./prettier-proxy.js"; +import type { Oxfmtrc } from "./bindings.js"; + +// NOTE: We need to re-export related types and enum fields manually +export type { Oxfmtrc } from "./bindings.js"; +export { + EndOfLineConfig, + QuotePropsConfig, + ArrowParensConfig, + EmbeddedLanguageFormattingConfig, + ObjectWrapConfig, + TrailingCommaConfig, + SortOrderConfig, +} from "./bindings.js"; + +export async function format(fileName: string, sourceText: string, options?: Oxfmtrc) { + if (typeof fileName !== "string") throw new TypeError("`fileName` must be a string"); + if (typeof sourceText !== "string") throw new TypeError("`sourceText` must be a string"); + + return napi.format( + fileName, + sourceText, + options ?? {}, + setupConfig, + formatEmbeddedCode, + formatFile, + ); +} diff --git a/apps/oxfmt/src-js/prettier-proxy.ts b/apps/oxfmt/src-js/prettier-proxy.ts index 1a4d2baf888f8..bfc625fb88caa 100644 --- a/apps/oxfmt/src-js/prettier-proxy.ts +++ b/apps/oxfmt/src-js/prettier-proxy.ts @@ -19,7 +19,8 @@ export async function setupConfig(configJSON: string, numThreads: number): Promi prettierConfig: JSON.parse(configJSON), }; - if (pool) throw new Error("`setupConfig()` has already been called"); + // Just ignore if already initialized + if (pool) return []; // Initialize worker pool for parallel Prettier formatting // Pass config via workerData so all workers get it on initialization diff --git a/apps/oxfmt/src/main_napi.rs b/apps/oxfmt/src/main_napi.rs index d467e6d14c66e..4ababd9cd76d9 100644 --- a/apps/oxfmt/src/main_napi.rs +++ b/apps/oxfmt/src/main_napi.rs @@ -1,18 +1,24 @@ use std::ffi::OsString; +use std::path::PathBuf; use napi_derive::napi; +use oxc_formatter::Oxfmtrc; +use oxc_napi::OxcError; +use serde_json::Value; + use crate::{ cli::{FormatRunner, Mode, format_command, init_miette, init_rayon, init_tracing}, - core::{ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb, JsSetupConfigCb}, + core::{ + ExternalFormatter, FormatFileStrategy, FormatResult as CoreFormatResult, + JsFormatEmbeddedCb, JsFormatFileCb, JsSetupConfigCb, SourceFormatter, + }, lsp::run_lsp, stdin::StdinRunner, }; -// NAPI based JS CLI entry point. -// For pure Rust CLI entry point, see `main.rs`. - -/// NAPI entry point. +/// NAPI based JS CLI entry point. +/// For pure Rust CLI entry point, see `main.rs`. /// /// JS side passes in: /// 1. `args`: Command line arguments (process.argv.slice(2)) @@ -91,3 +97,83 @@ pub async fn run_cli( } } } + +// --- + +#[napi(object)] +pub struct FormatResult { + /// The formatted code. + pub code: String, + /// Parse and format errors. + pub errors: Vec, +} + +/// Format source code asynchronously. +#[expect(clippy::allow_attributes)] +#[allow(clippy::trailing_empty_array, clippy::unused_async)] // https://github.com/napi-rs/napi-rs/issues/2758 +#[napi] +pub async fn format( + filename: String, + source_text: String, + options: Option, + #[napi(ts_arg_type = "(configJSON: string, numThreads: number) => Promise")] + setup_config_cb: JsSetupConfigCb, + #[napi(ts_arg_type = "(tagName: string, code: string) => Promise")] + format_embedded_cb: JsFormatEmbeddedCb, + #[napi( + ts_arg_type = "(parserName: string, fileName: string, code: string) => Promise" + )] + format_file_cb: JsFormatFileCb, +) -> FormatResult { + let external_formatter = + ExternalFormatter::new(setup_config_cb, format_embedded_cb, format_file_cb); + + // Determine format strategy from file path + let Ok(entry) = FormatFileStrategy::try_from(PathBuf::from(&filename)) else { + return FormatResult { + code: source_text, + errors: vec![OxcError::new(format!("Unsupported file type: {filename}"))], + }; + }; + + // Convert Oxfmtrc to FormatOptions and OxfmtOptions + let oxfmtrc = options.unwrap_or_default(); + let (format_options, oxfmt_options) = match oxfmtrc.into_options() { + Ok(opts) => opts, + Err(err) => { + return FormatResult { + code: source_text, + errors: vec![OxcError::new(format!("Invalid options: {err}"))], + }; + } + }; + + // Build external config JSON for Prettier (with defaults applied) + let mut external_config = Value::Object(serde_json::Map::new()); + Oxfmtrc::populate_prettier_config(&format_options, &mut external_config); + + let num_of_threads = 1; + + // Setup external formatter config (calls JS callback to initialize Prettier) + if let Err(err) = external_formatter.setup_config(&external_config.to_string(), num_of_threads) + { + return FormatResult { + code: source_text, + errors: vec![OxcError::new(format!("Failed to setup external formatter: {err}"))], + }; + } + + // Create source formatter with external formatter + let formatter = SourceFormatter::new(num_of_threads, format_options) + .with_external_formatter(Some(external_formatter), oxfmt_options.sort_package_json); + + // Run formatting in a blocking task within tokio runtime + // This is needed because external formatter uses `tokio::runtime::Handle::current()` + match tokio::task::block_in_place(|| formatter.format(&entry, &source_text)) { + CoreFormatResult::Success { code, .. } => FormatResult { code, errors: vec![] }, + CoreFormatResult::Error(diagnostics) => { + let errors = OxcError::from_diagnostics(&filename, &source_text, diagnostics); + FormatResult { code: source_text, errors } + } + } +} diff --git a/apps/oxfmt/test/api.test.ts b/apps/oxfmt/test/api.test.ts new file mode 100644 index 0000000000000..1d8d32e4e46c8 --- /dev/null +++ b/apps/oxfmt/test/api.test.ts @@ -0,0 +1,35 @@ +import { format, QuotePropsConfig } from "../dist/index.js"; +import { describe, expect, test } from "vitest"; +import type { Oxfmtrc } from "../dist/index.js"; + +describe("API Tests", () => { + test("`format()` function exists", () => { + expect(typeof format).toBe("function"); + }); + + test("should `format()` multiple times", async () => { + const result1 = await format("a.ts", "const x:number=42"); + expect(result1.code).toBe("const x: number = 42;\n"); + expect(result1.errors).toStrictEqual([]); + + const result2 = await format("a.json", '{"key": "value"}'); + expect(result2.code).toBe('{ "key": "value" }\n'); + expect(result2.errors).toStrictEqual([]); + }); + + test("should TS types work correctly", async () => { + const options: Oxfmtrc = { + quoteProps: QuotePropsConfig.AsNeeded, + printWidth: 120, + semi: false, + experimentalSortPackageJson: false, + experimentalSortImports: { + partitionByComment: false, + }, + }; + + const result = await format("a.ts", "const x={'y':1}", options); + expect(result.code).toBe("const x = { y: 1 }\n"); + expect(result.errors).toStrictEqual([]); + }); +}); diff --git a/crates/oxc_formatter/Cargo.toml b/crates/oxc_formatter/Cargo.toml index ae0769be4e2d2..9268ae0bd183b 100644 --- a/crates/oxc_formatter/Cargo.toml +++ b/crates/oxc_formatter/Cargo.toml @@ -30,6 +30,8 @@ oxc_syntax = { workspace = true } cow-utils = { workspace = true } json-strip-comments = { workspace = true } +napi = { workspace = true, optional = true } +napi-derive = { workspace = true, optional = true } natord = "1.0.9" phf = { workspace = true, features = ["macros"] } rustc-hash = { workspace = true } @@ -46,7 +48,9 @@ serde_json = { workspace = true } [build-dependencies] oxc_span = { workspace = true } +napi-build = { workspace = true } [features] default = [] detect_code_removal = ["dep:oxc_semantic"] +napi = ["dep:napi", "dep:napi-derive"] diff --git a/crates/oxc_formatter/build.rs b/crates/oxc_formatter/build.rs index c81de6b77c787..23815bd7fd59f 100644 --- a/crates/oxc_formatter/build.rs +++ b/crates/oxc_formatter/build.rs @@ -9,6 +9,26 @@ use std::{ use oxc_span::SourceType; fn main() { + // NOTE: Adding this is necessary to generate `Oxfmtrc` (defined in `oxc_formatter`) TS types from `apps/oxfmt`. + // + // But it triggers the following warning during build: + // ``` + // warning: oxc_formatter: cargo:rustc-cdylib-link-arg was specified in the build script of oxc_formatter, but that package does not contain a cdylib target + // + // Allowing this was an unintended change in the 1.50 release, and may become an error in the future. For more information, see . + // ``` + // + // To avoid this, `Oxfmtrc` should be moved to `apps/oxfmt`. + // But currently, + // - `oxc_language_server` depends on `Oxfmtrc` + // - apps/oxfmt` also depends on `oxc_language_server` + // So moving `Oxfmtrc` to `apps/oxfmt` would create a cyclic dependency... + // + // We need to remove `Oxfmtrc` dependency from `oxc_language_server` first. + // Or, create new crate `oxc_oxfmtrc` to hold `Oxfmtrc` and let both `oxc_language_server` and `apps/oxfmt` depend on it. + #[cfg(feature = "napi")] + napi_build::setup(); + let out_dir = env::var("OUT_DIR").unwrap(); let dest_path = Path::new(&out_dir).join("generated_tests.rs"); let mut f = File::create(&dest_path).unwrap(); diff --git a/crates/oxc_formatter/src/service/oxfmtrc.rs b/crates/oxc_formatter/src/service/oxfmtrc.rs index 1f0c2b2849429..42d4cd2d51965 100644 --- a/crates/oxc_formatter/src/service/oxfmtrc.rs +++ b/crates/oxc_formatter/src/service/oxfmtrc.rs @@ -1,5 +1,7 @@ use std::path::Path; +#[cfg(feature = "napi")] +use napi_derive::napi; use schemars::{JsonSchema, schema_for}; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; @@ -12,12 +14,19 @@ use crate::{ }; /// Configuration options for the Oxfmt. +/// /// Most options are the same as Prettier's options. /// See also -/// But some options are our own extensions. -// All fields are typed as `Option` to distinguish between user-specified values and defaults. +/// +/// In addition, some options are our own extensions. +// NOTE: All fields are typed as `Option` to distinguish between user-specified values and defaults. +// NOTE: `enum` fields are exported as `string_enum` in TS, and their use is enforced from the API. +// e.g. `{ endOfLine: EndOfLineConfig.Lf }` instead of `{ endOfLine: "lf" }`. +// Although we can expose them as simple string union types using the `ts_type` attribute, +// but when we actually call the API, it results in an error due to validation on the NAPI side... #[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase", default)] +#[cfg_attr(feature = "napi", napi(object))] pub struct Oxfmtrc { /// Use tabs for indentation or spaces. (Default: `false`) #[serde(skip_serializing_if = "Option::is_none")] @@ -66,12 +75,13 @@ pub struct Oxfmtrc { // NOTE: These experimental options are not yet supported. // Just be here to report error if they are used. + // Using Option instead of Option for NAPI compatibility. #[serde(skip_serializing_if = "Option::is_none")] #[schemars(skip)] - pub experimental_operator_position: Option, + pub experimental_operator_position: Option, #[serde(skip_serializing_if = "Option::is_none")] #[schemars(skip)] - pub experimental_ternaries: Option, + pub experimental_ternaries: Option, /// Control whether formats quoted code embedded in the file. (Default: `"auto"`) #[serde(skip_serializing_if = "Option::is_none")] @@ -94,6 +104,7 @@ pub struct Oxfmtrc { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "napi", napi(string_enum))] pub enum EndOfLineConfig { #[default] Lf, @@ -103,6 +114,7 @@ pub enum EndOfLineConfig { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "kebab-case")] +#[cfg_attr(feature = "napi", napi(string_enum))] pub enum QuotePropsConfig { #[default] AsNeeded, @@ -112,6 +124,7 @@ pub enum QuotePropsConfig { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "napi", napi(string_enum))] pub enum TrailingCommaConfig { #[default] All, @@ -121,6 +134,7 @@ pub enum TrailingCommaConfig { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "napi", napi(string_enum))] pub enum ArrowParensConfig { #[default] Always, @@ -129,6 +143,7 @@ pub enum ArrowParensConfig { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "napi", napi(string_enum))] pub enum ObjectWrapConfig { #[default] Preserve, @@ -138,6 +153,7 @@ pub enum ObjectWrapConfig { #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "napi", napi(string_enum))] pub enum EmbeddedLanguageFormattingConfig { Auto, // Disable by default at alpha release, synced with `options.rs` @@ -147,6 +163,7 @@ pub enum EmbeddedLanguageFormattingConfig { #[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "camelCase", default)] +#[cfg_attr(feature = "napi", napi(object))] pub struct SortImportsConfig { #[serde(default)] pub partition_by_newline: bool, @@ -222,6 +239,7 @@ where #[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "napi", napi(string_enum))] pub enum SortOrderConfig { #[default] Asc,