use tree_sitter::Node;
use super::{ProseRange, shared::child_of_kind};
const NON_PROSE_CONTENT: &[&str] = &[
"code-block",
"code",
"sourcecode",
"math",
"raw",
"csv-table",
"include",
"literalinclude",
"toctree",
"highlight",
"image",
];
const PROSE_OPTION_FIELDS: &[&str] = &["caption", "alt"];
const PROSE_ARGUMENT: &[&str] = &[
"csv-table",
"list-table",
"table",
"admonition",
"rubric",
"topic",
"sidebar",
];
pub fn extract(text: &str, root: Node) -> Vec<ProseRange> {
let mut ranges = Vec::new();
collect_prose_ranges(root, text, &mut ranges);
ranges
}
fn collect_prose_ranges(node: Node, text: &str, out: &mut Vec<ProseRange>) {
match node.kind() {
"paragraph" | "title" => push_range(node, out),
"directive" => collect_directive(node, text, out),
_ => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_prose_ranges(child, text, out);
}
}
}
}
fn push_range(node: Node, out: &mut Vec<ProseRange>) {
let mut exclusions = Vec::new();
collect_exclusions(node, &mut exclusions);
out.push(ProseRange {
start_byte: node.start_byte(),
end_byte: node.end_byte(),
exclusions,
language: None,
});
}
fn collect_directive(node: Node, text: &str, out: &mut Vec<ProseRange>) {
let directive_type = child_of_kind(node, "type").map(|n| &text[n.byte_range()]);
let content_is_prose = !directive_type.is_some_and(|name| NON_PROSE_CONTENT.contains(&name));
let argument_is_prose = directive_type.is_some_and(|name| PROSE_ARGUMENT.contains(&name));
let Some(body) = child_of_kind(node, "body") else {
return;
};
let mut cursor = body.walk();
for child in body.children(&mut cursor) {
match child.kind() {
"options" => collect_option_fields(child, text, out),
"content" if content_is_prose => collect_content(child, text, out),
"arguments" if argument_is_prose => push_range(child, out),
_ => {}
}
}
}
fn collect_option_fields(options: Node, text: &str, out: &mut Vec<ProseRange>) {
let mut cursor = options.walk();
for field in options.children(&mut cursor) {
let name = child_of_kind(field, "field_name").map(|n| &text[n.byte_range()]);
if !name.is_some_and(|name| PROSE_OPTION_FIELDS.contains(&name)) {
continue;
}
if let Some(value) = child_of_kind(field, "field_body") {
collect_prose_ranges(value, text, out);
}
}
}
fn collect_content(content: Node, text: &str, out: &mut Vec<ProseRange>) {
let end = content.end_byte();
let start = text[..content.start_byte()]
.rfind('\n')
.map_or(0, |nl| nl + 1);
let mut paragraph: Option<(usize, usize)> = None;
let mut skipping_under: Option<usize> = None;
let mut in_doctest = false;
for (offset, line) in line_offsets(&text[start..end], start) {
let trimmed = line.trim_start();
let indent = line.len() - trimmed.len();
if trimmed.trim_end().is_empty() {
flush(&mut paragraph, text, out);
in_doctest = false;
continue;
}
if in_doctest {
continue;
}
if let Some(base) = skipping_under {
if indent > base {
continue;
}
skipping_under = None;
}
if opens_non_prose_block(trimmed) {
flush(&mut paragraph, text, out);
skipping_under = Some(indent);
in_doctest = trimmed.starts_with(">>>");
if !trimmed.starts_with("..") && !in_doctest {
out.push(ProseRange {
start_byte: offset + indent,
end_byte: offset + line.trim_end().len(),
exclusions: inline_literals(line.trim_end(), offset),
language: None,
});
}
continue;
}
let line_end = offset + line.trim_end().len();
match &mut paragraph {
Some((_, para_end)) => *para_end = line_end,
None => paragraph = Some((offset + indent, line_end)),
}
}
flush(&mut paragraph, text, out);
}
fn flush(paragraph: &mut Option<(usize, usize)>, text: &str, out: &mut Vec<ProseRange>) {
if let Some((start, end)) = paragraph.take()
&& start < end
{
out.push(ProseRange {
start_byte: start,
end_byte: end,
exclusions: inline_literals(&text[start..end], start),
language: None,
});
}
}
fn opens_non_prose_block(trimmed: &str) -> bool {
trimmed.starts_with("..")
|| trimmed.starts_with(">>>")
|| trimmed.trim_end().ends_with("::")
}
fn inline_literals(line: &str, offset: usize) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i..].starts_with(b"``") {
let rest = &line[i + 2..];
if let Some(close) = rest.find("``") {
out.push((offset + i, offset + i + 2 + close + 2));
i += 2 + close + 2;
continue;
}
}
if bytes[i] == b'`'
&& let Some(close) = line[i + 1..].find('`')
{
out.push((offset + i, offset + i + 1 + close + 1));
i += 1 + close + 1;
continue;
}
i += 1;
}
out
}
fn line_offsets(block: &str, base: usize) -> impl Iterator<Item = (usize, &str)> {
let mut offset = base;
block.split_inclusive('\n').map(move |line| {
let here = offset;
offset += line.len();
(here, line.strip_suffix('\n').unwrap_or(line))
})
}
fn collect_exclusions(node: Node, out: &mut Vec<(usize, usize)>) {
let kind = node.kind();
if kind == "literal" || kind == "interpreted_text" {
out.push((node.start_byte(), node.end_byte()));
return;
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_exclusions(child, out);
}
}
#[cfg(test)]
mod tests {
use crate::prose::ProseExtractor;
use crate::prose::latex::LatexExtras;
use anyhow::Result;
fn rst_extractor() -> Result<ProseExtractor> {
let language: tree_sitter::Language = tree_sitter_rst::LANGUAGE.into();
ProseExtractor::new(language)
}
#[test]
fn test_rst_basic_extraction() -> Result<()> {
let mut extractor = rst_extractor()?;
let text = "My Title\n========\n\nThis is a paragraph.\n";
let ranges = extractor.extract(text, "rst", &LatexExtras::default())?;
let all_prose: String = ranges.iter().map(|r| r.extract_text(text)).collect();
assert!(
all_prose.contains("My Title"),
"Title should be extracted, got: {all_prose:?}"
);
assert!(
all_prose.contains("This is a paragraph"),
"Paragraph should be extracted, got: {all_prose:?}"
);
Ok(())
}
#[test]
fn test_rst_code_block_excluded() -> Result<()> {
let mut extractor = rst_extractor()?;
let text =
"Some text.\n\n.. code-block:: python\n\n def hello():\n pass\n\nMore text.\n";
let ranges = extractor.extract(text, "rst", &LatexExtras::default())?;
let all_prose: String = ranges.iter().map(|r| r.extract_text(text)).collect();
assert!(
all_prose.contains("Some text"),
"Paragraph before code should be extracted, got: {all_prose:?}"
);
assert!(
all_prose.contains("More text"),
"Paragraph after code should be extracted, got: {all_prose:?}"
);
assert!(
!all_prose.contains("def hello"),
"Code block content should not be in prose, got: {all_prose:?}"
);
Ok(())
}
#[test]
fn test_rst_math_excluded() -> Result<()> {
let mut extractor = rst_extractor()?;
let text = "Before math.\n\n.. math::\n\n E = mc^2\n\nAfter math.\n";
let ranges = extractor.extract(text, "rst", &LatexExtras::default())?;
let all_prose: String = ranges.iter().map(|r| r.extract_text(text)).collect();
assert!(
all_prose.contains("Before math"),
"Paragraph before math should be extracted, got: {all_prose:?}"
);
assert!(
!all_prose.contains("mc^2"),
"Math directive content should not be in prose, got: {all_prose:?}"
);
Ok(())
}
#[test]
fn test_rst_inline_code_excluded() -> Result<()> {
let mut extractor = rst_extractor()?;
let text = "Use ``some_function()`` to do things.\n";
let ranges = extractor.extract(text, "rst", &LatexExtras::default())?;
let all_prose: String = ranges.iter().map(|r| r.extract_text(text)).collect();
assert!(
all_prose.contains("Use"),
"Text around inline code should be extracted, got: {all_prose:?}"
);
assert!(
!all_prose.contains("some_function"),
"Inline code should be excluded, got: {all_prose:?}"
);
Ok(())
}
fn prose_of(text: &str) -> Result<String> {
let mut extractor = rst_extractor()?;
let ranges = extractor.extract(text, "rst", &LatexExtras::default())?;
Ok(ranges
.iter()
.map(|r| r.extract_text(text).into_owned())
.collect::<Vec<_>>()
.join("\n"))
}
#[test]
fn test_rst_admonition_body_extracted() -> Result<()> {
let text = "\
.. note::
First paragraph of the note.
Second paragraph with ``inline_code`` here.
.. code-block:: rust
fn nested() {}
Fourth paragraph after nested code.
";
let prose = prose_of(text)?;
assert!(prose.contains("First paragraph of the note."), "{prose:?}");
assert!(prose.contains("Second paragraph with"), "{prose:?}");
assert!(prose.contains("Fourth paragraph after"), "{prose:?}");
assert!(!prose.contains("inline_code"), "{prose:?}");
assert!(!prose.contains("fn nested"), "{prose:?}");
Ok(())
}
#[test]
fn test_rst_figure_caption_extracted() -> Result<()> {
let text = "\
.. figure:: diagram.png
:alt: An alternative description
The caption of the figure.
";
let prose = prose_of(text)?;
assert!(prose.contains("The caption of the figure."), "{prose:?}");
assert!(prose.contains("An alternative description"), "{prose:?}");
assert!(!prose.contains("diagram.png"), "{prose:?}");
Ok(())
}
#[test]
fn test_rst_code_block_caption_extracted_but_not_code() -> Result<()> {
let text = "\
.. code-block:: rust
:caption: A caption above the code
fn main() {}
";
let prose = prose_of(text)?;
assert!(prose.contains("A caption above the code"), "{prose:?}");
assert!(!prose.contains("fn main"), "{prose:?}");
assert!(!prose.contains("rust"), "{prose:?}");
Ok(())
}
#[test]
fn test_rst_literal_and_doctest_blocks_skipped() -> Result<()> {
let text = "\
.. warning::
Build it like this::
$ cargo build --unknown-flag
Then run the doctest:
>>> some_function()
'result'
Final paragraph of the warning.
";
let prose = prose_of(text)?;
assert!(prose.contains("Build it like this"), "{prose:?}");
assert!(
prose.contains("Final paragraph of the warning."),
"{prose:?}"
);
assert!(!prose.contains("cargo build"), "{prose:?}");
assert!(!prose.contains("some_function"), "{prose:?}");
assert!(!prose.contains("'result'"), "{prose:?}");
Ok(())
}
#[test]
fn test_rst_toctree_paths_not_prose() -> Result<()> {
let text = ".. toctree::\n :maxdepth: 2\n\n guide/index\n api/index\n";
let prose = prose_of(text)?;
assert!(!prose.contains("guide/index"), "{prose:?}");
assert!(!prose.contains("maxdepth"), "{prose:?}");
Ok(())
}
#[test]
fn test_rst_table_caption_extracted() -> Result<()> {
let text =
".. csv-table:: A table caption\n :header: \"A\", \"B\"\n\n \"one\", \"two\"\n";
let prose = prose_of(text)?;
assert!(prose.contains("A table caption"), "{prose:?}");
assert!(!prose.contains("one"), "{prose:?}");
Ok(())
}
#[test]
fn test_rst_list_items_extracted() -> Result<()> {
let mut extractor = rst_extractor()?;
let text = "- First item\n- Second item\n";
let ranges = extractor.extract(text, "rst", &LatexExtras::default())?;
let all_prose: String = ranges.iter().map(|r| r.extract_text(text)).collect();
assert!(
all_prose.contains("First item"),
"List items should be extracted, got: {all_prose:?}"
);
assert!(
all_prose.contains("Second item"),
"List items should be extracted, got: {all_prose:?}"
);
Ok(())
}
}