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 crate::utils::element_cache::ElementCache;
25use regex::Regex;
26use std::sync::LazyLock;
27
28static 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 fn has_continuation_content(&self, ctx: &crate::lint_context::LintContext, def_line_idx: usize) -> bool {
45 for next_idx in (def_line_idx + 1)..ctx.lines.len() {
47 if let Some(next_line_info) = ctx.lines.get(next_idx) {
48 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 if next_line_info.in_code_block && ElementCache::calculate_indentation_width_default(next_stripped) < 4
62 {
63 continue;
65 }
66
67 if next_stripped.trim().is_empty() {
69 continue;
70 }
71
72 if ElementCache::calculate_indentation_width_default(next_stripped) >= 4 {
74 return true;
75 }
76
77 if FOOTNOTE_DEF_PATTERN.is_match(next_stripped) {
79 return false;
80 }
81
82 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 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 if !FOOTNOTE_DEF_PATTERN.is_match(line_stripped) {
118 continue;
119 }
120
121 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 if content.trim().is_empty() {
128 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 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 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 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}