use crate::rule::{
CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
};
use crate::utils::frontmatter_values;
use crate::utils::range_utils::byte_to_char_count;
use crate::workspace_index::{
FileIndex, LinkOrigin, Md057LinkTarget, extract_cross_file_links, normalize_relative_path,
};
use pulldown_cmark::LinkType;
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use std::sync::{Arc, Mutex};
mod md057_config;
use crate::utils::mkdocs_config::resolve_docs_dir;
use crate::utils::obsidian_config::resolve_attachment_folder;
use crate::utils::project_root::discover_project_root_from;
pub use md057_config::{AbsoluteLinksOption, MD057Config};
static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
fn reset_file_existence_cache() {
if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
cache.clear();
}
}
fn file_exists_with_cache(path: &Path) -> bool {
match FILE_EXISTENCE_CACHE.lock() {
Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
Err(_) => path.exists(), }
}
fn file_exists_or_markdown_extension(path: &Path) -> bool {
resolve_existing_target(path).is_some()
}
fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
if file_exists_with_cache(path) {
return Some(path.to_path_buf());
}
if path.extension().is_none() {
for ext in MARKDOWN_EXTENSIONS {
let path_with_ext = path.with_extension(&ext[1..]);
if file_exists_with_cache(&path_with_ext) {
return Some(path_with_ext);
}
}
}
None
}
static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
static URL_EXTRACT_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
#[inline]
fn hex_digit_to_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
const MARKDOWN_EXTENSIONS: &[&str] = &[
".md",
".markdown",
".mdx",
".mkd",
".mkdn",
".mdown",
".mdwn",
".qmd",
".rmd",
];
#[derive(Debug, PartialEq, Eq)]
enum SelfReferentialLink {
WholeFile,
Fragment(String),
}
#[cfg(feature = "blake3")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DependencyPathState {
Missing,
File,
Directory,
Other,
}
#[derive(Debug, Clone)]
pub struct MD057ExistingRelativeLinks {
base_path: Arc<Mutex<Option<PathBuf>>>,
config: MD057Config,
}
impl Default for MD057ExistingRelativeLinks {
fn default() -> Self {
Self {
base_path: Arc::new(Mutex::new(None)),
config: MD057Config::default(),
}
}
}
impl MD057ExistingRelativeLinks {
pub fn new() -> Self {
Self::default()
}
pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
let path = path.as_ref();
let dir_path = if path.is_file() {
path.parent().map(std::path::Path::to_path_buf)
} else {
Some(path.to_path_buf())
};
if let Ok(mut guard) = self.base_path.lock() {
*guard = dir_path;
}
self
}
pub fn from_config_struct(config: MD057Config) -> Self {
Self {
base_path: Arc::new(Mutex::new(None)),
config,
}
}
fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
if Path::new(path_str).is_absolute() {
PathBuf::from(path_str)
} else {
project_root.join(path_str)
}
}
#[inline]
fn is_external_url(&self, url: &str) -> bool {
if url.is_empty() {
return false;
}
if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
return true;
}
if url.starts_with("{{") || url.starts_with("{%") {
return true;
}
if url.contains('@') {
return true; }
if !url.contains('/') && url.ends_with(".com") {
return true;
}
if url.starts_with('~') || url.starts_with('@') {
return true;
}
false
}
#[inline]
fn is_fragment_only_link(&self, url: &str) -> bool {
url.starts_with('#')
}
#[inline]
fn is_absolute_path(url: &str) -> bool {
url.starts_with('/')
}
fn url_decode(path: &str) -> String {
if !path.contains('%') {
return path.to_string();
}
let bytes = path.as_bytes();
let mut result = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex1 = bytes[i + 1];
let hex2 = bytes[i + 2];
if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
result.push(d1 * 16 + d2);
i += 3;
continue;
}
}
result.push(bytes[i]);
i += 1;
}
String::from_utf8(result).unwrap_or_else(|_| path.to_string())
}
fn strip_query_and_fragment(url: &str) -> &str {
let query_pos = url.find('?');
let fragment_pos = url.find('#');
match (query_pos, fragment_pos) {
(Some(q), Some(f)) => {
&url[..q.min(f)]
}
(Some(q), None) => &url[..q],
(None, Some(f)) => &url[..f],
(None, None) => url,
}
}
fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
base_path.join(link)
}
fn compute_search_paths(
&self,
flavor: crate::config::MarkdownFlavor,
source_file: Option<&Path>,
base_path: &Path,
project_root: &Path,
) -> Vec<PathBuf> {
let mut paths = Vec::new();
if flavor == crate::config::MarkdownFlavor::Obsidian
&& let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
&& attachment_dir != *base_path
{
paths.push(attachment_dir);
}
for search_path in &self.config.search_paths {
let resolved = Self::resolve_against_project_root(search_path, project_root);
if resolved != *base_path && !paths.contains(&resolved) {
paths.push(resolved);
}
}
paths
}
fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
if !ctx.links().is_empty() {
let lines = ctx.raw_lines();
let mut processed_lines = HashSet::new();
for link in ctx.links() {
let line_index = link.line - 1;
if line_index >= lines.len()
|| ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block)
|| !processed_lines.insert(line_index)
{
continue;
}
let line = lines[line_index];
if !line.contains("](") {
continue;
}
let line_start_byte = ctx.line_start_byte(link.line).unwrap_or(0);
for link_match in LINK_START_REGEX.find_iter(line) {
if link_match.as_str().starts_with('!') {
let escapes = line[..link_match.start()]
.bytes()
.rev()
.take_while(|&byte| byte == b'\\')
.count();
if escapes % 2 == 0 {
continue;
}
}
let absolute_start = line_start_byte + link_match.start();
if ctx.is_in_code_span_byte(absolute_start)
|| ctx.is_in_math_span(absolute_start)
|| ctx.is_in_shortcode(absolute_start)
{
continue;
}
let expected_start = link_match.end() - 1;
let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, expected_start)
.and_then(|caps| caps.get(1).map(|url| (caps, url)))
.or_else(|| {
extract_url_at(&URL_EXTRACT_REGEX, line, expected_start)
.and_then(|caps| caps.get(1).map(|url| (caps, url)))
});
let Some((_, url_match)) = caps_and_url else {
continue;
};
let url = url_match.as_str().trim();
if url.is_empty()
|| (url.starts_with('`') && url.ends_with('`'))
|| self.is_external_url(url)
|| self.is_fragment_only_link(url)
{
continue;
}
index.add_md057_link_target(Md057LinkTarget {
target: url.to_string(),
origin: LinkOrigin::Body,
});
}
}
}
for image in ctx.images() {
if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block)
|| matches!(image.link_type, LinkType::WikiLink { .. })
|| ctx.is_in_shortcode(image.byte_offset)
{
continue;
}
let url = image.url.as_ref();
if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
index.add_md057_link_target(Md057LinkTarget {
target: url.to_string(),
origin: LinkOrigin::Body,
});
}
for reference in ctx.reference_definitions() {
let url = reference.url.as_str();
if url.is_empty() || self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
index.add_md057_link_target(Md057LinkTarget {
target: url.to_string(),
origin: LinkOrigin::Body,
});
}
for link in frontmatter_values::link_destinations(ctx) {
let line = ctx.lines[link.line - 1].content(ctx.content);
let url = &line[link.range];
if self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
index.add_md057_link_target(Md057LinkTarget {
target: url.to_string(),
origin: LinkOrigin::FrontMatter { field: link.field },
});
}
}
fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
search_paths.iter().any(|dir| {
let candidate = dir.join(decoded_path);
file_exists_or_markdown_extension(&candidate)
})
}
fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
if !self.config.compact_paths {
return None;
}
let path_end = url
.find('?')
.unwrap_or(url.len())
.min(url.find('#').unwrap_or(url.len()));
let path_part = &url[..path_end];
let suffix = &url[path_end..];
let decoded_path = Self::url_decode(path_part);
compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
}
fn self_referential_link(
&self,
url: &str,
base_path: &Path,
search_paths: &[PathBuf],
source_file: Option<&Path>,
) -> Option<SelfReferentialLink> {
if !self.config.self_referential_links {
return None;
}
let source_file = source_file?;
let path_part = Self::strip_query_and_fragment(url);
if path_part.is_empty() {
return None;
}
let suffix = &url[path_part.len()..];
let decoded_path = Self::url_decode(path_part);
let resolved = std::iter::once(base_path)
.chain(search_paths.iter().map(PathBuf::as_path))
.find_map(|dir| resolve_existing_target(&Self::resolve_link_path_with_base(&decoded_path, dir)))?;
if !Self::is_same_file(&resolved, source_file) {
return None;
}
match suffix.strip_prefix('#') {
Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
_ => Some(SelfReferentialLink::WholeFile),
}
}
fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
let label_end = Self::label_end(def)?;
let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
title.saturating_sub(ref_def.byte_offset).min(def.len())
});
let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
let start = ref_def.byte_offset + offset;
Some(start..start + ref_def.url.len())
}
fn label_end(def: &str) -> Option<usize> {
let bytes = def.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
_ => i += 1,
}
}
None
}
fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
self.config.check_frontmatter && ctx.front_matter_end_line() > 0
}
fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
match self.config.absolute_links {
AbsoluteLinksOption::Ignore => None,
AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
AbsoluteLinksOption::RelativeToRoots => {
Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
}
}
}
fn check_front_matter(
&self,
ctx: &crate::lint_context::LintContext,
base_path: &Path,
search_paths: &[PathBuf],
project_root: &Path,
warnings: &mut Vec<LintWarning>,
) {
if !self.config.check_frontmatter {
return;
}
let ignored: HashSet<String> = self
.config
.ignore_frontmatter_fields
.iter()
.map(|field| field.to_lowercase())
.collect();
for link in frontmatter_values::link_destinations(ctx) {
if link.field_is_in(&ignored) {
continue;
}
let line = ctx.lines[link.line - 1].content(ctx.content);
let url = &line[link.range.clone()];
if self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
let column = byte_to_char_count(line, link.range.start);
let end_column = column + url.chars().count();
if Self::is_absolute_path(url) {
if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: link.line,
column,
end_line: link.line,
end_column,
message,
severity: Severity::Warning,
fix: None,
});
}
continue;
}
if Self::relative_target_exists(url, base_path, search_paths) {
continue;
}
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: link.line,
column,
end_line: link.line,
end_column,
message: format!("Relative link '{url}' does not exist"),
severity: Severity::Error,
fix: None,
});
}
}
fn relative_target_exists(url: &str, base_path: &Path, search_paths: &[PathBuf]) -> bool {
let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
if file_exists_or_markdown_extension(&resolved_path) {
return true;
}
if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
&& (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
&& let (Some(stem), Some(parent)) = (
resolved_path.file_stem().and_then(|s| s.to_str()),
resolved_path.parent(),
)
&& MARKDOWN_EXTENSIONS
.iter()
.any(|md_ext| file_exists_with_cache(&parent.join(format!("{stem}{md_ext}"))))
{
return true;
}
Self::exists_in_search_paths(&decoded_path, search_paths)
}
fn produces_fixes(&self) -> bool {
self.config.compact_paths || self.config.self_referential_links
}
fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
match self_link {
SelfReferentialLink::Fragment(fragment) => {
format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
}
SelfReferentialLink::WholeFile => {
format!("Relative link '{url}' points to the file it is in")
}
}
}
fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
if resolved.file_name() != source_file.file_name() {
return false;
}
match (resolved.canonicalize(), source_file.canonicalize()) {
(Ok(link), Ok(source)) => link == source,
_ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
}
}
fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
let Some(docs_dir) = resolve_docs_dir(source_path) else {
return Some(format!(
"Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
));
};
let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
Resolution::Found => None,
Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
"Absolute link '{url}' resolves to directory '{}' which has no index.md",
resolved.display()
)),
Resolution::NotFound { resolved } => Some(format!(
"Absolute link '{url}' resolves to '{}' which does not exist",
resolved.display()
)),
}
}
fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
for root in roots {
let root_path = Self::resolve_against_project_root(root, project_root);
if matches!(
Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
Resolution::Found
) {
return None;
}
}
if matches!(
Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
Resolution::Found
) {
return None;
}
let msg = if roots.is_empty() {
format!("Absolute link '{url}' was not found under the project root")
} else {
format!("Absolute link '{url}' was not found under any configured root or the project root")
};
Some(msg)
}
fn prepare_absolute_url(url: &str) -> (String, bool) {
let relative_url = url.trim_start_matches('/');
let file_path = Self::strip_query_and_fragment(relative_url);
let decoded = Self::url_decode(file_path);
let is_directory_link = url.ends_with('/') || decoded.is_empty();
(decoded, is_directory_link)
}
fn resolve_under_root_with_opts(
root_path: &Path,
decoded: &str,
is_directory_link: bool,
require_index_for_dirs: bool,
) -> Resolution {
let resolved = root_path.join(decoded);
let is_dir = resolved.is_dir();
if is_directory_link || (require_index_for_dirs && is_dir) {
let index_path = resolved.join("index.md");
if file_exists_with_cache(&index_path) {
return Resolution::Found;
}
if is_dir {
return Resolution::DirectoryWithoutIndex { resolved };
}
}
let decoded_has_trailing_slash = decoded.ends_with('/');
if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
return Resolution::Found;
}
if file_exists_or_markdown_extension(&resolved) {
return Resolution::Found;
}
if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
&& (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
&& let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
{
let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
let source_path = parent.join(format!("{stem}{md_ext}"));
file_exists_with_cache(&source_path)
});
if has_md_source {
return Resolution::Found;
}
}
Resolution::NotFound { resolved }
}
}
#[cfg(feature = "blake3")]
impl MD057ExistingRelativeLinks {
pub fn cache_dependency_fingerprint(
&self,
source_file: &Path,
flavor: crate::config::MarkdownFlavor,
file_index: &FileIndex,
) -> String {
let mut hasher = blake3::Hasher::new();
hasher.update(b"rumdl-md057-dependencies-v1");
if file_index.md057_link_targets.is_empty() {
return hasher.finalize().to_hex().to_string();
}
let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
let project_root = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
let resolved_source = source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf());
let base_path = explicit_base.unwrap_or_else(|| {
resolved_source
.parent()
.map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
});
let search_paths = self.compute_search_paths(flavor, Some(source_file), &base_path, &project_root);
let ignored_frontmatter_fields: HashSet<String> = self
.config
.ignore_frontmatter_fields
.iter()
.map(|field| field.to_lowercase())
.collect();
for dependency in &file_index.md057_link_targets {
if let LinkOrigin::FrontMatter { field } = &dependency.origin
&& (!self.config.check_frontmatter
|| field
.as_ref()
.is_some_and(|field| ignored_frontmatter_fields.contains(field)))
{
continue;
}
let url = dependency.target.as_str();
if self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
Self::hash_bytes(&mut hasher, url.as_bytes());
if Self::is_absolute_path(url) {
match self.config.absolute_links {
AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
AbsoluteLinksOption::RelativeToDocs => {
hasher.update(b"docs");
if let Some(docs_dir) = resolve_docs_dir(source_file) {
Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
} else {
hasher.update(b"no-docs-dir");
}
}
AbsoluteLinksOption::RelativeToRoots => {
hasher.update(b"roots");
let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
let mut found = false;
for root in &self.config.roots {
let root_path = Self::resolve_against_project_root(root, &project_root);
if Self::observe_under_root(&mut hasher, &root_path, &decoded, is_directory_link, false) {
found = true;
break;
}
}
if !found {
Self::observe_under_root(&mut hasher, &project_root, &decoded, is_directory_link, false);
}
}
}
} else {
hasher.update(b"relative");
if self.config.self_referential_links
&& Self::observe_self_referential_resolution(
&mut hasher,
url,
&base_path,
&search_paths,
&resolved_source,
)
{
continue;
}
Self::observe_relative_resolution(&mut hasher, url, &base_path, &search_paths);
}
}
hasher.finalize().to_hex().to_string()
}
fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
hasher.update(&(bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
Self::hash_bytes(hasher, path.as_os_str().as_bytes());
}
#[cfg(windows)]
{
use std::os::windows::ffi::OsStrExt;
let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
Self::hash_bytes(hasher, &encoded);
}
#[cfg(not(any(unix, windows)))]
Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
}
fn observe_path(hasher: &mut blake3::Hasher, path: &Path) -> DependencyPathState {
Self::hash_path(hasher, path);
let state = match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => DependencyPathState::File,
Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
Ok(_) => DependencyPathState::Other,
Err(_) => DependencyPathState::Missing,
};
hasher.update(&[match state {
DependencyPathState::Missing => 0,
DependencyPathState::File => 1,
DependencyPathState::Directory => 2,
DependencyPathState::Other => 3,
}]);
state
}
fn observe_existing_target(hasher: &mut blake3::Hasher, path: &Path) -> Option<PathBuf> {
if Self::observe_path(hasher, path) != DependencyPathState::Missing {
return Some(path.to_path_buf());
}
if path.extension().is_none() {
for extension in MARKDOWN_EXTENSIONS {
let candidate = path.with_extension(&extension[1..]);
if Self::observe_path(hasher, &candidate) != DependencyPathState::Missing {
return Some(candidate);
}
}
}
None
}
fn observe_self_referential_resolution(
hasher: &mut blake3::Hasher,
url: &str,
base_path: &Path,
search_paths: &[PathBuf],
source_file: &Path,
) -> bool {
let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
let candidate = Self::resolve_link_path_with_base(&decoded, directory);
if let Some(resolved) = Self::observe_existing_target(hasher, &candidate) {
let canonical = resolved.canonicalize().unwrap_or(resolved);
hasher.update(b"resolved-identity");
Self::hash_path(hasher, &canonical);
return Self::is_same_file(&canonical, source_file);
}
}
false
}
fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
if Self::observe_existing_target(hasher, &resolved).is_some() {
return;
}
if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
&& (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
&& let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
&& MARKDOWN_EXTENSIONS.iter().any(|extension| {
Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
})
{
return;
}
for search_path in search_paths {
if Self::observe_existing_target(hasher, &search_path.join(&decoded)).is_some() {
return;
}
}
}
fn observe_absolute_resolution(
hasher: &mut blake3::Hasher,
root: &Path,
url: &str,
require_index_for_dirs: bool,
) -> bool {
let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
Self::observe_under_root(hasher, root, &decoded, is_directory_link, require_index_for_dirs)
}
fn observe_under_root(
hasher: &mut blake3::Hasher,
root: &Path,
decoded: &str,
is_directory_link: bool,
require_index_for_dirs: bool,
) -> bool {
let resolved = root.join(decoded);
let resolved_state = Self::observe_path(hasher, &resolved);
let is_dir = resolved_state == DependencyPathState::Directory;
if is_directory_link || (require_index_for_dirs && is_dir) {
if Self::observe_path(hasher, &resolved.join("index.md")) != DependencyPathState::Missing {
return true;
}
if is_dir {
return false;
}
}
if !require_index_for_dirs && !is_directory_link && !decoded.ends_with('/') && is_dir {
return true;
}
if resolved_state != DependencyPathState::Missing {
return true;
}
if resolved.extension().is_none()
&& MARKDOWN_EXTENSIONS.iter().any(|extension| {
Self::observe_path(hasher, &resolved.with_extension(&extension[1..])) != DependencyPathState::Missing
})
{
return true;
}
if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
&& (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
&& let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
{
return MARKDOWN_EXTENSIONS.iter().any(|extension| {
Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
});
}
false
}
}
enum Resolution {
Found,
DirectoryWithoutIndex { resolved: PathBuf },
NotFound { resolved: PathBuf },
}
fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
let caps = re.captures_at(line, expected_start)?;
if caps.get(0)?.start() != expected_start {
return None;
}
Some(caps)
}
impl Rule for MD057ExistingRelativeLinks {
fn name(&self) -> &'static str {
"MD057"
}
fn description(&self) -> &'static str {
"Relative links should point to existing files"
}
fn category(&self) -> RuleCategory {
RuleCategory::Link
}
fn skippable_by_category(&self) -> bool {
!self.config.check_frontmatter
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let content = ctx.content;
if content.is_empty() {
return Ok(Vec::new());
}
let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
if !has_body_links && !self.checks_front_matter_of(ctx) {
return Ok(Vec::new());
}
reset_file_existence_cache();
let mut warnings = Vec::new();
let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
let self_path: Option<PathBuf> = ctx
.source_file()
.map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
let base_path: Option<PathBuf> = {
if explicit_base.is_some() {
explicit_base
} else if let Some(ref resolved_file) = self_path {
resolved_file
.parent()
.map(std::path::Path::to_path_buf)
.or_else(|| Some(CURRENT_DIR.clone()))
} else {
None
}
};
let Some(base_path) = base_path else {
return Ok(warnings);
};
let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
if !ctx.links().is_empty() {
let lines = ctx.raw_lines();
let mut processed_lines = std::collections::HashSet::new();
for link in ctx.links() {
let line_idx = link.line - 1;
if line_idx >= lines.len() {
continue;
}
if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
continue;
}
if !processed_lines.insert(line_idx) {
continue;
}
let line = lines[line_idx];
if !line.contains("](") {
continue;
}
for link_match in LINK_START_REGEX.find_iter(line) {
if link_match.as_str().starts_with('!') {
let escapes = line[..link_match.start()]
.bytes()
.rev()
.take_while(|&b| b == b'\\')
.count();
if escapes % 2 == 0 {
continue;
}
}
let start_pos = link_match.start();
let end_pos = link_match.end();
let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
let absolute_start_pos = line_start_byte + start_pos;
if ctx.is_in_code_span_byte(absolute_start_pos) {
continue;
}
if ctx.is_in_math_span(absolute_start_pos) {
continue;
}
if ctx.is_in_shortcode(absolute_start_pos) {
continue;
}
let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
.and_then(|caps| caps.get(1).map(|g| (caps, g)))
.or_else(|| {
extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
.and_then(|caps| caps.get(1).map(|g| (caps, g)))
});
if let Some((caps, url_group)) = caps_and_url {
let url = url_group.as_str().trim();
if url.is_empty() {
continue;
}
if url.starts_with('`') && url.ends_with('`') {
continue;
}
if self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
if Self::is_absolute_path(url) {
if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: link.line,
column: byte_to_char_count(line, url_group.start()),
end_line: link.line,
end_column: byte_to_char_count(line, url_group.end()),
message,
severity: Severity::Warning,
fix: None,
});
}
continue;
}
let full_url_for_compact = if let Some(frag) = caps.get(2) {
format!("{url}{}", frag.as_str())
} else {
url.to_string()
};
if let Some(self_link) = self.self_referential_link(
&full_url_for_compact,
&base_path,
&extra_search_paths,
self_path.as_deref(),
) {
let url_start = url_group.start();
let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
let fix_byte_start = line_start_byte + url_start;
let fix_byte_end = line_start_byte + url_end;
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: link.line,
column: byte_to_char_count(line, url_start),
end_line: link.line,
end_column: byte_to_char_count(line, url_end),
message: Self::self_referential_message(&full_url_for_compact, &self_link),
severity: Severity::Warning,
fix: match &self_link {
SelfReferentialLink::Fragment(fragment) => {
Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
}
SelfReferentialLink::WholeFile => None,
},
});
continue;
}
if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
let url_start = url_group.start();
let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
let fix_byte_start = line_start_byte + url_start;
let fix_byte_end = line_start_byte + url_end;
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: link.line,
column: byte_to_char_count(line, url_start),
end_line: link.line,
end_column: byte_to_char_count(line, url_end),
message: format!(
"Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
),
severity: Severity::Warning,
fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
});
}
if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
continue;
}
let url_start = url_group.start();
let url_end = url_group.end();
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: link.line,
column: byte_to_char_count(line, url_start),
end_line: link.line,
end_column: byte_to_char_count(line, url_end),
message: format!("Relative link '{url}' does not exist"),
severity: Severity::Error,
fix: None,
});
}
}
}
}
for image in ctx.images() {
if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
continue;
}
if matches!(image.link_type, LinkType::WikiLink { .. }) {
continue;
}
if ctx.is_in_shortcode(image.byte_offset) {
continue;
}
let url = image.url.as_ref();
if url.is_empty() {
continue;
}
if self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
if Self::is_absolute_path(url) {
if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: image.line,
column: image.start_col + 1,
end_line: image.line,
end_column: image.start_col + 1 + url.chars().count(),
message,
severity: Severity::Warning,
fix: None,
});
}
continue;
}
if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
let fix_byte_start = image.byte_offset + url_offset;
let fix_byte_end = fix_byte_start + url.len();
Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
});
let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
byte_to_char_count(image_line, f.range.start - img_line_start_byte)
});
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: image.line,
column: url_col,
end_line: image.line,
end_column: url_col + url.chars().count(),
message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
severity: Severity::Warning,
fix,
});
}
if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
continue;
}
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: image.line,
column: image.start_col + 1,
end_line: image.line,
end_column: image.start_col + 1 + url.chars().count(),
message: format!("Relative link '{url}' does not exist"),
severity: Severity::Error,
fix: None,
});
}
for ref_def in ctx.reference_definitions() {
let url = &ref_def.url;
if url.is_empty() {
continue;
}
if self.is_external_url(url) || self.is_fragment_only_link(url) {
continue;
}
let url_range = Self::ref_def_url_range(ctx.content, ref_def);
let (line, col) = url_range
.as_ref()
.map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
let end_col = col + url.chars().count();
if Self::is_absolute_path(url) {
if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line,
column: col,
end_line: line,
end_column: end_col,
message,
severity: Severity::Warning,
fix: None,
});
}
continue;
}
if let Some(self_link) =
self.self_referential_link(url, &base_path, &extra_search_paths, self_path.as_deref())
{
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line,
column: col,
end_line: line,
end_column: end_col,
message: Self::self_referential_message(url, &self_link),
severity: Severity::Warning,
fix: match (&self_link, &url_range) {
(SelfReferentialLink::Fragment(fragment), Some(range)) => {
Some(Fix::new(range.clone(), fragment.clone()))
}
_ => None,
},
});
continue;
}
if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line,
column: col,
end_line: line,
end_column: end_col,
message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
severity: Severity::Warning,
fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
});
}
if Self::relative_target_exists(url, &base_path, &extra_search_paths) {
continue;
}
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line,
column: col,
end_line: line,
end_column: end_col,
message: format!("Relative link '{url}' does not exist"),
severity: Severity::Error,
fix: None,
});
}
self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
Ok(warnings)
}
fn fix_capability(&self) -> FixCapability {
if self.produces_fixes() {
FixCapability::ConditionallyFixable
} else {
FixCapability::Unfixable
}
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
if !self.produces_fixes() {
return Ok(ctx.content.to_string());
}
let warnings = self.check(ctx)?;
let warnings =
crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
let mut content = ctx.content.to_string();
let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
let mut last_applied_start: Option<usize> = None;
for fix in fixes {
if let Some(prev_start) = last_applied_start
&& fix.range.end > prev_start
{
continue;
}
if fix.range.end <= content.len() {
content.replace_range(fix.range.clone(), &fix.replacement);
last_applied_start = Some(fix.range.start);
}
}
Ok(content)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
crate::impl_rule_config_sections!(MD057Config);
fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
Box::new(Self::from_config_struct(rule_config))
}
fn cross_file_scope(&self) -> CrossFileScope {
CrossFileScope::Workspace
}
fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
self.contribute_dependency_targets(ctx, index);
let links = extract_cross_file_links(ctx);
for link in links.relative {
index.add_cross_file_link(link);
}
for link in links.root_relative {
index.add_root_relative_link(link);
}
}
fn cross_file_check(
&self,
_file_path: &Path,
_file_index: &FileIndex,
_workspace_index: &crate::workspace_index::WorkspaceIndex,
) -> LintResult {
Ok(Vec::new())
}
}
fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
let from_components: Vec<_> = from_dir.components().collect();
let to_components: Vec<_> = to_path.components().collect();
let common_len = from_components
.iter()
.zip(to_components.iter())
.take_while(|(a, b)| a == b)
.count();
let mut result = PathBuf::new();
for _ in common_len..from_components.len() {
result.push("..");
}
for component in &to_components[common_len..] {
result.push(component);
}
result
}
fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
let link_path = Path::new(raw_link_path);
let has_traversal = link_path
.components()
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
if !has_traversal {
return None;
}
let combined = source_dir.join(link_path);
let normalized_target = normalize_relative_path(&combined);
let normalized_source = normalize_relative_path(source_dir);
let shortest = shortest_relative_path(&normalized_source, &normalized_target);
if shortest != link_path {
let compact = shortest.to_string_lossy().to_string();
if compact.is_empty() {
return None;
}
Some(compact.replace('\\', "/"))
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
#[test]
fn test_strip_query_and_fragment() {
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
"file.png"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
"file.png"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
"file.png"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
"file.md"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
"file.md"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
"file.md"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
"file.png"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
"path/to/image.png"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
"path/to/image.png"
);
assert_eq!(
MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
"file.md"
);
}
#[test]
fn test_url_decode() {
assert_eq!(
MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
"penguin with space.jpg"
);
assert_eq!(
MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
"assets/my file name.png"
);
assert_eq!(
MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
"hello world!.md"
);
assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
assert_eq!(
MD057ExistingRelativeLinks::url_decode("normal-file.md"),
"normal-file.md"
);
assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
assert_eq!(
MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
"path/to/file.md"
);
assert_eq!(
MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
"hello world/foo bar.md"
);
assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
}
#[test]
fn test_url_encoded_filenames() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let file_with_spaces = base_path.join("penguin with space.jpg");
File::create(&file_with_spaces)
.unwrap()
.write_all(b"image data")
.unwrap();
let subdir = base_path.join("my images");
std::fs::create_dir(&subdir).unwrap();
let nested_file = subdir.join("photo 1.png");
File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
let content = r#"
# Test Document with URL-Encoded Links



"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing%20file.jpg. Got: {result:?}"
);
assert!(
result[0].message.contains("missing%20file.jpg"),
"Warning should mention the URL-encoded filename"
);
}
#[test]
fn test_external_urls() {
let rule = MD057ExistingRelativeLinks::new();
assert!(rule.is_external_url("https://example.com"));
assert!(rule.is_external_url("http://example.com"));
assert!(rule.is_external_url("ftp://example.com"));
assert!(rule.is_external_url("www.example.com"));
assert!(rule.is_external_url("example.com"));
assert!(rule.is_external_url("file:///path/to/file"));
assert!(rule.is_external_url("smb://server/share"));
assert!(rule.is_external_url("macappstores://apps.apple.com/"));
assert!(rule.is_external_url("mailto:user@example.com"));
assert!(rule.is_external_url("tel:+1234567890"));
assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
assert!(rule.is_external_url("javascript:void(0)"));
assert!(rule.is_external_url("ssh://git@github.com/repo"));
assert!(rule.is_external_url("git://github.com/repo.git"));
assert!(rule.is_external_url("user@example.com"));
assert!(rule.is_external_url("steering@kubernetes.io"));
assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
assert!(rule.is_external_url("user_name@sub.domain.com"));
assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
assert!(rule.is_external_url("{{URL}}")); assert!(rule.is_external_url("{{#URL}}")); assert!(rule.is_external_url("{{> partial}}")); assert!(rule.is_external_url("{{ variable }}")); assert!(rule.is_external_url("{{% include %}}")); assert!(rule.is_external_url("{{"));
assert!(!rule.is_external_url("/api/v1/users"));
assert!(!rule.is_external_url("/blog/2024/release.html"));
assert!(!rule.is_external_url("/react/hooks/use-state.html"));
assert!(!rule.is_external_url("/pkg/runtime"));
assert!(!rule.is_external_url("/doc/go1compat"));
assert!(!rule.is_external_url("/index.html"));
assert!(!rule.is_external_url("/assets/logo.png"));
assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
assert!(rule.is_external_url("~/assets/image.png"));
assert!(rule.is_external_url("~/components/Button.vue"));
assert!(rule.is_external_url("~assets/logo.svg"));
assert!(rule.is_external_url("@/components/Header.vue"));
assert!(rule.is_external_url("@images/photo.jpg"));
assert!(rule.is_external_url("@assets/styles.css"));
assert!(!rule.is_external_url("./relative/path.md"));
assert!(!rule.is_external_url("relative/path.md"));
assert!(!rule.is_external_url("../parent/path.md"));
}
#[test]
fn test_dot_com_only_skips_bare_domains() {
let rule = MD057ExistingRelativeLinks::new();
assert!(rule.is_external_url("example.com"));
assert!(rule.is_external_url("sub.example.com"));
assert!(!rule.is_external_url("../../vendor.com"));
assert!(!rule.is_external_url("./vendor.com"));
assert!(!rule.is_external_url("docs/vendor.com"));
}
#[test]
fn test_framework_path_aliases() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"
# Framework Path Aliases




[Link](@/pages/about.md)
This is a [real missing link](missing.md) that should be flagged.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing.md, not framework aliases. Got: {result:?}"
);
assert!(
result[0].message.contains("missing.md"),
"Warning should be for missing.md"
);
}
#[test]
fn test_url_decode_security_path_traversal() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let file_in_base = base_path.join("safe.md");
File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
let content = r#"
[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
[Safe link](safe.md)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Should have warnings for traversal attempts. Got: {result:?}"
);
}
#[test]
fn test_url_encoded_utf8_filenames() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let cafe_file = base_path.join("café.md");
File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
let content = r#"
[Café link](caf%C3%A9.md)
[Missing unicode](r%C3%A9sum%C3%A9.md)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing résumé.md. Got: {result:?}"
);
assert!(
result[0].message.contains("r%C3%A9sum%C3%A9.md"),
"Warning should mention the URL-encoded filename"
);
}
#[test]
fn test_url_encoded_emoji_filenames() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let emoji_dir = base_path.join("👤 Personal");
std::fs::create_dir(&emoji_dir).unwrap();
let file_path = emoji_dir.join("TV Shows.md");
File::create(&file_path)
.unwrap()
.write_all(b"# TV Shows\n\nContent here.")
.unwrap();
let content = r#"
# Test Document
[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing file. Got: {result:?}");
assert!(
result[0].message.contains("Missing.md"),
"Warning should be for Missing.md, got: {}",
result[0].message
);
}
#[test]
fn test_no_warnings_without_base_path() {
let rule = MD057ExistingRelativeLinks::new();
let content = "[Link](missing.md)";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should have no warnings without base path");
}
#[test]
fn test_existing_and_missing_links() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let exists_path = base_path.join("exists.md");
File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
assert!(exists_path.exists(), "exists.md should exist for this test");
let content = r#"
# Test Document
[Valid Link](exists.md)
[Invalid Link](missing.md)
[External Link](https://example.com)
[Media Link](image.jpg)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2);
let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
assert!(messages.iter().any(|m| m.contains("missing.md")));
assert!(messages.iter().any(|m| m.contains("image.jpg")));
}
#[test]
fn test_angle_bracket_links() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let exists_path = base_path.join("exists.md");
File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
let content = r#"
# Test Document
[Valid Link](<exists.md>)
[Invalid Link](<missing.md>)
[External Link](<https://example.com>)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
assert!(
result[0].message.contains("missing.md"),
"Warning should mention missing.md"
);
}
#[test]
fn test_angle_bracket_links_with_parens() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let app_dir = base_path.join("app");
std::fs::create_dir(&app_dir).unwrap();
let upload_dir = app_dir.join("(upload)");
std::fs::create_dir(&upload_dir).unwrap();
let page_file = upload_dir.join("page.tsx");
File::create(&page_file)
.unwrap()
.write_all(b"export default function Page() {}")
.unwrap();
let content = r#"
# Test Document with Paths Containing Parens
[Upload Page](<app/(upload)/page.tsx>)
[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
[Missing](<app/(missing)/file.md>)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning for missing file. Got: {result:?}"
);
assert!(
result[0].message.contains("app/(missing)/file.md"),
"Warning should mention app/(missing)/file.md"
);
}
#[test]
fn test_all_file_types_checked() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"
[Image Link](image.jpg)
[Video Link](video.mp4)
[Markdown Link](document.md)
[PDF Link](file.pdf)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 4, "Should have warnings for all missing files");
}
#[test]
fn test_code_span_detection() {
let rule = MD057ExistingRelativeLinks::new();
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rule = rule.with_path(base_path);
let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
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 only flag the real link");
assert!(result[0].message.contains("nonexistent.md"));
}
#[test]
fn test_inline_code_spans() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"
# Test Document
This is a normal link: [Link](missing.md)
This is a code span with a link: `[Link](another-missing.md)`
Some more text with `inline code [Link](yet-another-missing.md) embedded`.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
assert!(
result[0].message.contains("missing.md"),
"Warning should be for missing.md"
);
assert!(
!result.iter().any(|w| w.message.contains("another-missing.md")),
"Should not warn about link in code span"
);
assert!(
!result.iter().any(|w| w.message.contains("yet-another-missing.md")),
"Should not warn about link in inline code"
);
}
#[test]
fn test_extensionless_link_resolution() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let page_path = base_path.join("page.md");
File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
let content = r#"
# Test Document
[Link without extension](page)
[Link with extension](page.md)
[Missing link](nonexistent)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about nonexistent link");
assert!(
result[0].message.contains("nonexistent"),
"Warning should be for 'nonexistent' not 'page'"
);
}
#[test]
fn test_cross_file_scope() {
let rule = MD057ExistingRelativeLinks::new();
assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
}
#[test]
fn test_contribute_to_index_extracts_markdown_links() {
let rule = MD057ExistingRelativeLinks::new();
let content = r#"
# Document
[Link to docs](./docs/guide.md)
[Link with fragment](./other.md#section)
[External link](https://example.com)
[Image link](image.png)
[Media file](video.mp4)
"#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut index);
assert_eq!(index.cross_file_links.len(), 2);
assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
assert_eq!(index.cross_file_links[0].fragment, "");
assert_eq!(index.cross_file_links[1].target_path, "./other.md");
assert_eq!(index.cross_file_links[1].fragment, "section");
}
#[test]
fn test_contribute_to_index_skips_external_and_anchors() {
let rule = MD057ExistingRelativeLinks::new();
let content = r#"
# Document
[External](https://example.com)
[Another external](http://example.org)
[Fragment only](#section)
[FTP link](ftp://files.example.com)
[Mail link](mailto:test@example.com)
[WWW link](www.example.com)
"#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut index);
assert_eq!(index.cross_file_links.len(), 0);
}
#[test]
fn test_cross_file_check_valid_link() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD057ExistingRelativeLinks::new();
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
let mut file_index = FileIndex::new();
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "guide.md".to_string(),
fragment: "".to_string(),
line: 5,
column: 1,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
.unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_cross_file_check_missing_link() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD057ExistingRelativeLinks::new();
let workspace_index = WorkspaceIndex::new();
let mut file_index = FileIndex::new();
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "missing.md".to_string(),
fragment: "".to_string(),
line: 5,
column: 1,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
);
}
#[test]
fn test_cross_file_check_parent_path() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD057ExistingRelativeLinks::new();
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
let mut file_index = FileIndex::new();
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "../readme.md".to_string(),
fragment: "".to_string(),
line: 5,
column: 1,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
.unwrap();
assert!(warnings.is_empty());
}
#[test]
fn test_cross_file_check_html_link_with_md_source() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD057ExistingRelativeLinks::new();
let mut workspace_index = WorkspaceIndex::new();
workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
let mut file_index = FileIndex::new();
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "guide.html".to_string(),
fragment: "section".to_string(),
line: 10,
column: 5,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"Expected no warnings for .html link with .md source, got: {warnings:?}"
);
}
#[test]
fn test_cross_file_check_html_link_without_source() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD057ExistingRelativeLinks::new();
let workspace_index = WorkspaceIndex::new();
let mut file_index = FileIndex::new();
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "missing.html".to_string(),
fragment: "".to_string(),
line: 10,
column: 5,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
);
}
#[test]
fn test_normalize_path_function() {
assert_eq!(
normalize_relative_path(Path::new("docs/guide.md")),
PathBuf::from("docs/guide.md")
);
assert_eq!(
normalize_relative_path(Path::new("./docs/guide.md")),
PathBuf::from("docs/guide.md")
);
assert_eq!(
normalize_relative_path(Path::new("docs/sub/../guide.md")),
PathBuf::from("docs/guide.md")
);
assert_eq!(
normalize_relative_path(Path::new("a/b/c/../../d.md")),
PathBuf::from("a/d.md")
);
}
#[test]
fn test_html_link_with_md_source() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let md_file = base_path.join("guide.md");
File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
let content = r#"
[Read the guide](guide.html)
[Also here](getting-started.html)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing source. Got: {result:?}"
);
assert!(result[0].message.contains("getting-started.html"));
}
#[test]
fn test_htm_link_with_md_source() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let md_file = base_path.join("page.md");
File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
let content = "[Page](page.htm)";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not warn when .md source exists for .htm link"
);
}
#[test]
fn test_html_link_finds_various_markdown_extensions() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
File::create(base_path.join("doc.md")).unwrap();
File::create(base_path.join("tutorial.mdx")).unwrap();
File::create(base_path.join("guide.markdown")).unwrap();
let content = r#"
[Doc](doc.html)
[Tutorial](tutorial.html)
[Guide](guide.html)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should find all markdown variants as source files. Got: {result:?}"
);
}
#[test]
fn test_html_link_in_subdirectory() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let docs_dir = base_path.join("docs");
std::fs::create_dir(&docs_dir).unwrap();
File::create(docs_dir.join("guide.md"))
.unwrap()
.write_all(b"# Guide")
.unwrap();
let content = "[Guide](docs/guide.html)";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(result.is_empty(), "Should find markdown source in subdirectory");
}
#[test]
fn test_absolute_path_skipped_in_check() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"
# Test Document
[Go Runtime](/pkg/runtime)
[Go Runtime with Fragment](/pkg/runtime#section)
[API Docs](/api/v1/users)
[Blog Post](/blog/2024/release.html)
[React Hook](/react/hooks/use-state.html)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Absolute paths should be skipped. Got warnings: {result:?}"
);
}
#[test]
fn test_absolute_path_skipped_in_cross_file_check() {
use crate::workspace_index::WorkspaceIndex;
let rule = MD057ExistingRelativeLinks::new();
let workspace_index = WorkspaceIndex::new();
let mut file_index = FileIndex::new();
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "/pkg/runtime.md".to_string(),
fragment: "".to_string(),
line: 5,
column: 1,
origin: LinkOrigin::Body,
});
file_index.add_cross_file_link(CrossFileLinkIndex {
target_path: "/api/v1/users.md".to_string(),
fragment: "section".to_string(),
line: 10,
column: 1,
origin: LinkOrigin::Body,
});
let warnings = rule
.cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
.unwrap();
assert!(
warnings.is_empty(),
"Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
);
}
#[test]
fn test_protocol_relative_url_not_skipped() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"
# Test Document
[External](//example.com/page)
[Another](//cdn.example.com/asset.js)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Protocol-relative URLs should be skipped. Got warnings: {result:?}"
);
}
#[test]
fn test_email_addresses_skipped() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"
# Test Document
[Contact](user@example.com)
[Steering](steering@kubernetes.io)
[Support](john.doe+filter@company.co.uk)
[User](user_name@sub.domain.com)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Email addresses should be skipped. Got warnings: {result:?}"
);
}
#[test]
fn test_email_addresses_vs_file_paths() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"
# Test Document
[Email](user@example.com) <!-- Should be skipped (email) -->
[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"All email addresses should be skipped. Got: {result:?}"
);
}
#[test]
fn test_diagnostic_position_accuracy() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "prefix [text](missing.md) suffix";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
assert_eq!(result[0].line, 1, "Should be on line 1");
assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
}
#[test]
fn test_diagnostic_position_non_ascii_link() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "ä½ å¥½ä½ å¥½[ä½ å¥½](not-exist.md) bar";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
assert_eq!(result[0].line, 1, "Should be on line 1");
assert_eq!(
result[0].column, 10,
"Column must be a character offset, not a byte offset"
);
assert_eq!(result[0].end_column, 22, "End column must be character-based");
}
#[test]
fn test_diagnostic_position_angle_brackets() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "[link](<missing.md>)";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
assert_eq!(result[0].line, 1, "Should be on line 1");
assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
}
#[test]
fn test_diagnostic_position_multiline() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Title
Some text on line 2
[link on line 3](missing1.md)
More text
[link on line 5](missing2.md)"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(result.len(), 2, "Should have two warnings");
assert_eq!(result[0].line, 3, "First warning should be on line 3");
assert!(result[0].message.contains("missing1.md"));
assert_eq!(result[1].line, 5, "Second warning should be on line 5");
assert!(result[1].message.contains("missing2.md"));
}
#[test]
fn test_diagnostic_position_with_spaces() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "[link]( missing.md )";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
}
#[test]
fn test_diagnostic_position_image() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning for image");
assert_eq!(result[0].line, 1);
assert!(result[0].column > 0, "Should have valid column position");
assert!(result[0].message.contains("missing.jpg"));
}
#[test]
fn test_diagnostic_position_non_ascii_image() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "ä½ å¥½ä½ å¥½";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning for image");
assert_eq!(result[0].line, 1, "Should be on line 1");
assert_eq!(
result[0].column, 5,
"Column must be a character offset, not a byte offset"
);
assert!(result[0].message.contains("not-exist.png"));
}
#[test]
fn test_diagnostic_position_non_ascii_reference_def() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "[ä½ å¥½]: not-exist.md";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning for reference def");
assert_eq!(result[0].line, 1, "Should be on line 1");
assert_eq!(
result[0].column, 7,
"Column must be a character offset, not a byte offset"
);
assert_eq!(result[0].end_column, 19, "End column must be character-based");
}
#[test]
fn test_wikilinks_skipped() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Test Document
[[Microsoft#Windows OS]]
[[SomePage]]
[[Page With Spaces]]
[[path/to/page#section]]
[[page|Display Text]]
This is a [real missing link](missing.md) that should be flagged.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing.md, not wikilinks. Got: {result:?}"
);
assert!(
result[0].message.contains("missing.md"),
"Warning should be for missing.md, not wikilinks"
);
}
#[test]
fn test_wiki_embeds_skipped() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Test Document
![[diagram.png]]
![[subfolder/diagram.png]]
![[diagram.png|300]]
![[Some Note]]
This is a [real missing link](missing.md) that should be flagged.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
for flavor in [
crate::config::MarkdownFlavor::Obsidian,
crate::config::MarkdownFlavor::Standard,
] {
let ctx = crate::lint_context::LintContext::new(content, flavor, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
);
assert!(result[0].message.contains("missing.md"));
}
}
#[test]
fn test_wikilinks_not_added_to_index() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Test Document
[[Microsoft#Windows OS]]
[[SomePage#section]]
[Regular Link](other.md)
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let mut file_index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut file_index);
let cross_file_links = &file_index.cross_file_links;
assert_eq!(
cross_file_links.len(),
1,
"Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
);
assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
}
#[test]
fn test_reference_definition_missing_file() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Test Document
[test]: ./missing.md
[example]: ./nonexistent.html
Use [test] and [example] here.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Should have warnings for missing reference definition targets. Got: {result:?}"
);
assert!(
result.iter().any(|w| w.message.contains("missing.md")),
"Should warn about missing.md"
);
assert!(
result.iter().any(|w| w.message.contains("nonexistent.html")),
"Should warn about nonexistent.html"
);
}
#[test]
fn test_reference_definition_existing_file() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let exists_path = base_path.join("exists.md");
File::create(&exists_path)
.unwrap()
.write_all(b"# Existing file")
.unwrap();
let content = r#"# Test Document
[test]: ./exists.md
Use [test] here.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not warn about existing file. Got: {result:?}"
);
}
#[test]
fn test_reference_definition_external_url_skipped() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Test Document
[google]: https://google.com
[example]: http://example.org
[mail]: mailto:test@example.com
[ftp]: ftp://files.example.com
[local]: ./missing.md
Use [google], [example], [mail], [ftp], [local] here.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about local missing file. Got: {result:?}"
);
assert!(
result[0].message.contains("missing.md"),
"Warning should be for missing.md"
);
}
#[test]
fn test_reference_definition_fragment_only_skipped() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Test Document
[section]: #my-section
Use [section] here.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should not warn about fragment-only reference. Got: {result:?}"
);
}
#[test]
fn test_reference_definition_column_position() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = "[ref]: ./missing.md";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
assert_eq!(result[0].line, 1, "Should be on line 1");
assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
}
#[test]
fn test_reference_definition_html_with_md_source() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let md_file = base_path.join("guide.md");
File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
let content = r#"# Test Document
[guide]: ./guide.html
[missing]: ./missing.html
Use [guide] and [missing] here.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing source. Got: {result:?}"
);
assert!(result[0].message.contains("missing.html"));
}
#[test]
fn test_reference_definition_url_encoded() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let file_with_spaces = base_path.join("file with spaces.md");
File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
let content = r#"# Test Document
[spaces]: ./file%20with%20spaces.md
[missing]: ./missing%20file.md
Use [spaces] and [missing] here.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 only warn about missing URL-encoded file. Got: {result:?}"
);
assert!(result[0].message.contains("missing%20file.md"));
}
#[test]
fn test_inline_and_reference_both_checked() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let content = r#"# Test Document
[inline link](./inline-missing.md)
[ref]: ./ref-missing.md
Use [ref] here.
"#;
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Should warn about both inline and reference links. Got: {result:?}"
);
assert!(
result.iter().any(|w| w.message.contains("inline-missing.md")),
"Should warn about inline-missing.md"
);
assert!(
result.iter().any(|w| w.message.contains("ref-missing.md")),
"Should warn about ref-missing.md"
);
}
#[test]
fn test_footnote_definitions_not_flagged() {
let rule = MD057ExistingRelativeLinks::default();
let content = r#"# Title
A footnote[^1].
[^1]: [link](https://www.google.com).
"#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
);
}
#[test]
fn test_footnote_with_relative_link_inside() {
let rule = MD057ExistingRelativeLinks::default();
let content = r#"# Title
See the footnote[^1].
[^1]: Check out [this file](./existing.md) for more info.
[^2]: Also see [missing](./does-not-exist.md).
"#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
for warning in &result {
assert!(
!warning.message.contains("[this file]"),
"Footnote content should not be treated as URL: {warning:?}"
);
assert!(
!warning.message.contains("[missing]"),
"Footnote content should not be treated as URL: {warning:?}"
);
}
}
#[test]
fn test_mixed_footnotes_and_reference_definitions() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let content = r#"# Title
A footnote[^1] and a [ref link][myref].
[^1]: This is a footnote with [link](https://example.com).
[myref]: ./missing-file.md "This should be checked"
"#;
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 only warn about the regular reference definition. Got: {result:?}"
);
assert!(
result[0].message.contains("missing-file.md"),
"Should warn about missing-file.md in reference definition"
);
}
#[test]
fn test_absolute_links_ignore_by_default() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let content = r#"# Links
[API docs](/api/v1/users)
[Blog post](/blog/2024/release.html)

[ref]: /docs/reference.md
"#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Absolute links should be ignored by default. Got: {result:?}"
);
}
#[test]
fn test_absolute_links_warn_config() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let config = MD057Config {
absolute_links: AbsoluteLinksOption::Warn,
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = r#"# Links
[API docs](/api/v1/users)
[Blog post](/blog/2024/release.html)
"#;
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
2,
"Should warn about both absolute links. Got: {result:?}"
);
assert!(
result[0].message.contains("cannot be validated locally"),
"Warning should explain why: {}",
result[0].message
);
assert!(
result[0].message.contains("/api/v1/users"),
"Warning should include the link path"
);
}
#[test]
fn test_absolute_links_warn_images() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let config = MD057Config {
absolute_links: AbsoluteLinksOption::Warn,
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = r#"# Images

"#;
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 warn about absolute image path. Got: {result:?}"
);
assert!(
result[0].message.contains("/assets/logo.png"),
"Warning should include the image path"
);
}
#[test]
fn test_absolute_links_warn_reference_definitions() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let config = MD057Config {
absolute_links: AbsoluteLinksOption::Warn,
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = r#"# Reference
See the [docs][ref].
[ref]: /docs/reference.md
"#;
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 warn about absolute reference definition. Got: {result:?}"
);
assert!(
result[0].message.contains("/docs/reference.md"),
"Warning should include the reference path"
);
}
#[test]
fn test_search_paths_inline_link() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let assets_dir = base_path.join("assets");
std::fs::create_dir_all(&assets_dir).unwrap();
std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
let config = MD057Config {
search_paths: vec![assets_dir.to_string_lossy().into_owned()],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = "# Test\n\n[Photo](photo.png)\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should find photo.png via search-paths. Got: {result:?}"
);
}
#[test]
fn test_search_paths_image() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let assets_dir = base_path.join("attachments");
std::fs::create_dir_all(&assets_dir).unwrap();
std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
let config = MD057Config {
search_paths: vec![assets_dir.to_string_lossy().into_owned()],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = "# Test\n\n\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should find diagram.svg via search-paths. Got: {result:?}"
);
}
#[test]
fn test_search_paths_reference_definition() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let assets_dir = base_path.join("images");
std::fs::create_dir_all(&assets_dir).unwrap();
std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
let config = MD057Config {
search_paths: vec![assets_dir.to_string_lossy().into_owned()],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should find logo.png via search-paths in reference definition. Got: {result:?}"
);
}
#[test]
fn test_search_paths_still_warns_when_truly_missing() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let assets_dir = base_path.join("assets");
std::fs::create_dir_all(&assets_dir).unwrap();
let config = MD057Config {
search_paths: vec![assets_dir.to_string_lossy().into_owned()],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = "# Test\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,
"Should still warn when file doesn't exist in any search path. Got: {result:?}"
);
}
#[test]
fn test_search_paths_nonexistent_directory() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let config = MD057Config {
search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = "# Test\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,
"Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
);
}
#[test]
fn test_obsidian_attachment_folder_named() {
let temp_dir = tempdir().unwrap();
let vault = temp_dir.path().join("vault");
std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
std::fs::create_dir_all(vault.join("Attachments")).unwrap();
std::fs::create_dir_all(vault.join("notes")).unwrap();
std::fs::write(
vault.join(".obsidian/app.json"),
r#"{"attachmentFolderPath": "Attachments"}"#,
)
.unwrap();
std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
let notes_dir = vault.join("notes");
let source_file = notes_dir.join("test.md");
std::fs::write(&source_file, "# Test\n\n\n").unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
let content = "# Test\n\n\n";
let ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Obsidian attachment folder should resolve photo.png. Got: {result:?}"
);
}
#[test]
fn test_obsidian_attachment_same_folder_as_file() {
let temp_dir = tempdir().unwrap();
let vault = temp_dir.path().join("vault-rf");
std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
std::fs::create_dir_all(vault.join("notes")).unwrap();
std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
let notes_dir = vault.join("notes");
let source_file = notes_dir.join("test.md");
std::fs::write(&source_file, "placeholder").unwrap();
std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
let content = "# Test\n\n\n";
let ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
);
}
#[test]
fn test_obsidian_not_triggered_without_obsidian_flavor() {
let temp_dir = tempdir().unwrap();
let vault = temp_dir.path().join("vault-nf");
std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
std::fs::create_dir_all(vault.join("Attachments")).unwrap();
std::fs::create_dir_all(vault.join("notes")).unwrap();
std::fs::write(
vault.join(".obsidian/app.json"),
r#"{"attachmentFolderPath": "Attachments"}"#,
)
.unwrap();
std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
let notes_dir = vault.join("notes");
let source_file = notes_dir.join("test.md");
std::fs::write(&source_file, "placeholder").unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
let content = "# Test\n\n\n";
let ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
);
}
#[test]
fn test_search_paths_combined_with_obsidian() {
let temp_dir = tempdir().unwrap();
let vault = temp_dir.path().join("vault-combo");
std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
std::fs::create_dir_all(vault.join("Attachments")).unwrap();
std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
std::fs::create_dir_all(vault.join("notes")).unwrap();
std::fs::write(
vault.join(".obsidian/app.json"),
r#"{"attachmentFolderPath": "Attachments"}"#,
)
.unwrap();
std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
let notes_dir = vault.join("notes");
let source_file = notes_dir.join("test.md");
std::fs::write(&source_file, "placeholder").unwrap();
let extra_assets_dir = vault.join("extra-assets");
let config = MD057Config {
search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
let content = "# Test\n\n\n\n\n";
let ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
);
}
#[test]
fn test_obsidian_attachment_subfolder_under_file() {
let temp_dir = tempdir().unwrap();
let vault = temp_dir.path().join("vault-sub");
std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
std::fs::write(
vault.join(".obsidian/app.json"),
r#"{"attachmentFolderPath": "./assets"}"#,
)
.unwrap();
std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
let notes_dir = vault.join("notes");
let source_file = notes_dir.join("test.md");
std::fs::write(&source_file, "placeholder").unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
let content = "# Test\n\n\n";
let ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
);
}
#[test]
fn test_obsidian_attachment_vault_root() {
let temp_dir = tempdir().unwrap();
let vault = temp_dir.path().join("vault-root");
std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
std::fs::create_dir_all(vault.join("notes")).unwrap();
std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
std::fs::write(vault.join("photo.png"), "fake").unwrap();
let notes_dir = vault.join("notes");
let source_file = notes_dir.join("test.md");
std::fs::write(&source_file, "placeholder").unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
let content = "# Test\n\n\n";
let ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
);
}
#[test]
fn test_search_paths_multiple_directories() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let dir_a = base_path.join("dir-a");
let dir_b = base_path.join("dir-b");
std::fs::create_dir_all(&dir_a).unwrap();
std::fs::create_dir_all(&dir_b).unwrap();
std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
let config = MD057Config {
search_paths: vec![
dir_a.to_string_lossy().into_owned(),
dir_b.to_string_lossy().into_owned(),
],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
let content = "# Test\n\n\n\n\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"Should find files across multiple search paths. Got: {result:?}"
);
}
#[test]
fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let file_path = base_path.join("README.md");
let content = "# Readme\n\n[Guide](missing-guide.md)\n";
std::fs::write(&file_path, content).unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
let ctx = crate::lint_context::LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
Some(file_path.clone()),
);
let per_file = rule.check(&ctx).unwrap();
assert_eq!(
per_file.len(),
1,
"control: check() is the pass that reports the broken link. Got: {per_file:?}"
);
let mut file_index = FileIndex::default();
file_index.cross_file_links.push(CrossFileLinkIndex {
target_path: "missing-guide.md".to_string(),
fragment: String::new(),
line: 3,
column: 1,
origin: LinkOrigin::Body,
});
let result = rule
.cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
.unwrap();
assert!(
result.is_empty(),
"cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
);
}
#[test]
fn test_check_clears_stale_cache() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let phantom_path = base_path.join("phantom.md");
{
let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
cache.insert(phantom_path.clone(), true);
}
let content = "[phantom](phantom.md)\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings = rule.check(&ctx).unwrap();
assert_eq!(
warnings.len(),
1,
"check() should report missing file after clearing stale cache. Got: {warnings:?}"
);
assert!(warnings[0].message.contains("phantom.md"));
}
#[test]
fn test_check_does_not_carry_over_cache_between_runs() {
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let content = "[missing](nonexistent.md)\n";
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let warnings_1 = rule.check(&ctx).unwrap();
assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
let nonexistent_path = base_path.join("nonexistent.md");
{
let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
cache.insert(nonexistent_path.clone(), true);
}
let warnings_2 = rule.check(&ctx).unwrap();
assert_eq!(
warnings_2.len(),
1,
"Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
);
}
#[test]
fn test_no_duplicate_warnings_for_broken_relative_link() {
use crate::workspace_index::WorkspaceIndex;
let temp_dir = tempdir().unwrap();
let base_path = temp_dir.path();
let source_file = base_path.join("index.md");
std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
let content = "[broken](does/not/exist.md)\n";
let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
let ctx = crate::lint_context::LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
Some(source_file.clone()),
);
let check_warnings = rule.check(&ctx).unwrap();
let mut file_index = FileIndex::new();
rule.contribute_to_index(&ctx, &mut file_index);
let workspace_index = WorkspaceIndex::new();
let cross_warnings = rule
.cross_file_check(&source_file, &file_index, &workspace_index)
.unwrap();
let total = check_warnings.len() + cross_warnings.len();
assert_eq!(
total, 1,
"Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
check={check_warnings:?}, cross={cross_warnings:?}"
);
}
#[test]
fn test_absolute_dir_link_accepted_relative_to_roots() {
let temp_dir = tempdir().unwrap();
let root = temp_dir.path();
let dir_d = root.join("d");
std::fs::create_dir_all(&dir_d).unwrap();
std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
let content = "\
[absolute dir](/d)\n\
[relative dir](d)\n\
[absolute file](/d/foo.md)\n\
[relative file](d/foo.md)\n";
let config = MD057Config {
absolute_links: AbsoluteLinksOption::RelativeToRoots,
roots: vec![],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert!(
result.is_empty(),
"All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
);
}
#[test]
fn test_absolute_trailing_slash_dir_link_requires_index() {
let temp_dir = tempdir().unwrap();
let root = temp_dir.path();
let dir_d = root.join("d");
std::fs::create_dir_all(&dir_d).unwrap();
std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
let content = "[dir with slash](/d/)\n";
let config = MD057Config {
absolute_links: AbsoluteLinksOption::RelativeToRoots,
roots: vec![],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
);
}
#[test]
fn test_docs_dir_variant_still_enforces_index_md() {
let temp_dir = tempdir().unwrap();
let root = temp_dir.path();
std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
let docs_dir = root.join("docs");
std::fs::create_dir_all(&docs_dir).unwrap();
let section_dir = docs_dir.join("section");
std::fs::create_dir_all(§ion_dir).unwrap();
std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
let source_file = docs_dir.join("index.md");
std::fs::write(&source_file, "[sec](/section)\n").unwrap();
let config = MD057Config {
absolute_links: AbsoluteLinksOption::RelativeToDocs,
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
let content = "[sec](/section)\n";
let ctx = crate::lint_context::LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
Some(source_file.clone()),
);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
);
assert!(
result[0].message.contains("index.md") || result[0].message.contains("section"),
"Message should mention the directory or missing index.md: {}",
result[0].message
);
}
#[test]
fn test_trailing_slash_with_fragment_treated_as_directory_link() {
let temp_dir = tempdir().unwrap();
let root = temp_dir.path();
let guide_dir = root.join("guide");
std::fs::create_dir_all(&guide_dir).unwrap();
std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
let content = "[guide with fragment](/guide/#intro)\n";
let config = MD057Config {
absolute_links: AbsoluteLinksOption::RelativeToRoots,
roots: vec![],
..Default::default()
};
let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
let result = rule.check(&ctx).unwrap();
assert_eq!(
result.len(),
1,
"Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
);
}
}
#[cfg(test)]
mod self_referential_links_tests {
use super::*;
use tempfile::tempdir;
fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
let source_file = dir.join(name);
std::fs::write(&source_file, content).unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(config);
let ctx =
crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
rule.check(&ctx).unwrap()
}
fn enabled() -> MD057Config {
MD057Config {
self_referential_links: true,
..Default::default()
}
}
#[test]
fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
assert_eq!(
result[0].message,
"Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
);
let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
assert_eq!(fix.replacement, "#level-2-heading");
assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
}
#[test]
fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [this file](test.md).\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
assert!(
result[0].fix.is_none(),
"Dropping the link would change the document, so there is no fix"
);
}
#[test]
fn test_the_check_is_off_by_default() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
assert!(result.is_empty(), "Off by default. Got: {result:?}");
}
#[test]
fn test_a_link_to_another_file_is_left_alone() {
let temp_dir = tempdir().unwrap();
std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
}
#[test]
fn test_a_self_link_written_with_traversal_reports_once() {
let temp_dir = tempdir().unwrap();
let sub_dir = temp_dir.path().join("sub");
std::fs::create_dir_all(&sub_dir).unwrap();
let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
let config = MD057Config {
self_referential_links: true,
compact_paths: true,
..Default::default()
};
let result = check_as_file(&sub_dir, "test.md", content, config);
assert_eq!(
result.len(),
1,
"A compacted path would still be a link back to this file. Got: {result:?}"
);
assert_eq!(
result[0].message,
"Relative link '../sub/test.md' points to the file it is in"
);
}
#[test]
fn test_compact_paths_still_reports_a_link_to_another_file() {
let temp_dir = tempdir().unwrap();
let sub_dir = temp_dir.path().join("sub");
std::fs::create_dir_all(&sub_dir).unwrap();
std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
let config = MD057Config {
self_referential_links: true,
compact_paths: true,
..Default::default()
};
let result = check_as_file(&sub_dir, "test.md", content, config);
assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
assert_eq!(
result[0].message,
"Relative link '../sub/other.md' can be simplified to 'other.md'"
);
}
#[test]
fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [this file](test#title).\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
assert_eq!(
result[0].fix.as_ref().map(|f| f.replacement.as_str()),
Some("#title"),
"Got: {result:?}"
);
}
#[test]
fn test_a_reference_definition_pointing_at_its_own_file() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
assert_eq!(fix.replacement, "#level-2-heading");
assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
}
#[test]
fn test_a_reference_definition_whose_label_repeats_the_destination() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
.fix(&crate::lint_context::LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
Some(temp_dir.path().join("test.md")),
))
.unwrap();
assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
}
#[test]
fn test_a_self_link_resolved_through_a_search_path() {
let temp_dir = tempdir().unwrap();
let guide_dir = temp_dir.path().join("docs/guide");
std::fs::create_dir_all(&guide_dir).unwrap();
let config = MD057Config {
search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
..enabled()
};
let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
let result = check_as_file(&guide_dir, "test.md", content, config);
assert_eq!(
result.len(),
1,
"A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
);
assert_eq!(
result[0].fix.as_ref().map(|f| f.replacement.as_str()),
Some("#title"),
"Got: {result:?}"
);
}
#[test]
fn test_a_target_next_to_the_document_outranks_a_search_path() {
let temp_dir = tempdir().unwrap();
let guide_dir = temp_dir.path().join("docs/guide");
std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
let config = MD057Config {
search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
..enabled()
};
let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
let result = check_as_file(&guide_dir, "test.md", content, config);
assert!(
result.is_empty(),
"The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
);
}
#[test]
fn test_an_image_pointing_at_its_own_file_is_not_reported() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\n\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert!(
result.is_empty(),
"An image is not a link the reader follows. Got: {result:?}"
);
}
#[test]
fn test_a_query_string_is_reported_without_a_suggestion() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
assert!(
result[0].fix.is_none(),
"A query does not survive losing its path. Got: {result:?}"
);
}
#[test]
fn test_fix_rewrites_the_document_and_settles() {
let temp_dir = tempdir().unwrap();
let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
let source_file = temp_dir.path().join("test.md");
std::fs::write(&source_file, content).unwrap();
let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
let ctx = crate::lint_context::LintContext::new(
content,
crate::config::MarkdownFlavor::Standard,
Some(source_file.clone()),
);
let fixed = rule.fix(&ctx).unwrap();
assert_eq!(
fixed,
"# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
);
let refixed =
crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
}
#[test]
fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
let unfixable = MD057ExistingRelativeLinks::default();
assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
}
#[test]
fn test_the_option_is_read_from_kebab_and_snake_case() {
let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
assert!(kebab.self_referential_links);
let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
assert!(snake.self_referential_links);
}
fn front_matter_checked() -> MD057Config {
MD057Config {
check_frontmatter: true,
..Default::default()
}
}
#[test]
fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
let temp_dir = tempdir().unwrap();
let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
assert_eq!(result[0].line, 2);
assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
assert_eq!(result[0].end_column, 23);
}
#[test]
fn test_frontmatter_paths_are_not_checked_by_default() {
let temp_dir = tempdir().unwrap();
let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
assert!(
result.is_empty(),
"Frontmatter is only checked on request. Got: {result:?}"
);
}
#[test]
fn test_an_existing_frontmatter_path_is_not_reported() {
let temp_dir = tempdir().unwrap();
std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
assert_eq!(result[0].line, 3);
}
#[test]
fn test_an_ignored_frontmatter_field_is_not_checked() {
let temp_dir = tempdir().unwrap();
let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
let config = MD057Config {
check_frontmatter: true,
ignore_frontmatter_fields: vec!["Image".to_string()],
..Default::default()
};
let result = check_as_file(temp_dir.path(), "test.md", content, config);
assert_eq!(
result.len(),
1,
"The ignored field is skipped and the other is not. Got: {result:?}"
);
assert_eq!(result[0].line, 3);
}
#[test]
fn test_an_external_frontmatter_url_is_not_reported() {
let temp_dir = tempdir().unwrap();
let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
assert!(
result.is_empty(),
"An external URL has no local target. Got: {result:?}"
);
}
#[test]
fn test_a_frontmatter_fragment_is_left_to_md051() {
let temp_dir = tempdir().unwrap();
let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
assert!(
result.is_empty(),
"A fragment names a heading, not a file. Got: {result:?}"
);
}
#[test]
fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
let temp_dir = tempdir().unwrap();
let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
assert!(
ignored.is_empty(),
"Absolute paths are ignored by default. Got: {ignored:?}"
);
let warning_config = MD057Config {
check_frontmatter: true,
absolute_links: AbsoluteLinksOption::Warn,
..Default::default()
};
let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
assert_eq!(
warned[0].message,
"Absolute link '/docs/guide.md' cannot be validated locally"
);
}
#[test]
fn test_a_frontmatter_path_carrying_a_query_is_checked() {
let temp_dir = tempdir().unwrap();
std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
assert_eq!(
result.len(),
1,
"A query names no file, so only the missing target is reported. Got: {result:?}"
);
assert_eq!(result[0].line, 2);
assert_eq!(
result[0].message,
"Relative link 'docs/missing.md?raw=true' does not exist"
);
}
#[test]
fn test_prose_in_frontmatter_is_not_read_as_a_path() {
let temp_dir = tempdir().unwrap();
let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
assert!(
result.is_empty(),
"Only path-shaped values are destinations. Got: {result:?}"
);
}
}