Skip to main content

whisker_cli/
fmt.rs

1//! `whisker fmt` — a rustfmt drop-in that also formats Whisker's
2//! `render!` / `css!` macro bodies.
3//!
4//! Three modes:
5//!
6//! - `whisker fmt <files...>` — format each file in place (or print to
7//!   stdout with `--check`).
8//! - `whisker fmt --stdin` — read stdin, write formatted to stdout.
9//!   This is the rust-analyzer integration point:
10//!   `rust-analyzer.rustfmt.overrideCommand = ["whisker", "fmt", "--stdin"]`.
11//! - `whisker fmt --check <files...>` — don't write; print a unified
12//!   diff and exit non-zero if any file would change.
13//!
14//! There are NO whisker-specific formatting options: the layout values
15//! ([`whisker_fmt::FmtOptions`]) come from the nearest `rustfmt.toml`
16//! (resolved per file directory), and the base Rust pass shells out to
17//! the real rustfmt binary which reads `rustfmt.toml` itself.
18
19use anyhow::{bail, Context, Result};
20use clap::Args;
21use std::io::{Read, Write};
22use std::path::{Path, PathBuf};
23use whisker_fmt::FmtOptions;
24
25#[derive(Args, Debug)]
26pub struct FmtArgs {
27    /// Rust source files to format. Ignored when `--stdin` is set.
28    pub files: Vec<PathBuf>,
29
30    /// Read source from stdin and write the formatted result to stdout.
31    /// For `rust-analyzer.rustfmt.overrideCommand`.
32    #[arg(long)]
33    pub stdin: bool,
34
35    /// Don't write anything. Print a unified diff of what would change
36    /// and exit non-zero if any input is not already formatted.
37    #[arg(long)]
38    pub check: bool,
39}
40
41pub fn run(args: FmtArgs) -> Result<()> {
42    if args.stdin {
43        return run_stdin(&args);
44    }
45    if args.files.is_empty() {
46        bail!("whisker fmt: no input files (pass file paths, or use --stdin)");
47    }
48    run_files(&args)
49}
50
51/// stdin → stdout. `--check` on stdin prints the diff to stderr and
52/// exits non-zero if a change would be made.
53fn run_stdin(args: &FmtArgs) -> Result<()> {
54    let mut src = String::new();
55    std::io::stdin()
56        .read_to_string(&mut src)
57        .context("reading source from stdin")?;
58    // For stdin we resolve rustfmt.toml from the current directory.
59    let opts = resolve_options(&std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
60    let formatted = whisker_fmt::format_source_in_dir(
61        &src,
62        &opts,
63        &std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
64    )
65    .context("formatting stdin")?;
66
67    if args.check {
68        if formatted != src {
69            eprint!("{}", whisker_fmt::unified_diff(&src, &formatted));
70            std::process::exit(1);
71        }
72        return Ok(());
73    }
74
75    let mut stdout = std::io::stdout().lock();
76    stdout
77        .write_all(formatted.as_bytes())
78        .context("writing formatted output to stdout")?;
79    Ok(())
80}
81
82fn run_files(args: &FmtArgs) -> Result<()> {
83    let mut any_changed = false;
84    let mut errored = false;
85
86    for file in &args.files {
87        match format_one_file(file, args.check) {
88            Ok(changed) => {
89                if changed {
90                    any_changed = true;
91                    if args.check {
92                        // Diff already printed by format_one_file.
93                    } else {
94                        eprintln!("formatted {}", file.display());
95                    }
96                }
97            }
98            Err(e) => {
99                errored = true;
100                eprintln!("error: {}: {e:#}", file.display());
101            }
102        }
103    }
104
105    if errored {
106        std::process::exit(1);
107    }
108    if args.check && any_changed {
109        std::process::exit(1);
110    }
111    Ok(())
112}
113
114/// Format a single file. Returns `Ok(true)` if the file's content would
115/// change. In `--check` mode prints a unified diff; otherwise writes the
116/// result back in place.
117fn format_one_file(path: &Path, check: bool) -> Result<bool> {
118    let src =
119        std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
120    // `Path::parent` of a bare filename is `Some("")` (empty), which is
121    // not a valid cwd to spawn rustfmt in — normalize it to `.`.
122    let dir = match path.parent() {
123        Some(p) if !p.as_os_str().is_empty() => p,
124        _ => Path::new("."),
125    };
126    let opts = resolve_options(dir);
127
128    let formatted = whisker_fmt::format_source_in_dir(&src, &opts, dir)
129        .with_context(|| format!("formatting {}", path.display()))?;
130
131    if formatted == src {
132        return Ok(false);
133    }
134
135    if check {
136        println!("Diff in {}:", path.display());
137        print!("{}", whisker_fmt::unified_diff(&src, &formatted));
138    } else {
139        std::fs::write(path, &formatted).with_context(|| format!("writing {}", path.display()))?;
140    }
141    Ok(true)
142}
143
144/// Build [`FmtOptions`] for `dir`, delegating to the whisker-fmt library
145/// resolver so file-arg and `--stdin` paths both get the full edition
146/// resolution: nearest `rustfmt.toml` `edition` → nearest `Cargo.toml`
147/// edition (`[package]` / `[workspace.package]`) → `"2021"` default. The
148/// base Rust pass re-reads `rustfmt.toml` via rustfmt itself; here we
149/// supply the layout keys the macro-body printer needs plus the resolved
150/// `--edition` (so 2018+ syntax like `async move` doesn't hit rustfmt's
151/// 2015 default).
152fn resolve_options(dir: &Path) -> FmtOptions {
153    whisker_fmt::resolve_options(dir)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn resolve_options_defaults_without_toml() {
162        // A directory with no rustfmt.toml anywhere up the chain falls
163        // back to rustfmt defaults. Use a tmp dir to avoid picking up a
164        // repo-level config.
165        let tmp = std::env::temp_dir().join(format!("whisker-fmt-test-{}", std::process::id()));
166        let _ = std::fs::create_dir_all(&tmp);
167        let o = resolve_options(&tmp);
168        // No rustfmt.toml here, but a parent might have one in some
169        // environments — only assert the function returns a valid set.
170        assert!(o.tab_spaces >= 1);
171        let _ = std::fs::remove_dir_all(&tmp);
172    }
173
174    #[test]
175    fn resolve_options_reads_local_toml() {
176        let tmp = std::env::temp_dir().join(format!(
177            "whisker-fmt-test-toml-{}-{}",
178            std::process::id(),
179            std::time::SystemTime::now()
180                .duration_since(std::time::UNIX_EPOCH)
181                .unwrap()
182                .as_nanos()
183        ));
184        std::fs::create_dir_all(&tmp).unwrap();
185        std::fs::write(tmp.join("rustfmt.toml"), "tab_spaces = 2\nmax_width = 80\n").unwrap();
186        let o = resolve_options(&tmp);
187        assert_eq!(o.tab_spaces, 2);
188        assert_eq!(o.max_width, 80);
189        std::fs::remove_dir_all(&tmp).unwrap();
190    }
191}