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 || 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 && calculate_indentation_width_default(next_stripped) < 4 {
62 continue;
64 }
65
66 if next_stripped.trim().is_empty() {
68 continue;
69 }
70
71 if calculate_indentation_width_default(next_stripped) >= 4 {
73 return true;
74 }
75
76 if FOOTNOTE_DEF_PATTERN.is_match(next_stripped) {
78 return false;
79 }
80
81 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 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 if !FOOTNOTE_DEF_PATTERN.is_match(line_stripped) {
129 continue;
130 }
131
132 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 if content.trim().is_empty() {
139 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 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 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 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}