Skip to main content

rumdl_lib/rules/
md086_no_unclosed_comments.rs

1//! Rule MD086: Comment delimiters must be closed.
2//!
3//! An opener with no closer does not fail loudly. `<!--` with no `-->` after it
4//! is an HTML block that runs to the end of the document, so every heading,
5//! list and paragraph below it disappears from the rendered page while the
6//! source still looks complete. Mid-paragraph the failure inverts: CommonMark
7//! renders the unmatched `<!--` as literal text, so the note the author meant to
8//! hide is published instead.
9//!
10//! Either way no other rule reports the missing closer, and `rumdl fmt` will
11//! not add one, so the document lints clean without this rule. The only visible
12//! symptom is content that stops appearing on the rendered page.
13//!
14//! In the Obsidian flavor the same applies to `%%`, whose closer is another
15//! `%%`. Other flavors treat `%%` as ordinary text and are not checked for it.
16//!
17//! A degenerate `<!-->` or `<!--->` is a complete comment in CommonMark (the
18//! opener's own dashes close it) and is not reported.
19//!
20//! Detection only. Where a missing `-->` belongs is a guess: appending one at
21//! the end of the document would comment out everything the author meant to
22//! publish, and inserting it after the first line would hide nothing but assume
23//! the comment was a one-liner.
24
25use crate::lint_context::LintContext;
26use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
27
28/// A comment syntax whose opener was never closed.
29struct UnclosedComment {
30    /// Byte offset of the opener.
31    offset: usize,
32    /// The opener as written, which is also its length in characters.
33    opener: &'static str,
34    /// The closer the document is missing.
35    closer: &'static str,
36    /// Name of the comment syntax, for the message.
37    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        // Not `Html`: that category is skipped for content without a `<`, which
77        // would drop every Obsidian `%%` comment.
78        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        // Both scanners run during context construction and each reports its
87        // first unclosed opener.
88        //
89        // An opener the other syntax hides is already gone by this point, in
90        // both directions. The HTML scan is re-resolved against the Obsidian
91        // comments when the context is built, and an unclosed `<!--` that opens
92        // an HTML block covers the rest of that block, so a `%%` inside it is
93        // never scanned as a delimiter.
94        //
95        // Both therefore report an opener only where it is a real one, and a
96        // document with two of them genuinely has two.
97        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        // Detection only: any inserted closer would decide for the author which
121        // part of the document was meant to be hidden.
122        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        // CommonMark closes these with the opener's own dashes, so the text
178        // after them renders and the document has no unclosed comment.
179        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        // Here CommonMark publishes the marker as literal text rather than
195        // hiding what follows, but the author still wrote a comment that is not
196        // one.
197        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        // The closed range ends at the end of the content, exactly like an
252        // unclosed one would, so this is what tells the two apart.
253        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        // Obsidian hides everything from an unclosed `%%` to the end of the
265        // note, so the `<!--` on line 3 is text inside that comment rather than
266        // a second unclosed opener.
267        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        // The reverse does not hold: mid-paragraph CommonMark renders `<!--` as
277        // literal text, so it hides nothing and the `%%` below it is its own
278        // problem. Reporting only the first would lose it.
279        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        // A line-start `<!--` opens an HTML block, so the `%%` below it is
291        // comment text rather than a delimiter. Closing the block is the one
292        // edit to make, and the `%%` may well be a `%%` the author wrote inside
293        // the comment on purpose.
294        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        // The unclosed block ends with the blockquote, so the `%%` after it is
304        // outside the comment and is its own missing closer.
305        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        // The first `%%` is inside the unclosed block, so it is comment text and
317        // cannot close anything. That leaves the `%%` below the blockquote an
318        // opener in its own right rather than the pair's closer.
319        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        // The hidden `%%` shares its line with the `<!--`, so no whole-line flag
331        // marks it as commented out. The comment still starts before it, which
332        // is what decides whether it is a delimiter.
333        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        // A closed comment hides its own text just as an unclosed one does, and
345        // it can open and close partway along a line.
346        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        // The HTML scan runs first, so it pairs the hidden `<!--` with the
356        // `-->` two lines down and reports a comment covering the closing `%%`.
357        // The `%%` opens before that `<!--`, so it wins and the pair is closed.
358        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        // Obsidian hides the text between the `%%` pair, so the `<!--` there is
366        // never a comment opener.
367        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        // On its own line the `<!--` would open an HTML block, but Obsidian
375        // strips the `%%` pair before that can happen. Treating it as an opener
376        // swallows the closing `%%` and turns a closed comment into an unclosed
377        // one, hiding the visible text below it from every rule.
378        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        // Suppressing the hidden opener must resume the search rather than end
386        // it: the opener on line 5 is the one the author has to close.
387        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        // `<!--` in a YAML value is data, not a delimiter, and renderers strip
397        // front matter before parsing markdown at all.
398        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        // The parser reports a real indented code block, so the `<!--` is sample
420        // text that opens nothing and closes nothing.
421        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        // The body is markdown at a 4-space indent, not code, so the missing
428        // closer is a real one.
429        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}