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 name(&self) -> &'static str {
68 "MD086"
69 }
70
71 fn description(&self) -> &'static str {
72 "Comments should be closed"
73 }
74
75 fn category(&self) -> RuleCategory {
76 RuleCategory::Other
79 }
80
81 fn should_skip(&self, ctx: &LintContext) -> bool {
82 ctx.unterminated_html_comment().is_none() && ctx.unterminated_obsidian_comment().is_none()
83 }
84
85 fn check(&self, ctx: &LintContext) -> LintResult {
86 let html = ctx.unterminated_html_comment().map(|offset| UnclosedComment {
98 offset,
99 opener: "<!--",
100 closer: "-->",
101 syntax: "HTML",
102 });
103 let obsidian = ctx.unterminated_obsidian_comment().map(|offset| UnclosedComment {
104 offset,
105 opener: "%%",
106 closer: "%%",
107 syntax: "Obsidian",
108 });
109 let mut unclosed: Vec<UnclosedComment> = [html, obsidian].into_iter().flatten().collect();
110 unclosed.sort_by_key(|c| c.offset);
111
112 Ok(unclosed.iter().map(|c| self.warning(ctx, c)).collect())
113 }
114
115 fn fix_capability(&self) -> FixCapability {
116 FixCapability::Unfixable
117 }
118
119 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
120 Ok(ctx.content.to_string())
123 }
124
125 fn as_any(&self) -> &dyn std::any::Any {
126 self
127 }
128
129 fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
130 where
131 Self: Sized,
132 {
133 Box::new(Self)
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::config::MarkdownFlavor;
141
142 fn check_with(content: &str, flavor: MarkdownFlavor) -> Vec<LintWarning> {
143 let ctx = LintContext::new(content, flavor, None);
144 MD086NoUnclosedComments::new().check(&ctx).unwrap()
145 }
146
147 fn check(content: &str) -> Vec<LintWarning> {
148 check_with(content, MarkdownFlavor::Standard)
149 }
150
151 #[test]
152 fn reports_an_html_comment_that_is_never_closed() {
153 let content = "# Title\n\n<!-- a note that never ends\n\n## Section\n";
154 let warnings = check(content);
155 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
156 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
157 assert_eq!(warnings[0].end_column, 5, "the warning spans the opener");
158 assert_eq!(
159 warnings[0].message,
160 "Unclosed HTML comment: '<!--' has no matching '-->'"
161 );
162 assert!(warnings[0].fix.is_none(), "the closer's place is a guess");
163 }
164
165 #[test]
166 fn accepts_a_closed_html_comment() {
167 assert!(check("# Title\n\n<!-- a note -->\n\n## Section\n").is_empty());
168 }
169
170 #[test]
171 fn accepts_a_multi_line_html_comment() {
172 assert!(check("<!--\nline one\nline two\n-->\n\nText\n").is_empty());
173 }
174
175 #[test]
176 fn accepts_degenerate_comments() {
177 for content in ["<!--> text\n", "<!---> text\n", "<!----> text\n"] {
180 assert!(check(content).is_empty(), "{content:?} is a complete comment");
181 }
182 }
183
184 #[test]
185 fn reports_an_unclosed_opener_after_a_closed_comment() {
186 let content = "<!-- first -->\n\nText\n\n<!-- second\n";
187 let warnings = check(content);
188 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
189 assert_eq!((warnings[0].line, warnings[0].column), (5, 1));
190 }
191
192 #[test]
193 fn reports_an_unclosed_opener_inside_a_paragraph() {
194 let content = "Some prose <!-- an aside\n\nMore prose.\n";
198 let warnings = check(content);
199 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
200 assert_eq!((warnings[0].line, warnings[0].column), (1, 12));
201 }
202
203 #[test]
204 fn ignores_an_opener_inside_a_fenced_code_block() {
205 let content = "```html\n<!-- sample markup\n```\n\nText\n";
206 assert!(check(content).is_empty(), "code shows delimiters, it does not use them");
207 }
208
209 #[test]
210 fn ignores_an_opener_inside_a_code_span() {
211 assert!(check("An opener is written `<!--` in HTML.\n").is_empty());
212 }
213
214 #[test]
215 fn reports_a_real_opener_that_follows_a_literal_one() {
216 let content = "An opener is written `<!--` in HTML.\n\n<!-- and here is a real one\n";
217 let warnings = check(content);
218 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
219 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
220 }
221
222 #[test]
223 fn columns_count_characters_not_bytes() {
224 let content = "Работа <!-- заметка\n";
225 let warnings = check(content);
226 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
227 assert_eq!((warnings[0].line, warnings[0].column), (1, 8));
228 assert_eq!(warnings[0].end_column, 12);
229 }
230
231 #[test]
232 fn reports_an_unclosed_obsidian_comment() {
233 let content = "# Title\n\n%% a note that never ends\n\n## Section\n";
234 let warnings = check_with(content, MarkdownFlavor::Obsidian);
235 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
236 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
237 assert_eq!(warnings[0].end_column, 3, "the warning spans the opener");
238 assert_eq!(
239 warnings[0].message,
240 "Unclosed Obsidian comment: '%%' has no matching '%%'"
241 );
242 }
243
244 #[test]
245 fn accepts_a_closed_obsidian_comment() {
246 assert!(check_with("Text %% a note %% more text\n", MarkdownFlavor::Obsidian).is_empty());
247 }
248
249 #[test]
250 fn accepts_an_obsidian_comment_closing_at_the_end_of_the_document() {
251 assert!(check_with("Text %% a note %%", MarkdownFlavor::Obsidian).is_empty());
254 }
255
256 #[test]
257 fn ignores_obsidian_comments_outside_the_obsidian_flavor() {
258 let content = "# Title\n\n%% a note that never ends\n";
259 assert!(check(content).is_empty(), "%% is ordinary text in other flavors");
260 }
261
262 #[test]
263 fn ignores_an_html_opener_inside_an_unclosed_obsidian_comment() {
264 let content = "%% an Obsidian note\n\n<!-- an HTML note\n";
268 let warnings = check_with(content, MarkdownFlavor::Obsidian);
269 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
270 assert_eq!(warnings[0].line, 1);
271 assert!(warnings[0].message.contains("Obsidian"));
272 }
273
274 #[test]
275 fn reports_an_obsidian_opener_below_an_unclosed_inline_html_opener() {
276 let content = "Some prose <!-- an aside\n\n%% an Obsidian note\n";
280 let warnings = check_with(content, MarkdownFlavor::Obsidian);
281 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
282 assert_eq!((warnings[0].line, warnings[0].column), (1, 12));
283 assert!(warnings[0].message.contains("HTML"));
284 assert_eq!((warnings[1].line, warnings[1].column), (3, 1));
285 assert!(warnings[1].message.contains("Obsidian"));
286 }
287
288 #[test]
289 fn ignores_an_obsidian_opener_inside_an_unclosed_html_block() {
290 let content = "<!-- an aside\n\n%% an Obsidian note\n";
295 let warnings = check_with(content, MarkdownFlavor::Obsidian);
296 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
297 assert_eq!((warnings[0].line, warnings[0].column), (1, 1));
298 assert!(warnings[0].message.contains("HTML"));
299 }
300
301 #[test]
302 fn reports_an_obsidian_opener_after_an_html_block_that_ends_at_its_container() {
303 let content = "> <!-- an aside\n> inside\n\n%% an Obsidian note\n";
306 let warnings = check_with(content, MarkdownFlavor::Obsidian);
307 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
308 assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
309 assert!(warnings[0].message.contains("HTML"));
310 assert_eq!((warnings[1].line, warnings[1].column), (4, 1));
311 assert!(warnings[1].message.contains("Obsidian"));
312 }
313
314 #[test]
315 fn reports_an_obsidian_opener_that_only_a_hidden_delimiter_appeared_to_close() {
316 let content = "> <!-- an aside\n> %% hidden\n\n%% a note\n";
320 let warnings = check_with(content, MarkdownFlavor::Obsidian);
321 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
322 assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
323 assert!(warnings[0].message.contains("HTML"));
324 assert_eq!((warnings[1].line, warnings[1].column), (4, 1));
325 assert!(warnings[1].message.contains("Obsidian"));
326 }
327
328 #[test]
329 fn reports_an_obsidian_opener_a_delimiter_beside_the_html_opener_appeared_to_close() {
330 let content = "> <!-- an aside %% hidden\n\n%% a note\n";
334 let warnings = check_with(content, MarkdownFlavor::Obsidian);
335 assert_eq!(warnings.len(), 2, "got: {warnings:?}");
336 assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
337 assert!(warnings[0].message.contains("HTML"));
338 assert_eq!((warnings[1].line, warnings[1].column), (3, 1));
339 assert!(warnings[1].message.contains("Obsidian"));
340 }
341
342 #[test]
343 fn reports_an_obsidian_opener_a_delimiter_inside_a_closed_comment_appeared_to_close() {
344 let content = "text <!-- %% --> tail\n\n%% a note\n";
347 let warnings = check_with(content, MarkdownFlavor::Obsidian);
348 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
349 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
350 assert!(warnings[0].message.contains("Obsidian"));
351 }
352
353 #[test]
354 fn ignores_an_html_comment_a_closed_obsidian_pair_opened_and_a_later_one_closed() {
355 let content = "%% note <!-- hidden %%\n\n<!-- closed -->\n\nVisible text.\n";
359 let warnings = check_with(content, MarkdownFlavor::Obsidian);
360 assert!(warnings.is_empty(), "got: {warnings:?}");
361 }
362
363 #[test]
364 fn ignores_an_html_opener_inside_a_closed_obsidian_comment() {
365 let content = "# Title\n\n%% note <!-- marker %%\n\nVisible text.\n";
368 let warnings = check_with(content, MarkdownFlavor::Obsidian);
369 assert!(warnings.is_empty(), "got: {warnings:?}");
370 }
371
372 #[test]
373 fn ignores_a_line_start_html_opener_inside_a_closed_obsidian_comment() {
374 let content = "%% note\n<!-- hidden\n%%\n\nVisible text.\n";
379 let warnings = check_with(content, MarkdownFlavor::Obsidian);
380 assert!(warnings.is_empty(), "got: {warnings:?}");
381 }
382
383 #[test]
384 fn reports_a_real_opener_below_one_hidden_in_an_obsidian_comment() {
385 let content = "%% note <!-- marker %%\n\n<!-- a genuinely unclosed one\n";
388 let warnings = check_with(content, MarkdownFlavor::Obsidian);
389 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
390 assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
391 assert!(warnings[0].message.contains("HTML"));
392 }
393
394 #[test]
395 fn ignores_an_opener_in_front_matter() {
396 let content = "---\nauthor: \"a <!-- b\"\n---\n\n# Title\n";
399 assert!(check(content).is_empty(), "got: {:?}", check(content));
400 }
401
402 #[test]
403 fn ignores_an_obsidian_opener_in_front_matter() {
404 let content = "---\ntitle: \"50%% off\"\n---\n\n# Title\n";
405 let warnings = check_with(content, MarkdownFlavor::Obsidian);
406 assert!(warnings.is_empty(), "got: {warnings:?}");
407 }
408
409 #[test]
410 fn reports_a_body_opener_below_front_matter_holding_one() {
411 let content = "---\nauthor: \"a <!-- b\"\n---\n\n# Title\n\n<!-- a real one\n";
412 let warnings = check(content);
413 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
414 assert_eq!((warnings[0].line, warnings[0].column), (7, 1));
415 }
416
417 #[test]
418 fn ignores_an_opener_inside_an_indented_code_block() {
419 let content = "Intro text.\n\n <!-- a sample opener\n\nAfter.\n";
422 assert!(check(content).is_empty(), "got: {:?}", check(content));
423 }
424
425 #[test]
426 fn reports_an_opener_in_an_admonition_body() {
427 let content = "!!! note\n <!-- a note that never ends\n more text\n";
430 let warnings = check_with(content, MarkdownFlavor::MkDocs);
431 assert_eq!(warnings.len(), 1, "got: {warnings:?}");
432 assert_eq!((warnings[0].line, warnings[0].column), (2, 5));
433 }
434
435 #[test]
436 fn accepts_a_document_with_no_comments() {
437 assert!(check("# Title\n\nJust prose.\n").is_empty());
438 }
439
440 #[test]
441 fn fix_leaves_the_document_alone() {
442 let content = "# Title\n\n<!-- a note that never ends\n";
443 let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
444 let rule = MD086NoUnclosedComments::new();
445 assert_eq!(rule.fix(&ctx).unwrap(), content);
446 assert_eq!(rule.fix_capability(), FixCapability::Unfixable);
447 }
448}