From 907fea9d228ca921566082fb580830d416eb27dd Mon Sep 17 00:00:00 2001 From: Boshen Date: Wed, 10 Dec 2025 21:18:32 +0800 Subject: [PATCH] feat(oxfmt): node API `format` and `formatSync` [ci skip] closes #15913 --- Cargo.lock | 4 + apps/oxfmt/Cargo.toml | 3 +- apps/oxfmt/src-js/bindings.d.ts | 207 +++++++++++++++++- apps/oxfmt/src-js/bindings.js | 17 +- apps/oxfmt/src-js/cli.ts | 4 +- apps/oxfmt/src/main_napi.rs | 98 ++++++++- crates/oxc_formatter/Cargo.toml | 4 + crates/oxc_formatter/build.rs | 2 + .../src/ir_transform/sort_imports/options.rs | 5 + crates/oxc_formatter/src/options.rs | 19 ++ napi/playground/playground.wasi-browser.js | 20 +- 11 files changed, 373 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 764c416290f51..acd6d13df5e94 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 fb31168f0f06f..83c0bf6fbbcd3 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, optional = true } oxc_parser = { workspace = true } oxc_span = { workspace = true } @@ -66,6 +67,6 @@ 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", "dep:oxc_napi", "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 52eee40f94132..86f75e1c777c2 100644 --- a/apps/oxfmt/src-js/bindings.d.ts +++ b/apps/oxfmt/src-js/bindings.d.ts @@ -1,5 +1,208 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +export declare const enum ArrowParentheses { + Always = 0, + AsNeeded = 1 +} + +export declare const enum AttributePosition { + Auto = 0, + Multiline = 1 +} + +/** Put the `>` of a multi-line HTML or JSX element at the end of the last line instead of being alone on the next line (does not apply to self closing elements). */ +export type BracketSameLine = + boolean + +export type BracketSpacing = + boolean + +export declare const enum EmbeddedLanguageFormatting { + /** Enable formatting for embedded languages. */ + Auto = 0, + /** Disable formatting for embedded languages. */ + Off = 1 +} + +export declare const enum Expand { + /** + * Objects are expanded when the first property has a leading newline. Arrays are always + * expanded if they are shorter than the line width. + */ + Auto = 0, + /** Objects and arrays are always expanded. */ + Always = 1, + /** Objects and arrays are never expanded, if they are shorter than the line width. */ + Never = 2 +} + +export interface FormatOptions { + /** The indent style. */ + indentStyle: IndentStyle + /** The indent width. */ + indentWidth: IndentWidth + /** The type of line ending. */ + lineEnding: LineEnding + /** What's the max width of a line. Defaults to 100. */ + lineWidth: LineWidth + /** The style for quotes. Defaults to double. */ + quoteStyle: QuoteStyle + /** The style for JSX quotes. Defaults to double. */ + jsxQuoteStyle: QuoteStyle + /** When properties in objects are quoted. Defaults to as-needed. */ + quoteProperties: QuoteProperties + /** Print trailing commas wherever possible in multi-line comma-separated syntactic structures. Defaults to "all". */ + trailingCommas: TrailingCommas + /** Whether the formatter prints semicolons for all statements, class members, and type members or only when necessary because of [ASI](https://tc39.es/ecma262/multipage/ecmascript-language-lexical-grammar.html#sec-automatic-semicolon-insertion). */ + semicolons: Semicolons + /** Whether to add non-necessary parentheses to arrow functions. Defaults to "always". */ + arrowParentheses: ArrowParentheses + /** Whether to insert spaces around brackets in object literals. Defaults to true. */ + bracketSpacing: BracketSpacing + /** Whether to hug the closing bracket of multiline HTML/JSX tags to the end of the last line, rather than being alone on the following line. Defaults to false. */ + bracketSameLine: BracketSameLine + /** Attribute position style. By default auto. */ + attributePosition: AttributePosition + /** Whether to expand object and array literals to multiple lines. Defaults to "auto". */ + expand: Expand + /** + * Controls the position of operators in binary expressions. [**NOT SUPPORTED YET**] + * + * Accepted values are: + * - `"start"`: Places the operator at the beginning of the next line. + * - `"end"`: Places the operator at the end of the current line (default). + */ + experimentalOperatorPosition: OperatorPosition + /** + * Try prettier's new ternary formatting before it becomes the default behavior. [**NOT SUPPORTED YET**] + * + * Valid options: + * - `true` - Use curious ternaries, with the question mark after the condition. + * - `false` - Retain the default behavior of ternaries; keep question marks on the same line as the consequent. + */ + experimentalTernaries: boolean + /** Enable formatting for embedded languages (e.g., CSS, SQL, GraphQL) within template literals. Defaults to "auto". */ + embeddedLanguageFormatting: EmbeddedLanguageFormatting + /** Sort import statements. By default disabled. */ + experimentalSortImports?: SortImportsOptions +} + +export declare const enum IndentStyle { + /** Tab */ + Tab = 0, + /** Space */ + Space = 1 +} + +export type IndentWidth = + number + +export declare const enum LineEnding { + /** Line Feed only ( + ), common on Linux and macOS as well as inside git repos */ + Lf = 0, + /** Carriage Return + Line Feed characters (\r + ), common on Windows */ + Crlf = 1, + /** Carriage Return character only (\r), used very rarely */ + Cr = 2 +} + +/** + * Validated value for the `line_width` formatter options + * + * The allowed range of values is 1..=320 + */ +export type LineWidth = + number + +export declare const enum OperatorPosition { + /** When binary expressions wrap lines, print operators at the start of new lines. */ + Start = 0, + End = 1 +} + +export declare const enum QuoteProperties { + /** Only add quotes around object properties where required. */ + AsNeeded = 0, + /** Respect the input use of quotes in object properties. */ + Preserve = 1, + /** If at least one property in an object requires quotes, quote all properties. [**NOT SUPPORTED YET**] */ + Consistent = 2 +} + +export declare const enum QuoteStyle { + Double = 0, + Single = 1 +} + +export declare const enum Semicolons { + Always = 0, + AsNeeded = 1 +} + +export interface SortImportsOptions { + /** + * Partition imports by newlines. + * Default is `false`. + */ + partitionByNewline: boolean + /** + * Partition imports by comments. + * Default is `false`. + */ + partitionByComment: boolean + /** + * Sort side effects imports. + * Default is `false`. + */ + sortSideEffects: boolean + /** + * Sort order (asc or desc). + * Default is ascending (asc). + */ + order: SortOrder + /** + * Ignore case when sorting. + * Default is `true`. + */ + ignoreCase: boolean + /** + * Whether to insert blank lines between different import groups. + * - `true`: Insert one blank line between groups (default) + * - `false`: No blank lines between groups + * + * NOTE: Cannot be used together with `partition_by_newline: true`. + */ + newlinesBetween: boolean + /** + * Prefixes for internal imports. + * Defaults to `["~/", "@/"]`. + */ + internalPattern: Array + /** + * Groups configuration for organizing imports. + * Each inner `Vec` represents a group, and multiple group names in the same `Vec` are treated as one. + */ + groups: Array> +} + +export declare const enum SortOrder { + /** Sort in ascending order (A-Z). */ + Asc = 0, + /** Sort in descending order (Z-A). */ + Desc = 1 +} + +/** Print trailing commas wherever possible in multi-line comma-separated syntactic structures. */ +export declare const enum TrailingCommas { + /** Trailing commas wherever possible (including function parameters and calls). */ + All = 0, + /** Trailing commas where valid in ES5 (objects, arrays, etc.). No trailing commas in type parameters in TypeScript. */ + Es5 = 1, + /** No trailing commas. */ + None = 2 +} /** * NAPI entry point. * @@ -11,4 +214,6 @@ * * Returns `true` if formatting succeeded without errors, `false` otherwise. */ -export declare function format(args: Array, setupConfigCb: (configJSON: string, numThreads: number) => Promise, formatEmbeddedCb: (tagName: string, code: string) => Promise, formatFileCb: (parserName: string, fileName: string, code: string) => Promise): Promise +export declare function formatInternal(args: Array, setupConfigCb: (configJSON: string, numThreads: number) => Promise, formatEmbeddedCb: (tagName: string, code: string) => Promise, formatFileCb: (parserName: string, fileName: string, code: string) => Promise): Promise + +export declare function formatSync(source: string, options: FormatOptions): string diff --git a/apps/oxfmt/src-js/bindings.js b/apps/oxfmt/src-js/bindings.js index 19d9210aa778c..43bef4f0628f4 100644 --- a/apps/oxfmt/src-js/bindings.js +++ b/apps/oxfmt/src-js/bindings.js @@ -575,5 +575,18 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { format } = nativeBinding -export { format } +const { ArrowParentheses, AttributePosition, EmbeddedLanguageFormatting, Expand, IndentStyle, LineEnding, OperatorPosition, QuoteProperties, QuoteStyle, Semicolons, SortOrder, TrailingCommas, formatInternal, formatSync } = nativeBinding +export { ArrowParentheses } +export { AttributePosition } +export { EmbeddedLanguageFormatting } +export { Expand } +export { IndentStyle } +export { LineEnding } +export { OperatorPosition } +export { QuoteProperties } +export { QuoteStyle } +export { Semicolons } +export { SortOrder } +export { TrailingCommas } +export { formatInternal } +export { formatSync } diff --git a/apps/oxfmt/src-js/cli.ts b/apps/oxfmt/src-js/cli.ts index 61a806c0d3890..7030bd9b8726d 100644 --- a/apps/oxfmt/src-js/cli.ts +++ b/apps/oxfmt/src-js/cli.ts @@ -1,10 +1,10 @@ -import { format } from "./bindings.js"; +import { formatInternal } from "./bindings.js"; import { setupConfig, formatEmbeddedCode, formatFile } from "./prettier-proxy.js"; const args = process.argv.slice(2); // Call the Rust formatter with our JS callback -const success = await format(args, setupConfig, formatEmbeddedCode, formatFile); +const success = await formatInternal(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. diff --git a/apps/oxfmt/src/main_napi.rs b/apps/oxfmt/src/main_napi.rs index c3a0997334d3b..3335f6b4ef39d 100644 --- a/apps/oxfmt/src/main_napi.rs +++ b/apps/oxfmt/src/main_napi.rs @@ -4,7 +4,13 @@ use std::{ process::{ExitCode, Termination}, }; +use napi::{Task, bindgen_prelude::AsyncTask}; use napi_derive::napi; +use oxc_allocator::Allocator; +use oxc_formatter::{FormatOptions, Formatter}; +use oxc_napi::OxcError; +use oxc_parser::Parser; +use oxc_span::SourceType; use crate::{ cli::{CliRunResult, FormatRunner, format_command, init_miette, init_tracing}, @@ -27,7 +33,7 @@ use crate::{ #[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( +pub async fn format_internal( args: Vec, #[napi(ts_arg_type = "(configJSON: string, numThreads: number) => Promise")] setup_config_cb: JsSetupConfigCb, @@ -38,12 +44,12 @@ pub async fn format( )] format_file_cb: JsFormatFileCb, ) -> bool { - format_impl(args, setup_config_cb, format_embedded_cb, format_file_cb).await.report() + format_internal_impl(args, setup_config_cb, format_embedded_cb, format_file_cb).await.report() == ExitCode::SUCCESS } /// Run the formatter. -async fn format_impl( +async fn format_internal_impl( args: Vec, setup_config_cb: JsSetupConfigCb, format_embedded_cb: JsFormatEmbeddedCb, @@ -89,3 +95,89 @@ async fn format_impl( .with_external_formatter(Some(external_formatter)) .run(&mut stdout, &mut stderr) } + +/// Result of formatting operation. +#[derive(Default)] +#[napi(object)] +pub struct FormatResult { + /// The formatted code. + pub code: String, + /// Parse and format errors. + pub errors: Vec, +} + +fn format_impl(filename: &str, source_text: &str, options: Option) -> FormatResult { + let options = options.unwrap_or_default(); + let allocator = Allocator::default(); + + // Infer source type from filename + // TODO: don't do anything if file type not recognized + let source_type = SourceType::from_path(filename).unwrap_or_default(); + + // Parse the source code + let ret = Parser::new(&allocator, source_text, source_type).parse(); + + // If there are parse errors, return them + if !ret.errors.is_empty() { + return FormatResult { + code: source_text.to_string(), + errors: OxcError::from_diagnostics(filename, source_text, ret.errors), + }; + } + + // Format the parsed program + // TODO: hook up all the napi callbacks + let formatter = Formatter::new(&allocator, options); + let formatted = formatter.format(&ret.program); + + // Print the formatted output + match formatted.print() { + Ok(printer) => FormatResult { code: printer.into_code(), errors: vec![] }, + Err(_err) => { + // Return original source if formatting fails + FormatResult { code: source_text.to_string(), errors: vec![] } + } + } +} + +/// Format synchronously. +#[napi] +pub fn format_sync( + filename: String, + source_text: String, + options: Option, +) -> FormatResult { + format_impl(&filename, &source_text, options) +} + +pub struct FormatTask { + filename: String, + source_text: String, + options: Option, +} + +#[napi] +impl Task for FormatTask { + type JsValue = FormatResult; + type Output = FormatResult; + + fn compute(&mut self) -> napi::Result { + Ok(format_impl(&self.filename, &self.source_text, self.options.take())) + } + + fn resolve(&mut self, _: napi::Env, result: Self::Output) -> napi::Result { + Ok(result) + } +} + +/// Format asynchronously. +/// +/// Note: This function can be slower than `formatSync` due to the overhead of spawning a thread. +#[napi] +pub fn format( + filename: String, + source_text: String, + options: Option, +) -> AsyncTask { + AsyncTask::new(FormatTask { filename, source_text, options }) +} diff --git a/crates/oxc_formatter/Cargo.toml b/crates/oxc_formatter/Cargo.toml index 153a1ec29ab71..913490d941542 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..4a888395ccfc2 100644 --- a/crates/oxc_formatter/build.rs +++ b/crates/oxc_formatter/build.rs @@ -9,6 +9,8 @@ use std::{ use oxc_span::SourceType; fn main() { + 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/ir_transform/sort_imports/options.rs b/crates/oxc_formatter/src/ir_transform/sort_imports/options.rs index e293e7b66da7a..706df377b853c 100644 --- a/crates/oxc_formatter/src/ir_transform/sort_imports/options.rs +++ b/crates/oxc_formatter/src/ir_transform/sort_imports/options.rs @@ -1,7 +1,11 @@ use std::fmt; use std::str::FromStr; +#[cfg(feature = "napi")] +use napi_derive::napi; + #[derive(Clone, Debug, Eq, PartialEq)] +#[cfg_attr(feature = "napi", napi(object))] pub struct SortImportsOptions { /// Partition imports by newlines. /// Default is `false`. @@ -50,6 +54,7 @@ impl Default for SortImportsOptions { // --- #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum SortOrder { /// Sort in ascending order (A-Z). #[default] diff --git a/crates/oxc_formatter/src/options.rs b/crates/oxc_formatter/src/options.rs index 6b1fca42aa142..5e168363ba0d4 100644 --- a/crates/oxc_formatter/src/options.rs +++ b/crates/oxc_formatter/src/options.rs @@ -11,6 +11,10 @@ use crate::{ write, }; +#[cfg(feature = "napi")] +use napi_derive::napi; + +#[cfg_attr(feature = "napi", napi(object))] #[derive(Debug, Default, Clone)] pub struct FormatOptions { /// The indent style. @@ -128,6 +132,7 @@ impl fmt::Display for FormatOptions { } #[derive(Debug, Default, Clone, Copy, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum IndentStyle { /// Tab Tab, @@ -174,6 +179,7 @@ impl fmt::Display for IndentStyle { } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Default)] +#[cfg_attr(feature = "napi", napi)] pub enum LineEnding { /// Line Feed only (\n), common on Linux and macOS as well as inside git repos #[default] @@ -235,6 +241,7 @@ impl fmt::Display for LineEnding { } #[derive(Clone, Copy, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi(transparent))] pub struct IndentWidth(u8); impl IndentWidth { @@ -292,6 +299,7 @@ impl fmt::Debug for IndentWidth { /// /// The allowed range of values is 1..=320 #[derive(Clone, Copy, Eq, PartialEq)] +#[cfg_attr(feature = "napi", napi(transparent))] pub struct LineWidth(u16); impl LineWidth { @@ -419,6 +427,7 @@ impl From for u16 { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum QuoteStyle { #[default] Double, @@ -522,6 +531,7 @@ impl From for u8 { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum QuoteProperties { /// Only add quotes around object properties where required. #[default] @@ -563,6 +573,7 @@ impl fmt::Display for QuoteProperties { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum Semicolons { #[default] Always, @@ -604,6 +615,7 @@ impl fmt::Display for Semicolons { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum ArrowParentheses { #[default] Always, @@ -701,6 +713,7 @@ impl Format<'_> for FormatTrailingCommas { /// Print trailing commas wherever possible in multi-line comma-separated syntactic structures. #[derive(Clone, Copy, Default, Debug, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum TrailingCommas { /// Trailing commas wherever possible (including function parameters and calls). #[default] @@ -751,6 +764,7 @@ impl fmt::Display for TrailingCommas { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum AttributePosition { #[default] Auto, @@ -782,6 +796,7 @@ impl FromStr for AttributePosition { } #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi(transparent))] pub struct BracketSpacing(bool); impl BracketSpacing { @@ -826,6 +841,7 @@ impl FromStr for BracketSpacing { /// Put the `>` of a multi-line HTML or JSX element at the end of the last line instead of being alone on the next line (does not apply to self closing elements). #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi(transparent))] pub struct BracketSameLine(bool); impl BracketSameLine { @@ -861,6 +877,7 @@ impl FromStr for BracketSameLine { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum Expand { /// Objects are expanded when the first property has a leading newline. Arrays are always /// expanded if they are shorter than the line width. @@ -897,6 +914,7 @@ impl fmt::Display for Expand { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum OperatorPosition { /// When binary expressions wrap lines, print operators at the start of new lines. Start, @@ -938,6 +956,7 @@ impl fmt::Display for OperatorPosition { } #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] +#[cfg_attr(feature = "napi", napi)] pub enum EmbeddedLanguageFormatting { /// Enable formatting for embedded languages. Auto, diff --git a/napi/playground/playground.wasi-browser.js b/napi/playground/playground.wasi-browser.js index 53267972ba447..2a649cfcb1054 100644 --- a/napi/playground/playground.wasi-browser.js +++ b/napi/playground/playground.wasi-browser.js @@ -57,4 +57,22 @@ const { }) export default __napiModule.exports export const Severity = __napiModule.exports.Severity -export const Oxc = __napiModule.exports.Oxc + +import { jsonParseAst } from "../parser/src-js/wrap.js" + +export function Oxc() { + const oxc = new __napiModule.exports.Oxc(); + return new Proxy(oxc, { + get(_target, p, _receiver) { + if (p === 'ast') { + return jsonParseAst(oxc.astJson); + } + const value = oxc[p]; + if (typeof value === 'function') { + return value.bind(oxc); + } + return value; + } + }) +} +