diff --git a/crates/editor/src/content/buffer_tests.rs b/crates/editor/src/content/buffer_tests.rs index 4489a6b22ba..4d2155eae2e 100644 --- a/crates/editor/src/content/buffer_tests.rs +++ b/crates/editor/src/content/buffer_tests.rs @@ -13019,6 +13019,53 @@ fn test_clipboard_table_copy_uses_source_offsets_for_later_formatted_cells() { }); } +#[test] +fn test_clipboard_table_copy_emits_real_newline_for_in_cell_break() { + App::test((), |mut app| async move { + // Product decision (#13732): the two clipboard flavors carry a `
` differently. + // + // - The plain-text flavor is *rendered text*: it already strips styling, so an + // authored hard break emits a real `\n`, never the literal HTML string "
". + // Injecting HTML the user didn't select would violate that rendered-text contract. + // - The HTML flavor (see serialize_table_cell_inline / selected_text_as_html) carries + // the break as a real `
` element, so Warp-to-Warp paste round-trips faithfully. + // + // Accepted cost: a plain-text-only re-paste of this row into a Warp table may split it, + // because the in-cell newline collides with the tab/newline row delimiter. That is a + // standard TSV-class limitation we consciously accept — it is NOT an oversight. Do not + // "fix" it by re-emitting "
" here; that regresses the plain-text contract above. + let table_source = "a
b\tc"; + let markdown = format!("```{TABLE_BLOCK_MARKDOWN_LANG}\n{table_source}\n```\n"); + let (buffer, _selection) = Buffer::mock_from_markdown( + &markdown, + None, + Box::new(|_, _| IndentBehavior::Ignore), + &mut app, + ); + + buffer.update(&mut app, |buffer, _ctx| { + let block_start = buffer.containing_block_start(CharOffset::from(1)); + let max_offset = buffer.max_charoffset(); + + let copied = buffer.clipboard_table_text_in_range( + block_start, + block_start..max_offset, + LineEnding::LF, + ); + + assert!( + !copied.contains("
"), + "plain-text flavor must not contain literal HTML the user didn't select, got {copied:?}" + ); + // The in-cell break renders as a real newline; the trailing `\n` terminates the row. + assert_eq!( + copied, "a\nb\tc\n", + "in-cell break should emit a real newline in the plain-text flavor" + ); + }); + }); +} + #[test] fn test_multiselect_text_styling() { App::test((), |mut app| async move { diff --git a/crates/editor/src/content/edit.rs b/crates/editor/src/content/edit.rs index 93130d07fed..ee15e2c06e2 100644 --- a/crates/editor/src/content/edit.rs +++ b/crates/editor/src/content/edit.rs @@ -295,7 +295,15 @@ fn measure_table_cells( let mut line = LayOutArgs::new(); line.highlighted_urls = highlight_urls(&runs); for run in &runs { - line.layout_run(layout, run, ¶graph_style); + // `layout_run` strips a trailing `\n` (from a raw HTML `
`) out of + // `line.text`; an interior `\n` is left in place. Reinsert the break at each run + // boundary where it occurred — a `
` can land at a style-run boundary (e.g. + // `**bold**
**plain**`), so the break may terminate a non-final run. Tracking + // only the final run's flag would drop such interior breaks and silently collapse + // the cell to one line. + if line.layout_run(layout, run, ¶graph_style) { + line.text.push('\n'); + } } let text_layout = InlineTextLayoutInput { text: line.text, diff --git a/crates/editor/src/content/edit_tests.rs b/crates/editor/src/content/edit_tests.rs index a94a3b2dd21..ec2b0bdef8d 100644 --- a/crates/editor/src/content/edit_tests.rs +++ b/crates/editor/src/content/edit_tests.rs @@ -14,6 +14,7 @@ use warpui_core::{App, SingletonEntity}; use super::{ BlockLocation, LayOutArgs, layout_mermaid_diagram_block, layout_table_block, layout_text_block, + measure_table_cells, }; use crate::content::buffer::{StyledBufferRun, StyledTextBlock}; use crate::content::edit::{ @@ -843,6 +844,156 @@ fn test_layout_table_block_caches_cell_text_frames() { }) } +/// Lays out the given internal-format table string and returns the measured cell text strings +/// (header row first, then body rows). The returned `String`s are the exact inputs handed to the +/// text layout system, so a trailing `\n` here means the cell will render a trailing empty line. +/// +/// This asserts on `measure_table_cells`' output rather than laid-out line counts because +/// `App::test`'s text layout backend is a stub (`platform::test::delegate`'s `layout_text` ignores +/// its input and always returns a single empty line), so `line_heights.len()` cannot observe +/// newline handling. +fn measured_cell_texts(text_layout: &TextLayout<'_>, content: &str) -> Vec> { + let table = + crate::content::text::table_from_internal_format_with_inline_markdown(content, Vec::new()); + let table_style = text_layout.rich_text_styles().table_style; + let mut header_style = text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new())); + header_style.font_weight = Weight::Bold; + let body_style = text_layout.paragraph_styles(&BufferBlockStyle::table(Vec::new())); + + let (_widths, cell_text_layouts) = + measure_table_cells(&table, text_layout, &table_style, header_style, body_style); + cell_text_layouts + .into_iter() + .map(|row| row.into_iter().map(|cell| cell.text_layout.text).collect()) + .collect() +} + +/// A trailing `
` at the end of a table cell must preserve the trailing newline in the cell's +/// measured text so it renders an extra empty line, matching GitHub's rendering of a trailing hard +/// break inside a GFM pipe-table cell. +#[test] +fn test_measure_table_cells_trailing_br_keeps_newline() { + App::test((), |app| async move { + app.read(|ctx| { + let layout_cache = LayoutCache::new(); + let text_layout = TextLayout::new( + &layout_cache, + ctx.font_cache().text_layout_system(), + &TEST_STYLES, + f32::MAX, + ); + // Body cell 0 ends in `
`; control cell 1 is a plain single-line cell. + let cells = measured_cell_texts(&text_layout, "a\tb\none
\ttwo\n"); + + assert_eq!( + cells[1][0], "one\n", + "cell ending in
must keep its trailing newline (extra empty line)" + ); + assert_eq!( + cells[1][1], "two", + "control cell without
must be unchanged" + ); + }); + }) +} + +/// A cell containing only `
` parses to a lone `\n` fragment; its measured text must be a lone +/// newline (an empty content line plus a trailing empty line), not the empty string. +#[test] +fn test_measure_table_cells_lone_br_keeps_newline() { + App::test((), |app| async move { + app.read(|ctx| { + let layout_cache = LayoutCache::new(); + let text_layout = TextLayout::new( + &layout_cache, + ctx.font_cache().text_layout_system(), + &TEST_STYLES, + f32::MAX, + ); + let cells = measured_cell_texts(&text_layout, "a\tb\n
\ttwo\n"); + + assert_eq!(cells[1][0], "\n", "a lone
cell must keep its newline"); + }); + }) +} + +/// Embedded `
` (text on both sides) is the control path: the interior newline was never +/// stripped, so it must remain untouched by the trailing-break fix. +#[test] +fn test_measure_table_cells_embedded_br_unchanged() { + App::test((), |app| async move { + app.read(|ctx| { + let layout_cache = LayoutCache::new(); + let text_layout = TextLayout::new( + &layout_cache, + ctx.font_cache().text_layout_system(), + &TEST_STYLES, + f32::MAX, + ); + let cells = measured_cell_texts(&text_layout, "a\tb\none
two\tthree\n"); + + assert_eq!( + cells[1][0], "one\ntwo", + "embedded
must keep its single interior newline" + ); + }); + }) +} + +/// A `
` that falls between two styled runs (e.g. `**bold**
**plain**`) parses to a +/// standalone `\n` fragment sitting in a non-final run position. `layout_run` strips that run's +/// trailing `\n` and reports the break, but the following run reports no break — so tracking only +/// the final run's flag would drop the interior newline and collapse the two lines into one. The +/// break must be reinserted per-run, at the run boundary. +#[test] +fn test_measure_table_cells_style_boundary_br_keeps_newline() { + App::test((), |app| async move { + app.read(|ctx| { + let layout_cache = LayoutCache::new(); + let text_layout = TextLayout::new( + &layout_cache, + ctx.font_cache().text_layout_system(), + &TEST_STYLES, + f32::MAX, + ); + // Body cell 0: bold "bold", a
break, bold "plain". The
becomes a standalone + // `\n` fragment between the two bold runs — a non-final run that ends in the break. + let cells = measured_cell_texts(&text_layout, "a\tb\n**bold**
**plain**\ttwo\n"); + + assert_eq!( + cells[1][0], "bold\nplain", + "a
at a style-run boundary must keep its newline between the runs" + ); + }); + }) +} + +/// A cell with breaks in both an interior style-boundary position and a trailing position must +/// preserve every newline, proving the per-run reinsertion handles multiple breaks rather than +/// only the last one. +#[test] +fn test_measure_table_cells_style_boundary_and_trailing_br() { + App::test((), |app| async move { + app.read(|ctx| { + let layout_cache = LayoutCache::new(); + let text_layout = TextLayout::new( + &layout_cache, + ctx.font_cache().text_layout_system(), + &TEST_STYLES, + f32::MAX, + ); + // Body cell 0: an interior style-boundary break (between the two bold runs) plus a + // trailing break at the end of the cell. + let cells = measured_cell_texts(&text_layout, "a\tb\n**bold**
**plain**
\ttwo\n"); + + assert_eq!( + cells[1][0], "bold\nplain\n", + "breaks at a style boundary and at the trailing position must both be preserved" + ); + }); + }) +} + #[test] fn test_layout_table_block_clamps_cell_width_to_max() { App::test((), |app| async move { diff --git a/crates/editor/src/content/markdown.rs b/crates/editor/src/content/markdown.rs index 7c3276f4867..803ebd5ba11 100644 --- a/crates/editor/src/content/markdown.rs +++ b/crates/editor/src/content/markdown.rs @@ -1172,7 +1172,25 @@ where serializer.start_elem(QualName::new(None, ns!(html), "code".into()), iter::empty())?; } - serializer.write_text(&fragment.text)?; + // An authored hard break (raw HTML `
`) is stored as an embedded newline. In an + // HTML table cell a raw newline collapses to a space, silently dropping the break, so + // emit a real `
` element between the split parts instead. Inline code cannot + // contain such a newline (the `.md` inline parser never produces one), so it is written + // verbatim. Mirrors the guard in `inline_to_markdown`. + if !styles.is_inline_code() && fragment.text.contains('\n') { + let mut parts = fragment.text.split('\n'); + if let Some(first) = parts.next() { + serializer.write_text(first)?; + } + for part in parts { + let br_tag = QualName::new(None, ns!(html), "br".into()); + serializer.start_elem(br_tag.clone(), iter::empty())?; + serializer.end_elem(br_tag)?; + serializer.write_text(part)?; + } + } else { + serializer.write_text(&fragment.text)?; + } if styles.is_inline_code() { serializer.end_elem(QualName::new(None, ns!(html), "code".into()))?; @@ -1249,10 +1267,20 @@ fn inline_to_markdown(inline: &FormattedTextInline) -> String { let mut previous_styles = TextStylesWithMetadata::default(); for fragment in inline { let next_styles = TextStylesWithMetadata::from(fragment.styles.clone()); + // An authored hard break (raw HTML `
`) is stored as an embedded newline. Emit it as + // literal `
` so it survives the GFM pipe-table serialization without splitting the + // cell into a spurious extra row. Inline code cannot contain a newline from the `.md` + // inline parser, so it is left untouched. Mirrors the guard in + // `markdown_parser::inline_to_markdown`. + let fragment_text = if !next_styles.is_inline_code() && fragment.text.contains('\n') { + fragment.text.replace('\n', "
") + } else { + fragment.text.clone() + }; let content = BufferMarkdownParser::append_formatting( &previous_styles, &next_styles, - fragment.text.as_str(), + fragment_text.as_str(), &mut markdown, ); previous_styles = next_styles; diff --git a/crates/editor/src/content/markdown_tests.rs b/crates/editor/src/content/markdown_tests.rs index f8e01a683c0..587195dec4f 100644 --- a/crates/editor/src/content/markdown_tests.rs +++ b/crates/editor/src/content/markdown_tests.rs @@ -178,6 +178,40 @@ fn test_gfm_table_html_serialization() { }); } +#[test] +fn test_gfm_table_html_serialization_preserves_br_in_cell() { + App::test((), |mut app| async move { + let _flag = warp_core::features::FeatureFlag::MarkdownTables.override_enabled(true); + let markdown = "\ +| header 1 | header 2 |\n\ +| --- | --- |\n\ +| line one
line two | value 2 |\n"; + let (buffer, _selection) = Buffer::mock_from_markdown( + markdown, + None, + Box::new(|_, _| IndentBehavior::Ignore), + &mut app, + ); + + let html = app.read_model(&buffer, |buffer, ctx| { + let range = CharOffset::from(1)..buffer.max_charoffset(); + buffer.ranges_as_html(Vec1::try_from_vec(vec![range]).unwrap(), ctx) + }); + + let html = html.expect("table should export HTML"); + // The in-cell break must serialize as a real
element, not a raw newline (which + // HTML collapses to a space, silently dropping the break). + assert!( + html.contains("line one
line two"), + "cell break should become a
element, got {html}" + ); + assert!( + !html.contains("line one\nline two"), + "cell HTML must not contain a raw newline, got {html:?}" + ); + }); +} + #[test] fn test_apply_formatted_text_delta_append() { App::test((), |mut app| async move { @@ -357,6 +391,35 @@ fn test_table_markdown_export_escapes_pipe_characters() { }); } +#[test] +fn test_table_markdown_export_preserves_br_line_breaks() { + App::test((), |mut app| async move { + // A cell whose text contains a `
` hard break is stored internally as an + // embedded newline. The GFM export must emit it back as literal `
` so the + // newline does not split the pipe-table row into a spurious extra row. + let markdown = format!( + "```{}\nheader 1\theader 2\nline one
line two\tvalue 2\n```\n", + TABLE_BLOCK_MARKDOWN_LANG + ); + let (buffer, _selection) = Buffer::mock_from_markdown( + &markdown, + None, + Box::new(|_, _| IndentBehavior::Ignore), + &mut app, + ); + + let exported_markdown = app.read_model(&buffer, |buffer, _| buffer.markdown_unescaped()); + assert_eq!( + exported_markdown, + "| header 1 | header 2 |\n| --- | --- |\n| line one
line two | value 2 |\n" + ); + assert!( + !exported_markdown.contains("line one\nline two"), + "in-cell hard break must not emit a raw newline that splits the table row: {exported_markdown:?}" + ); + }); +} + #[test] fn test_url_link_display_text_round_trip_is_stable() { App::test((), |mut app| async move { diff --git a/crates/editor/src/render/model/table_offset_map.rs b/crates/editor/src/render/model/table_offset_map.rs index 3af29f39f8d..73c171bc7c6 100644 --- a/crates/editor/src/render/model/table_offset_map.rs +++ b/crates/editor/src/render/model/table_offset_map.rs @@ -283,6 +283,37 @@ impl TableOffsetMap { // per-cell offsets can be derived by seeking to boundaries instead of re-parsing the // whole table on every edit. The current embedded-text-plus-cached-parse model is // sufficient for read-only tables; see PR #24326 discussion for context. +/// If a raw HTML `
` line-break token begins at `idx` in `chars`, return its length in +/// characters. Accepts the same forms as the Markdown parser (`markdown_parser::parse_inline_token_br`): +/// `
`, `
`, and `
` with a case-insensitive `br` and any run of ASCII whitespace +/// before an optional self-closing slash. Attributes (e.g. `
`) are not accepted, +/// matching the parser, so such input is walked as ordinary source text. +fn br_token_len_at(chars: &[char], idx: usize) -> Option { + let mut i = idx; + if chars.get(i)? != &'<' { + return None; + } + i += 1; + if !chars.get(i)?.eq_ignore_ascii_case(&'b') { + return None; + } + i += 1; + if !chars.get(i)?.eq_ignore_ascii_case(&'r') { + return None; + } + i += 1; + while chars.get(i).is_some_and(|c| c.is_ascii_whitespace()) { + i += 1; + } + if chars.get(i) == Some(&'/') { + i += 1; + } + if chars.get(i)? != &'>' { + return None; + } + Some(i + 1 - idx) +} + impl TableCellOffsetMap { /// Build a cell offset map from the raw cell `source` text and the parsed `inline` /// fragments produced by the Markdown parser. @@ -306,52 +337,78 @@ impl TableCellOffsetMap { continue; } - let first_rendered = rendered_chars[0]; - while source_idx < total_source_chars { - let sc = source_chars[source_idx]; - if sc == '\\' - && source_idx + 1 < total_source_chars - && source_chars[source_idx + 1] == first_rendered - { - source_idx += 1; - break; - } - if sc == first_rendered { - break; + // A fragment may embed authored hard breaks (rendered `\n`, spelled `
` in + // source). Walk it in newline-delimited segments and emit one range per segment so + // each range keeps a linear rendered↔source correspondence; the `
` token span + // between segments becomes a gap, handled the same way as Markdown markers between + // fragments (see the `source_end` back-fill below). + for (segment_index, segment) in rendered_chars.split(|&c| c == '\n').enumerate() { + if segment_index > 0 { + // Scan forward to the `
` token that produced this break (skipping any + // trailing Markdown markers, e.g. the closing `**` of a bold run) and + // consume it. + while source_idx < total_source_chars { + if let Some(token_len) = br_token_len_at(&source_chars, source_idx) { + source_idx += token_len; + break; + } + source_idx += 1; + } + // Account for the rendered `\n` we split on. + rendered_offset += CharOffset::from(1usize); } - source_idx += 1; - } - - let visible_source_start = CharOffset::from(source_idx); - for &rendered_char in &rendered_chars { - if source_idx >= total_source_chars { - break; + if segment.is_empty() { + continue; } - let sc = source_chars[source_idx]; - if sc == '\\' - && source_idx + 1 < total_source_chars - && source_chars[source_idx + 1] == rendered_char - { - source_idx += 2; - } else { + + let first_rendered = segment[0]; + while source_idx < total_source_chars { + let sc = source_chars[source_idx]; + if sc == '\\' + && source_idx + 1 < total_source_chars + && source_chars[source_idx + 1] == first_rendered + { + source_idx += 1; + break; + } + if sc == first_rendered { + break; + } source_idx += 1; } - } - let visible_source_end = CharOffset::from(source_idx); - let rendered_start = rendered_offset; - let rendered_end = rendered_start + CharOffset::from(rendered_chars.len()); + let visible_source_start = CharOffset::from(source_idx); + + for &rendered_char in segment { + if source_idx >= total_source_chars { + break; + } + let sc = source_chars[source_idx]; + if sc == '\\' + && source_idx + 1 < total_source_chars + && source_chars[source_idx + 1] == rendered_char + { + source_idx += 2; + } else { + source_idx += 1; + } + } + + let visible_source_end = CharOffset::from(source_idx); + let rendered_start = rendered_offset; + let rendered_end = rendered_start + CharOffset::from(segment.len()); - fragment_ranges.push(TableCellFragmentRange { - rendered_start, - rendered_end, - source_end: visible_source_end, - visible_source_start, - visible_source_end, - }); + fragment_ranges.push(TableCellFragmentRange { + rendered_start, + rendered_end, + source_end: visible_source_end, + visible_source_start, + visible_source_end, + }); - rendered_offset = rendered_end; + rendered_offset = rendered_end; + } } let total_source_offset = CharOffset::from(total_source_chars); @@ -389,10 +446,18 @@ impl TableCellOffsetMap { .unwrap_or(self.source_length); } + let mut previous_visible_source_end = CharOffset::zero(); for fragment in &self.fragment_ranges { + // A rendered `\n` (authored `
`) sits in the gap before this range's + // `rendered_start`. Map it to the end of the previous range's visible source (the + // start of the `
` token) rather than interpolating into this range. + if rendered_offset < fragment.rendered_start { + return previous_visible_source_end; + } if rendered_offset < fragment.rendered_end { return fragment.visible_source_start + (rendered_offset - fragment.rendered_start); } + previous_visible_source_end = fragment.visible_source_end; } self.source_length diff --git a/crates/editor/src/render/model/table_offset_map_tests.rs b/crates/editor/src/render/model/table_offset_map_tests.rs index 5232d241681..65698648c27 100644 --- a/crates/editor/src/render/model/table_offset_map_tests.rs +++ b/crates/editor/src/render/model/table_offset_map_tests.rs @@ -231,6 +231,115 @@ fn test_table_cell_offset_map_handles_backslash_escaped_punctuation() { ); } +#[test] +fn test_table_cell_offset_map_maps_br_source_offsets() { + // `
` is 4 source chars but renders as a single `\n`. The map must map offsets in + // the post-break region ("two") to their true source positions, not collapse them to + // the break. + let source = "one
two"; + let inline = parse_inline_markdown(source); + let rendered_text: String = inline + .iter() + .map(|fragment| fragment.text.as_str()) + .collect(); + assert_eq!(rendered_text, "one\ntwo"); + + let map = TableCellOffsetMap::from_inline_and_source(source, &inline); + assert_eq!( + map.source_length(), + CharOffset::from(source.chars().count()) + ); + assert_eq!( + map.rendered_length(), + CharOffset::from(rendered_text.chars().count()) + ); + + // Rendered "two" begins at rendered offset 4 and source offset 7 (after `one
`). + assert_eq!( + map.rendered_to_source(CharOffset::from(4)), + CharOffset::from(7), + "rendered 't' should map to source 't' after the
token" + ); + assert_eq!( + map.rendered_to_source(CharOffset::from(6)), + CharOffset::from(9), + ); + assert_eq!( + map.source_to_rendered(CharOffset::from(7)), + CharOffset::from(4), + "source 't' should map to rendered 't'" + ); + assert_eq!( + map.source_to_rendered(CharOffset::from(9)), + CharOffset::from(6), + ); + + // Every rendered char (including the break) should map back to a source position whose + // char matches, except the break which corresponds to the `<` of `
`. + for (rendered_idx, rendered_char) in rendered_text.chars().enumerate() { + if rendered_char == '\n' { + continue; + } + let source_pos = map.rendered_to_source(CharOffset::from(rendered_idx)); + assert_eq!( + source.chars().nth(source_pos.as_usize()), + Some(rendered_char), + "rendered {rendered_idx} ({rendered_char:?}) should map to same char in source", + ); + } +} + +#[test] +fn test_table_cell_offset_map_maps_self_closing_br_source_offsets() { + // `
` is 5 source chars, still a single rendered `\n`. + let source = "one
two"; + let inline = parse_inline_markdown(source); + let rendered_text: String = inline + .iter() + .map(|fragment| fragment.text.as_str()) + .collect(); + assert_eq!(rendered_text, "one\ntwo"); + + let map = TableCellOffsetMap::from_inline_and_source(source, &inline); + // Rendered "two" begins at rendered offset 4 and source offset 8 (after `one
`). + assert_eq!( + map.rendered_to_source(CharOffset::from(4)), + CharOffset::from(8), + ); + assert_eq!( + map.source_to_rendered(CharOffset::from(8)), + CharOffset::from(4), + ); +} + +#[test] +fn test_table_cell_offset_map_maps_br_at_style_boundary() { + // The break falls between a bold fragment ("a") and a plain one that begins with the + // break ("\nb"), so the walk must handle a `\n` as the *first* rendered char too. + let source = "**a**
b"; + let inline = parse_inline_markdown(source); + let rendered_text: String = inline + .iter() + .map(|fragment| fragment.text.as_str()) + .collect(); + assert_eq!(rendered_text, "a\nb"); + + let map = TableCellOffsetMap::from_inline_and_source(source, &inline); + assert_eq!( + map.source_length(), + CharOffset::from(source.chars().count()) + ); + // Rendered 'b' is at rendered offset 2 and source offset 9 (after `**a**
`). + assert_eq!( + map.rendered_to_source(CharOffset::from(2)), + CharOffset::from(9), + ); + assert_eq!( + map.source_to_rendered(CharOffset::from(9)), + CharOffset::from(2), + ); +} + #[test] fn test_table_cell_offset_map_handles_nested_styles() { let source = "**a *b* c**"; diff --git a/crates/markdown_parser/src/lib.rs b/crates/markdown_parser/src/lib.rs index e9cf95c66ef..3084237e744 100644 --- a/crates/markdown_parser/src/lib.rs +++ b/crates/markdown_parser/src/lib.rs @@ -431,7 +431,13 @@ impl FormattedTable { /// Serialize to GFM pipe-table markdown. pub fn to_plain_text(&self) -> String { fn inline_to_text(inline: &FormattedTextInline) -> String { - inline.iter().map(|f| f.text.as_str()).collect() + // Translate authored hard breaks (embedded newlines, from raw HTML `
`) back + // to `
` so an in-cell line break does not split the pipe-table row. A cell's + // text never contains a newline for any other reason. + inline + .iter() + .map(|f| f.text.replace('\n', "
")) + .collect() } let mut lines = Vec::new(); @@ -464,6 +470,14 @@ fn inline_to_markdown(inline: &FormattedTextInline) -> String { continue; } + // An authored hard break (raw HTML `
`) is stored as an embedded newline. Emit it + // as literal `
` so it survives the newline/tab/pipe-delimited table serialization + // formats without splitting a cell into a spurious extra row. Inline code spans are + // handled below (a newline cannot appear in one from the `.md` inline parser). + if !fragment.styles.inline_code && text.contains('\n') { + text = text.replace('\n', "
"); + } + if fragment.styles.inline_code { result.push('`'); result.push_str(&text); diff --git a/crates/markdown_parser/src/markdown_parser.rs b/crates/markdown_parser/src/markdown_parser.rs index 1e67e7f43da..c17593d5595 100644 --- a/crates/markdown_parser/src/markdown_parser.rs +++ b/crates/markdown_parser/src/markdown_parser.rs @@ -1035,6 +1035,14 @@ fn parse_inline<'a, E: ContextError<&'a str> + ParseError<&'a str>>( InlineToken::UnderlineEnd => { input = parse_underline(&mut state, remaining); } + InlineToken::LineBreak => { + // Emit the break as a newline fragment. Both the paragraph and table-cell + // render paths turn an embedded newline into a real visual break. The + // fragment may later coalesce into adjacent plain text (the newline is + // preserved, so rendering is unaffected); serialization paths translate any + // embedded newline back to `
`. See `HARD_LINE_BREAK`. + state.push_closed_node(FormattedTextFragment::plain_text(HARD_LINE_BREAK)); + } } } @@ -1549,6 +1557,7 @@ fn parse_inline_token<'a, E: ContextError<&'a str> + ParseError<&'a str>>( parse_inline_token_autolink, parse_inline_token_underline_start, parse_inline_token_underline_end, + parse_inline_token_br, whitespace, text, // This _must_ be the last parser in the chain. It unconditionally consumes a single @@ -1654,6 +1663,33 @@ fn parse_inline_token_underline_end<'a, E: ContextError<&'a str> + ParseError<&' )(input) } +/// Parse a raw HTML `
` line break. +/// +/// Accepts the case-insensitive forms `
`, `
`, and `
` (any run of ASCII +/// whitespace is allowed before an optional self-closing `/`). Attributes are intentionally +/// *not* accepted: a tag such as `
` falls through to literal text rather than +/// being silently reinterpreted, keeping malformed/unsupported input deterministic. A stray +/// `` also fails here and is emitted as literal text by the fallback +/// `text`/`unmatched_char` parsers, matching the ``/`` precedent. +fn parse_inline_token_br<'a, E: ContextError<&'a str> + ParseError<&'a str>>( + input: &'a str, +) -> IResult<&'a str, InlineToken<'a>, E> { + context( + "line_break", + value( + InlineToken::LineBreak, + tuple(( + tag("<"), + tag_no_case("br"), + space0, + // Optional self-closing slash: `
` or `
`. + map(take_while_m_n(0, 1, |c| c == '/'), |_| ()), + tag(">"), + )), + ), + )(input) +} + /// Helper to parse a run of delimiters. fn parse_delimiter_run<'a, E: ContextError<&'a str> + ParseError<&'a str>>( kind: DelimiterKind, @@ -1700,7 +1736,19 @@ enum InlineToken<'a> { LinkEnd, /// A closing , which triggers underline parsing. UnderlineEnd, -} + /// A raw HTML `
` line break (including the `
`, `
`, and uppercase + /// variants). Rendered as a forced line break within the current paragraph or table + /// cell. See [`HARD_LINE_BREAK`]. + LineBreak, +} + +/// The text a hard line break (raw HTML `
`) is represented as within a +/// [`FormattedTextFragment`]. Both the paragraph and table-cell render paths already turn an +/// embedded newline into a real visual break, so the break is stored as a plain-text `"\n"` +/// fragment. Serialization paths that flatten fragments into newline/tab/pipe-delimited +/// formats (e.g. [`inline_to_markdown`], [`FormattedTable::to_plain_text`]) must translate +/// this sentinel back to `
` so it does not corrupt row/column structure. +pub(crate) const HARD_LINE_BREAK: &str = "\n"; /// An entry in the [delimiter stack](https://spec.commonmark.org/0.30/#delimiter-stack) #[derive(Debug, Clone)] diff --git a/crates/markdown_parser/src/markdown_parser_tests.rs b/crates/markdown_parser/src/markdown_parser_tests.rs index d347c01c981..3dde1a489d4 100644 --- a/crates/markdown_parser/src/markdown_parser_tests.rs +++ b/crates/markdown_parser/src/markdown_parser_tests.rs @@ -2912,3 +2912,219 @@ fn test_parse_table_with_strikethrough() { panic!("Expected table"); } } + +// --- Raw HTML `
` line breaks (GH13732) --------------------------------- +// +// A `
` is parsed into a bare `"\n"` fragment; both the paragraph and table-cell render +// paths turn an embedded newline into a real visual break. These tests assert the parse-level +// shape, the malformed-input fallbacks, and that serialization round-trips the break as +// literal `
` rather than corrupting the delimited table formats. + +/// Collect the fragments of the single paragraph line produced by `source`. +fn parse_single_line_fragments(source: &str) -> Vec { + let result = test_parse_markdown(source); + assert_eq!(result.len(), 1, "expected a single line, got {result:?}"); + match result.into_iter().next().unwrap() { + FormattedTextLine::Line(fragments) => fragments, + other => panic!("expected a paragraph Line, got {other:?}"), + } +} + +/// The joined text of the single paragraph line produced by `source`. A `
` renders as +/// an embedded `"\n"`; fragments with identical styles coalesce, so the break lives inside a +/// merged fragment rather than as a standalone one — the render path breaks on the newline +/// either way. +fn parse_single_line_text(source: &str) -> String { + parse_single_line_fragments(source) + .iter() + .map(|f| f.text.as_str()) + .collect() +} + +#[test] +fn test_br_standalone_becomes_line_break() { + assert_eq!( + parse_single_line_text("First half
second half"), + "First half\nsecond half" + ); +} + +#[test] +fn test_br_self_closing_variants() { + // `
`, `
`, and `
` are all recognized identically. + for source in ["a
b", "a
b", "a
b", "a
b"] { + assert_eq!( + parse_single_line_text(source), + "a\nb", + "failed for {source:?}" + ); + } +} + +#[test] +fn test_br_case_insensitive() { + for source in ["a
b", "a
b", "a
b", "a
b"] { + assert_eq!( + parse_single_line_text(source), + "a\nb", + "failed for {source:?}" + ); + } +} + +#[test] +fn test_br_consecutive_produce_two_breaks() { + // `

` is two forced breaks (one visually blank line), not a paragraph boundary. + assert_eq!(parse_single_line_text("a

b"), "a\n\nb"); +} + +#[test] +fn test_br_malformed_stays_literal() { + // Each of these fails the `
` shape and must survive as literal text, not a break. + // - `` + // - `< br>` : whitespace before the tag name + // - `
` : end tag, not a void element + // - `
` : attributes are intentionally rejected (deterministic fallback) + // - `` : not the `br` tag name + for source in [ + "a
b", + "a
b", + "a
b", + "ab", + ] { + let fragments = parse_single_line_fragments(source); + let joined: String = fragments.iter().map(|f| f.text.as_str()).collect(); + assert!( + !joined.contains('\n'), + "expected no line break for malformed {source:?}, got {fragments:?}" + ); + // The literal tag text survives. + let literal = source.trim_start_matches('a').trim_end_matches('b'); + assert!( + joined.contains(literal), + "expected literal {literal:?} to survive for {source:?}, got {joined:?}" + ); + } +} + +#[test] +fn test_br_inside_code_span_is_literal() { + // Code spans are consumed atomically before the `
` parser sees their contents. + let fragments = parse_single_line_fragments("before `no
here` after"); + let joined: String = fragments.iter().map(|f| f.text.as_str()).collect(); + assert!( + !joined.contains('\n'), + "expected no break inside a code span, got {fragments:?}" + ); + assert!( + joined.contains("
"), + "expected literal
preserved inside code span, got {joined:?}" + ); +} + +#[test] +fn test_kbd_tag_still_literal_control() { + // Control: an unrelated raw tag is NOT special-cased and stays literal text. + let fragments = parse_single_line_fragments("Press K now"); + let joined: String = fragments.iter().map(|f| f.text.as_str()).collect(); + assert!(!joined.contains('\n'), "kbd must not produce a break"); + assert!( + joined.contains("") && joined.contains(""), + "expected literal tags preserved, got {joined:?}" + ); +} + +#[test] +fn test_br_inside_gfm_table_cell_becomes_line_break() { + let source = "| Feature | Notes |\n| --- | --- |\n| Export | Supports CSV.
Also JSON. |\n"; + let result = test_parse_markdown_with_gfm_tables(source); + assert_eq!(result.len(), 1); + + let FormattedTextLine::Table(table) = &result[0] else { + panic!("expected a table, got {result:?}"); + }; + let cell_text: String = table.rows[0][1].iter().map(|f| f.text.as_str()).collect(); + assert_eq!( + cell_text, "Supports CSV.\nAlso JSON.", + "expected the cell to contain an embedded newline break, got {:?}", + table.rows[0][1] + ); +} + +#[test] +fn test_br_in_table_cell_round_trips_as_literal_br() { + // A break inside a cell must serialize back to `
` in both the internal tab/newline + // format and the GFM pipe-table format, never as a raw `\n` that would split the row. + let source = "| A | B |\n| --- | --- |\n| one
two | plain |\n"; + let result = test_parse_markdown_with_gfm_tables(source); + let FormattedTextLine::Table(table) = &result[0] else { + panic!("expected a table, got {result:?}"); + }; + + let internal = table.to_internal_format(); + // Exactly two lines (header + one data row), no spurious row from the in-cell break. + assert_eq!( + internal.lines().count(), + 2, + "in-cell break must not add a table row, got {internal:?}" + ); + assert!( + internal.contains("one
two"), + "expected
in internal format, got {internal:?}" + ); + + let plain = table.to_plain_text(); + assert!( + plain.contains("| one
two |"), + "expected
in GFM plain text, got {plain:?}" + ); + // Header + separator + one data row. + assert_eq!( + plain.lines().count(), + 3, + "in-cell break must not add a pipe-table row, got {plain:?}" + ); +} + +// Regression guards for `
` combined with inline style delimiters. A `
` inside a +// styled run keeps the surrounding style on both sides of the embedded newline; fragments with +// identical styles coalesce, so the break lives inside a single styled fragment. + +#[test] +fn test_br_inside_bold_keeps_bold_across_break() { + let fragments = parse_single_line_fragments("**a
b**"); + let joined: String = fragments.iter().map(|f| f.text.as_str()).collect(); + assert_eq!(joined, "a\nb", "expected a single break, got {fragments:?}"); + assert!( + fragments + .iter() + .all(|f| f.styles.weight == Some(CustomWeight::Bold)), + "both sides of the break should stay bold, got {fragments:?}" + ); +} + +#[test] +fn test_br_inside_underline_keeps_underline_across_break() { + let fragments = parse_single_line_fragments("a
b
"); + let joined: String = fragments.iter().map(|f| f.text.as_str()).collect(); + assert_eq!(joined, "a\nb", "expected a single break, got {fragments:?}"); + assert!( + fragments.iter().all(|f| f.styles.underline), + "both sides of the break should stay underlined, got {fragments:?}" + ); +} + +#[test] +fn test_br_inside_link_keeps_hyperlink_across_break() { + let fragments = parse_single_line_fragments("[a
b](https://x.com)"); + let joined: String = fragments.iter().map(|f| f.text.as_str()).collect(); + assert_eq!(joined, "a\nb", "expected a single break, got {fragments:?}"); + assert!( + fragments.iter().all(|f| matches!( + &f.styles.hyperlink, + Some(Hyperlink::Url(url)) if url == "https://x.com" + )), + "both sides of the break should keep the hyperlink, got {fragments:?}" + ); +}