blob: 389a63931b8beeed228d4d531f50592a42e4b723 [file] [log] [blame]
use super::MarkdownFormatter;
use pulldown_cmark::escape::StrWrite;
use std::borrow::Cow;
impl<'i, F> MarkdownFormatter<'i, F> {
pub(super) fn write_inline_link<S: AsRef<str>>(
&mut self,
url: &str,
title: Option<(S, char)>,
) -> std::io::Result<()> {
let url = format_link_url(url, false);
match title {
Some((title, quote)) if quote == ')' => {
write!(self, r#"]({url} ({}))"#, title.as_ref())?
}
Some((title, quote)) => write!(self, r#"]({url} {quote}{}{quote})"#, title.as_ref())?,
None => write!(self, "]({url})")?,
}
Ok(())
}
}
pub(crate) fn format_link_url(url: &str, wrap_empty_urls: bool) -> Cow<'_, str> {
if wrap_empty_urls && url.is_empty() {
Cow::from("<>")
} else if !url.starts_with('<') && !url.ends_with('>') && url.contains(' ')
|| !balanced_parens(&url)
{
// https://spec.commonmark.org/0.30/#link-destination
Cow::from(format!("<{url}>"))
} else {
url.into()
}
}
/// Check if the parens are balanced
fn balanced_parens(url: &str) -> bool {
let mut stack = vec![];
let mut was_last_escape = false;
for b in url.bytes() {
if !was_last_escape && b == b'(' {
stack.push(b);
continue;
}
if !was_last_escape && b == b')' {
if let Some(top) = stack.last() {
if *top != b'(' {
return false;
}
stack.pop();
} else {
return false;
}
}
was_last_escape = b == b'\\';
}
stack.is_empty()
}
/// Search for enclosing balanced brackets
fn find_text_within_last_set_of_balance_bracket(
label: &str,
opener: u8,
closer: u8,
halt_condition: Option<fn(u8) -> bool>,
) -> (usize, usize) {
let mut stack = vec![];
let mut was_last_escape = false;
let mut start = 0;
let mut end = label.len();
let mut bytes = label.bytes().enumerate().peekable();
while let Some((index, byte)) = bytes.next() {
if !was_last_escape && byte == opener {
stack.push(index)
}
if !was_last_escape && byte == closer {
if let Some(start_index) = stack.pop() {
start = start_index;
end = index;
}
if stack.is_empty() && halt_condition.is_some() {
match (bytes.peek(), halt_condition) {
(Some((_, byte)), Some(halt_condition)) if halt_condition(*byte) => {
break;
}
_ => {}
}
}
}
was_last_escape = byte == b'\\'
}
(start, end + 1)
}
/// Reference links are expected to be well formed:
/// [foo][bar] -> bar
/// [link \[bar][ref] -> ref
pub(super) fn find_reference_link_label(input: &str) -> &str {
let (start, end) = find_text_within_last_set_of_balance_bracket(input, b'[', b']', None);
// +1 to move past '['
// -1 to move before ']'
input[start + 1..end - 1].trim()
}
/// Inline links are expected to be well formed:
/// [link](/uri) -> '/uri'
/// [link](</my uri>) -> '/my uri'
pub(super) fn find_inline_url_and_title(input: &str) -> Option<(String, Option<(String, char)>)> {
let (_, end) =
find_text_within_last_set_of_balance_bracket(input, b'[', b']', Some(|b| b == b'('));
// +1 to move past '('
// -1 to move before ')'
let inline_url = input[end + 1..input.len() - 1].trim();
if inline_url.is_empty() {
return Some((String::new(), None));
}
split_inline_url_from_title(inline_url, inline_url.ends_with(['"', '\'', ')']))
}
// The link must have a title if we're calling this
fn link_title_start(link: &[u8]) -> usize {
let last = *link.last().expect("links titles must have quotes");
let opener = if last == b')' { b'(' } else { last };
// offset by 2 to skip triling quote
let mut index = link.len() - 2;
while index.saturating_sub(1) != 0 {
if link[index] == opener && link[index - 1] != b'\\' {
return index;
}
index -= 1;
}
// Odd case where a title is in the place of a url
//https://spec.commonmark.org/0.30/#example-503
0
}
/// Grab the link destination from the source text
///
/// `pulldown_cmark` unescape link destinations and titles so grabbing the escaped link
/// from the source is the easiest way to maintain all the escaped characters.
pub(super) fn recover_escaped_link_destination_and_title(
complete_link: &str,
has_title: bool,
) -> Option<(String, Option<(String, char)>)> {
let rest = complete_link.split_once(":").map(|(_, rest)| rest.trim())?;
split_inline_url_from_title(rest, has_title)
}
fn trim_angle_brackes(url: &str) -> &str {
if url.starts_with('<') && url.ends_with('>') {
url[1..url.len() - 1].trim()
} else {
url.trim()
}
}
fn split_inline_url_from_title(
input: &str,
has_title: bool,
) -> Option<(String, Option<(String, char)>)> {
// If both link destination and link title are present, they must be separated by spaces, tabs,
// and up to one line ending.
let has_space = input.contains(char::is_whitespace);
let only_link = !has_space && has_title;
let link_start = link_title_start(input.as_bytes());
if only_link || !has_title || link_start == 0 {
return Some((trim_angle_brackes(input).to_string(), None));
}
let (mut url, mut title_with_quotes) = input.split_at(link_start);
url = url.trim();
title_with_quotes = title_with_quotes.trim();
// Remove the wrapping quotes from the title
let quote = title_with_quotes
.bytes()
.last()
.expect("url title has a quote") as char;
let title = &title_with_quotes[1..title_with_quotes.len() - 1];
Some((
trim_angle_brackes(url).to_string(),
Some((title.to_string(), quote)),
))
}