schemat 0.5.0

A code formatter for Scheme, Lisp, and any S-expressions
#![doc = include_str!("../README.md")]

extern crate alloc;

mod ast;
mod context;
mod error;
mod file;
mod format;
mod parse;
mod position;
mod position_map;

use self::file::read_paths;
use crate::{
    file::display_path,
    format::format,
    parse::{ParseError, parse, parse_comments, parse_hash_directives},
    position_map::PositionMap,
};
use bumpalo::Bump;
use clap::Parser;
use colored::Colorize;
use core::error::Error;
use error::ApplicationError;
use futures::future::try_join_all;
use std::{env::current_dir, path::Path, process::ExitCode};
use tokio::{
    fs::{read_to_string, write},
    io::{AsyncReadExt, AsyncWriteExt, stdin, stdout},
    spawn,
};

#[derive(clap::Parser)]
#[command(about, version)]
struct Arguments {
    /// A glob pattern of files.
    #[arg()]
    path: Vec<String>,
    /// Check if files are formatted correctly.
    #[arg(short, long)]
    check: bool,
    /// Ignore a glob pattern of files and directories.
    #[arg(short, long)]
    ignore: Vec<String>,
    /// Be verbose.
    #[arg(short, long)]
    verbose: bool,
}

#[tokio::main]
async fn main() -> ExitCode {
    if let Err(error) = run(Arguments::parse()).await {
        eprintln!("{error}");
        ExitCode::FAILURE
    } else {
        ExitCode::SUCCESS
    }
}

async fn run(
    Arguments {
        path,
        check,
        ignore,
        verbose,
        ..
    }: Arguments,
) -> Result<(), Box<dyn Error>> {
    if path.is_empty() && check {
        Err("cannot check stdin".into())
    } else if path.is_empty() {
        format_stdin().await
    } else if check {
        check_paths(&path, &ignore, verbose).await
    } else {
        format_paths(&path, &ignore, verbose).await
    }
}

async fn check_paths(
    paths: &[String],
    ignore_patterns: &[String],
    verbose: bool,
) -> Result<(), Box<dyn Error>> {
    let directory = current_dir()?;
    let mut count = 0;
    let mut error_count = 0;

    for (result, path) in try_join_all(
        read_paths(&directory, paths, ignore_patterns)?
            .map(|path| spawn(async { (check_path(&path).await, path) })),
    )
    .await?
    {
        count += 1;

        match result {
            Ok(success) => {
                if !success {
                    eprintln!("{}\t{}", "FAIL".yellow(), display_path(&path, &directory));
                    error_count += 1;
                } else if verbose {
                    eprintln!("{}\t{}", "OK".green(), display_path(&path, &directory));
                }
            }
            Err(error) => {
                eprintln!(
                    "{}\t{}\t{}",
                    "ERROR".red(),
                    display_path(&path, &directory),
                    error
                );
                error_count += 1;
            }
        }
    }

    if error_count == 0 {
        Ok(())
    } else {
        Err(format!("{error_count} / {count} file(s) failed").into())
    }
}

async fn format_paths(
    paths: &[String],
    ignore_patterns: &[String],
    verbose: bool,
) -> Result<(), Box<dyn Error>> {
    let directory = current_dir()?;
    let mut count = 0;
    let mut error_count = 0;

    for (result, path) in try_join_all(
        read_paths(&directory, paths, ignore_patterns)?
            .map(|path| spawn(async { (format_path(&path).await, path) })),
    )
    .await?
    {
        count += 1;

        match result {
            Ok(_) => {
                if verbose {
                    eprintln!("{}\t{}", "FORMAT".blue(), display_path(&path, &directory));
                }
            }
            Err(error) => {
                eprintln!(
                    "{}\t{}\t{}",
                    "ERROR".red(),
                    display_path(&path, &directory),
                    error
                );
                error_count += 1;
            }
        }
    }

    if error_count == 0 {
        Ok(())
    } else {
        Err(format!("{error_count} / {count} file(s) failed to format").into())
    }
}

async fn format_stdin() -> Result<(), Box<dyn Error>> {
    let mut source = Default::default();
    stdin().read_to_string(&mut source).await?;
    let position_map = PositionMap::new(&source);
    let convert_error = |error| convert_parse_error(error, &source, &position_map);
    let allocator = Bump::new();

    let mut stdout = stdout();
    stdout
        .write_all(
            format(
                &parse(&source, &allocator).map_err(convert_error)?,
                &parse_comments(&source, &allocator).map_err(convert_error)?,
                &parse_hash_directives(&source, &allocator).map_err(convert_error)?,
                &position_map,
                &allocator,
            )?
            .as_bytes(),
        )
        .await?;
    stdout.flush().await?;

    Ok(())
}

async fn check_path(path: &Path) -> Result<bool, ApplicationError> {
    let source = read_to_string(path).await?;

    Ok(source == format_string(&source)?)
}

async fn format_path(path: &Path) -> Result<(), ApplicationError> {
    let source = read_to_string(path).await?;
    let formatted = format_string(&source)?;

    // Skip write to a file to improve performance and reduce workload to a file
    // system if the file is formatted already.
    if source != formatted {
        write(path, formatted).await?;
    }

    Ok(())
}

fn format_string(source: &str) -> Result<String, ApplicationError> {
    let position_map = PositionMap::new(source);
    let convert_error = |error: ParseError| convert_parse_error(error, source, &position_map);
    let allocator = Bump::new();

    let source = format(
        &parse(source, &allocator).map_err(convert_error)?,
        &parse_comments(source, &allocator).map_err(convert_error)?,
        &parse_hash_directives(source, &allocator).map_err(convert_error)?,
        &position_map,
        &allocator,
    )?;

    Ok(source)
}

fn convert_parse_error(
    error: ParseError,
    source: &str,
    position_map: &PositionMap,
) -> ApplicationError {
    ApplicationError::Parse(error.to_string(source, position_map))
}