use std::io::{self, Read, Write};
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
use std::path::Path;
use std::process::exit;
use std::sync::atomic::{AtomicBool, Ordering};
use nixfmt_rs::VERSION;
mod json_diag;
const HELP: &str = "\
nixfmt-rs [OPTIONS] [FILES or -]
Format Nix source code (Rust implementation of nixfmt)
Use '-' as a file argument to read from stdin.
Common flags:
-w --width=INT Maximum width in characters [default: 100]
--indent=INT Number of spaces to use for indentation [default: 2]
-c --check Check whether files are formatted without modifying them
-m --mergetool Git mergetool mode: format BASE/LOCAL/REMOTE, run
'git merge-file', format and move result to MERGED
-q --quiet Do not report errors
-s --strict Enable a stricter formatting mode (accepted, currently no-op)
-v --verify Apply sanity checks on the output after formatting
-a --ast Pretty print the internal AST to stderr (debug)
-f --filename=ITEM Filename to display when input is read from stdin
--ir Pretty print the internal IR to stderr (debug)
--message-format=FMT
How to render diagnostics: 'human' (default) or 'json'
(one JSON object per line on stderr, LSP Diagnostic shape)
-h -? --help Display help message
-V --version Print version information
--numeric-version Print just the version number
";
#[derive(Default)]
#[allow(clippy::struct_excessive_bools)] struct Opts {
width: usize,
indent: usize,
check: bool,
quiet: bool,
#[allow(dead_code)] strict: bool,
verify: bool,
ast: bool,
ir: bool,
parse_only: bool,
mergetool: bool,
json_diagnostics: bool,
filename: Option<String>,
files: Vec<String>,
}
fn parse_args() -> Result<Opts, lexopt::Error> {
use lexopt::prelude::*;
let mut o = Opts {
width: 100,
indent: 2,
..Opts::default()
};
let mut p = lexopt::Parser::from_env();
while let Some(arg) = p.next()? {
match arg {
Short('h' | '?') | Long("help") => {
print!("{HELP}");
exit(0);
}
Short('V') | Long("version") => {
println!("nixfmt-rs {VERSION}");
exit(0);
}
Long("numeric-version") => {
println!("{VERSION}");
exit(0);
}
Short('w') | Long("width") => o.width = p.value()?.parse()?,
Long("indent") => o.indent = p.value()?.parse()?,
Short('c') | Long("check") => o.check = true,
Short('q') | Long("quiet") => o.quiet = true,
Short('s') | Long("strict") => o.strict = true,
Short('v') | Long("verify") => o.verify = true,
Short('a') | Long("ast") => o.ast = true,
Long("ir") => o.ir = true,
Long("parse-only") => o.parse_only = true,
Short('m') | Long("mergetool") => o.mergetool = true,
Long("message-format") => {
let v = p.value()?.string()?;
o.json_diagnostics = match v.as_str() {
"human" => false,
"json" => true,
other => {
return Err(lexopt::Error::Custom(
format!("Unknown --message-format: {other}").into(),
));
}
};
}
Short('f') | Long("filename") => o.filename = Some(p.value()?.string()?),
Value(path) => o.files.push(path.string()?),
_ => return Err(arg.unexpected()),
}
}
Ok(o)
}
fn render_err(o: &Opts, source: &str, name: &str, e: &nixfmt_rs::ParseError) -> String {
if o.json_diagnostics {
json_diag::parse_error(source, name, e)
} else {
nixfmt_rs::format_error(source, Some(name), e)
}
}
fn report(o: &Opts, file: Option<&str>, severity: &str, msg: &str) {
eprintln!("{}", render_msg(o, file, severity, msg));
}
fn try_format(o: &Opts, name: &str, source: &str) -> Result<String, String> {
let fmt = |s: &str| {
let mut opts = nixfmt_rs::Options::default();
opts.width = o.width;
opts.indent = o.indent;
nixfmt_rs::format_with(s, &opts).map_err(|e| render_err(o, s, name, &e))
};
let out = fmt(source)?;
if o.verify {
let again = fmt(&out)?;
if again != out {
return Err(render_msg(
o,
Some(name),
"error",
"verify: output is not idempotent",
));
}
}
Ok(out)
}
fn render_msg(o: &Opts, file: Option<&str>, severity: &str, msg: &str) -> String {
if o.json_diagnostics {
json_diag::message(file, severity, msg)
} else if let Some(f) = file {
format!("{f}: {msg}")
} else {
msg.to_string()
}
}
fn process(o: &Opts, name: &str, source: &str, in_place: bool) -> bool {
if o.parse_only {
return match nixfmt_rs::parse(source) {
Ok(_) => true,
Err(e) => {
if !o.quiet {
eprintln!("{}", render_err(o, source, name, &e));
}
false
}
};
}
if o.ast || o.ir {
let res = if o.ast {
nixfmt_rs::format_ast(source)
} else {
nixfmt_rs::format_ir(source)
};
match res {
Ok(s) => eprint!("{s}"),
Err(e) if !o.quiet => {
eprintln!("{}", render_err(o, source, name, &e));
}
Err(_) => {}
}
return false;
}
let out = match try_format(o, name, source) {
Ok(s) => s,
Err(msg) => {
if !o.quiet {
eprintln!("{msg}");
}
return false;
}
};
if o.check {
if out != source {
if !o.quiet {
report(o, Some(name), "warning", "not formatted");
}
return false;
}
return true;
}
if in_place {
if out != source
&& let Err(e) = std::fs::write(name, &out)
{
if !o.quiet {
report(o, Some(name), "error", &e.to_string());
}
return false;
}
} else {
let _ = io::stdout().write_all(out.as_bytes());
}
true
}
fn main() {
let o = match parse_args() {
Ok(o) => o,
Err(e) => {
eprintln!("{e}");
exit(1);
}
};
let mut ok = true;
if o.mergetool {
exit(i32::from(!run_mergetool(&o)));
}
let stdin_only = o.files.is_empty() || o.files.iter().all(|f| f == "-");
if o.files.is_empty() && !o.quiet && !o.json_diagnostics {
eprintln!(
"Warning: Bare invocation of nixfmt-rs is deprecated. Use 'nixfmt-rs -' for anonymous stdin."
);
}
if stdin_only {
let mut buf = String::new();
if let Err(e) = io::stdin().read_to_string(&mut buf) {
eprintln!("error: failed to read stdin: {e}");
exit(1);
}
let name = o.filename.as_deref().unwrap_or("<stdin>");
ok &= process(&o, name, &buf, false);
} else if o.files.iter().any(|f| f == "-") {
eprintln!("error: cannot mix '-' (stdin) with file arguments");
exit(1);
} else {
let parallel = !(o.ast || o.ir || o.parse_only);
ok &= walk_and_process(&o, parallel);
}
exit(i32::from(!ok));
}
fn process_path(o: &Opts, path: &Path) -> bool {
let name = path.to_string_lossy();
match std::fs::read_to_string(path) {
Ok(source) => process(o, &name, &source, true),
Err(e) => {
if !o.quiet {
report(o, Some(&name), "error", &e.to_string());
}
false
}
}
}
fn walk_and_process(o: &Opts, parallel: bool) -> bool {
let mut args = o.files.iter();
let first = args.next().expect("caller checked non-empty");
let mut wb = ignore::WalkBuilder::new(first);
for a in args {
wb.add(a);
}
wb.standard_filters(false);
let want = |e: &ignore::DirEntry| {
e.file_type().is_some_and(|t| t.is_file())
&& (e.depth() == 0 || e.path().extension().is_some_and(|x| x == "nix"))
};
let visit = |entry: Result<ignore::DirEntry, ignore::Error>| -> bool {
match entry {
Ok(e) if want(&e) => process_path(o, e.path()),
Ok(_) => true,
Err(e) => {
if !o.quiet {
report(o, None, "error", &e.to_string());
}
false
}
}
};
if !parallel {
wb.threads(1);
return wb.build().map(visit).fold(true, |a, b| a & b);
}
let ok = AtomicBool::new(true);
wb.build_parallel().run(|| {
Box::new(|entry| {
if !visit(entry) {
ok.store(false, Ordering::Relaxed);
}
ignore::WalkState::Continue
})
});
ok.load(Ordering::Relaxed)
}
fn run_mergetool(o: &Opts) -> bool {
let [base, local, remote, merged] = if let [b, l, r, m] = o.files.as_slice() {
[b.as_str(), l.as_str(), r.as_str(), m.as_str()]
} else {
if !o.quiet {
eprintln!(
"--mergetool mode expects exactly 4 file arguments ($BASE, $LOCAL, $REMOTE, $MERGED)"
);
}
return false;
};
if Path::new(merged)
.extension()
.is_none_or(|ext| !ext.eq_ignore_ascii_case("nix"))
{
if !o.quiet {
eprintln!("Skipping non-Nix file {merged}");
}
return false;
}
let pre_format = |label: &str, path: &str| -> bool {
if process_path(o, Path::new(path)) {
return true;
}
if !o.quiet {
eprintln!("pre-formatting the {label} version failed");
}
false
};
let mut ok = pre_format("base", base);
ok &= pre_format("local", local);
ok &= pre_format("remote", remote);
if !ok {
return false;
}
let status = match std::process::Command::new("git")
.args(["merge-file", local, base, remote])
.status()
{
Ok(s) => s,
Err(e) => {
if !o.quiet {
eprintln!("failed to run git merge-file: {e}");
}
return false;
}
};
if status.code().is_none() {
if !o.quiet {
eprintln!("git merge-file terminated by signal");
}
return false;
}
if !process_path(o, Path::new(local)) {
return false;
}
if let Err(e) = std::fs::rename(local, merged) {
if !o.quiet {
eprintln!("failed to move {local} to {merged}: {e}");
}
return false;
}
status.success()
}