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