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
7 changes: 4 additions & 3 deletions apps/oxfmt/src-js/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
*
* JS side passes in:
* 1. `args`: Command line arguments (process.argv.slice(2))
* 2. `format_embedded_cb`: Callback to format embedded code in templates
* 3. `format_file_cb`: Callback to format files
* 2. `setup_config_cb`: Callback to setup Prettier config
* 3. `format_embedded_cb`: Callback to format embedded code in templates
* 4. `format_file_cb`: Callback to format files
*
* Returns `true` if formatting succeeded without errors, `false` otherwise.
*/
export declare function format(args: Array<string>, formatEmbeddedCb: (tagName: string, code: string) => Promise<string>, formatFileCb: (parserName: string, code: string) => Promise<string>): Promise<boolean>
export declare function format(args: Array<string>, setupConfigCb: (configJSON: string) => Promise<string[]>, formatEmbeddedCb: (tagName: string, code: string) => Promise<string>, formatFileCb: (parserName: string, code: string) => Promise<string>): Promise<boolean>
4 changes: 2 additions & 2 deletions apps/oxfmt/src-js/cli.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { format } from "./bindings.js";
import { formatEmbeddedCode, formatFile } from "./prettier-proxy.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, formatEmbeddedCode, formatFile);
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.
Expand Down
2 changes: 1 addition & 1 deletion apps/oxfmt/src-js/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
export * from "./bindings.js";
export { formatEmbeddedCode, formatFile } from "./prettier-proxy.js";
export { setupConfig, formatEmbeddedCode, formatFile } from "./prettier-proxy.js";
43 changes: 37 additions & 6 deletions apps/oxfmt/src-js/prettier-proxy.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Options } from "prettier";

// Import Prettier lazily.
// This helps to reduce initial load time if not needed.
//
Expand All @@ -11,6 +13,37 @@
// But actually, this makes `oxfmt --lsp` immediately stop with `Parse error` JSON-RPC error
let prettierCache: typeof import("prettier");

// Cache for Prettier options.
// Set by `setupConfig` function once.
//
// Read `.oxfmtrc.json(c)` directly does not work,
// because our brand new defaults are not compatible with Prettier's defaults.
// So we need to pass the config from Rust side after merging with our defaults.
let configCache: Options = {};

// ---

/**
* Setup Prettier configuration.
* NOTE: Called from Rust via NAPI ThreadsafeFunction with FnArgs
* @param configJSON - Prettier configuration as JSON string
* @returns Array of loaded plugin's `languages` info
* */
export async function setupConfig(configJSON: string): Promise<string[]> {
// NOTE: `napi-rs` has ability to pass `Object` directly.
// But since we don't know what options various plugins may specify,
// we have to receive it as a JSON string and parse it.
//
// SAFETY: This is valid JSON string generated in Rust side
configCache = JSON.parse(configJSON) as Options;

// TODO: Plugins support
// - Read `plugins` field
// - Load plugins dynamically and parse `languages` field
// - Map file extensions and filenames to Prettier parsers
return [];
}

// ---

// Map template tag names to Prettier parsers
Expand Down Expand Up @@ -52,17 +85,15 @@ export async function formatEmbeddedCode(tagName: string, code: string): Promise

return prettierCache
.format(code, {
...configCache,
parser,
// TODO: Read config
printWidth: 80,
tabWidth: 2,
semi: true,
singleQuote: false,
})
.then((formatted) => formatted.trimEnd())
.catch(() => code);
}

// ---

/**
* Format whole file content using Prettier.
* NOTE: Called from Rust via NAPI ThreadsafeFunction with FnArgs
Expand All @@ -76,7 +107,7 @@ export async function formatFile(parserName: string, code: string): Promise<stri
}

return prettierCache.format(code, {
...configCache,
parser: parserName,
// TODO: Read config
});
}
21 changes: 21 additions & 0 deletions apps/oxfmt/src/cli/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ impl FormatRunner {
self
}

/// # Panics
/// Panics if `napi` feature is enabled but external_formatter is not set.
pub fn run(self, stdout: &mut dyn Write, stderr: &mut dyn Write) -> CliRunResult {
let start_time = Instant::now();

Expand Down Expand Up @@ -78,6 +80,25 @@ impl FormatRunner {
}
};

// TODO: Plugins support
// - Parse returned `languages`
// - Allow its `extensions` and `filenames` in `walk.rs`
// - Pass `parser` to `SourceFormatter`
#[cfg(feature = "napi")]
if let Err(err) = self
.external_formatter
.as_ref()
.expect("External formatter must be set when `napi` feature is enabled")
// TODO: Construct actual config
.setup_config("{}")
{
print_and_flush(
stderr,
&format!("Failed to setup external formatter config.\n{err}\n"),
);
return CliRunResult::InvalidOptionConfig;
}

let walker = match Walk::build(
&cwd,
&paths,
Expand Down
61 changes: 59 additions & 2 deletions apps/oxfmt/src/core/external_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ use napi::{
threadsafe_function::ThreadsafeFunction,
};
use oxc_formatter::EmbeddedFormatterCallback;
use tokio::task::block_in_place;

/// Type alias for the setup config callback function signature.
/// Takes config_json as argument and returns plugin languages.
pub type JsSetupConfigCb = ThreadsafeFunction<
// Input arguments
FnArgs<(String,)>, // (config_json,)
// Return type (what JS function returns)
Promise<Vec<String>>,
// Arguments (repeated)
FnArgs<(String,)>,
// Error status
Status,
// CalleeHandled
false,
>;

/// Type alias for the callback function signature.
/// Takes (tag_name, code) as separate arguments and returns formatted code.
Expand Down Expand Up @@ -41,16 +57,22 @@ pub type JsFormatFileCb = ThreadsafeFunction<
/// Takes (parser_name, code) and returns formatted code or an error.
type FileFormatterCallback = Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;

/// Callback function type for setup config.
/// Takes config_json and returns plugin languages.
type SetupConfigCallback = Arc<dyn Fn(&str) -> Result<Vec<String>, String> + Send + Sync>;

/// External formatter that wraps a JS callback.
#[derive(Clone)]
pub struct ExternalFormatter {
pub setup_config: SetupConfigCallback,
pub format_embedded: EmbeddedFormatterCallback,
pub format_file: FileFormatterCallback,
}

impl std::fmt::Debug for ExternalFormatter {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExternalFormatter")
.field("setup_config", &"<callback>")
.field("format_embedded", &"<callback>")
.field("format_file", &"<callback>")
.finish()
Expand All @@ -59,10 +81,24 @@ impl std::fmt::Debug for ExternalFormatter {

impl ExternalFormatter {
/// Create an [`ExternalFormatter`] from JS callbacks.
pub fn new(format_embedded_cb: JsFormatEmbeddedCb, format_file_cb: JsFormatFileCb) -> Self {
pub fn new(
setup_config_cb: JsSetupConfigCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_file_cb: JsFormatFileCb,
) -> Self {
let rust_setup_config = wrap_setup_config(setup_config_cb);
let rust_format_embedded = wrap_format_embedded(format_embedded_cb);
let rust_format_file = wrap_format_file(format_file_cb);
Self { format_embedded: rust_format_embedded, format_file: rust_format_file }
Self {
setup_config: rust_setup_config,
format_embedded: rust_format_embedded,
format_file: rust_format_file,
}
}

/// Setup Prettier config using the JS callback.
pub fn setup_config(&self, config_json: &str) -> Result<Vec<String>, String> {
(self.setup_config)(config_json)
}

/// Convert this external formatter to the oxc_formatter::EmbeddedFormatter type
Expand All @@ -78,6 +114,27 @@ impl ExternalFormatter {
}
}

// ---

/// Wrap JS `setupConfig` callback as a normal Rust function.
// NOTE: Use `block_in_place()` because this is called from a sync context, unlike the others
fn wrap_setup_config(cb: JsSetupConfigCb) -> SetupConfigCallback {
Arc::new(move |config_json: &str| {
block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
let status = cb.call_async(FnArgs::from((config_json.to_string(),))).await;
match status {
Ok(promise) => match promise.await {
Ok(languages) => Ok(languages),
Err(err) => Err(format!("JS setupConfig promise rejected: {err}")),
},
Err(err) => Err(format!("Failed to call JS setupConfig callback: {err}")),
}
})
})
})
}

/// Wrap JS `formatEmbeddedCode` callback as a normal Rust function.
fn wrap_format_embedded(cb: JsFormatEmbeddedCb) -> EmbeddedFormatterCallback {
Arc::new(move |tag_name: &str, code: &str| {
Expand Down
4 changes: 3 additions & 1 deletion apps/oxfmt/src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ pub use format::{FormatResult, SourceFormatter};
pub use support::FormatFileSource;

#[cfg(feature = "napi")]
pub use external_formatter::{ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb};
pub use external_formatter::{
ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb, JsSetupConfigCb,
};
16 changes: 11 additions & 5 deletions apps/oxfmt/src/main_napi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use napi_derive::napi;

use crate::{
cli::{CliRunResult, FormatRunner, format_command, init_miette, init_tracing},
core::{ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb},
core::{ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb, JsSetupConfigCb},
lsp::run_lsp,
};

Expand All @@ -19,26 +19,31 @@ use crate::{
///
/// JS side passes in:
/// 1. `args`: Command line arguments (process.argv.slice(2))
/// 2. `format_embedded_cb`: Callback to format embedded code in templates
/// 3. `format_file_cb`: Callback to format files
/// 2. `setup_config_cb`: Callback to setup Prettier config
/// 3. `format_embedded_cb`: Callback to format embedded code in templates
/// 4. `format_file_cb`: Callback to format files
///
/// Returns `true` if formatting succeeded without errors, `false` otherwise.
#[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(
args: Vec<String>,
#[napi(ts_arg_type = "(configJSON: string) => 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, code: string) => Promise<string>")]
format_file_cb: JsFormatFileCb,
) -> bool {
format_impl(args, format_embedded_cb, format_file_cb).await.report() == ExitCode::SUCCESS
format_impl(args, setup_config_cb, format_embedded_cb, format_file_cb).await.report()
== ExitCode::SUCCESS
}

/// Run the formatter.
async fn format_impl(
args: Vec<String>,
setup_config_cb: JsSetupConfigCb,
format_embedded_cb: JsFormatEmbeddedCb,
format_file_cb: JsFormatFileCb,
) -> CliRunResult {
Expand Down Expand Up @@ -71,7 +76,8 @@ async fn format_impl(
command.handle_threads();

// Create external formatter from JS callback
let external_formatter = ExternalFormatter::new(format_embedded_cb, format_file_cb);
let external_formatter =
ExternalFormatter::new(setup_config_cb, format_embedded_cb, format_file_cb);

// stdio is blocked by LineWriter, use a BufWriter to reduce syscalls.
// See `https://github.com/rust-lang/rust/issues/60673`.
Expand Down
Loading