use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::doctree::{kinds, AttrValue, Doctree, Node};
use crate::env::numbers::{clean_astext, std_numfig_title};
use crate::env::BuildEnvironment;
use crate::error::{BuildWarning, WarningType};
use crate::rst::RegistryExport;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StdDomainData {
pub labels: BTreeMap<String, (String, String, String)>,
pub anonlabels: BTreeMap<String, (String, String)>,
pub objects: BTreeMap<(String, String), (String, String)>,
pub progoptions: BTreeMap<(Option<String>, String), (String, String)>,
pub terms: BTreeMap<String, (String, String)>,
}
const PRESEEDED_LABELS: &[(&str, &str, &str)] = &[
("genindex", "genindex", "Index"),
("modindex", "py-modindex", "Module Index"),
("py-modindex", "py-modindex", "Python Module Index"),
("search", "search", "Search Page"),
];
impl Default for StdDomainData {
fn default() -> Self {
let mut labels = BTreeMap::new();
let mut anonlabels = BTreeMap::new();
for (name, docname, title) in PRESEEDED_LABELS {
labels.insert(
(*name).to_string(),
((*docname).to_string(), String::new(), (*title).to_string()),
);
anonlabels.insert((*name).to_string(), ((*docname).to_string(), String::new()));
}
Self {
labels,
anonlabels,
objects: BTreeMap::new(),
progoptions: BTreeMap::new(),
terms: BTreeMap::new(),
}
}
}
impl StdDomainData {
pub fn note_object(
&mut self,
objtype: &str,
name: &str,
docname: &str,
labelid: &str,
) -> Option<String> {
let key = (objtype.to_string(), name.to_string());
let previous = self.objects.get(&key).map(|(doc, _)| doc.clone());
self.objects
.insert(key, (docname.to_string(), labelid.to_string()));
previous
}
pub fn note_term(&mut self, term: &str, docname: &str, labelid: &str) -> Option<String> {
let previous = self.note_object("term", term, docname, labelid);
self.terms.insert(
term.to_lowercase(),
(docname.to_string(), labelid.to_string()),
);
previous
}
pub fn add_program_option(
&mut self,
program: Option<&str>,
name: &str,
docname: &str,
labelid: &str,
) {
self.progoptions
.entry((program.map(str::to_string), name.to_string()))
.or_insert_with(|| (docname.to_string(), labelid.to_string()));
}
}
pub struct DocumentSource<'a> {
pub docname: &'a str,
pub doctree: &'a Doctree,
pub registry: &'a RegistryExport,
pub path: &'a Path,
}
pub(crate) fn source_path_of(doc: &DocumentSource<'_>, source: u16) -> PathBuf {
doc.doctree
.sources
.get(source as usize)
.map(PathBuf::from)
.unwrap_or_else(|| doc.path.to_path_buf())
}
pub fn process_doc(
env: &mut BuildEnvironment,
doc: &DocumentSource<'_>,
doc2path: &dyn Fn(&str) -> PathBuf,
warnings: &mut Vec<BuildWarning>,
) {
let ids = DocumentIds::of(doc.doctree);
let mut parse_time: Vec<(usize, BuildWarning)> = Vec::new();
collect_glossary_terms(env, doc, &ids, &mut parse_time);
collect_descriptions(env, doc, &ids, &mut parse_time);
crate::env::py_domain::collect_registrations(env, doc, &ids, &mut parse_time);
parse_time.sort_by_key(|(order, _)| *order);
warnings.extend(parse_time.into_iter().map(|(_, warning)| warning));
collect_labels(env, doc, &ids, doc2path, warnings);
}
fn collect_labels(
env: &mut BuildEnvironment,
doc: &DocumentSource<'_>,
ids: &DocumentIds<'_>,
doc2path: &dyn Fn(&str) -> PathBuf,
warnings: &mut Vec<BuildWarning>,
) {
let mut named: Vec<(&str, &str, usize, &Node)> = Vec::new();
for (name, labelid, explicit) in &doc.registry.nameids {
if !explicit {
continue;
}
let Some(labelid) = labelid else { continue };
let Some((order, node)) = ids.get(labelid) else {
continue;
};
named.push((name, labelid, order, node));
}
named.sort_by_key(|(name, _, order, _)| (*order, *name));
for (name, labelid, _, node) in named {
if node.kind == kinds::FOOTNOTE
|| node.get("refuri").is_some()
|| node.kind.starts_with("desc_")
{
continue;
}
if let Some((other, _, _)) = env.std.labels.get(name) {
let (source_path, line) = doc.doctree.source_and_line(node.span);
warnings.push(
BuildWarning::new(
PathBuf::from(source_path),
Some(line as usize),
format!(
"duplicate label {name}, other instance in {}",
doc2path(other).display()
),
WarningType::DuplicateLabel,
)
.with_category(None),
);
}
env.std.anonlabels.insert(
name.to_string(),
(doc.docname.to_string(), labelid.to_string()),
);
let Some(sectname) = section_name(node) else {
continue;
};
env.std.labels.insert(
name.to_string(),
(doc.docname.to_string(), labelid.to_string(), sectname),
);
}
}
fn section_name(node: &Node) -> Option<String> {
if node.kind == kinds::SECTION {
return Some(clean_astext(node.children.first()?));
}
if node.kind == "rubric" {
return Some(clean_astext(node));
}
if is_enumerable_node(node) {
let title = numfig_title(node).unwrap_or_default();
return (!title.is_empty()).then_some(title);
}
let mut node = node;
if matches!(node.kind, kinds::DEFINITION_LIST | kinds::FIELD_LIST) && !node.children.is_empty()
{
node = &node.children[0];
}
if matches!(node.kind, kinds::FIELD | kinds::DEFINITION_LIST_ITEM) {
node = node.children.first()?;
}
if matches!(node.kind, kinds::TERM | kinds::FIELD_NAME) {
return Some(clean_astext(node));
}
let toctree = find_first(node, kinds::TOCTREE)?;
match toctree.get("caption") {
Some(AttrValue::Str(caption)) if !caption.is_empty() && !is_none_sentinel(caption) => {
Some(caption.clone())
}
_ => None,
}
}
pub(crate) fn is_none_sentinel(value: &str) -> bool {
value == "True"
}
fn is_enumerable_node(node: &Node) -> bool {
matches!(node.kind, "figure" | kinds::TABLE | "container")
}
fn numfig_title(node: &Node) -> Option<String> {
is_enumerable_node(node)
.then(|| std_numfig_title(node).map(clean_astext))
.flatten()
}
fn collect_glossary_terms(
env: &mut BuildEnvironment,
doc: &DocumentSource<'_>,
ids: &DocumentIds<'_>,
warnings: &mut Vec<(usize, BuildWarning)>,
) {
let mut terms: Vec<&Node> = Vec::new();
collect_glossary_term_nodes(&doc.doctree.root, &mut terms);
for term in terms {
let Some(node_id) = term.attrs.ids.first() else {
continue;
};
let text = term.astext();
if let Some(other) = env.std.note_term(&text, doc.docname, node_id) {
let (source_path, _) = doc.doctree.source_and_line(term.span);
let order = ids
.get(node_id)
.map(|(order, _)| order)
.unwrap_or(usize::MAX);
warnings.push((
order,
duplicate_object_warning(
PathBuf::from(source_path),
glossary_term_line(term),
"term",
&text,
&other,
),
));
}
}
}
fn collect_glossary_term_nodes<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
if node.kind == kinds::DEFINITION_LIST
&& node.attrs.classes.iter().any(|class| class == "glossary")
{
for item in &node.children {
for child in &item.children {
if child.kind == kinds::TERM {
out.push(child);
}
}
}
return;
}
for child in &node.children {
collect_glossary_term_nodes(child, out);
}
}
fn collect_descriptions(
env: &mut BuildEnvironment,
doc: &DocumentSource<'_>,
ids: &DocumentIds<'_>,
warnings: &mut Vec<(usize, BuildWarning)>,
) {
for record in &doc.registry.program_options {
env.std.add_program_option(
record.program.as_deref(),
&record.name,
doc.docname,
&record.node_id,
);
}
for record in &doc.registry.std_objects {
if let Some(other) =
env.std
.note_object(&record.objtype, &record.name, doc.docname, &record.node_id)
{
let order = ids
.get(&record.node_id)
.map(|(order, _)| order)
.unwrap_or(usize::MAX);
warnings.push((
order,
duplicate_object_warning(
source_path_of(doc, record.source),
record.line as usize,
&record.objtype,
&record.name,
&other,
),
));
}
}
}
fn glossary_term_line(term: &Node) -> usize {
(term.span.line as usize).saturating_sub(1)
}
fn duplicate_object_warning(
source_path: PathBuf,
line: usize,
objtype: &str,
name: &str,
other: &str,
) -> BuildWarning {
BuildWarning::new(
source_path,
Some(line),
format!("duplicate {objtype} description of {name}, other instance in {other}"),
WarningType::DuplicateLabel,
)
.with_category(None)
}
pub(crate) struct DocumentIds<'a> {
map: HashMap<&'a str, (usize, &'a Node)>,
}
impl<'a> DocumentIds<'a> {
pub(crate) fn of(doctree: &'a Doctree) -> Self {
let mut flat: Vec<FlatNode<'a>> = Vec::new();
flatten(&doctree.root, kinds::DOCUMENT, &mut flat);
let mut map: HashMap<&str, (usize, &Node)> = HashMap::new();
for (order, entry) in flat.iter().enumerate() {
for id in &entry.node.attrs.ids {
map.insert(id.as_str(), (order, entry.node));
}
}
for donation in propagations(&flat) {
for id in donation.ids {
map.insert(id.as_str(), (donation.order, donation.receiver));
}
}
Self { map }
}
pub(crate) fn get(&self, id: &str) -> Option<(usize, &'a Node)> {
self.map.get(id).copied()
}
pub(crate) fn node(&self, id: &str) -> Option<&'a Node> {
self.map.get(id).map(|(_, node)| *node)
}
}
struct Donation<'a> {
order: usize,
receiver: &'a Node,
ids: &'a [String],
}
fn propagations<'a>(flat: &[FlatNode<'a>]) -> Vec<Donation<'a>> {
let mut donations = Vec::new();
for (index, entry) in flat.iter().enumerate() {
if !is_propagating_target(entry.node, entry.parent) {
continue;
}
let Some((order, receiver)) = next_propagation_target(flat, index) else {
continue;
};
donations.push(Donation {
order,
receiver,
ids: entry.node.attrs.ids.as_slice(),
});
}
donations
}
pub(crate) struct PropagatedIds {
donations: HashMap<usize, Vec<String>>,
}
impl PropagatedIds {
pub(crate) fn of(doctree: &Doctree) -> Self {
let mut flat: Vec<FlatNode<'_>> = Vec::new();
flatten(&doctree.root, kinds::DOCUMENT, &mut flat);
let mut donations: HashMap<usize, Vec<String>> = HashMap::new();
for donation in propagations(&flat) {
donations
.entry(node_identity(donation.receiver))
.or_default()
.extend(donation.ids.iter().cloned());
}
Self { donations }
}
pub(crate) fn effective_ids(&self, node: &Node) -> Vec<String> {
let mut ids = node.attrs.ids.clone();
if let Some(donated) = self.donations.get(&node_identity(node)) {
ids.extend(donated.iter().cloned());
}
ids
}
}
fn node_identity(node: &Node) -> usize {
std::ptr::from_ref(node) as usize
}
struct FlatNode<'a> {
node: &'a Node,
parent: &'static str,
subtree_end: usize,
}
fn flatten<'a>(node: &'a Node, parent: &'static str, out: &mut Vec<FlatNode<'a>>) {
let index = out.len();
out.push(FlatNode {
node,
parent,
subtree_end: 0,
});
for child in &node.children {
flatten(child, node.kind, out);
}
out[index].subtree_end = out.len();
}
fn is_propagating_target(node: &Node, parent: &'static str) -> bool {
node.kind == kinds::TARGET
&& !matches!(
parent,
kinds::PARAGRAPH | kinds::TITLE | kinds::TERM | kinds::FIELD_NAME | "caption"
)
&& node.children.is_empty()
&& node.get("refid").is_none()
&& node.get("refuri").is_none()
&& node.get("refname").is_none()
}
fn next_propagation_target<'a>(flat: &[FlatNode<'a>], index: usize) -> Option<(usize, &'a Node)> {
let mut next = index + 1;
while let Some(entry) = flat.get(next) {
if entry.node.kind != kinds::SYSTEM_MESSAGE {
break;
}
next = entry.subtree_end;
}
let node = flat.get(next)?.node;
let blocked = matches!(
node.kind,
kinds::COMMENT
| "substitution_definition"
| "pending"
| kinds::FOOTNOTE
| kinds::CITATION
| kinds::TEXT
);
(!blocked).then_some((next, node))
}
fn find_first<'a>(node: &'a Node, kind: &str) -> Option<&'a Node> {
if node.kind == kind {
return Some(node);
}
node.children
.iter()
.find_map(|child| find_first(child, kind))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rst::{parse_rst_full, ParseOptions};
fn parse(source: &str, docname: &str) -> crate::rst::ParseOutput {
parse_rst_full(
source,
&ParseOptions {
source_path: format!("<{docname}>"),
sphinx: true,
docname: docname.to_string(),
found_docs: None,
exclude_patterns: Vec::new(),
py: Default::default(),
srcdir: None,
..Default::default()
},
)
}
fn read(sources: &[(&str, &str)]) -> (BuildEnvironment, Vec<BuildWarning>) {
let mut env = BuildEnvironment::default();
let mut warnings = Vec::new();
let doc2path = |docname: &str| PathBuf::from(format!("/src/{docname}.rst"));
for (docname, source) in sources {
let parsed = parse(source, docname);
let path = PathBuf::from(format!("/src/{docname}.rst"));
process_doc(
&mut env,
&DocumentSource {
docname,
doctree: &parsed.doctree,
registry: &parsed.registry,
path: &path,
},
&doc2path,
&mut warnings,
);
}
(env, warnings)
}
#[test]
fn parse_time_diagnostics_precede_label_diagnostics() {
let document = ".. envvar:: MYVAR\n\n\
.. glossary::\n\n \
alpha\n \
The first.\n\n\
.. _dup:\n\n\
Sec\n---\n\nx\n";
let (_, warnings) = read(&[("a", document), ("b", document)]);
assert_eq!(
warnings
.iter()
.map(|warning| (warning.line, warning.message.as_str()))
.collect::<Vec<_>>(),
vec![
(
Some(1),
"duplicate envvar description of MYVAR, other instance in a"
),
(
Some(4),
"duplicate term description of alpha, other instance in a"
),
(
Some(11),
"duplicate label dup, other instance in /src/a.rst"
),
],
"{warnings:?}"
);
}
#[test]
fn object_descriptions_register_per_directive() {
let (env, warnings) = read(&[(
"a",
".. envvar:: HOME_A\n\n\
.. confval:: my_setting\n\n\
.. program:: myprog\n\n\
.. option:: --verbose, -v\n\n\
.. program:: None\n\n\
.. option:: --global-opt\n\n\
.. describe:: widget\n\n\
.. object:: thing\n",
)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(
env.std.objects,
[
(
("confval".to_string(), "my_setting".to_string()),
("a".to_string(), "confval-my_setting".to_string())
),
(
("envvar".to_string(), "HOME_A".to_string()),
("a".to_string(), "envvar-HOME_A".to_string())
),
]
.into_iter()
.collect(),
"`option` contributes no object, and neither `describe` nor \
`object` contributes anything"
);
assert_eq!(
env.std.progoptions,
[
(
(None, "--global-opt".to_string()),
("a".to_string(), "cmdoption-global-opt".to_string())
),
(
(Some("myprog".to_string()), "--verbose".to_string()),
("a".to_string(), "cmdoption-myprog-verbose".to_string())
),
(
(Some("myprog".to_string()), "-v".to_string()),
("a".to_string(), "cmdoption-myprog-verbose".to_string())
),
]
.into_iter()
.collect()
);
}
#[test]
fn no_typesetting_registers_the_object_it_refuses_to_render() {
let (env, warnings) = read(&[(
"a",
".. confval:: hidden_setting\n :no-typesetting:\n\n\
.. envvar:: HIDDEN\n :no-typesetting:\n\n\
.. option:: --hidden\n :no-typesetting:\n",
)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(
env.std.objects,
[
(
("confval".to_string(), "hidden_setting".to_string()),
("a".to_string(), "confval-hidden_setting".to_string())
),
(
("envvar".to_string(), "HIDDEN".to_string()),
("a".to_string(), "envvar-HIDDEN".to_string())
),
]
.into_iter()
.collect()
);
assert_eq!(
env.std.progoptions,
[(
(None, "--hidden".to_string()),
("a".to_string(), "cmdoption-hidden".to_string())
)]
.into_iter()
.collect()
);
}
#[test]
fn a_duplicate_object_description_warns_with_the_sphinx_text() {
let (_, warnings) = read(&[
("a", ".. envvar:: HOME\n"),
("b", "B\n=\n\n.. envvar:: HOME\n"),
]);
assert_eq!(
warnings.iter().map(|w| w.render()).collect::<Vec<_>>(),
vec![
"<b>:4: WARNING: duplicate envvar description of HOME, \
other instance in a"
]
);
}
#[test]
fn the_four_virtual_labels_are_preseeded() {
let std = StdDomainData::default();
assert_eq!(
std.labels.get("genindex"),
Some(&("genindex".to_string(), String::new(), "Index".to_string()))
);
assert_eq!(
std.labels.get("modindex"),
Some(&(
"py-modindex".to_string(),
String::new(),
"Module Index".to_string()
))
);
assert_eq!(
std.labels.get("py-modindex"),
Some(&(
"py-modindex".to_string(),
String::new(),
"Python Module Index".to_string()
))
);
assert_eq!(
std.anonlabels.get("search"),
Some(&("search".to_string(), String::new()))
);
assert_eq!(std.anonlabels.len(), 4);
}
#[test]
fn a_label_before_a_section_is_titled_by_that_section() {
let (env, warnings) = read(&[(
"a",
"A\n=\n\n.. _dup-label:\n\nSection One\n-----------\n\nText.\n",
)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(
env.std.labels.get("dup-label"),
Some(&(
"a".to_string(),
"dup-label".to_string(),
"Section One".to_string()
)),
"the target donates its id to the following section, whose \
title becomes the label's section name"
);
assert_eq!(
env.std.anonlabels.get("dup-label"),
Some(&("a".to_string(), "dup-label".to_string()))
);
assert!(!env.std.labels.contains_key("section one"));
}
#[test]
fn a_duplicate_label_warns_at_the_second_definition() {
let (env, warnings) = read(&[
(
"a",
"A\n=\n\n.. _dup-label:\n\nSection One\n-----------\n\nText.\n",
),
(
"b",
"B\n=\n\n.. _dup-label:\n\nSection Two\n-----------\n\nText.\n",
),
]);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(
warnings[0].render(),
"<b>:7: WARNING: duplicate label dup-label, other instance in /src/a.rst",
"the location is the *section* the target propagated onto, whose \
docutils line is its title underline; its path is the doctree \
source table's, not the document path handed to process_doc"
);
assert_eq!(
env.std.labels["dup-label"],
(
"b".to_string(),
"dup-label".to_string(),
"Section Two".to_string()
)
);
}
#[test]
fn a_label_on_a_captioned_figure_is_titled_by_its_caption() {
let (env, _) = read(&[(
"a",
"A\n=\n\n.. figure:: pic.png\n :name: fig-a\n\n The Caption\n",
)]);
assert_eq!(
env.std.labels.get("fig-a"),
Some(&(
"a".to_string(),
"fig-a".to_string(),
"The Caption".to_string()
))
);
}
#[test]
fn a_label_on_an_uncaptioned_enumerable_stays_anonymous_only() {
let (env, _) = read(
&["a"]
.iter()
.map(|d| (*d, "A\n=\n\n.. figure:: pic.png\n :name: fig-a\n"))
.collect::<Vec<_>>(),
);
assert!(
!env.std.labels.contains_key("fig-a"),
"an uncaptioned figure has no numfig title, so `continue`"
);
assert!(env.std.anonlabels.contains_key("fig-a"));
}
#[test]
fn glossary_terms_register_as_objects_and_lowercased_terms() {
let (env, warnings) = read(&[(
"a",
"A\n=\n\n.. glossary::\n\n Environment\n A thing.\n\n template engine\n Another.\n",
)]);
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(
env.std
.objects
.get(&("term".to_string(), "Environment".to_string())),
Some(&("a".to_string(), "term-Environment".to_string()))
);
assert_eq!(
env.std.terms.get("environment"),
Some(&("a".to_string(), "term-Environment".to_string())),
"`terms` is keyed by the lowercased term, `objects` by the term as written"
);
assert!(env.std.terms.contains_key("template engine"));
assert_eq!(env.std.labels.len(), PRESEEDED_LABELS.len());
}
#[test]
fn a_term_defined_twice_warns_naming_the_other_document() {
let glossary = "A\n=\n\n.. glossary::\n\n environment\n A thing.\n";
let (env, warnings) = read(&[("a", glossary), ("b", glossary)]);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert_eq!(
warnings[0].render(),
"<b>:5: WARNING: duplicate term description of environment, \
other instance in a",
"an object duplicate names the other *docname*, not its path โ \
and offers no `:no-index:` hint, unlike the py domain's"
);
assert_eq!(
env.std.terms["environment"],
("b".to_string(), "term-environment".to_string())
);
}
#[test]
fn a_system_message_between_a_target_and_its_section_is_stepped_over() {
let (env, _) = read(&[(
"a",
"A\n=\n\n.. _lbl:\n\n.. nosuchdirective::\n\n body\n\nSection\n-------\n\nText.\n",
)]);
assert_eq!(
env.std.labels.get("lbl"),
Some(&("a".to_string(), "lbl".to_string(), "Section".to_string())),
"the label belongs to the section behind the message"
);
}
#[test]
fn a_label_on_a_captionless_toctree_is_not_named_by_the_none_sentinel() {
let (env, _) = read(&[("a", "A\n=\n\n.. _lbl:\n\n.. toctree::\n\n other\n")]);
assert!(
!env.std.labels.contains_key("lbl"),
"got {:?}",
env.std.labels.get("lbl")
);
assert!(env.std.anonlabels.contains_key("lbl"));
let (env, _) = read(&[(
"a",
"A\n=\n\n.. _lbl:\n\n.. toctree::\n :caption: Real Caption\n\n other\n",
)]);
assert_eq!(env.std.labels["lbl"].2, "Real Caption".to_string());
}
#[test]
fn a_label_pointing_at_a_link_target_is_skipped() {
let (env, _) = read(&[("a", "A\n=\n\n.. _elsewhere: https://example.com/\n")]);
assert!(!env.std.labels.contains_key("elsewhere"));
assert!(!env.std.anonlabels.contains_key("elsewhere"));
}
#[test]
fn first_program_option_entry_wins() {
let mut std = StdDomainData::default();
std.add_program_option(Some("prog"), "--opt", "a", "cmdoption-prog-opt");
std.add_program_option(Some("prog"), "--opt", "b", "other-id");
assert_eq!(
std.progoptions[&(Some("prog".to_string()), "--opt".to_string())],
("a".to_string(), "cmdoption-prog-opt".to_string())
);
}
#[test]
fn note_object_reports_the_previous_docname() {
let mut std = StdDomainData::default();
assert_eq!(std.note_object("envvar", "PATH", "a", "envvar-PATH"), None);
assert_eq!(
std.note_object("envvar", "PATH", "b", "envvar-PATH"),
Some("a".to_string())
);
assert_eq!(
std.objects[&("envvar".to_string(), "PATH".to_string())],
("b".to_string(), "envvar-PATH".to_string()),
"the later description still wins, exactly like Sphinx"
);
}
#[test]
fn node_lines_follow_docutils_conventions() {
let source = "Top\n===\n\nUnder\n-----\n\nBody.\n";
let parsed = parse(source, "a");
let doctree = &parsed.doctree;
let top = &doctree.root.children[0];
assert_eq!(
doctree.source_and_line(top.span),
("<a>", 2),
"section line = its underline"
);
let under = top
.children
.iter()
.find(|c| c.kind == kinds::SECTION)
.unwrap();
assert_eq!(doctree.source_and_line(under.span), ("<a>", 5));
let body = under
.children
.iter()
.find(|c| c.kind == kinds::PARAGRAPH)
.unwrap();
assert_eq!(
doctree.source_and_line(body.span),
("<a>", 7),
"other nodes: their first line"
);
let overlined = parse(
"=====
Top
=====
Body.
",
"b",
);
let top = &overlined.doctree.root.children[0];
assert_eq!(
overlined.doctree.source_and_line(top.span),
("<b>", 2),
"overline form: span first line + 1 = the title line"
);
}
}