use crate::formats;
use crate::graph::Symbol;
use std::path::Path;
pub const MAX_CHARS: usize = 800;
pub const OVERLAP_LINES: usize = 2;
pub const MAX_FILE_BYTES: u64 = 8 * 1024 * 1024;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Chunk {
pub start_line: u32,
pub end_line: u32,
pub text: String,
pub context: String,
}
impl Chunk {
pub fn embedded(&self) -> String {
if self.context.is_empty() {
self.text.clone()
} else {
format!("{}\n{}", self.context, self.text)
}
}
}
pub fn extract(path: &Path, bytes: &[u8]) -> Result<String, crate::SkipReason> {
extract_counted(path, bytes).map(|(text, _)| text)
}
pub const COUNTED: &[&str] = &["pdf", "pptx", "odp", "xlsx", "ods", "csv", "tsv", "ipynb"];
pub fn is_counted(path: &Path) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| COUNTED.contains(&e.to_ascii_lowercase().as_str()))
}
pub fn extract_counted(
path: &Path,
bytes: &[u8],
) -> Result<(String, Option<i64>), crate::SkipReason> {
use crate::SkipReason;
if bytes.is_empty() {
return Err(SkipReason::Empty);
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("pdf") => extract_pdf(bytes)
.map(|(text, pages)| (text, Some(pages)))
.ok_or(SkipReason::NoText),
Some(ext) if formats::handles(ext) => {
guard(|| formats::extract_counted(ext, bytes)).ok_or(SkipReason::NoText)
}
other => {
if is_binary(bytes) {
return Err(SkipReason::Binary);
}
let text = String::from_utf8_lossy(bytes).into_owned();
let units = match other {
Some("csv") => Some(formats::delimited_cells(&text, ',')),
Some("tsv") => Some(formats::delimited_cells(&text, '\t')),
_ => None,
};
Ok((text, units))
}
}
}
pub fn reader_of(path: &Path) -> &'static str {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
match ext.as_deref() {
Some("pdf") => "pdf",
Some("ipynb") => "notebook",
Some("html" | "htm") => "html",
Some("docx") => "word",
Some("pptx") => "powerpoint",
Some("xlsx") => "excel",
Some("odt" | "odp" | "ods") => "opendocument",
Some("epub") => "epub",
Some("rtf") => "rtf",
Some("eml" | "mbox") => "mail",
Some(ext) if crate::image::EXTENSIONS.contains(&ext) => "image",
_ => "text",
}
}
fn extract_pdf(bytes: &[u8]) -> Option<(String, i64)> {
let (text, pages) = guard(|| {
let mut doc = pdf_extract::Document::load_mem(bytes).ok()?;
if doc.is_encrypted() {
doc.decrypt("").ok()?;
}
let pages = doc.get_pages().len() as i64;
let mut text = String::new();
pdf_extract::output_doc(&doc, &mut pdf_extract::PlainTextOutput::new(&mut text)).ok()?;
Some((text, pages))
})?;
(!text.trim().is_empty()).then_some((text, pages))
}
fn guard<T>(extract: impl FnOnce() -> Option<T>) -> Option<T> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(extract)).ok()?
}
fn is_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8192).any(|&b| b == 0)
}
pub fn chunk_text(text: &str) -> Vec<Chunk> {
chunk_at(text, &[])
}
pub fn chunk_at(text: &str, cuts: &[u32]) -> Vec<Chunk> {
let lines: Vec<&str> = text.lines().collect();
let cut = |line_index: usize| -> bool { cuts.binary_search(&(line_index as u32 + 1)).is_ok() };
let mut chunks = Vec::new();
let mut i = 0;
while i < lines.len() {
let start = i;
let mut len = 0;
let mut first_cut: Option<usize> = None;
while i < lines.len() && (len == 0 || len + lines[i].len() < MAX_CHARS) {
if i > start && cut(i) {
first_cut = first_cut.or(Some(i));
}
len += lines[i].len() + 1;
i += 1;
}
if let Some(boundary) = first_cut {
i = i.min(boundary);
}
let body = lines[start..i].join("\n");
if body.trim().is_empty() {
continue;
}
if body.chars().count() > MAX_CHARS {
let line = start as u32 + 1;
for piece in split_chars(&body, MAX_CHARS) {
chunks.push(Chunk {
start_line: line,
end_line: line,
text: piece,
context: String::new(),
});
}
} else {
chunks.push(Chunk {
start_line: start as u32 + 1,
end_line: i as u32,
text: body,
context: String::new(),
});
}
if i < lines.len() && !cut(i) {
i = i.saturating_sub(OVERLAP_LINES).max(start + 1);
}
}
chunks
}
pub fn chunk_file(path: &Path, text: &str, symbols: &[Symbol]) -> Vec<Chunk> {
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
if matches!(ext.as_deref(), Some("md" | "markdown" | "mdx")) {
let cuts = heading_cuts(text);
if cuts.is_empty() {
return chunk_text(text);
}
let mut chunks = chunk_at(text, &cuts);
for chunk in &mut chunks {
chunk.context = heading_path(text, chunk.start_line);
}
return chunks;
}
let mut chunks = chunk_text(text);
if !symbols.is_empty() {
let lines: Vec<&str> = text.lines().collect();
for chunk in &mut chunks {
chunk.context = code_context(&lines, symbols, chunk.start_line);
}
}
chunks
}
fn code_context(lines: &[&str], symbols: &[Symbol], start_line: u32) -> String {
let Some(symbol) = symbols
.iter()
.filter(|s| s.start_line <= start_line && start_line <= s.end_line)
.max_by_key(|s| s.start_line)
else {
return String::new();
};
let at = symbol.start_line.saturating_sub(1) as usize;
let Some(signature) = lines.get(at) else {
return String::new();
};
let signature = signature.trim();
if signature.is_empty() {
return String::new();
}
let mut doc = None;
let mut above = at;
while above > 0 {
above -= 1;
let line = lines[above].trim();
if let Some(said) = comment_body(line) {
if !said.is_empty() {
doc = Some(said.to_string());
}
continue;
}
break;
}
let signature = truncate_chars(signature, CONTEXT_CHARS);
match doc {
Some(doc) => format!("{signature}\n{}", truncate_chars(&doc, CONTEXT_CHARS)),
None => signature.to_string(),
}
}
fn comment_body(line: &str) -> Option<&str> {
for marker in [
"///", "//!", "//", "#!", "#", "--", "*/", "/**", "/*", "*", ";;", "%",
] {
if let Some(rest) = line.strip_prefix(marker) {
return Some(rest.trim());
}
}
None
}
fn truncate_chars(text: &str, max: usize) -> String {
match text.char_indices().nth(max) {
Some((at, _)) => text[..at].to_string(),
None => text.to_string(),
}
}
const CONTEXT_CHARS: usize = 200;
pub fn heading_cuts(text: &str) -> Vec<u32> {
let mut cuts = Vec::new();
let mut fenced = false;
for (at, line) in text.lines().enumerate() {
if line.starts_with("```") || line.starts_with("~~~") {
fenced = !fenced;
continue;
}
if !fenced && is_heading(line) {
cuts.push(at as u32 + 1);
}
}
cuts
}
fn is_heading(line: &str) -> bool {
let hashes = line.len() - line.trim_start_matches('#').len();
(1..=6).contains(&hashes) && line[hashes..].starts_with(' ')
}
pub fn heading_path(text: &str, line: u32) -> String {
let mut path: Vec<(usize, String)> = Vec::new();
let mut fenced = false;
for (at, raw) in text.lines().enumerate() {
if at as u32 + 1 > line {
break;
}
if raw.starts_with("```") || raw.starts_with("~~~") {
fenced = !fenced;
continue;
}
if fenced || !is_heading(raw) {
continue;
}
let hashes = raw.len() - raw.trim_start_matches('#').len();
path.retain(|(level, _)| *level < hashes);
path.push((hashes, raw[hashes..].trim().to_string()));
}
path.into_iter()
.map(|(_, title)| title)
.collect::<Vec<_>>()
.join(" > ")
}
fn split_chars(s: &str, n: usize) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
chars.chunks(n).map(|c| c.iter().collect()).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chunks_cover_every_line_and_stay_under_budget() {
let text: String = (1..=200).map(|i| format!("line {i}\n")).collect();
let chunks = chunk_text(&text);
assert!(chunks.len() > 1, "200 lines should not fit in one chunk");
for c in &chunks {
assert!(c.text.chars().count() <= MAX_CHARS, "chunk over budget");
assert!(c.start_line >= 1 && c.end_line >= c.start_line);
}
for i in 1..=200 {
let needle = format!("line {i}\n");
assert!(
chunks.iter().any(|c| c.text.contains(needle.trim_end())),
"line {i} missing from all chunks"
);
}
for w in chunks.windows(2) {
assert!(
w[1].start_line > w[0].start_line,
"chunking did not advance"
);
}
}
#[test]
fn over_long_single_line_is_split() {
let text = "x".repeat(MAX_CHARS * 3);
let chunks = chunk_text(&text);
assert_eq!(chunks.len(), 3);
assert!(chunks.iter().all(|c| c.text.chars().count() <= MAX_CHARS));
}
#[test]
fn blank_input_yields_nothing() {
assert!(chunk_text("").is_empty());
assert!(chunk_text("\n\n \n").is_empty());
}
#[test]
fn binary_is_detected() {
assert!(is_binary(b"abc\0def"));
assert!(!is_binary(b"fn main() {}"));
}
#[test]
fn a_cut_ends_a_chunk_even_under_budget() {
let text = "fn a() {}\nfn b() {}\nfn c() {}\n";
let chunks = chunk_at(text, &[1, 2, 3]);
assert_eq!(
chunks.iter().map(|c| c.start_line).collect::<Vec<_>>(),
vec![1, 2, 3],
"a forced cut did not start a chunk"
);
}
#[test]
fn an_over_long_definition_is_still_split() {
let body: String = (1..=100)
.map(|i| format!(" let x{i} = {i};\n"))
.collect();
let text = format!("fn big() {{\n{body}}}\n");
let chunks = chunk_at(&text, &[1]);
assert!(chunks.len() > 1, "a 100-line function fit in one chunk?");
assert_eq!(chunks[0].start_line, 1);
for chunk in &chunks {
assert!(chunk.text.chars().count() <= MAX_CHARS);
}
}
#[test]
fn a_markdown_chunk_carries_its_heading_path() {
let text = "\
# Architecture
Prose.
## Store format
More prose.
### Shards
The part a reader needs the two headings above to understand.
";
let chunks = chunk_file(Path::new("docs/architecture.md"), text, &[]);
let deepest = chunks
.iter()
.find(|c| c.text.contains("the two headings above"))
.expect("the deepest section is a chunk");
assert_eq!(deepest.context, "Architecture > Store format > Shards");
assert!(
deepest
.embedded()
.starts_with("Architecture > Store format > Shards\n"),
"the model should see the path first: {:?}",
deepest.embedded()
);
assert!(
!deepest.text.contains("Architecture > Store format"),
"the path leaked into the stored text, which would shift every span \
this store can answer with by one line"
);
}
#[test]
fn a_hash_inside_a_code_fence_is_not_a_heading() {
let text = "\
# Install
```sh
# install semlith
curl -fsSL https://example.invalid/install.sh | sh
```
Text after the fence.
";
assert_eq!(
heading_cuts(text),
vec![1],
"a fenced comment was cut as a heading"
);
let after = text
.lines()
.position(|l| l.starts_with("Text after"))
.unwrap() as u32
+ 1;
assert_eq!(heading_path(text, after), "Install");
}
#[test]
fn a_code_chunk_carries_the_definition_it_sits_inside() {
let mut text = String::from(
"/// How many nodes a traversal may visit before it gives up.\n\
/// Four thousand, measured on the pinned corpus.\n\
pub fn walk(graph: &Graph, max_nodes: usize) -> Vec<Node> {\n",
);
for i in 1..=60 {
text.push_str(&format!(" let step_{i} = visit(graph, {i});\n"));
}
text.push_str("}\n");
let symbols = crate::graph::extract(Path::new("walk.rs"), &text)
.expect("the file parses")
.expect("Rust has a grammar")
.symbols;
let chunks = chunk_file(Path::new("walk.rs"), &text, &symbols);
let inside = chunks
.iter()
.find(|c| c.text.contains("let step_40"))
.expect("the body is chunked");
assert_eq!(
inside.context,
format!(
"{}\n{}",
"pub fn walk(graph: &Graph, max_nodes: usize) -> Vec<Node> {",
"How many nodes a traversal may visit before it gives up."
),
"a window out of a function body must say whose body it is"
);
assert!(
inside.embedded().starts_with("pub fn walk("),
"the model is shown the definition first"
);
assert!(
!inside.text.contains("pub fn walk("),
"the stored bytes are the file's own: an invented line would shift every span"
);
assert_eq!(
chunk_file(Path::new("walk.rs"), &text, &[])
.iter()
.map(|c| c.text.clone())
.collect::<Vec<_>>(),
chunks.iter().map(|c| c.text.clone()).collect::<Vec<_>>(),
"the context changes what is embedded and nothing about where the cuts fall"
);
}
#[test]
fn text_with_no_structure_keeps_the_fixed_window() {
let text: String = (1..=200).map(|i| format!("line {i}\n")).collect();
assert_eq!(
chunk_file(Path::new("notes.txt"), &text, &[]),
chunk_text(&text)
);
}
}