use anyhow::{Result, anyhow};
use tree_sitter::{Language, Node, Parser, Query, QueryCursor, StreamingIterator};
use super::ProseRange;
pub fn extract(
text: &str,
root: Node,
language: &Language,
lang_id: &str,
) -> Result<Vec<ProseRange>> {
let query_str = match lang_id {
"markdown" => "(paragraph) @prose (atx_heading) @prose (pipe_table_cell) @prose",
"html" => "(text) @prose",
_ => "(paragraph) @prose",
};
let query = Query::new(language, query_str)
.map_err(|e| anyhow!("Failed to create query for {lang_id}: {e}"))?;
let mut inline = if lang_id == "markdown" {
let mut parser = Parser::new();
parser
.set_language(&tree_sitter_md::INLINE_LANGUAGE.into())
.map_err(|e| anyhow!("Failed to load the Markdown inline grammar: {e}"))?;
Some(parser)
} else {
None
};
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, root, text.as_bytes());
let mut ranges = Vec::new();
while let Some(m) = matches.next() {
for capture in m.captures() {
let mut exclusions = Vec::new();
if let Some(parser) = inline.as_mut() {
inline_markup(capture.node, text, parser, &mut exclusions);
}
ranges.push(ProseRange {
start_byte: capture.node.start_byte(),
end_byte: capture.node.end_byte(),
exclusions,
language: None,
});
}
}
Ok(ranges)
}
const INLINE_MARKUP: &[&str] = &[
"emphasis_delimiter",
"code_span",
"uri_autolink",
"email_autolink",
"link_destination",
"link_title",
"link_label",
"html_tag",
"backslash_escape",
"entity_reference",
];
const INLINE_WRAPPERS: &[&str] = &[
"inline_link",
"image",
"shortcut_link",
"collapsed_reference_link",
"full_reference_link",
];
const WRAPPED_PROSE: &[&str] = &["link_text", "image_description"];
fn inline_markup(node: Node, text: &str, parser: &mut Parser, out: &mut Vec<(usize, usize)>) {
if matches!(node.kind(), "inline" | "pipe_table_cell") {
let base = node.start_byte();
let run = &text[node.byte_range()];
let Some(tree) = parser.parse(run, None) else {
return;
};
collect_inline_markup(tree.root_node(), base, out);
return;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
inline_markup(child, text, parser, out);
}
}
fn collect_inline_markup(node: Node, base: usize, out: &mut Vec<(usize, usize)>) {
if INLINE_MARKUP.contains(&node.kind()) {
out.push((base + node.start_byte(), base + node.end_byte()));
return;
}
if INLINE_WRAPPERS.contains(&node.kind()) {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if WRAPPED_PROSE.contains(&child.kind()) {
collect_inline_markup(child, base, out);
} else {
out.push((base + child.start_byte(), base + child.end_byte()));
}
}
return;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_inline_markup(child, base, out);
}
}
#[cfg(test)]
mod tests {
use crate::prose::{ProseExtractor, latex::LatexExtras};
fn checked_text(markdown: &str) -> String {
let language: tree_sitter::Language = tree_sitter_md::LANGUAGE.into();
let mut extractor = ProseExtractor::new(language).expect("extractor");
extractor
.extract(markdown, "markdown", &LatexExtras::default())
.expect("extraction")
.iter()
.map(|r| r.extract_text(markdown).trim_end().to_string())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn emphasis_delimiters_do_not_reach_the_engine() {
assert_eq!(
checked_text("The word _reception_ here.\n"),
"The word reception here."
);
}
#[test]
fn strong_delimiters_do_not_reach_the_engine() {
assert_eq!(checked_text("A **strong** word.\n"), "A strong word.");
}
#[test]
fn an_underscore_inside_a_word_is_not_a_delimiter() {
assert_eq!(
checked_text("Use snake_case_names here.\n"),
"Use snake_case_names here."
);
}
#[test]
fn inline_code_is_excluded_whole() {
assert_eq!(checked_text("Call `recieve` now.\n"), "Call now.");
}
#[test]
fn a_link_keeps_its_text_and_drops_its_url() {
assert_eq!(
checked_text("See [the guide](https://example.org/x) now.\n"),
"See the guide now."
);
}
#[test]
fn an_autolink_is_excluded_whole() {
assert_eq!(
checked_text("Visit <https://example.org> now.\n"),
"Visit now."
);
}
#[test]
fn an_image_keeps_its_alt_text() {
assert_eq!(
checked_text("Here  is.\n"),
"Here a diagram is."
);
}
#[test]
fn a_backslash_escape_leaves_the_word_it_escapes() {
assert_eq!(
checked_text("An \\_escaped\\_ word.\n"),
"An escaped word."
);
}
#[test]
fn a_heading_gets_the_same_treatment() {
assert_eq!(
checked_text("# A _stressed_ heading\n"),
"# A stressed heading"
);
}
#[test]
fn prose_without_markup_is_untouched() {
assert_eq!(
checked_text("Just a plain sentence.\n"),
"Just a plain sentence."
);
}
#[test]
fn markup_inside_a_table_cell_is_excluded_too() {
let table = "| Col | Meaning |\n| --- | ------- |\n| `id` | A _cell_. |\n";
let checked = checked_text(table);
assert!(
!checked.contains('`') && !checked.contains('_'),
"markup survived into a table cell: {checked:?}"
);
assert!(
checked.contains("cell"),
"the cell's prose was lost: {checked:?}"
);
assert!(!checked.contains("id"), "a code span survived: {checked:?}");
}
#[test]
fn a_fenced_block_and_its_info_string_never_reach_the_engine() {
let doc = "Before.\n\n```python title=\"config.yaml\"\nrecieve = 1\n```\n\nAfter.\n";
let checked = checked_text(doc);
assert!(
!checked.contains("recieve"),
"fence content leaked: {checked:?}"
);
assert!(
!checked.contains("config.yaml"),
"info string leaked: {checked:?}"
);
assert!(checked.contains("Before.") && checked.contains("After."));
}
#[test]
fn a_tables_delimiter_row_is_not_prose() {
let table = "| Col |\n| --- |\n| Yes |\n";
let checked = checked_text(table);
assert!(
!checked.contains("---"),
"delimiter row leaked: {checked:?}"
);
}
}