use std::sync::LazyLock;
use regex::Regex;
use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::utils::regex_cache::{EMAIL_PATTERN, URL_IPV6_REGEX, URL_STANDARD_REGEX, URL_WWW_REGEX, XMPP_URI_REGEX};
use crate::filtered_lines::FilteredLinesExt;
use crate::lint_context::LintContext;
fn collect_urls(line: &str, found: &mut Vec<(usize, usize)>) {
found.clear();
if line.contains("://[") {
for mat in URL_IPV6_REGEX.find_iter(line) {
found.push((mat.start(), mat.end()));
}
}
if line.contains("://") {
for mat in URL_STANDARD_REGEX.find_iter(line) {
let url_str = mat.as_str();
if url_str.contains("://[") {
continue;
}
if let Some(host_start) = url_str.find("://") {
let after_protocol = &url_str[host_start + 3..];
if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
if line.as_bytes().get(mat.end()) == Some(&b']') {
continue;
}
}
}
found.push((mat.start(), mat.end()));
}
}
if line.contains("www.") {
for mat in URL_WWW_REGEX.find_iter(line) {
let start_pos = mat.start();
let end_pos = mat.end();
if start_pos > 0 {
let prev_char = line.as_bytes().get(start_pos - 1).copied();
if prev_char == Some(b'/') || prev_char == Some(b'@') {
continue;
}
}
if start_pos > 0 && end_pos < line.len() {
let prev_char = line.as_bytes().get(start_pos - 1).copied();
let next_char = line.as_bytes().get(end_pos).copied();
if prev_char == Some(b'<') && next_char == Some(b'>') {
continue;
}
}
found.push((start_pos, end_pos));
}
}
if line.contains("xmpp:") {
for mat in XMPP_URI_REGEX.find_iter(line) {
let start_pos = mat.start();
let end_pos = mat.end();
if start_pos > 0 && end_pos < line.len() {
let prev_char = line.as_bytes().get(start_pos - 1).copied();
let next_char = line.as_bytes().get(end_pos).copied();
if prev_char == Some(b'<') && next_char == Some(b'>') {
continue;
}
}
found.push((start_pos, end_pos));
}
}
found.sort_by_key(|&(start, end)| (start, std::cmp::Reverse(end)));
let mut previous_end = 0;
found.retain(|&(start, end)| {
if start < previous_end {
false
} else {
previous_end = end;
true
}
});
}
#[cfg(test)]
fn collect_urls_legacy(line: &str, found: &mut Vec<(usize, usize)>) {
for mat in URL_IPV6_REGEX.find_iter(line) {
found.push((mat.start(), mat.end()));
}
for mat in URL_STANDARD_REGEX.find_iter(line) {
let url_str = mat.as_str();
if url_str.contains("://[") {
continue;
}
if let Some(host_start) = url_str.find("://") {
let after_protocol = &url_str[host_start + 3..];
if after_protocol.contains("::") || after_protocol.chars().filter(|&c| c == ':').count() > 1 {
if line.as_bytes().get(mat.end()) == Some(&b']') {
continue;
}
}
}
found.push((mat.start(), mat.end()));
}
for mat in URL_WWW_REGEX.find_iter(line) {
let start_pos = mat.start();
let end_pos = mat.end();
if start_pos > 0 {
let prev_char = line.as_bytes().get(start_pos - 1).copied();
if prev_char == Some(b'/') || prev_char == Some(b'@') {
continue;
}
}
if start_pos > 0 && end_pos < line.len() {
let prev_char = line.as_bytes().get(start_pos - 1).copied();
let next_char = line.as_bytes().get(end_pos).copied();
if prev_char == Some(b'<') && next_char == Some(b'>') {
continue;
}
}
found.push((start_pos, end_pos));
}
for mat in XMPP_URI_REGEX.find_iter(line) {
let start_pos = mat.start();
let end_pos = mat.end();
if start_pos > 0 && end_pos < line.len() {
let prev_char = line.as_bytes().get(start_pos - 1).copied();
let next_char = line.as_bytes().get(end_pos).copied();
if prev_char == Some(b'<') && next_char == Some(b'>') {
continue;
}
}
found.push((start_pos, end_pos));
}
found.sort_by_key(|&(start, end)| (start, std::cmp::Reverse(end)));
let mut previous_end = 0;
found.retain(|&(start, end)| {
if start < previous_end {
false
} else {
previous_end = end;
true
}
});
}
static MARKDOWN_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap()
});
static MARKDOWN_EMPTY_LINK_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\(\)"#).unwrap());
static MARKDOWN_EMPTY_REF_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"\[(?:[^\[\]]|\[[^\]]*\])*\]\[\]"#).unwrap());
static ANGLE_LINK_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r#"<((?:https?|ftps?)://(?:\[[0-9a-fA-F:]+(?:%[a-zA-Z0-9]+)?\]|[^>]+)|xmpp:[^>]+|[^@\s]+@[^@\s]+\.[^@\s>]+)>"#,
)
.unwrap()
});
static BADGE_LINK_LINE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"^\s*\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)\s*$"#).unwrap());
static MARKDOWN_IMAGE_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"!\s*\[([^\]]*)\]\s*\(([^)\s]+)(?:\s+(?:\"[^\"]*\"|\'[^\']*\'))?\)"#).unwrap());
static SHORTCUT_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r#"\[([^\[\]]+)\]"#).unwrap());
static SCHEMED_URI_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"[a-zA-Z][a-zA-Z0-9+.-]*://[^\s<>\\'\"`]+"#).unwrap());
const MDX_LINK_TEXT_ESCAPES: [char; 12] = ['\\', '`', '*', '_', '{', '}', '[', ']', '<', '>', '~', '&'];
fn escape_mdx_link_text(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
if MDX_LINK_TEXT_ESCAPES.contains(&ch) {
escaped.push('\\');
}
escaped.push(ch);
}
escaped
}
fn has_balanced_parens(url: &str) -> bool {
let mut depth: i32 = 0;
for ch in url.chars() {
match ch {
'(' => depth += 1,
')' => {
depth -= 1;
if depth < 0 {
return false;
}
}
_ => {}
}
}
depth == 0
}
fn jsx_safe_link(text: &str, destination: &str) -> String {
let escaped = escape_mdx_link_text(text);
if has_balanced_parens(destination) {
format!("[{escaped}]({destination})")
} else {
format!("[{escaped}](<{destination}>)")
}
}
enum LinkPrefix {
Free,
ActiveBang,
ActiveCloseBracket,
}
fn classify_link_prefix(line: &str, start: usize) -> LinkPrefix {
let before = &line[..start];
let Some(last) = before.chars().next_back() else {
return LinkPrefix::Free;
};
if last != '!' && last != ']' {
return LinkPrefix::Free;
}
let preceding = &before[..before.len() - last.len_utf8()];
if preceding.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 {
return LinkPrefix::Free;
}
if last == '!' {
LinkPrefix::ActiveBang
} else {
LinkPrefix::ActiveCloseBracket
}
}
fn jsx_fix(line: &str, start: usize, text: &str, destination: &str) -> Option<(usize, String)> {
let link = jsx_safe_link(text, destination);
match classify_link_prefix(line, start) {
LinkPrefix::Free => Some((start, link)),
LinkPrefix::ActiveBang => Some((start - 1, format!("\\!{link}"))),
LinkPrefix::ActiveCloseBracket => None,
}
}
fn follows_uri_scheme(line: &str, start: usize) -> bool {
let prefix = &line[..start];
let Some(before) = prefix.strip_suffix("://").or_else(|| prefix.strip_suffix(':')) else {
return false;
};
let scheme: &str = {
let tail = before.len() - before.bytes().rev().take_while(|b| is_scheme_byte(*b)).count();
&before[tail..]
};
scheme.bytes().next().is_some_and(|b| b.is_ascii_alphabetic())
}
fn is_scheme_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')
}
fn uri_content_end(uri: &str) -> usize {
let mut parens = 0usize;
let mut brackets = 0usize;
for (offset, byte) in uri.bytes().enumerate() {
match byte {
b'(' => parens += 1,
b'[' => brackets += 1,
b')' if parens == 0 => return offset,
b']' if brackets == 0 => return offset,
b')' => parens -= 1,
b']' => brackets -= 1,
_ => {}
}
}
uri.len()
}
#[derive(Default)]
struct ConstructRanges(Vec<(usize, usize)>);
impl ConstructRanges {
fn clear(&mut self) {
self.0.clear();
}
fn push(&mut self, range: (usize, usize)) {
self.0.push(range);
}
fn index(&mut self) {
self.0.sort_unstable_by_key(|&(start, _)| start);
let mut max_end = 0;
for (_, end) in &mut self.0 {
max_end = max_end.max(*end);
*end = max_end;
}
}
fn end_at(&self, start: usize) -> Option<usize> {
let count = self.0.partition_point(|&(range_start, _)| range_start <= start);
count.checked_sub(1).map(|index| self.0[index].1)
}
fn contains_start(&self, start: usize) -> bool {
self.end_at(start).is_some_and(|end| start < end)
}
fn contains_span(&self, start: usize, end: usize) -> bool {
self.end_at(start).is_some_and(|range_end| end <= range_end)
}
}
#[derive(Default)]
struct ColumnCursor {
byte: usize,
chars: usize,
}
impl ColumnCursor {
fn range(&mut self, line: usize, text: &str, start: usize, len: usize) -> (usize, usize, usize, usize) {
debug_assert!(start >= self.byte);
self.chars += text[self.byte..start].chars().count();
let column = self.chars + 1;
self.byte = start + len;
self.chars += text[start..self.byte].chars().count();
(line, column, line, self.chars + 1)
}
}
#[derive(Default)]
struct LineCheckBuffers {
markdown_link_ranges: ConstructRanges,
image_ranges: ConstructRanges,
urls_found: Vec<(usize, usize)>,
schemed_uri_ranges: Vec<(usize, usize)>,
}
#[derive(Default, Clone)]
pub struct MD034NoBareUrls;
impl MD034NoBareUrls {
#[inline]
pub fn should_skip_content(&self, content: &str) -> bool {
let bytes = content.as_bytes();
let has_colon = bytes.contains(&b':');
let has_at = bytes.contains(&b'@');
let has_www = content.contains("www.");
!has_colon && !has_at && !has_www
}
fn trim_trailing_punctuation<'a>(&self, url: &'a str) -> &'a str {
let mut trimmed = url;
let open_parens = url.chars().filter(|&c| c == '(').count();
let close_parens = url.chars().filter(|&c| c == ')').count();
if close_parens > open_parens {
let mut balance = 0;
let mut last_balanced_pos = url.len();
for (byte_idx, c) in url.char_indices() {
if c == '(' {
balance += 1;
} else if c == ')' {
balance -= 1;
if balance < 0 {
last_balanced_pos = byte_idx;
break;
}
}
}
trimmed = &trimmed[..last_balanced_pos];
}
trimmed.trim_end_matches(['.', ',', ';', ':', '!', '?', '*', '_', '~'])
}
fn check_line(
&self,
line: &str,
ctx: &LintContext,
line_number: usize,
buffers: &mut LineCheckBuffers,
) -> Vec<LintWarning> {
let mut warnings = Vec::new();
if ctx
.line_info(line_number)
.is_some_and(|info| info.in_html_block || info.is_myst_comment)
{
return warnings;
}
let has_quick_check = line.contains("://") || line.contains("xmpp:");
let has_www = line.contains("www.");
let has_at = line.contains('@');
if !has_quick_check && !has_at && !has_www {
return warnings;
}
buffers.markdown_link_ranges.clear();
buffers.image_ranges.clear();
buffers.schemed_uri_ranges.clear();
if has_at && line.contains("://") {
for mat in SCHEMED_URI_REGEX.find_iter(line) {
if mat.start() > 0 && is_scheme_byte(line.as_bytes()[mat.start() - 1]) {
continue;
}
let end = mat.start() + uri_content_end(mat.as_str());
buffers.schemed_uri_ranges.push((mat.start(), end));
}
}
let has_bracket = line.contains('[');
let has_angle = line.contains('<');
let has_bang = line.contains('!');
if has_bracket {
for mat in MARKDOWN_LINK_REGEX.find_iter(line) {
buffers.markdown_link_ranges.push((mat.start(), mat.end()));
}
for mat in MARKDOWN_EMPTY_LINK_REGEX.find_iter(line) {
buffers.markdown_link_ranges.push((mat.start(), mat.end()));
}
for mat in MARKDOWN_EMPTY_REF_REGEX.find_iter(line) {
buffers.markdown_link_ranges.push((mat.start(), mat.end()));
}
for mat in SHORTCUT_REF_REGEX.find_iter(line) {
let end = mat.end();
let next_non_ws = line[end..].bytes().find(|b| !b.is_ascii_whitespace());
if next_non_ws == Some(b'(') || next_non_ws == Some(b'[') {
continue;
}
buffers.markdown_link_ranges.push((mat.start(), mat.end()));
}
if has_bang && BADGE_LINK_LINE_REGEX.is_match(line) {
return warnings;
}
}
if has_angle {
for mat in ANGLE_LINK_REGEX.find_iter(line) {
buffers.markdown_link_ranges.push((mat.start(), mat.end()));
}
}
if has_bang && has_bracket {
for mat in MARKDOWN_IMAGE_REGEX.find_iter(line) {
buffers.image_ranges.push((mat.start(), mat.end()));
}
}
buffers.markdown_link_ranges.index();
buffers.image_ranges.index();
buffers.urls_found.clear();
collect_urls(line, &mut buffers.urls_found);
let mut url_columns = ColumnCursor::default();
for &(start, end) in &buffers.urls_found {
let url_str = &line[start..end];
let is_inside_construct =
buffers.markdown_link_ranges.contains_start(start) || buffers.image_ranges.contains_start(start);
if is_inside_construct {
continue;
}
let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
let absolute_pos = line_start_byte + start;
if ctx
.image_containing(absolute_pos)
.is_some_and(|image| !(image.is_reference && image.url.is_empty()))
{
continue;
}
if ctx.is_in_jinja_string(absolute_pos) {
continue;
}
if ctx.overlaps_mdx_inline_code(absolute_pos, line_start_byte + end) {
continue;
}
if ctx.is_in_math_span(absolute_pos) {
continue;
}
if ctx.is_in_html_tag(absolute_pos) {
continue;
}
if ctx.is_in_jsx_component_tag(absolute_pos) {
continue;
}
if ctx.is_in_html_comment(absolute_pos) || ctx.is_in_mdx_comment(absolute_pos) {
continue;
}
if ctx.is_in_shortcode(absolute_pos) {
continue;
}
if ctx.flavor.is_pandoc_compatible()
&& (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
{
continue;
}
let trimmed_url = self.trim_trailing_punctuation(url_str);
if !trimmed_url.is_empty() && !is_scheme_without_host(trimmed_url) {
let trimmed_len = trimmed_url.len();
let (start_line, start_col, end_line, end_col) =
url_columns.range(line_number, line, start, trimmed_len);
let destination = if trimmed_url.starts_with("www.") {
format!("https://{trimmed_url}")
} else {
trimmed_url.to_string()
};
let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
let span_end = line_start_byte + start + trimmed_len;
let fix = if ctx.flavor.supports_jsx() {
jsx_fix(line, start, trimmed_url, &destination)
.map(|(fix_start, replacement)| Fix::new((line_start_byte + fix_start)..span_end, replacement))
} else {
Some(Fix::new(
(line_start_byte + start)..span_end,
format!("<{destination}>"),
))
};
warnings.push(LintWarning {
rule_name: Some("MD034".to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
format!(
"URL without link formatting: '{trimmed_url}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
)
} else {
format!("URL without angle brackets or link formatting: '{trimmed_url}'")
},
severity: Severity::Warning,
fix,
});
}
}
let mut email_columns = ColumnCursor::default();
for cap in EMAIL_PATTERN.captures_iter(line) {
if let Some(mat) = cap.get(0) {
let email = mat.as_str();
let start = mat.start();
let end = mat.end();
let url_index = buffers.urls_found.partition_point(|&(url_start, _)| url_start <= start);
if url_index > 0 && end <= buffers.urls_found[url_index - 1].1 {
continue;
}
if follows_uri_scheme(line, start) {
continue;
}
let uri_index = buffers
.schemed_uri_ranges
.partition_point(|&(uri_start, _)| uri_start <= start);
if uri_index > 0 && end <= buffers.schemed_uri_ranges[uri_index - 1].1 {
continue;
}
let is_inside_construct = buffers.markdown_link_ranges.contains_span(start, end);
if !is_inside_construct {
let line_start_byte = ctx.line_start_byte(line_number).unwrap_or(0);
let absolute_pos = line_start_byte + start;
if ctx
.image_containing(absolute_pos)
.is_some_and(|image| !(image.is_reference && image.url.is_empty()))
{
continue;
}
if ctx.is_in_jinja_string(absolute_pos) {
continue;
}
if ctx.overlaps_mdx_inline_code(absolute_pos, line_start_byte + end) {
continue;
}
if ctx.is_in_math_span(absolute_pos) {
continue;
}
if ctx.is_in_html_tag(absolute_pos) {
continue;
}
if ctx.is_in_jsx_component_tag(absolute_pos) {
continue;
}
if ctx.is_in_html_comment(absolute_pos)
|| ctx.is_in_mdx_comment(absolute_pos)
|| ctx.is_in_shortcode(absolute_pos)
{
continue;
}
if ctx.flavor.is_pandoc_compatible()
&& (ctx.is_in_line_block(absolute_pos) || ctx.is_in_pandoc_metadata(absolute_pos))
{
continue;
}
let is_in_code_span = ctx.is_byte_offset_in_code_span(absolute_pos);
if !is_in_code_span {
let email_len = end - start;
let (start_line, start_col, end_line, end_col) =
email_columns.range(line_number, line, start, email_len);
let fix = if ctx.flavor.supports_jsx() {
jsx_fix(line, start, email, &format!("mailto:{email}")).map(|(fix_start, replacement)| {
Fix::new((line_start_byte + fix_start)..(line_start_byte + end), replacement)
})
} else {
Some(Fix::new(
(line_start_byte + start)..(line_start_byte + end),
format!("<{email}>"),
))
};
warnings.push(LintWarning {
rule_name: Some("MD034".to_string()),
line: start_line,
column: start_col,
end_line,
end_column: end_col,
message: if ctx.flavor == crate::config::MarkdownFlavor::MDG {
format!(
"Email address without link formatting: '{email}' (angle brackets are Gherkin placeholder syntax; use explicit link formatting where appropriate, or disable MD034)"
)
} else {
format!("Email address without angle brackets or link formatting: '{email}'")
},
severity: Severity::Warning,
fix,
});
}
}
}
}
warnings
}
}
impl Rule for MD034NoBareUrls {
fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(34))
}
#[inline]
fn name(&self) -> &'static str {
"MD034"
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD034NoBareUrls)
}
#[inline]
fn category(&self) -> RuleCategory {
RuleCategory::Link
}
fn skippable_by_category(&self) -> bool {
false
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
!ctx.likely_has_links_or_images() && self.should_skip_content(ctx.content)
}
#[inline]
fn description(&self) -> &'static str {
"No bare URLs - wrap URLs in angle brackets"
}
fn check(&self, ctx: &LintContext) -> LintResult {
let mut warnings = Vec::new();
let content = ctx.content;
if self.should_skip_content(content) {
return Ok(warnings);
}
let ref_def_lines: std::collections::HashSet<usize> =
ctx.reference_definitions().iter().map(|def| def.line).collect();
let mut buffers = LineCheckBuffers::default();
for line in ctx
.filtered_lines()
.skip_front_matter()
.skip_code_blocks()
.skip_esm_blocks()
.skip_obsidian_comments()
.candidates(crate::lint_context::LineCandidateKind::Url)
{
if ctx.flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_control_line(line.content) {
continue;
}
if ctx.is_myst_colon_directive_opener_line(line.line_num) {
continue;
}
if ref_def_lines.contains(&line.line_num) {
continue;
}
let mut line_warnings = self.check_line(line.content, ctx, line.line_num, &mut buffers);
line_warnings.retain(|warning| {
if let Some(fix) = &warning.fix {
!ctx.is_byte_offset_in_code_span(fix.range.start)
} else {
!ctx.source_code_spans().iter().any(|span| {
span.line == warning.line
&& span.end_line == warning.line
&& warning.column > 0
&& (warning.column - 1) >= span.start_col
&& (warning.column - 1) < span.end_col
})
}
});
line_warnings.retain(|warning| {
if let Some(fix) = &warning.fix {
!ctx.is_range_in_rendered_link(&fix.range)
} else {
true
}
});
line_warnings.retain(|warning| !ctx.is_position_in_obsidian_comment(warning.line, warning.column));
for warning in &mut line_warnings {
if let Some(fix) = &warning.fix {
let source = &ctx.content[fix.range.clone()];
if !source.contains('{') {
continue;
}
let crosses_statement = source
.match_indices("{%")
.any(|(offset, _)| ctx.is_in_jinja_range(fix.range.start + offset));
let partial_expression = source.contains("{{") && ctx.is_in_jinja_range(fix.range.end);
if crosses_statement || partial_expression {
warning.fix = None;
}
}
}
warnings.extend(line_warnings);
}
if ctx.flavor == crate::config::MarkdownFlavor::MDG {
for warning in &mut warnings {
warning.fix = None;
}
}
Ok(warnings)
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
let mut content = ctx.content.to_string();
let warnings = self.check(ctx)?;
let mut warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
warnings.sort_by_key(|w| w.fix.as_ref().map_or(0, |f| f.range.start));
for warning in warnings.iter().rev() {
if let Some(fix) = &warning.fix {
let start = fix.range.start;
let end = fix.range.end;
content.replace_range(start..end, &fix.replacement);
}
}
Ok(content)
}
}
fn is_scheme_without_host(url: &str) -> bool {
url.split_once("://").is_some_and(|(scheme, authority)| {
!scheme.is_empty()
&& scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
&& !authority.starts_with('[')
&& authority.chars().all(|c| c.is_ascii_punctuation())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn construct_lookup_preserves_nested_ranges_and_span_boundaries() {
let ranges = [(5, 12), (0, 10), (2, 4), (5, 7), (12, 15), (20, 20)];
let mut indexed = ConstructRanges(ranges.to_vec());
indexed.index();
for start in 0..=21 {
assert_eq!(
indexed.contains_start(start),
ranges.iter().any(|&(s, e)| s <= start && start < e),
"point {start}"
);
for end in start..=21 {
assert_eq!(
indexed.contains_span(start, end),
ranges.iter().any(|&(s, e)| s <= start && end <= e),
"span {start}..{end}"
);
}
}
indexed.clear();
indexed.push((30, 40));
indexed.index();
assert!(!indexed.contains_start(5));
assert!(indexed.contains_span(30, 40));
}
proptest::proptest! {
#[test]
fn construct_lookup_matches_linear_scan(
raw in proptest::collection::vec((0usize..256, 0usize..256), 0..64),
start in 0usize..256,
len in 0usize..256,
) {
let ranges: Vec<_> = raw.into_iter().map(|(a, b)| (a.min(b), a.max(b))).collect();
let mut indexed = ConstructRanges(ranges.clone());
indexed.index();
proptest::prop_assert_eq!(
indexed.contains_start(start),
ranges.iter().any(|&(s, e)| s <= start && start < e)
);
proptest::prop_assert_eq!(
indexed.contains_span(start, start + len),
ranges.iter().any(|&(s, e)| s <= start && start + len <= e)
);
}
}
#[test]
fn diagnostic_cursors_handle_trimmed_urls_and_skipped_matches() {
use crate::config::MarkdownFlavor;
use crate::utils::range_utils::calculate_url_range;
for (content, count) in [
(
"é https://one.example/path., https://two.example/ 🦀 www.three.example!",
3,
),
(
"é first@example.com [middle@example.com](https://hidden.example) last@example.com",
2,
),
("é www.one.example café https://two.example/path, third@example.com", 3),
] {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let warnings = MD034NoBareUrls.check(&ctx).unwrap();
assert_eq!(warnings.len(), count, "{content}");
for warning in warnings {
let fix = warning.fix.unwrap();
assert_eq!(
(warning.line, warning.column, warning.end_line, warning.end_column),
calculate_url_range(1, content, fix.range.start, fix.range.len()),
"{content}"
);
}
}
}
#[test]
fn long_unicode_lines_preserve_url_and_email_columns_and_fixes() {
use crate::config::MarkdownFlavor;
use crate::utils::range_utils::calculate_url_range;
let unit = "é🦀 [label](https://hidden.example) https://bare.example/path café hello@example.com ";
for ending in ["\n", "\r\n"] {
let content = format!(
"{}{ending}尾 hello@example.com https://bare.example/path{ending}",
unit.repeat(128)
);
let ctx = LintContext::new(&content, MarkdownFlavor::Standard, None);
let rule = MD034NoBareUrls;
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 258);
for warning in &warnings {
let fix = warning.fix.as_ref().unwrap();
let line_start = ctx.line_start_byte(warning.line).unwrap();
let line = content.lines().nth(warning.line - 1).unwrap();
assert_eq!(
(warning.line, warning.column, warning.end_line, warning.end_column),
calculate_url_range(warning.line, line, fix.range.start - line_start, fix.range.len())
);
}
let expected = content
.replace("https://bare.example/path", "<https://bare.example/path>")
.replace("hello@example.com", "<hello@example.com>");
assert_eq!(rule.fix(&ctx).unwrap(), expected);
}
}
#[test]
fn test_shortcut_ref_at_end_of_line_no_trailing_chars() {
let rule = MD034NoBareUrls;
let content = "See [https://example.com]";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"[URL] at end of line should be treated as shortcut ref: {result:?}"
);
}
#[test]
fn test_shortcut_ref_multiple_spaces_before_paren() {
let rule = MD034NoBareUrls;
let content = "[text] (https://example.com)";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
let _ = result; }
#[test]
fn test_shortcut_ref_tab_before_bracket() {
let rule = MD034NoBareUrls;
let content = "[https://example.com]\t[other]";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Bare URL inside shortcut ref should be detected: {result:?}"
);
}
#[test]
fn test_shortcut_ref_followed_by_punctuation() {
let rule = MD034NoBareUrls;
let content = "[https://example.com], see also other things.";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"[URL] followed by comma should be treated as shortcut ref: {result:?}"
);
}
#[test]
fn test_url_in_backticks_inside_mdx_component_not_flagged() {
let rule = MD034NoBareUrls;
let content = "# Test\n\nControl: `https://rumdl.example.com/` is fine here.\n\n<ParamField path=\"--stuff\">\n This URL `https://rumdl.example.com/` must not be flagged.\n</ParamField>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL in backticks inside MDX component must not be flagged: {result:?}"
);
}
#[test]
fn test_bare_url_inside_mdx_component_still_flagged() {
let rule = MD034NoBareUrls;
let content =
"# Test\n\n<ParamField path=\"--stuff\">\n Visit https://rumdl.example.com/ for details.\n</ParamField>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Bare URL in MDX component body must still be flagged: {result:?}"
);
}
#[test]
fn test_url_in_backticks_inside_nested_mdx_component_not_flagged() {
let rule = MD034NoBareUrls;
let content = "<Outer>\n <Inner>\n Check `https://example.com/` here.\n </Inner>\n</Outer>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL in backticks inside nested MDX component must not be flagged: {result:?}"
);
}
#[test]
fn test_url_in_fenced_code_block_inside_jsx_not_flagged() {
let rule = MD034NoBareUrls;
let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n```bash\ncurl https://example.com/api\n```\n </Step>\n</Steps>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL in a fenced code block nested in a JSX component must not be flagged: {result:?}"
);
}
#[test]
fn test_fix_does_not_rewrite_url_in_fenced_code_block_inside_jsx() {
let rule = MD034NoBareUrls;
let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n```bash\ncurl https://example.com/api\n```\n </Step>\n</Steps>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed, content,
"fix must not rewrite a URL inside a JSX-nested fenced code block"
);
}
#[test]
fn test_bare_url_in_jsx_body_outside_fence_still_flagged() {
let rule = MD034NoBareUrls;
let content = "# Title\n\n<Steps>\n <Step title=\"Send a request\">\n Visit https://example.com/api now.\n </Step>\n</Steps>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"A bare URL in the JSX body (not in a fence) must still be flagged: {result:?}"
);
}
#[test]
fn test_bare_url_not_masked_by_comment_delimiter_in_code_block() {
let rule = MD034NoBareUrls;
let content =
"# T\n\n```text\n<!-- literal opener, not a comment\n```\n\nhttps://example.com should be flagged\n\n-->\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "the bare URL must still be flagged: {result:?}");
assert!(
result[0].message.contains("example.com"),
"the flagged URL must be the bare one: {result:?}"
);
}
#[test]
fn test_bare_url_in_indented_comment_in_admonition_still_skipped() {
let rule = MD034NoBareUrls;
let content = "# T\n\n!!! note\n Some text.\n\n <!--\n https://example.com\n -->\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MkDocs, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL inside an indented HTML comment in an admonition must not be flagged: {result:?}"
);
}
#[test]
fn test_url_in_jsx_component_attribute_not_flagged() {
let rule = MD034NoBareUrls;
let content = "<Card title=\"Docs\" href=\"https://example.com/docs\" />\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL in a JSX component attribute must not be flagged: {result:?}"
);
}
#[test]
fn test_url_in_multiline_jsx_component_attribute_not_flagged() {
let rule = MD034NoBareUrls;
let content = "<Card\n title=\"Docs\"\n href=\"https://example.com/docs\"\n/>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL in a multi-line JSX component attribute must not be flagged: {result:?}"
);
}
#[test]
fn test_jsx_attribute_url_skipped_but_body_url_flagged() {
let rule = MD034NoBareUrls;
let content = "<Card href=\"https://attr.example.com\">\n Visit https://body.example.com now.\n</Card>\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Only the body URL must be flagged, not the attribute URL: {result:?}"
);
assert!(
result[0].message.contains("body.example.com"),
"The flagged URL must be the body one: {result:?}"
);
}
#[test]
fn test_email_in_jsx_component_attribute_not_flagged() {
let rule = MD034NoBareUrls;
let content = "<Contact email=\"hello@example.com\" />\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Email in a JSX component attribute must not be flagged: {result:?}"
);
}
#[test]
fn test_jsx_attribute_url_not_flagged_in_standard_flavor() {
let rule = MD034NoBareUrls;
let content = "<Card href=\"https://example.com/docs\" />\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL in a tag attribute must not be flagged under Standard flavor either: {result:?}"
);
}
#[test]
fn test_pandoc_skips_urls_in_line_blocks() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = "| See https://example.com\n| For details\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD034 should skip URLs in Pandoc line blocks: {result:?}"
);
}
#[test]
fn test_pandoc_skips_urls_in_metadata() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = "---\nhomepage: https://example.com\n---\n\nBody.\n";
let ctx = LintContext::new(content, MarkdownFlavor::Pandoc, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"MD034 should skip URLs in Pandoc YAML metadata: {result:?}"
);
}
#[test]
fn test_standard_still_flags_urls_in_pipe_prefixed_lines() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = "| See https://example.com\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
!result.is_empty(),
"MD034 should still flag URLs in pipe-prefixed lines under Standard flavor"
);
}
#[test]
fn test_url_in_backticks_after_fenced_code_block_inside_mdx_not_flagged() {
let rule = MD034NoBareUrls;
let content = "\
<Component>
Some intro text.
```
example code here
```
Check `https://example.com/` here.
</Component>
";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL in backticks after a fenced code block inside MDX must not be flagged: {result:?}"
);
}
#[test]
fn test_myst_colon_directive_argument_url_not_flagged() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = "\
:::{anywidget} https://cdn.jsdelivr.net/npm/repo-review-webapp@1.1.3/dist/repo-review-anywidget.mjs
{
\"deps\": [\"repo-review~=1.1.0\"]
}
:::
";
let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL argument on a MyST colon directive opener must not be flagged: {result:?}"
);
}
#[test]
fn test_myst_nested_colon_directive_argument_url_not_flagged() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = "\
::::{grid}
:::{card} https://example.com/card-target
Some caption.
:::
::::
";
let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL argument on a nested MyST colon directive opener must not be flagged: {result:?}"
);
}
#[test]
fn test_myst_directive_body_url_still_flagged() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = "\
:::{note}
See https://example.com/docs for more details.
:::
";
let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Bare URL in a MyST directive body must still be flagged: {result:?}"
);
}
#[test]
fn test_myst_unclosed_colon_directive_argument_url_not_flagged() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = "\
:::{anywidget} https://example.com/widget.mjs
Some trailing content with no closing fence.
";
let ctx = LintContext::new(content, MarkdownFlavor::MyST, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"URL argument on an unclosed MyST colon directive opener must not be flagged: {result:?}"
);
}
#[test]
fn test_colon_directive_url_flagged_in_standard_flavor() {
use crate::config::MarkdownFlavor;
use crate::lint_context::LintContext;
let rule = MD034NoBareUrls;
let content = ":::{anywidget} https://example.com/widget.mjs\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Under Standard flavor a bare URL on a `:::` line must still be flagged: {result:?}"
);
}
#[test]
fn test_md034_complex_link() {
let rule = MD034NoBareUrls;
let content = "Check [link `code [with brackets]` text](http://example.com) and see http://bare.com.\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 1, "Should flag exactly 1 URL (the bare one): {result:?}");
assert!(result[0].message.contains("bare.com"));
let content2 = "Check [link `code [` text](http://example.com) and see http://bare.com.\n";
let ctx2 = crate::lint_context::LintContext::new(content2, crate::config::MarkdownFlavor::Standard, None);
let result2 = rule.check(&ctx2).unwrap();
assert_eq!(
result2.len(),
1,
"Should flag exactly 1 URL (the bare one): {result2:?}"
);
assert!(result2[0].message.contains("bare.com"));
}
#[test]
fn test_mdg_reports_bare_urls_without_fixing_them() {
let rule = MD034NoBareUrls;
let content = "\
# Feature: Visit https://feature.example.com
Prose about https://prose.example.com for background.
## Scenario Outline: Open https://outline.example.com
* Given I go to https://step.example.com
| site |
| https://datatable.example.com |
> * Given I go to https://blockquoted.example.com
1. Given I go to https://ordered.example.com
| url |
| ------------------------------ |
| https://unindented.example.com |
### Examples:
| url |
| ---------------------------- |
| https://examples.example.com |
";
let standard_ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let standard_lines: Vec<usize> = rule.check(&standard_ctx).unwrap().iter().map(|w| w.line).collect();
assert_eq!(
standard_lines,
vec![1, 3, 5, 7, 9, 11, 13, 17, 23],
"Standard flavor flags every bare URL"
);
let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
assert!(!rule.should_skip(&mdg_ctx), "MDG must still run the diagnostic");
let mdg = rule.check(&mdg_ctx).unwrap();
assert_eq!(mdg.iter().map(|w| w.line).collect::<Vec<_>>(), standard_lines);
assert!(mdg.iter().all(|warning| warning.fix.is_none()));
assert!(
mdg.iter()
.all(|warning| warning.message.contains("Gherkin placeholder"))
);
assert!(mdg.iter().all(|warning| warning.message.contains("disable MD034")));
assert_eq!(rule.fix(&mdg_ctx).unwrap(), content, "MDG must rewrite nothing");
}
#[test]
fn test_mdg_reports_bare_email_without_fixing_it() {
let rule = MD034NoBareUrls;
let content = "# Feature: Contact\n\n* Given I email user@example.com\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("Gherkin placeholder"));
assert!(warnings[0].message.contains("disable MD034"));
assert!(warnings[0].fix.is_none());
assert_eq!(rule.fix(&ctx).unwrap(), content);
}
#[test]
fn test_mdg_exemption_does_not_affect_other_flavors() {
let rule = MD034NoBareUrls;
let content = "\
# Feature: Visit https://feature.example.com
Prose about https://prose.example.com for background.
## Scenario Outline: Open https://outline.example.com
* Given I go to https://step.example.com
| site |
| https://datatable.example.com |
### Examples:
| url |
| ---------------------------- |
| https://examples.example.com |
";
let expected = "\
# Feature: Visit <https://feature.example.com>
Prose about <https://prose.example.com> for background.
## Scenario Outline: Open <https://outline.example.com>
* Given I go to <https://step.example.com>
| site |
| <https://datatable.example.com> |
### Examples:
| url |
| ---------------------------- |
| <https://examples.example.com> |
";
for flavor in [
crate::config::MarkdownFlavor::Standard,
crate::config::MarkdownFlavor::MkDocs,
crate::config::MarkdownFlavor::MyST,
] {
let ctx = crate::lint_context::LintContext::new(content, flavor, None);
assert!(!rule.should_skip(&ctx), "{flavor:?} must still run the rule");
assert_eq!(rule.check(&ctx).unwrap().len(), 6, "{flavor:?} must flag all six URLs");
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(fixed, expected, "{flavor:?} must wrap all six URLs");
let fixed_ctx = crate::lint_context::LintContext::new(&fixed, flavor, None);
assert!(rule.check(&fixed_ctx).unwrap().is_empty());
assert_eq!(
rule.fix(&fixed_ctx).unwrap(),
fixed,
"{flavor:?} fix must be idempotent"
);
}
}
#[test]
fn test_mdx_fixes_bare_urls_to_links_instead_of_autolinks() {
let rule = MD034NoBareUrls;
let cases = [
(
"Bare link: http://localhost/\n",
"Bare link: [http://localhost/](http://localhost/)\n",
"Bare link: <http://localhost/>\n",
),
(
"Visit www.example.com today\n",
"Visit [www.example.com](https://www.example.com) today\n",
"Visit <https://www.example.com> today\n",
),
(
"Mail user@example.com now\n",
"Mail [user@example.com](mailto:user@example.com) now\n",
"Mail <user@example.com> now\n",
),
(
"Chat xmpp:foo@bar.baz please\n",
"Chat [xmpp:foo@bar.baz](xmpp:foo@bar.baz) please\n",
"Chat <xmpp:foo@bar.baz> please\n",
),
];
for (content, expected_mdx, expected_standard) in cases {
let mdx_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(
rule.check(&mdx_ctx).unwrap().len(),
1,
"MDX must still report the bare URL in {content:?}"
);
assert_eq!(rule.fix(&mdx_ctx).unwrap(), expected_mdx, "MDX fix for {content:?}");
let standard_ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.fix(&standard_ctx).unwrap(),
expected_standard,
"Standard fix for {content:?} must be unchanged"
);
}
}
#[test]
fn test_an_address_behind_a_uri_scheme_is_not_a_bare_email() {
let rule = MD034NoBareUrls;
for content in [
"Mail mailto:user@example.com now\n",
"Chat xmpp:foo@bar.baz please\n",
"Call sip:user@example.com now\n",
"Key openpgp4fpr:user@example.com here\n",
"Ping xmpp+tls:user@example.com now\n",
] {
for flavor in [
crate::config::MarkdownFlavor::Standard,
crate::config::MarkdownFlavor::MDX,
] {
let ctx = crate::lint_context::LintContext::new(content, flavor, None);
let emails: Vec<_> = rule
.check(&ctx)
.unwrap()
.into_iter()
.filter(|w| w.message.starts_with("Email address"))
.collect();
assert!(
emails.is_empty(),
"{flavor:?} reported the tail of a schemed URI in {content:?} as a bare email: {emails:?}"
);
}
}
}
#[test]
fn test_a_colon_before_an_address_is_still_a_bare_email() {
let rule = MD034NoBareUrls;
for content in [
"Contact: user@example.com\n",
"Note (see 3:1): user@example.com\n",
"Mail 2user@example.com now\n",
"Ratio 3:user@example.com now\n",
"Mail :user@example.com now\n",
] {
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.check(&ctx).unwrap().len(),
1,
"{content:?} must still report a bare email"
);
}
}
#[test]
fn test_follows_uri_scheme() {
assert!(follows_uri_scheme("mailto:a@b.co", 7));
assert!(follows_uri_scheme("Mail mailto:a@b.co", 12));
assert!(follows_uri_scheme("xmpp+tls:a@b.co", 9));
assert!(follows_uri_scheme("a:a@b.co", 2));
assert!(!follows_uri_scheme("a@b.co", 0));
assert!(!follows_uri_scheme("Contact: a@b.co", 9), "a space separates the colon");
assert!(!follows_uri_scheme("2mailto:a@b.co", 8));
assert!(!follows_uri_scheme(":a@b.co", 1), "empty scheme");
assert!(follows_uri_scheme("Schrijf mailto:a@b.co", 15));
assert!(!follows_uri_scheme("Schrijf é:a@b.co", 11));
}
#[test]
fn test_mdx_escapes_an_active_bang_before_the_link() {
let rule = MD034NoBareUrls;
let cases = [
(
"Download now!https://example.com/f today\n",
"Download now\\ today\n",
),
(
"Contact us!user@example.com now\n",
"Contact us\\ now\n",
),
(
"Escaped already\\!https://example.com/e today\n",
"Escaped already\\ today\n",
),
(
"Two slashes\\\\!https://example.com/t today\n",
"Two slashes\\\\\\ today\n",
),
(
"Normal! https://example.com/s today\n",
"Normal! [https://example.com/s](https://example.com/s) today\n",
),
];
for (content, expected) in cases {
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(rule.fix(&ctx).unwrap(), expected, "MDX fix for {content:?}");
}
}
#[test]
fn test_a_preceding_bang_is_untouched_outside_jsx_flavors() {
let rule = MD034NoBareUrls;
let ctx = crate::lint_context::LintContext::new(
"Download now!https://example.com/f today\n",
crate::config::MarkdownFlavor::Standard,
None,
);
assert_eq!(rule.fix(&ctx).unwrap(), "Download now!<https://example.com/f> today\n");
}
#[test]
fn test_mdx_reports_but_does_not_fix_a_url_after_an_active_close_bracket() {
let rule = MD034NoBareUrls;
let content =
"[See more]https://example.com/x here\n\n[https://example.com/x]: https://elsewhere.example.com/\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(warnings.len(), 1, "the bare URL is still a finding");
assert!(warnings[0].fix.is_none(), "no replacement is safe here");
assert_eq!(rule.fix(&ctx).unwrap(), content, "fmt must leave the line alone");
}
#[test]
fn test_mdx_fixes_after_an_escaped_close_bracket() {
let rule = MD034NoBareUrls;
let ctx = crate::lint_context::LintContext::new(
"Text \\]https://example.com/x here\n",
crate::config::MarkdownFlavor::MDX,
None,
);
assert_eq!(
rule.fix(&ctx).unwrap(),
"Text \\][https://example.com/x](https://example.com/x) here\n"
);
}
#[test]
fn test_classify_link_prefix() {
let free = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::Free);
let bang = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveBang);
let bracket = |s: &str| matches!(classify_link_prefix(s, s.len()), LinkPrefix::ActiveCloseBracket);
assert!(free(""), "start of line binds to nothing");
assert!(free("plain "));
assert!(free("plain"));
assert!(bang("hi!"));
assert!(free("hi\\!"), "one backslash escapes the bang");
assert!(bang("hi\\\\!"), "two backslashes escape each other, not the bang");
assert!(free("hi\\\\\\!"), "three escape the bang again");
assert!(bracket("[a]"));
assert!(free("[a\\]"), "an escaped bracket closes no span");
assert!(free("café"));
assert!(bang("café!"));
}
#[test]
fn test_mdx_link_text_escapes_characters_that_would_not_render_literally() {
let rule = MD034NoBareUrls;
let cases = [
("https://ex.com/a*b*c", "https://ex.com/a\\*b\\*c"),
("https://ex.com/a&b", "https://ex.com/a\\&b"),
("https://ex.com/a~b~c", "https://ex.com/a\\~b\\~c"),
("https://ex.com/a_b_c", "https://ex.com/a\\_b\\_c"),
];
for (url, escaped_text) in cases {
let content = format!("See {url} here\n");
let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
format!("See [{escaped_text}]({url}) here\n"),
"MDX must escape the link text for {url}"
);
let standard_ctx =
crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.fix(&standard_ctx).unwrap(),
format!("See <{url}> here\n"),
"Standard emits the autolink, which needs no escaping"
);
}
}
#[test]
fn test_mdx_braces_are_skipped_when_paired_and_escaped_when_not() {
let rule = MD034NoBareUrls;
let paired = "See https://ex.com/a{b}c here\n";
let paired_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::MDX, None);
assert!(
rule.check(&paired_ctx).unwrap().is_empty(),
"a balanced brace pair is a JSX expression, which MD034 leaves alone"
);
let standard_ctx = crate::lint_context::LintContext::new(paired, crate::config::MarkdownFlavor::Standard, None);
assert_eq!(
rule.fix(&standard_ctx).unwrap(),
"See <https://ex.com/a{b}c> here\n",
"outside MDX the braces carry no meaning, so the URL is still reported"
);
for (url, escaped_text) in [
("https://ex.com/a{b", "https://ex.com/a\\{b"),
("https://ex.com/a}b", "https://ex.com/a\\}b"),
] {
let content = format!("See {url} here\n");
let ctx = crate::lint_context::LintContext::new(&content, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
format!("See [{escaped_text}]({url}) here\n"),
"an unmatched brace reaches the fix and must be escaped"
);
}
}
#[test]
fn test_mdx_unbalanced_open_paren_uses_an_angle_bracket_destination() {
let rule = MD034NoBareUrls;
let unbalanced = "Go to https://ex.com/a(b now\n";
let ctx = crate::lint_context::LintContext::new(unbalanced, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
"Go to [https://ex.com/a(b](<https://ex.com/a(b>) now\n"
);
let balanced = "Go to https://en.wikipedia.org/wiki/Foo_(bar) now\n";
let ctx = crate::lint_context::LintContext::new(balanced, crate::config::MarkdownFlavor::MDX, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
"Go to [https://en.wikipedia.org/wiki/Foo\\_(bar)](https://en.wikipedia.org/wiki/Foo_(bar)) now\n",
"balanced parens need no angle brackets"
);
}
#[test]
fn test_mdx_fix_is_idempotent_and_stops_reporting() {
let rule = MD034NoBareUrls;
let content = "\
Plain http://localhost/ and www.example.com.
Mail user@example.com or see https://ex.com/a*b_c{d}e.
Parens https://ex.com/a(b and https://en.wikipedia.org/wiki/Foo_(bar).
";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDX, None);
let fixed = rule.fix(&ctx).unwrap();
assert_ne!(fixed, content, "the fix must actually rewrite this document");
let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDX, None);
assert!(
rule.check(&fixed_ctx).unwrap().is_empty(),
"MDX must not re-report its own output: {:?}",
rule.check(&fixed_ctx).unwrap()
);
assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDX fix must be idempotent");
}
#[test]
fn test_link_form_is_confined_to_jsx_flavors() {
let rule = MD034NoBareUrls;
let content = "Visit https://example.com today\n";
for flavor in [
crate::config::MarkdownFlavor::Standard,
crate::config::MarkdownFlavor::MkDocs,
crate::config::MarkdownFlavor::MyST,
crate::config::MarkdownFlavor::Quarto,
crate::config::MarkdownFlavor::Obsidian,
] {
assert!(!flavor.supports_jsx(), "{flavor:?} is not a JSX flavor");
let ctx = crate::lint_context::LintContext::new(content, flavor, None);
assert_eq!(
rule.fix(&ctx).unwrap(),
"Visit <https://example.com> today\n",
"{flavor:?} must keep the autolink form"
);
}
let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 1);
assert!(rule.check(&mdg_ctx).unwrap()[0].fix.is_none());
assert_eq!(rule.fix(&mdg_ctx).unwrap(), content);
}
#[test]
fn test_escape_mdx_link_text_covers_every_active_character() {
assert_eq!(escape_mdx_link_text("plain"), "plain");
for ch in MDX_LINK_TEXT_ESCAPES {
assert_eq!(escape_mdx_link_text(&ch.to_string()), format!("\\{ch}"));
}
}
#[test]
fn test_has_balanced_parens() {
assert!(has_balanced_parens("https://ex.com/a"));
assert!(has_balanced_parens("https://ex.com/(a)"));
assert!(has_balanced_parens("https://ex.com/(a)(b)"));
assert!(has_balanced_parens("https://ex.com/((a))"));
assert!(!has_balanced_parens("https://ex.com/(a"));
assert!(!has_balanced_parens("https://ex.com/a)"));
assert!(
!has_balanced_parens("https://ex.com/)a("),
"equal counts are not balance"
);
}
}
#[cfg(test)]
mod shared_url_scan_tests {
use super::{collect_urls, collect_urls_legacy};
use proptest::prelude::*;
#[test]
fn crossing_domain_and_scheme_preserves_independent_cursors() {
for line in [
"WWW.example.com Www.example.com XMPP:user@example.com HTTPS://host HTTP://[::1]",
"www.example.comhttps://example.comhttps://host/https://host/",
"www.x.com/https://[::1]/https://foo",
"<www.example.com/https://inner.com>",
"xmpp:user@example.comhttps://host/https://host/",
] {
let mut expected = Vec::new();
collect_urls_legacy(line, &mut expected);
let mut actual = Vec::new();
collect_urls(line, &mut actual);
assert_eq!(actual, expected, "{line}");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(12000))]
#[test]
fn prefix_gated_scan_matches_independent_patterns(
parts in prop::collection::vec(prop::sample::select(vec![
"https://example.com", "http://localhost:80/a(b)?x=y#z",
"ftps: "xmpp:user@example.com/path", "https: "https: "WWW.", "Www.", "XMPP:", "HTTPS: "https: "(", ")", "<", ">", "\"", "'", "`", "\\", "/", "?", "#",
" ", "\n", "\t", "\r\n", "\u{a0}", "\u{2003}", "é", "中文", ":", ".", "_", "*",
]), 0..40)
) {
let line = parts.concat();
let mut expected = Vec::new();
collect_urls_legacy(&line, &mut expected);
let mut actual = vec![(999, 999)];
collect_urls(&line, &mut actual);
prop_assert_eq!(actual, expected, "input: {:?}", line);
}
}
}