whisker-cli 0.3.1

Whisker CLI: `whisker` and `cargo-whisker` (hybrid) — scaffold, doctor, and dev-loop Whisker apps.
Documentation
//! `whisker fmt` — a rustfmt drop-in that also formats Whisker's
//! `render!` / `css!` macro bodies.
//!
//! Three modes:
//!
//! - `whisker fmt <files...>` — format each file in place (or print to
//!   stdout with `--check`).
//! - `whisker fmt --stdin` — read stdin, write formatted to stdout.
//!   This is the rust-analyzer integration point:
//!   `rust-analyzer.rustfmt.overrideCommand = ["whisker", "fmt", "--stdin"]`.
//! - `whisker fmt --check <files...>` — don't write; print a unified
//!   diff and exit non-zero if any file would change.
//!
//! There are NO whisker-specific formatting options: the layout values
//! ([`whisker_fmt::FmtOptions`]) come from the nearest `rustfmt.toml`
//! (resolved per file directory), and the base Rust pass shells out to
//! the real rustfmt binary which reads `rustfmt.toml` itself.

use anyhow::{bail, Context, Result};
use clap::Args;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use whisker_fmt::FmtOptions;

#[derive(Args, Debug)]
pub struct FmtArgs {
    /// Rust source files to format. Ignored when `--stdin` is set.
    pub files: Vec<PathBuf>,

    /// Read source from stdin and write the formatted result to stdout.
    /// For `rust-analyzer.rustfmt.overrideCommand`.
    #[arg(long)]
    pub stdin: bool,

    /// Don't write anything. Print a unified diff of what would change
    /// and exit non-zero if any input is not already formatted.
    #[arg(long)]
    pub check: bool,
}

pub fn run(args: FmtArgs) -> Result<()> {
    if args.stdin {
        return run_stdin(&args);
    }
    if args.files.is_empty() {
        bail!("whisker fmt: no input files (pass file paths, or use --stdin)");
    }
    run_files(&args)
}

/// stdin → stdout. `--check` on stdin prints the diff to stderr and
/// exits non-zero if a change would be made.
fn run_stdin(args: &FmtArgs) -> Result<()> {
    let mut src = String::new();
    std::io::stdin()
        .read_to_string(&mut src)
        .context("reading source from stdin")?;
    // For stdin we resolve rustfmt.toml from the current directory.
    let opts = resolve_options(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
    let formatted = whisker_fmt::format_source_in_dir(
        &src,
        &opts,
        &std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
    )
    .context("formatting stdin")?;

    if args.check {
        if formatted != src {
            eprint!("{}", whisker_fmt::unified_diff(&src, &formatted));
            std::process::exit(1);
        }
        return Ok(());
    }

    let mut stdout = std::io::stdout().lock();
    stdout
        .write_all(formatted.as_bytes())
        .context("writing formatted output to stdout")?;
    Ok(())
}

fn run_files(args: &FmtArgs) -> Result<()> {
    let mut any_changed = false;
    let mut errored = false;

    for file in &args.files {
        match format_one_file(file, args.check) {
            Ok(changed) => {
                if changed {
                    any_changed = true;
                    if args.check {
                        // Diff already printed by format_one_file.
                    } else {
                        eprintln!("formatted {}", file.display());
                    }
                }
            }
            Err(e) => {
                errored = true;
                eprintln!("error: {}: {e:#}", file.display());
            }
        }
    }

    if errored {
        std::process::exit(1);
    }
    if args.check && any_changed {
        std::process::exit(1);
    }
    Ok(())
}

/// Format a single file. Returns `Ok(true)` if the file's content would
/// change. In `--check` mode prints a unified diff; otherwise writes the
/// result back in place.
fn format_one_file(path: &Path, check: bool) -> Result<bool> {
    let src =
        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
    // `Path::parent` of a bare filename is `Some("")` (empty), which is
    // not a valid cwd to spawn rustfmt in — normalize it to `.`.
    let dir = match path.parent() {
        Some(p) if !p.as_os_str().is_empty() => p,
        _ => Path::new("."),
    };
    let opts = resolve_options(dir);

    let formatted = whisker_fmt::format_source_in_dir(&src, &opts, dir)
        .with_context(|| format!("formatting {}", path.display()))?;

    if formatted == src {
        return Ok(false);
    }

    if check {
        println!("Diff in {}:", path.display());
        print!("{}", whisker_fmt::unified_diff(&src, &formatted));
    } else {
        std::fs::write(path, &formatted).with_context(|| format!("writing {}", path.display()))?;
    }
    Ok(true)
}

/// Build [`FmtOptions`] from the nearest `rustfmt.toml` (searching from
/// `dir` upward). Missing keys keep rustfmt's defaults. The base Rust
/// pass re-reads the same file via rustfmt itself; here we only extract
/// the few layout keys the macro-body printer needs.
fn resolve_options(dir: &Path) -> FmtOptions {
    if let Some(toml_path) = find_rustfmt_toml(dir) {
        if let Ok(text) = std::fs::read_to_string(&toml_path) {
            return FmtOptions::from_rustfmt_config(&text);
        }
    }
    FmtOptions::default()
}

/// Walk upward from `dir` looking for `rustfmt.toml` or `.rustfmt.toml`.
fn find_rustfmt_toml(dir: &Path) -> Option<PathBuf> {
    let mut cur = Some(dir);
    while let Some(d) = cur {
        for name in ["rustfmt.toml", ".rustfmt.toml"] {
            let candidate = d.join(name);
            if candidate.is_file() {
                return Some(candidate);
            }
        }
        cur = d.parent();
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn resolve_options_defaults_without_toml() {
        // A directory with no rustfmt.toml anywhere up the chain falls
        // back to rustfmt defaults. Use a tmp dir to avoid picking up a
        // repo-level config.
        let tmp = std::env::temp_dir().join(format!("whisker-fmt-test-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&tmp);
        let o = resolve_options(&tmp);
        // No rustfmt.toml here, but a parent might have one in some
        // environments — only assert the function returns a valid set.
        assert!(o.tab_spaces >= 1);
        let _ = std::fs::remove_dir_all(&tmp);
    }

    #[test]
    fn resolve_options_reads_local_toml() {
        let tmp = std::env::temp_dir().join(format!(
            "whisker-fmt-test-toml-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        ));
        std::fs::create_dir_all(&tmp).unwrap();
        std::fs::write(tmp.join("rustfmt.toml"), "tab_spaces = 2\nmax_width = 80\n").unwrap();
        let o = resolve_options(&tmp);
        assert_eq!(o.tab_spaces, 2);
        assert_eq!(o.max_width, 80);
        std::fs::remove_dir_all(&tmp).unwrap();
    }
}