use crate::lint_context::LintContext;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use pulldown_cmark::LinkType;
fn is_destination_space(c: char) -> bool {
matches!(c, ' ' | '\t' | '\n' | '\r')
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WhitespaceIssue {
Leading,
Trailing,
Both,
}
impl WhitespaceIssue {
fn message(self, is_image: bool) -> String {
let element = if is_image { "Image" } else { "Link" };
match self {
WhitespaceIssue::Leading => {
format!("{element} destination has leading whitespace")
}
WhitespaceIssue::Trailing => {
format!("{element} destination has trailing whitespace")
}
WhitespaceIssue::Both => {
format!("{element} destination has leading and trailing whitespace")
}
}
}
}
#[derive(Debug, Default, Clone)]
pub struct MD062LinkDestinationWhitespace;
impl MD062LinkDestinationWhitespace {
pub fn new() -> Self {
Self
}
fn is_escaped(text: &str, byte_index: usize) -> bool {
text.as_bytes()[..byte_index]
.iter()
.rev()
.take_while(|&&byte| byte == b'\\')
.count()
% 2
!= 0
}
fn without_quote_markers<'a>(ctx: &LintContext, line: usize, raw: &'a str) -> std::borrow::Cow<'a, str> {
use crate::utils::blockquote::parse_blockquote_prefix;
use std::borrow::Cow;
if !raw.contains('\n') {
return Cow::Borrowed(raw);
}
let mut stripped = String::with_capacity(raw.len());
let mut changed = false;
for (index, segment) in raw.split_inclusive('\n').enumerate() {
let in_quote = index > 0
&& ctx
.lines
.get(line - 1 + index)
.is_some_and(|info| info.blockquote.is_some());
match parse_blockquote_prefix(segment).filter(|_| in_quote) {
Some(parsed) => {
stripped.push_str(parsed.content);
changed = true;
}
None => stripped.push_str(segment),
}
}
if changed {
Cow::Owned(stripped)
} else {
Cow::Borrowed(raw)
}
}
fn fix_for(
&self,
ctx: &LintContext,
line: usize,
raw: &str,
target: (&str, Option<&str>),
) -> Option<(WhitespaceIssue, String)> {
let (dest_start, _, _) = self.extract_destination_info(raw)?;
let (label, tail) = raw.split_at(dest_start);
let label_lines = label.matches('\n').count();
let tail = Self::without_quote_markers(ctx, line + label_lines, tail);
let markers_dropped = matches!(tail, std::borrow::Cow::Owned(_));
let source = format!("{label}{tail}");
let (_, _, raw_dest) = self.extract_destination_info(&source)?;
let issue = self.check_destination_whitespace(raw_dest)?;
let fixed = self.create_fix(&source)?;
if markers_dropped && fixed[dest_start..].contains(['\n', '\r']) {
return None;
}
if markers_dropped && !Self::parses_to_target(&format!("[x]({}", &fixed[dest_start..]), target) {
return None;
}
Some((issue, fixed))
}
fn parses_to_target(link: &str, (url, title): (&str, Option<&str>)) -> bool {
use pulldown_cmark::{Event, Parser, Tag};
let mut found = Parser::new(link).filter_map(|event| match event {
Event::Start(Tag::Link {
dest_url, title: found, ..
}) => Some((dest_url, found)),
_ => None,
});
match (found.next(), found.next()) {
(Some((dest, found)), None) => dest.as_ref() == url && (found.as_ref() == title.unwrap_or("")),
_ => false,
}
}
fn extract_destination_info<'a>(&self, raw_link: &'a str) -> Option<(usize, usize, &'a str)> {
let mut bracket_depth = 0;
let mut paren_start = None;
let mut escaped = false;
for (i, c) in raw_link.char_indices() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' => escaped = true,
'[' => bracket_depth += 1,
']' => {
bracket_depth -= 1;
if bracket_depth == 0 {
let rest = &raw_link[i + 1..];
if rest.starts_with('(') {
paren_start = Some(i + 1);
}
break;
}
}
_ => {}
}
}
let paren_start = paren_start?;
let dest_content_start = paren_start + 1; let dest_content_end = raw_link.len().checked_sub(1)?;
if raw_link.as_bytes().get(dest_content_end) != Some(&b')')
|| Self::is_escaped(raw_link, dest_content_end)
|| dest_content_end < dest_content_start
{
return None;
}
let dest_content = &raw_link[dest_content_start..dest_content_end];
let (url, _) = Self::split_url_and_title(dest_content);
let mut in_angle_brackets = false;
let mut escaped = false;
for c in url.chars() {
if escaped {
escaped = false;
continue;
}
match c {
'\\' => escaped = true,
'<' if !in_angle_brackets => in_angle_brackets = true,
'>' if in_angle_brackets => in_angle_brackets = false,
_ => {}
}
}
if in_angle_brackets {
return None;
}
Some((dest_content_start, dest_content_end, dest_content))
}
fn split_url_and_title(dest: &str) -> (&str, Option<&str>) {
if let Some(q_start) = dest.find(['"', '\'']) {
let before = &dest[..q_start];
if !before.ends_with(is_destination_space) || before.trim_matches(is_destination_space).is_empty() {
return (dest, None);
}
let quote = dest.as_bytes()[q_start] as char;
if let Some(rel_close) = dest[q_start + 1..].find(quote) {
let close = q_start + 1 + rel_close;
if dest[close + 1..].trim_matches(is_destination_space).is_empty() {
return (&dest[..q_start], Some(&dest[q_start..=close]));
}
}
}
(dest, None)
}
fn check_destination_whitespace(&self, full_dest: &str) -> Option<WhitespaceIssue> {
if full_dest.is_empty() {
return None;
}
let first_char = full_dest.chars().next();
let last_char = full_dest.chars().last();
let has_leading = first_char.is_some_and(is_destination_space);
let has_trailing = if last_char.is_some_and(is_destination_space) {
true
} else {
let (url_portion, title) = Self::split_url_and_title(full_dest);
title.is_some() && url_portion.ends_with(is_destination_space)
};
match (has_leading, has_trailing) {
(true, true) => Some(WhitespaceIssue::Both),
(true, false) => Some(WhitespaceIssue::Leading),
(false, true) => Some(WhitespaceIssue::Trailing),
(false, false) => None,
}
}
fn create_fix(&self, raw_link: &str) -> Option<String> {
let (dest_start, dest_end, _) = self.extract_destination_info(raw_link)?;
let full_dest_content = &raw_link[dest_start..dest_end];
let (url_raw, title_raw) = Self::split_url_and_title(full_dest_content);
let url_part = url_raw.trim_matches(is_destination_space);
let title_part = title_raw.map(|title| title.trim_matches(is_destination_space));
let text_part = &raw_link[..dest_start];
let mut fixed = String::with_capacity(raw_link.len());
fixed.push_str(text_part);
fixed.push_str(url_part);
if let Some(title) = title_part {
fixed.push(' ');
fixed.push_str(title);
}
fixed.push(')');
if fixed != raw_link { Some(fixed) } else { None }
}
}
impl Rule for MD062LinkDestinationWhitespace {
fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(62))
}
fn name(&self) -> &'static str {
"MD062"
}
fn description(&self) -> &'static str {
"Link destination should not have leading or trailing whitespace"
}
fn category(&self) -> RuleCategory {
RuleCategory::Link
}
fn should_skip(&self, ctx: &LintContext) -> bool {
ctx.content.is_empty() || !ctx.likely_has_links_or_images()
}
fn check(&self, ctx: &LintContext) -> LintResult {
let mut warnings = Vec::new();
for link in ctx.links() {
if link.is_reference || !matches!(link.link_type, LinkType::Inline) {
continue;
}
if ctx.is_in_jinja_range(link.byte_offset) {
continue;
}
let raw_link = &ctx.content[link.byte_offset..link.byte_end];
if let Some((issue, fixed)) = self.fix_for(ctx, link.line, raw_link, (&link.url, link.title.as_deref())) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: link.line,
column: link.start_col + 1,
end_line: link.end_line,
end_column: link.end_col + 1,
message: issue.message(false),
severity: Severity::Warning,
fix: Some(Fix::new(link.byte_offset..link.byte_end, fixed)),
});
}
}
for image in ctx.images() {
if image.is_reference || !matches!(image.link_type, LinkType::Inline) {
continue;
}
if ctx.is_in_jinja_range(image.byte_offset) {
continue;
}
let raw_image = &ctx.content[image.byte_offset..image.byte_end];
let link_portion = raw_image.strip_prefix('!').unwrap_or(raw_image);
if let Some((issue, fixed_link)) =
self.fix_for(ctx, image.line, link_portion, (&image.url, image.title.as_deref()))
{
let fixed = format!("!{fixed_link}");
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: image.line,
column: image.start_col + 1,
end_line: image.end_line,
end_column: image.end_col + 1,
message: issue.message(true),
severity: Severity::Warning,
fix: Some(Fix::new(image.byte_offset..image.byte_end, fixed)),
});
}
}
Ok(warnings)
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
let warnings = self.check(ctx)?;
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
if warnings.is_empty() {
return Ok(ctx.content.to_string());
}
let mut content = ctx.content.to_string();
let mut fixes: Vec<_> = warnings
.into_iter()
.filter_map(|w| w.fix.map(|f| (f.range.start, f.range.end, f.replacement)))
.collect();
fixes.sort_by_key(|(start, _, _)| *start);
for (start, end, replacement) in fixes.into_iter().rev() {
content.replace_range(start..end, &replacement);
}
Ok(content)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(Self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
#[test]
fn test_no_whitespace() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](https://example.com)";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_leading_whitespace() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com)";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com)"
);
}
#[test]
fn test_trailing_whitespace() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](https://example.com )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com)"
);
}
#[test]
fn test_both_whitespace() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com)"
);
}
#[test]
fn test_multiple_spaces() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com)"
);
}
#[test]
fn test_with_title() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com \"title\")";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com \"title\")"
);
}
#[test]
fn test_image_leading_whitespace() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
""
);
}
#[test]
fn test_multiple_links() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[a]( url1) and [b](url2 ) here";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 2);
}
#[test]
fn test_fix() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com ) and ";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "[link](https://example.com) and ");
}
#[test]
fn test_fix_preserves_parentheses_inside_destination_and_title() {
let rule = MD062LinkDestinationWhitespace::new();
let cases = [
(r"[link]( path\)suffix )", r"[link](path\)suffix)"),
(r"[link]( path\(suffix )", r"[link](path\(suffix)"),
(r#"[link]( path "title) rest" )"#, r#"[link](path "title) rest")"#),
(r"[link]( path 'title) rest' )", r"[link](path 'title) rest')"),
(r"suffix )", r"suffix)"),
(r"[a \] b]( path )", r"[a \] b](path)"),
];
for (content, expected) in cases {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, expected, "unexpected fix for {content:?}");
let fixed_ctx = LintContext::new(&fixed, MarkdownFlavor::Standard, None);
assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "second fix changed {content:?}");
}
}
#[test]
fn test_fix_idempotent_with_unbalanced_quote() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let once = rule.fix(&ctx).unwrap();
let ctx2 = LintContext::new(&once, MarkdownFlavor::Standard, None);
let twice = rule.fix(&ctx2).unwrap();
assert_eq!(once, twice, "MD062 fix must be idempotent for unbalanced quotes");
assert_eq!(
once.lines().count(),
2,
"fix must not drop the second image line, got: {once:?}"
);
}
#[test]
fn test_reference_links_skipped() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link][ref]\n\n[ref]: https://example.com";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_nested_brackets() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[text [nested]]( https://example.com)";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
}
#[test]
fn test_empty_destination() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]()";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_tabs_and_newlines() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](\thttps://example.com\t)";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com)"
);
}
#[test]
fn test_trailing_whitespace_after_title() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](https://example.com \"title\" )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com \"title\")"
);
}
#[test]
fn test_leading_and_trailing_with_title() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com \"title\" )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com \"title\")"
);
}
#[test]
fn test_multiple_spaces_before_title() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](https://example.com \"title\")";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com \"title\")"
);
}
#[test]
fn test_single_quote_title() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com 'title')";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com 'title')"
);
}
#[test]
fn test_single_quote_title_trailing_space() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](https://example.com 'title' )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com 'title')"
);
}
#[test]
fn test_wikipedia_style_url() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[wiki]( https://en.wikipedia.org/wiki/Rust_(programming_language) )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[wiki](https://en.wikipedia.org/wiki/Rust_(programming_language))"
);
}
#[test]
fn test_angle_bracket_url_no_warning() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](<https://example.com/path with spaces>)";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_image_with_title() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
""
);
}
#[test]
fn test_only_whitespace_in_destination() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].fix.as_ref().unwrap().replacement, "[link]()");
}
#[test]
fn test_code_block_skipped() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "```\n[link]( https://example.com )\n```";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_inline_code_not_skipped() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "text `[link]( url )` more text";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_valid_link_with_title_no_warning() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link](https://example.com \"Title\")";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_mixed_links_on_same_line() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[good](https://example.com) and [bad]( https://example.com ) here";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[bad](https://example.com)"
);
}
#[test]
fn test_fix_multiple_on_same_line() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[a]( url1 ) and [b]( url2 )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, "[a](url1) and [b](url2)");
}
#[test]
fn test_complex_nested_brackets() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[text [with [deeply] nested] brackets]( https://example.com )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
}
#[test]
fn test_url_with_query_params() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com?foo=bar&baz=qux )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com?foo=bar&baz=qux)"
);
}
#[test]
fn test_url_with_fragment() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( https://example.com#section )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](https://example.com#section)"
);
}
#[test]
fn test_relative_path() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[link]( ./path/to/file.md )";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert_eq!(
warnings[0].fix.as_ref().unwrap().replacement,
"[link](./path/to/file.md)"
);
}
#[test]
fn test_unmatched_angle_bracket_in_destination() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[]( \"<)";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Should not warn when closing paren is masked by angle bracket"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content);
}
#[test]
fn test_unicode_whitespace_in_destination() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "[](\u{2000}\"<)";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(
warnings.is_empty(),
"Should not warn when angle bracket masks closing paren"
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, content, "Fix must be idempotent for unparseable links");
}
#[test]
fn test_autolink_not_affected() {
let rule = MD062LinkDestinationWhitespace::new();
let content = "<https://example.com>";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert!(warnings.is_empty());
}
fn md062_fix(content: &str) -> (usize, String) {
let rule = MD062LinkDestinationWhitespace::new();
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
(rule.check(&ctx).unwrap().len(), rule.fix(&ctx).unwrap())
}
#[test]
fn test_non_commonmark_whitespace_is_destination_content() {
for ws in ['\u{a0}', '\u{2003}', '\u{3000}'] {
for source in [
format!("[a]({ws}x)\n"),
format!("[a](x{ws})\n"),
format!("[a]({ws}x{ws})\n"),
format!("[a](x{ws}\"t\")\n"),
format!("\n"),
] {
let (warnings, fixed) = md062_fix(&source);
assert_eq!(warnings, 0, "{source:?} must not warn");
assert_eq!(fixed, source, "{source:?} must be unchanged");
}
}
}
#[test]
fn test_ascii_whitespace_around_non_commonmark_whitespace() {
let (warnings, fixed) = md062_fix("[a]( \u{a0}x\t)\n");
assert_eq!(warnings, 1);
assert_eq!(fixed, "[a](\u{a0}x)\n");
}
#[test]
fn test_blockquote_multiline_destination_is_fixed_without_the_marker() {
for (source, expected) in [
("> [k](\n> )\n", "> [k]()\n"),
("> [k](\n> url)\n", "> [k](url)\n"),
("> [k](url\n> )\n", "> [k](url)\n"),
("> [k](\n> x\n> )\n", "> [k](x)\n"),
("> [k](\n> x\n> \"t\")\n", "> [k](x \"t\")\n"),
(">> [k](\n>> )\n", ">> [k]()\n"),
("> > [k](\n> > url\n> > )\n", "> > [k](url)\n"),
("> - [k](\n> )\n", "> - [k]()\n"),
("- > [k](\n > )\n", "- > [k]()\n"),
("> \n", "> ![k]()\n"),
("> [k](\n)\n", "> [k]()\n"),
] {
let (warnings, fixed) = md062_fix(source);
assert_eq!(warnings, 1, "{source:?} must warn");
assert_eq!(fixed, expected, "{source:?}");
assert_eq!(md062_fix(expected).0, 0, "{expected:?} must be clean");
}
}
#[test]
fn test_blockquote_multiline_label_keeps_its_markers() {
for (source, expected) in [
("> [multi\n> line]( url )\n", "> [multi\n> line](url)\n"),
("> \n", "> \n"),
(
"> > [multi\n> > line](\n> > url\n> > )\n",
"> > [multi\n> > line](url)\n",
),
("- > [multi\n > line]( url )\n", "- > [multi\n > line](url)\n"),
] {
let (warnings, fixed) = md062_fix(source);
assert_eq!(warnings, 1, "{source:?} must warn");
assert_eq!(fixed, expected, "{source:?}");
assert_eq!(md062_fix(expected).0, 0, "{expected:?} must be clean");
}
}
#[test]
fn test_blockquote_title_spanning_lines_is_left_alone() {
let source = "> [k](x \"a\n> b\" )\n";
let (warnings, fixed) = md062_fix(source);
assert_eq!(warnings, 0);
assert_eq!(fixed, source);
}
#[test]
fn test_blockquote_single_line_destination_is_still_fixed() {
let (warnings, fixed) = md062_fix("> [a]( x )\n>\n> [b]( y \"t\" )\n");
assert_eq!(warnings, 2);
assert_eq!(fixed, "> [a](x)\n>\n> [b](y \"t\")\n");
}
#[test]
fn test_list_item_multiline_destination_is_still_fixed() {
let (warnings, fixed) = md062_fix("- [k](\n )\n");
assert_eq!(warnings, 1);
assert_eq!(fixed, "- [k]()\n");
let (warnings, fixed) = md062_fix("- [k](\n url\n )\n");
assert_eq!(warnings, 1);
assert_eq!(fixed, "- [k](url)\n");
}
#[test]
fn test_quote_glued_to_the_destination_is_not_a_title() {
for source in ["[a](x\"t\" )\n", "[a](x\u{a0}\"t\" )\n", "[a]( x'a' )\n"] {
let (_, fixed) = md062_fix(source);
assert!(
!fixed.contains(" \"t\")") && !fixed.contains(" 'a')"),
"{source:?} gained a title: {fixed:?}"
);
}
assert_eq!(md062_fix("[a](x\"t\" )\n").1, "[a](x\"t\")\n");
assert_eq!(md062_fix("[a](x \"t\" )\n").1, "[a](x \"t\")\n");
}
#[test]
fn test_indented_angle_bracket_is_destination_text_not_a_quote_marker() {
for source in ["[a](\n >url)\n", "\n", "[a](\n >url\n )\n"] {
let (_, fixed) = md062_fix(source);
assert!(
fixed.contains(">url") || fixed == source,
"{source:?} lost the > of its destination: {fixed:?}"
);
}
assert_eq!(md062_fix("> [a](\n> url)\n").1, "> [a](url)\n");
}
}