@@ -52,6 +52,11 @@ impl MarkdownStreamCollector {
5252 }
5353
5454 if self . committed_line_count >= complete_line_count {
55+ // When the re-render produces fewer lines than previously committed
56+ // (e.g., pulldown-cmark retroactively reclassified partial content
57+ // as a link reference definition), adjust the counter so future
58+ // commits are not permanently blocked.
59+ self . committed_line_count = complete_line_count;
5560 return Vec :: new ( ) ;
5661 }
5762
@@ -85,6 +90,7 @@ impl MarkdownStreamCollector {
8590 markdown:: append_markdown ( & source, self . width , & mut rendered) ;
8691
8792 let out = if self . committed_line_count >= rendered. len ( ) {
93+ self . committed_line_count = rendered. len ( ) ;
8894 Vec :: new ( )
8995 } else {
9096 rendered[ self . committed_line_count ..] . to_vec ( )
@@ -667,4 +673,92 @@ mod tests {
667673 ] )
668674 . await ;
669675 }
676+
677+ #[ tokio:: test]
678+ async fn link_ref_def_reclassification_does_not_freeze_output ( ) {
679+ // When partial text like "[foo\n" is parsed as paragraph content, and then
680+ // the buffer grows to "[foo]: http://example.com\n" (a link reference
681+ // definition that produces 0 rendered lines), committed_line_count can
682+ // become stale. New content after the reclassification must still be emitted.
683+ let mut c = MarkdownStreamCollector :: new ( None ) ;
684+
685+ // Step 1: "[foo\n" looks like paragraph text.
686+ c. push_delta ( "[foo\n " ) ;
687+ let out1 = c. commit_complete_lines ( ) ;
688+ // pulldown-cmark renders "[foo" as paragraph text.
689+ assert ! ( !out1. is_empty( ) , "partial link ref should render as text" ) ;
690+
691+ // Step 2: Complete the link ref def. This reclassifies the previous
692+ // paragraph text as a non-rendered link reference definition.
693+ c. push_delta ( "]: http://example.com\n " ) ;
694+ let _ = c. commit_complete_lines ( ) ;
695+
696+ // Step 3: Add new content after the reclassification.
697+ c. push_delta ( "New paragraph.\n " ) ;
698+ let out3 = c. commit_complete_lines ( ) ;
699+ let strings3 = lines_to_plain_strings ( & out3) ;
700+
701+ // The key assertion: "New paragraph." must appear in the output.
702+ // Without the fix, committed_line_count stays at 1 while the link ref def
703+ // produces 0 lines, blocking all output.
704+ assert ! (
705+ strings3. iter( ) . any( |s| s. contains( "New paragraph." ) ) ,
706+ "new content after link ref reclassification must be emitted, got: {strings3:?}"
707+ ) ;
708+ }
709+
710+ #[ tokio:: test]
711+ async fn line_count_regression_does_not_block_subsequent_content ( ) {
712+ // Generic test: if a re-render ever produces fewer lines than previously
713+ // committed, subsequent content should still be emitted.
714+ let mut c = MarkdownStreamCollector :: new ( None ) ;
715+
716+ // Stream a paragraph.
717+ c. push_delta ( "Hello.\n " ) ;
718+ let out1 = c. commit_complete_lines ( ) ;
719+ assert_eq ! ( out1. len( ) , 1 ) ;
720+
721+ // Stream an empty code fence (which may cause trailing blank trim to
722+ // reduce effective line count).
723+ c. push_delta ( "```\n ```\n " ) ;
724+ let _ = c. commit_complete_lines ( ) ;
725+
726+ // Stream a heading after the empty fence.
727+ c. push_delta ( "## Heading\n " ) ;
728+ let out3 = c. commit_complete_lines ( ) ;
729+ let strings3 = lines_to_plain_strings ( & out3) ;
730+
731+ // The heading must appear in the output regardless of any intermediate
732+ // line count regression from the empty fence.
733+ assert ! (
734+ strings3. iter( ) . any( |s| s. contains( "## Heading" ) ) ,
735+ "heading after empty fence must be emitted, got: {strings3:?}"
736+ ) ;
737+ }
738+
739+ #[ tokio:: test]
740+ async fn finalize_emits_content_after_line_count_regression ( ) {
741+ // Exercises finalize_and_drain() after a line count regression to ensure
742+ // the counter adjustment works for the finalize path too.
743+ let mut c = MarkdownStreamCollector :: new ( None ) ;
744+
745+ c. push_delta ( "[foo\n " ) ;
746+ let _ = c. commit_complete_lines ( ) ;
747+
748+ // Reclassify as link ref def (0 lines).
749+ c. push_delta ( "]: http://example.com\n " ) ;
750+ let _ = c. commit_complete_lines ( ) ;
751+
752+ // Partial line without trailing newline — commit won't emit it.
753+ c. push_delta ( "Final text." ) ;
754+ assert ! ( c. commit_complete_lines( ) . is_empty( ) ) ;
755+
756+ // finalize_and_drain should emit the remaining content.
757+ let out = c. finalize_and_drain ( ) ;
758+ let strings = lines_to_plain_strings ( & out) ;
759+ assert ! (
760+ strings. iter( ) . any( |s| s. contains( "Final text." ) ) ,
761+ "finalize should emit remaining content after regression, got: {strings:?}"
762+ ) ;
763+ }
670764}
0 commit comments