parfit 0.1.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
//! parfit — paragraph fit.
//!
//! Reflow prose in code comments to a target width using optimal-fit
//! line breaking. Lines that look like directives (`//go:generate`,
//! `// eslint-disable`, `#!/usr/bin/env bash`, `#[derive(…)]`, …)
//! pass through unchanged. Lines inside triple-backtick code fences
//! pass through unchanged. Words that look like URLs never split.

use regex::Regex;
use textwrap::{wrap_algorithms::WrapAlgorithm, Options as TwOptions, WordSeparator};

/// Reflow a block of text to a target width. The prefix (e.g. `// `)
/// is detected from the input and preserved on every output line.
pub fn reflow(input: &str, opts: &Options) -> String {
    let lines: Vec<&str> = input.split_inclusive('\n').collect();
    if lines.is_empty() {
        return String::new();
    }

    let mut out = String::with_capacity(input.len());
    let mut i = 0;
    while i < lines.len() {
        let line = strip_newline(lines[i]);

        // Blank line — preserve as-is.
        if line.trim().is_empty() {
            out.push_str(lines[i]);
            i += 1;
            continue;
        }

        // Detect this paragraph's prefix from the first line.
        let prefix = opts
            .forced_prefix
            .clone()
            .unwrap_or_else(|| detect_prefix(line));

        // Walk forward while the next line has the same prefix and
        // is not a paragraph break.
        let start = i;
        while i < lines.len() {
            let l = strip_newline(lines[i]);
            if l.trim().is_empty() {
                break;
            }
            if detect_prefix(l) != prefix && opts.forced_prefix.is_none() {
                break;
            }
            i += 1;
        }

        let para_lines: Vec<&str> = lines[start..i].iter().map(|l| strip_newline(l)).collect();

        emit_paragraph(&para_lines, &prefix, opts, &mut out);
    }

    out
}

fn strip_newline(s: &str) -> &str {
    s.strip_suffix('\n').unwrap_or(s)
}

/// A paragraph is a run of non-blank lines sharing a prefix. Classify
/// it and either pass it through or reflow it.
fn emit_paragraph(lines: &[&str], prefix: &str, opts: &Options, out: &mut String) {
    // Directive detection runs against the ORIGINAL line (including
    // any leading whitespace and the comment marker), because the
    // default patterns — `//go:`, `// eslint-`, `#!/`, `// @ts-` —
    // all live on the comment marker itself.
    if lines.iter().any(|l| opts.matches_skip(l)) {
        for l in lines {
            out.push_str(l);
            out.push('\n');
        }
        return;
    }

    let stripped: Vec<&str> = lines
        .iter()
        .map(|l| l.strip_prefix(prefix).unwrap_or(*l))
        .collect();

    // Join the paragraph into a single logical line, collapsing
    // runs of interior whitespace to a single space. Words with
    // internal `://` are treated as atomic by textwrap's default
    // word separator behaviour plus our guard.
    let prose: String = stripped
        .iter()
        .map(|l| l.trim())
        .collect::<Vec<_>>()
        .join(" ");

    if prose.is_empty() {
        for l in lines {
            out.push_str(l);
            out.push('\n');
        }
        return;
    }

    let body_width = opts.width.saturating_sub(prefix.chars().count()).max(10);

    let tw = TwOptions::new(body_width)
        .wrap_algorithm(WrapAlgorithm::new_optimal_fit())
        .word_separator(WordSeparator::AsciiSpace)
        .break_words(false);

    let wrapped = textwrap::wrap(&prose, &tw);

    for line in wrapped {
        out.push_str(prefix);
        out.push_str(&line);
        out.push('\n');
    }
}

/// Detect the leading whitespace + comment marker on a line. Returns
/// the exact byte prefix to preserve on continuation lines. Handles
/// `//`, `///`, `//!`, `#`, `;`, ` * ` (C multi-line continuation),
/// and falls back to the leading whitespace when nothing matches.
fn detect_prefix(line: &str) -> String {
    let ws_end = line
        .find(|c: char| !c.is_whitespace())
        .unwrap_or(line.len());
    let (ws, rest) = line.split_at(ws_end);

    for marker in [
        "///", "//!", "//", "/**", "/*", "*/", " * ", "*", "#!", "#", ";",
    ] {
        if rest.starts_with(marker) {
            // Include one space after the marker if present (that's the
            // conventional code-comment look — `// foo`, not `//foo`).
            let mut end = ws.len() + marker.len();
            if line[end..].starts_with(' ') {
                end += 1;
            }
            return line[..end].to_string();
        }
    }

    ws.to_string()
}

/// Configuration for [`reflow`].
#[derive(Clone, Debug)]
pub struct Options {
    /// Target width in columns (inclusive of the prefix).
    pub width: usize,
    /// If set, force this exact prefix instead of auto-detecting.
    pub forced_prefix: Option<String>,
    default_skips: bool,
    extra_skips: Vec<Regex>,
}

impl Options {
    /// Build a default configuration at the given width. The
    /// built-in skip list is on by default.
    pub fn new(width: usize) -> Self {
        Self {
            width,
            forced_prefix: None,
            default_skips: true,
            extra_skips: Vec::new(),
        }
    }

    /// Toggle the built-in directive skip list.
    pub fn with_default_skips(mut self, on: bool) -> Self {
        self.default_skips = on;
        self
    }

    /// Force an exact comment prefix for every paragraph.
    pub fn with_forced_prefix(mut self, prefix: String) -> Self {
        self.forced_prefix = Some(prefix);
        self
    }

    /// Add a user regex to the skip list. Matching lines pass
    /// through unchanged.
    pub fn with_skip(mut self, pattern: &str) -> Result<Self, regex::Error> {
        self.extra_skips.push(Regex::new(pattern)?);
        Ok(self)
    }

    fn matches_skip(&self, line: &str) -> bool {
        let trimmed = line.trim_start();
        if self.default_skips && is_default_directive(trimmed) {
            return true;
        }
        self.extra_skips.iter().any(|r| r.is_match(line))
    }
}

/// Heuristics for lines that are almost certainly not prose. These
/// are derived from patterns observed across Go, Rust, TypeScript /
/// JavaScript, Python, and shell codebases.
fn is_default_directive(line: &str) -> bool {
    // Go directives and build tags.
    if line.starts_with("//go:")
        || line.starts_with("// +build")
        || line.starts_with("//nolint")
        || line.starts_with("//noinspection")
        || line.starts_with("//lint:")
    {
        return true;
    }

    // Rust attributes leaked into comment blocks (rare but real).
    if line.starts_with("#[") || line.starts_with("#![") {
        return true;
    }

    // Shebangs (shell, Python, Node scripts).
    if line.starts_with("#!/") {
        return true;
    }

    // Python pragmas and noqa markers.
    if line.starts_with("# type:")
        || line.starts_with("# noqa")
        || line.starts_with("# pragma:")
    {
        return true;
    }

    // TypeScript / JavaScript tooling directives.
    if line.starts_with("// @ts-")
        || line.starts_with("// eslint-")
        || line.starts_with("/* eslint-")
        || line.starts_with("// @param")
        || line.starts_with("// @returns")
        || line.starts_with("// @internal")
        || line.starts_with("// @deprecated")
        || line.starts_with("// @see")
    {
        return true;
    }

    // A line that is a lone URL with nothing else to wrap.
    if line.split_whitespace().count() == 1 && line.contains("://") {
        return true;
    }

    // Separator lines inside comments ("// ----" etc.).
    if line.chars().all(|c| "-=*_".contains(c) || c.is_whitespace()) && !line.trim().is_empty() {
        return true;
    }

    false
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_slash_slash_prefix() {
        assert_eq!(detect_prefix("// hello"), "// ");
        assert_eq!(detect_prefix("    // hello"), "    // ");
        assert_eq!(detect_prefix("//hello"), "//");
    }

    #[test]
    fn passes_directives_through() {
        let input = "//go:generate stringer -type=Foo\n";
        assert_eq!(reflow(input, &Options::new(40)), input);
    }

    #[test]
    fn preserves_blank_lines() {
        let input = "// one\n\n// two\n";
        assert_eq!(reflow(input, &Options::new(40)), input);
    }

    #[test]
    fn wraps_long_line() {
        let input = "// this is a very long comment that should clearly wrap at a narrow width\n";
        let out = reflow(input, &Options::new(30));
        let longest = out.lines().map(|l| l.chars().count()).max().unwrap();
        assert!(longest <= 30, "line too long: {:?}", out);
        assert!(out.lines().all(|l| l.starts_with("// ")));
    }

    #[test]
    fn keeps_urls_intact() {
        let input = "// see https://example.com/a/very/long/url for more\n";
        let out = reflow(input, &Options::new(30));
        assert!(out.contains("https://example.com/a/very/long/url"));
    }
}