Skip to content
Closed
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: 2 additions & 1 deletion apps/oxfmt/src-js/bindings.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
* 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
*
* Returns `true` if formatting succeeded without errors, `false` otherwise.
*/
export declare function format(args: Array<string>, formatEmbeddedCb: (tagName: string, code: string) => Promise<string>): Promise<boolean>
export declare function format(args: Array<string>, formatEmbeddedCb: (tagName: string, code: string) => Promise<string>, formatFileCb: (fileName: 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 } from "./embedded.js";
import { 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);
const success = await format(args, 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 } from "./embedded.js";
export { formatEmbeddedCode, formatFile } from "./prettier-proxy.js";
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Import Prettier lazily.
// This helps to reduce initial load time if embedded formatting is not needed.
// This helps to reduce initial load time if not needed.
//
// Also, this solves unknown issue described below...
//
Expand All @@ -11,6 +11,8 @@
// But actually, this makes `oxfmt --lsp` immediately stop with `Parse error` JSON-RPC error
let prettierCache: typeof import("prettier");

// ---

// Map template tag names to Prettier parsers
const TAG_TO_PARSER: Record<string, string> = {
// CSS
Expand All @@ -30,8 +32,8 @@ const TAG_TO_PARSER: Record<string, string> = {
};

/**
* Format embedded code using Prettier (synchronous).
* Note: Called from Rust via NAPI ThreadsafeFunction with FnArgs
* Format embedded code using Prettier.
* NOTE: Called from Rust via NAPI ThreadsafeFunction with FnArgs
* @param tagName - The template tag name (e.g., "css", "gql", "html")
* @param code - The code to format
* @returns Formatted code
Expand All @@ -51,6 +53,7 @@ export async function formatEmbeddedCode(tagName: string, code: string): Promise
return prettierCache
.format(code, {
parser,
// TODO: Read config
printWidth: 80,
tabWidth: 2,
semi: true,
Expand All @@ -59,3 +62,24 @@ export async function formatEmbeddedCode(tagName: string, code: string): Promise
.then((formatted) => formatted.trimEnd())
.catch(() => code);
}

/**
* Format whole file content using Prettier.
* NOTE: Called from Rust via NAPI ThreadsafeFunction with FnArgs
* @param fileName - The file name (used to infer parser)
* @param code - The code to format
* @returns Formatted code
*/
export async function formatFile(fileName: string, code: string): Promise<string> {
if (!prettierCache) {
prettierCache = await import("prettier");
}

// TODO: Tweak parser for `tsconfig.json` with `jsonc` parser?

return prettierCache.format(code, {
// Let Prettier infer the parser
filepath: fileName,
// TODO: Read config
});
}
43 changes: 29 additions & 14 deletions apps/oxfmt/src/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
command::{FormatCommand, OutputOptions},
reporter::DefaultReporter,
result::CliRunResult,
service::FormatService,
service::{FormatService, SuccessResult},
walk::Walk,
};

Expand Down Expand Up @@ -69,7 +69,11 @@ impl FormatRunner {
}
};

// Extract options before move
let ignore_patterns = config.ignore_patterns.clone().unwrap_or_default();
#[cfg(feature = "napi")]
let handle_external_files = config.experimental_external_formatter.is_some_and(|b| b);

let format_options = match config.into_format_options() {
Ok(options) => options,
Err(err) => {
Expand Down Expand Up @@ -97,10 +101,8 @@ impl FormatRunner {

// Get the receiver for streaming entries
let rx_entry = walker.stream_entries();
// Count all files for stats
let (tx_count, rx_count) = mpsc::channel::<()>();
// Collect file paths that were updated
let (tx_path, rx_path) = mpsc::channel();
// Collect format results (changed paths or unchanged count)
let (tx_success, rx_success) = mpsc::channel();
// Diagnostic from formatting service
let (mut diagnostic_service, tx_error) =
DiagnosticService::new(Box::new(DefaultReporter::default()));
Expand All @@ -123,13 +125,23 @@ impl FormatRunner {
let format_service =
FormatService::new(allocator_pool, cwd, output_options_clone, format_options);
#[cfg(feature = "napi")]
let format_service = format_service.with_external_formatter(external_formatter_clone);
let format_service = format_service
.with_external_formatter(external_formatter_clone, handle_external_files);

format_service.run_streaming(rx_entry, &tx_error, &tx_path, tx_count);
format_service.run_streaming(rx_entry, &tx_error, &tx_success);
});

// First, collect and print sorted file paths to stdout
let mut changed_paths: Vec<String> = rx_path.iter().collect();
// Collect results and separate changed paths from unchanged count
let mut changed_paths: Vec<String> = vec![];
let mut unchanged_count: usize = 0;
for result in rx_success {
match result {
SuccessResult::Changed(path) => changed_paths.push(path),
SuccessResult::Unchanged => unchanged_count += 1,
}
}

// Print sorted changed file paths to stdout
if !changed_paths.is_empty() {
changed_paths.sort_unstable();
print_and_flush(stdout, &changed_paths.join("\n"));
Expand All @@ -138,21 +150,24 @@ impl FormatRunner {
// Then, output diagnostics errors to stderr
// NOTE: This is blocking and print errors
let diagnostics = diagnostic_service.run(stderr);
// NOTE: We are not using `DiagnosticService` for warnings
let error_count = diagnostics.errors_count();

// Count the processed files
let total_target_files_count = rx_count.iter().count();
// Count the handled files
// NOTE: Files that processed but not handled due to `external_files` option are not counted
let total_handled_files_count = changed_paths.len() + unchanged_count + error_count;
let print_stats = |stdout| {
let elapsed_ms = start_time.elapsed().as_millis();
print_and_flush(
stdout,
&format!(
"Finished in {elapsed_ms}ms on {total_target_files_count} files using {num_of_threads} threads.\n",
"Finished in {elapsed_ms}ms on {total_handled_files_count} files using {num_of_threads} threads.\n",
),
);
};

// Check if no files were found
if total_target_files_count == 0 {
if total_handled_files_count == 0 {
if misc_options.no_error_on_unmatched_pattern {
print_and_flush(stderr, "No files found matching the given patterns.\n");
print_stats(stdout);
Expand All @@ -163,7 +178,7 @@ impl FormatRunner {
return CliRunResult::NoFilesFound;
}

if 0 < diagnostics.errors_count() {
if 0 < error_count {
// Each error is already printed in reporter
print_and_flush(
stderr,
Expand Down
15 changes: 11 additions & 4 deletions apps/oxfmt/src/main_napi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::{
format::FormatRunner,
init::{init_miette, init_tracing},
lsp::run_lsp,
prettier_plugins::{JsFormatEmbeddedCb, create_external_formatter},
prettier_plugins::{ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb},
result::CliRunResult,
};

Expand All @@ -23,6 +23,7 @@ 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
///
/// Returns `true` if formatting succeeded without errors, `false` otherwise.
#[expect(clippy::allow_attributes)]
Expand All @@ -32,12 +33,18 @@ pub async fn format(
args: Vec<String>,
#[napi(ts_arg_type = "(tagName: string, code: string) => Promise<string>")]
format_embedded_cb: JsFormatEmbeddedCb,
#[napi(ts_arg_type = "(fileName: string, code: string) => Promise<string>")]
format_file_cb: JsFormatFileCb,
) -> bool {
format_impl(args, format_embedded_cb).await.report() == ExitCode::SUCCESS
format_impl(args, format_embedded_cb, format_file_cb).await.report() == ExitCode::SUCCESS
}

/// Run the formatter.
async fn format_impl(args: Vec<String>, format_embedded_cb: JsFormatEmbeddedCb) -> CliRunResult {
async fn format_impl(
args: Vec<String>,
format_embedded_cb: JsFormatEmbeddedCb,
format_file_cb: JsFormatFileCb,
) -> CliRunResult {
// Convert String args to OsString for compatibility with bpaf
let args: Vec<OsString> = args.into_iter().map(OsString::from).collect();

Expand Down Expand Up @@ -67,7 +74,7 @@ async fn format_impl(args: Vec<String>, format_embedded_cb: JsFormatEmbeddedCb)
command.handle_threads();

// Create external formatter from JS callback
let external_formatter = create_external_formatter(format_embedded_cb);
let external_formatter = ExternalFormatter::new(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
64 changes: 54 additions & 10 deletions apps/oxfmt/src/prettier_plugins/external_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,47 @@ pub type JsFormatEmbeddedCb = ThreadsafeFunction<
false,
>;

/// Type alias for the callback function signature.
/// Takes (tag_name, code) as separate arguments and returns formatted code.
pub type JsFormatFileCb = ThreadsafeFunction<
// Input arguments
FnArgs<(String, String)>, // (file_name, code) as separate arguments
// Return type (what JS function returns)
Promise<String>,
// Arguments (repeated)
FnArgs<(String, String)>,
// Error status
Status,
// CalleeHandled
false,
>;

/// Callback function type for formatting files.
/// Takes (file_name, code) and returns formatted code or an error.
type FileFormatterCallback = Arc<dyn Fn(&str, &str) -> Result<String, String> + Send + Sync>;

/// External formatter that wraps a JS callback.
#[derive(Clone)]
pub struct ExternalFormatter {
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("format_embedded", &"<callback>").finish()
f.debug_struct("ExternalFormatter")
.field("format_embedded", &"<callback>")
.field("format_file", &"<callback>")
.finish()
}
}

impl ExternalFormatter {
pub fn new(format_embedded: EmbeddedFormatterCallback) -> Self {
Self { format_embedded }
/// Create an [`ExternalFormatter`] from JS callbacks.
pub fn new(format_embedded_cb: JsFormatEmbeddedCb, format_file_cb: JsFormatFileCb) -> Self {
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 }
}

/// Convert this external formatter to the oxc_formatter::EmbeddedFormatter type
Expand All @@ -45,12 +71,15 @@ impl ExternalFormatter {
// The callback already expects &str, so just use it directly
oxc_formatter::EmbeddedFormatter::new(callback)
}

/// Format non-js file using the JS callback.
pub fn format_file(&self, file_name: &str, code: &str) -> Result<String, String> {
(self.format_file)(file_name, code)
}
}

/// Wrap JS `formatEmbeddedCode` callback as a normal Rust function.
///
/// Uses a channel to capture the result from the JS callback.
pub fn wrap_format_embedded(cb: JsFormatEmbeddedCb) -> EmbeddedFormatterCallback {
fn wrap_format_embedded(cb: JsFormatEmbeddedCb) -> EmbeddedFormatterCallback {
Arc::new(move |tag_name: &str, code: &str| {
block_on(async {
let status =
Expand All @@ -70,8 +99,23 @@ pub fn wrap_format_embedded(cb: JsFormatEmbeddedCb) -> EmbeddedFormatterCallback
})
}

/// Create an [`ExternalFormatter`] from JS callbacks.
pub fn create_external_formatter(format_embedded_cb: JsFormatEmbeddedCb) -> ExternalFormatter {
let rust_format_embedded = wrap_format_embedded(format_embedded_cb);
ExternalFormatter::new(rust_format_embedded)
/// Wrap JS `formatFile` callback as a normal Rust function.
fn wrap_format_file(cb: JsFormatFileCb) -> EmbeddedFormatterCallback {
Arc::new(move |file_name: &str, code: &str| {
block_on(async {
let status =
cb.call_async(FnArgs::from((file_name.to_string(), code.to_string()))).await;
match status {
Ok(promise) => match promise.await {
Ok(formatted_code) => Ok(formatted_code),
Err(err) => {
Err(format!("JS formatter promise rejected for file '{file_name}': {err}"))
}
},
Err(err) => Err(format!(
"Failed to call JS formatting callback for file '{file_name}': {err}"
)),
}
})
})
}
2 changes: 1 addition & 1 deletion apps/oxfmt/src/prettier_plugins/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
mod external_formatter;

pub use external_formatter::{ExternalFormatter, JsFormatEmbeddedCb, create_external_formatter};
pub use external_formatter::{ExternalFormatter, JsFormatEmbeddedCb, JsFormatFileCb};
Loading
Loading