#![doc = include_str!("../docs/fmt.md")]
mod error;
use std::{collections::BTreeSet, io::Read, path::Path, process::Stdio, time::Instant};
use clap::Args;
use console::style;
use tokio::{io::AsyncWriteExt, process::Command};
use topcoat_core_grammar::pretty::{Registry, pretty_print_str};
use crate::fmt::error::Error;
#[derive(Args)]
#[command(version, about = "Format the content of view macro invocations in Rust source files.", long_about = None)]
pub struct FmtCommand {
#[arg(long)]
stdin: bool,
#[arg(long)]
check: bool,
#[arg(long)]
rustfmt: bool,
#[arg(long, value_delimiter = ',')]
macros: Option<Vec<String>>,
files: Vec<String>,
}
impl FmtCommand {
pub async fn run(&self) {
let registry = {
const ALL_MACROS: &[&str] = &[
"view",
"attributes",
"class",
"live",
"emit",
"font_face",
"font",
"fontsource_font_face",
"fontsource_font",
"mail",
];
let mut registry = Registry::new();
let selected: BTreeSet<&str> = match &self.macros {
Some(names) => names.iter().map(String::as_str).collect(),
None => ALL_MACROS.iter().copied().collect(),
};
for name in &selected {
if !ALL_MACROS.contains(name) {
eprintln!(
"{}",
style(format!(
"unknown macro '{name}'; supported macros are: {}",
ALL_MACROS.join(", ")
))
.red()
);
std::process::exit(1);
}
}
if selected.contains("view") {
registry.register_macro::<topcoat_view_grammar::view::View>("view");
}
if selected.contains("attributes") {
registry
.register_macro::<topcoat_view_grammar::attributes::Attributes>("attributes");
}
if selected.contains("class") {
registry.register_macro::<topcoat_view_grammar::class::Class>("class");
}
if selected.contains("live") {
registry.register_macro::<topcoat_view_grammar::live::Live>("live");
}
if selected.contains("emit") {
registry.register_macro::<topcoat_view_grammar::live::Emit>("emit");
}
if selected.contains("font_face") {
registry.register_macro::<topcoat_font_grammar::font_face::FontFace>("font_face");
}
if selected.contains("font") {
registry.register_macro::<topcoat_font_grammar::font::Font>("font");
}
if selected.contains("fontsource_font_face") {
registry
.register_macro::<topcoat_font_grammar::fontsource::font_face::FontsourceFontFace>(
"fontsource_font_face",
);
}
if selected.contains("fontsource_font") {
registry.register_macro::<topcoat_font_grammar::fontsource::font::FontsourceFont>(
"fontsource_font",
);
}
if selected.contains("mail") {
registry.register_macro::<topcoat_mail_grammar::mail::Mail>("mail");
}
registry
};
let start = Instant::now();
let result: Result<(), Error> = async {
let mut files = BTreeSet::new();
let patterns: Vec<&str> = if self.files.is_empty() && !self.stdin {
vec!["**/*.rs"]
} else {
self.files.iter().map(String::as_str).collect()
};
for pattern in patterns {
for entry in glob::glob(pattern)? {
let entry = entry?;
if entry.is_dir() {
let dir = entry
.to_str()
.expect("directory does not have a UTF-8 compatible name");
for entry in glob::glob(&format!("{dir}/**/*.rs"))? {
files.insert(entry?);
}
} else {
files.insert(entry);
}
}
}
let mut count = 0;
let mut modified = 0;
let mut failed = 0;
for file in &files {
match self.format_file(file, ®istry).await {
Ok(true) => {
count += 1;
modified += 1;
if self.check {
eprintln!("{}: needs formatting", file.display());
}
}
Ok(false) => {
count += 1;
}
Err(error) => {
failed += 1;
eprintln!("{}", style(format!("{}: {error}", file.display())).red());
}
}
}
if self.stdin {
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
let output = self.format_source(&buf, ®istry, None).await?;
if self.check {
count += 1;
if output != buf {
modified += 1;
eprintln!("stdin: needs formatting");
}
} else {
print!("{output}");
return Ok(());
}
}
if self.check {
let summary = format!(
"checked {count} inputs ({modified} need formatting), {failed} failed in {:.0?}",
start.elapsed()
);
if modified > 0 || failed > 0 {
eprintln!("{}", style(summary).red());
std::process::exit(1);
}
eprintln!("{}", style(summary).green());
} else if failed > 0 {
eprintln!(
"{}",
style(format!(
"formatted {count} files ({modified} modified), {failed} failed in {:.0?}",
start.elapsed()
))
.red()
);
std::process::exit(1);
} else {
eprintln!(
"{}",
style(format!(
"successfully formatted {count} files ({modified} modified) in {:.0?}",
start.elapsed()
))
.green()
);
}
Ok(())
}
.await;
match result {
Ok(()) => {}
Err(error) => {
eprintln!("{}", style(error).red());
std::process::exit(1);
}
}
}
async fn format_file(&self, path: &Path, registry: &Registry) -> Result<bool, Error> {
let input = std::fs::read_to_string(path)?;
let output = self.format_source(&input, registry, path.parent()).await?;
if output == input {
Ok(false)
} else {
if !self.check {
std::fs::write(path, output)?;
}
Ok(true)
}
}
async fn format_source(
&self,
input: &str,
registry: &Registry,
directory: Option<&Path>,
) -> Result<String, Error> {
if !self.rustfmt {
return Ok(pretty_print_str(registry, input)?);
}
let mut command = Command::new("rustfmt");
command
.args(["--emit", "stdout"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.kill_on_drop(true);
if let Some(directory) = directory.filter(|path| !path.as_os_str().is_empty()) {
command.current_dir(directory);
}
let mut child = command.spawn().map_err(Error::RustfmtIo)?;
let mut stdin = child.stdin.take().expect("rustfmt stdin is piped");
let (write_result, output) = tokio::join!(
async move { stdin.write_all(input.as_bytes()).await },
child.wait_with_output(),
);
let output = output.map_err(Error::RustfmtIo)?;
if !output.status.success() {
return Err(Error::RustfmtFailed(output.status));
}
write_result.map_err(Error::RustfmtIo)?;
let source = String::from_utf8(output.stdout).map_err(|error| {
Error::RustfmtIo(std::io::Error::new(std::io::ErrorKind::InvalidData, error))
})?;
Ok(pretty_print_str(registry, &source)?)
}
}