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
1 change: 1 addition & 0 deletions apps/oxfmt/src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub fn resolve_editorconfig_path(cwd: &Path) -> Option<PathBuf> {

/// 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 {
Expand Down
12 changes: 12 additions & 0 deletions apps/oxfmt/src/core/external_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,18 @@ impl ExternalFormatter {
) -> Result<String, String> {
(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![]),
}
}
}

// ---
Expand Down
6 changes: 4 additions & 2 deletions apps/oxfmt/src/lsp/mod.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
use oxc_language_server::run_server;

use crate::core::ExternalFormatter;

mod options;
mod server_formatter;
#[cfg(test)]
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;
}
135 changes: 81 additions & 54 deletions apps/oxfmt/src/lsp/server_formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<LSPFormatOptions>(options) {
Ok(opts) => opts,
Err(err) => {
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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<dyn Tool> {
Box::new(ServerFormatterBuilder::build(root_uri, options))
Box::new(self.build(root_uri, options))
}
}

Expand Down Expand Up @@ -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<Gitignore>,
}
Expand All @@ -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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Gitignore>) -> Self {
Self { config_resolver, gitignore_glob }
pub fn new(
source_formatter: SourceFormatter,
config_resolver: ConfigResolver,
gitignore_glob: Option<Gitignore>,
) -> Self {
Self { source_formatter, config_resolver, gitignore_glob }
}

fn is_ignored(&self, path: &Path) -> bool {
Expand All @@ -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>(
Expand Down Expand Up @@ -351,6 +376,8 @@ fn load_ignore_paths(cwd: &Path) -> Vec<PathBuf> {
.collect::<Vec<_>>()
}

// ---

#[cfg(test)]
mod tests_builder {
use crate::lsp::server_formatter::ServerFormatterBuilder;
Expand All @@ -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());
Expand Down
8 changes: 3 additions & 5 deletions apps/oxfmt/src/lsp/tester.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
57 changes: 30 additions & 27 deletions apps/oxfmt/src/main_napi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ pub async fn run_cli(
)]
sort_tailwindcss_classes_cb: JsSortTailwindClassesCb,
) -> (String, Option<u8>) {
// Convert String args to OsString for compatibility with bpaf
// Convert `String` args to `OsString` for compatibility with `bpaf`
let args: Vec<OsString> = 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) => {
Expand All @@ -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"),
}
}

Expand Down
Loading