@@ -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\r Some text\r More text\r " ,
545+ "# Title\r \r Some text\r " ,
546+ "# Title \r Some text\r " ,
547+ "a\r b\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\r Some text\r More text\r " , "# Title\r \n Some 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} \r b" , false ) , ( "a\r \u{200B} b" , false ) , ( "a\u{200C} \r b" , 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\r b" , "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\n second 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