diff --git a/Cargo.lock b/Cargo.lock index 16377958e719e..6ebfcfcb3db76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1451,6 +1451,8 @@ dependencies = [ "napi-sys", "nohash-hasher", "rustc-hash", + "serde", + "serde_json", "tokio", ] @@ -2539,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..595c579b15bf6 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 } @@ -49,7 +50,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = [] } # Omit the `regex` feature # NAPI dependencies (conditional on napi feature) -napi = { workspace = true, features = ["async"], optional = true } +napi = { workspace = true, features = ["async", "serde-json"], optional = true } napi-derive = { workspace = true, optional = true } [build-dependencies] diff --git a/apps/oxfmt/src-js/bindings.d.ts b/apps/oxfmt/src-js/bindings.d.ts index 290d0d776876c..288de297cc625 100644 --- a/apps/oxfmt/src-js/bindings.d.ts +++ b/apps/oxfmt/src-js/bindings.d.ts @@ -1,7 +1,48 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +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' +} /** - * NAPI entry point. + * NAPI based format API entry point. + * + * Since it internally uses `await prettier.format()` in JS side, `formatSync()` cannot be provided. + */ +export declare function format(filename: string, sourceText: string, options: any | 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..7d1d40923b144 100644 --- a/apps/oxfmt/src-js/bindings.js +++ b/apps/oxfmt/src-js/bindings.js @@ -575,5 +575,7 @@ if (!nativeBinding) { throw new Error(`Failed to load native binding`) } -const { runCli } = nativeBinding +const { Severity, format, runCli } = nativeBinding +export { Severity } +export { format } export { runCli } diff --git a/apps/oxfmt/src-js/index.ts b/apps/oxfmt/src-js/index.ts index a8ab01e7da50f..a5ef0236033c6 100644 --- a/apps/oxfmt/src-js/index.ts +++ b/apps/oxfmt/src-js/index.ts @@ -1,2 +1,113 @@ -export * from "./bindings.js"; -export { setupConfig, formatEmbeddedCode, formatFile } from "./prettier-proxy.js"; +import { format as napiFormat } from "./bindings.js"; +import { setupConfig, formatEmbeddedCode, formatFile } from "./prettier-proxy.js"; + +export async function format(fileName: string, sourceText: string, options?: FormatOptions) { + 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 napiFormat( + fileName, + sourceText, + options ?? {}, + setupConfig, + formatEmbeddedCode, + formatFile, + ); +} + +// NOTE: Regarding the handwritten TypeScript types. +// +// Initially, I tried to use the `Oxfmtrc` struct to automatically generate types with `napi(object)`, +// but since `Oxfmtrc` has many fields defined as `enum`, the API usage would look like this: +// ```ts +// oxfmt.format("file.ts", "const a=1;", { +// endOfLine: oxfmt.EndOfLine.Lf, +// // ... +// }); +// ``` +// Since it cannot be specified with string literals, the API usability is not good. +// +// Also, since `Oxfmtrc` is primarily a configuration file, +// it includes fields like `ignorePatterns` that are unnecessary for the API. +// +// Therefore, I decided that if I were to create a dedicated struct for `napi(object)`, +// it would be better to just handwrite the TypeScript types. +// +// There is a mechanism to generate JSON Schema, so it might be possible to generate type definitions from that in the future. + +/** + * 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 type FormatOptions = { + /** 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?: "lf" | "crlf" | "cr"; + /** 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?: "as-needed" | "consistent" | "preserve"; + /** Print trailing commas wherever possible. (Default: `"all"`) */ + trailingComma?: "all" | "es5" | "none"; + /** Print semicolons at the ends of statements. (Default: `true`) */ + semi?: boolean; + /** Include parentheses around a sole arrow function parameter. (Default: `"always"`) */ + arrowParens?: "always" | "avoid"; + /** 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?: "preserve" | "collapse" | "always"; + /** Put each attribute on a new line in JSX. (Default: `false`) */ + singleAttributePerLine?: boolean; + /** Control whether formats quoted code embedded in the file. (Default: `"auto"`) */ + embeddedLanguageFormatting?: "auto" | "off"; + /** Experimental: Sort import statements. Disabled by default. */ + experimentalSortImports?: SortImportsOptions; + /** Experimental: Sort `package.json` keys. (Default: `true`) */ + experimentalSortPackageJson?: boolean; +}; + +/** + * Configuration options for sort imports. + */ +export type SortImportsOptions = { + /** Partition imports by newlines. (Default: `false`) */ + partitionByNewline?: boolean; + /** Partition imports by comments. (Default: `false`) */ + partitionByComment?: boolean; + /** Sort side-effect imports. (Default: `false`) */ + sortSideEffects?: boolean; + /** Sort order. (Default: `"asc"`) */ + order?: "asc" | "desc"; + /** Ignore case when sorting. (Default: `true`) */ + ignoreCase?: boolean; + /** Add newlines between import groups. (Default: `true`) */ + newlinesBetween?: boolean; + /** Glob patterns to identify internal imports. */ + internalPattern?: string[]; + /** + * 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?: (string | string[])[]; +}; diff --git a/apps/oxfmt/src/main_napi.rs b/apps/oxfmt/src/main_napi.rs index d467e6d14c66e..5555ae4a0f2db 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::Oxfmtrc; +use oxc_napi::OxcError; +use serde_json::{Value, from_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,96 @@ pub async fn run_cli( } } } + +// --- + +#[napi(object)] +pub struct FormatResult { + /// The formatted code. + pub code: String, + /// Parse and format errors. + pub errors: Vec, +} + +/// NAPI based format API entry point. +/// +/// Since it internally uses `await prettier.format()` in JS side, `formatSync()` cannot be provided. +#[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 num_of_threads = 1; + + 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}"))], + }; + }; + + // `core::config::load_config()` equivalent + // Deserialize options from JSON Value to Oxfmtrc + let oxfmtrc: Oxfmtrc = match options { + Some(value) => match from_value(value) { + Ok(config) => config, + Err(err) => { + return FormatResult { + code: source_text, + errors: vec![OxcError::new(format!("Invalid options: {err}"))], + }; + } + }, + None => Oxfmtrc::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}"))], + }; + } + }; + let mut external_config = Value::Object(serde_json::Map::new()); + Oxfmtrc::populate_prettier_config(&format_options, &mut external_config); + + // TODO: Plugins support + // Use `block_in_place()` to avoid nested async runtime access + if let Err(err) = tokio::task::block_in_place(|| { + 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 formatter and format + let formatter = SourceFormatter::new(num_of_threads, format_options) + .with_external_formatter(Some(external_formatter), oxfmt_options.sort_package_json); + + // Use `block_in_place()` to avoid nested async runtime access + 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..8468c6a8ed4e7 --- /dev/null +++ b/apps/oxfmt/test/api.test.ts @@ -0,0 +1,42 @@ +import { format } from "../dist/index.js"; +import { describe, expect, test } from "vitest"; +import type { FormatOptions } 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 and options work", async () => { + const options: FormatOptions = { + quoteProps: "as-needed", // Can be string literal + printWidth: 120, + semi: false, + experimentalSortPackageJson: false, + experimentalSortImports: { + // Can be optional object + 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([]); + + const { errors } = await format("a.ts", "const x={'y':1}", { + // @ts-expect-error: Test invalid options is validated + semi: "invalid", + }); + expect(errors.length).toBe(1); + }); +});