use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
use crate::rules::md066_footnote_validation::{FOOTNOTE_DEF_PATTERN, strip_blockquote_prefix};
use crate::utils::calculate_indentation_width_default;
use regex::Regex;
use std::sync::LazyLock;
static FOOTNOTE_DEF_WITH_CONTENT: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[ ]{0,3}\[\^([^\]]+)\]:(.*)$").unwrap());
#[derive(Debug, Default, Clone)]
pub struct MD068EmptyFootnoteDefinition;
impl MD068EmptyFootnoteDefinition {
pub fn new() -> Self {
Self
}
fn has_continuation_content(&self, ctx: &crate::lint_context::LintContext, def_line_idx: usize) -> bool {
for next_idx in (def_line_idx + 1)..ctx.lines.len() {
if let Some(next_line_info) = ctx.lines.get(next_idx) {
if next_line_info.in_front_matter || next_line_info.in_html_comment || next_line_info.in_html_block {
continue;
}
let next_line = next_line_info.content(ctx.content);
let next_stripped = strip_blockquote_prefix(next_line);
if next_line_info.in_code_block && calculate_indentation_width_default(next_stripped) < 4 {
continue;
}
if next_stripped.trim().is_empty() {
continue;
}
if calculate_indentation_width_default(next_stripped) >= 4 {
return true;
}
if FOOTNOTE_DEF_PATTERN.is_match(next_stripped) {
return false;
}
return false;
}
}
false
}
}
impl Rule for MD068EmptyFootnoteDefinition {
fn name(&self) -> &'static str {
"MD068"
}
fn description(&self) -> &'static str {
"Footnote definitions should not be empty"
}
fn category(&self) -> RuleCategory {
RuleCategory::Other
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
ctx.content.is_empty() || !ctx.content.contains("[^")
}
fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
let mut warnings = Vec::new();
for (line_idx, line_info) in ctx.lines.iter().enumerate() {
if line_info.in_code_block
|| line_info.in_front_matter
|| line_info.in_html_comment
|| line_info.in_html_block
{
continue;
}
let line = line_info.content(ctx.content);
let line_stripped = strip_blockquote_prefix(line);
if !FOOTNOTE_DEF_PATTERN.is_match(line_stripped) {
continue;
}
if let Some(caps) = FOOTNOTE_DEF_WITH_CONTENT.captures(line_stripped) {
let id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let content = caps.get(2).map(|m| m.as_str()).unwrap_or("");
if content.trim().is_empty() {
let has_continuation = self.has_continuation_content(ctx, line_idx);
if !has_continuation {
warnings.push(LintWarning {
rule_name: Some(self.name().to_string()),
line: line_idx + 1,
column: 1,
end_line: line_idx + 1,
end_column: line.len() + 1,
message: format!("Footnote definition '[^{id}]' is empty"),
severity: Severity::Error,
fix: None,
});
}
}
}
}
Ok(warnings)
}
fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(MD068EmptyFootnoteDefinition)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LintContext;
fn check(content: &str) -> Vec<LintWarning> {
let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
MD068EmptyFootnoteDefinition::new().check(&ctx).unwrap()
}
#[test]
fn test_non_empty_definition() {
let content = r#"Text with [^1].
[^1]: This has content.
"#;
let warnings = check(content);
assert!(warnings.is_empty());
}
#[test]
fn test_empty_definition() {
let content = r#"Text with [^1].
[^1]:
"#;
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("empty"));
assert!(warnings[0].message.contains("[^1]"));
}
#[test]
fn test_whitespace_only_definition() {
let content = "Text with [^1].\n\n[^1]: \n";
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("empty"));
}
#[test]
fn test_multi_line_footnote() {
let content = "Text with [^1].\n\n[^1]:\n This is the content.\n";
let warnings = check(content);
assert!(
warnings.is_empty(),
"Multi-line footnotes with continuation are valid: {warnings:?}"
);
}
#[test]
fn test_multi_paragraph_footnote() {
let content = "Text with [^1].\n\n[^1]:\n First paragraph.\n\n Second paragraph.\n";
let warnings = check(content);
assert!(warnings.is_empty(), "Multi-paragraph footnotes: {warnings:?}");
}
#[test]
fn test_multiple_empty_definitions() {
let content = r#"Text with [^1] and [^2].
[^1]:
[^2]:
"#;
let warnings = check(content);
assert_eq!(warnings.len(), 2);
}
#[test]
fn test_mixed_empty_and_non_empty() {
let content = r#"Text with [^1] and [^2].
[^1]: Has content
[^2]:
"#;
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("[^2]"));
}
#[test]
fn test_skip_code_blocks() {
let content = r#"Text.
```
[^1]:
```
"#;
let warnings = check(content);
assert!(warnings.is_empty());
}
#[test]
fn test_blockquote_empty_definition() {
let content = r#"> Text with [^1].
>
> [^1]:
"#;
let warnings = check(content);
assert_eq!(warnings.len(), 1);
}
#[test]
fn test_blockquote_with_continuation() {
let content = "> Text with [^1].\n>\n> [^1]:\n> Content on next line.\n";
let warnings = check(content);
assert!(warnings.is_empty(), "Blockquote with continuation: {warnings:?}");
}
#[test]
fn test_named_footnote_empty() {
let content = r#"Text with [^note].
[^note]:
"#;
let warnings = check(content);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].message.contains("[^note]"));
}
#[test]
fn test_content_after_colon_space() {
let content = r#"Text with [^1].
[^1]: Content here
"#;
let warnings = check(content);
assert!(warnings.is_empty());
}
}