@@ -320,6 +320,22 @@ impl Rule for MD040FencedCodeLanguage {
320320 if needs_language && !has_pandoc_or_quarto_syntax {
321321 let ( start_line, start_col, end_line, end_col) = calculate_line_range ( block. line_idx + 1 , line) ;
322322
323+ let fix = fence_marker_offset ( line, & block. fence_marker ) . map ( |marker_offset| {
324+ let line_start_byte = ctx. line_offsets . get ( block. line_idx ) . copied ( ) . unwrap_or ( 0 ) ;
325+ let fence_end_byte = line_start_byte + marker_offset + block. fence_marker . len ( ) ;
326+ // Replace from after fence marker to end of line content,
327+ // so trailing whitespace is cleaned up while any existing
328+ // info string / attributes are preserved via the replacement.
329+ let line_end_byte = line_start_byte + line. len ( ) ;
330+ let after_fence_trimmed = line[ marker_offset + block. fence_marker . len ( ) ..] . trim ( ) ;
331+ let replacement = if after_fence_trimmed. is_empty ( ) {
332+ "text" . to_string ( )
333+ } else {
334+ format ! ( "text {after_fence_trimmed}" )
335+ } ;
336+ Fix :: new ( fence_end_byte..line_end_byte, replacement)
337+ } ) ;
338+
323339 warnings. push ( LintWarning {
324340 rule_name : Some ( self . name ( ) . to_string ( ) ) ,
325341 line : start_line,
@@ -328,28 +344,7 @@ impl Rule for MD040FencedCodeLanguage {
328344 end_column : end_col,
329345 message : "Code block (```) missing language" . to_string ( ) ,
330346 severity : Severity :: Warning ,
331- fix : Some ( Fix :: new (
332- {
333- let marker_offset = fence_marker_offset ( line) ;
334- let line_start_byte = ctx. line_offsets . get ( block. line_idx ) . copied ( ) . unwrap_or ( 0 ) ;
335- let fence_end_byte = line_start_byte + marker_offset + block. fence_marker . len ( ) ;
336- // Replace from after fence marker to end of line content,
337- // so trailing whitespace is cleaned up while any existing
338- // info string / attributes are preserved via the replacement.
339- let line_end_byte = line_start_byte + line. len ( ) ;
340- fence_end_byte..line_end_byte
341- } ,
342- {
343- let line: & str = line;
344- let after_fence = & line[ fence_marker_offset ( line) + block. fence_marker . len ( ) ..] ;
345- let after_fence_trimmed = after_fence. trim ( ) ;
346- if after_fence_trimmed. is_empty ( ) {
347- "text" . to_string ( )
348- } else {
349- format ! ( "text {after_fence_trimmed}" )
350- }
351- } ,
352- ) ) ,
347+ fix,
353348 } ) ;
354349 continue ;
355350 }
@@ -500,18 +495,8 @@ fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<Fenc
500495 let line_start = line_offsets. get ( line_idx) . copied ( ) . unwrap_or ( 0 ) ;
501496 let line_end = line_offsets. get ( line_idx + 1 ) . copied ( ) . unwrap_or ( content. len ( ) ) ;
502497 let line = content. get ( line_start..line_end) . unwrap_or ( "" ) ;
503- // Strip any blockquote prefix (`> `) before measuring the fence so
504- // markers inside blockquotes are detected by their actual length.
505- let trimmed = crate :: utils:: blockquote:: strip_blockquote_prefix ( line) . trim ( ) ;
506- let fence_marker = if trimmed. starts_with ( '`' ) {
507- let count = trimmed. chars ( ) . take_while ( |& c| c == '`' ) . count ( ) ;
508- "`" . repeat ( count)
509- } else if trimmed. starts_with ( '~' ) {
510- let count = trimmed. chars ( ) . take_while ( |& c| c == '~' ) . count ( ) ;
511- "~" . repeat ( count)
512- } else {
513- "```" . to_string ( )
514- } ;
498+ let fence_marker =
499+ find_fence_marker ( line) . map_or_else ( || "```" . to_string ( ) , |( _, marker) | marker. to_string ( ) ) ;
515500
516501 let language = detail. info_string . split_whitespace ( ) . next ( ) . unwrap_or ( "" ) . to_string ( ) ;
517502
@@ -524,26 +509,34 @@ fn derive_fenced_code_blocks(ctx: &crate::lint_context::LintContext) -> Vec<Fenc
524509 . collect ( )
525510}
526511
527- /// Byte offset within `line` where the fence marker begins.
512+ /// Locate the fence marker on a fence-opening line: its byte offset and the run
513+ /// of fence characters itself.
514+ ///
515+ /// A fence opener can carry a blockquote prefix, indentation and one or more
516+ /// list markers (`- `, `1. `, and nested combinations). Rather than enumerating
517+ /// those prefixes, locate the marker itself: none of them can hold a backtick or
518+ /// a tilde, so the first run of either is the fence.
519+ fn find_fence_marker ( line : & str ) -> Option < ( usize , & str ) > {
520+ let bytes = line. as_bytes ( ) ;
521+ let start = bytes. iter ( ) . position ( |& b| b == b'`' || b == b'~' ) ?;
522+ let fence_char = bytes[ start] ;
523+ let len = bytes[ start..] . iter ( ) . take_while ( |& & b| b == fence_char) . count ( ) ;
524+ Some ( ( start, & line[ start..start + len] ) )
525+ }
526+
527+ /// Byte offset within `line` where `fence_marker` begins.
528528///
529- /// Accounts for an optional blockquote prefix (`>`, `> >`, `>>`, etc.) followed
530- /// by indentation. For a plain or list-indented fence the blockquote prefix is
531- /// empty, so this reduces to the leading-whitespace length.
532- fn fence_marker_offset ( line : & str ) -> usize {
533- let content = crate :: utils:: blockquote:: strip_blockquote_prefix ( line) ;
534- let blockquote_prefix_len = line. len ( ) - content. len ( ) ;
535- let indent_len = content. len ( ) - content. trim_start ( ) . len ( ) ;
536- blockquote_prefix_len + indent_len
529+ /// Returns `None` when the line's fence run is not the expected marker, so
530+ /// callers offer no fix rather than one anchored at a guessed position.
531+ fn fence_marker_offset ( line : & str , fence_marker : & str ) -> Option < usize > {
532+ let ( start, marker) = find_fence_marker ( line) ?;
533+ ( marker == fence_marker) . then_some ( start)
537534}
538535
539536/// Find the byte span of the language label in a fence line.
540537fn find_label_span ( line : & str , fence_marker : & str ) -> Option < ( usize , usize ) > {
541- let marker_offset = fence_marker_offset ( line) ;
542- let after_indent = & line[ marker_offset..] ;
543- if !after_indent. starts_with ( fence_marker) {
544- return None ;
545- }
546- let after_fence = & after_indent[ fence_marker. len ( ) ..] ;
538+ let marker_offset = fence_marker_offset ( line, fence_marker) ?;
539+ let after_fence = & line[ marker_offset + fence_marker. len ( ) ..] ;
547540
548541 let label_start_rel = after_fence
549542 . char_indices ( )
@@ -701,6 +694,68 @@ another block without
701694 assert_eq ! ( spaced, "> > ```text\n > > code\n > > ```\n " ) ;
702695 }
703696
697+ #[ test]
698+ fn test_fix_list_marker_empty_fence ( ) {
699+ // A fence opened on a list marker line must become `- ```text`, not a
700+ // corrupted `` - `text `` `` inline span. The marker sits after the list
701+ // bullet, so the fix has to locate it rather than assume it starts at the
702+ // first non-whitespace byte.
703+ let content = "# Title\n \n - ```\n root/\n └── nested/\n └── file.txt\n ```\n " ;
704+ let fixed = run_fix ( content) . unwrap ( ) ;
705+ let expected = "# Title\n \n - ```text\n root/\n └── nested/\n └── file.txt\n ```\n " ;
706+ assert_eq ! ( fixed, expected) ;
707+ }
708+
709+ #[ test]
710+ fn test_fix_list_marker_fence_across_marker_styles ( ) {
711+ // Every list marker form pushes the fence a different distance into the
712+ // line, including nested markers on one line and a marker inside a
713+ // blockquote.
714+ for ( input, expected) in [
715+ ( "- ```\n code\n ```\n " , "- ```text\n code\n ```\n " ) ,
716+ ( "* ```\n code\n ```\n " , "* ```text\n code\n ```\n " ) ,
717+ ( "+ ```\n code\n ```\n " , "+ ```text\n code\n ```\n " ) ,
718+ ( "1. ```\n code\n ```\n " , "1. ```text\n code\n ```\n " ) ,
719+ ( "1) ```\n code\n ```\n " , "1) ```text\n code\n ```\n " ) ,
720+ ( " - ```\n code\n ```\n " , " - ```text\n code\n ```\n " ) ,
721+ ( "- - ```\n code\n ```\n " , "- - ```text\n code\n ```\n " ) ,
722+ ( "> - ```\n > code\n > ```\n " , "> - ```text\n > code\n > ```\n " ) ,
723+ ] {
724+ assert_eq ! ( run_fix( input) . unwrap( ) , expected, "input: {input:?}" ) ;
725+ }
726+ }
727+
728+ #[ test]
729+ fn test_fix_list_marker_tilde_and_longer_fences ( ) {
730+ // The marker is derived from the line, so a tilde fence or a run longer
731+ // than three characters must be measured at its real position instead of
732+ // falling back to a three-backtick default.
733+ let tilde = run_fix ( "- ~~~\n code\n ~~~\n " ) . unwrap ( ) ;
734+ assert_eq ! ( tilde, "- ~~~text\n code\n ~~~\n " ) ;
735+
736+ let longer_tilde = run_fix ( "- ~~~~\n code\n ~~~~\n " ) . unwrap ( ) ;
737+ assert_eq ! ( longer_tilde, "- ~~~~text\n code\n ~~~~\n " ) ;
738+
739+ let longer_backtick = run_fix ( "- ````\n code\n ````\n " ) . unwrap ( ) ;
740+ assert_eq ! ( longer_backtick, "- ````text\n code\n ````\n " ) ;
741+ }
742+
743+ #[ test]
744+ fn test_fix_list_marker_fence_is_idempotent ( ) {
745+ let content = "- ```\n root/\n nested\n ```\n " ;
746+ let once = run_fix ( content) . unwrap ( ) ;
747+ let twice = run_fix ( & once) . unwrap ( ) ;
748+ assert_eq ! ( once, twice) ;
749+ assert_eq ! ( once, "- ```text\n root/\n nested\n ```\n " ) ;
750+ }
751+
752+ #[ test]
753+ fn test_fix_list_marker_fence_with_language_untouched ( ) {
754+ let content = "- ```rust\n code\n ```\n " ;
755+ assert ! ( run_check( content) . unwrap( ) . is_empty( ) ) ;
756+ assert_eq ! ( run_fix( content) . unwrap( ) , content) ;
757+ }
758+
704759 #[ test]
705760 fn test_fix_blockquote_empty_fence_is_idempotent ( ) {
706761 // Re-running the fix on its own output must be a no-op.
0 commit comments