rumdl_lib/rules/
md068_empty_footnote_definition.rs

1//! MD068: Footnote definitions should not be empty
2//!
3//! This rule flags footnote definitions that have no content,
4//! which is almost always a mistake.
5//!
6//! ## Example
7//!
8//! ### Incorrect
9//! ```markdown
10//! Text with [^1] reference.
11//!
12//! [^1]:
13//! ```
14//!
15//! ### Correct
16//! ```markdown
17//! Text with [^1] reference.
18//!
19//! [^1]: This is the footnote content.
20//! ```
21
22use crate::rule::{LintError, LintResult, LintWarning, Rule, Severity};
23use crate::rules::md066_footnote_validation::{FOOTNOTE_DEF_PATTERN, strip_blockquote_prefix};
24use regex::Regex;
25use std::sync::LazyLock;
26
27/// Pattern to match a complete footnote definition line and capture its content
28/// Group 1: footnote ID
29/// Group 2: content after the colon (may be empty or whitespace-only)
30static FOOTNOTE_DEF_WITH_CONTENT: LazyLock<Regex> =
31    LazyLock::new(|| Regex::new(r"^[ ]{0,3}\[\^([^\]]+)\]:(.*)$").unwrap());
32
33#[derive(Debug, Default, Clone)]
34pub struct MD068EmptyFootnoteDefinition;
35
36impl MD068EmptyFootnoteDefinition {
37    pub fn new() -> Self {
38        Self
39    }
40
41    /// Check if a footnote definition has continuation content on subsequent lines
42    /// Multi-line footnotes have indented continuation paragraphs
43    fn has_continuation_content(&self, ctx: &crate::lint_context::LintContext, def_line_idx: usize) -> bool {
44        // Look at subsequent lines for indented content
45        for next_idx in (def_line_idx + 1)..ctx.lines.len() {
46            if let Some(next_line_info) = ctx.lines.get(next_idx) {
47                // Skip frontmatter, HTML comments, and HTML blocks
48                if next_line_info.in_front_matter || next_line_info.in_html_comment || next_line_info.in_html_block {
49                    continue;
50                }
51
52                let next_line = next_line_info.content(ctx.content);
53                let next_stripped = strip_blockquote_prefix(next_line);
54
55                // NOTE: We intentionally do NOT skip in_code_block blindly because
56                // footnote continuation uses 4-space indentation, which LintContext
57                // interprets as an indented code block. We check the stripped content
58                // to see if it's a legitimate continuation (starts with 4 spaces).
59                // If in_code_block but doesn't start with indentation, it's a fenced code block.
60                if next_line_info.in_code_block
61                    && !next_stripped.starts_with("    ")
62                    && !next_stripped.starts_with('\t')
63                {
64                    // This is a fenced code block, not an indented continuation
65                    continue;
66                }
67
68                // Empty line - could be paragraph break in multi-line footnote
69                if next_stripped.trim().is_empty() {
70                    continue;
71                }
72
73                // If next non-empty line starts with indentation, it's a continuation
74                if next_stripped.starts_with("    ") || next_stripped.starts_with('\t') {
75                    return true;
76                }
77
78                // If it's another footnote definition, the current one has no continuation
79                if FOOTNOTE_DEF_PATTERN.is_match(next_stripped) {
80                    return false;
81                }
82
83                // Non-indented, non-footnote content means no continuation
84                return false;
85            }
86        }
87
88        false
89    }
90}
91
92impl Rule for MD068EmptyFootnoteDefinition {
93    fn name(&self) -> &'static str {
94        "MD068"
95    }
96
97    fn description(&self) -> &'static str {
98        "Footnote definitions should not be empty"
99    }
100
101    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
102        let mut warnings = Vec::new();
103
104        for (line_idx, line_info) in ctx.lines.iter().enumerate() {
105            // Skip special contexts
106            if line_info.in_code_block
107                || line_info.in_front_matter
108                || line_info.in_html_comment
109                || line_info.in_html_block
110            {
111                continue;
112            }
113
114            let line = line_info.content(ctx.content);
115            let line_stripped = strip_blockquote_prefix(line);
116
117            // Check if this is a footnote definition
118            if !FOOTNOTE_DEF_PATTERN.is_match(line_stripped) {
119                continue;
120            }
121
122            // Extract the content after the colon
123            if let Some(caps) = FOOTNOTE_DEF_WITH_CONTENT.captures(line_stripped) {
124                let id = caps.get(1).map(|m| m.as_str()).unwrap_or("");
125                let content = caps.get(2).map(|m| m.as_str()).unwrap_or("");
126
127                // Check if content is empty or whitespace-only
128                if content.trim().is_empty() {
129                    // Check if this is a multi-line footnote (next line is indented continuation)
130                    let has_continuation = self.has_continuation_content(ctx, line_idx);
131
132                    if !has_continuation {
133                        warnings.push(LintWarning {
134                            rule_name: Some(self.name().to_string()),
135                            line: line_idx + 1,
136                            column: 1,
137                            end_line: line_idx + 1,
138                            end_column: line.len() + 1,
139                            message: format!("Footnote definition '[^{id}]' is empty"),
140                            severity: Severity::Warning,
141                            fix: None,
142                        });
143                    }
144                }
145            }
146        }
147
148        Ok(warnings)
149    }
150
151    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
152        // Can't auto-fix - we don't know what content should be
153        Ok(ctx.content.to_string())
154    }
155
156    fn as_any(&self) -> &dyn std::any::Any {
157        self
158    }
159
160    fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
161    where
162        Self: Sized,
163    {
164        Box::new(MD068EmptyFootnoteDefinition)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::LintContext;
172
173    fn check(content: &str) -> Vec<LintWarning> {
174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
175        MD068EmptyFootnoteDefinition::new().check(&ctx).unwrap()
176    }
177
178    #[test]
179    fn test_non_empty_definition() {
180        let content = r#"Text with [^1].
181
182[^1]: This has content.
183"#;
184        let warnings = check(content);
185        assert!(warnings.is_empty());
186    }
187
188    #[test]
189    fn test_empty_definition() {
190        let content = r#"Text with [^1].
191
192[^1]:
193"#;
194        let warnings = check(content);
195        assert_eq!(warnings.len(), 1);
196        assert!(warnings[0].message.contains("empty"));
197        assert!(warnings[0].message.contains("[^1]"));
198    }
199
200    #[test]
201    fn test_whitespace_only_definition() {
202        let content = "Text with [^1].\n\n[^1]:   \n";
203        let warnings = check(content);
204        assert_eq!(warnings.len(), 1);
205        assert!(warnings[0].message.contains("empty"));
206    }
207
208    #[test]
209    fn test_multi_line_footnote() {
210        // Using explicit string to ensure proper spacing
211        let content = "Text with [^1].\n\n[^1]:\n    This is the content.\n";
212        let warnings = check(content);
213        assert!(
214            warnings.is_empty(),
215            "Multi-line footnotes with continuation are valid: {warnings:?}"
216        );
217    }
218
219    #[test]
220    fn test_multi_paragraph_footnote() {
221        let content = "Text with [^1].\n\n[^1]:\n    First paragraph.\n\n    Second paragraph.\n";
222        let warnings = check(content);
223        assert!(warnings.is_empty(), "Multi-paragraph footnotes: {warnings:?}");
224    }
225
226    #[test]
227    fn test_multiple_empty_definitions() {
228        let content = r#"Text with [^1] and [^2].
229
230[^1]:
231[^2]:
232"#;
233        let warnings = check(content);
234        assert_eq!(warnings.len(), 2);
235    }
236
237    #[test]
238    fn test_mixed_empty_and_non_empty() {
239        let content = r#"Text with [^1] and [^2].
240
241[^1]: Has content
242[^2]:
243"#;
244        let warnings = check(content);
245        assert_eq!(warnings.len(), 1);
246        assert!(warnings[0].message.contains("[^2]"));
247    }
248
249    #[test]
250    fn test_skip_code_blocks() {
251        let content = r#"Text.
252
253```
254[^1]:
255```
256"#;
257        let warnings = check(content);
258        assert!(warnings.is_empty());
259    }
260
261    #[test]
262    fn test_blockquote_empty_definition() {
263        let content = r#"> Text with [^1].
264>
265> [^1]:
266"#;
267        let warnings = check(content);
268        assert_eq!(warnings.len(), 1);
269    }
270
271    #[test]
272    fn test_blockquote_with_continuation() {
273        // Using explicit string for clarity
274        let content = "> Text with [^1].\n>\n> [^1]:\n>     Content on next line.\n";
275        let warnings = check(content);
276        assert!(warnings.is_empty(), "Blockquote with continuation: {warnings:?}");
277    }
278
279    #[test]
280    fn test_named_footnote_empty() {
281        let content = r#"Text with [^note].
282
283[^note]:
284"#;
285        let warnings = check(content);
286        assert_eq!(warnings.len(), 1);
287        assert!(warnings[0].message.contains("[^note]"));
288    }
289
290    #[test]
291    fn test_content_after_colon_space() {
292        let content = r#"Text with [^1].
293
294[^1]: Content here
295"#;
296        let warnings = check(content);
297        assert!(warnings.is_empty());
298    }
299}