1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
//! What counts as a blank line for the rules that require blank lines around a
//! construct (MD022, MD031, MD032, MD058).
//!
//! Those rules exist so a reader can see where one block ends and the next
//! begins, and a line holding nothing but an HTML comment separates two blocks
//! as plainly as an empty one does. CommonMark disagrees - a comment is an HTML
//! block of type 2 that ends on the line carrying `-->`, so the construct below
//! it really does start with no blank line between them - but reporting that
//! costs more than it is worth: the fix inserts a blank line, and for a
//! directive comment (`<!-- prettier-ignore -->`, `<!-- markdownlint-disable
//! -->`, a generator's own trigger) adjacency *is* the meaning, so rewriting the
//! document turns the directive off. rumdl treats such a line as blank instead.
//! (#866)
//!
//! The odd corners are deliberate and match markdownlint's `isBlankLine`, whose
//! convention this is: an unclosed `<!--` and a bare `-->` each count, which is
//! what makes a comment spanning several lines work, and `>` characters are
//! removed so the whole convention holds inside a blockquote.
/// Whether a line contributes nothing but HTML comments, and so separates the
/// blocks around it the way an empty line does.
///
/// A line with any other content is not blank, however little: `text <!-- c -->`
/// is a paragraph, and a construct written directly below it is missing its
/// blank line.
pub fn is_blank_or_comment_only(line: &str) -> bool {
// Only the retained characters matter. Removing comments and `>` cannot
// make a non-whitespace retained character disappear, so inspect source
// slices directly instead of constructing and rewriting an owned string.
let blank = |text: &str| text.chars().all(|ch| ch == '>' || ch.is_whitespace());
let mut remaining = line;
loop {
let open = remaining.find("<!--");
let close = remaining.find("-->");
match (open, close) {
(None, None) => return blank(remaining),
(Some(open), None) => return blank(&remaining[..open]),
(None, Some(close)) => remaining = &remaining[close + 3..],
(Some(open), Some(close)) if close < open => remaining = &remaining[close + 3..],
(Some(open), Some(close)) => {
if !blank(&remaining[..open]) {
return false;
}
remaining = &remaining[close + 3..];
}
}
}
}
/// Remove every HTML comment from a line, tolerating a comment that opens
/// without closing and a close marker with no opener.
///
/// An unpaired marker takes everything on its side of the line with it: an
/// opener swallows the rest of the line (the comment continues below), a closer
/// swallows the start of it (the comment began above). That is what lets a
/// comment spanning several lines count as blank at both ends.
#[cfg(test)]
fn remove_comments(line: &str) -> String {
const OPEN: &str = "<!--";
const CLOSE: &str = "-->";
let mut remaining = line;
let mut kept = String::new();
loop {
let open = remaining.find(OPEN);
let close = remaining.find(CLOSE);
match (open, close) {
(None, None) => {
kept.push_str(remaining);
return kept;
}
// An opener with no closer: the comment continues onto the next line,
// so it takes the rest of this one.
(Some(open), None) => {
kept.push_str(&remaining[..open]);
return kept;
}
// A closer with no opener before it: the comment started on an
// earlier line, so everything up to and including it is inside it.
(None, Some(close)) => {
remaining = &remaining[close + CLOSE.len()..];
}
(Some(open), Some(close)) if close < open => {
remaining = &remaining[close + CLOSE.len()..];
}
// A complete comment: drop it and carry on with what is left.
(Some(open), Some(close)) => {
kept.push_str(&remaining[..open]);
remaining = &remaining[close + CLOSE.len()..];
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
proptest::proptest! {
#[test]
fn borrowed_blank_query_matches_the_owned_reading(parts in proptest::collection::vec(
proptest::prop_oneof![
proptest::strategy::Just("<!--".to_string()),
proptest::strategy::Just("-->".to_string()),
proptest::strategy::Just(">".to_string()),
proptest::strategy::Just(" \t\u{a0}\u{2003}".to_string()),
".{0,64}",
], 0..64,
)) {
let source = parts.concat();
let expected = remove_comments(&source).replace('>', "").trim().is_empty();
proptest::prop_assert_eq!(is_blank_or_comment_only(&source), expected, "{:?}", source);
}
}
#[test]
fn an_empty_line_is_blank() {
assert!(is_blank_or_comment_only(""));
assert!(is_blank_or_comment_only(" "));
assert!(is_blank_or_comment_only("\t"));
}
#[test]
fn a_comment_only_line_is_blank() {
assert!(is_blank_or_comment_only("<!-- c -->"));
assert!(is_blank_or_comment_only(" <!-- prettier-ignore --> "));
assert!(is_blank_or_comment_only("<!-- a --><!-- b -->"));
}
#[test]
fn an_unpaired_marker_is_blank_so_a_multi_line_comment_works_at_both_ends() {
assert!(is_blank_or_comment_only("<!--"));
assert!(is_blank_or_comment_only("-->"));
assert!(is_blank_or_comment_only("<!-- opens here"));
assert!(is_blank_or_comment_only("closes here -->"));
}
#[test]
fn the_convention_holds_inside_a_blockquote() {
assert!(is_blank_or_comment_only("> <!-- c -->"));
assert!(is_blank_or_comment_only(">> <!-- c -->"));
assert!(is_blank_or_comment_only(">"));
}
#[test]
fn a_line_carrying_anything_else_is_not_blank() {
assert!(!is_blank_or_comment_only("text"));
assert!(!is_blank_or_comment_only("text <!-- c -->"));
assert!(!is_blank_or_comment_only("<!-- c --> text"));
assert!(!is_blank_or_comment_only("<!-- a --> x <!-- b -->"));
assert!(!is_blank_or_comment_only("> text"));
assert!(!is_blank_or_comment_only("-->text<!--"));
}
}