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:
@@ -385,7 +385,11 @@ impl StreamingMarkdownFormatter {
|
|||||||
let could_be_hr = self.current_line.chars().all(|c| c == '*' || c == '-' || c == '_')
|
let could_be_hr = self.current_line.chars().all(|c| c == '*' || c == '-' || c == '_')
|
||||||
&& self.current_line.len() >= 2; // At least ** or -- or __
|
&& 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();
|
self.emit_formatted_inline();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1875,6 +1875,124 @@ fn test_mixed_formatting_inside_header() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Helper to strip ANSI escape codes for easier assertion
|
/// 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 {
|
fn strip_ansi(s: &str) -> String {
|
||||||
let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
|
let re = regex::Regex::new(r"\x1b\[[0-9;]*m").unwrap();
|
||||||
re.replace_all(s, "").to_string()
|
re.replace_all(s, "").to_string()
|
||||||
|
|||||||
Reference in New Issue
Block a user