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 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        // Not `Html`: that category is skipped for content without a `<`, which
81        // would drop every Obsidian `%%` comment.
82        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        // Both scanners run during context construction and each reports its
91        // first unclosed opener.
92        //
93        // An opener the other syntax hides is already gone by this point, in
94        // both directions. The HTML scan is re-resolved against the Obsidian
95        // comments when the context is built, and an unclosed `<!--` that opens
96        // an HTML block covers the rest of that block, so a `%%` inside it is
97        // never scanned as a delimiter.
98        //
99        // Both therefore report an opener only where it is a real one, and a
100        // document with two of them genuinely has two.
101        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        // Detection only: any inserted closer would decide for the author which
125        // part of the document was meant to be hidden.
126        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        // CommonMark closes these with the opener's own dashes, so the text
182        // after them renders and the document has no unclosed comment.
183        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        // Here CommonMark publishes the marker as literal text rather than
199        // hiding what follows, but the author still wrote a comment that is not
200        // one.
201        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        // The closed range ends at the end of the content, exactly like an
256        // unclosed one would, so this is what tells the two apart.
257        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        // Obsidian hides everything from an unclosed `%%` to the end of the
269        // note, so the `<!--` on line 3 is text inside that comment rather than
270        // a second unclosed opener.
271        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        // The reverse does not hold: mid-paragraph CommonMark renders `<!--` as
281        // literal text, so it hides nothing and the `%%` below it is its own
282        // problem. Reporting only the first would lose it.
283        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        // A line-start `<!--` opens an HTML block, so the `%%` below it is
295        // comment text rather than a delimiter. Closing the block is the one
296        // edit to make, and the `%%` may well be a `%%` the author wrote inside
297        // the comment on purpose.
298        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        // The unclosed block ends with the blockquote, so the `%%` after it is
308        // outside the comment and is its own missing closer.
309        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        // The first `%%` is inside the unclosed block, so it is comment text and
321        // cannot close anything. That leaves the `%%` below the blockquote an
322        // opener in its own right rather than the pair's closer.
323        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        // The hidden `%%` shares its line with the `<!--`, so no whole-line flag
335        // marks it as commented out. The comment still starts before it, which
336        // is what decides whether it is a delimiter.
337        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        // A closed comment hides its own text just as an unclosed one does, and
349        // it can open and close partway along a line.
350        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        // The HTML scan runs first, so it pairs the hidden `<!--` with the
360        // `-->` two lines down and reports a comment covering the closing `%%`.
361        // The `%%` opens before that `<!--`, so it wins and the pair is closed.
362        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        // Obsidian hides the text between the `%%` pair, so the `<!--` there is
370        // never a comment opener.
371        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        // On its own line the `<!--` would open an HTML block, but Obsidian
379        // strips the `%%` pair before that can happen. Treating it as an opener
380        // swallows the closing `%%` and turns a closed comment into an unclosed
381        // one, hiding the visible text below it from every rule.
382        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        // Suppressing the hidden opener must resume the search rather than end
390        // it: the opener on line 5 is the one the author has to close.
391        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        // `<!--` in a YAML value is data, not a delimiter, and renderers strip
401        // front matter before parsing markdown at all.
402        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        // The parser reports a real indented code block, so the `<!--` is sample
424        // text that opens nothing and closes nothing.
425        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        // The body is markdown at a 4-space indent, not code, so the missing
432        // closer is a real one.
433        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}