use crate::cmd::completions::ShellType;
use crate::format::{FormatConfig, escape_string, format_directive};
use anyhow::{Context, Result};
use clap::Parser;
use rustledger_parser::{Span, Spanned, parse};
use std::fs;
use std::io::{self, Write};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
pub struct Args {
#[arg(value_name = "FILE")]
pub files: Vec<PathBuf>,
#[arg(long, value_name = "SHELL", hide = true)]
pub generate_completions: Option<ShellType>,
#[arg(short = 'o', long, value_name = "OUTPUT")]
pub output: Option<PathBuf>,
#[arg(short = 'i', long)]
pub in_place: bool,
#[arg(long)]
pub check: bool,
#[arg(long, requires = "check")]
pub diff: bool,
#[arg(short = 'c', long = "currency-column", default_value = "60")]
pub column: usize,
#[arg(short = 'w', long)]
pub prefix_width: Option<usize>,
#[arg(short = 'W', long)]
pub num_width: Option<usize>,
#[arg(long)]
pub indent: Option<usize>,
#[arg(short, long)]
pub verbose: bool,
}
pub fn run(args: &Args) -> Result<ExitCode> {
if args.files.is_empty() {
anyhow::bail!("FILE is required (or set default.file in config)");
}
if args.output.is_some() && args.files.len() > 1 {
anyhow::bail!(
"--output can only be used with a single input file. Use --in-place for multiple files."
);
}
if args.output.is_some() && args.in_place {
anyhow::bail!("--output and --in-place cannot be used together");
}
let mut any_needs_formatting = false;
for file in &args.files {
let result = format_file(file, args)?;
if result == ExitCode::from(1) {
any_needs_formatting = true;
}
}
if args.check && any_needs_formatting {
Ok(ExitCode::from(1))
} else {
Ok(ExitCode::SUCCESS)
}
}
enum FormattableItem {
Directive(Spanned<rustledger_core::Directive>),
Option(String, String, Span),
Include(String, Span),
Plugin(String, Option<String>, Span),
Comment(Spanned<String>),
}
impl FormattableItem {
const fn span(&self) -> Span {
match self {
Self::Directive(d) => d.span,
Self::Option(_, _, span) => *span,
Self::Include(_, span) => *span,
Self::Plugin(_, _, span) => *span,
Self::Comment(c) => c.span,
}
}
}
fn format_file(file: &PathBuf, args: &Args) -> Result<ExitCode> {
if !file.exists() {
anyhow::bail!("file not found: {}", file.display());
}
let original_content =
fs::read_to_string(file).with_context(|| format!("failed to read {}", file.display()))?;
let parse_result = parse(&original_content);
if !parse_result.errors.is_empty() {
for err in &parse_result.errors {
eprintln!("error: {err}");
}
anyhow::bail!("file has parse errors, cannot format");
}
let mut items: Vec<FormattableItem> = Vec::new();
for directive in parse_result.directives {
items.push(FormattableItem::Directive(directive));
}
for (key, value, span) in parse_result.options {
items.push(FormattableItem::Option(key, value, span));
}
for (path, span) in parse_result.includes {
items.push(FormattableItem::Include(path, span));
}
for (name, config, span) in parse_result.plugins {
items.push(FormattableItem::Plugin(name, config, span));
}
for comment in parse_result.comments {
items.push(FormattableItem::Comment(comment));
}
items.sort_by(|a, b| {
let a_start = a.span().start;
let b_start = b.span().start;
a_start.cmp(&b_start)
});
let config = FormatConfig::new(args.column, args.indent.unwrap_or(2));
let mut formatted = String::new();
let mut prev_end: usize = 0;
for item in &items {
let item_start = item.span().start;
if item_start > prev_end {
let between = &original_content[prev_end..item_start];
let newline_count = between.chars().filter(|&c| c == '\n').count();
let blank_lines = if prev_end == 0 {
newline_count
} else {
newline_count.saturating_sub(1)
};
for _ in 0..blank_lines {
formatted.push('\n');
}
}
match item {
FormattableItem::Directive(d) => {
formatted.push_str(&format_directive(&d.value, &config));
let original_text = &original_content[d.span.start..d.span.end];
let mut trailing_newlines = 0usize;
for c in original_text.chars().rev() {
match c {
'\n' => trailing_newlines += 1,
'\r' => {} _ => break,
}
}
for _ in 1..trailing_newlines {
formatted.push('\n');
}
}
FormattableItem::Option(key, value, _) => {
formatted.push_str(&format!(
"option \"{}\" \"{}\"\n",
escape_string(key),
escape_string(value)
));
}
FormattableItem::Include(path, _) => {
formatted.push_str(&format!("include \"{}\"\n", escape_string(path)));
}
FormattableItem::Plugin(name, config_str, _) => {
if let Some(cfg) = config_str {
formatted.push_str(&format!(
"plugin \"{}\" \"{}\"\n",
escape_string(name),
escape_string(cfg)
));
} else {
formatted.push_str(&format!("plugin \"{}\"\n", escape_string(name)));
}
}
FormattableItem::Comment(c) => {
formatted.push_str(&c.value);
if !c.value.ends_with('\n') {
formatted.push('\n');
}
}
}
prev_end = item.span().end;
}
if !formatted.ends_with('\n') {
formatted.push('\n');
}
if args.check {
if formatted.trim() == original_content.trim() {
if args.verbose {
eprintln!("File is already formatted: {}", file.display());
}
Ok(ExitCode::SUCCESS)
} else {
if args.verbose {
eprintln!("File needs formatting: {}", file.display());
}
if args.diff {
eprintln!("--- {}", file.display());
eprintln!("+++ {} (formatted)", file.display());
for (i, (orig, fmt)) in original_content.lines().zip(formatted.lines()).enumerate()
{
if orig != fmt {
eprintln!("@@ line {} @@", i + 1);
eprintln!("-{orig}");
eprintln!("+{fmt}");
}
}
let orig_lines: Vec<_> = original_content.lines().collect();
let fmt_lines: Vec<_> = formatted.lines().collect();
if orig_lines.len() != fmt_lines.len() {
let min_len = orig_lines.len().min(fmt_lines.len());
for (i, line) in orig_lines.iter().skip(min_len).enumerate() {
eprintln!("@@ line {} (removed) @@", min_len + i + 1);
eprintln!("-{line}");
}
for (i, line) in fmt_lines.iter().skip(min_len).enumerate() {
eprintln!("@@ line {} (added) @@", min_len + i + 1);
eprintln!("+{line}");
}
}
}
Ok(ExitCode::from(1))
}
} else if args.in_place {
fs::write(file, &formatted)
.with_context(|| format!("failed to write {}", file.display()))?;
if args.verbose {
eprintln!("Formatted: {}", file.display());
}
Ok(ExitCode::SUCCESS)
} else if let Some(ref output_path) = args.output {
fs::write(output_path, &formatted)
.with_context(|| format!("failed to write {}", output_path.display()))?;
if args.verbose {
eprintln!("Formatted {} -> {}", file.display(), output_path.display());
}
Ok(ExitCode::SUCCESS)
} else {
let mut stdout = io::stdout().lock();
stdout
.write_all(formatted.as_bytes())
.context("failed to write to stdout")?;
Ok(ExitCode::SUCCESS)
}
}