use crate::models::EntityKind;
use tree_sitter::Node;
pub(crate) fn handle_markdown_capture(
cap_name: &str,
_text: &str,
node: Node<'_>,
source_bytes: &[u8],
) -> Option<(String, EntityKind, usize)> {
let start_line = node.start_position().row + 1;
match cap_name {
"markdown.document.name" => Some((
"Document".to_string(),
EntityKind::MarkdownDocument,
start_line,
)),
"markdown.section" => {
let heading_text = section_heading_text(node, source_bytes)?;
let clean_name = clean_heading_name(&heading_text);
Some((clean_name, EntityKind::MarkdownSection, start_line))
}
_ => None,
}
}
fn clean_heading_name(raw: &str) -> String {
let stripped: String = raw
.chars()
.map(|c| match c {
'[' | ']' | '(' | ')' | '`' | '*' | '_' | '#' | '!' => ' ',
other => other,
})
.collect();
stripped.split_whitespace().collect::<Vec<_>>().join(" ")
}
pub(crate) fn section_heading_text(section: Node<'_>, source: &[u8]) -> Option<String> {
let mut cursor = section.walk();
for child in section.children(&mut cursor) {
if child.kind() == "atx_heading" {
let inline = child.child_by_field_name("heading_content")?;
return inline.utf8_text(source).ok().map(str::to_string);
}
}
None
}
pub(crate) fn build_markdown_fqn(section: Node<'_>, source: &[u8]) -> String {
let mut chain: Vec<String> = Vec::new();
let mut current = Some(section);
while let Some(node) = current {
if node.kind() == "section" {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "atx_heading" {
if let Some(inline) = child.child_by_field_name("heading_content")
&& let Ok(text) = inline.utf8_text(source)
{
chain.push(clean_heading_name(text));
}
break;
}
}
}
current = node.parent();
}
chain.reverse();
chain.join(" > ")
}
pub(crate) fn extract_document_intro(document: Node<'_>, source: &[u8]) -> String {
let mut cursor = document.walk();
let mut section_count = 0;
for child in document.children(&mut cursor) {
if child.kind() == "section" {
section_count += 1;
if section_count == 2 {
let end_byte = child.start_byte();
return std::str::from_utf8(&source[..end_byte])
.unwrap_or("")
.to_string();
}
}
}
document.utf8_text(source).unwrap_or("").to_string()
}
#[cfg(test)]
mod tests {
use crate::pipeline::parser::test_utils::parse_markdown_snippet;
#[test]
fn test_print_markdown_ast() {
let code = "# Header 1\nSome text.\n## Header 2\nMore text.";
let tree = parse_markdown_snippet(code).expect("Failed to parse");
println!("{}", tree.root_node().to_sexp());
}
}
#[cfg(test)]
mod section_heading_text_tests {
use super::section_heading_text;
use crate::pipeline::parser::test_utils::parse_markdown_snippet;
use tree_sitter::Node;
fn find_section_by_heading<'a>(
node: Node<'a>,
source: &[u8],
heading_text: &str,
) -> Option<Node<'a>> {
if node.kind() == "section"
&& let Some(text) = section_heading_text(node, source)
&& text == heading_text
{
return Some(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_section_by_heading(child, source, heading_text) {
return Some(found);
}
}
None
}
fn first_section_node<'tree>(tree: &'tree tree_sitter::Tree) -> Option<Node<'tree>> {
fn walk<'a>(node: Node<'a>) -> Option<Node<'a>> {
if node.kind() == "section" {
return Some(node);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(s) = walk(child) {
return Some(s);
}
}
None
}
walk(tree.root_node())
}
#[test]
fn extracts_h1_heading_text() {
let code = "# Hello World\n\nSome paragraph.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let section = first_section_node(&tree).expect("section");
assert_eq!(
section_heading_text(section, code.as_bytes()),
Some("Hello World".to_string()),
);
}
#[test]
fn extracts_h2_heading_text() {
let code = "## Setup\n\nBody.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let section = first_section_node(&tree).expect("section");
assert_eq!(
section_heading_text(section, code.as_bytes()),
Some("Setup".to_string()),
);
}
#[test]
fn extracts_deepest_heading_correctly() {
let code = "# Top\n\n## Middle\n\n### Bottom\n\nBody.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let source = code.as_bytes();
let top = find_section_by_heading(tree.root_node(), source, "Top").expect("Top section");
let middle =
find_section_by_heading(tree.root_node(), source, "Middle").expect("Middle section");
let bottom =
find_section_by_heading(tree.root_node(), source, "Bottom").expect("Bottom section");
assert_eq!(section_heading_text(top, source), Some("Top".to_string()),);
assert_eq!(
section_heading_text(middle, source),
Some("Middle".to_string()),
);
assert_eq!(
section_heading_text(bottom, source),
Some("Bottom".to_string()),
);
}
#[test]
fn handles_heading_with_special_characters() {
let code = "## What's New in v2.0?\n\nDetails.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let section = first_section_node(&tree).expect("section");
assert_eq!(
section_heading_text(section, code.as_bytes()),
Some("What's New in v2.0?".to_string()),
);
}
#[test]
fn returns_none_for_non_section_node() {
let code = "Just a paragraph, no heading.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let root = tree.root_node();
assert_eq!(section_heading_text(root, code.as_bytes()), None);
}
}
#[cfg(test)]
mod build_markdown_fqn_tests {
use super::build_markdown_fqn;
use crate::pipeline::parser::test_utils::parse_markdown_snippet;
use tree_sitter::Node;
fn find_section_by_heading<'a>(
node: Node<'a>,
source: &[u8],
heading_text: &str,
) -> Option<Node<'a>> {
if node.kind() == "section" {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "atx_heading"
&& let Some(inline) = child.child_by_field_name("heading_content")
&& let Ok(text) = inline.utf8_text(source)
&& text == heading_text
{
return Some(node);
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if let Some(found) = find_section_by_heading(child, source, heading_text) {
return Some(found);
}
}
None
}
#[test]
fn single_h1_returns_only_its_heading() {
let code = "# Hello World\n\nBody.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let section = find_section_by_heading(tree.root_node(), code.as_bytes(), "Hello World")
.expect("Hello World section");
assert_eq!(build_markdown_fqn(section, code.as_bytes()), "Hello World",);
}
#[test]
fn nested_h2_includes_h1_ancestor() {
let code = "# Top\n\n## Setup\n\nBody.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let setup = find_section_by_heading(tree.root_node(), code.as_bytes(), "Setup")
.expect("Setup section");
assert_eq!(build_markdown_fqn(setup, code.as_bytes()), "Top > Setup",);
}
#[test]
fn deeply_nested_h3_includes_full_chain() {
let code = "# Top\n\n## Middle\n\n### Bottom\n\nBody.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let bottom = find_section_by_heading(tree.root_node(), code.as_bytes(), "Bottom")
.expect("Bottom section");
assert_eq!(
build_markdown_fqn(bottom, code.as_bytes()),
"Top > Middle > Bottom",
);
}
#[test]
fn sibling_sections_have_distinct_chains() {
let code = "# Top\n\n## First\n\nBody A.\n\n## Second\n\nBody B.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let source = code.as_bytes();
let first =
find_section_by_heading(tree.root_node(), source, "First").expect("First section");
let second =
find_section_by_heading(tree.root_node(), source, "Second").expect("Second section");
assert_eq!(build_markdown_fqn(first, source), "Top > First");
assert_eq!(build_markdown_fqn(second, source), "Top > Second");
}
#[test]
fn duplicate_heading_text_at_different_levels_produces_distinct_chains() {
let code = "\
# Doc
## Setup
First Setup body.
## Configuration
### Setup
Second Setup body.
";
let tree = parse_markdown_snippet(code).expect("parse");
let source = code.as_bytes();
let mut found_chains: Vec<String> = Vec::new();
collect_section_chains(tree.root_node(), source, &mut found_chains);
assert!(
found_chains.contains(&"Doc > Setup".to_string()),
"expected 'Doc > Setup', got {:?}",
found_chains
);
assert!(
found_chains.contains(&"Doc > Configuration > Setup".to_string()),
"expected 'Doc > Configuration > Setup', got {:?}",
found_chains
);
}
fn collect_section_chains(node: Node<'_>, source: &[u8], chains: &mut Vec<String>) {
if node.kind() == "section" {
chains.push(build_markdown_fqn(node, source));
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_section_chains(child, source, chains);
}
}
#[test]
fn returns_empty_string_for_non_section_node() {
let code = "Just a paragraph.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let result = build_markdown_fqn(tree.root_node(), code.as_bytes());
assert_eq!(result, "");
}
#[test]
fn preserves_special_characters_in_heading_text() {
let code = "# Doc\n\n## What's New in v2.0?\n\nDetails.\n";
let tree = parse_markdown_snippet(code).expect("parse");
let section =
find_section_by_heading(tree.root_node(), code.as_bytes(), "What's New in v2.0?")
.expect("section");
assert_eq!(
build_markdown_fqn(section, code.as_bytes()),
"Doc > What's New in v2.0?",
);
}
}
#[cfg(test)]
mod clean_heading_name_tests {
use super::clean_heading_name;
#[test]
fn passes_through_plain_text() {
assert_eq!(clean_heading_name("Setup"), "Setup");
}
#[test]
fn strips_link_brackets_and_parens() {
assert_eq!(clean_heading_name("Use [foo](bar.md)"), "Use foo bar.md");
}
}
#[cfg(test)]
mod extract_document_intro_tests {
use super::extract_document_intro;
use crate::pipeline::parser::test_utils::parse_markdown_snippet;
fn extract(code: &str) -> String {
let tree = parse_markdown_snippet(code).expect("parse");
extract_document_intro(tree.root_node(), code.as_bytes())
}
#[test]
fn empty_file_returns_empty() {
assert_eq!(extract(""), "");
}
#[test]
fn no_headings_returns_whole_file() {
let code = "Just a paragraph.\n\nAnother paragraph, no headings here.\n";
assert_eq!(extract(code), code);
}
#[test]
fn single_section_returns_whole_file() {
let code = "# Only Heading\n\nSome body content.\n";
assert_eq!(extract(code), code);
}
#[test]
fn stops_at_second_top_level_section() {
let code = "# First\n\nFirst body.\n\n# Second\n\nSecond body.\n";
let intro = extract(code);
assert!(
intro.contains("# First"),
"intro should include the first heading, got: {:?}",
intro
);
assert!(
intro.contains("First body."),
"intro should include the first section's body"
);
assert!(
!intro.contains("# Second"),
"intro must stop before the second heading, got: {:?}",
intro
);
assert!(
!intro.contains("Second body."),
"intro must not include the second section's body"
);
}
#[test]
fn includes_intro_paragraphs_before_first_heading() {
let code = "Intro paragraph here.\n\n# First\n\nFirst body.\n\n# Second\n\nSecond body.\n";
let intro = extract(code);
assert!(intro.contains("Intro paragraph here."));
}
#[test]
fn nested_sections_dont_count_toward_the_limit() {
let code = "# Top\n\n## Nested\n\nNested body.\n\n# Second Top\n\nSecond body.\n";
let intro = extract(code);
assert!(intro.contains("# Top"));
assert!(intro.contains("## Nested"));
assert!(intro.contains("Nested body."));
assert!(
!intro.contains("# Second Top"),
"intro must stop at the second top-level heading, got: {:?}",
intro
);
}
#[test]
fn handles_h2_as_top_level_when_no_h1() {
let code = "## First\n\nBody A.\n\n## Second\n\nBody B.\n";
let intro = extract(code);
assert!(intro.contains("## First"));
assert!(intro.contains("Body A."));
assert!(!intro.contains("## Second"));
}
}