Skip to content

Commit e6ca26e

Browse files
committed
fix(md084): stop deleting line endings as invisible characters
CommonMark counts a lone carriage return as a line ending in its own right, so a document written with classic-Mac line endings is a single line to a splitter that breaks on \n and \r\n. Every carriage return in it was therefore visible to this rule, and all three default triggers reached one: the last sits at a line boundary, a doubled pair is a run of two, and one after a space is adjacent to whitespace. Each carried a removal fix, so `rumdl fmt --enable MD084` joined lines and dropped the document's final line ending; strict mode reported every carriage return and collapsed the document onto one line. Line endings are now exempt in both modes. Strict widens which hidden characters are worth reporting, which is a judgment the allow list already lets users make; it is not a licence to restructure the document. A carriage return still draws no glyph, so it keeps counting toward a run and toward the whitespace trigger, and a zero-width space pressed against one is still reported with only that character removed. The same predicate repairs the skip guard, which scanned the whole document rather than the split lines. Line feeds are in the invisible set, so counting them as reportable left the guard unable to skip any document with more than one line.
1 parent 6b2a0df commit e6ca26e

2 files changed

Lines changed: 109 additions & 17 deletions

File tree

docs/md084.md

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,28 @@ line, next to whitespace, or with a character that draws no glyph where its base
4040
should be. A duplicated variation selector is reported, since only the first one has
4141
a base to modify, and so is one that follows an interlinear annotation delimiter.
4242

43-
Presentation characters and the interlinear annotation delimiters are never reported
44-
or removed by those three triggers, but they draw no glyph either, so they still count
45-
when looking for consecutive ones and nothing can hide behind them. The zero-width
46-
space in `⚠️​x` is reported, and the auto-fix removes only the zero-width space.
47-
48-
With `strict = true`, it reports any detected invisible character anywhere.
43+
Line endings are never reported or removed. CommonMark counts a lone carriage return
44+
as a line ending in its own right, so a document written with classic-Mac line endings
45+
is a single line as far as this rule is concerned, and every carriage return in it sits
46+
at a line boundary or beside whitespace. Removing one would join two lines, or drop the
47+
document's final line ending.
48+
49+
Presentation characters, the interlinear annotation delimiters and line endings are
50+
never reported or removed by those three triggers, but they draw no glyph either, so
51+
they still count when looking for consecutive ones and nothing can hide behind them.
52+
The zero-width space in `⚠️​x` is reported, and the auto-fix removes only the
53+
zero-width space.
54+
55+
With `strict = true`, it reports any detected invisible character anywhere, other than
56+
the line endings. Strict mode widens which hidden characters are worth reporting, which
57+
is a judgment the allow-list already lets you make; it is not a licence to restructure
58+
the document.
4959

5060
The list of non-visible characters that this rule will trigger on is:
5161

5262
| Unicode Codepoint | Description |
5363
| ----------------- | ---------------------------------------------------------- |
54-
| U+0000 to U+001F | C0 control characters |
64+
| U+0000 to U+001F | C0 control characters, except tab (U+0009) and the line endings U+000A and U+000D |
5565
| U+007F to U+009F | DEL + C1 control characters |
5666
| U+00AD | SOFT HYPHEN |
5767
| U+034F | COMBINING GRAPHEME JOINER |

src/rules/md084_invisible_characters.rs

Lines changed: 92 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ impl MD084InvisibleCharacters {
5656
matches!(c as u32, 0xFFF9..=0xFFFB)
5757
}
5858

59+
/// A line ending, which CommonMark counts as document structure rather than text
60+
/// (2.1 Characters and lines) - a lone carriage return ends a line just as a line
61+
/// feed does. This rule edits characters within a line, so removing one of these
62+
/// would join two lines or drop the document's final line ending.
63+
///
64+
/// Both reach this rule. Lines arrive already split on `\n` and `\r\n`, so a
65+
/// carriage return surviving into one is a lone classic-Mac line ending; the line
66+
/// feeds are only seen by the whole-document scan in `should_skip`, where counting
67+
/// them as reportable would leave that guard unable to skip anything.
68+
#[inline]
69+
fn is_line_ending(c: char) -> bool {
70+
c == '\n' || c == '\r'
71+
}
72+
5973
/// Whether this code point puts no glyph on the page, whether or not the rule is
6074
/// willing to delete it. This is what a variation selector or joiner needs beside
6175
/// it to be doing its job, and what makes a stretch of characters a cluster.
@@ -211,10 +225,11 @@ impl Rule for MD084InvisibleCharacters {
211225

212226
fn should_skip(&self, ctx: &LintContext) -> bool {
213227
ctx.content.is_empty()
214-
|| !ctx
215-
.content
216-
.chars()
217-
.any(|c| (unicode::is_invisible_char(c) || Self::is_markup_char(c)) && !self.is_allowed(c))
228+
|| !ctx.content.chars().any(|c| {
229+
(unicode::is_invisible_char(c) || Self::is_markup_char(c))
230+
&& !Self::is_line_ending(c)
231+
&& !self.is_allowed(c)
232+
})
218233
}
219234

220235
fn check(&self, ctx: &LintContext) -> LintResult {
@@ -228,10 +243,13 @@ impl Rule for MD084InvisibleCharacters {
228243
continue;
229244
}
230245

231-
// Quick return for strict mode: flag any invisible character that is not allow-listed.
246+
// Quick return for strict mode: flag any invisible character that is neither
247+
// allow-listed nor a line ending. Strict widens which hidden characters are
248+
// worth reporting, which is a judgment the allow list already lets users
249+
// make; it is not a licence to restructure the document.
232250
if self.config.strict {
233251
warnings.extend(chars.iter().enumerate().filter_map(|(i, &c)| {
234-
if self.is_allowed(c) {
252+
if self.is_allowed(c) || Self::is_line_ending(c) {
235253
None
236254
} else if unicode::is_invisible_char(c) {
237255
Some(self.build_warning(
@@ -255,16 +273,22 @@ impl Rule for MD084InvisibleCharacters {
255273
}
256274

257275
// In non-strict mode, we only flag the three triggers defined in the rule
258-
// description. Presentation characters and annotation delimiters are never
259-
// reported or removed by those triggers, but they still draw no glyph, so
260-
// they count toward a cluster and nothing can hide behind one.
276+
// description. Presentation characters, annotation delimiters and line
277+
// endings are never reported or removed by those triggers, but they still
278+
// draw no glyph, so they count toward a cluster and nothing can hide behind
279+
// one: a zero-width space pressed against a line ending is still reported,
280+
// and only it is removed.
261281
let mut flagged = vec![false; chars.len()];
262282
let flaggable: Vec<bool> = chars
263283
.iter()
264284
.map(|&c| Self::draws_no_glyph(c) && !self.is_allowed(c))
265285
.collect();
266286
let exempt: Vec<bool> = (0..chars.len())
267-
.map(|i| Self::is_annotation_delimiter(chars[i]) || Self::is_presentation(&chars, i))
287+
.map(|i| {
288+
Self::is_annotation_delimiter(chars[i])
289+
|| Self::is_line_ending(chars[i])
290+
|| Self::is_presentation(&chars, i)
291+
})
268292
.collect();
269293
let is_target: Vec<bool> = (0..chars.len()).map(|i| flaggable[i] && !exempt[i]).collect();
270294

@@ -508,6 +532,64 @@ mod tests {
508532
assert!(findings.is_empty());
509533
}
510534

535+
#[test]
536+
fn test_carriage_returns_are_line_endings_not_hidden_content() {
537+
// A document written with classic-Mac line endings is a single line to
538+
// `str::lines()`, which splits on `\n` and `\r\n` only. Every carriage return
539+
// in it is therefore visible to this rule, and each of the three default
540+
// triggers used to reach one: the last is at a line boundary, a doubled pair
541+
// is a run of two, and one after a space is adjacent to whitespace. Removing
542+
// any of them joins two lines or drops the document's final line ending.
543+
for content in [
544+
"# Title\rSome text\rMore text\r",
545+
"# Title\r\rSome text\r",
546+
"# Title \rSome text\r",
547+
"a\rb\n",
548+
] {
549+
let findings = check(content);
550+
assert!(findings.is_empty(), "{content:?} gave {findings:?}");
551+
assert_eq!(fix(content), content, "fixing {content:?}");
552+
}
553+
}
554+
555+
#[test]
556+
fn test_strict_mode_keeps_carriage_returns() {
557+
// Strict mode widens which hidden characters are reported. Line endings are
558+
// not among them at any strictness: deleting all three here would collapse
559+
// the document onto one line.
560+
for content in ["# Title\rSome text\rMore text\r", "# Title\r\nSome text\r\n"] {
561+
let findings = check_with_config(content, true, "");
562+
assert!(findings.is_empty(), "{content:?} gave {findings:?}");
563+
assert_eq!(fix_with_config(content, true, ""), content, "fixing {content:?}");
564+
}
565+
}
566+
567+
#[test]
568+
fn test_hidden_character_beside_a_carriage_return_is_still_removed() {
569+
// Sparing the line ending must not spare what hides against it. A carriage
570+
// return draws no glyph, so it still forms a run with its neighbor and still
571+
// counts as the whitespace trigger 3 looks for; only the line ending survives
572+
// the fix.
573+
for (content, strict) in [("a\u{200B}\rb", false), ("a\r\u{200B}b", false), ("a\u{200C}\rb", true)] {
574+
let findings = check_with_config(content, strict, "");
575+
assert_eq!(findings.len(), 1, "{content:?} (strict={strict}) gave {findings:?}");
576+
assert_eq!(fix_with_config(content, strict, ""), "a\rb", "fixing {content:?}");
577+
}
578+
}
579+
580+
#[test]
581+
fn test_line_feeds_do_not_defeat_the_skip_guard() {
582+
// `should_skip` scans the whole document rather than the split lines, so it
583+
// sees line feeds. They are in the invisible set, so counting them as
584+
// reportable left the guard unable to skip any document with more than one
585+
// line.
586+
let ctx = LintContext::new("plain text\nsecond line\n", MarkdownFlavor::Standard, None);
587+
assert!(MD084InvisibleCharacters::default().should_skip(&ctx));
588+
589+
let ctx = LintContext::new("hidden\u{200B}\n", MarkdownFlavor::Standard, None);
590+
assert!(!MD084InvisibleCharacters::default().should_skip(&ctx));
591+
}
592+
511593
#[test]
512594
fn test_default_ignores_variation_selector_attached_to_base() {
513595
// U+FE0F gives the preceding character emoji presentation. It legitimately

0 commit comments

Comments
 (0)