Fix headers with inline formatting breaking onto new line

When streaming markdown headers containing inline tags (backticks, bold,
italic), the closing delimiter triggered early emission via
emit_formatted_inline(). Since format_header() appends a newline, any
text after the closing tag ended up on a separate line.

Added an in_header guard to handle_delimiter() so headers wait for the
actual newline to emit as a complete line. Added 4 char-by-char streaming
tests covering the bug pattern.
This commit is contained in:
Dhanji R. Prasanna
2026-02-17 12:42:17 +11:00
parent ca1cf5998a
commit e30ddb8cbc
2 changed files with 123 additions and 1 deletions

View File

@@ -385,7 +385,11 @@ impl StreamingMarkdownFormatter {
let could_be_hr = self.current_line.chars().all(|c| c == '*' || c == '-' || c == '_')
&& self.current_line.len() >= 2; // At least ** or -- or __
if self.delimiter_stack.is_empty() && !in_potential_link && !could_be_hr {
// Don't emit yet if we're inside a header - headers must be emitted
// as a complete line at newline, otherwise trailing text after the
// closing delimiter ends up on a new line (format_header adds \n)
let in_header = self.current_line.starts_with('#');
if self.delimiter_stack.is_empty() && !in_potential_link && !could_be_hr && !in_header {
self.emit_formatted_inline();
}
} else {

View File

@@ -1875,6 +1875,124 @@ fn test_mixed_formatting_inside_header() {
}
/// Helper to strip ANSI escape codes for easier assertion
#[test]
fn test_header_with_inline_code_streaming_no_linebreak() {
let mut fmt = make_formatter();
// This is the exact pattern from the bug: header with inline code mid-line
// e.g., "## Bug Fix (`src/main.rs`)\n"
// When streamed char-by-char, the closing backtick used to trigger early emit
// of the header (with trailing \n), causing `)` to appear on a new line.
let input = "## Bug Fix (`src/main.rs`)\n";
let mut full_output = String::new();
for ch in input.chars() {
full_output.push_str(&fmt.process(&ch.to_string()));
}
full_output.push_str(&fmt.finish());
eprintln!("Input: {:?}", input);
eprintln!("Output: {:?}", full_output);
let without_ansi = strip_ansi(&full_output);
eprintln!("Without ANSI: {:?}", without_ansi);
// The header text should be on a single line — no spurious line break
// after the closing backtick
assert!(
without_ansi.contains("Bug Fix (src/main.rs)"),
"Header should render on a single line without line break after inline code, got: {:?}",
without_ansi
);
// Should NOT have the closing paren on its own line
assert!(
!without_ansi.contains(")\n)"),
"Closing paren should not be on a separate line"
);
}
#[test]
fn test_header_with_bold_mid_line_streaming() {
let mut fmt = make_formatter();
// Header with bold text followed by more text
let input = "## Found **critical** issue in module\n";
let mut full_output = String::new();
for ch in input.chars() {
full_output.push_str(&fmt.process(&ch.to_string()));
}
full_output.push_str(&fmt.finish());
eprintln!("Input: {:?}", input);
eprintln!("Output: {:?}", full_output);
let without_ansi = strip_ansi(&full_output);
// All text should be on one line
assert!(
without_ansi.contains("Found critical issue in module"),
"Header should render on a single line, got: {:?}",
without_ansi
);
}
#[test]
fn test_header_with_multiple_inline_elements_streaming() {
let mut fmt = make_formatter();
// Header with multiple inline elements — each closing delimiter must not
// trigger early emission
let input = "# **Bold** and `code` here\n";
let mut full_output = String::new();
for ch in input.chars() {
full_output.push_str(&fmt.process(&ch.to_string()));
}
full_output.push_str(&fmt.finish());
eprintln!("Input: {:?}", input);
eprintln!("Output: {:?}", full_output);
let without_ansi = strip_ansi(&full_output);
// Everything on one line
assert!(
without_ansi.contains("Bold and code here"),
"Header with multiple inline elements should be on one line, got: {:?}",
without_ansi
);
}
#[test]
fn test_header_with_inline_code_at_end_streaming() {
let mut fmt = make_formatter();
// Header where inline code is the very last thing — no trailing text
// This should still work (boundary case)
let input = "### See `README.md`\n";
let mut full_output = String::new();
for ch in input.chars() {
full_output.push_str(&fmt.process(&ch.to_string()));
}
full_output.push_str(&fmt.finish());
eprintln!("Input: {:?}", input);
eprintln!("Output: {:?}", full_output);
let without_ansi = strip_ansi(&full_output);
// Should contain the text on one line, properly formatted
assert!(
without_ansi.contains("See README.md"),
"Header with inline code at end should render correctly, got: {:?}",
without_ansi
);
}
fn strip_ansi(s: &str) -> String {
let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
re.replace_all(s, "").to_string()