use alloc::string::String;
use alloc::vec::Vec;
use core::mem;
use super::is_space;
use crate::tree::{Dom, NodeRef};
const MAX_TITLE: usize = 256;
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Chunk {
pub heading_path: Vec<String>,
pub markdown: String,
}
impl Chunk {
pub fn context(&self) -> String {
self.heading_path.join(" > ")
}
}
impl Dom {
pub fn to_markdown_chunks(&self, max_chars: usize) -> Vec<Chunk> {
chunk_markdown(&self.to_markdown(), max_chars)
}
}
impl NodeRef<'_> {
pub fn to_markdown_chunks(&self, max_chars: usize) -> Vec<Chunk> {
chunk_markdown(&self.to_markdown(), max_chars)
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Heading(u8),
Fence,
Table,
Text,
}
struct Block<'a> {
lines: Vec<&'a str>,
kind: Kind,
}
fn chunk_markdown(md: &str, max: usize) -> Vec<Chunk> {
let mut packer = Packer {
max,
..Packer::default()
};
for block in blocks(md) {
let text = block.lines.join("\n");
let len = text.chars().count();
match block.kind {
Kind::Heading(level) => packer.heading(level, text, len),
_ => packer.block(&block, text, len),
}
}
packer.flush();
packer.chunks
}
#[derive(Default)]
struct Packer {
max: usize,
chunks: Vec<Chunk>,
path: Vec<(u8, String)>,
cur: String,
len: usize,
body: bool,
level: Option<u8>,
}
impl Packer {
fn fits(&self, len: usize) -> bool {
if self.cur.is_empty() {
len <= self.max
} else {
self.len.saturating_add(2).saturating_add(len) <= self.max
}
}
fn append(&mut self, text: &str, len: usize) {
if !self.cur.is_empty() {
self.cur.push_str("\n\n");
self.len = self.len.saturating_add(2);
}
self.cur.push_str(text);
self.len = self.len.saturating_add(len);
}
fn flush(&mut self) {
let markdown = mem::take(&mut self.cur);
self.len = 0;
self.body = false;
self.level = None;
if markdown.chars().any(|c| !c.is_whitespace()) {
self.chunks.push(Chunk {
heading_path: self.path.iter().map(|(_, t)| t.clone()).collect(),
markdown,
});
}
}
fn heading(&mut self, level: u8, line: String, len: usize) {
if self.body || self.level.is_some_and(|l| level <= l) || !self.fits(len) {
self.flush();
}
while self.path.last().is_some_and(|&(l, _)| l >= level) {
self.path.pop();
}
self.path.push((level, cap_title(heading_text(&line))));
self.append(&line, len);
self.level = Some(level);
}
fn block(&mut self, block: &Block<'_>, text: String, len: usize) {
if self.fits(len) {
self.append(&text, len);
self.body = true;
return;
}
if len <= self.max {
self.flush();
self.append(&text, len);
self.body = true;
return;
}
let room = if self.cur.is_empty() || self.body {
0
} else {
self.max.saturating_sub(self.len.saturating_add(2))
};
let mut pieces = Vec::new();
if room > 0 {
pieces = split(block, room, self.max);
if pieces.first().is_some_and(|&(_, l)| l > room) {
pieces.clear();
}
}
if pieces.is_empty() {
self.flush();
pieces = split(block, self.max, self.max);
}
for (i, (piece, piece_len)) in pieces.into_iter().enumerate() {
if i > 0 {
self.flush();
}
self.append(&piece, piece_len);
self.body = true;
}
}
}
fn split(block: &Block<'_>, first: usize, max: usize) -> Vec<(String, usize)> {
let chars = |s: &str| s.chars().count();
let size =
|ls: &[&str]| ls.iter().map(|l| chars(l)).sum::<usize>() + ls.len().saturating_sub(1);
let lines = block.lines.as_slice();
let (head, body, tail): (&[&str], &[&str], &[&str]) = match block.kind {
Kind::Fence if lines.len() > 2 => {
let (open, rest) = lines.split_at_checked(1).unwrap_or((&[], lines));
let last = rest.len().saturating_sub(1);
let (body, close) = rest.split_at_checked(last).unwrap_or((rest, &[]));
(open, body, close)
}
Kind::Table if lines.len() > 2 => {
let (head, body) = lines.split_at_checked(2).unwrap_or((&[], lines));
(head, body, &[])
}
_ => (&[], lines, &[]),
};
let longest = body.iter().map(|l| chars(l)).max().unwrap_or(0);
let tail_len = |tail: &[&str]| if tail.is_empty() { 0 } else { 1 + size(tail) };
let wrap = !head.is_empty() && size(head) + 1 + longest + tail_len(tail) <= max;
let (head, body, tail) = if wrap {
(head, body, tail)
} else {
(&[][..], lines, &[][..])
};
let (head_text, head_len) = (head.join("\n"), size(head));
let (tail_text, tail_len) = (tail.join("\n"), tail_len(tail));
let mut pieces = Vec::new();
let mut limit = first;
let mut piece = head_text.clone();
let mut len = head_len;
let mut count = head.len(); let mut has_body = false;
for line in body {
let line_len = chars(line);
let sep = usize::from(count > 0);
if has_body && len + sep + line_len + tail_len > limit {
if !tail.is_empty() {
piece.push('\n');
piece.push_str(&tail_text);
len += tail_len;
}
pieces.push((mem::replace(&mut piece, head_text.clone()), len));
len = head_len;
count = head.len();
limit = max;
}
if count > 0 {
piece.push('\n');
len += 1;
}
piece.push_str(line);
len += line_len;
count += 1;
has_body = true;
}
if !tail.is_empty() {
piece.push('\n');
piece.push_str(&tail_text);
len += tail_len;
}
pieces.push((piece, len));
pieces
}
fn blocks(md: &str) -> Vec<Block<'_>> {
let mut out = Vec::new();
let mut cur: Vec<&str> = Vec::new();
let mut fence: Option<usize> = None;
for line in md.split('\n') {
if let Some(open) = fence {
cur.push(line);
if closes_fence(line, open) {
fence = None;
}
continue;
}
if line.trim().is_empty() {
push_block(&mut out, &mut cur);
continue;
}
if let Some(level) = heading_level(line) {
push_block(&mut out, &mut cur);
out.push(Block {
lines: alloc::vec![line],
kind: Kind::Heading(level),
});
continue;
}
fence = opens_fence(line);
cur.push(line);
}
push_block(&mut out, &mut cur);
out
}
fn push_block<'a>(out: &mut Vec<Block<'a>>, cur: &mut Vec<&'a str>) {
if cur.is_empty() {
return;
}
let lines = mem::take(cur);
let first = lines.first().copied().unwrap_or("");
let last = lines.last().copied().unwrap_or("");
let fence = (lines.len() >= 2 && first.starts_with("```"))
.then(|| opens_fence(first))
.flatten()
.is_some_and(|n| !last.starts_with(' ') && closes_fence(last, n));
let table = first.starts_with('|')
&& lines.get(1).is_some_and(|l| {
l.starts_with('|') && l.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
});
let kind = if fence {
Kind::Fence
} else if table {
Kind::Table
} else {
Kind::Text
};
out.push(Block { lines, kind });
}
fn heading_level(line: &str) -> Option<u8> {
let hashes = line.bytes().take_while(|&b| b == b'#').count();
let after = line.get(hashes..)?;
((1..=6).contains(&hashes) && after.starts_with(' ')).then_some(hashes as u8)
}
fn content(line: &str) -> &str {
let mut rest = line;
loop {
let trimmed = rest.trim_start_matches(' ');
if let Some(r) = trimmed.strip_prefix('>') {
rest = r.strip_prefix(' ').unwrap_or(r);
continue;
}
if let Some(r) = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.or_else(|| trimmed.strip_prefix("+ "))
{
rest = r;
continue;
}
let digits = trimmed.bytes().take_while(u8::is_ascii_digit).count();
if (1..=9).contains(&digits)
&& let Some(r) = trimmed
.get(digits..)
.and_then(|r| r.strip_prefix(". ").or_else(|| r.strip_prefix(") ")))
{
rest = r;
continue;
}
return trimmed;
}
}
fn opens_fence(line: &str) -> Option<usize> {
let line = content(line);
let n = line.bytes().take_while(|&b| b == b'`').count();
(n >= 3 && !line.get(n..).is_some_and(|info| info.contains('`'))).then_some(n)
}
fn closes_fence(line: &str, open: usize) -> bool {
let line = content(line).trim_end();
line.len() >= open && line.bytes().all(|b| b == b'`')
}
fn heading_text(line: &str) -> String {
let mut rest = line.trim_start_matches('#');
let mut out = String::with_capacity(rest.len());
while let Some(c) = rest.chars().next() {
let after = rest.get(c.len_utf8()..).unwrap_or("");
match c {
'\\' => match after.chars().next() {
Some(n) if n.is_ascii_punctuation() => {
out.push(n);
rest = after.get(1..).unwrap_or("");
continue;
}
_ => out.push('\\'),
},
'`' => {
let n = rest.bytes().take_while(|&b| b == b'`').count();
let body = rest.get(n..).unwrap_or("");
match code_span(body, n) {
Some((code, after_span)) => {
out.push_str(code);
rest = after_span;
}
None => {
out.push_str(rest.get(..n).unwrap_or(""));
rest = body;
}
}
continue;
}
'*' | '~' | '[' => {}
'!' if after.starts_with('[') => {}
']' if after.starts_with('(') => {
rest = skip_destination(after);
continue;
}
']' => {}
c => out.push(c),
}
rest = after;
}
let mut text = String::with_capacity(out.len());
for word in out.split(is_space).filter(|w| !w.is_empty()) {
if !text.is_empty() {
text.push(' ');
}
text.push_str(word);
}
text
}
fn cap_title(mut title: String) -> String {
if let Some((end, _)) = title.char_indices().nth(MAX_TITLE) {
title.truncate(end);
let kept = title.trim_end_matches(is_space).len();
title.truncate(kept);
title.push('…');
}
title
}
fn code_span(body: &str, n: usize) -> Option<(&str, &str)> {
let bytes = body.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes.get(i) != Some(&b'`') {
i += 1;
continue;
}
let start = i;
while bytes.get(i) == Some(&b'`') {
i += 1;
}
if i - start == n {
let code = body.get(..start)?;
let code = match code.strip_prefix(' ').and_then(|c| c.strip_suffix(' ')) {
Some(inner) if !inner.trim().is_empty() => inner,
_ => code,
};
return Some((code, body.get(i..)?));
}
}
None
}
fn skip_destination(rest: &str) -> &str {
let mut angle = false;
let mut quote = false;
let mut escaped = false;
for (i, c) in rest.char_indices().skip(1) {
if escaped {
escaped = false;
continue;
}
match c {
'\\' => escaped = true,
'<' if !quote && i == 1 => angle = true,
'>' if angle => angle = false,
'"' if !angle => quote = !quote,
')' if !angle && !quote => return rest.get(i + 1..).unwrap_or(""),
_ => {}
}
}
""
}
#[cfg(feature = "serde")]
impl serde::Serialize for Chunk {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut st = s.serialize_struct("Chunk", 2)?;
st.serialize_field("heading_path", &self.heading_path)?;
st.serialize_field("markdown", &self.markdown)?;
st.end()
}
}