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`] from the nearest `rustfmt.toml` (searching from
145/// `dir` upward). Missing keys keep rustfmt's defaults. The base Rust
146/// pass re-reads the same file via rustfmt itself; here we only extract
147/// the few layout keys the macro-body printer needs.
148fn resolve_options(dir: &Path) -> FmtOptions {
149    if let Some(toml_path) = find_rustfmt_toml(dir) {
150        if let Ok(text) = std::fs::read_to_string(&toml_path) {
151            return FmtOptions::from_rustfmt_config(&text);
152        }
153    }
154    FmtOptions::default()
155}
156
157/// Walk upward from `dir` looking for `rustfmt.toml` or `.rustfmt.toml`.
158fn find_rustfmt_toml(dir: &Path) -> Option<PathBuf> {
159    let mut cur = Some(dir);
160    while let Some(d) = cur {
161        for name in ["rustfmt.toml", ".rustfmt.toml"] {
162            let candidate = d.join(name);
163            if candidate.is_file() {
164                return Some(candidate);
165            }
166        }
167        cur = d.parent();
168    }
169    None
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn resolve_options_defaults_without_toml() {
178        // A directory with no rustfmt.toml anywhere up the chain falls
179        // back to rustfmt defaults. Use a tmp dir to avoid picking up a
180        // repo-level config.
181        let tmp = std::env::temp_dir().join(format!("whisker-fmt-test-{}", std::process::id()));
182        let _ = std::fs::create_dir_all(&tmp);
183        let o = resolve_options(&tmp);
184        // No rustfmt.toml here, but a parent might have one in some
185        // environments — only assert the function returns a valid set.
186        assert!(o.tab_spaces >= 1);
187        let _ = std::fs::remove_dir_all(&tmp);
188    }
189
190    #[test]
191    fn resolve_options_reads_local_toml() {
192        let tmp = std::env::temp_dir().join(format!(
193            "whisker-fmt-test-toml-{}-{}",
194            std::process::id(),
195            std::time::SystemTime::now()
196                .duration_since(std::time::UNIX_EPOCH)
197                .unwrap()
198                .as_nanos()
199        ));
200        std::fs::create_dir_all(&tmp).unwrap();
201        std::fs::write(tmp.join("rustfmt.toml"), "tab_spaces = 2\nmax_width = 80\n").unwrap();
202        let o = resolve_options(&tmp);
203        assert_eq!(o.tab_spaces, 2);
204        assert_eq!(o.max_width, 80);
205        std::fs::remove_dir_all(&tmp).unwrap();
206    }
207}