use crate::parser::Span;
use std::collections::HashMap;
#[derive(Debug, Clone, Default)]
pub struct ReferenceMap {
defs: HashMap<String, (String, Option<String>)>,
}
impl ReferenceMap {
pub fn new() -> Self {
Self::default()
}
pub fn insert(&mut self, label: &str, url: String, title: Option<String>) {
let normalized = normalize_label(label);
self.defs.entry(normalized).or_insert((url, title));
}
pub fn get(&self, label: &str) -> Option<&(String, Option<String>)> {
let normalized = normalize_label(label);
self.defs.get(&normalized)
}
pub fn contains(&self, label: &str) -> bool {
let normalized = normalize_label(label);
self.defs.contains_key(&normalized)
}
}
fn normalize_label(label: &str) -> String {
let mut collapsed = String::with_capacity(label.len());
let mut first = true;
for word in label.split_whitespace() {
if !first {
collapsed.push(' ');
}
collapsed.push_str(word);
first = false;
}
let mut out = String::with_capacity(collapsed.len());
for ch in collapsed.chars() {
for lower in ch.to_lowercase() {
if lower == 'ß' {
out.push('s');
out.push('s');
} else {
out.push(lower);
}
}
}
out
}
#[derive(Debug, Clone, Default)]
pub struct Document {
pub children: Vec<Node>,
pub references: ReferenceMap,
}
#[derive(Debug, Clone)]
pub struct Node {
pub kind: NodeKind,
pub span: Option<Span>,
pub children: Vec<Node>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TableAlignment {
#[default]
None,
Left,
Center,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmonitionKind {
Note,
Tip,
Important,
Warning,
Caution,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmonitionStyle {
Alert,
Quote,
}
#[derive(Debug, Clone)]
pub enum NodeKind {
Heading {
level: u8,
text: String,
id: Option<String>,
},
Paragraph,
CodeBlock {
language: Option<String>,
code: String,
},
ThematicBreak,
List {
ordered: bool,
start: Option<u32>,
tight: bool,
},
ListItem,
DefinitionList,
DefinitionTerm,
DefinitionDescription,
TaskCheckbox {
checked: bool,
},
Blockquote,
Admonition {
kind: AdmonitionKind,
title: Option<String>,
icon: Option<String>,
style: AdmonitionStyle,
},
TabGroup,
TabItem {
title: String,
},
SliderDeck {
timer_seconds: Option<u32>,
},
Slide {
vertical: bool,
},
Table {
alignments: Vec<TableAlignment>,
},
TableRow {
header: bool,
},
TableCell {
header: bool,
alignment: TableAlignment,
},
HtmlBlock {
html: String,
},
FootnoteDefinition {
label: String,
},
Text(String),
TaskCheckboxInline {
checked: bool,
},
Emphasis,
Strong,
StrongEmphasis,
Strikethrough,
Mark,
Superscript,
Subscript,
Link {
url: String,
title: Option<String>,
},
LinkReference {
label: String,
suffix: String,
},
FootnoteReference {
label: String,
},
Image {
url: String,
alt: String,
},
CodeSpan(String),
InlineHtml(String),
HardBreak,
SoftBreak,
PlatformMention {
username: String,
platform: String,
display: Option<String>,
},
InlineMath {
content: String,
},
DisplayMath {
content: String,
},
MermaidDiagram {
content: String,
},
}
impl Document {
pub fn new() -> Self {
Self::default()
}
pub fn len(&self) -> usize {
self.children.len()
}
pub fn is_empty(&self) -> bool {
self.children.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::ReferenceMap;
#[test]
fn smoke_test_reference_map_first_definition_wins() {
let mut refs = ReferenceMap::new();
refs.insert("foo", "https://first.example".to_string(), None);
refs.insert("foo", "https://second.example".to_string(), None);
let (url, title) = refs.get("foo").expect("reference not found");
assert_eq!(url, "https://first.example");
assert_eq!(title, &None);
}
#[test]
fn smoke_test_reference_map_casefold_sharp_s() {
let mut refs = ReferenceMap::new();
refs.insert("SS", "/url".to_string(), None);
let (url, _) = refs.get("ẞ").expect("reference not found");
assert_eq!(url, "/url");
}
#[test]
fn smoke_test_reference_map_whitespace_collapse() {
let mut refs = ReferenceMap::new();
refs.insert("Foo\n\t bar", "/url".to_string(), None);
assert!(refs.contains("foo bar"));
assert!(refs.contains(" FOO BAR "));
}
}