use crate::lint_context::LintContext;
use crate::rule::{FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
struct UnclosedComment {
offset: usize,
opener: &'static str,
closer: &'static str,
syntax: &'static str,
}
#[derive(Debug, Clone, Default)]
pub struct MD086NoUnclosedComments;
impl MD086NoUnclosedComments {
pub fn new() -> Self {
Self
}
fn warning(&self, ctx: &LintContext, unclosed: &UnclosedComment) -> LintWarning {
let (line, column) = ctx.offset_to_line_col(unclosed.offset);
LintWarning {
rule_name: Some(self.name().to_string()),
severity: Severity::Warning,
line,
column,
end_line: line,
end_column: column + unclosed.opener.chars().count(),
message: format!(
"Unclosed {} comment: '{}' has no matching '{}'",
unclosed.syntax, unclosed.opener, unclosed.closer
),
fix: None,
}
}
}
impl Rule for MD086NoUnclosedComments {
fn name(&self) -> &'static str {
"MD086"
}
fn description(&self) -> &'static str {
"Comments should be closed"
}
fn category(&self) -> RuleCategory {
RuleCategory::Other
}
fn should_skip(&self, ctx: &LintContext) -> bool {
ctx.unterminated_html_comment().is_none() && ctx.unterminated_obsidian_comment().is_none()
}
fn check(&self, ctx: &LintContext) -> LintResult {
let html = ctx.unterminated_html_comment().map(|offset| UnclosedComment {
offset,
opener: "<!--",
closer: "-->",
syntax: "HTML",
});
let obsidian = ctx.unterminated_obsidian_comment().map(|offset| UnclosedComment {
offset,
opener: "%%",
closer: "%%",
syntax: "Obsidian",
});
let mut unclosed: Vec<UnclosedComment> = [html, obsidian].into_iter().flatten().collect();
unclosed.sort_by_key(|c| c.offset);
Ok(unclosed.iter().map(|c| self.warning(ctx, c)).collect())
}
fn fix_capability(&self) -> FixCapability {
FixCapability::Unfixable
}
fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
Ok(ctx.content.to_string())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn from_config(_config: &crate::config::Config) -> Box<dyn Rule>
where
Self: Sized,
{
Box::new(Self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
fn check_with(content: &str, flavor: MarkdownFlavor) -> Vec<LintWarning> {
let ctx = LintContext::new(content, flavor, None);
MD086NoUnclosedComments::new().check(&ctx).unwrap()
}
fn check(content: &str) -> Vec<LintWarning> {
check_with(content, MarkdownFlavor::Standard)
}
#[test]
fn reports_an_html_comment_that_is_never_closed() {
let content = "# Title\n\n<!-- a note that never ends\n\n## Section\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
assert_eq!(warnings[0].end_column, 5, "the warning spans the opener");
assert_eq!(
warnings[0].message,
"Unclosed HTML comment: '<!--' has no matching '-->'"
);
assert!(warnings[0].fix.is_none(), "the closer's place is a guess");
}
#[test]
fn accepts_a_closed_html_comment() {
assert!(check("# Title\n\n<!-- a note -->\n\n## Section\n").is_empty());
}
#[test]
fn accepts_a_multi_line_html_comment() {
assert!(check("<!--\nline one\nline two\n-->\n\nText\n").is_empty());
}
#[test]
fn accepts_degenerate_comments() {
for content in ["<!--> text\n", "<!---> text\n", "<!----> text\n"] {
assert!(check(content).is_empty(), "{content:?} is a complete comment");
}
}
#[test]
fn reports_an_unclosed_opener_after_a_closed_comment() {
let content = "<!-- first -->\n\nText\n\n<!-- second\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (5, 1));
}
#[test]
fn reports_an_unclosed_opener_inside_a_paragraph() {
let content = "Some prose <!-- an aside\n\nMore prose.\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (1, 12));
}
#[test]
fn ignores_an_opener_inside_a_fenced_code_block() {
let content = "```html\n<!-- sample markup\n```\n\nText\n";
assert!(check(content).is_empty(), "code shows delimiters, it does not use them");
}
#[test]
fn ignores_an_opener_inside_a_code_span() {
assert!(check("An opener is written `<!--` in HTML.\n").is_empty());
}
#[test]
fn reports_a_real_opener_that_follows_a_literal_one() {
let content = "An opener is written `<!--` in HTML.\n\n<!-- and here is a real one\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
}
#[test]
fn columns_count_characters_not_bytes() {
let content = "Работа <!-- заметка\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (1, 8));
assert_eq!(warnings[0].end_column, 12);
}
#[test]
fn reports_an_unclosed_obsidian_comment() {
let content = "# Title\n\n%% a note that never ends\n\n## Section\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
assert_eq!(warnings[0].end_column, 3, "the warning spans the opener");
assert_eq!(
warnings[0].message,
"Unclosed Obsidian comment: '%%' has no matching '%%'"
);
}
#[test]
fn accepts_a_closed_obsidian_comment() {
assert!(check_with("Text %% a note %% more text\n", MarkdownFlavor::Obsidian).is_empty());
}
#[test]
fn accepts_an_obsidian_comment_closing_at_the_end_of_the_document() {
assert!(check_with("Text %% a note %%", MarkdownFlavor::Obsidian).is_empty());
}
#[test]
fn ignores_obsidian_comments_outside_the_obsidian_flavor() {
let content = "# Title\n\n%% a note that never ends\n";
assert!(check(content).is_empty(), "%% is ordinary text in other flavors");
}
#[test]
fn ignores_an_html_opener_inside_an_unclosed_obsidian_comment() {
let content = "%% an Obsidian note\n\n<!-- an HTML note\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!(warnings[0].line, 1);
assert!(warnings[0].message.contains("Obsidian"));
}
#[test]
fn reports_an_obsidian_opener_below_an_unclosed_inline_html_opener() {
let content = "Some prose <!-- an aside\n\n%% an Obsidian note\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 2, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (1, 12));
assert!(warnings[0].message.contains("HTML"));
assert_eq!((warnings[1].line, warnings[1].column), (3, 1));
assert!(warnings[1].message.contains("Obsidian"));
}
#[test]
fn ignores_an_obsidian_opener_inside_an_unclosed_html_block() {
let content = "<!-- an aside\n\n%% an Obsidian note\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (1, 1));
assert!(warnings[0].message.contains("HTML"));
}
#[test]
fn reports_an_obsidian_opener_after_an_html_block_that_ends_at_its_container() {
let content = "> <!-- an aside\n> inside\n\n%% an Obsidian note\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 2, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
assert!(warnings[0].message.contains("HTML"));
assert_eq!((warnings[1].line, warnings[1].column), (4, 1));
assert!(warnings[1].message.contains("Obsidian"));
}
#[test]
fn reports_an_obsidian_opener_that_only_a_hidden_delimiter_appeared_to_close() {
let content = "> <!-- an aside\n> %% hidden\n\n%% a note\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 2, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
assert!(warnings[0].message.contains("HTML"));
assert_eq!((warnings[1].line, warnings[1].column), (4, 1));
assert!(warnings[1].message.contains("Obsidian"));
}
#[test]
fn reports_an_obsidian_opener_a_delimiter_beside_the_html_opener_appeared_to_close() {
let content = "> <!-- an aside %% hidden\n\n%% a note\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 2, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (1, 3));
assert!(warnings[0].message.contains("HTML"));
assert_eq!((warnings[1].line, warnings[1].column), (3, 1));
assert!(warnings[1].message.contains("Obsidian"));
}
#[test]
fn reports_an_obsidian_opener_a_delimiter_inside_a_closed_comment_appeared_to_close() {
let content = "text <!-- %% --> tail\n\n%% a note\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
assert!(warnings[0].message.contains("Obsidian"));
}
#[test]
fn ignores_an_html_comment_a_closed_obsidian_pair_opened_and_a_later_one_closed() {
let content = "%% note <!-- hidden %%\n\n<!-- closed -->\n\nVisible text.\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert!(warnings.is_empty(), "got: {warnings:?}");
}
#[test]
fn ignores_an_html_opener_inside_a_closed_obsidian_comment() {
let content = "# Title\n\n%% note <!-- marker %%\n\nVisible text.\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert!(warnings.is_empty(), "got: {warnings:?}");
}
#[test]
fn ignores_a_line_start_html_opener_inside_a_closed_obsidian_comment() {
let content = "%% note\n<!-- hidden\n%%\n\nVisible text.\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert!(warnings.is_empty(), "got: {warnings:?}");
}
#[test]
fn reports_a_real_opener_below_one_hidden_in_an_obsidian_comment() {
let content = "%% note <!-- marker %%\n\n<!-- a genuinely unclosed one\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (3, 1));
assert!(warnings[0].message.contains("HTML"));
}
#[test]
fn ignores_an_opener_in_front_matter() {
let content = "---\nauthor: \"a <!-- b\"\n---\n\n# Title\n";
assert!(check(content).is_empty(), "got: {:?}", check(content));
}
#[test]
fn ignores_an_obsidian_opener_in_front_matter() {
let content = "---\ntitle: \"50%% off\"\n---\n\n# Title\n";
let warnings = check_with(content, MarkdownFlavor::Obsidian);
assert!(warnings.is_empty(), "got: {warnings:?}");
}
#[test]
fn reports_a_body_opener_below_front_matter_holding_one() {
let content = "---\nauthor: \"a <!-- b\"\n---\n\n# Title\n\n<!-- a real one\n";
let warnings = check(content);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (7, 1));
}
#[test]
fn ignores_an_opener_inside_an_indented_code_block() {
let content = "Intro text.\n\n <!-- a sample opener\n\nAfter.\n";
assert!(check(content).is_empty(), "got: {:?}", check(content));
}
#[test]
fn reports_an_opener_in_an_admonition_body() {
let content = "!!! note\n <!-- a note that never ends\n more text\n";
let warnings = check_with(content, MarkdownFlavor::MkDocs);
assert_eq!(warnings.len(), 1, "got: {warnings:?}");
assert_eq!((warnings[0].line, warnings[0].column), (2, 5));
}
#[test]
fn accepts_a_document_with_no_comments() {
assert!(check("# Title\n\nJust prose.\n").is_empty());
}
#[test]
fn fix_leaves_the_document_alone() {
let content = "# Title\n\n<!-- a note that never ends\n";
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
let rule = MD086NoUnclosedComments::new();
assert_eq!(rule.fix(&ctx).unwrap(), content);
assert_eq!(rule.fix_capability(), FixCapability::Unfixable);
}
}