use std::ops::Range;
use super::format::{OutputFormat, QuoteMarks, realize_wrap};
use super::visible_scan::{RunBuilder, find_matching, skip_balanced};
use crate::values::ScriptClass;
use citum_schema::template::WrapPunctuation;
fn escape_commonmark_text(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 4);
for ch in s.chars() {
match ch {
'\\' | '*' | '_' | '[' | ']' | '`' | '<' | '>' | '&' => {
out.push('\\');
out.push(ch);
}
_ => out.push(ch),
}
}
out
}
#[derive(Default, Clone)]
pub struct Markdown;
impl OutputFormat for Markdown {
type Output = String;
fn text(&self, s: &str) -> Self::Output {
escape_commonmark_text(s)
}
fn join(&self, items: Vec<Self::Output>, delimiter: &str) -> Self::Output {
items.join(delimiter)
}
fn finish(&self, output: Self::Output) -> String {
output
}
fn heading(&self, level: u8, content: Self::Output) -> Self::Output {
let marks = "#".repeat(level.max(1) as usize);
format!("{marks} {content}\n\n")
}
fn emph(&self, content: Self::Output) -> Self::Output {
if content.is_empty() {
return content;
}
format!("*{content}*")
}
fn strong(&self, content: Self::Output) -> Self::Output {
if content.is_empty() {
return content;
}
format!("**{content}**")
}
fn small_caps(&self, content: Self::Output) -> Self::Output {
if content.is_empty() {
return content;
}
format!("<span style=\"font-variant:small-caps\">{content}</span>")
}
fn superscript(&self, content: Self::Output) -> Self::Output {
if content.is_empty() {
return content;
}
format!("<sup>{content}</sup>")
}
fn quote(&self, content: Self::Output, marks: &QuoteMarks) -> Self::Output {
if content.is_empty() {
return content;
}
let (open, close) = marks.for_depth(0);
format!("{open}{content}{close}")
}
fn affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
format!("{prefix}{content}{suffix}")
}
fn inner_affix(&self, prefix: &str, content: Self::Output, suffix: &str) -> Self::Output {
format!("{prefix}{content}{suffix}")
}
fn wrap_punctuation(
&self,
wrap: &WrapPunctuation,
content: Self::Output,
marks: &QuoteMarks,
script: ScriptClass,
realization: Option<&citum_schema::options::PunctuationRealization>,
) -> Self::Output {
match realize_wrap(wrap, script, realization) {
Some((open, close)) => {
format!("{}{}{}", self.text(&open), content, self.text(&close))
}
None => self.quote(content, marks),
}
}
fn semantic(&self, _class: &str, content: Self::Output) -> Self::Output {
content
}
fn annotation(&self, content: Self::Output) -> Self::Output {
if content.is_empty() {
return content;
}
format!("\n\n{content}")
}
fn link(&self, url: &str, content: Self::Output) -> Self::Output {
if content.is_empty() {
return content;
}
format!("[{content}]({url})")
}
fn entry(
&self,
_id: &str,
content: Self::Output,
url: Option<&str>,
_metadata: &super::format::ProcEntryMetadata,
) -> Self::Output {
if let Some(u) = url {
self.link(u, content)
} else {
content
}
}
fn visible_runs(&self, fragment: &str) -> Vec<Range<usize>> {
let mut runs = RunBuilder::default();
let chars: Vec<(usize, char)> = fragment.char_indices().collect();
let mut i = 0;
let mut in_tag = false;
let mut pending_link_close: Option<usize> = None;
while let Some(&(pos, ch)) = chars.get(i) {
if in_tag {
if ch == '>' {
in_tag = false;
}
i += 1;
continue;
}
match ch {
'\\' => {
if let Some(&(epos, echar)) = chars.get(i + 1) {
runs.push_visible(epos, epos + echar.len_utf8());
}
i += 2;
}
'<' => {
in_tag = true;
i += 1;
}
'*' => {
i += 1;
if chars.get(i).map(|&(_, c)| c) == Some('*') {
i += 1;
}
}
'[' => {
let close_i = find_matching(&chars, i, '[', ']', true);
let is_link = close_i
.is_some_and(|c| chars.get(c + 1).map(|&(_, next)| next) == Some('('));
if is_link {
pending_link_close = close_i;
i += 1;
} else {
runs.push_visible(pos, pos + 1);
i += 1;
}
}
']' => {
if pending_link_close == Some(i) {
pending_link_close = None;
let mut j = i + 1;
if chars.get(j).map(|&(_, c)| c) == Some('(') {
j = skip_balanced(&chars, j, '(', ')', true);
}
i = j;
} else {
runs.push_visible(pos, pos + 1);
i += 1;
}
}
_ => {
runs.push_visible(pos, pos + ch.len_utf8());
i += 1;
}
}
}
runs.finish()
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "tests"
)]
mod tests {
use super::*;
#[test]
fn test_markdown_emph() {
let fmt = Markdown;
for (input, expected) in [("", ""), ("text", "*text*")] {
assert_eq!(fmt.emph(input.to_string()), expected);
}
}
#[test]
fn test_markdown_strong() {
let fmt = Markdown;
for (input, expected) in [("", ""), ("text", "**text**")] {
assert_eq!(fmt.strong(input.to_string()), expected);
}
}
#[test]
fn test_markdown_small_caps() {
let fmt = Markdown;
assert_eq!(fmt.small_caps(String::new()), "");
assert_eq!(
fmt.small_caps("Smith".to_string()),
"<span style=\"font-variant:small-caps\">Smith</span>"
);
}
#[test]
fn test_markdown_superscript() {
let fmt = Markdown;
assert_eq!(fmt.superscript(String::new()), "");
assert_eq!(fmt.superscript("2".to_string()), "<sup>2</sup>");
}
#[test]
fn test_markdown_quote() {
let fmt = Markdown;
let marks = QuoteMarks::default();
for (input, expected) in [("", ""), ("text", "\u{201C}text\u{201D}")] {
assert_eq!(fmt.quote(input.to_string(), &marks), expected);
}
}
#[test]
fn test_markdown_quote_uses_locale_marks() {
let fmt = Markdown;
let marks = QuoteMarks {
open: "\u{ab}".to_string(),
close: "\u{bb}".to_string(),
open_inner: "\u{2039}".to_string(),
close_inner: "\u{203a}".to_string(),
punctuation_realization: None,
};
assert_eq!(fmt.quote("text".to_string(), &marks), "\u{ab}text\u{bb}");
}
#[test]
fn test_markdown_semantic_passthrough() {
let fmt = Markdown;
assert_eq!(fmt.semantic("author", "Jane Doe".to_string()), "Jane Doe");
assert_eq!(fmt.semantic("title", String::new()), "");
}
#[test]
fn test_markdown_link() {
let fmt = Markdown;
assert_eq!(fmt.link("https://example.com", String::new()), "");
assert_eq!(
fmt.link("https://example.com", "Example".to_string()),
"[Example](https://example.com)"
);
}
#[test]
fn test_markdown_wrap_punctuation() {
let fmt = Markdown;
let marks = QuoteMarks::default();
for (wrap, script, input, expected) in [
(
WrapPunctuation::Parentheses,
ScriptClass::Latin,
"text",
"(text)",
),
(
WrapPunctuation::Brackets,
ScriptClass::Latin,
"text",
"\\[text\\]",
),
(
WrapPunctuation::Quotes,
ScriptClass::Latin,
"text",
"\u{201C}text\u{201D}",
),
(
WrapPunctuation::Parentheses,
ScriptClass::Cjk,
"text",
"\u{ff08}text\u{ff09}",
),
(
WrapPunctuation::Brackets,
ScriptClass::Cjk,
"text",
"\u{3010}text\u{3011}",
),
] {
assert_eq!(
fmt.wrap_punctuation(&wrap, input.to_string(), &marks, script, None),
expected
);
}
}
#[test]
fn test_markdown_text_escapes_active_chars() {
let fmt = Markdown;
assert_eq!(fmt.text("plain"), "plain");
assert_eq!(fmt.text("A * B"), "A \\* B");
assert_eq!(fmt.text("use [x]"), "use \\[x\\]");
assert_eq!(fmt.text("code `foo`"), "code \\`foo\\`");
assert_eq!(fmt.text("back\\slash"), "back\\\\slash");
assert_eq!(fmt.text("under_score"), "under\\_score");
assert_eq!(fmt.text("<doi:10.1/x>"), "\\<doi:10.1/x\\>");
assert_eq!(fmt.text("Smith & Jones"), "Smith \\& Jones");
assert_eq!(fmt.text("<em>bold</em>"), "\\<em\\>bold\\</em\\>");
}
#[test]
fn visible_text_strips_emph_and_strong_delimiters() {
let fmt = Markdown;
assert_eq!(fmt.visible_text("*Title.*"), "Title.");
assert_eq!(fmt.visible_text("**Title.**"), "Title.");
}
#[test]
fn visible_text_hides_link_url_keeps_text() {
let fmt = Markdown;
assert_eq!(
fmt.visible_text("[Example](https://example.com/a.b)"),
"Example"
);
}
#[test]
fn visible_text_keeps_literal_wrap_brackets_visible() {
let fmt = Markdown;
assert_eq!(fmt.visible_text("[Dataset]"), "[Dataset]");
}
#[test]
fn visible_text_strips_raw_html_spans() {
let fmt = Markdown;
assert_eq!(
fmt.visible_text("<span style=\"font-variant:small-caps\">Smith</span>"),
"Smith"
);
assert_eq!(fmt.visible_text("<sup>2</sup>"), "2");
}
#[test]
fn visible_text_keeps_escaped_punctuation() {
let fmt = Markdown;
assert_eq!(fmt.visible_text(r"A \* B"), "A * B");
}
}