Skip to content
Merged
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
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion apps/oxfmt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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]
Expand Down
43 changes: 42 additions & 1 deletion apps/oxfmt/src-js/bindings.d.ts
Original file line number Diff line number Diff line change
@@ -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<ErrorLabel>
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<string[]>, formatEmbeddedCb: (tagName: string, code: string) => Promise<string>, formatFileCb: (parserName: string, fileName: string, code: string) => Promise<string>): Promise<FormatResult>

export interface FormatResult {
/** The formatted code. */
code: string
/** Parse and format errors. */
errors: Array<OxcError>
}

/**
* 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))
Expand Down
4 changes: 3 additions & 1 deletion apps/oxfmt/src-js/bindings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
115 changes: 113 additions & 2 deletions apps/oxfmt/src-js/index.ts
Original file line number Diff line number Diff line change
@@ -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 <https://prettier.io/docs/options>
*
* 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[])[];
};
109 changes: 104 additions & 5 deletions apps/oxfmt/src/main_napi.rs
Original file line number Diff line number Diff line change
@@ -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))
Expand Down Expand Up @@ -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<OxcError>,
}

/// 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<Value>,
#[napi(ts_arg_type = "(configJSON: string, numThreads: number) => Promise<string[]>")]
setup_config_cb: JsSetupConfigCb,
#[napi(ts_arg_type = "(tagName: string, code: string) => Promise<string>")]
format_embedded_cb: JsFormatEmbeddedCb,
#[napi(
ts_arg_type = "(parserName: string, fileName: string, code: string) => Promise<string>"
)]
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 }
}
}
}
42 changes: 42 additions & 0 deletions apps/oxfmt/test/api.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading