use textwrap::Options;
use textwrap::WordSplitter;
use crate::markdown::unwrap::classify;
use crate::markdown::unwrap::fence_marker;
use crate::markdown::unwrap::unwrap_markdown;
pub(crate) fn wrap_markdown(text: &str, width: usize) -> String {
let joined = unwrap_markdown(text);
let mut in_fence: Option<String> = None;
let mut out: Vec<String> = Vec::new();
let mut prev_blocks = true;
for line in joined.lines() {
if let Some(marker) = &in_fence {
out.push(line.to_string());
if let Some(m) = fence_marker(line)
&& m == *marker
{
in_fence = None;
prev_blocks = true;
}
continue;
}
if let Some(marker) = fence_marker(line) {
in_fence = Some(marker);
out.push(line.to_string());
prev_blocks = true;
continue;
}
match classify(line, prev_blocks) {
Some(true) => {
out.push(line.to_string());
prev_blocks = true;
}
Some(false) => {
let indent = " ".repeat(list_continuation_indent(line));
out.push(fold(line, width, &indent));
prev_blocks = false;
}
None => {
let indent = " ".repeat(leading_spaces(line));
out.push(fold(line, width, &indent));
prev_blocks = false;
}
}
}
out.join("\n")
}
fn fold(line: &str, width: usize, subsequent_indent: &str) -> String {
let leading = " ".repeat(leading_spaces(line));
let body = line.trim_start();
let options = Options::new(width)
.initial_indent(&leading)
.subsequent_indent(subsequent_indent)
.word_splitter(WordSplitter::NoHyphenation);
textwrap::fill(body, options)
}
fn leading_spaces(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn list_continuation_indent(line: &str) -> usize {
let leading = leading_spaces(line);
let trimmed = line.trim_start();
let marker =
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
2
} else {
let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
digits + 2
};
leading + marker
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = include_str!("test_fixtures/render_sample.md");
#[test]
fn render_sample_width_80() {
insta::assert_snapshot!(wrap_markdown(SAMPLE, 80));
}
#[test]
fn render_sample_width_40() {
insta::assert_snapshot!(wrap_markdown(SAMPLE, 40));
}
#[test]
fn joins_semantic_line_breaks() {
let input = "One clause.\nAnother clause.\nA third.";
assert_eq!(
wrap_markdown(input, 80),
"One clause. Another clause. A third."
);
}
#[test]
fn folds_long_prose() {
let input = "alpha bravo charlie delta echo foxtrot";
assert_eq!(
wrap_markdown(input, 20),
"alpha bravo charlie\ndelta echo foxtrot"
);
}
#[test]
fn fence_content_is_verbatim() {
let input = "```\nstakk submit --keep base --new-auto wmtk --dry-run\n```";
assert_eq!(wrap_markdown(input, 20), input);
}
#[test]
fn table_rows_are_verbatim() {
let input = "| Flag | Meaning that is quite long indeed \
|\n|------|-----------------------------------|";
assert_eq!(wrap_markdown(input, 20), input);
}
#[test]
fn indented_code_block_is_verbatim() {
let input = "Intro paragraph.\n\n stakk show --format=json | jq '.stacks[0]'";
assert_eq!(wrap_markdown(input, 20), input);
}
#[test]
fn flags_are_never_split_at_a_hyphen() {
let wrapped = wrap_markdown("Pass the --stack-placement flag now", 20);
assert!(
wrapped.contains("--stack-placement"),
"flag was split: {wrapped}"
);
}
#[test]
fn heading_is_verbatim() {
let input = "## A heading long enough to exceed the width";
assert_eq!(wrap_markdown(input, 20), input);
}
#[test]
fn list_continuations_hang_under_the_text() {
let input = "- alpha bravo charlie delta";
assert_eq!(wrap_markdown(input, 16), "- alpha bravo\n charlie delta");
}
#[test]
fn ordered_list_continuations_hang_under_the_text() {
let input = "10. alpha bravo charlie delta";
assert_eq!(
wrap_markdown(input, 18),
"10. alpha bravo\n charlie delta"
);
}
#[test]
fn nested_list_keeps_its_indent() {
let input = " - alpha bravo charlie delta";
assert_eq!(
wrap_markdown(input, 18),
" - alpha bravo\n charlie delta"
);
}
}