rumdl_lib/rules/
md068_empty_footnote_definition.rs1use 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
27static 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 fn has_continuation_content(&self, ctx: &crate::lint_context::LintContext, def_line_idx: usize) -> bool {
44 for next_idx in (def_line_idx + 1)..ctx.lines.len() {
46 if let Some(next_line_info) = ctx.lines.get(next_idx) {
47 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 if next_line_info.in_code_block
61 && !next_stripped.starts_with(" ")
62 && !next_stripped.starts_with('\t')
63 {
64 continue;
66 }
67
68 if next_stripped.trim().is_empty() {
70 continue;
71 }
72
73 if next_stripped.starts_with(" ") || next_stripped.starts_with('\t') {
75 return true;
76 }
77
78 if FOOTNOTE_DEF_PATTERN.is_match(next_stripped) {
80 return false;
81 }
82
83 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 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 if !FOOTNOTE_DEF_PATTERN.is_match(line_stripped) {
119 continue;
120 }
121
122 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 if content.trim().is_empty() {
129 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::Error,
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 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 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 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}