1use alloc::string::String;
12use alloc::vec::Vec;
13use core::mem;
14
15use super::is_space;
16use crate::tree::{Dom, NodeRef};
17
18const MAX_TITLE: usize = 256;
23
24#[non_exhaustive]
31#[derive(Clone, Debug, Default, PartialEq, Eq)]
32pub struct Chunk {
33 pub heading_path: Vec<String>,
38 pub markdown: String,
41}
42
43impl Chunk {
44 pub fn context(&self) -> String {
54 self.heading_path.join(" > ")
55 }
56}
57
58impl Dom {
59 pub fn to_markdown_chunks(&self, max_chars: usize) -> Vec<Chunk> {
106 chunk_markdown(&self.to_markdown(), max_chars)
107 }
108}
109
110impl NodeRef<'_> {
111 pub fn to_markdown_chunks(&self, max_chars: usize) -> Vec<Chunk> {
125 chunk_markdown(&self.to_markdown(), max_chars)
126 }
127}
128
129#[derive(Clone, Copy, PartialEq, Eq)]
130enum Kind {
131 Heading(u8),
132 Fence,
134 Table,
136 Text,
137}
138
139struct Block<'a> {
140 lines: Vec<&'a str>,
141 kind: Kind,
142}
143
144fn chunk_markdown(md: &str, max: usize) -> Vec<Chunk> {
145 let mut packer = Packer {
146 max,
147 ..Packer::default()
148 };
149 for block in blocks(md) {
150 let text = block.lines.join("\n");
151 let len = text.chars().count();
152 match block.kind {
153 Kind::Heading(level) => packer.heading(level, text, len),
154 _ => packer.block(&block, text, len),
155 }
156 }
157 packer.flush();
158 packer.chunks
159}
160
161#[derive(Default)]
162struct Packer {
163 max: usize,
164 chunks: Vec<Chunk>,
165 path: Vec<(u8, String)>,
167 cur: String,
168 len: usize,
170 body: bool,
172 level: Option<u8>,
174}
175
176impl Packer {
177 fn fits(&self, len: usize) -> bool {
178 if self.cur.is_empty() {
179 len <= self.max
180 } else {
181 self.len.saturating_add(2).saturating_add(len) <= self.max
182 }
183 }
184
185 fn append(&mut self, text: &str, len: usize) {
186 if !self.cur.is_empty() {
187 self.cur.push_str("\n\n");
188 self.len = self.len.saturating_add(2);
189 }
190 self.cur.push_str(text);
191 self.len = self.len.saturating_add(len);
192 }
193
194 fn flush(&mut self) {
195 let markdown = mem::take(&mut self.cur);
196 self.len = 0;
197 self.body = false;
198 self.level = None;
199 if markdown.chars().any(|c| !c.is_whitespace()) {
200 self.chunks.push(Chunk {
201 heading_path: self.path.iter().map(|(_, t)| t.clone()).collect(),
202 markdown,
203 });
204 }
205 }
206
207 fn heading(&mut self, level: u8, line: String, len: usize) {
208 if self.body || self.level.is_some_and(|l| level <= l) || !self.fits(len) {
209 self.flush();
210 }
211 while self.path.last().is_some_and(|&(l, _)| l >= level) {
212 self.path.pop();
213 }
214 self.path.push((level, cap_title(heading_text(&line))));
215 self.append(&line, len);
216 self.level = Some(level);
217 }
218
219 fn block(&mut self, block: &Block<'_>, text: String, len: usize) {
220 if self.fits(len) {
221 self.append(&text, len);
222 self.body = true;
223 return;
224 }
225 if len <= self.max {
226 self.flush();
227 self.append(&text, len);
228 self.body = true;
229 return;
230 }
231 let room = if self.cur.is_empty() || self.body {
234 0
235 } else {
236 self.max.saturating_sub(self.len.saturating_add(2))
237 };
238 let mut pieces = Vec::new();
239 if room > 0 {
240 pieces = split(block, room, self.max);
241 if pieces.first().is_some_and(|&(_, l)| l > room) {
242 pieces.clear();
243 }
244 }
245 if pieces.is_empty() {
246 self.flush();
247 pieces = split(block, self.max, self.max);
248 }
249 for (i, (piece, piece_len)) in pieces.into_iter().enumerate() {
250 if i > 0 {
251 self.flush();
252 }
253 self.append(&piece, piece_len);
254 self.body = true;
255 }
256 }
257}
258
259fn split(block: &Block<'_>, first: usize, max: usize) -> Vec<(String, usize)> {
264 let chars = |s: &str| s.chars().count();
265 let size =
267 |ls: &[&str]| ls.iter().map(|l| chars(l)).sum::<usize>() + ls.len().saturating_sub(1);
268 let lines = block.lines.as_slice();
269 let (head, body, tail): (&[&str], &[&str], &[&str]) = match block.kind {
272 Kind::Fence if lines.len() > 2 => {
273 let (open, rest) = lines.split_at_checked(1).unwrap_or((&[], lines));
274 let last = rest.len().saturating_sub(1);
275 let (body, close) = rest.split_at_checked(last).unwrap_or((rest, &[]));
276 (open, body, close)
277 }
278 Kind::Table if lines.len() > 2 => {
279 let (head, body) = lines.split_at_checked(2).unwrap_or((&[], lines));
280 (head, body, &[])
281 }
282 _ => (&[], lines, &[]),
283 };
284 let longest = body.iter().map(|l| chars(l)).max().unwrap_or(0);
285 let tail_len = |tail: &[&str]| if tail.is_empty() { 0 } else { 1 + size(tail) };
286 let wrap = !head.is_empty() && size(head) + 1 + longest + tail_len(tail) <= max;
288 let (head, body, tail) = if wrap {
289 (head, body, tail)
290 } else {
291 (&[][..], lines, &[][..])
292 };
293 let (head_text, head_len) = (head.join("\n"), size(head));
294 let (tail_text, tail_len) = (tail.join("\n"), tail_len(tail));
295
296 let mut pieces = Vec::new();
297 let mut limit = first;
298 let mut piece = head_text.clone();
299 let mut len = head_len;
300 let mut count = head.len(); let mut has_body = false;
302 for line in body {
303 let line_len = chars(line);
304 let sep = usize::from(count > 0);
305 if has_body && len + sep + line_len + tail_len > limit {
306 if !tail.is_empty() {
307 piece.push('\n');
308 piece.push_str(&tail_text);
309 len += tail_len;
310 }
311 pieces.push((mem::replace(&mut piece, head_text.clone()), len));
312 len = head_len;
313 count = head.len();
314 limit = max;
315 }
316 if count > 0 {
317 piece.push('\n');
318 len += 1;
319 }
320 piece.push_str(line);
321 len += line_len;
322 count += 1;
323 has_body = true;
324 }
325 if !tail.is_empty() {
326 piece.push('\n');
327 piece.push_str(&tail_text);
328 len += tail_len;
329 }
330 pieces.push((piece, len));
331 pieces
332}
333
334fn blocks(md: &str) -> Vec<Block<'_>> {
337 let mut out = Vec::new();
338 let mut cur: Vec<&str> = Vec::new();
339 let mut fence: Option<usize> = None;
340 for line in md.split('\n') {
341 if let Some(open) = fence {
342 cur.push(line);
343 if closes_fence(line, open) {
344 fence = None;
345 }
346 continue;
347 }
348 if line.trim().is_empty() {
349 push_block(&mut out, &mut cur);
350 continue;
351 }
352 if let Some(level) = heading_level(line) {
353 push_block(&mut out, &mut cur);
354 out.push(Block {
355 lines: alloc::vec![line],
356 kind: Kind::Heading(level),
357 });
358 continue;
359 }
360 fence = opens_fence(line);
361 cur.push(line);
362 }
363 push_block(&mut out, &mut cur);
364 out
365}
366
367fn push_block<'a>(out: &mut Vec<Block<'a>>, cur: &mut Vec<&'a str>) {
368 if cur.is_empty() {
369 return;
370 }
371 let lines = mem::take(cur);
372 let first = lines.first().copied().unwrap_or("");
373 let last = lines.last().copied().unwrap_or("");
374 let fence = (lines.len() >= 2 && first.starts_with("```"))
375 .then(|| opens_fence(first))
376 .flatten()
377 .is_some_and(|n| !last.starts_with(' ') && closes_fence(last, n));
378 let table = first.starts_with('|')
379 && lines.get(1).is_some_and(|l| {
380 l.starts_with('|') && l.chars().all(|c| matches!(c, '|' | '-' | ':' | ' '))
381 });
382 let kind = if fence {
383 Kind::Fence
384 } else if table {
385 Kind::Table
386 } else {
387 Kind::Text
388 };
389 out.push(Block { lines, kind });
390}
391
392fn heading_level(line: &str) -> Option<u8> {
394 let hashes = line.bytes().take_while(|&b| b == b'#').count();
395 let after = line.get(hashes..)?;
396 ((1..=6).contains(&hashes) && after.starts_with(' ')).then_some(hashes as u8)
397}
398
399fn content(line: &str) -> &str {
402 let mut rest = line;
403 loop {
404 let trimmed = rest.trim_start_matches(' ');
405 if let Some(r) = trimmed.strip_prefix('>') {
406 rest = r.strip_prefix(' ').unwrap_or(r);
407 continue;
408 }
409 if let Some(r) = trimmed
410 .strip_prefix("- ")
411 .or_else(|| trimmed.strip_prefix("* "))
412 .or_else(|| trimmed.strip_prefix("+ "))
413 {
414 rest = r;
415 continue;
416 }
417 let digits = trimmed.bytes().take_while(u8::is_ascii_digit).count();
418 if (1..=9).contains(&digits) {
419 if let Some(r) = trimmed
420 .get(digits..)
421 .and_then(|r| r.strip_prefix(". ").or_else(|| r.strip_prefix(") ")))
422 {
423 rest = r;
424 continue;
425 }
426 }
427 return trimmed;
428 }
429}
430
431fn opens_fence(line: &str) -> Option<usize> {
434 let line = content(line);
435 let n = line.bytes().take_while(|&b| b == b'`').count();
436 (n >= 3 && !line.get(n..).is_some_and(|info| info.contains('`'))).then_some(n)
437}
438
439fn closes_fence(line: &str, open: usize) -> bool {
441 let line = content(line).trim_end();
442 line.len() >= open && line.bytes().all(|b| b == b'`')
443}
444
445fn heading_text(line: &str) -> String {
448 let mut rest = line.trim_start_matches('#');
449 let mut out = String::with_capacity(rest.len());
450 while let Some(c) = rest.chars().next() {
451 let after = rest.get(c.len_utf8()..).unwrap_or("");
452 match c {
453 '\\' => match after.chars().next() {
454 Some(n) if n.is_ascii_punctuation() => {
455 out.push(n);
456 rest = after.get(1..).unwrap_or("");
457 continue;
458 }
459 _ => out.push('\\'),
460 },
461 '`' => {
462 let n = rest.bytes().take_while(|&b| b == b'`').count();
463 let body = rest.get(n..).unwrap_or("");
464 match code_span(body, n) {
465 Some((code, after_span)) => {
466 out.push_str(code);
467 rest = after_span;
468 }
469 None => {
470 out.push_str(rest.get(..n).unwrap_or(""));
471 rest = body;
472 }
473 }
474 continue;
475 }
476 '*' | '~' | '[' => {}
477 '!' if after.starts_with('[') => {}
478 ']' if after.starts_with('(') => {
479 rest = skip_destination(after);
480 continue;
481 }
482 ']' => {}
483 c => out.push(c),
484 }
485 rest = after;
486 }
487 let mut text = String::with_capacity(out.len());
488 for word in out.split(is_space).filter(|w| !w.is_empty()) {
489 if !text.is_empty() {
490 text.push(' ');
491 }
492 text.push_str(word);
493 }
494 text
495}
496
497fn cap_title(mut title: String) -> String {
499 if let Some((end, _)) = title.char_indices().nth(MAX_TITLE) {
500 title.truncate(end);
501 let kept = title.trim_end_matches(is_space).len();
502 title.truncate(kept);
503 title.push('…');
504 }
505 title
506}
507
508fn code_span(body: &str, n: usize) -> Option<(&str, &str)> {
511 let bytes = body.as_bytes();
512 let mut i = 0;
513 while i < bytes.len() {
514 if bytes.get(i) != Some(&b'`') {
515 i += 1;
516 continue;
517 }
518 let start = i;
519 while bytes.get(i) == Some(&b'`') {
520 i += 1;
521 }
522 if i - start == n {
523 let code = body.get(..start)?;
524 let code = match code.strip_prefix(' ').and_then(|c| c.strip_suffix(' ')) {
525 Some(inner) if !inner.trim().is_empty() => inner,
526 _ => code,
527 };
528 return Some((code, body.get(i..)?));
529 }
530 }
531 None
532}
533
534fn skip_destination(rest: &str) -> &str {
536 let mut angle = false;
537 let mut quote = false;
538 let mut escaped = false;
539 for (i, c) in rest.char_indices().skip(1) {
540 if escaped {
541 escaped = false;
542 continue;
543 }
544 match c {
545 '\\' => escaped = true,
546 '<' if !quote && i == 1 => angle = true,
547 '>' if angle => angle = false,
548 '"' if !angle => quote = !quote,
549 ')' if !angle && !quote => return rest.get(i + 1..).unwrap_or(""),
550 _ => {}
551 }
552 }
553 ""
554}
555
556#[cfg(feature = "serde")]
557impl serde::Serialize for Chunk {
558 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
559 use serde::ser::SerializeStruct;
560 let mut st = s.serialize_struct("Chunk", 2)?;
561 st.serialize_field("heading_path", &self.heading_path)?;
562 st.serialize_field("markdown", &self.markdown)?;
563 st.end()
564 }
565}