use crate::config::schema::MarkdownConfig;
use crate::render::markdown::block::{classify, BlockKind, Carry, Marker};
use crate::render::markdown::inline;
pub fn render(text: &str, measure: u16, justify: bool) -> String {
let md = MarkdownConfig::default();
let mut out: Vec<String> = Vec::new();
let mut carry = Carry::None;
let mut in_fence = false;
for (i, line) in text.lines().enumerate() {
let (kind, next) = classify(line, &carry, i == 0);
carry = next;
match &kind {
BlockKind::FenceOpen(_) => {
in_fence = true;
continue;
}
BlockKind::FenceClose => {
in_fence = false;
continue;
}
BlockKind::FenceBody { .. } => {
out.push(format!(" {line}"));
continue;
}
BlockKind::FrontMatter => continue,
_ => {}
}
if in_fence {
out.push(format!(" {line}"));
continue;
}
match &kind {
BlockKind::Heading(level) => {
let title = strip_inline(line.trim_start().trim_start_matches('#').trim(), &md);
out.push(title.clone());
let rule = match level {
1 => Some('='),
2 => Some('-'),
_ => None,
};
if let Some(c) = rule {
out.push(c.to_string().repeat(title.chars().count().max(1)));
}
}
BlockKind::Rule => out.push("-".repeat(40)),
BlockKind::Quote(depth) => {
let body = line.trim_start().trim_start_matches(['>', ' ']);
let prefix = "> ".repeat((*depth as usize).max(1));
out.push(format!("{prefix}{}", strip_inline(body, &md)));
}
BlockKind::ListItem { marker, checked, depth } => {
let indent = " ".repeat(*depth as usize);
let body = list_body(line);
let bullet = match (marker, checked) {
(_, Some(true)) => "[x] ".to_string(),
(_, Some(false)) => "[ ] ".to_string(),
(Marker::Ordered, None) => format!("{} ", ordinal(line)),
(_, None) => "- ".to_string(),
};
out.push(format!("{indent}{bullet}{}", strip_inline(body, &md)));
}
BlockKind::Paragraph if justify => {
push_justified(&mut out, &strip_inline(line, &md), measure)
}
_ => out.push(strip_inline(line, &md)),
}
}
let mut joined = out.join("\n");
if !joined.ends_with('\n') {
joined.push('\n');
}
joined
}
fn list_body(line: &str) -> &str {
let t = line.trim_start();
let past_marker = match t.find(' ') {
Some(i) => &t[i + 1..],
None => "",
};
let t = past_marker.trim_start();
if (t.starts_with("[ ]") || t.starts_with("[x]") || t.starts_with("[X]")) && t.len() >= 3 {
t[3..].trim_start()
} else {
t
}
}
fn ordinal(line: &str) -> String {
let t = line.trim_start();
let digits: String = t.chars().take_while(|c| c.is_ascii_digit()).collect();
let delim = t.chars().nth(digits.len()).unwrap_or('.');
format!("{digits}{delim}")
}
fn push_justified(out: &mut Vec<String>, body: &str, measure: u16) {
let chars: Vec<char> = body.chars().collect();
let ranges = crate::render::layout::wrap_line(body, measure);
let last = ranges.len() - 1;
for (i, (start, end)) in ranges.into_iter().enumerate() {
let row: String = chars[start..end].iter().collect();
if i == last {
out.push(row.trim_end().to_string());
} else {
let gaps = crate::render::align::justify_gaps(&row, measure);
out.push(stretch(&row, &gaps));
}
}
}
fn stretch(row: &str, gaps: &[(usize, u16)]) -> String {
let trimmed = row.trim_end();
if gaps.is_empty() {
return trimmed.to_string();
}
let mut out = String::new();
for (i, ch) in trimmed.chars().enumerate() {
out.push(ch);
if let Some(&(_, extra)) = gaps.iter().find(|g| g.0 == i) {
out.push_str(&" ".repeat(extra as usize));
}
}
out
}
fn strip_inline(line: &str, md: &MarkdownConfig) -> String {
let chars: Vec<char> = line.chars().collect();
let spans = inline::scan(line, md);
if spans.is_empty() {
return line.to_string();
}
let mut out = String::new();
let mut i = 0usize;
for sp in &spans {
if sp.outer.start < i {
continue;
}
out.extend(&chars[i..sp.outer.start]);
let inner: String = chars[sp.inner.clone()].iter().collect();
match sp.kind {
inline::Inline::Link => {
let whole: String = chars[sp.outer.clone()].iter().collect();
match url_of(&whole) {
Some(url) if url != inner => out.push_str(&format!("{inner} ({url})")),
_ => out.push_str(&inner),
}
}
inline::Inline::Image => out.push_str(&format!("[image: {inner}]")),
_ => out.push_str(&inner),
}
i = sp.outer.end;
}
out.extend(&chars[i.min(chars.len())..]);
out
}
fn url_of(whole: &str) -> Option<&str> {
let open = whole.rfind("](")?;
let close = whole.rfind(')')?;
(close > open + 2).then(|| whole[open + 2..close].trim())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn headings_keep_their_rank_by_being_underlined() {
let got = render("# Title\n\n## Section\n\n### Deeper\n", 72, false);
assert!(got.contains("Title\n====="), "an H1 is ruled with =");
assert!(got.contains("Section\n-------"), "an H2 with -");
assert!(got.contains("Deeper"), "deeper ranks stand alone");
assert!(!got.contains("###"));
}
#[test]
fn inline_markers_go_and_their_text_stays() {
let got = render("a **bold** and *italic* and `code` and ==hi== here\n", 72, false);
assert_eq!(got.trim(), "a bold and italic and code and hi here");
}
#[test]
fn a_link_keeps_its_address() {
let got = render("see [the docs](https://example.com/x) now\n", 72, false);
assert_eq!(got.trim(), "see the docs (https://example.com/x) now");
let got = render("[https://example.com](https://example.com)\n", 72, false);
assert_eq!(got.trim(), "https://example.com");
}
#[test]
fn lists_stay_lists_and_checkboxes_survive() {
let got = render("- one\n- two\n\n1. first\n2. second\n\n- [ ] todo\n- [x] done\n", 72, false);
assert!(got.contains("- one"));
assert!(got.contains("1. first"));
assert!(got.contains("2. second"));
assert!(got.contains("[ ] todo"), "an unticked box is the point of the line");
assert!(got.contains("[x] done"));
}
#[test]
fn quotes_keep_their_mark_and_code_is_indented() {
let got = render("> quoted words\n\n```rust\nlet x = 1;\n```\n", 72, false);
assert!(got.contains("> quoted words"));
assert!(got.contains(" let x = 1;"), "verbatim text is indented");
assert!(!got.contains("```"), "the fence itself goes");
assert!(!got.contains("rust"), "…and so does its info string");
}
#[test]
fn a_hash_inside_a_fence_is_left_alone() {
let got = render("```sh\n# a comment\n```\n", 72, false);
assert!(got.contains(" # a comment"));
assert!(!got.contains("====="));
}
#[test]
fn front_matter_is_metadata_and_goes() {
let got = render("---\ntitle: x\n---\nbody\n", 72, false);
assert!(got.contains("body"));
assert!(!got.contains("title: x"));
}
#[test]
fn justify_wraps_and_stretches_a_paragraph_but_its_last_row() {
let got = render("aaa bbb ccc ddd\n", 10, true);
let lines: Vec<&str> = got.lines().collect();
assert_eq!(lines, vec!["aaa bbb", "ccc ddd"], "{got:?}");
}
#[test]
fn justify_leaves_an_unwrapped_paragraph_ragged() {
let got = render("aaa bbb\n", 20, true);
assert_eq!(got.trim_end(), "aaa bbb");
}
#[test]
fn justify_leaves_headings_quotes_lists_and_tables_alone() {
let got = render(
"# A heading much longer than the measure itself\n\n\
> a quoted line that is also much longer than the measure\n\n\
- a list item that likewise runs well past the measure\n\n\
| a | b |\n|---|---|\n| longer than the measure | cell |\n",
10,
true,
);
assert!(got.contains("A heading much longer than the measure itself"));
assert!(got.contains("> a quoted line that is also much longer than the measure"));
assert!(got.contains("- a list item that likewise runs well past the measure"));
assert!(got.contains("| longer than the measure | cell |"));
}
}