1use crate::lint_context::LintContext;
26use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
27
28struct UnclosedComment {
30 offset: usize,
32 opener: &'static str,
34 closer: &'static str,
36 syntax: &'static str,
38}
39
40#[derive(Debug, Clone, Default)]
41pub struct MD086NoUnclosedComments;
42
43impl MD086NoUnclosedComments {
44 pub fn new() -> Self {
45 Self
46 }
47
48 fn warning(&self, ctx: &LintContext, unclosed: &UnclosedComment) -> LintWarning {
49 let (line, column) = ctx.offset_to_line_col(unclosed.offset);
50 LintWarning {
51 rule_name: Some(self.name().to_string()),
52 severity: Severity::Warning,
53 line,
54 column,
55 end_line: line,
56 end_column: column + unclosed.opener.chars().count(),
57 message: format!(
58 "Unclosed {} comment: '{}' has no matching '{}'",
59 unclosed.syntax, unclosed.opener, unclosed.closer
60 ),
61 fix: None,
62 }
63 }
64}
65
66impl Rule for MD086NoUnclosedComments {
67 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
68 Some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(86))
69 }
70
71 fn name(&self) -> &'static str {
72 "MD086"
73 }
74
75 fn description(&self) -> &'static str {
76 "Comments should be closed"
77 }
78
79 fn category(&self) -> RuleCategory {
80 RuleCategory::Other
83 }
84
85 fn should_skip(&self, ctx: &LintContext) -> bool {
86 ctx.unterminated_html_comment().is_none() && ctx.unterminated_obsidian_comment().is_none()
87 }
88
89 fn check(&self, ctx: &LintContext) -> LintResult {
90 let html = ctx.unterminated_html_comment().map(|offset| UnclosedComment {
102 offset,
103 opener: "<!--",
104 closer: "-->",
105 syntax: "HTML",
106 });
107 let obsidian = ctx.unterminated_obsidian_comment().map(|offset| UnclosedComment {
108 offset,
109 opener: "%%",
110 closer: "%%",
111 syntax: "Obsidian",
112 });
113 let mut unclosed: Vec<UnclosedComment> = [html, obsidian].into_iter().flatten().collect();
114 unclosed.sort_by_key(|c| c.offset);
115
116 Ok(unclosed.iter().map(|c| self.warning(ctx, c)).collect())
117 }
118
119 fn fix_capability(&self) -> FixCapability {
120 FixCapability::Unfixable
121 }
122
123 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
124 Ok(ctx.content.to_string())
127 }
128
129 fn as_any(&self) -> &dyn std::any::Any {
130 self
131 }
132
133 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
134 where
135 Self: Sized,
136 {
137 Box::new(Self)
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use crate::config::MarkdownFlavor;
145
146 fn check_with(content: &str, flavor: MarkdownFlavor) -> Vec<LintWarning> {
147 let ctx = LintContext::new(content, flavor, None);
148 MD086NoUnclosedComments::new().check(&ctx).unwrap()
149 }
150
151 fn check(content: &str) -> Vec<LintWarning> {
152 check_with(content, MarkdownFlavor::Standard)
153 }
154
155 #[test]
156 fn reports_an_html_comment_that_is_never_closed() {
157 let content = "# Title\n\n<!-- a note that never ends\n\n## Section\n";
158 let warnings = check(content);
159 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
160 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
161 assert_eq!(warnings[0].end_column, 5, "the warning spans the opener");
162 assert_eq!(
163 warnings[0].message,
164 "Unclosed HTML comment: '<!--' has no matching '-->'"
165 );
166 assert!(warnings[0].fix.is_none(), "the closer's place is a guess");
167 }
168
169 #[test]
170 fn accepts_a_closed_html_comment() {
171 assert!(check("# Title\n\n<!-- a note -->\n\n## Section\n").is_empty());
172 }
173
174 #[test]
175 fn accepts_a_multi_line_html_comment() {
176 assert!(check("<!--\nline one\nline two\n-->\n\nText\n").is_empty());
177 }
178
179 #[test]
180 fn accepts_degenerate_comments() {
181 for content in ["<!--> text\n", "<!---> text\n", "<!----> text\n"] {
184 assert!(check(content).is_empty(), "{content:?} is a complete comment");
185 }
186 }
187
188 #[test]
189 fn reports_an_unclosed_opener_after_a_closed_comment() {
190 let content = "<!-- first -->\n\nText\n\n<!-- second\n";
191 let warnings = check(content);
192 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
193 assert_eq!((warnings[0].line, warnings[0].column), (5, 1));
194 }
195
196 #[test]
197 fn reports_an_unclosed_opener_inside_a_paragraph() {
198 let content = "Some prose <!-- an aside\n\nMore prose.\n";
202 let warnings = check(content);
203 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
204 assert_eq!((warnings[0].line, warnings[0].column), (1, 12));
205 }
206
207 #[test]
208 fn ignores_an_opener_inside_a_fenced_code_block() {
209 let content = "```html\n<!-- sample markup\n```\n\nText\n";
210 assert!(check(content).is_empty(), "code shows delimiters, it does not use them");
211 }
212
213 #[test]
214 fn ignores_an_opener_inside_a_code_span() {
215 assert!(check("An opener is written `<!--` in HTML.\n").is_empty());
216 }
217
218 #[test]
219 fn reports_a_real_opener_that_follows_a_literal_one() {
220 let content = "An opener is written `<!--` in HTML.\n\n<!-- and here is a real one\n";
221 let warnings = check(content);
222 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
223 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
224 }
225
226 #[test]
227 fn columns_count_characters_not_bytes() {
228 let content = "Работа <!-- заметка\n";
229 let warnings = check(content);
230 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
231 assert_eq!((warnings[0].line, warnings[0].column), (1, 8));
232 assert_eq!(warnings[0].end_column, 12);
233 }
234
235 #[test]
236 fn reports_an_unclosed_obsidian_comment() {
237 let content = "# Title\n\n%% a note that never ends\n\n## Section\n";
238 let warnings = check_with(content, MarkdownFlavor::Obsidian);
239 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
240 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
241 assert_eq!(warnings[0].end_column, 3, "the warning spans the opener");
242 assert_eq!(
243 warnings[0].message,
244 "Unclosed Obsidian comment: '%%' has no matching '%%'"
245 );
246 }
247
248 #[test]
249 fn accepts_a_closed_obsidian_comment() {
250 assert!(check_with("Text %% a note %% more text\n", MarkdownFlavor::Obsidian).is_empty());
251 }
252
253 #[test]
254 fn accepts_an_obsidian_comment_closing_at_the_end_of_the_document() {
255 assert!(check_with("Text %% a note %%", MarkdownFlavor::Obsidian).is_empty());
258 }
259
260 #[test]
261 fn ignores_obsidian_comments_outside_the_obsidian_flavor() {
262 let content = "# Title\n\n%% a note that never ends\n";
263 assert!(check(content).is_empty(), "%% is ordinary text in other flavors");
264 }
265
266 #[test]
267 fn ignores_an_html_opener_inside_an_unclosed_obsidian_comment() {
268 let content = "%% an Obsidian note\n\n<!-- an HTML note\n";
272 let warnings = check_with(content, MarkdownFlavor::Obsidian);
273 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
274 assert_eq!(warnings[0].line, 1);
275 assert!(warnings[0].message.contains("Obsidian"));
276 }
277
278 #[test]
279 fn reports_an_obsidian_opener_below_an_unclosed_inline_html_opener() {
280 let content = "Some prose <!-- an aside\n\n%% an Obsidian note\n";
284 let warnings = check_with(content, MarkdownFlavor::Obsidian);
285 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
286 assert_eq!((warnings[0].line, warnings[0].column), (1, 12));
287 assert!(warnings[0].message.contains("HTML"));
288 assert_eq!((warnings[1].line, warnings[1].column), (3, 1));
289 assert!(warnings[1].message.contains("Obsidian"));
290 }
291
292 #[test]
293 fn ignores_an_obsidian_opener_inside_an_unclosed_html_block() {
294 let content = "<!-- an aside\n\n%% an Obsidian note\n";
299 let warnings = check_with(content, MarkdownFlavor::Obsidian);
300 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
301 assert_eq!((warnings[0].line, warnings[0].column), (1, 1));
302 assert!(warnings[0].message.contains("HTML"));
303 }
304
305 #[test]
306 fn reports_an_obsidian_opener_after_an_html_block_that_ends_at_its_container() {
307 let content = "> <!-- an aside\n> inside\n\n%% an Obsidian note\n";
310 let warnings = check_with(content, MarkdownFlavor::Obsidian);
311 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
312 assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
313 assert!(warnings[0].message.contains("HTML"));
314 assert_eq!((warnings[1].line, warnings[1].column), (4, 1));
315 assert!(warnings[1].message.contains("Obsidian"));
316 }
317
318 #[test]
319 fn reports_an_obsidian_opener_that_only_a_hidden_delimiter_appeared_to_close() {
320 let content = "> <!-- an aside\n> %% hidden\n\n%% a note\n";
324 let warnings = check_with(content, MarkdownFlavor::Obsidian);
325 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
326 assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
327 assert!(warnings[0].message.contains("HTML"));
328 assert_eq!((warnings[1].line, warnings[1].column), (4, 1));
329 assert!(warnings[1].message.contains("Obsidian"));
330 }
331
332 #[test]
333 fn reports_an_obsidian_opener_a_delimiter_beside_the_html_opener_appeared_to_close() {
334 let content = "> <!-- an aside %% hidden\n\n%% a note\n";
338 let warnings = check_with(content, MarkdownFlavor::Obsidian);
339 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
340 assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
341 assert!(warnings[0].message.contains("HTML"));
342 assert_eq!((warnings[1].line, warnings[1].column), (3, 1));
343 assert!(warnings[1].message.contains("Obsidian"));
344 }
345
346 #[test]
347 fn reports_an_obsidian_opener_a_delimiter_inside_a_closed_comment_appeared_to_close() {
348 let content = "text <!-- %% --> tail\n\n%% a note\n";
351 let warnings = check_with(content, MarkdownFlavor::Obsidian);
352 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
353 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
354 assert!(warnings[0].message.contains("Obsidian"));
355 }
356
357 #[test]
358 fn ignores_an_html_comment_a_closed_obsidian_pair_opened_and_a_later_one_closed() {
359 let content = "%% note <!-- hidden %%\n\n<!-- closed -->\n\nVisible text.\n";
363 let warnings = check_with(content, MarkdownFlavor::Obsidian);
364 assert!(warnings.is_empty(), "got: {warnings:?}");
365 }
366
367 #[test]
368 fn ignores_an_html_opener_inside_a_closed_obsidian_comment() {
369 let content = "# Title\n\n%% note <!-- marker %%\n\nVisible text.\n";
372 let warnings = check_with(content, MarkdownFlavor::Obsidian);
373 assert!(warnings.is_empty(), "got: {warnings:?}");
374 }
375
376 #[test]
377 fn ignores_a_line_start_html_opener_inside_a_closed_obsidian_comment() {
378 let content = "%% note\n<!-- hidden\n%%\n\nVisible text.\n";
383 let warnings = check_with(content, MarkdownFlavor::Obsidian);
384 assert!(warnings.is_empty(), "got: {warnings:?}");
385 }
386
387 #[test]
388 fn reports_a_real_opener_below_one_hidden_in_an_obsidian_comment() {
389 let content = "%% note <!-- marker %%\n\n<!-- a genuinely unclosed one\n";
392 let warnings = check_with(content, MarkdownFlavor::Obsidian);
393 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
394 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
395 assert!(warnings[0].message.contains("HTML"));
396 }
397
398 #[test]
399 fn ignores_an_opener_in_front_matter() {
400 let content = "---\nauthor: \"a <!-- b\"\n---\n\n# Title\n";
403 assert!(check(content).is_empty(), "got: {:?}", check(content));
404 }
405
406 #[test]
407 fn ignores_an_obsidian_opener_in_front_matter() {
408 let content = "---\ntitle: \"50%% off\"\n---\n\n# Title\n";
409 let warnings = check_with(content, MarkdownFlavor::Obsidian);
410 assert!(warnings.is_empty(), "got: {warnings:?}");
411 }
412
413 #[test]
414 fn reports_a_body_opener_below_front_matter_holding_one() {
415 let content = "---\nauthor: \"a <!-- b\"\n---\n\n# Title\n\n<!-- a real one\n";
416 let warnings = check(content);
417 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
418 assert_eq!((warnings[0].line, warnings[0].column), (7, 1));
419 }
420
421 #[test]
422 fn ignores_an_opener_inside_an_indented_code_block() {
423 let content = "Intro text.\n\n <!-- a sample opener\n\nAfter.\n";
426 assert!(check(content).is_empty(), "got: {:?}", check(content));
427 }
428
429 #[test]
430 fn reports_an_opener_in_an_admonition_body() {
431 let content = "!!! note\n <!-- a note that never ends\n more text\n";
434 let warnings = check_with(content, MarkdownFlavor::MkDocs);
435 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
436 assert_eq!((warnings[0].line, warnings[0].column), (2, 5));
437 }
438
439 #[test]
440 fn accepts_a_document_with_no_comments() {
441 assert!(check("# Title\n\nJust prose.\n").is_empty());
442 }
443
444 #[test]
445 fn fix_leaves_the_document_alone() {
446 let content = "# Title\n\n<!-- a note that never ends\n";
447 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
448 let rule = MD086NoUnclosedComments::new();
449 assert_eq!(rule.fix(&ctx).unwrap(), content);
450 assert_eq!(rule.fix_capability(), FixCapability::Unfixable);
451 }
452}