use std::collections::{btree_map::Entry, BTreeMap};
use crate::ast::{Block, BlockKind, Document, Inline, Span};
use crate::diagnostic::{Diagnostic, Severity};
use crate::visit::{self, InlineContext, VisitorMut};
pub(crate) type Registry = BTreeMap<String, Target>;
pub(crate) fn catalog(registry: &Registry) -> Vec<crate::ast::Reference> {
let mut entries: Vec<_> = registry.iter().collect();
entries.sort_by_key(|(_, target)| target.order);
entries
.into_iter()
.map(|(id, target)| crate::ast::Reference {
id: id.clone(),
inline: target.inline,
})
.collect()
}
pub(crate) struct Target {
pub reftext: Option<crate::reftext::Read>,
pub title: Option<Title>,
pub order: usize,
pub numbering: Numbering,
pub inline: bool,
}
#[derive(Default)]
pub(crate) enum Numbering {
#[default]
None,
Caption {
caption: Vec<crate::AttributePart>,
numeral: Option<String>,
context: &'static str,
},
Section(crate::section_numbers::Section),
}
pub(crate) fn styled(
title: Vec<Inline>,
numbering: &Numbering,
style: Option<&str>,
attributes: &crate::Attributes,
span: Span,
) -> Vec<Inline> {
let Some(style) = style else {
return title;
};
let text = |value: String| Inline::Text { value, span };
let (caption, numeral, context) = match numbering {
Numbering::Section(section) => {
return styled_section(title, section, style, attributes, span)
}
Numbering::Caption {
caption,
numeral,
context,
} if !crate::attributes::parts_text(caption).is_empty() => (caption, numeral, context),
_ => return title,
};
let signifier = match (
numeral,
attributes.value_parts(&format!("{context}-caption")),
) {
(Some(numeral), Some(parts)) => {
vec![signifier(parts, span), text(format!(" {numeral}"))]
}
_ => {
let mut caption = caption.clone();
if let Some(crate::AttributePart::Text(last) | crate::AttributePart::Raw(last)) =
caption.last_mut()
{
if let Some(chomped) = last.strip_suffix(". ") {
*last = chomped.to_owned();
}
}
vec![signifier(caption, span)]
}
};
match style {
"full" => {
let mut label = signifier;
label.push(text(", \u{201c}".into()));
label.extend(title);
label.push(text("\u{201d}".into()));
label
}
"short" => signifier,
_ => title,
}
}
pub(crate) fn styled_uses_title(numbering: &Numbering, style: Option<&str>) -> bool {
if style != Some("short") {
return true;
}
match numbering {
Numbering::Section(section) => !section.is_numbered(),
Numbering::Caption { caption, .. } => crate::attributes::parts_text(caption).is_empty(),
_ => true,
}
}
fn styled_section(
title: Vec<Inline>,
section: &crate::section_numbers::Section,
style: &str,
attributes: &crate::Attributes,
span: Span,
) -> Vec<Inline> {
let text = |value: String| Inline::Text { value, span };
let emphasized = |title| {
if section.emphasized() {
vec![Inline::Emphasis {
children: title,
span,
}]
} else {
title
}
};
let (append, quoted) = match style {
_ if !section.is_numbered() => return emphasized(title),
"full" if section.emphasized() => (",", emphasized(title)),
"full" => {
let mut quoted = vec![text("\u{201c}".into())];
quoted.extend(title);
quoted.push(text("\u{201d}".into()));
(",", quoted)
}
"short" => ("", Vec::new()),
_ => return emphasized(title),
};
let mut label = Vec::new();
if let Some(parts) = attributes.value_parts(&format!("{}-refsig", section.sectname)) {
label.push(signifier(parts, span));
label.push(text(" ".into()));
}
label.push(text(section.sectnum(".", append)));
if !quoted.is_empty() {
label.push(text(" ".into()));
label.extend(quoted);
}
label
}
fn signifier(parts: Vec<crate::AttributePart>, span: Span) -> Inline {
let value = crate::attributes::parts_text(&parts);
if parts
.iter()
.all(|p| matches!(p, crate::AttributePart::Text(_)))
{
Inline::Text { value, span }
} else {
Inline::Substituted { value, parts, span }
}
}
pub(crate) fn ordered(registry: &Registry) -> Vec<&str> {
let mut ids: Vec<_> = registry.iter().collect();
ids.sort_by_key(|(_, target)| target.order);
ids.into_iter().map(|(id, _)| id.as_str()).collect()
}
pub(crate) struct Definition {
pub span: Span,
pub attributes: std::sync::Arc<crate::Attributes>,
pub xref: crate::Xref,
pub textless: bool,
}
pub(crate) struct Fixed {
xref: crate::Xref,
label: Option<Vec<Inline>>,
}
pub(crate) type FixedXrefs = Vec<Option<Fixed>>;
pub(crate) type Registered = (Registry, EarlyTitles, FixedXrefs);
pub(crate) fn apply_fixed(fixed: &FixedXrefs, xref: &mut crate::Xref) -> Option<Vec<Inline>> {
let resolved = fixed.get(xref.definition?)?.as_ref()?;
*xref = crate::Xref {
definition: None,
..resolved.xref.clone()
};
resolved.label.clone()
}
pub(crate) fn resolve_attribute_definitions(
attributes: &mut crate::Attributes,
fixed: &FixedXrefs,
) {
let values: Vec<_> = attributes
.iter()
.filter_map(|(name, _)| {
attributes
.raw_parts_exact(name)
.map(|parts| (name.to_owned(), parts.to_vec()))
})
.collect();
for (name, mut parts) in values {
resolve_definition_parts(&mut parts, fixed);
attributes.set_parts(name, parts);
}
}
pub(crate) fn resolve_definition_parts(parts: &mut Vec<crate::AttributePart>, fixed: &FixedXrefs) {
fill_part_labels(parts, &mut |xref, _| apply_fixed(fixed, xref));
}
pub(crate) type EarlyTitle<'a> =
dyn FnMut((usize, usize), &crate::Attributes) -> Option<Vec<Inline>> + 'a;
pub(crate) const HEADER_KEY: (usize, usize) = (usize::MAX, usize::MAX);
pub(crate) type EarlyTitles = BTreeMap<(usize, usize), Vec<Inline>>;
pub(crate) enum Title {
Nodes(Vec<Inline>),
Heading((usize, usize), Vec<Inline>),
Header,
}
pub(crate) struct Read<'a> {
pub anchors: Vec<(String, crate::anchor_catalog::Entry)>,
pub reftexts: BTreeMap<(usize, usize), crate::reftext::Read>,
pub numerals: BTreeMap<(usize, usize), String>,
pub credits: std::collections::BTreeSet<(usize, usize)>,
pub sections: &'a BTreeMap<(usize, usize), crate::section_numbers::Section>,
pub definitions: Vec<Definition>,
pub nested_titles: std::collections::BTreeSet<(usize, usize)>,
pub dropped_blocks: BTreeMap<usize, Vec<Block>>,
pub block_stamps: BTreeMap<(usize, usize), crate::context::Stamp>,
}
pub(crate) fn register(
doc: &mut Document,
read: Read<'_>,
early_title: &mut EarlyTitle<'_>,
diagnostics: &mut Vec<Diagnostic>,
placed: &mut Vec<crate::context::Placed>,
) -> Registered {
let Read {
mut anchors,
mut reftexts,
mut numerals,
credits,
sections,
definitions,
nested_titles,
dropped_blocks,
block_stamps,
} = read;
let doctitle = doc
.blocks
.first()
.filter(|_| doc.has_title)
.map(|block| block.span);
anchors.sort_by_key(|(_, entry)| entry.span.start);
let fixed = (0..definitions.len()).map(|_| None).collect();
let mut ids = Ids {
doctitle,
nested_titles,
dropped_blocks,
anchors: anchors.into_iter().peekable(),
definitions: definitions.into_iter().peekable(),
fixed,
position: 0,
reftexts: &mut reftexts,
numerals: &mut numerals,
credits,
sections,
early_title,
early: EarlyTitles::new(),
header_resolving: false,
registry: Registry::new(),
diagnostics,
placed,
block_stamps,
manpage_name: false,
};
let title = usize::from(doc.has_title).min(doc.blocks.len());
let (head, body) = doc.blocks.split_at_mut(title);
visit::walk_blocks_mut(&mut ids, head);
if let Some(heading) = doc
.manpage
.as_mut()
.and_then(|manpage| manpage.name_section.as_mut())
.map(|section| &mut section.heading)
{
let attributes = std::sync::Arc::clone(&heading.attribute_context);
if let (Some(_), BlockKind::Heading { inlines, .. }) = (&heading.id, &mut heading.kind) {
ids.resolve_reading(inlines, &attributes);
}
ids.manpage_name = true;
visit::walk_block_mut(&mut ids, heading);
ids.manpage_name = false;
}
visit::walk_blocks_mut(&mut ids, body);
ids.flush(usize::MAX);
(ids.registry, ids.early, ids.fixed)
}
struct Ids<'a> {
doctitle: Option<Span>,
nested_titles: std::collections::BTreeSet<(usize, usize)>,
dropped_blocks: BTreeMap<usize, Vec<Block>>,
anchors: std::iter::Peekable<std::vec::IntoIter<(String, crate::anchor_catalog::Entry)>>,
definitions: std::iter::Peekable<std::vec::IntoIter<Definition>>,
fixed: FixedXrefs,
position: usize,
reftexts: &'a mut BTreeMap<(usize, usize), crate::reftext::Read>,
numerals: &'a mut BTreeMap<(usize, usize), String>,
credits: std::collections::BTreeSet<(usize, usize)>,
sections: &'a BTreeMap<(usize, usize), crate::section_numbers::Section>,
early_title: &'a mut EarlyTitle<'a>,
early: EarlyTitles,
header_resolving: bool,
registry: Registry,
diagnostics: &'a mut Vec<Diagnostic>,
placed: &'a mut Vec<crate::context::Placed>,
block_stamps: BTreeMap<(usize, usize), crate::context::Stamp>,
manpage_name: bool,
}
fn credit_start(block: &Block) -> Option<usize> {
match &block.kind {
BlockKind::Quote {
attribution: Some(attribution),
..
}
| BlockKind::Verse {
attribution: Some(attribution),
..
} => attribution_start(attribution),
_ => None,
}
}
impl Ids<'_> {
fn flush(&mut self, position: usize) {
loop {
let anchor = self.anchors.peek().map(|(_, entry)| entry.span.start);
let definition = self.definitions.peek().map(|d| d.span.start);
match (anchor, definition) {
(Some(a), d) if a < position && d.is_none_or(|d| a <= d) => self.anchor(),
(_, Some(d)) if d < position => self.define(),
_ => break,
}
}
}
fn define(&mut self) {
let Definition {
span,
attributes,
mut xref,
textless,
} = self.definitions.next().unwrap();
self.position = span.start;
self.reading_target(&mut xref, true, &attributes);
let label = textless
.then(|| self.reading_xref_label(&xref, true, span, &attributes))
.flatten();
if let Some(slot) = xref.definition.and_then(|i| self.fixed.get_mut(i)) {
*slot = Some(Fixed { xref, label });
}
}
fn anchor(&mut self) {
let (id, entry) = self.anchors.next().unwrap();
if entry.bibliography && self.registry.contains_key(&id) {
self.report(
Some(entry.stamp),
Diagnostic {
omitted_span: None,
report: Default::default(),
span: entry.span,
severity: Severity::Warning,
message: format!("id assigned to bibliography anchor already in use: {id}"),
},
);
return;
}
let target = Target {
reftext: entry.reftext,
title: None,
order: self.registry.len(),
numbering: Numbering::None,
inline: !entry.block,
};
let kind = if entry.block { "block" } else { "anchor" };
if let Some(duplicate) = register_id(&mut self.registry, id, target, kind, entry.span) {
self.report(Some(entry.stamp), duplicate);
}
}
fn report(&mut self, stamp: Option<crate::context::Stamp>, diagnostic: Diagnostic) {
match stamp {
Some(stamp) => self.placed.push(crate::context::Placed {
stamp,
diagnostic,
physical: false,
}),
None => self.diagnostics.push(diagnostic),
}
}
}
impl Ids<'_> {
fn resolve_reading(&mut self, inlines: &mut [Inline], attributes: &crate::Attributes) {
for inline in inlines {
match inline {
Inline::CrossReference {
xref,
children,
span,
} => {
self.reading_target(xref, false, attributes);
if children.is_empty() {
if let Some(mut label) =
self.reading_xref_label(xref, false, *span, attributes)
{
strip_label(&mut label);
*children = label;
}
} else {
self.resolve_reading(children, attributes)
}
}
Inline::Strong { children, .. }
| Inline::Emphasis { children, .. }
| Inline::Monospace { children, .. }
| Inline::Mark { children, .. }
| Inline::Superscript { children, .. }
| Inline::Subscript { children, .. }
| Inline::Link { children, .. } => self.resolve_reading(children, attributes),
Inline::AttributeRef { span, .. }
| Inline::Substituted { span, .. }
| Inline::Footnote { span, .. } => {
let span = *span;
fill_labels(inline, |xref, textless| {
if let Some(label) = apply_fixed(&self.fixed, xref) {
return Some(label);
}
self.reading_target(xref, true, attributes);
textless
.then(|| self.reading_xref_label(xref, true, span, attributes))
.flatten()
});
}
_ => {}
}
}
}
fn reading_target(
&mut self,
xref: &mut crate::Xref,
lexical: bool,
attributes: &crate::Attributes,
) {
let Some(fragment) = natural_text(xref, &self.registry, lexical) else {
return;
};
for id in ordered(&self.registry)
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>()
{
let label = self.reading_xreftext(&id, None, Span::new(0, 0), attributes);
if label.as_deref().and_then(crate::xref_target::lexical_label)
== Some(fragment.clone())
{
crate::xref_target::retarget(xref, &id, lexical);
return;
}
}
}
fn reading_xref_label(
&mut self,
xref: &crate::Xref,
lexical: bool,
span: Span,
attributes: &crate::Attributes,
) -> Option<Vec<Inline>> {
if let Some(path) = &xref.path {
return Some(crate::entity_references::reference_inlines(
&decoded(path, lexical),
span,
));
}
if xref.refid.as_deref().is_none_or(str::is_empty) {
return self.reading_top_label(span, attributes);
}
let id = decoded_refid(xref, lexical)?;
if self.registry.contains_key(&id) {
let style = match &xref.xrefstyle {
Some(style) => Some(decoded(style, lexical)),
None => attributes.get("xrefstyle").map(str::to_owned),
};
self.reading_label(&id, style.as_deref(), span, attributes)
} else if id.is_empty() {
None
} else {
Some(crate::entity_references::unknown_reference_label(
xref, &id, lexical, span,
))
}
}
}
pub(crate) fn natural_text(
xref: &mut crate::Xref,
registry: &Registry,
lexical: bool,
) -> Option<String> {
let spelling = xref.natural.take()?;
let decoded = decoded(xref.fragment.as_deref()?, lexical);
(!registry.contains_key(&decoded) && crate::xref_target::looks_like_text(&decoded))
.then_some(spelling)
}
pub(crate) fn decoded_refid(xref: &crate::Xref, lexical: bool) -> Option<String> {
if xref.path.is_some() {
return None;
}
xref.refid.as_deref().map(|refid| decoded(refid, lexical))
}
pub(crate) fn decoded(value: &str, lexical: bool) -> String {
if lexical {
crate::attributes::decode_special(value)
} else {
value.to_owned()
}
}
pub(crate) fn fill_labels(
node: &mut Inline,
mut f: impl FnMut(&mut crate::Xref, bool) -> Option<Vec<Inline>>,
) {
match node {
Inline::AttributeRef {
value: Some(value),
parts,
..
}
| Inline::Substituted { value, parts, .. } => {
if fill_part_labels(parts, &mut f) {
*value = crate::attributes::parts_text(parts);
}
}
Inline::Footnote { footnote, .. } => {
fill_part_labels(&mut footnote.content, &mut f);
}
Inline::Ui { ui, .. } => {
for label in ui.labels_mut() {
fill_part_labels(label, &mut f);
}
}
Inline::Stem { stem, .. } => {
fill_part_labels(&mut stem.parts, &mut f);
}
Inline::IndexTerm { term, .. } => {
for label in term.labels_mut() {
fill_part_labels(label, &mut f);
}
}
_ => {}
}
}
fn fill_part_labels(
parts: &mut Vec<crate::AttributePart>,
f: &mut impl FnMut(&mut crate::Xref, bool) -> Option<Vec<Inline>>,
) -> bool {
let mut filled = false;
let mut i = 0;
while i < parts.len() {
if let crate::AttributePart::Footnote(footnote) = &mut parts[i] {
filled |= fill_part_labels(&mut footnote.content, f);
i += 1;
continue;
}
if let crate::AttributePart::Ui(ui) = &mut parts[i] {
for label in ui.labels_mut() {
filled |= fill_part_labels(label, f);
}
i += 1;
continue;
}
if let crate::AttributePart::Stem(stem) = &mut parts[i] {
filled |= fill_part_labels(&mut stem.parts, f);
i += 1;
continue;
}
if let crate::AttributePart::IndexTerm(term) = &mut parts[i] {
for label in term.labels_mut() {
filled |= fill_part_labels(label, f);
}
i += 1;
continue;
}
let textless = matches!(parts.get(i + 1), Some(crate::AttributePart::CloseXref));
if let crate::AttributePart::OpenXref(xref) = &mut parts[i] {
if let Some(mut nodes) = f(xref, textless).filter(|_| textless) {
strip_label(&mut nodes);
let inserted = label_parts(&nodes);
let count = inserted.len();
parts.splice(i + 1..i + 1, inserted);
i += count;
filled = true;
}
}
i += 1;
}
filled
}
pub(crate) fn label_parts(nodes: &[Inline]) -> Vec<crate::AttributePart> {
use crate::AttributePart as Part;
let mut parts = Vec::new();
for node in nodes {
match node {
Inline::Text { value, .. } => crate::attributes::push_part(&mut parts, value, false),
Inline::Raw { value, .. } => crate::attributes::push_part(&mut parts, value, true),
Inline::Substituted { parts: inner, .. } => {
for part in inner {
crate::attributes::push_value(&mut parts, part.clone());
}
}
Inline::AttributeRef {
name,
value,
parts: inner,
..
} => match value {
Some(_) if !inner.is_empty() => {
for part in inner {
crate::attributes::push_value(&mut parts, part.clone());
}
}
Some(value) => crate::attributes::push_part(&mut parts, value, false),
None => crate::attributes::push_part(&mut parts, &format!("{{{name}}}"), false),
},
Inline::Link { children, .. } | Inline::CrossReference { children, .. } => {
parts.extend(label_parts(children))
}
Inline::Anchor { .. } => {}
Inline::Image { image, .. } => parts.push(Part::Image(Box::new(image.clone()))),
Inline::Footnote { footnote, .. } => {
parts.push(Part::Footnote(Box::new(footnote.clone())))
}
Inline::Ui { ui, .. } => parts.push(Part::Ui(Box::new(ui.clone()))),
Inline::IndexTerm { term, .. } => parts.push(Part::IndexTerm(Box::new(term.clone()))),
Inline::Stem { stem, .. } => parts.push(Part::Stem(Box::new(stem.clone()))),
node => {
let format = crate::attribute_macros::format(node).expect("formatting node");
let (Inline::Strong { children, .. }
| Inline::Emphasis { children, .. }
| Inline::Monospace { children, .. }
| Inline::Mark { children, .. }
| Inline::Superscript { children, .. }
| Inline::Subscript { children, .. }) = node
else {
unreachable!()
};
parts.push(Part::Open(format));
for part in label_parts(children) {
crate::attributes::push_value(&mut parts, part);
}
parts.push(Part::Close(format));
}
}
}
parts
}
pub(crate) fn placeholder(nodes: &[Inline]) -> Option<(usize, usize)> {
match nodes {
[Inline::Text { value, span }] if value.is_empty() => Some((span.start, span.end)),
_ => None,
}
}
impl Ids<'_> {
fn reading_top_label(
&mut self,
span: Span,
attributes: &crate::Attributes,
) -> Option<Vec<Inline>> {
if let Some(doctitle) = self.doctitle {
let top = || {
vec![Inline::Text {
value: "[^top]".into(),
span,
}]
};
if self.header_resolving {
return Some(top());
}
let header_reftext = self
.registry
.values()
.find(|target| matches!(target.title, Some(Title::Header)))
.and_then(|target| target.reftext.as_ref())
.map(|read| read.snapshot(attributes));
let reftext = header_reftext.or_else(|| {
attributes.get("reftext").map(|value| {
crate::reftext::Read::source(value, span.start).snapshot(attributes)
})
});
let mut label = if let Some(nodes) = reftext.filter(|n| !inline_text(n).is_empty()) {
nodes
} else if let Some(value) = attributes.get("title") {
crate::entity_references::reference_inlines(value, span)
} else {
let key = (doctitle.start, doctitle.end);
match self.early.get(&key) {
Some(converted) => converted.clone(),
None => {
let mut converted = (self.early_title)(HEADER_KEY, attributes)?;
self.header_resolving = true;
self.resolve_reading(&mut converted, attributes);
self.header_resolving = false;
self.early.insert(key, converted.clone());
converted
}
}
};
strip_label(&mut label);
return Some(label);
}
let nested = |start: usize| {
self.sections
.iter()
.any(|(&(outer, _), section)| outer < start && start < section.closed_at)
};
let first = self
.sections
.iter()
.find(|(&(start, _), section)| section.closed_at <= span.start && !nested(start))
.map(|(key, _)| *key);
let Some(first) = first else {
return Some(vec![Inline::Text {
value: "[^top]".into(),
span,
}]);
};
self.registry
.values()
.find_map(|target| match &target.title {
Some(Title::Heading(key, nodes))
if *key == first && placeholder(nodes).is_none() =>
{
let mut label = nodes.clone();
strip_label(&mut label);
Some(label)
}
_ => None,
})
}
fn reading_label(
&mut self,
id: &str,
style: Option<&str>,
span: Span,
attributes: &crate::Attributes,
) -> Option<Vec<Inline>> {
self.reading_xreftext(id, style, span, attributes)
.or_else(|| {
self.registry[id]
.title
.is_none()
.then(|| crate::entity_references::reference_inlines(&format!("[{id}]"), span))
})
}
fn reading_xreftext(
&mut self,
id: &str,
style: Option<&str>,
span: Span,
attributes: &crate::Attributes,
) -> Option<Vec<Inline>> {
let target = &self.registry[id];
if let Some(nodes) = target
.reftext
.as_ref()
.map(|read| read.snapshot(attributes))
.filter(|nodes| target.inline || !inline_text(nodes).is_empty())
{
return Some(nodes);
}
let title = match &target.title {
Some(Title::Nodes(nodes)) => match placeholder(nodes) {
None => Some(nodes.clone()),
Some(key) => {
let mut converted = (self.early_title)(key, attributes)?;
self.resolve_reading(&mut converted, attributes);
self.early.insert(key, converted.clone());
if let Some(Title::Nodes(nodes)) = &mut self
.registry
.get_mut(id)
.and_then(|target| target.title.as_mut())
{
*nodes = converted.clone();
}
Some(converted)
}
},
Some(Title::Heading(_, nodes)) => Some(nodes.clone()),
Some(Title::Header) | None => None,
}?;
let masked;
let numbering = match &self.registry[id].numbering {
Numbering::Section(section) => {
masked = Numbering::Section(section.at(self.position));
&masked
}
numbering => numbering,
};
Some(styled(title, numbering, style, attributes, span))
}
}
impl VisitorMut for Ids<'_> {
fn enter_block(&mut self, block: &mut Block) -> bool {
self.flush(block.span.start);
self.position = block.span.start;
if matches!(block.kind, BlockKind::Heading { .. }) {
self.register_block(block);
}
if credit_start(block).is_some_and(|start| start < block.span.start) {
self.resolve_attribution(block);
}
true
}
fn leave_block(&mut self, block: &mut Block) {
if let Some(start) = credit_start(block).filter(|&start| start >= block.span.start) {
self.flush(start);
self.position = start;
self.resolve_attribution(block);
}
if matches!(block.kind, BlockKind::Table(_)) {
while let Some((&start, _)) = self
.dropped_blocks
.range(block.span.start..block.span.end)
.next()
{
if let Some(mut blocks) = self.dropped_blocks.remove(&start) {
visit::walk_blocks_mut(self, &mut blocks);
}
}
}
if !matches!(block.kind, BlockKind::Heading { .. }) {
self.flush(block.span.end);
self.position = block.span.end;
self.register_block(block);
}
}
fn visit_inlines(&mut self, _inlines: &mut Vec<Inline>, _context: InlineContext) {}
}
impl Ids<'_> {
fn register_block(&mut self, block: &mut Block) {
let doctitle = Some(block.span) == self.doctitle;
let nested_title = self
.nested_titles
.contains(&(block.span.start, block.span.end));
let attributes = std::sync::Arc::clone(&block.attribute_context);
match (&mut block.kind, &mut block.title) {
(BlockKind::Heading { inlines, .. }, _)
if (block.id.is_none() || placeholder(inlines).is_none())
&& !doctitle
&& !nested_title =>
{
self.resolve_reading(inlines, &attributes)
}
(_, Some(title)) if block.id.is_some() && placeholder(title).is_none() => {
self.resolve_reading(title, &attributes)
}
_ => {}
}
let title = match &block.kind {
BlockKind::Heading { .. } if doctitle => Some(Title::Header),
BlockKind::Heading { inlines, .. } if placeholder(inlines).is_some() => {
Some(Title::Nodes(inlines.clone()))
}
BlockKind::Heading { inlines, .. } => Some(Title::Heading(
(block.span.start, block.span.end),
inlines.clone(),
)),
_ => block.title.clone().map(Title::Nodes),
};
let key = (block.span.start, block.span.end);
let numbering = match (&block.kind, &block.caption) {
(BlockKind::Heading { .. }, _) => match self.sections.get(&key) {
Some(section) if section.is_numbered() || section.emphasized() => {
let mut section = section.clone();
if self.manpage_name {
section.path.clear();
}
Numbering::Section(section)
}
None if self.manpage_name => {
let section = crate::section_numbers::Section::read(
None,
1,
false,
false,
&attributes,
None,
);
if section.is_numbered() {
Numbering::Section(section)
} else {
Numbering::None
}
}
_ => Numbering::None,
},
(kind, Some(caption)) => match crate::context::caption_context(kind) {
Some(context) => Numbering::Caption {
caption: if block.caption_parts.is_empty() {
vec![crate::AttributePart::Text(caption.clone())]
} else {
block.caption_parts.clone()
},
numeral: self.numerals.remove(&key),
context,
},
None => Numbering::None,
},
_ => Numbering::None,
};
if let Some(id) = block.id.clone() {
let reftext = self.reftexts.remove(&key);
let target = Target {
reftext,
title,
order: self.registry.len(),
numbering,
inline: false,
};
let kind = match &block.kind {
BlockKind::Heading { .. }
if !doctitle
&& !matches!(block.attrs.style.as_deref(), Some("discrete" | "float")) =>
{
"section"
}
_ => "block",
};
let stamp = match kind {
"section" => self
.sections
.get(&key)
.map(|section| section.diagnostics_before),
_ => self.block_stamps.get(&key).copied(),
};
if let Some(duplicate) = register_id(&mut self.registry, id, target, kind, block.span) {
self.report(stamp, duplicate);
}
} else if let (BlockKind::Heading { inlines, .. }, false, Some(policy)) = (
&block.kind,
doctitle || nested_title,
IdPolicy::new(&attributes),
) {
let id = policy.generate(&inline_text(inlines));
let unique = policy.unique(id, |candidate| self.registry.contains_key(candidate));
let target = Target {
reftext: None,
title,
order: self.registry.len(),
numbering,
inline: false,
};
self.registry.insert(unique.clone(), target);
block.id = Some(unique);
}
}
fn resolve_attribution(&mut self, block: &mut Block) {
let credit = self.credits.contains(&(block.span.start, block.span.end));
let attributes = std::sync::Arc::clone(&block.attribute_context);
if let BlockKind::Quote {
attribution: Some(attribution),
..
}
| BlockKind::Verse {
attribution: Some(attribution),
..
} = &mut block.kind
{
for part in [&mut attribution.author, &mut attribution.citetitle]
.into_iter()
.flatten()
{
self.resolve_reading(part, &attributes);
}
if credit {
if let Some(author) = attribution.author.take() {
let credit = crate::blockquote_credit::split(author);
attribution.author = Some(credit.author);
attribution.citetitle = credit.citetitle;
attribution.trailing = credit.trailing;
}
}
}
}
}
fn attribution_start(attribution: &crate::ast::Attribution) -> Option<usize> {
[&attribution.author, &attribution.citetitle]
.into_iter()
.flatten()
.filter_map(|part| part.first())
.map(|inline| inline.span().start)
.min()
}
fn register_id(
registry: &mut Registry,
id: String,
target: Target,
kind: &str,
span: Span,
) -> Option<Diagnostic> {
match registry.entry(id) {
Entry::Occupied(e) => Some(Diagnostic {
omitted_span: None,
report: Default::default(),
span,
severity: Severity::Warning,
message: format!("id assigned to {kind} already in use: {}", e.key()),
}),
Entry::Vacant(e) => {
e.insert(target);
None
}
}
}
pub(crate) fn section_id(heading: &Block, doc_id: Option<&str>) -> Option<String> {
let BlockKind::Heading { inlines, .. } = &heading.kind else {
unreachable!("a section title")
};
let policy = IdPolicy::new(&heading.attribute_context)?;
let id = policy.generate(&inline_text(inlines));
Some(policy.unique(id, |candidate| doc_id == Some(candidate)))
}
struct IdPolicy {
prefix: String,
separator: Option<char>,
}
impl IdPolicy {
fn new(attributes: &crate::Attributes) -> Option<Self> {
attributes.get("sectids")?;
let prefix = attributes.get("idprefix").unwrap_or("_").to_owned();
let separator = match attributes.get("idseparator") {
Some(separator) => separator.chars().next(),
None => Some('_'),
};
Some(Self { prefix, separator })
}
fn generate(&self, title: &str) -> String {
let mut id = self.prefix.clone();
id.push_str(&strip_invalid(title));
let Some(separator) = self.separator else {
return id.replace(' ', "");
};
let mut squeezed = String::with_capacity(id.len());
let mut translated = false;
for ch in id.chars() {
if matches!(ch, ' ' | '.' | '-') || ch == separator {
if !translated {
squeezed.push(separator);
}
translated = true;
} else {
squeezed.push(ch);
translated = false;
}
}
if squeezed.ends_with(separator) {
squeezed.pop();
}
if self.prefix.is_empty() && squeezed.starts_with(separator) {
squeezed.remove(0);
}
squeezed
}
fn unique(&self, base: String, taken: impl Fn(&str) -> bool) -> String {
if !taken(&base) {
return base;
}
let separator = self.separator.map(String::from).unwrap_or_default();
(2u64..)
.map(|n| format!("{base}{separator}{n}"))
.find(|candidate| !taken(candidate))
.expect("an unused id")
}
}
fn strip_invalid(title: &str) -> String {
let title: String = title.chars().flat_map(char::to_lowercase).collect();
let mut kept = String::with_capacity(title.len());
let mut offset = 0;
let mut unclosed_tag = false;
while offset < title.len() {
let rest = &title[offset..];
if rest.starts_with('<') && !unclosed_tag {
if let Some(end) = rest.find('>') {
if end > 1 {
offset += end + 1;
continue;
}
} else {
unclosed_tag = true;
}
}
if rest.starts_with('&') {
if let Some(end) =
crate::entity_references::entity_name_end(title.as_bytes(), offset + 1)
{
offset = end;
continue;
}
}
let ch = rest.chars().next().unwrap();
offset += ch.len_utf8();
if matches!(ch, ' ' | '-' | '.') || crate::unicode_word::is_word(ch) {
kept.push(ch);
}
}
kept
}
pub(crate) fn inline_text(inlines: &[Inline]) -> String {
let mut s = String::new();
push_text(inlines, &mut s);
s
}
fn push_text(inlines: &[Inline], s: &mut String) {
for inline in inlines {
match inline {
Inline::Text { value, .. } => push_protected(value, s),
Inline::Raw { value, .. } => s.push_str(value),
Inline::Substituted { parts, .. } => {
s.push_str(&crate::attributes::reference_text(parts))
}
Inline::AttributeRef {
name, value, parts, ..
} => match value {
Some(v) if parts.is_empty() => push_protected(v, s),
Some(_) => {
s.push_str(&crate::attributes::reference_text(parts));
}
None => {
s.push('{');
s.push_str(name);
s.push('}');
}
},
Inline::Strong { children, .. }
| Inline::Emphasis { children, .. }
| Inline::Monospace { children, .. }
| Inline::Mark { children, .. }
| Inline::Superscript { children, .. }
| Inline::Subscript { children, .. }
| Inline::Link { children, .. }
| Inline::CrossReference { children, .. } => {
s.push_str("<>");
push_text(children, s);
s.push_str("<>");
}
Inline::Image { image, .. } if image.icon && image.icons.is_none() => {
s.push_str("<>[");
s.push_str(&crate::attributes::reference_text(&image.alt));
s.push_str("]<>");
}
Inline::Anchor { .. } | Inline::Image { .. } => s.push_str("<>"),
Inline::Footnote { footnote, .. } => s.push_str(&crate::attributes::reference_text(&[
crate::AttributePart::Footnote(Box::new(footnote.clone())),
])),
Inline::Ui { ui, .. } => s.push_str(&crate::attributes::reference_text(&[
crate::AttributePart::Ui(Box::new(ui.clone())),
])),
Inline::Stem { stem, .. } => s.push_str(&stem.lexical()),
Inline::IndexTerm { term, .. } => s.push_str(&crate::attributes::reference_text(&[
crate::AttributePart::IndexTerm(Box::new(term.clone())),
])),
}
}
}
fn push_protected(text: &str, output: &mut String) {
for ch in text.chars() {
match ch {
'&' => output.push_str("&"),
'<' => output.push_str("<"),
'>' => output.push_str(">"),
_ => output.push(ch),
}
}
}
pub(crate) fn strip_label(inlines: &mut Vec<Inline>) {
let mut stripped = Vec::with_capacity(inlines.len());
for mut inline in inlines.drain(..) {
match &mut inline {
Inline::Anchor { .. } => {}
Inline::Link { children, .. } | Inline::CrossReference { children, .. } => {
strip_label(children);
stripped.append(children);
}
Inline::Footnote { footnote, span }
if footnote.kind != crate::FootnoteKind::Unresolved =>
{
use crate::AttributePart as Part;
let definition = footnote.kind == crate::FootnoteKind::Definition;
let marker = footnote.marker();
stripped.push(Inline::Substituted {
value: marker.clone(),
parts: vec![
Part::OpenWithAttributes {
format: crate::AttributeFormat::Superscript,
id: footnote
.id
.as_ref()
.filter(|_| definition)
.map(|id| format!("_footnote_{id}")),
role: Some(
if definition {
"footnote"
} else {
"footnoteref"
}
.into(),
),
},
Part::Text(marker),
Part::Close(crate::AttributeFormat::Superscript),
],
span: *span,
});
}
Inline::Image { image, .. } => {
image.attributes.remove("link");
stripped.push(inline);
}
Inline::Ui { ui, .. } => {
for label in ui.labels_mut() {
strip_parts(label);
}
stripped.push(inline);
}
Inline::Stem { stem, .. } => {
strip_parts(&mut stem.parts);
stripped.push(inline);
}
Inline::IndexTerm { term, .. } => {
for label in term.labels_mut() {
strip_parts(label);
}
stripped.push(inline);
}
Inline::Strong { children, .. }
| Inline::Emphasis { children, .. }
| Inline::Monospace { children, .. }
| Inline::Mark { children, .. }
| Inline::Superscript { children, .. }
| Inline::Subscript { children, .. } => {
strip_label(children);
stripped.push(inline);
}
_ => stripped.push(inline),
}
}
*inlines = stripped;
strip_attribute_links(inlines);
}
fn strip_attribute_links(inlines: &mut [Inline]) {
struct Strip;
impl VisitorMut for Strip {
fn visit_inline(&mut self, inline: &mut Inline) {
if let Inline::AttributeRef { parts, .. } | Inline::Substituted { parts, .. } = inline {
strip_parts(parts);
}
visit::walk_inline_mut(self, inline);
}
}
visit::walk_inlines_mut(&mut Strip, inlines);
}
fn strip_parts(parts: &mut Vec<crate::AttributePart>) {
for part in parts.iter_mut() {
match part {
crate::AttributePart::Image(image) => {
image.attributes.remove("link");
}
crate::AttributePart::Ui(ui) => {
for label in ui.labels_mut() {
strip_parts(label);
}
}
crate::AttributePart::Stem(stem) => strip_parts(&mut stem.parts),
crate::AttributePart::IndexTerm(term) => {
for label in term.labels_mut() {
strip_parts(label);
}
}
_ => {}
}
}
parts.retain(|part| {
!matches!(
part,
crate::AttributePart::OpenLink(_)
| crate::AttributePart::CloseLink
| crate::AttributePart::Anchor(_)
| crate::AttributePart::OpenXref(_)
| crate::AttributePart::CloseXref
)
});
}
#[cfg(test)]
mod tests {
use crate::ast::{BlockKind, Inline};
use crate::diagnostic::Severity;
fn heading_id(src: &str, nth: usize) -> Option<String> {
let doc = crate::parse(src).document;
doc.flat_blocks()
.iter()
.filter(|b| matches!(b.kind, BlockKind::Heading { .. }))
.map(|b| b.id.clone())
.nth(nth)
.flatten()
}
fn first_xref(src: &str) -> (String, Vec<Inline>) {
fn find(inlines: &[Inline]) -> Option<(String, Vec<Inline>)> {
for n in inlines {
match n {
Inline::CrossReference { xref, children, .. } => {
return Some((xref.refid.clone().unwrap_or_default(), children.clone()))
}
Inline::Strong { children, .. }
| Inline::Emphasis { children, .. }
| Inline::Monospace { children, .. }
| Inline::Mark { children, .. }
| Inline::Superscript { children, .. }
| Inline::Subscript { children, .. }
| Inline::Link { children, .. } => {
if let Some(hit) = find(children) {
return Some(hit);
}
}
_ => {}
}
}
None
}
let doc = crate::parse(src).document;
for b in &doc.flat_blocks() {
let inlines = match &b.kind {
BlockKind::Heading { inlines, .. } | BlockKind::Paragraph { inlines } => inlines,
_ => continue,
};
if let Some(hit) = find(inlines) {
return hit;
}
}
panic!("no cross-reference in {src:?}");
}
fn text_of(children: &[Inline]) -> String {
super::inline_text(children).replace("<>", "")
}
fn generate_id(title: &str) -> String {
let policy = super::IdPolicy {
prefix: "_".into(),
separator: Some('_'),
};
policy.generate(title)
}
#[test]
fn generate_id_matches_asciidoctor() {
assert_eq!(generate_id("Hello World"), "_hello_world");
assert_eq!(generate_id("Hello, World!"), "_hello_world");
assert_eq!(generate_id("foo & bar"), "_foo_bar");
assert_eq!(generate_id("1. Intro"), "_1_intro");
assert_eq!(generate_id("a.b.c"), "_a_b_c");
assert_eq!(generate_id("a-b-c"), "_a_b_c");
assert_eq!(generate_id("A/B/C"), "_abc");
assert_eq!(generate_id("100%"), "_100");
assert_eq!(generate_id("café déjà"), "_café_déjà");
assert_eq!(generate_id("It\u{2019}s me"), "_its_me");
assert_eq!(generate_id("___"), "");
}
fn policy(prefix: &str, separator: Option<char>) -> super::IdPolicy {
super::IdPolicy {
prefix: prefix.into(),
separator,
}
}
#[test]
fn id_policy_matches_section_generate_id() {
let cases = [
(policy("", Some('-')), "-Lead and trail-", "lead-and-trail"),
(policy("", Some('.')), "a. b - c__d", "a.b.c__d"),
(policy("Sec.", Some('_')), "Hello World", "Sec_hello_world"),
(policy("Pre ", Some('-')), "Über Größe", "Pre-über-größe"),
(policy("_", Some('x')), "xxlarge x y", "_xlargexy"),
(
policy("sec-", None),
"-Lead and trail-",
"sec--leadandtrail-",
),
(policy("sec-", None), "a. b - c", "sec-a.b-c"),
(policy("A&b;", Some('_')), "C D", "A&b;c_d"),
(policy("", Some('_')), "___", ""),
];
for (policy, title, expected) in cases {
assert_eq!(policy.generate(title), expected, "{title}");
}
assert_eq!(generate_id("e\u{301}t\u{e9}"), "_e\u{301}t\u{e9}");
}
#[test]
fn id_policy_deduplicates_with_its_separator() {
let taken = ["dup", "dup-2", "x"];
let is_taken = |id: &str| taken.contains(&id);
assert_eq!(
policy("", Some('-')).unique("dup".into(), is_taken),
"dup-3"
);
assert_eq!(policy("", None).unique("dup".into(), is_taken), "dup2");
assert_eq!(policy("", None).unique("new".into(), is_taken), "new");
}
#[test]
fn id_policy_reads_attributes_at_each_title() {
let src = "== One Two\n\n:idprefix: p-\n\n== One Two\n\n:idseparator: --\n\n\
=== One Two\n\n:idprefix!:\n\n== One Two\n\n[discrete]\n== One Two\n";
let ids: Vec<_> = (0..5).map(|n| heading_id(src, n)).collect();
assert_eq!(
ids,
[
"_one_two",
"p_one_two",
"p-one-two",
"_one-two",
"_one-two-2"
]
.map(|id| Some(id.to_owned()))
);
}
#[test]
fn unset_sectids_leaves_headings_without_ids() {
let src = "= Doc\n:sectids!:\n\n== First\n\n[discrete]\n=== Float\n\n\
[[x]]\n== Explicit\n\n== Second [[y]]\n\n:sectids:\n\n== Later\n\n\
<<_first>> <<First>> <<x>>\n";
let ids: Vec<_> = (1..6).map(|n| heading_id(src, n)).collect();
assert_eq!(
ids,
[
None,
None,
Some("x".into()),
Some("y".into()),
Some("_later".into())
]
);
let messages: Vec<_> = crate::parse(src)
.diagnostics
.into_iter()
.map(|d| d.message)
.collect();
assert_eq!(
messages,
[
"unresolved cross reference: '_first'",
"unresolved cross reference: 'First'"
]
);
}
#[test]
fn generated_ids_cut_a_longer_separator_for_later_reading() {
let src = ":idseparator: -+\n\nBefore {idseparator}\n\n[[a]]\n== A {idseparator}\n\n\
== B C {idseparator}\n\n== D {idseparator}\n\nAfter {idseparator}\n";
let doc = crate::parse(src).document;
let texts: Vec<_> = doc
.flat_blocks()
.iter()
.filter_map(|b| match &b.kind {
BlockKind::Heading { inlines, .. } | BlockKind::Paragraph { inlines } => {
Some((b.id.clone(), text_of(inlines)))
}
_ => None,
})
.collect();
let owned = |id: Option<&str>, text: &str| (id.map(str::to_owned), text.to_owned());
assert_eq!(
texts,
[
owned(None, "Before -+"),
owned(Some("a"), "A -+"),
owned(Some("_b-c"), "B C -+"),
owned(Some("_d"), "D -"),
owned(None, "After -+"),
]
);
}
#[test]
fn section_gets_auto_id_doctitle_does_not() {
assert_eq!(
heading_id("= Doc Title\n\n== Real Section\n\nbody", 0),
None
);
assert_eq!(
heading_id("= Doc Title\n\n== Real Section\n\nbody", 1),
Some("_real_section".into())
);
}
#[test]
fn duplicate_titles_get_numbered_ids() {
let src = "== Intro\n\na\n\n== Intro\n\nb";
assert_eq!(heading_id(src, 0), Some("_intro".into()));
assert_eq!(heading_id(src, 1), Some("_intro_2".into()));
}
#[test]
fn textless_reference_resolves_to_section_title() {
let src = "== Section Two\n\nSee <<_section_two>>.";
let result = crate::parse(src);
assert!(
result.diagnostics.is_empty(),
"no diagnostic for a known id"
);
let (target, children) = first_xref(src);
assert_eq!(target, "_section_two");
assert_eq!(text_of(&children), "Section Two");
}
#[test]
fn forward_reference_resolves() {
let src = "Intro: <<_later>>.\n\n== Later\n\nbody";
let result = crate::parse(src);
assert!(result.diagnostics.is_empty());
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "Later");
}
#[test]
fn explicit_reftext_is_kept() {
let src = "== Section Two\n\nSee <<_section_two,custom>>.";
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "custom");
assert!(crate::parse(src).diagnostics.is_empty());
}
#[test]
fn resolved_reftext_preserves_formatting() {
let src = "== The _good_ part\n\nRef: <<_the_good_part>>.";
let (_, children) = first_xref(src);
assert!(
children
.iter()
.any(|n| matches!(n, Inline::Emphasis { .. })),
"expected emphasis in resolved reftext, got {children:?}"
);
assert_eq!(text_of(&children), "The good part");
}
#[test]
fn xref_macro_resolves_like_angle_form() {
let src = "== Section Two\n\nSee xref:_section_two[].";
let (target, children) = first_xref(src);
assert_eq!(target, "_section_two");
assert_eq!(text_of(&children), "Section Two");
}
fn title_and_paragraph_labels(src: &str) -> (String, crate::Span, String) {
let result = crate::parse(src);
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
let blocks = result.document.flat_blocks();
let titled = blocks
.iter()
.find(|b| b.id.as_deref() == Some("b"))
.expect("block b");
let (span, title) = titled
.title
.as_ref()
.and_then(|t| {
t.iter().find_map(|n| match n {
Inline::CrossReference { children, span, .. } => {
Some((*span, text_of(children)))
}
_ => None,
})
})
.expect("a reference in b's title");
let paragraph = blocks
.iter()
.find_map(|b| match &b.kind {
BlockKind::Paragraph { inlines } => inlines.iter().find_map(|n| match n {
Inline::CrossReference { children, .. } => Some(text_of(children)),
_ => None,
}),
_ => None,
})
.expect("the paragraph's reference");
(title, span, paragraph)
}
#[test]
fn a_short_label_does_not_convert_the_target_title() {
let body = ".First\n[#a]\n----\nx\n----\n\nSee <<b>>.\n\n.Second, from <<a>>\n[#b]\n----\ny\n----\n";
let short = format!(":listing-caption: Example\n:xrefstyle: short\n\n{body}");
let (title, span, paragraph) = title_and_paragraph_labels(&short);
assert_eq!(
(title.as_str(), paragraph.as_str()),
("Example 1", "Example 2")
);
assert_eq!(&short[span.start..span.end], "<<a>>");
let full = format!(":listing-caption: Example\n:xrefstyle: full\n\n{body}");
let (title, _, paragraph) = title_and_paragraph_labels(&full);
assert_eq!(title, "[a]");
assert_eq!(paragraph, "Example 2, \u{201c}Second, from [a]\u{201d}");
let (title, _, paragraph) = title_and_paragraph_labels(body);
assert_eq!(
(title.as_str(), paragraph.as_str()),
("[a]", "Second, from [a]")
);
}
#[test]
fn cross_reference_inside_admonition_resolves() {
let src = "== Later\n\nNOTE: see <<_later>>.";
let result = crate::parse(src);
assert!(result.diagnostics.is_empty(), "known id, no diagnostic");
let inlines = result
.document
.flat_blocks()
.iter()
.find_map(|b| match &b.kind {
BlockKind::Admonition { blocks, .. } => match &blocks[0].kind {
BlockKind::Paragraph { inlines } => Some(inlines.clone()),
_ => None,
},
_ => None,
})
.expect("an admonition block with a paragraph body");
let children = inlines
.iter()
.find_map(|n| match n {
Inline::CrossReference { children, .. } => Some(children.clone()),
_ => None,
})
.expect("a cross-reference in the admonition body");
assert_eq!(text_of(&children), "Later");
}
#[test]
fn unknown_id_warns_and_falls_back_to_bracketed_text() {
let src = "See <<nope>>.";
let result = crate::parse(src);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
let span = result.diagnostics[0].span;
assert_eq!(&src[span.start..span.end], "<<nope>>");
let (target, children) = first_xref(src);
assert_eq!(target, "nope");
assert_eq!(text_of(&children), "[nope]");
}
#[test]
fn unknown_id_with_explicit_text_keeps_text_but_warns() {
let src = "See <<nope,go there>>.";
let result = crate::parse(src);
assert_eq!(result.diagnostics.len(), 1);
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "go there");
}
#[test]
fn textless_ref_to_paragraph_anchor_falls_back_without_diagnostic() {
let src = "[[pa]]\nHello.\n\nSee <<pa>>.";
let result = crate::parse(src);
assert!(
result.diagnostics.is_empty(),
"no diagnostic for a known id"
);
let (target, children) = first_xref(src);
assert_eq!(target, "pa");
assert_eq!(text_of(&children), "[pa]");
}
#[test]
fn textless_ref_to_anchor_reftext_is_display_text() {
let src = "[[pa,The Para]]\nHello.\n\nSee <<pa>>.";
let result = crate::parse(src);
assert!(result.diagnostics.is_empty());
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "The Para");
}
#[test]
fn explicit_anchor_overrides_section_auto_id() {
let src = "[[custom]]\n== Real Title\n\nSee <<custom>>.";
let result = crate::parse(src);
assert!(result.diagnostics.is_empty());
assert_eq!(heading_id(src, 0), Some("custom".into()));
let (target, children) = first_xref(src);
assert_eq!(target, "custom");
assert_eq!(text_of(&children), "Real Title");
}
#[test]
fn explicit_reftext_overrides_section_title() {
let src = "[[s,Custom Ref]]\n== Real Title\n\nSee <<s>>.";
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "Custom Ref");
assert!(crate::parse(src).diagnostics.is_empty());
}
#[test]
fn duplicate_explicit_id_warns_once_and_first_wins() {
let src = "[[dup]]\nFirst.\n\n[[dup]]\nSecond.\n\nSee <<dup>>.";
let result = crate::parse(src);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
assert!(
result.diagnostics[0].message.contains("already in use"),
"got: {}",
result.diagnostics[0].message
);
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "[dup]");
}
#[test]
fn xref_resolves_to_inline_anchor_reftext() {
let src = "Go to <<point>>.\n\nSee the [[point,the Important Part]]here.";
let result = crate::parse(src);
assert!(result.diagnostics.is_empty(), "known id → no diagnostic");
let (target, children) = first_xref(src);
assert_eq!(target, "point");
assert_eq!(text_of(&children), "the Important Part");
}
#[test]
fn xref_to_textless_inline_anchor_falls_back_without_diagnostic() {
let src = "Go to <<spot>>.\n\nSee the [[spot]]here.";
let result = crate::parse(src);
assert!(result.diagnostics.is_empty(), "known id → no diagnostic");
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "[spot]");
}
#[test]
fn anchor_macro_id_resolves_like_the_shorthand() {
let src = "Go to <<point>>.\n\nSee anchor:point[the Part] now.";
let (_, children) = first_xref(src);
assert_eq!(text_of(&children), "the Part");
}
#[test]
fn duplicate_inline_anchor_and_block_anchor_warns() {
let src = "[[dup]]\nFirst.\n\nthen [[dup]]again.";
let result = crate::parse(src);
assert_eq!(result.diagnostics.len(), 1);
assert_eq!(result.diagnostics[0].severity, Severity::Warning);
assert!(result.diagnostics[0].message.contains("already in use"));
}
fn example_captions(src: &str) -> Vec<Option<String>> {
crate::parse(src)
.document
.blocks
.iter()
.filter(|b| matches!(b.kind, BlockKind::Example { .. }))
.map(|b| b.caption.clone())
.collect()
}
#[test]
fn titled_example_gets_numbered_caption() {
let src = ".First\n====\none\n====";
assert_eq!(example_captions(src), vec![Some("Example 1. ".into())]);
}
#[test]
fn two_titled_examples_number_sequentially() {
let src = ".First\n====\none\n====\n\n.Second\n====\ntwo\n====";
assert_eq!(
example_captions(src),
vec![Some("Example 1. ".into()), Some("Example 2. ".into())]
);
}
#[test]
fn untitled_example_has_no_caption_and_does_not_advance_counter() {
let src = ".First\n====\none\n====\n\n====\nmid\n====\n\n.Third\n====\nthree\n====";
assert_eq!(
example_captions(src),
vec![Some("Example 1. ".into()), None, Some("Example 2. ".into())]
);
}
#[test]
fn example_caption_attribute_overrides_prefix() {
let src = ":example-caption: Esempio\n\n.Custom\n====\nx\n====";
assert_eq!(example_captions(src), vec![Some("Esempio 1. ".into())]);
}
#[test]
fn titled_listing_gets_no_caption() {
let src = ".Code\n----\nputs 1\n----";
let caption = crate::parse(src)
.document
.blocks
.iter()
.find(|b| matches!(b.kind, BlockKind::Listing { .. }))
.and_then(|b| b.caption.clone());
assert_eq!(caption, None);
}
#[test]
fn example_nested_in_quote_is_numbered_but_quote_is_not() {
let src = "____\n.Inner\n====\nx\n====\n____";
let doc = crate::parse(src).document;
let quote = &doc.flat_blocks()[0];
assert!(quote.caption.is_none());
let inner = match "e.kind {
BlockKind::Quote { blocks, .. } => &blocks[0],
other => panic!("expected quote, got {other:?}"),
};
assert_eq!(inner.caption.as_deref(), Some("Example 1. "));
}
}