use crate::parse::traits::{
Block, CodeIntelligence, ComplexityMetrics, Edge, Error, Graph, Result, SignatureInfo,
};
use tree_sitter::Parser;
const MAX_DOC_FILE_BYTES: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DocFlavor {
Markdown,
Rst,
Adoc,
Plain,
}
impl DocFlavor {
pub fn for_extension(ext: &str) -> Option<Self> {
match ext.to_ascii_lowercase().as_str() {
"md" | "markdown" => Some(Self::Markdown),
"rst" => Some(Self::Rst),
"adoc" | "asciidoc" => Some(Self::Adoc),
"txt" => Some(Self::Plain),
_ => None,
}
}
}
pub struct DocParser {
flavor: DocFlavor,
}
impl DocParser {
pub fn new(flavor: DocFlavor) -> Self {
Self { flavor }
}
}
impl Default for DocParser {
fn default() -> Self {
Self::new(DocFlavor::Markdown)
}
}
impl CodeIntelligence for DocParser {
fn get_signatures(&self, source: &[u8]) -> Result<Vec<SignatureInfo>> {
if source.len() > MAX_DOC_FILE_BYTES {
let mut end = source.len().min(MAX_DOC_FILE_BYTES);
while end > 0 && source[end] & 0xC0 == 0x80 {
end -= 1;
}
let truncated = &source[..end];
return self.get_signatures(truncated);
}
let text = std::str::from_utf8(source)
.map_err(|e| Error::ParseFailed(format!("doc file is not UTF-8: {e}")))?;
let sections = match self.flavor {
DocFlavor::Markdown => markdown_sections(text),
DocFlavor::Rst => rst_sections(text),
DocFlavor::Adoc => adoc_sections(text),
DocFlavor::Plain => plain_sections(text),
};
Ok(sections
.into_iter()
.map(|(name, byte_range, summary)| SignatureInfo {
name: sanitize_name(&name, &byte_range),
qualified_name: name,
parameters: Vec::new(),
return_type: Some("doc_section".to_string()),
visibility: crate::parse::traits::Visibility::Public,
is_async: false,
is_method: false,
docstring: summary,
calls: Vec::new(),
imports: Vec::new(),
byte_range,
cyclomatic_complexity: 1,
flow_facts: Vec::new(),
})
.collect())
}
fn get_signatures_with_parser(
&self,
source: &[u8],
_parser: &mut Parser,
) -> Result<Vec<SignatureInfo>> {
self.get_signatures(source)
}
fn compute_cfg(&self, _source: &[u8], _node_id: usize) -> Result<Graph<Block, Edge>> {
Ok(Graph {
blocks: vec![],
edges: vec![],
entry_block: 0,
exit_blocks: vec![],
})
}
fn extract_complexity(&self, _node: &tree_sitter::Node<'_>) -> ComplexityMetrics {
ComplexityMetrics {
cyclomatic: 1,
nesting_depth: 0,
line_count: 0,
token_count: 0,
}
}
}
type Section = (String, (usize, usize), Option<String>);
fn markdown_sections(text: &str) -> Vec<Section> {
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(text, options);
let mut sections: Vec<Section> = Vec::new();
let mut current_start: Option<usize> = None;
let mut title = String::new();
let mut in_heading = false;
for (event, range) in parser.into_offset_iter() {
match event {
Event::Start(Tag::Heading { .. }) => {
if let Some(start) = current_start.take() {
sections.push((std::mem::take(&mut title), (start, range.start), None));
}
title.clear();
in_heading = true;
current_start = Some(range.start);
}
Event::Text(chunk) => {
if in_heading && title.len() < 200 {
title.push_str(&chunk);
}
}
Event::End(TagEnd::Heading(_)) => {
in_heading = false;
}
_ => {}
}
}
if let Some(start) = current_start {
sections.push((title, (start, text.len()), None));
}
for (title, (start, end), summary_slot) in &mut sections {
let _ = title;
let body = &text.get(*start..*end).unwrap_or("");
*summary_slot = first_paragraph(body, title.len());
}
sections
}
fn next_line_start(text: &str, start: usize) -> usize {
match text[start..].find('\n') {
Some(nl) => start + nl + 1,
None => text.len(),
}
}
fn rst_sections(text: &str) -> Vec<Section> {
let lines: Vec<(usize, &str)> = text
.lines()
.scan(0usize, |offset, line| {
let start = *offset;
*offset = next_line_start(text, start);
Some((start, line))
})
.collect();
let mut sections: Vec<Section> = Vec::new();
let mut i = 1;
while i < lines.len() {
let (pos, line) = lines[i];
let trimmed = line.trim_end();
let is_underline = !trimmed.is_empty()
&& trimmed.len() >= 3
&& trimmed
.chars()
.all(|c| matches!(c, '=' | '-' | '~' | '^' | '"' | '\''));
if is_underline {
let (prev_pos, prev_line) = lines[i - 1];
let title = prev_line.trim();
if !title.is_empty()
&& !title
.chars()
.all(|c| matches!(c, '=' | '-' | '~' | '^' | '"' | '\''))
{
sections.push((title.to_string(), (prev_pos, text.len()), None));
}
}
let _ = pos;
i += 1;
}
let starts: Vec<usize> = sections.iter().map(|(_, range, _)| range.0).collect();
for (section, next_start) in sections.iter_mut().zip(starts.iter().skip(1)) {
section.1.1 = *next_start;
}
if let Some(last) = sections.last_mut() {
last.1.1 = text.len();
let body = &text.get(last.1.0..).unwrap_or("");
last.2 = first_paragraph(body, 0);
}
sections
}
fn adoc_sections(text: &str) -> Vec<Section> {
let mut sections = Vec::new();
let mut offset = 0usize;
for line in text.lines() {
let start = offset;
offset = next_line_start(text, offset);
let trimmed = line.trim_start();
let level = trimmed.chars().take_while(|&c| c == '=').count();
if level >= 1 && trimmed.len() > level && trimmed.as_bytes()[level] == b' ' {
let title = trimmed[level + 1..].trim();
if !title.is_empty() {
sections.push((title.to_string(), (start, text.len()), None));
}
}
}
let starts: Vec<usize> = sections.iter().map(|(_, range, _)| range.0).collect();
for (section, next_start) in sections.iter_mut().zip(starts.iter().skip(1)) {
section.1.1 = *next_start;
}
if let Some(last) = sections.last_mut() {
last.1.1 = text.len();
let body = &text.get(last.1.0..).unwrap_or("");
last.2 = first_paragraph(body, 0);
}
sections
}
fn plain_sections(text: &str) -> Vec<Section> {
if text.trim().is_empty() {
return Vec::new();
}
let title = text
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("document")
.chars()
.take(120)
.collect::<String>();
vec![(title, (0, text.len()), first_paragraph(text, 0))]
}
fn first_paragraph(body: &str, _heading_len: usize) -> Option<String> {
let after = match body.find("\n\n") {
Some(blank) => &body[blank + 2..],
None => body
.strip_prefix('#')
.map(|rest| rest.trim_start())
.unwrap_or(body),
};
let paragraph: String = after
.trim_start()
.lines()
.take_while(|line| !line.trim().is_empty())
.collect::<Vec<_>>()
.join(" ");
let trimmed: String = paragraph.trim().chars().take(500).collect();
(!trimmed.is_empty()).then_some(trimmed)
}
fn sanitize_name(raw: &str, byte_range: &(usize, usize)) -> String {
let cleaned: String = raw
.trim()
.chars()
.map(|c| {
if c.is_alphanumeric() || matches!(c, '_' | '-' | ' ' | '.' | '/') {
c
} else {
' '
}
})
.collect();
let collapsed = cleaned.split_whitespace().collect::<Vec<_>>().join("_");
if collapsed.is_empty() {
format!("section_{}", byte_range.0)
} else {
collapsed.chars().take(120).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_markdown_sections_have_exact_byte_ranges() {
let text = "# Title\n\nintro paragraph.\n\n## Section A\n\ncontent a\n\n## Section B\n\ncontent b\n";
let sections = markdown_sections(text);
assert_eq!(sections.len(), 3, "{sections:?}");
assert_eq!(sections[0].0, "Title");
assert_eq!(sections[1].0, "Section A");
assert_eq!(sections[2].0, "Section B");
assert_eq!(
text[sections[1].1.0..sections[1].1.1].trim(),
"## Section A\n\ncontent a"
);
assert_eq!(sections[1].2.as_deref(), Some("content a"));
}
#[test]
fn test_plain_text_whole_file_section() {
let sections = plain_sections("My Notes\n\nsome body\n");
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].0, "My Notes");
assert_eq!(sections[0].1, (0, 20));
}
#[test]
fn test_doc_parser_signatures_carry_kind() {
let parser = DocParser::new(DocFlavor::Markdown);
let signatures = parser
.get_signatures(b"# Architecture\n\nLeIndex has layers.\n")
.unwrap();
assert_eq!(signatures.len(), 1);
assert_eq!(signatures[0].name, "Architecture");
assert_eq!(signatures[0].return_type.as_deref(), Some("doc_section"));
assert_eq!(
signatures[0].docstring.as_deref(),
Some("LeIndex has layers.")
);
}
#[test]
fn test_flavor_for_extension() {
assert_eq!(DocFlavor::for_extension("md"), Some(DocFlavor::Markdown));
assert_eq!(DocFlavor::for_extension("RST"), Some(DocFlavor::Rst));
assert_eq!(DocFlavor::for_extension("txt"), Some(DocFlavor::Plain));
assert_eq!(DocFlavor::for_extension("rs"), None);
}
#[test]
fn test_oversized_doc_truncates_not_drops() {
let mut big = vec![b'#'; 64];
big.resize(64 + 1_100_000, b'\n');
let parser = DocParser::new(DocFlavor::Plain);
assert!(parser.get_signatures(&big).is_ok());
}
#[test]
fn test_oversized_doc_truncates_at_a_character_boundary() {
let parser = DocParser::new(DocFlavor::Markdown);
let mut body = vec![b'#'; MAX_DOC_FILE_BYTES - 1];
body.extend_from_slice("é\n# Tail heading\n".as_bytes());
assert!(body.len() > MAX_DOC_FILE_BYTES);
assert_eq!(
body[MAX_DOC_FILE_BYTES - 1],
0xC3,
"cut lands mid-codepoint"
);
let signatures = parser
.get_signatures(&body)
.expect("truncation must not fail UTF-8 validation");
let _ = signatures;
}
#[test]
fn test_rst_and_adoc_section_ranges_survive_crlf() {
let rst =
"Title One\r\n=====\r\n\r\nintro text\r\n\r\nTitle Two\r\n=====\r\n\r\nbody two\r\n";
let sections = rst_sections(rst);
assert!(
sections.len() >= 2,
"both headings detected under CRLF, got {sections:?}"
);
assert_eq!(sections[0].0, "Title One");
assert_eq!(sections[1].0, "Title Two");
for (name, (start, end), _) in §ions {
assert!(
end > start && *end <= rst.len(),
"section {name} range ({start},{end}) out of bounds/empty"
);
let slice = &rst[*start..*end];
assert!(
slice.contains(name),
"section {name} must start at its own title, got: {slice:?}"
);
}
let adoc = "= Title One\r\n\r\nintro\r\n\r\n== Title Two\r\n\r\nbody two\r\n";
let sections = adoc_sections(adoc);
assert!(
sections.len() >= 2,
"both adoc headings under CRLF: {sections:?}"
);
for (name, (start, end), _) in §ions {
assert!(end > start && *end <= adoc.len());
let slice = &adoc[*start..*end];
assert!(
slice.contains(name),
"adoc section {name} must start at its own title, got: {slice:?}"
);
}
}
}