Skip to main content

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