diff --git a/apps/oxfmt/src/core/config.rs b/apps/oxfmt/src/core/config.rs index dac0062ca6ee3..23f5f8307e92e 100644 --- a/apps/oxfmt/src/core/config.rs +++ b/apps/oxfmt/src/core/config.rs @@ -47,6 +47,7 @@ pub fn resolve_editorconfig_path(cwd: &Path) -> Option { /// Resolved options for each file type. /// Each variant contains only the options needed for that formatter. +#[derive(Debug)] pub enum ResolvedOptions { /// For JS/TS files formatted by oxc_formatter. OxcFormatter { diff --git a/apps/oxfmt/src/core/external_formatter.rs b/apps/oxfmt/src/core/external_formatter.rs index e9b5addb1ce3e..c2991e8a69ecf 100644 --- a/apps/oxfmt/src/core/external_formatter.rs +++ b/apps/oxfmt/src/core/external_formatter.rs @@ -176,6 +176,18 @@ impl ExternalFormatter { ) -> Result { (self.format_file)(options, parser_name, file_name, code) } + + #[cfg(test)] + pub fn dummy() -> Self { + // Currently, LSP tests are implemented in Rust, while our external formatter relies on JS. + // Therefore, just provides a dummy external formatter that consistently returns errors. + Self { + init: Arc::new(|_| Err("Dummy init called".to_string())), + format_embedded: Arc::new(|_, _, _| Err("Dummy format_embedded called".to_string())), + format_file: Arc::new(|_, _, _, _| Err("Dummy format_file called".to_string())), + sort_tailwindcss_classes: Arc::new(|_, _, _| vec![]), + } + } } // --- diff --git a/apps/oxfmt/src/lsp/mod.rs b/apps/oxfmt/src/lsp/mod.rs index ee874bd33be6c..197a078d57d6a 100644 --- a/apps/oxfmt/src/lsp/mod.rs +++ b/apps/oxfmt/src/lsp/mod.rs @@ -1,5 +1,7 @@ use oxc_language_server::run_server; +use crate::core::ExternalFormatter; + mod options; mod server_formatter; #[cfg(test)] @@ -7,11 +9,11 @@ mod tester; const FORMAT_CONFIG_FILES: &[&str; 2] = &[".oxfmtrc.json", ".oxfmtrc.jsonc"]; /// Run the language server -pub async fn run_lsp() { +pub async fn run_lsp(external_formatter: ExternalFormatter) { run_server( "oxfmt".to_string(), env!("CARGO_PKG_VERSION").to_string(), - vec![Box::new(server_formatter::ServerFormatterBuilder)], + vec![Box::new(server_formatter::ServerFormatterBuilder::new(external_formatter))], ) .await; } diff --git a/apps/oxfmt/src/lsp/server_formatter.rs b/apps/oxfmt/src/lsp/server_formatter.rs index aabc07326c527..b1ec628527703 100644 --- a/apps/oxfmt/src/lsp/server_formatter.rs +++ b/apps/oxfmt/src/lsp/server_formatter.rs @@ -2,28 +2,35 @@ use std::path::{Path, PathBuf}; use ignore::gitignore::{Gitignore, GitignoreBuilder}; use tower_lsp_server::ls_types::{Pattern, Position, Range, ServerCapabilities, TextEdit, Uri}; -use tracing::{debug, warn}; +use tracing::{debug, error, warn}; -use oxc_allocator::Allocator; use oxc_data_structures::rope::{Rope, get_line_column}; -use oxc_formatter::{ - Formatter, enable_jsx_source_type, get_parse_options, get_supported_source_type, -}; use oxc_language_server::{Capabilities, Tool, ToolBuilder, ToolRestartChanges}; -use oxc_parser::Parser; use crate::core::{ - ConfigResolver, FormatFileStrategy, ResolvedOptions, resolve_editorconfig_path, - resolve_oxfmtrc_path, utils, + ConfigResolver, ExternalFormatter, FormatFileStrategy, FormatResult, SourceFormatter, + resolve_editorconfig_path, resolve_oxfmtrc_path, utils, }; use crate::lsp::{FORMAT_CONFIG_FILES, options::FormatOptions as LSPFormatOptions}; -pub struct ServerFormatterBuilder; +pub struct ServerFormatterBuilder { + external_formatter: ExternalFormatter, +} impl ServerFormatterBuilder { + pub fn new(external_formatter: ExternalFormatter) -> Self { + Self { external_formatter } + } + + /// Create a dummy `ServerFormatterBuilder` for testing. + #[cfg(test)] + pub fn dummy() -> Self { + Self { external_formatter: ExternalFormatter::dummy() } + } + /// # Panics /// Panics if the root URI cannot be converted to a file path. - pub fn build(root_uri: &Uri, options: serde_json::Value) -> ServerFormatter { + pub fn build(&self, root_uri: &Uri, options: serde_json::Value) -> ServerFormatter { let options = match serde_json::from_value::(options) { Ok(opts) => opts, Err(err) => { @@ -55,7 +62,19 @@ impl ServerFormatterBuilder { } }; - ServerFormatter::new(config_resolver, gitignore_glob) + let num_of_threads = 1; // Single threaded for LSP + // Use `block_in_place()` to avoid nested async runtime access + match tokio::task::block_in_place(|| self.external_formatter.init(num_of_threads)) { + // TODO: Plugins support + Ok(_) => {} + Err(err) => { + error!("Failed to setup external formatter.\n{err}\n"); + } + } + let source_formatter = SourceFormatter::new(num_of_threads) + .with_external_formatter(Some(self.external_formatter.clone())); + + ServerFormatter::new(source_formatter, config_resolver, gitignore_glob) } } @@ -68,8 +87,9 @@ impl ToolBuilder for ServerFormatterBuilder { capabilities.document_formatting_provider = Some(tower_lsp_server::ls_types::OneOf::Left(true)); } + fn build_boxed(&self, root_uri: &Uri, options: serde_json::Value) -> Box { - Box::new(ServerFormatterBuilder::build(root_uri, options)) + Box::new(self.build(root_uri, options)) } } @@ -128,7 +148,11 @@ impl ServerFormatterBuilder { builder.build().map_err(|_| "Failed to build ignore globs".to_string()) } } + +// --- + pub struct ServerFormatter { + source_formatter: SourceFormatter, config_resolver: ConfigResolver, gitignore_glob: Option, } @@ -137,6 +161,7 @@ impl Tool for ServerFormatter { fn name(&self) -> &'static str { "formatter" } + /// # Panics /// Panics if the root URI cannot be converted to a file path. fn handle_configuration_change( @@ -226,8 +251,9 @@ impl Tool for ServerFormatter { return Ok(Vec::new()); } - let Some(source_type) = get_supported_source_type(&path).map(enable_jsx_source_type) else { - debug!("Unsupported source type for formatting: {}", path.display()); + // Determine format strategy from file path (supports JS/TS, JSON, YAML, CSS, etc.) + let Ok(strategy) = FormatFileStrategy::try_from(path.to_path_buf()) else { + debug!("Unsupported file type for formatting: {}", path.display()); return Ok(Vec::new()); }; let source_text = match content { @@ -237,53 +263,50 @@ impl Tool for ServerFormatter { } }; - // Create `FormatFileStrategy` for config resolution - let strategy = FormatFileStrategy::OxcFormatter { path: path.to_path_buf(), source_type }; + // Resolve options for this file + let resolved_options = self.config_resolver.resolve(&strategy); + debug!("resolved_options = {resolved_options:?}"); - let resolved = self.config_resolver.resolve(&strategy); - let ResolvedOptions::OxcFormatter { format_options, .. } = resolved else { - unreachable!("Strategy is OxcFormatter, so resolved should also be OxcFormatter"); - }; - - debug!("format_options = {format_options:?}"); - // debug!("oxfmt_options = {oxfmt_options:?}"); + let result = tokio::task::block_in_place(|| { + self.source_formatter.format(&strategy, source_text, resolved_options) + }); - let allocator = Allocator::new(); - let ret = Parser::new(&allocator, source_text, source_type) - .with_options(get_parse_options()) - .parse(); - - if !ret.errors.is_empty() { - // Parser errors should not be returned to the user. - // The user probably wanted to format while typing incomplete code. - return Ok(Vec::new()); - } - - let code = Formatter::new(&allocator, format_options).build(&ret.program); + // Handle result + match result { + FormatResult::Success { code, is_changed } => { + if !is_changed { + return Ok(vec![]); + } - // nothing has changed - if code == *source_text { - return Ok(vec![]); + let (start, end, replacement) = compute_minimal_text_edit(source_text, &code); + let rope = Rope::from(source_text); + let (start_line, start_character) = get_line_column(&rope, start, source_text); + let (end_line, end_character) = get_line_column(&rope, end, source_text); + + Ok(vec![TextEdit::new( + Range::new( + Position::new(start_line, start_character), + Position::new(end_line, end_character), + ), + replacement.to_string(), + )]) + } + FormatResult::Error(_) => { + // Errors should not be returned to the user. + // The user probably wanted to format while typing incomplete code. + Ok(Vec::new()) + } } - - let (start, end, replacement) = compute_minimal_text_edit(source_text, &code); - let rope = Rope::from(source_text); - let (start_line, start_character) = get_line_column(&rope, start, source_text); - let (end_line, end_character) = get_line_column(&rope, end, source_text); - - Ok(vec![TextEdit::new( - Range::new( - Position::new(start_line, start_character), - Position::new(end_line, end_character), - ), - replacement.to_string(), - )]) } } impl ServerFormatter { - pub fn new(config_resolver: ConfigResolver, gitignore_glob: Option) -> Self { - Self { config_resolver, gitignore_glob } + pub fn new( + source_formatter: SourceFormatter, + config_resolver: ConfigResolver, + gitignore_glob: Option, + ) -> Self { + Self { source_formatter, config_resolver, gitignore_glob } } fn is_ignored(&self, path: &Path) -> bool { @@ -299,6 +322,8 @@ impl ServerFormatter { } } +// --- + /// Returns the minimal text edit (start, end, replacement) to transform `source_text` into `formatted_text` #[expect(clippy::cast_possible_truncation)] fn compute_minimal_text_edit<'a>( @@ -351,6 +376,8 @@ fn load_ignore_paths(cwd: &Path) -> Vec { .collect::>() } +// --- + #[cfg(test)] mod tests_builder { use crate::lsp::server_formatter::ServerFormatterBuilder; @@ -360,7 +387,7 @@ mod tests_builder { fn test_server_capabilities() { use tower_lsp_server::ls_types::{OneOf, ServerCapabilities}; - let builder = ServerFormatterBuilder; + let builder = ServerFormatterBuilder::dummy(); let mut capabilities = ServerCapabilities::default(); builder.server_capabilities(&mut capabilities, &Capabilities::default()); diff --git a/apps/oxfmt/src/lsp/tester.rs b/apps/oxfmt/src/lsp/tester.rs index b2a5f25b9f05b..f3c39b6725245 100644 --- a/apps/oxfmt/src/lsp/tester.rs +++ b/apps/oxfmt/src/lsp/tester.rs @@ -55,10 +55,8 @@ impl Tester<'_> { } fn create_formatter(&self) -> ServerFormatter { - ServerFormatterBuilder::build( - &Self::get_root_uri(self.relative_root_dir), - self.options.clone(), - ) + ServerFormatterBuilder::dummy() + .build(&Self::get_root_uri(self.relative_root_dir), self.options.clone()) } pub fn get_root_uri(relative_root_dir: &str) -> Uri { @@ -109,7 +107,7 @@ impl Tester<'_> { &self, new_options: serde_json::Value, ) -> ToolRestartChanges { - let builder = ServerFormatterBuilder; + let builder = ServerFormatterBuilder::dummy(); self.create_formatter().handle_configuration_change( &builder, &Self::get_root_uri(self.relative_root_dir), diff --git a/apps/oxfmt/src/main_napi.rs b/apps/oxfmt/src/main_napi.rs index 8ca0257458254..834c4810eba18 100644 --- a/apps/oxfmt/src/main_napi.rs +++ b/apps/oxfmt/src/main_napi.rs @@ -50,10 +50,10 @@ pub async fn run_cli( )] sort_tailwindcss_classes_cb: JsSortTailwindClassesCb, ) -> (String, Option) { - // Convert String args to OsString for compatibility with bpaf + // Convert `String` args to `OsString` for compatibility with `bpaf` let args: Vec = args.into_iter().map(OsString::from).collect(); - // Use `run_inner()` to report errors instead of panicking. + // Use `run_inner()` to report errors instead of panicking let command = match format_command().run_inner(&*args) { Ok(cmd) => cmd, Err(e) => { @@ -64,49 +64,52 @@ pub async fn run_cli( } }; + // Early return for modes that handle everything in JS side match command.mode { - Mode::Init => ("init".to_string(), None), - Mode::Migrate(_) => ("migrate:prettier".to_string(), None), - Mode::Lsp => { - utils::init_tracing(); + Mode::Init => { + return ("init".to_string(), None); + } + Mode::Migrate(_) => { + return ("migrate:prettier".to_string(), None); + } + _ => {} + } - run_lsp().await; + // Otherwise, handle modes that require Rust side processing + + let external_formatter = ExternalFormatter::new( + init_external_formatter_cb, + format_embedded_cb, + format_file_cb, + sort_tailwindcss_classes_cb, + ); + + utils::init_tracing(); + match command.mode { + Mode::Lsp => { + run_lsp(external_formatter).await; ("lsp".to_string(), Some(0)) } Mode::Stdin(_) => { - utils::init_tracing(); init_miette(); - let result = StdinRunner::new(command) - // Create external formatter from JS callbacks - .with_external_formatter(Some(ExternalFormatter::new( - init_external_formatter_cb, - format_embedded_cb, - format_file_cb, - sort_tailwindcss_classes_cb, - ))) - .run(); + // TODO: `.with_external_formatter()` is not needed, just pass with `new(command, external_formatter)` + let result = + StdinRunner::new(command).with_external_formatter(Some(external_formatter)).run(); ("stdin".to_string(), Some(result.exit_code())) } Mode::Cli(_) => { - utils::init_tracing(); init_miette(); init_rayon(command.runtime_options.threads); - let result = FormatRunner::new(command) - // Create external formatter from JS callbacks - .with_external_formatter(Some(ExternalFormatter::new( - init_external_formatter_cb, - format_embedded_cb, - format_file_cb, - sort_tailwindcss_classes_cb, - ))) - .run(); + let result = + FormatRunner::new(command).with_external_formatter(Some(external_formatter)).run(); ("cli".to_string(), Some(result.exit_code())) } + _ => unreachable!("All other modes must have been handled above match arm"), } }