topcoat-cli 0.10.0

A modular, batteries-included Rust web framework for server-rendered apps.
Documentation
#![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)]
    /// If specified, reads the standard input and formats to standard output.
    stdin: bool,

    /// Check formatting without writing files or emitting formatted source.
    #[arg(long)]
    check: bool,

    /// Run rustfmt before formatting macro bodies. Requires rustfmt on PATH.
    #[arg(long)]
    rustfmt: bool,

    /// Comma-separated list of macro names to register and format.
    ///
    /// Defaults to all supported macros. Only macro invocations whose name
    /// appears in this list will have their bodies formatted.
    #[arg(long, value_delimiter = ',')]
    macros: Option<Vec<String>>,

    files: Vec<String>,
}

impl FmtCommand {
    pub async fn run(&self) {
        let registry = {
            // The full set of macros `topcoat fmt` knows how to format.
            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, &registry).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, &registry, 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");
        // Drain stdout while writing stdin so large sources cannot fill both pipes.
        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)?)
    }
}