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