use crate::ast::Node;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NfBlock {
Heading(u8, Vec<NfInline>),
Para(Vec<NfInline>),
List {
ordered: bool,
items: Vec<NfItem>,
},
Code {
info: String,
text: String,
},
Table {
rows: Vec<Vec<Vec<NfInline>>>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NfItem {
pub content: Vec<NfInline>,
pub sublists: Vec<NfBlock>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NfInline {
Run {
text: String,
bold: bool,
italic: bool,
},
Link {
href: String,
label: Vec<NfInline>,
},
}
pub fn md_href(target: &str) -> String {
if ["http://", "https://", "ftp://", "mailto:", "//"]
.iter()
.any(|p| target.starts_with(p))
{
let mut href = String::with_capacity(target.len());
for (i, ch) in target.char_indices() {
match ch {
'\0'..='\u{1f}' | '\u{7f}' | '<' | '>' | '|' | '\\' => {
href.push_str(&format!("%{:02X}", ch as u32));
}
'&' if entity_shaped(&target.as_bytes()[i + 1..]) => href.push_str("%26"),
_ => href.push(ch),
}
}
return href;
}
let decoded = crate::entities::decode(target);
let mut href = String::with_capacity(decoded.len() + 2);
href.push_str("./");
for &b in decoded.as_bytes() {
match b {
b' ' => href.push('_'),
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' => href.push(b as char),
b'-' | b'.' | b'_' | b'~' | b'!' | b'$' | b'\'' | b'(' | b')' | b'*' | b'+' | b','
| b';' | b'=' | b':' | b'@' | b'/' => href.push(b as char),
_ => href.push_str(&format!("%{b:02X}")),
}
}
href
}
pub fn normalize_inlines(inlines: Vec<NfInline>) -> Vec<NfInline> {
let flat: Vec<NfInline> = inlines
.into_iter()
.map(|i| match i {
NfInline::Run { text, bold, italic } => NfInline::Run {
text: collapse_ws(&text),
bold,
italic,
},
NfInline::Link { href, label } => NfInline::Link {
href,
label: normalize_inlines(label),
},
})
.collect();
let flat = merge_runs(flat);
let mut peeled: Vec<NfInline> = Vec::with_capacity(flat.len());
for i in flat {
match i {
NfInline::Run { text, bold, italic } if (bold || italic) && !text.is_empty() => {
let core_start = text
.find(|c: char| c.is_alphanumeric())
.unwrap_or(text.len());
let core_end = text
.rfind(|c: char| c.is_alphanumeric())
.map_or(0, |p| p + text[p..].chars().next().unwrap().len_utf8());
if core_start >= core_end {
peeled.push(plain_run(&text));
continue;
}
if core_start > 0 {
peeled.push(plain_run(&text[..core_start]));
}
peeled.push(NfInline::Run {
text: text[core_start..core_end].to_string(),
bold,
italic,
});
if core_end < text.len() {
peeled.push(plain_run(&text[core_end..]));
}
}
other => peeled.push(other),
}
}
let mut merged = merge_runs(peeled);
if let Some(NfInline::Run { text, .. }) = merged.first_mut() {
*text = text.trim_start().to_string();
}
if let Some(NfInline::Run { text, .. }) = merged.last_mut() {
*text = text.trim_end().to_string();
}
merged.retain(|i| !matches!(i, NfInline::Run { text, .. } if text.is_empty()));
merged
}
fn entity_shaped(rest: &[u8]) -> bool {
let body = match rest.first() {
Some(b'#') => match rest.get(1) {
Some(b'x') | Some(b'X') => &rest[2..],
_ => &rest[1..],
},
_ => rest,
};
let mut len = 0;
for &b in body {
match b {
b';' => return len > 0,
_ if b.is_ascii_alphanumeric() => len += 1,
_ => return false,
}
}
false
}
fn merge_runs(runs: Vec<NfInline>) -> Vec<NfInline> {
let mut merged: Vec<NfInline> = Vec::with_capacity(runs.len());
for i in runs {
match (merged.last_mut(), &i) {
(
Some(NfInline::Run {
text: t0,
bold: b0,
italic: i0,
}),
NfInline::Run { text, bold, italic },
) if b0 == bold && i0 == italic => {
t0.push_str(text);
*t0 = collapse_ws(t0);
}
_ => merged.push(i),
}
}
merged
}
fn code_norm(s: &str) -> String {
s.trim_end()
.replace('\0', "\u{FFFD}")
.replace("\r\n", "\n")
.replace('\r', "\n")
}
fn plain_run(s: &str) -> NfInline {
NfInline::Run {
text: s.to_string(),
bold: false,
italic: false,
}
}
fn collapse_ws(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_ws = false;
for ch in s.chars() {
if ch.is_whitespace() {
if !in_ws {
out.push(' ');
}
in_ws = true;
} else {
out.push(if ch == '\0' { '\u{FFFD}' } else { ch });
in_ws = false;
}
}
out
}
pub fn from_ast(nodes: &[Node]) -> Vec<NfBlock> {
let mut out = Vec::new();
for node in nodes {
match node {
Node::Heading { level, content } => {
out.push(NfBlock::Heading(
(*level).clamp(1, 6),
inline_nf(content, false, false),
));
}
Node::Paragraph(children) => {
let inl = inline_nf(children, false, false);
if !inl.is_empty() {
out.push(NfBlock::Para(inl));
}
}
Node::List { ordered, items } => {
if let Some(list) = list_nf(*ordered, items) {
out.push(list);
}
}
Node::Preformatted(lines) => {
let text = lines
.iter()
.map(|l| plain_text(l))
.collect::<Vec<_>>()
.join("\n");
out.push(NfBlock::Code {
info: String::new(),
text: code_norm(&text),
});
}
Node::Unsupported(s) => out.push(NfBlock::Code {
info: "wikitext".to_string(),
text: code_norm(s),
}),
Node::Table { rows } => {
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if cols > 0 {
out.push(NfBlock::Table {
rows: rows
.iter()
.map(|r| {
let mut row: Vec<Vec<NfInline>> =
r.iter().map(|c| inline_nf(c, false, false)).collect();
row.resize(cols, Vec::new());
row
})
.collect(),
});
}
}
other => {
let inl = inline_nf(std::slice::from_ref(other), false, false);
if !inl.is_empty() {
out.push(NfBlock::Para(inl));
}
}
}
}
out
}
fn list_nf(ordered: bool, items: &[Vec<Node>]) -> Option<NfBlock> {
let items: Vec<NfItem> = items
.iter()
.map(|item| {
let mut content = Vec::new();
let mut sublists = Vec::new();
for n in item {
if let Node::List { ordered, items } = n {
sublists.extend(list_nf(*ordered, items));
} else {
walk_inline(std::slice::from_ref(n), false, false, false, &mut content);
}
}
NfItem {
content: normalize_inlines(content),
sublists,
}
})
.filter(|it| !it.content.is_empty() || !it.sublists.is_empty())
.collect();
if items.is_empty() {
None
} else {
Some(NfBlock::List { ordered, items })
}
}
fn inline_nf(nodes: &[Node], bold: bool, italic: bool) -> Vec<NfInline> {
let mut out = Vec::new();
walk_inline(nodes, bold, italic, false, &mut out);
normalize_inlines(out)
}
fn walk_inline(nodes: &[Node], bold: bool, italic: bool, in_label: bool, out: &mut Vec<NfInline>) {
for node in nodes {
match node {
Node::Text(s) => out.push(NfInline::Run {
text: crate::entities::decode(s).into_owned(),
bold,
italic,
}),
Node::Bold(children) => walk_inline(children, true, italic, in_label, out),
Node::Italic(children) => walk_inline(children, bold, true, in_label, out),
Node::Link { target, label } if target.trim().is_empty() => {
let text = plain_text(label);
out.push(NfInline::Run { text, bold, italic });
}
Node::Link { target, label } if in_label => {
let text = if label.is_empty() {
target.to_string()
} else {
plain_text(label)
};
out.push(NfInline::Run { text, bold, italic });
}
Node::Link { target, label } => {
let href = md_href(target);
let label_nf = if label.is_empty() {
vec![NfInline::Run {
text: target.to_string(),
bold: false,
italic: false,
}]
} else {
let mut inner = Vec::new();
walk_inline(label, false, false, true, &mut inner);
normalize_inlines(inner)
};
out.push(NfInline::Link {
href,
label: label_nf,
});
}
other => out.push(NfInline::Run {
text: plain_text(std::slice::from_ref(other)),
bold,
italic,
}),
}
}
}
fn plain_text(nodes: &[Node]) -> String {
let mut s = String::new();
collect_text(nodes, &mut s);
s
}
fn collect_text(nodes: &[Node], out: &mut String) {
for n in nodes {
match n {
Node::Text(s) => out.push_str(&crate::entities::decode(s)),
Node::Bold(c) | Node::Italic(c) => collect_text(c, out),
Node::Link { label, target } => {
if label.is_empty() {
out.push_str(target);
} else {
collect_text(label, out);
}
}
Node::Heading { content, .. } => collect_text(content, out),
Node::Paragraph(c) => collect_text(c, out),
Node::List { items, .. } => items.iter().for_each(|i| collect_text(i, out)),
Node::Preformatted(lines) => lines.iter().for_each(|l| collect_text(l, out)),
Node::Table { rows } => rows.iter().flatten().for_each(|c| collect_text(c, out)),
Node::Unsupported(s) => out.push_str(s),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn styled_edges_are_alphanumeric_after_normalization() {
let runs = normalize_inlines(vec![NfInline::Run {
text: " !hot stuff! ".to_string(),
bold: false,
italic: true,
}]);
assert_eq!(
runs,
vec![
NfInline::Run {
text: "!".to_string(),
bold: false,
italic: false
},
NfInline::Run {
text: "hot stuff".to_string(),
bold: false,
italic: true
},
NfInline::Run {
text: "!".to_string(),
bold: false,
italic: false
},
]
);
let runs = normalize_inlines(vec![NfInline::Run {
text: "!!!".to_string(),
bold: true,
italic: false,
}]);
assert_eq!(
runs,
vec![NfInline::Run {
text: "!!!".to_string(),
bold: false,
italic: false
}]
);
}
}