parfit 0.2.0

Paragraph fit — a codebase-aware comment reflow tool that wraps prose with optimal-fit line breaking and leaves directives alone. Inspired by par.
Documentation
use std::fs;
use std::io::{self, Read, Write};
use std::path::PathBuf;

use clap::Parser;

use parfit::{reflow, Options};

mod walker;

/// Paragraph fit — a codebase-aware comment reflow tool.
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Cli {
    /// Files or directories to reflow. When omitted, parfit reads
    /// standard input and writes standard output.
    #[arg(value_name = "PATH")]
    paths: Vec<PathBuf>,

    /// Target width in columns.
    #[arg(short = 'w', long, default_value_t = 68)]
    width: usize,

    /// Walk directories recursively. Required when any path
    /// argument is a directory.
    #[arg(short = 'r', long)]
    recursive: bool,

    /// Rewrite files in place instead of emitting to stdout.
    /// Required when more than one file will be processed.
    #[arg(short = 'i', long = "in-place")]
    in_place: bool,

    /// Glob of file names to include. Repeatable. Applies only
    /// when path arguments are given.
    #[arg(long = "include", value_name = "GLOB")]
    includes: Vec<String>,

    /// Glob of file names to exclude. Repeatable.
    #[arg(long = "exclude", value_name = "GLOB")]
    excludes: Vec<String>,

    /// Custom regex whose matching lines pass through verbatim.
    /// Repeatable.
    #[arg(short = 's', long = "skip", value_name = "REGEX")]
    skips: Vec<String>,

    /// Turn off the built-in directive skip list.
    #[arg(long)]
    no_default_skips: bool,

    /// Force a specific comment prefix instead of auto-detecting.
    #[arg(short = 'p', long, value_name = "STRING")]
    prefix: Option<String>,
}

fn main() -> io::Result<()> {
    let cli = Cli::parse();
    let opts = build_options(&cli)?;

    if cli.paths.is_empty() {
        return run_stdin(&opts);
    }
    run_paths(&cli, &opts)
}

fn build_options(cli: &Cli) -> io::Result<Options> {
    let mut opts = Options::new(cli.width);
    if cli.no_default_skips {
        opts = opts.with_default_skips(false);
    }
    for pattern in &cli.skips {
        opts = opts
            .with_skip(pattern)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e.to_string()))?;
    }
    if let Some(prefix) = &cli.prefix {
        opts = opts.with_forced_prefix(prefix.clone());
    }
    Ok(opts)
}

fn run_stdin(opts: &Options) -> io::Result<()> {
    let mut input = String::new();
    io::stdin().read_to_string(&mut input)?;
    io::stdout().write_all(reflow(&input, opts).as_bytes())
}

fn run_paths(cli: &Cli, opts: &Options) -> io::Result<()> {
    let files = walker::walk(&cli.paths, cli.recursive, &cli.includes, &cli.excludes)?;

    if files.is_empty() {
        return Ok(());
    }

    if files.len() > 1 && !cli.in_place {
        return Err(io::Error::new(
            io::ErrorKind::InvalidInput,
            "multiple files would be processed; pass --in-place to rewrite them \
             (or narrow the input to a single file for stdout)",
        ));
    }

    for path in &files {
        let content = fs::read_to_string(path)?;
        let wrapped = reflow(&content, opts);
        if cli.in_place {
            fs::write(path, wrapped)?;
        } else {
            io::stdout().write_all(wrapped.as_bytes())?;
        }
    }
    Ok(())
}