Skip to content

Commit b16cc0d

Browse files
committed
feat(oxfmt): Add apps/oxfmt crate with minimum formatting usage (#13259)
Part of #10573 - Add `apps/oxfmt` crate - Copy minimum functionalities from `apps/oxlint` - Currently, these two are very similar - However, as following Prettier more, they may become different - There may still have in common, but at this point, it is unclear - `oxc_linter` also has many optimizations - Format files with `oxc_formatter` and save - Usage not optimized
1 parent 5c5788e commit b16cc0d

File tree

9 files changed

+524
-0
lines changed

9 files changed

+524
-0
lines changed

Cargo.lock

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/oxfmt/Cargo.toml

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
[package]
2+
name = "oxfmt"
3+
version = "0.1.0"
4+
authors.workspace = true
5+
categories.workspace = true
6+
edition.workspace = true
7+
homepage.workspace = true
8+
keywords.workspace = true
9+
license.workspace = true
10+
publish = false
11+
repository.workspace = true
12+
rust-version.workspace = true
13+
description.workspace = true
14+
15+
[lints]
16+
workspace = true
17+
18+
[lib]
19+
crate-type = ["lib"]
20+
path = "src/lib.rs"
21+
doctest = false
22+
23+
[[bin]]
24+
name = "oxfmt"
25+
path = "src/main.rs"
26+
test = false
27+
doctest = false
28+
29+
[dependencies]
30+
oxc_allocator = { workspace = true }
31+
oxc_formatter = { workspace = true }
32+
oxc_parser = { workspace = true }
33+
oxc_span = { workspace = true }
34+
35+
bpaf = { workspace = true, features = ["autocomplete", "bright-color", "derive"] }
36+
ignore = { workspace = true, features = ["simd-accel"] }
37+
indexmap = { workspace = true }
38+
rayon = { workspace = true }
39+
rustc-hash = { workspace = true }
40+
tracing-subscriber = { workspace = true, features = [] } # Omit the `regex` feature
41+
42+
[target.'cfg(not(any(target_os = "linux", target_os = "freebsd", target_arch = "arm", target_family = "wasm")))'.dependencies]
43+
mimalloc-safe = { workspace = true, optional = true, features = ["skip_collect_on_exit"] }
44+
45+
[target.'cfg(all(target_os = "linux", not(target_arch = "arm"), not(target_arch = "aarch64")))'.dependencies]
46+
mimalloc-safe = { workspace = true, optional = true, features = ["skip_collect_on_exit", "local_dynamic_tls"] }
47+
48+
[target.'cfg(all(target_os = "linux", target_arch = "aarch64"))'.dependencies]
49+
mimalloc-safe = { workspace = true, optional = true, features = ["skip_collect_on_exit", "local_dynamic_tls", "no_opt_arch"] }
50+
51+
[dev-dependencies]
52+
53+
[features]
54+
default = []
55+
allocator = ["dep:mimalloc-safe"]

apps/oxfmt/src/command.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
use std::path::PathBuf;
2+
3+
use bpaf::Bpaf;
4+
5+
const VERSION: &str = match option_env!("OXC_VERSION") {
6+
Some(v) => v,
7+
None => "dev",
8+
};
9+
10+
#[expect(clippy::ptr_arg)]
11+
fn validate_paths(paths: &Vec<PathBuf>) -> bool {
12+
if paths.is_empty() {
13+
true
14+
} else {
15+
paths.iter().all(|p| p.components().all(|c| c != std::path::Component::ParentDir))
16+
}
17+
}
18+
19+
const PATHS_ERROR_MESSAGE: &str = "PATH must not contain \"..\"";
20+
21+
#[derive(Debug, Clone, Bpaf)]
22+
#[bpaf(options, version(VERSION))]
23+
pub struct FormatCommand {
24+
#[bpaf(external)]
25+
pub basic_options: BasicOptions,
26+
27+
#[bpaf(external)]
28+
pub misc_options: MiscOptions,
29+
30+
/// Single file, single path or list of paths
31+
#[bpaf(positional("PATH"), many, guard(validate_paths, PATHS_ERROR_MESSAGE))]
32+
pub paths: Vec<PathBuf>,
33+
}
34+
35+
/// Basic Configuration
36+
#[derive(Debug, Clone, Bpaf)]
37+
pub struct BasicOptions {
38+
/// TODO: docs
39+
#[bpaf(switch)]
40+
pub check: bool,
41+
}
42+
43+
/// Miscellaneous
44+
#[derive(Debug, Clone, Bpaf)]
45+
pub struct MiscOptions {
46+
/// Number of threads to use. Set to 1 for using only 1 CPU core
47+
#[bpaf(argument("INT"), hide_usage)]
48+
pub threads: Option<usize>,
49+
}
50+
51+
impl FormatCommand {
52+
pub fn handle_threads(&self) {
53+
Self::init_rayon_thread_pool(self.misc_options.threads);
54+
}
55+
56+
/// Initialize Rayon global thread pool with specified number of threads.
57+
///
58+
/// If `--threads` option is not used, or `--threads 0` is given,
59+
/// default to the number of available CPU cores.
60+
#[expect(clippy::print_stderr)]
61+
fn init_rayon_thread_pool(threads: Option<usize>) {
62+
// Always initialize thread pool, even if using default thread count,
63+
// to ensure thread pool's thread count is locked after this point.
64+
// `rayon::current_num_threads()` will always return the same number after this point.
65+
//
66+
// If you don't initialize the global thread pool explicitly, or don't specify `num_threads`,
67+
// Rayon will initialize the thread pool when it's first used, with a thread count of
68+
// `std::thread::available_parallelism()`, and that thread count won't change thereafter.
69+
// So we don't *need* to initialize the thread pool here if we just want the default thread count.
70+
//
71+
// However, Rayon's docs state that:
72+
// > In the future, the default behavior may change to dynamically add or remove threads as needed.
73+
// https://docs.rs/rayon/1.11.0/rayon/struct.ThreadPoolBuilder.html#method.num_threads
74+
//
75+
// To ensure we continue to have a "locked" thread count, even after future Rayon upgrades,
76+
// we always initialize the thread pool and explicitly specify thread count here.
77+
78+
let thread_count = if let Some(thread_count) = threads
79+
&& thread_count > 0
80+
{
81+
thread_count
82+
} else if let Ok(thread_count) = std::thread::available_parallelism() {
83+
thread_count.get()
84+
} else {
85+
eprintln!(
86+
"Unable to determine available thread count. Defaulting to 1.\nConsider specifying the number of threads explicitly with `--threads` option."
87+
);
88+
1
89+
};
90+
91+
rayon::ThreadPoolBuilder::new().num_threads(thread_count).build_global().unwrap();
92+
}
93+
}

apps/oxfmt/src/format.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
use std::{env, ffi::OsStr, io::Write, path::PathBuf, sync::Arc};
2+
3+
use ignore::overrides::OverrideBuilder;
4+
5+
use crate::{
6+
cli::{CliRunResult, FormatCommand},
7+
service::FormatService,
8+
walk::Walk,
9+
};
10+
11+
#[derive(Debug)]
12+
pub struct FormatRunner {
13+
options: FormatCommand,
14+
cwd: PathBuf,
15+
}
16+
17+
impl FormatRunner {
18+
pub(crate) fn new(options: FormatCommand) -> Self {
19+
Self { options, cwd: env::current_dir().expect("Failed to get current working directory") }
20+
}
21+
22+
pub(crate) fn run(self, stdout: &mut dyn Write) -> CliRunResult {
23+
let cwd = self.cwd;
24+
let FormatCommand { paths, basic_options: _, .. } = self.options;
25+
26+
let (exclude_patterns, regular_paths): (Vec<_>, Vec<_>) =
27+
paths.into_iter().partition(|p| p.to_string_lossy().starts_with('!'));
28+
29+
// Need at least one regular path
30+
if regular_paths.is_empty() {
31+
print_and_flush_stdout(
32+
stdout,
33+
"Expected at least one target file/dir/glob(non-override pattern)\n",
34+
);
35+
return CliRunResult::FormatNoFilesFound;
36+
}
37+
38+
// Build exclude patterns if any exist
39+
let override_builder = (!exclude_patterns.is_empty())
40+
.then(|| {
41+
let mut builder = OverrideBuilder::new(cwd);
42+
for pattern in exclude_patterns {
43+
builder.add(&pattern.to_string_lossy()).ok()?;
44+
}
45+
builder.build().ok()
46+
})
47+
.flatten();
48+
49+
let walker = Walk::new(&regular_paths, override_builder);
50+
let paths = walker.paths();
51+
52+
let files_to_format = paths
53+
.into_iter()
54+
// .filter(|path| !config_store.should_ignore(Path::new(path)))
55+
.collect::<Vec<Arc<OsStr>>>();
56+
57+
if files_to_format.is_empty() {
58+
print_and_flush_stdout(stdout, "Expected at least one target file\n");
59+
return CliRunResult::FormatNoFilesFound;
60+
}
61+
62+
// let (mut diagnostic_service, tx_error) =
63+
// Self::get_diagnostic_service(&output_formatter, &warning_options, &misc_options);
64+
65+
// rayon::spawn(move || {
66+
let mut format_service = FormatService::new();
67+
format_service.with_paths(files_to_format);
68+
format_service.run();
69+
// });
70+
71+
// let diagnostic_result = diagnostic_service.run(stdout);
72+
73+
CliRunResult::FormatSucceeded
74+
}
75+
}
76+
77+
pub fn print_and_flush_stdout(stdout: &mut dyn Write, message: &str) {
78+
use std::io::{Error, ErrorKind};
79+
fn check_for_writer_error(error: Error) -> Result<(), Error> {
80+
// Do not panic when the process is killed (e.g. piping into `less`).
81+
if matches!(error.kind(), ErrorKind::Interrupted | ErrorKind::BrokenPipe) {
82+
Ok(())
83+
} else {
84+
Err(error)
85+
}
86+
}
87+
88+
stdout.write_all(message.as_bytes()).or_else(check_for_writer_error).unwrap();
89+
stdout.flush().unwrap();
90+
}

apps/oxfmt/src/lib.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
mod command;
2+
mod format;
3+
mod result;
4+
mod service;
5+
mod walk;
6+
7+
pub mod cli {
8+
pub use crate::{
9+
command::{FormatCommand, format_command},
10+
format::FormatRunner,
11+
result::CliRunResult,
12+
};
13+
}
14+
15+
use std::io::BufWriter;
16+
17+
use cli::{CliRunResult, FormatRunner, format_command};
18+
19+
#[cfg(all(feature = "allocator", not(miri), not(target_family = "wasm")))]
20+
#[global_allocator]
21+
static GLOBAL: mimalloc_safe::MiMalloc = mimalloc_safe::MiMalloc;
22+
23+
pub fn format() -> CliRunResult {
24+
init_tracing();
25+
26+
let mut args = std::env::args_os();
27+
// If first arg is `node`, also skip script path (`node script.js ...`).
28+
// Otherwise, just skip first arg (`oxfmt ...`).
29+
if args.next().is_some_and(|arg| arg == "node") {
30+
args.next();
31+
}
32+
let args = args.collect::<Vec<_>>();
33+
34+
let command = match format_command().run_inner(&*args) {
35+
Ok(cmd) => cmd,
36+
Err(e) => {
37+
e.print_message(100);
38+
return if e.exit_code() == 0 {
39+
CliRunResult::FormatSucceeded
40+
} else {
41+
CliRunResult::InvalidOptionConfig
42+
};
43+
}
44+
};
45+
command.handle_threads();
46+
47+
// stdio is blocked by LineWriter, use a BufWriter to reduce syscalls.
48+
// See `https://github.com/rust-lang/rust/issues/60673`.
49+
let mut stdout = BufWriter::new(std::io::stdout());
50+
FormatRunner::new(command).run(&mut stdout)
51+
}
52+
53+
/// To debug `oxc_formatter`:
54+
/// `OXC_LOG=oxc_formatter oxfmt`
55+
fn init_tracing() {
56+
use tracing_subscriber::{filter::Targets, prelude::*};
57+
58+
// Usage without the `regex` feature.
59+
// <https://github.com/tokio-rs/tracing/issues/1436#issuecomment-918528013>
60+
tracing_subscriber::registry()
61+
.with(std::env::var("OXC_LOG").map_or_else(
62+
|_| Targets::new(),
63+
|env_var| {
64+
use std::str::FromStr;
65+
Targets::from_str(&env_var).unwrap()
66+
},
67+
))
68+
.with(tracing_subscriber::fmt::layer())
69+
.init();
70+
}

apps/oxfmt/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
use oxfmt::{cli::CliRunResult, format};
2+
3+
fn main() -> CliRunResult {
4+
format()
5+
}

apps/oxfmt/src/result.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
use std::process::{ExitCode, Termination};
2+
3+
#[derive(Debug)]
4+
pub enum CliRunResult {
5+
// Success
6+
None,
7+
FormatSucceeded,
8+
// Failure
9+
FormatNoFilesFound,
10+
InvalidOptionConfig,
11+
}
12+
13+
impl Termination for CliRunResult {
14+
fn report(self) -> ExitCode {
15+
match self {
16+
Self::None | Self::FormatSucceeded => ExitCode::SUCCESS,
17+
Self::FormatNoFilesFound | Self::InvalidOptionConfig => ExitCode::FAILURE,
18+
}
19+
}
20+
}

0 commit comments

Comments
 (0)