use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use super::hooks::{escape_text, RenderHooks};
use super::node::{Block, Inline};
use super::render::{render_blocks_with, render_inlines};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct FootnoteIndex {
numbers: std::collections::HashMap<String, usize>,
order: Vec<(usize, String)>,
}
impl FootnoteIndex {
pub fn build(blocks: &[Block]) -> Self {
let mut defined: Vec<String> = Vec::new();
collect_definitions(blocks, &mut defined, &mut HashSet::new());
if defined.is_empty() {
return Self::default();
}
let defined_set: HashSet<&str> = defined.iter().map(String::as_str).collect();
let mut order: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
let hoisted = first_definition_bodies(blocks, &defined);
collect_refs(blocks, &mut order, &mut seen, &hoisted);
let mut i = 0;
loop {
if i == order.len() {
let Some(next) = defined.iter().find(|l| !seen.contains(l.as_str())) else {
break;
};
seen.insert(next.clone());
order.push(next.clone());
}
if let Some(children) = Self::definition(blocks, &order[i]) {
collect_refs(children, &mut order, &mut seen, &hoisted);
}
i += 1;
}
let mut index = Self::default();
for label in order {
if !defined_set.contains(label.as_str()) {
continue;
}
let n = index.order.len() + 1;
index.numbers.insert(label.clone(), n);
index.order.push((n, label));
}
index
}
pub fn is_empty(&self) -> bool {
self.order.is_empty()
}
pub fn number(&self, label: &str) -> Option<usize> {
self.numbers.get(label).copied()
}
pub fn entries(&self) -> &[(usize, String)] {
&self.order
}
pub fn definition<'a>(blocks: &'a [Block], label: &str) -> Option<&'a [Block]> {
for block in blocks {
let found = match block {
Block::FootnoteDefinition { label: l, children } if l == label => {
return Some(children)
}
Block::FootnoteDefinition { children, .. }
| Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::LinkCard { children, .. } => Self::definition(children, label),
Block::List { items, .. } => items
.iter()
.find_map(|item| Self::definition(item, label)),
_ => None,
};
if found.is_some() {
return found;
}
}
None
}
}
fn collect_definitions(blocks: &[Block], out: &mut Vec<String>, seen: &mut HashSet<String>) {
for block in blocks {
match block {
Block::FootnoteDefinition { label, children } => {
if seen.insert(label.clone()) {
out.push(label.clone());
}
collect_definitions(children, out, seen);
}
Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::LinkCard { children, .. } => collect_definitions(children, out, seen),
Block::List { items, .. } => {
for item in items {
collect_definitions(item, out, seen);
}
}
_ => {}
}
}
}
pub(super) fn hoisted_definition_bodies(blocks: &[Block]) -> HashMap<String, usize> {
let mut defined = Vec::new();
collect_definitions(blocks, &mut defined, &mut HashSet::new());
first_definition_bodies(blocks, &defined)
}
fn first_definition_bodies(blocks: &[Block], labels: &[String]) -> HashMap<String, usize> {
labels
.iter()
.filter_map(|label| {
FootnoteIndex::definition(blocks, label)
.map(|children| (label.clone(), children.as_ptr() as usize))
})
.collect()
}
fn collect_refs(
blocks: &[Block],
out: &mut Vec<String>,
seen: &mut HashSet<String>,
hoisted: &HashMap<String, usize>,
) {
for block in blocks {
match block {
Block::Heading { children, .. } | Block::Paragraph(children) => {
collect_refs_in_inlines(children, out, seen)
}
Block::BlockQuote(children)
| Block::Callout { children, .. }
| Block::LinkCard { children, .. } => collect_refs(children, out, seen, hoisted),
Block::FootnoteDefinition { label, children } => {
if hoisted.get(label.as_str()) != Some(&(children.as_ptr() as usize)) {
collect_refs(children, out, seen, hoisted);
}
}
Block::List { items, .. } => {
for item in items {
collect_refs(item, out, seen, hoisted);
}
}
Block::Table { header, rows, .. } => {
for cell in header.iter().chain(rows.iter().flatten()) {
collect_refs_in_inlines(cell, out, seen);
}
}
Block::Figure { caption, .. } => {
if let Some(caption) = caption {
collect_refs_in_inlines(caption, out, seen);
}
}
_ => {}
}
}
}
fn collect_refs_in_inlines(inlines: &[Inline], out: &mut Vec<String>, seen: &mut HashSet<String>) {
for inline in inlines {
match inline {
Inline::FootnoteRef(label) => {
if seen.insert(label.clone()) {
out.push(label.clone());
}
}
Inline::Emphasis(children)
| Inline::Strong(children)
| Inline::Strikethrough(children)
| Inline::Link { children, .. } => collect_refs_in_inlines(children, out, seen),
_ => {}
}
}
}
#[derive(Default)]
pub struct FootnoteCtx {
index: FootnoteIndex,
emitted: HashMap<String, usize>,
hoisted: HashMap<String, usize>,
}
impl FootnoteCtx {
pub fn for_document(blocks: &[Block]) -> Self {
let index = FootnoteIndex::build(blocks);
let labels: Vec<String> = index.entries().iter().map(|(_, l)| l.clone()).collect();
let hoisted = first_definition_bodies(blocks, &labels);
Self {
index,
emitted: HashMap::new(),
hoisted,
}
}
}
fn marker_id(n: usize, k: usize) -> String {
if k == 1 {
format!("fnref-{n}")
} else {
format!("fnref-{n}-{k}")
}
}
pub(super) fn render_marker<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
label: &str,
ctx: &mut FootnoteCtx,
) {
let Some(n) = ctx.index.number(label) else {
out.push_str("[^");
out.push_str(&escape_text(label));
out.push(']');
return;
};
let k = ctx.emitted.entry(label.to_string()).or_insert(0);
*k += 1;
if hooks.emit_footnote_anchors() {
let id = marker_id(n, *k);
let _ = write!(
out,
r##"<sup class="moss-footnote-ref" id="{id}"><a href="#fn-{n}" role="doc-noteref">{n}</a></sup>"##
);
} else {
let _ = write!(out, r##"<sup class="moss-footnote-ref">{n}</sup>"##);
}
}
pub(super) fn is_hoisted(label: &str, children: &[Block], ctx: &FootnoteCtx) -> bool {
is_hoisted_in(label, children, &ctx.hoisted)
}
pub(super) fn is_hoisted_in(
label: &str,
children: &[Block],
hoisted: &HashMap<String, usize>,
) -> bool {
hoisted.get(label) == Some(&(children.as_ptr() as usize))
}
pub fn render_section<H: RenderHooks + ?Sized>(
hooks: &H,
out: &mut String,
blocks: &[Block],
ctx: &mut FootnoteCtx,
) {
if ctx.index.is_empty() {
return;
}
let mut notes: Vec<(usize, String, String, Option<String>)> = Vec::new();
for (n, label) in ctx.index.entries().to_vec() {
let Some(children) = FootnoteIndex::definition(blocks, &label) else {
continue;
};
let (mut head, mut tail) = (String::new(), None);
match children.split_last() {
Some((Block::Paragraph(inlines), rest)) => {
render_blocks_with(hooks, &mut head, rest, ctx);
let mut last = String::new();
render_inlines(hooks, &mut last, inlines, ctx);
tail = Some(last);
}
_ => render_blocks_with(hooks, &mut head, children, ctx),
}
notes.push((n, label, head, tail));
}
let emit_anchors = hooks.emit_footnote_anchors();
out.push_str("<section class=\"moss-footnotes\" role=\"doc-endnotes\">\n<ol>\n");
for (n, label, head, tail) in notes {
let backrefs = ctx.emitted.get(&label).copied().unwrap_or(0);
if emit_anchors {
let _ = write!(out, "<li id=\"fn-{n}\">");
} else {
out.push_str("<li>");
}
out.push_str(&head);
if tail.is_some() || backrefs > 0 {
out.push_str("<p>");
out.push_str(tail.as_deref().unwrap_or_default());
if emit_anchors {
push_backrefs(out, n, backrefs);
}
out.push_str("</p>\n");
}
out.push_str("</li>\n");
}
out.push_str("</ol>\n</section>\n");
}
fn push_backrefs(out: &mut String, n: usize, count: usize) {
for k in 1..=count {
let id = marker_id(n, k);
let nth = if k == 1 {
String::new()
} else {
format!(" ({k})")
};
let _ = write!(
out,
r##" <a class="moss-footnote-backref" href="#{id}" role="doc-backlink" aria-label="Back to reference {n}{nth}">↩︎</a>"##
);
}
}