use std::path::Path;
use anyhow::{Result, bail};
use super::parse::{ParsedPage, doc_id_for, file_type_for};
const MAX_SECTION_BYTES: usize = 64 * 1024;
const MIN_SECTION_BYTES: usize = 2 * 1024;
pub(super) fn is_text_path(rel_path: &str) -> bool {
matches!(
Path::new(rel_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase()
.as_str(),
"md" | "markdown" | "txt"
)
}
pub(super) fn parse_text(bytes: &[u8], rel_path: &str) -> Result<Vec<ParsedPage>> {
let text = decode(bytes, rel_path)?;
let file_type = file_type_for(Path::new(rel_path));
let doc_id = doc_id_for(rel_path);
let markdown_rules = file_type == "md";
let sections = split_into_sections(&text, markdown_rules);
if sections.is_empty() {
tracing::warn!("documents: {rel_path} has no content; no sections emitted");
return Ok(Vec::new());
}
Ok(sections
.into_iter()
.enumerate()
.map(|(index, markdown)| ParsedPage {
doc_id: doc_id.clone(),
path: rel_path.to_string(),
page: index as i32 + 1,
markdown,
tables_json: "[]".to_string(),
page_image_ref: None,
image_refs: Vec::new(),
file_type: file_type.clone(),
})
.collect())
}
fn decode(bytes: &[u8], rel_path: &str) -> Result<String> {
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
let text = match std::str::from_utf8(bytes) {
Ok(text) => text,
Err(e) => bail!(
"documents: {rel_path} is not UTF-8 (invalid byte at offset {})",
e.valid_up_to()
),
};
if let Some(at) = text.find('\0') {
bail!(
"documents: {rel_path} contains a NUL byte at offset {at}, which no text column can hold"
);
}
Ok(text.replace("\r\n", "\n").replace('\r', "\n"))
}
fn split_into_sections(text: &str, markdown_rules: bool) -> Vec<String> {
let primary = if markdown_rules {
split_at_headings(text)
} else {
split_at_blank_lines(text)
};
let mut merged: Vec<String> = Vec::new();
for segment in primary {
match merged.last_mut() {
Some(last) if last.len() < MIN_SECTION_BYTES => last.push_str(segment),
_ => merged.push(segment.to_string()),
}
}
let mut out = Vec::new();
for section in merged {
let headings = if markdown_rules {
heading_starts(§ion)
} else {
Vec::new()
};
let mut start = 0usize;
while start < section.len() {
let rest = §ion[start..];
let at = ceiling_split(rest, &headings, start);
let head = &rest[..at];
if !head.trim().is_empty() {
out.push(head.to_string());
}
start += at;
}
}
out
}
fn split_at_headings(text: &str) -> Vec<&str> {
let mut starts = vec![0usize];
starts.extend(heading_starts(text));
slice_at(text, &starts)
}
fn heading_starts(text: &str) -> Vec<usize> {
let mut starts = Vec::new();
let mut fence: Option<(char, usize)> = None;
let mut offset = 0usize;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
match fence {
Some((marker, len)) => {
let closing = trimmed.trim_end();
if closing.starts_with(marker)
&& closing.chars().all(|c| c == marker)
&& closing.len() >= len
{
fence = None;
}
}
None => {
if let Some(opened) = fence_open(trimmed) {
fence = Some(opened);
} else if is_section_heading(trimmed) && offset > 0 {
starts.push(offset);
}
}
}
offset += line.len();
}
starts
}
fn fence_open(line: &str) -> Option<(char, usize)> {
let marker = line.chars().next()?;
if marker != '`' && marker != '~' {
return None;
}
let run = line.chars().take_while(|c| *c == marker).count();
(run >= 3).then_some((marker, run))
}
fn is_section_heading(line: &str) -> bool {
let rest = line.strip_prefix("##").or_else(|| line.strip_prefix('#'));
match rest {
Some(rest) => rest.starts_with(' ') || rest.starts_with('\t'),
None => false,
}
}
fn split_at_blank_lines(text: &str) -> Vec<&str> {
let mut starts = vec![0usize];
let mut offset = 0usize;
let mut previous_blank = false;
for line in text.split_inclusive('\n') {
let blank = line.trim().is_empty();
if !blank && previous_blank && offset > 0 {
starts.push(offset);
}
previous_blank = blank;
offset += line.len();
}
slice_at(text, &starts)
}
fn slice_at<'a>(text: &'a str, starts: &[usize]) -> Vec<&'a str> {
starts
.iter()
.enumerate()
.map(|(i, start)| {
let end = starts.get(i + 1).copied().unwrap_or(text.len());
&text[*start..end]
})
.filter(|segment| !segment.is_empty())
.collect()
}
fn ceiling_split(section: &str, headings: &[usize], base: usize) -> usize {
if section.len() <= MAX_SECTION_BYTES {
return section.len();
}
let mut window = MAX_SECTION_BYTES;
while !section.is_char_boundary(window) {
window -= 1;
}
let head = §ion[..window];
if let Some(at) = headings
.iter()
.rev()
.find(|&&at| at > base && at <= base + window)
{
return at - base;
}
if let Some(at) = head.rfind("\n\n") {
return at + 2;
}
if let Some(at) = head.rfind('\n') {
return at + 1;
}
window
}
#[cfg(test)]
mod tests {
use super::*;
fn sections(text: &str, markdown_rules: bool) -> Vec<String> {
split_into_sections(text, markdown_rules)
}
fn assert_lossless(text: &str, markdown_rules: bool) {
let joined: String = sections(text, markdown_rules).concat();
assert_eq!(joined.trim_end(), text.trim_end(), "sections lost content");
}
fn body(tag: &str) -> String {
format!("{tag} {}\n", "word ".repeat(MIN_SECTION_BYTES / 4))
}
#[test]
fn a_markdown_file_splits_at_top_level_headings() {
let text = format!(
"# Title\n\n{}\n## First\n\n{}\n## Second\n\n{}",
body("intro"),
body("first"),
body("second")
);
let out = sections(&text, true);
assert_eq!(out.len(), 3, "{out:#?}");
assert!(out[0].starts_with("# Title"));
assert!(out[1].starts_with("## First"));
assert!(out[2].starts_with("## Second"));
assert_lossless(&text, true);
}
#[test]
fn a_document_under_the_merge_floor_is_one_section() {
let text = "# Title\n\nintro\n\n## First\n\nbody\n\n## Second\n\nbody\n";
assert!(text.len() < MIN_SECTION_BYTES, "the premise");
let out = sections(text, true);
assert_eq!(out.len(), 1, "{out:#?}");
assert_lossless(text, true);
}
#[test]
fn deeper_headings_do_not_start_a_section() {
let text = format!(
"# Title\n\n{}\n### Deep\n\n{}\n#### Deeper\n\n{}",
body("a"),
body("b"),
body("c")
);
assert_eq!(sections(&text, true).len(), 1, "only levels 1-2 split");
}
#[test]
fn a_hash_inside_a_fenced_block_is_not_a_heading() {
let text = format!(
"# Title\n\n```bash\n# not a heading\necho hi\n## also not\n```\n\n{}\n## Real\n\n{}",
body("prose"),
body("real")
);
let out = sections(&text, true);
assert_eq!(out.len(), 2, "{out:#?}");
assert!(out[0].contains("# not a heading"));
assert!(out[0].contains("## also not"));
assert!(out[1].starts_with("## Real"));
assert_lossless(&text, true);
}
#[test]
fn tilde_fences_and_longer_fences_close_correctly() {
let text = format!(
"# T\n\n~~~\n# inside tilde\n~~~\n\n{}\n## After\n\n{}",
body("prose"),
body("after")
);
let out = sections(&text, true);
assert_eq!(out.len(), 2, "{out:#?}");
assert!(out[0].contains("# inside tilde"));
let nested = format!(
"# T\n\n````\n```\n# still inside\n```\n````\n\n{}\n## After\n\n{}",
body("prose"),
body("after")
);
let out = sections(&nested, true);
assert_eq!(out.len(), 2, "{out:#?}");
assert!(out[0].contains("# still inside"));
}
#[test]
fn a_hash_with_no_space_is_not_a_heading() {
let text = format!(
"# Title\n\n{}\n#tag line\n\n{}\n#!/bin/sh\n\n{}",
body("a"),
body("b"),
body("c")
);
assert_eq!(sections(&text, true).len(), 1, "the space is required");
}
#[test]
fn plain_text_splits_at_blank_lines_and_merges_the_small_ones() {
let text = "one\n\ntwo\n\nthree\n";
let out = sections(text, false);
assert_eq!(out.len(), 1, "{out:#?}");
assert!(out[0].contains("one") && out[0].contains("three"));
assert_lossless(text, false);
}
#[test]
fn plain_text_paragraphs_over_the_floor_stay_separate() {
let big = "x".repeat(MIN_SECTION_BYTES + 10);
let text = format!("{big}\n\n{big}\n\n{big}\n");
let out = sections(&text, false);
assert_eq!(out.len(), 3, "each paragraph is over the merge floor");
assert_lossless(&text, false);
}
#[test]
fn a_title_only_preamble_is_not_its_own_section() {
let body = "y".repeat(MIN_SECTION_BYTES + 10);
let text = format!("# Title\n\n## First\n\n{body}\n");
let out = sections(&text, true);
assert_eq!(out.len(), 1, "{out:#?}");
assert!(out[0].starts_with("# Title"));
assert!(out[0].contains("## First"));
}
#[test]
fn one_enormous_line_is_still_cut_into_bounded_sections() {
let text = "z".repeat(MAX_SECTION_BYTES * 3 + 17);
let out = sections(&text, false);
assert!(
out.len() >= 4,
"expected several sections, got {}",
out.len()
);
assert!(
out.iter().all(|s| s.len() <= MAX_SECTION_BYTES),
"a section exceeded the ceiling"
);
assert_eq!(out.concat(), text, "the hard cut lost content");
}
#[test]
fn the_hard_cut_never_splits_a_code_point() {
for pad in 0..4 {
let text = format!("{}{}", "a".repeat(pad), "€".repeat(MAX_SECTION_BYTES));
let out = sections(&text, false);
assert!(out.iter().all(|s| s.len() <= MAX_SECTION_BYTES));
assert_eq!(out.concat(), text, "pad {pad} lost content");
}
}
#[test]
fn the_ceiling_split_prefers_the_best_boundary_it_can_reach() {
let filler = "w".repeat(MAX_SECTION_BYTES - 100);
let text = format!(
"{filler}\n\ntail paragraph\n{}",
"v".repeat(MAX_SECTION_BYTES)
);
let out = sections(&text, false);
assert!(
out[0].ends_with("\n\n"),
"expected a blank-line boundary, got {:?}",
&out[0][out[0].len() - 20..]
);
}
#[test]
fn the_ceiling_split_does_not_cut_inside_a_fence() {
let filler = "w".repeat(MAX_SECTION_BYTES / 2);
let text = format!(
"# Title\n\n{filler}\n\n```sh\n# inside the fence\n{filler}\n```\n\n{}",
"v".repeat(MAX_SECTION_BYTES)
);
let out = sections(&text, true);
assert!(out.iter().all(|s| s.len() <= MAX_SECTION_BYTES));
assert!(
out.iter().all(|s| !s.starts_with("# inside the fence")),
"a section starts inside the fence: {:?}",
out.iter().map(|s| &s[..24]).collect::<Vec<_>>()
);
assert!(
out[0].ends_with("\n\n"),
"expected the blank line before the fence, got {:?}",
&out[0][out[0].len() - 24..]
);
assert_lossless(&text, true);
}
#[test]
fn plain_text_never_cuts_at_a_heading_shaped_line() {
let filler = "w".repeat(MAX_SECTION_BYTES / 2);
let text = format!(
"{filler}\n\n{filler}\n# not a heading in a txt\n{filler}\n{}",
"v".repeat(MAX_SECTION_BYTES)
);
let out = sections(&text, false);
assert!(out.iter().all(|s| s.len() <= MAX_SECTION_BYTES));
assert!(
out.iter().all(|s| !s.starts_with("# not a heading")),
"{:?}",
out.iter().map(|s| &s[..24]).collect::<Vec<_>>()
);
assert_lossless(&text, false);
}
#[test]
fn a_bom_is_stripped_so_the_first_heading_still_parses() {
let rows = parse_text("\u{feff}# Title\n\nbody\n".as_bytes(), "a.md").unwrap();
assert_eq!(rows.len(), 1);
assert!(
rows[0].markdown.starts_with("# Title"),
"{:?}",
rows[0].markdown
);
}
#[test]
fn crlf_and_lone_cr_become_newlines() {
let rows = parse_text(b"# A\r\n\r\npara\r\n", "a.md").unwrap();
assert!(!rows[0].markdown.contains('\r'));
let rows = parse_text(b"# A\r\rpara\r", "a.md").unwrap();
assert!(!rows[0].markdown.contains('\r'));
}
#[test]
fn latin_1_is_refused_rather_than_lossily_decoded() {
let err = parse_text(b"caf\xe9 notes", "notes.txt").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("is not UTF-8"), "{msg}");
assert!(
msg.contains("notes.txt"),
"the message must name the file: {msg}"
);
}
#[test]
fn a_nul_byte_is_refused_although_it_is_valid_utf8() {
assert!(std::str::from_utf8(b"a\0b").is_ok(), "the premise");
let err = parse_text(b"a\0b", "notes.txt").unwrap_err();
assert!(format!("{err:#}").contains("NUL byte"), "{err:#}");
}
#[test]
fn markdown_and_md_report_one_file_type() {
for path in ["a.md", "a.markdown", "A.MARKDOWN"] {
let rows = parse_text(b"# T\n\nbody\n", path).unwrap();
assert_eq!(rows[0].file_type, "md", "{path}");
}
let rows = parse_text(b"plain\n", "a.txt").unwrap();
assert_eq!(rows[0].file_type, "txt");
}
#[test]
fn the_route_is_chosen_by_extension_and_case_does_not_matter() {
for yes in ["a.md", "a.MD", "a.markdown", "a.txt", "dir/deep/notes.TXT"] {
assert!(is_text_path(yes), "{yes}");
}
for no in [
"a.pdf", "a.docx", "a.mdx", "a.csv", "a.log", "README", "a.md.pdf",
] {
assert!(!is_text_path(no), "{no}");
}
}
#[test]
fn rows_carry_the_section_ordinal_and_the_empty_liteparse_columns() {
let body = "q".repeat(MIN_SECTION_BYTES + 10);
let text = format!("# One\n\n{body}\n\n## Two\n\n{body}\n");
let rows = parse_text(text.as_bytes(), "deep/a.md").unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0].page, 1);
assert_eq!(rows[1].page, 2);
assert!(rows.iter().all(|r| r.path == "deep/a.md"));
assert!(rows.iter().all(|r| r.doc_id == rows[0].doc_id));
assert!(rows.iter().all(|r| r.tables_json == "[]"));
assert!(rows.iter().all(|r| r.page_image_ref.is_none()));
assert!(rows.iter().all(|r| r.image_refs.is_empty()));
}
#[test]
fn the_section_body_is_the_file_verbatim() {
let text = "# Title\n\n| a | b |\n|---|---|\n| 1 | 2 |\n\n```rust\nfn main() {}\n```\n";
let rows = parse_text(text.as_bytes(), "a.md").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].markdown, text);
}
#[test]
fn an_empty_file_is_no_rows_rather_than_an_error() {
assert!(parse_text(b"", "a.txt").unwrap().is_empty());
assert!(parse_text(b" \n\n \t\n", "a.txt").unwrap().is_empty());
}
#[test]
fn a_one_line_file_is_one_section() {
let rows = parse_text(b"just one line, no trailing newline", "a.txt").unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].markdown, "just one line, no trailing newline");
}
}