use std::collections::HashMap;
use crate::{
Document, Parser,
blocks::{Block, IsBlock, SimpleBlock},
parser::{
CatalogResolver, HtmlSubstitutionRenderer, ReferenceResolver, ResolutionContext,
ResolvedReference,
},
};
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()
}
#[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 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 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 {
href: format!("doc-b.html#{id}"),
text: entry.reftext.clone(),
},
);
}
}
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 resolution_is_repeatable() {
let mut doc = Parser::default().parse_deferred("See <<topic>>.\n");
let first = CrossDocResolver {
index: HashMap::from([(
"topic".to_string(),
ResolvedReference {
href: "first.html#topic".to_string(),
text: 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 {
href: "second.html#topic".to_string(),
text: 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 {
href: "first.html#topic".to_string(),
text: 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 {
href: "other.html#topic".to_string(),
text: 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 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}"
);
}