use regex::Regex;
use textwrap::{wrap_algorithms::WrapAlgorithm, Options as TwOptions, WordSeparator};
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]);
if line.trim().is_empty() {
out.push_str(lines[i]);
i += 1;
continue;
}
let prefix = opts
.forced_prefix
.clone()
.unwrap_or_else(|| detect_prefix(line));
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(¶_lines, &prefix, opts, &mut out);
}
out
}
fn strip_newline(s: &str) -> &str {
s.strip_suffix('\n').unwrap_or(s)
}
fn emit_paragraph(lines: &[&str], prefix: &str, opts: &Options, out: &mut String) {
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();
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');
}
}
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) {
let mut end = ws.len() + marker.len();
if line[end..].starts_with(' ') {
end += 1;
}
return line[..end].to_string();
}
}
ws.to_string()
}
#[derive(Clone, Debug)]
pub struct Options {
pub width: usize,
pub forced_prefix: Option<String>,
default_skips: bool,
extra_skips: Vec<Regex>,
}
impl Options {
pub fn new(width: usize) -> Self {
Self {
width,
forced_prefix: None,
default_skips: true,
extra_skips: Vec::new(),
}
}
pub fn with_default_skips(mut self, on: bool) -> Self {
self.default_skips = on;
self
}
pub fn with_forced_prefix(mut self, prefix: String) -> Self {
self.forced_prefix = Some(prefix);
self
}
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))
}
}
fn is_default_directive(line: &str) -> bool {
if line.starts_with("//go:")
|| line.starts_with("// +build")
|| line.starts_with("//nolint")
|| line.starts_with("//noinspection")
|| line.starts_with("//lint:")
{
return true;
}
if line.starts_with("#[") || line.starts_with("#![") {
return true;
}
if line.starts_with("#!/") {
return true;
}
if line.starts_with("# type:")
|| line.starts_with("# noqa")
|| line.starts_with("# pragma:")
{
return true;
}
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;
}
if line.split_whitespace().count() == 1 && line.contains("://") {
return true;
}
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"));
}
}