rumdl_lib/rules/
md068_empty_footnote_definition.rs1use 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
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
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 if next_line_info.in_code_block && calculate_indentation_width_default(next_stripped) < 4 {
66 continue;
68 }
69
70 if next_stripped.trim().is_empty() {
72 continue;
73 }
74
75 if calculate_indentation_width_default(next_stripped) >= 4 {
77 return true;
78 }
79
80 if FOOTNOTE_DEF_PATTERN.is_match(next_stripped) {
82 return false;
83 }
84
85 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 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 if !FOOTNOTE_DEF_PATTERN.is_match(line_stripped) {
134 continue;
135 }
136
137 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 if content.trim().is_empty() {
144 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 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 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 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}