use crate::model::Section;
use crate::{MAX_DOC_BODY_BYTES, ORDER_GAP, SECTION_MAX_BYTES};
pub fn split_with(body: &str, max: usize) -> (String, Vec<Section>) {
let max = max.max(4);
let (title, rest) = take_title(body);
let mut chunks = Vec::new();
for block in blocks(rest, 2) {
pack_into(&mut chunks, block, max, 3);
}
(title, lay_out(chunks))
}
pub fn split(body: &str) -> (String, Vec<Section>) {
split_with(body, SECTION_MAX_BYTES)
}
pub fn join(title: &str, sections: &[Section]) -> String {
let mut ordered: Vec<&Section> = sections.iter().collect();
ordered.sort_by_key(|s| s.order);
let mut out = String::new();
if !title.is_empty() {
out.push_str("# ");
out.push_str(title);
out.push('\n');
}
for s in ordered {
out.push_str(&s.text);
}
out
}
pub fn renumber(n: usize) -> Vec<u32> {
(1..=n as u32)
.map(|i| i.saturating_mul(ORDER_GAP))
.collect()
}
pub fn order_between(before: Option<u32>, after: Option<u32>) -> Option<u32> {
let lo = before.unwrap_or(0);
let Some(hi) = after else {
return lo.checked_add(ORDER_GAP);
};
(hi > lo.saturating_add(1)).then(|| lo + (hi - lo) / 2)
}
fn lay_out(chunks: Vec<String>) -> Vec<Section> {
let chunks: Vec<String> = chunks.into_iter().filter(|c| !c.is_empty()).collect();
renumber(chunks.len())
.into_iter()
.zip(chunks)
.map(|(order, text)| Section { order, text })
.collect()
}
fn take_title(body: &str) -> (String, &str) {
let first_end = body.find('\n').map_or(body.len(), |i| i + 1);
let first = &body[..first_end];
match first.strip_prefix("# ") {
Some(t) => (
t.trim_end_matches(['\n', '\r']).to_owned(),
&body[first_end..],
),
None => (String::new(), body),
}
}
fn is_heading(line: &str, level: usize) -> bool {
let b = line.as_bytes();
b.len() > level && b[..level].iter().all(|c| *c == b'#') && b[level] == b' '
}
#[derive(Default)]
pub(crate) struct Fence {
open: Option<(u8, usize)>,
}
impl Fence {
pub(crate) fn feed(&mut self, line: &str) -> bool {
let t = line.trim_start();
let marker = t.bytes().next().filter(|c| *c == b'`' || *c == b'~');
let run = marker.map_or(0, |m| t.bytes().take_while(|c| *c == m).count());
match (self.open, marker) {
(Some((m, n)), Some(c)) if c == m && run >= n && t[run..].trim().is_empty() => {
self.open = None;
true
}
(Some(_), _) => true,
(None, Some(c)) if run >= 3 => {
self.open = Some((c, run));
true
}
(None, _) => false,
}
}
}
fn blocks(text: &str, level: usize) -> Vec<&str> {
let mut out = Vec::new();
let mut fence = Fence::default();
let mut start = 0;
let mut pos = 0;
for line in text.split_inclusive('\n') {
let fenced = fence.feed(line);
if !fenced && pos > start && is_heading(line, level) {
out.push(&text[start..pos]);
start = pos;
}
pos += line.len();
}
if start < text.len() {
out.push(&text[start..]);
}
out
}
fn paragraphs(text: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut fence = Fence::default();
let mut start = 0;
let mut pos = 0;
for line in text.split_inclusive('\n') {
let fenced = fence.feed(line);
pos += line.len();
if !fenced && line.trim().is_empty() {
out.push(&text[start..pos]);
start = pos;
}
}
if start < text.len() {
out.push(&text[start..]);
}
out
}
fn pack_into(chunks: &mut Vec<String>, block: &str, max: usize, level: usize) {
if fits(block.len(), escaped_len(block), max) {
chunks.push(block.to_owned());
return;
}
let (pieces, next): (Vec<&str>, usize) = match level {
3..=6 => (blocks(block, level), level + 1),
7 => (paragraphs(block), 8),
8 => (block.split_inclusive('\n').collect(), 9),
_ => {
hard_cut(chunks, block, max);
return;
}
};
if pieces.len() <= 1 {
pack_into(chunks, block, max, next);
return;
}
let mut acc = String::new();
let mut acc_escaped = 0;
for piece in pieces {
let escaped = escaped_len(piece);
if !fits(piece.len(), escaped, max) {
if !acc.is_empty() {
chunks.push(std::mem::take(&mut acc));
acc_escaped = 0;
}
pack_into(chunks, piece, max, next);
} else if !fits(acc.len() + piece.len(), acc_escaped + escaped, max) {
chunks.push(std::mem::replace(&mut acc, piece.to_owned()));
acc_escaped = escaped;
} else {
acc.push_str(piece);
acc_escaped += escaped;
}
}
if !acc.is_empty() {
chunks.push(acc);
}
}
const BODY_ENVELOPE: usize = 64;
fn fits(len: usize, escaped: usize, max: usize) -> bool {
len <= max && escaped + 2 + BODY_ENVELOPE <= MAX_DOC_BODY_BYTES
}
fn escaped_len(text: &str) -> usize {
text.chars()
.map(|c| match c {
'"' | '\\' | '\n' | '\r' | '\t' | '\u{08}' | '\u{0c}' => 2,
c if (c as u32) < 0x20 => 6,
c => c.len_utf8(),
})
.sum()
}
fn hard_cut(chunks: &mut Vec<String>, text: &str, max: usize) {
let mut rest = text;
while !fits(rest.len(), escaped_len(rest), max) {
let mut cut = max.min(rest.len());
while !rest.is_char_boundary(cut) || !fits(cut, escaped_len(&rest[..cut]), max) {
cut -= 1;
}
chunks.push(rest[..cut].to_owned());
rest = &rest[cut..];
}
if !rest.is_empty() {
chunks.push(rest.to_owned());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn texts(s: &[Section]) -> Vec<&str> {
s.iter().map(|s| s.text.as_str()).collect()
}
#[test]
fn splits_at_level_two_headings_and_round_trips() {
let body = "# Title\nintro\n\n## One\na\n### Sub\nb\n## Two\nc\n";
let (title, sections) = split(body);
assert_eq!(title, "Title");
assert_eq!(
texts(§ions),
["intro\n\n", "## One\na\n### Sub\nb\n", "## Two\nc\n"]
);
assert_eq!(sections[1].heading(), Some("One"));
assert_eq!(sections[0].heading(), None);
assert_eq!(join(&title, §ions), body);
}
#[test]
fn headings_inside_fences_are_text() {
let body = "# T\n## A\n```md\n## not a heading\n```\n## B\nx\n";
let (_, sections) = split(body);
assert_eq!(sections.len(), 2);
assert!(sections[0].text.contains("## not a heading"));
}
#[test]
fn oversized_blocks_split_finer_and_stay_lossless() {
let para = "word ".repeat(30) + "\n\n";
let body = format!("# T\n## Big\n{}### Sub\n{}", para.repeat(4), para.repeat(4));
let (title, sections) = split_with(&body, 400);
assert!(sections.len() > 2);
assert!(sections.iter().all(|s| s.text.len() <= 400));
assert_eq!(join(&title, §ions), body);
let orders: Vec<u32> = sections.iter().map(|s| s.order).collect();
assert_eq!(orders, renumber(sections.len()));
}
#[test]
fn a_single_overlong_line_is_cut_at_char_boundaries() {
let body = format!("# T\n{}\n", "ä".repeat(300));
let (title, sections) = split_with(&body, 101);
assert!(sections.iter().all(|s| s.text.len() <= 101));
assert_eq!(join(&title, §ions), body);
}
#[test]
fn heavy_escaping_splits_under_the_json_body_limit() {
let body = format!("# T\n{}\n", "\"\\".repeat(9_000));
let (title, sections) = split(&body);
assert!(sections.len() >= 2, "18 000 bytes escape to 36 000");
for s in §ions {
let json = serde_json::to_string(&s.text).unwrap_or_default();
assert!(json.len() + BODY_ENVELOPE <= MAX_DOC_BODY_BYTES);
assert_eq!(json.len(), escaped_len(&s.text) + 2);
}
assert_eq!(join(&title, §ions), body);
}
#[test]
fn a_body_without_title_keeps_everything() {
let (title, sections) = split("plain\n## A\nx\n");
assert_eq!(title, "");
assert_eq!(join(&title, §ions), "plain\n## A\nx\n");
}
#[test]
fn order_between_finds_gaps_or_asks_for_renumbering() {
assert_eq!(order_between(None, None), Some(ORDER_GAP));
assert_eq!(order_between(Some(1024), Some(2048)), Some(1536));
assert_eq!(order_between(Some(5), Some(6)), None);
assert_eq!(order_between(Some(2048), None), Some(3072));
assert_eq!(order_between(None, Some(1024)), Some(512));
}
}