use std::collections::HashMap;
use crate::{
Document, Parser,
blocks::{Block, IsBlock, SimpleBlock},
parser::{
CatalogResolver, HtmlSubstitutionRenderer, ReferenceResolver, ResolutionContext,
ResolvedReference, XrefSignifier,
},
};
fn first_simple<'a>(doc: &'a Document<'a>) -> &'a SimpleBlock<'a> {
fn walk<'a>(mut blocks: impl Iterator<Item = &'a Block<'a>>) -> Option<&'a SimpleBlock<'a>> {
blocks.find_map(|block| {
if let Block::Simple(simple) = block {
Some(simple)
} else {
walk(block.nested_blocks())
}
})
}
walk(doc.nested_blocks()).expect("expected at least one simple block")
}
fn first_paragraph<'a>(doc: &'a Document<'a>) -> &'a str {
first_simple(doc).content().rendered()
}
fn first_section<'a>(doc: &'a Document<'a>) -> &'a crate::blocks::SectionBlock<'a> {
fn walk<'a>(
mut blocks: impl Iterator<Item = &'a Block<'a>>,
) -> Option<&'a crate::blocks::SectionBlock<'a>> {
blocks.find_map(|block| {
if let Block::Section(section) = block {
Some(section)
} else {
walk(block.nested_blocks())
}
})
}
walk(doc.nested_blocks()).expect("expected at least one section block")
}
#[test]
fn forward_reference_resolves() {
let doc = Parser::default().parse("See <<later>>.\n\n[#later]\n== Later\n");
assert_eq!(first_paragraph(&doc), "See <a href=\"#later\">Later</a>.");
}
#[test]
fn backward_reference_resolves() {
let doc =
Parser::default().parse("[#first]\n== First\n\nText.\n\n== Second\n\nBack to <<first>>.\n");
let mut paragraphs = vec![];
fn collect<'a>(blocks: impl Iterator<Item = &'a Block<'a>>, out: &mut Vec<String>) {
for block in blocks {
if let Block::Simple(simple) = block {
out.push(simple.content().rendered().to_string());
}
collect(block.nested_blocks(), out);
}
}
collect(doc.nested_blocks(), &mut paragraphs);
assert!(
paragraphs
.iter()
.any(|p| p == "Back to <a href=\"#first\">First</a>."),
"paragraphs were {paragraphs:?}"
);
}
#[test]
fn reference_with_explicit_text() {
let doc = Parser::default().parse("<<sec,Custom Label>>\n\n[#sec]\n== Section\n");
assert_eq!(first_paragraph(&doc), "<a href=\"#sec\">Custom Label</a>");
}
#[test]
fn natural_reference_by_reftext() {
let doc = Parser::default().parse("See <<The Beginning>>.\n\n== The Beginning\n");
assert_eq!(
first_paragraph(&doc),
"See <a href=\"#_the_beginning\">The Beginning</a>."
);
}
#[test]
fn xref_macro_form_resolves() {
let doc = Parser::default().parse("See xref:later[].\n\n[#later]\n== Later\n");
assert_eq!(first_paragraph(&doc), "See <a href=\"#later\">Later</a>.");
}
#[test]
fn footnote_in_heading_does_not_leak_into_xref_text() {
let doc = Parser::default().parse(concat!(
"See <<sect2>>.\n",
"\n",
"== Section 1\n",
"\n",
"para.footnote:[first footnote]\n",
"\n",
"[#sect2]\n",
"== Section 2footnote:[second footnote]\n",
"\n",
"para.footnote:[third footnote]\n",
));
assert_eq!(
first_paragraph(&doc),
"See <a href=\"#sect2\">Section 2</a>."
);
let footnotes = doc.catalog().footnotes();
assert_eq!(
footnotes
.iter()
.map(|f| (f.index.as_str(), f.text.as_str()))
.collect::<Vec<_>>(),
vec![
("1", "first footnote"),
("2", "second footnote"),
("3", "third footnote"),
]
);
}
#[test]
fn footnote_in_heading_does_not_leak_into_generated_id() {
let doc = Parser::default().parse(concat!(
"See <<Section 2>>.\n",
"\n",
"== Section 2footnote:[a note]\n",
));
assert_eq!(
first_paragraph(&doc),
"See <a href=\"#_section_2\">Section 2</a>."
);
}
#[test]
fn footnote_reaching_a_heading_via_attribute_is_kept_out_of_xref_text() {
let doc = Parser::default().parse(concat!(
":disclaimer: footnote:[Not legal advice.]\n",
"\n",
"See <<Terms>>.\n",
"\n",
"== Terms{disclaimer}\n",
));
assert_eq!(first_paragraph(&doc), "See <a href=\"#_terms\">Terms</a>.");
let footnotes = doc.catalog().footnotes();
assert_eq!(footnotes.len(), 1);
assert_eq!(footnotes[0].index, "1");
assert_eq!(footnotes[0].text, "Not legal advice.");
}
#[test]
fn footnote_and_xref_in_the_same_heading_render_without_sentinels() {
let doc = Parser::default().parse(concat!(
"== Title footnote:[a note] see <<other>>\n",
"\n",
"[#other]\n",
"== Other\n",
));
let title = first_section(&doc).section_title();
assert!(
!title.contains('\u{E002}') && !title.contains('\u{E003}'),
"heading still contains marker sentinels: {title:?}"
);
assert!(title.contains(r#"class="footnote""#), "{title:?}");
assert!(
title.contains(r##"<a href="#other">Other</a>"##),
"{title:?}"
);
assert_eq!(doc.catalog().footnotes().len(), 1);
}
#[test]
fn footnote_in_heading_does_not_advance_a_counter_twice() {
let doc = Parser::default().parse(concat!(
"See <<Chapter 1>>.\n",
"\n",
"== Chapter {counter:ch}footnote:[a note]\n",
"\n",
"Next is {counter:ch}.\n",
));
assert_eq!(
first_paragraph(&doc),
"See <a href=\"#_chapter_1\">Chapter 1</a>."
);
let mut paragraphs = vec![];
fn collect<'a>(blocks: impl Iterator<Item = &'a Block<'a>>, out: &mut Vec<String>) {
for block in blocks {
if let Block::Simple(simple) = block {
out.push(simple.content().rendered().to_string());
}
collect(block.nested_blocks(), out);
}
}
collect(doc.nested_blocks(), &mut paragraphs);
assert!(
paragraphs.iter().any(|p| p == "Next is 2."),
"paragraphs were {paragraphs:?}"
);
}
#[test]
fn unresolved_reference_falls_back_and_warns() {
let mut doc = Parser::default().parse_deferred("See <<nope>>.\n");
assert!(first_simple(&doc).content().has_unresolved_refs());
let catalog = doc.catalog().clone();
let resolver = CatalogResolver::new(&catalog);
let warnings = doc.resolve_references(&resolver, &HtmlSubstitutionRenderer {});
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].target, "nope");
assert_eq!(first_paragraph(&doc), "See <a href=\"#nope\">[nope]</a>.");
}
#[test]
fn xrefstyle_survives_deferred_resolution() {
let src = ":sectnums:\n:xrefstyle: full\n\nSee <<install>>.\n\n\
== One\n\n== Two\n\n=== Two-A\n\n=== Two-B\n\n\
[#install]\n=== Installation\n";
let mut doc = Parser::default().parse_deferred(src);
assert!(first_simple(&doc).content().has_unresolved_refs());
assert_eq!(
first_paragraph(&doc),
"See <a href=\"#install\">[install]</a>."
);
let catalog = doc.catalog().clone();
let resolver = CatalogResolver::new(&catalog);
let warnings = doc.resolve_references(&resolver, &HtmlSubstitutionRenderer {});
assert!(warnings.is_empty());
assert_eq!(
first_paragraph(&doc),
"See <a href=\"#install\">Section 2.3, “Installation”</a>.",
);
}
#[test]
fn host_resolver_can_attach_signifier() {
let mut doc = Parser::default().parse_deferred(":xrefstyle: full\n\nSee <<install>>.\n");
let resolver = CrossDocResolver {
index: HashMap::from([(
"install".to_string(),
ResolvedReference::new(
"guide.html#install".to_string(),
Some("Installation".to_string()),
)
.with_signifier(XrefSignifier {
label: "Section 2.3".to_string(),
emphasize: false,
}),
)]),
};
let warnings = doc.resolve_references(&resolver, &HtmlSubstitutionRenderer {});
assert!(warnings.is_empty());
assert_eq!(
first_paragraph(&doc),
"See <a href=\"guide.html#install\">Section 2.3, “Installation”</a>.",
);
}
#[test]
fn escaped_reference_is_not_a_cross_reference() {
let doc = Parser::default().parse("See \\<<later>>.\n\n[#later]\n== Later\n");
assert!(!first_simple(&doc).content().has_unresolved_refs());
assert_eq!(first_paragraph(&doc), "See <<later>>.");
}
struct CrossDocResolver {
index: HashMap<String, ResolvedReference>,
}
impl ReferenceResolver for CrossDocResolver {
fn resolve(&self, context: &ResolutionContext<'_>) -> Option<ResolvedReference> {
self.index.get(context.target).cloned()
}
}
#[test]
fn cross_document_resolution() {
let mut parser = Parser::default();
let mut doc_a = parser.parse_deferred("See <<b-topic>> for details.\n");
let doc_b = parser.parse_deferred("[#b-topic]\n== B Topic\n\nContent.\n");
let mut index = HashMap::new();
for id in ["b-topic"] {
if let Some(entry) = doc_b.catalog().get_ref(id) {
index.insert(
entry.id.clone(),
ResolvedReference::from_entry(format!("doc-b.html#{id}"), entry),
);
}
}
let resolver = CrossDocResolver { index };
assert!(first_simple(&doc_a).content().has_unresolved_refs());
let warnings = doc_a.resolve_references(&resolver, &HtmlSubstitutionRenderer {});
assert!(warnings.is_empty());
assert_eq!(
first_paragraph(&doc_a),
"See <a href=\"doc-b.html#b-topic\">B Topic</a> for details."
);
assert!(!first_simple(&doc_a).content().has_unresolved_refs());
}
#[test]
fn xrefstyle_carries_across_documents() {
let mut parser = Parser::default();
let mut doc_a = parser.parse_deferred(":xrefstyle: full\n\nSee <<install>>.\n");
let doc_b = parser.parse_deferred(
":sectnums:\n\n== One\n\n== Two\n\n=== Two-A\n\n=== Two-B\n\n[#install]\n=== Installation\n",
);
let mut index = HashMap::new();
if let Some(entry) = doc_b.catalog().get_ref("install") {
index.insert(
"install".to_string(),
ResolvedReference::from_entry("doc-b.html#install".to_string(), entry),
);
}
let resolver = CrossDocResolver { index };
let warnings = doc_a.resolve_references(&resolver, &HtmlSubstitutionRenderer {});
assert!(warnings.is_empty());
assert_eq!(
first_paragraph(&doc_a),
"See <a href=\"doc-b.html#install\">Section 2.3, “Installation”</a>.",
);
}
#[test]
fn resolution_is_repeatable() {
let mut doc = Parser::default().parse_deferred("See <<topic>>.\n");
let first = CrossDocResolver {
index: HashMap::from([(
"topic".to_string(),
ResolvedReference::new("first.html#topic".to_string(), Some("First".to_string())),
)]),
};
doc.resolve_references(&first, &HtmlSubstitutionRenderer {});
assert_eq!(
first_paragraph(&doc),
"See <a href=\"first.html#topic\">First</a>."
);
let second = CrossDocResolver {
index: HashMap::from([(
"topic".to_string(),
ResolvedReference::new("second.html#topic".to_string(), Some("Second".to_string())),
)]),
};
doc.resolve_references(&second, &HtmlSubstitutionRenderer {});
assert_eq!(
first_paragraph(&doc),
"See <a href=\"second.html#topic\">Second</a>."
);
}
#[test]
fn re_resolution_is_a_full_independent_sweep() {
let mut doc = Parser::default().parse_deferred("See <<topic>>.\n");
let knows_topic = CrossDocResolver {
index: HashMap::from([(
"topic".to_string(),
ResolvedReference::new("first.html#topic".to_string(), Some("Topic".to_string())),
)]),
};
let warnings = doc.resolve_references(&knows_topic, &HtmlSubstitutionRenderer {});
assert!(warnings.is_empty());
assert_eq!(
first_paragraph(&doc),
"See <a href=\"first.html#topic\">Topic</a>."
);
let knows_nothing = CrossDocResolver {
index: HashMap::new(),
};
let warnings = doc.resolve_references(&knows_nothing, &HtmlSubstitutionRenderer {});
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].target, "topic");
assert_eq!(first_paragraph(&doc), "See <a href=\"#topic\">[topic]</a>.");
}
#[test]
fn footnote_cross_references_resolve_via_host_resolver() {
let mut doc =
Parser::default().parse_deferred("Text.footnote:[See <<topic>> and <<missing>>.]\n");
let resolver = CrossDocResolver {
index: HashMap::from([(
"topic".to_string(),
ResolvedReference::new("other.html#topic".to_string(), Some("Topic".to_string())),
)]),
};
let warnings = doc.resolve_references(&resolver, &HtmlSubstitutionRenderer {});
assert_eq!(
doc.catalog().footnotes()[0].text,
r##"See <a href="other.html#topic">Topic</a> and <a href="#missing">[missing]</a>."##
);
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].target, "missing");
}
#[test]
fn footnote_debug_includes_deferred_cross_references() {
let doc = Parser::default().parse("Text.footnote:[See <<sec>>.]\n\n[#sec]\n== Section\n");
let footnote = &doc.catalog().footnotes()[0];
let debug = format!("{footnote:?}");
assert!(debug.contains("deferred"), "debug was: {debug}");
}
#[test]
fn xref_macro_honors_role_and_non_blank_window() {
let doc = Parser::default().parse("xref:sec[Go,role=hint,window=_top]\n\n[#sec]\n== Section\n");
assert_eq!(
first_paragraph(&doc),
r##"<a href="#sec" class="hint" target="_top">Go</a>"##
);
}
#[test]
fn xrefstyle_value_interpretation() {
let with_xrefstyle = |header: &str| {
Parser::default().parse(&format!(
"{header}See <<data>>.\n\n[appendix]\n[#data]\n== Data\n"
))
};
assert_eq!(
first_paragraph(&with_xrefstyle("")),
r##"See <a href="#data">Data</a>."##
);
assert_eq!(
first_paragraph(&with_xrefstyle(":xrefstyle: basic\n\n")),
r##"See <a href="#data"><em>Data</em></a>."##
);
assert_eq!(
first_paragraph(&with_xrefstyle(":xrefstyle:\n\n")),
r##"See <a href="#data"><em>Data</em></a>."##
);
assert_eq!(
first_paragraph(&with_xrefstyle(":xrefstyle: bogus\n\n")),
r##"See <a href="#data"><em>Data</em></a>."##
);
}
#[test]
fn xref_escapes_author_supplied_window_and_role() {
let doc = Parser::default().parse(
"xref:sec[Go,role=\"a\\\"b\",window=\"_top\\\" onclick=\\\"evil()\"]\n\n[#sec]\n== Section\n",
);
let rendered = first_paragraph(&doc);
assert!(
!rendered.contains("onclick=\"evil"),
"attribute injection was not escaped: {rendered}"
);
assert!(
rendered.contains("""),
"expected escaped quotes in: {rendered}"
);
}