markdown: Add support for colspan and rowspan for HTML tables (#39898)

Closes https://github.com/zed-industries/zed/issues/39837

This PR adds support for `colspan` feature that is only supported for
HTML tables. I also fixed an edge case where the right side border was
not applied because it didn't match the total column count.

**Before**
<img width="725" height="179"
alt="499166907-385cc787-fc89-4e6d-bf06-c72c3c0bd775"
src="https://github.com/user-attachments/assets/69586053-9893-4c92-aa89-7830d2bc7a6d"
/>

**After**
<img width="1165" height="180" alt="Screenshot 2025-10-21 at 22 51 55"
src="https://github.com/user-attachments/assets/f40686e7-d95b-45a6-be42-e226e2f77483"
/>

```html
<table>
    <tr>
        <th rowspan="2">Region</th>
        <th colspan="2">Revenue</th>
        <th rowspan="2">Growth</th>
    </tr>
    <tr>
        <th>Q2 2024</th>
        <th>Q3 2024</th>
    </tr>
    <tr>
        <td>North America</td>
        <td>$2.8M</td>
        <td>$2.4B</td>
        <td>+85,614%</td>
    </tr>
    <tr>
        <td>Europe</td>
        <td>$1.2M</td>
        <td>$1.9B</td>
        <td>+158,233%</td>
    </tr>
    <tr>
        <td>Asia-Pacific</td>
        <td>$0.5M</td>
        <td>$1.4B</td>
        <td>+279,900%</td>
    </tr>
</table>
```

**TODO**:
- [x] Add tests for rending logic
- [x] Test all the tables again

cc @bennetbo

Release Notes:

- Markdown: Added support for `colspan` and `rowspan` for HTML tables

---------

Co-authored-by: Zed AI <ai@zed.nl>
Co-authored-by: Anthony Eid <hello@anthonyeid.me>
This commit is contained in:
Remco Smits
2025-10-22 13:10:37 -04:00
committed by GitHub
co-authored by Zed AI Anthony Eid
parent 96a0db24d9
commit d558005058
3 changed files with 385 additions and 159 deletions
+141 -34
View File
@@ -462,9 +462,9 @@ impl<'a> MarkdownParser<'a> {
fn parse_table(&mut self, alignment: Vec<Alignment>) -> ParsedMarkdownTable {
let (_event, source_range) = self.previous().unwrap();
let source_range = source_range.clone();
let mut header = ParsedMarkdownTableRow::new();
let mut header = vec![];
let mut body = vec![];
let mut current_row = vec![];
let mut row_columns = vec![];
let mut in_header = true;
let column_alignments = alignment.iter().map(Self::convert_alignment).collect();
@@ -484,17 +484,21 @@ impl<'a> MarkdownParser<'a> {
Event::Start(Tag::TableCell) => {
self.cursor += 1;
let cell_contents = self.parse_text(false, Some(source_range));
current_row.push(cell_contents);
row_columns.push(ParsedMarkdownTableColumn {
col_span: 1,
row_span: 1,
is_header: in_header,
children: cell_contents,
});
}
Event::End(TagEnd::TableHead) | Event::End(TagEnd::TableRow) => {
self.cursor += 1;
let new_row = std::mem::take(&mut current_row);
let columns = std::mem::take(&mut row_columns);
if in_header {
header.children = new_row;
header.push(ParsedMarkdownTableRow { columns: columns });
in_header = false;
} else {
let row = ParsedMarkdownTableRow::with_children(new_row);
body.push(row);
body.push(ParsedMarkdownTableRow::with_columns(columns));
}
}
Event::End(TagEnd::Table) => {
@@ -941,6 +945,70 @@ impl<'a> MarkdownParser<'a> {
}
}
fn parse_table_row(
&self,
source_range: Range<usize>,
node: &Rc<markup5ever_rcdom::Node>,
) -> Option<ParsedMarkdownTableRow> {
let mut columns = Vec::new();
match &node.data {
markup5ever_rcdom::NodeData::Element { name, .. } => {
if local_name!("tr") != name.local {
return None;
}
for node in node.children.borrow().iter() {
if let Some(column) = self.parse_table_column(source_range.clone(), node) {
columns.push(column);
}
}
}
_ => {}
}
if columns.is_empty() {
None
} else {
Some(ParsedMarkdownTableRow { columns })
}
}
fn parse_table_column(
&self,
source_range: Range<usize>,
node: &Rc<markup5ever_rcdom::Node>,
) -> Option<ParsedMarkdownTableColumn> {
match &node.data {
markup5ever_rcdom::NodeData::Element { name, attrs, .. } => {
if !matches!(name.local, local_name!("th") | local_name!("td")) {
return None;
}
let mut children = MarkdownParagraph::new();
self.consume_paragraph(source_range, node, &mut children);
Some(ParsedMarkdownTableColumn {
col_span: std::cmp::max(
Self::attr_value(attrs, local_name!("colspan"))
.and_then(|span| span.parse().ok())
.unwrap_or(1),
1,
),
row_span: std::cmp::max(
Self::attr_value(attrs, local_name!("rowspan"))
.and_then(|span| span.parse().ok())
.unwrap_or(1),
1,
),
is_header: matches!(name.local, local_name!("th")),
children,
})
}
_ => None,
}
}
fn consume_children(
&self,
source_range: Range<usize>,
@@ -1056,7 +1124,7 @@ impl<'a> MarkdownParser<'a> {
node: &Rc<markup5ever_rcdom::Node>,
source_range: Range<usize>,
) -> Option<ParsedMarkdownTable> {
let mut header_columns = Vec::new();
let mut header_rows = Vec::new();
let mut body_rows = Vec::new();
// node should be a thead or tbody element
@@ -1066,21 +1134,16 @@ impl<'a> MarkdownParser<'a> {
if local_name!("thead") == name.local {
// node should be a tr element
for node in node.children.borrow().iter() {
let mut paragraph = MarkdownParagraph::new();
self.consume_paragraph(source_range.clone(), node, &mut paragraph);
for paragraph in paragraph.into_iter() {
header_columns.push(vec![paragraph]);
if let Some(row) = self.parse_table_row(source_range.clone(), node) {
header_rows.push(row);
}
}
} else if local_name!("tbody") == name.local {
// node should be a tr element
for node in node.children.borrow().iter() {
let mut row = MarkdownParagraph::new();
self.consume_paragraph(source_range.clone(), node, &mut row);
body_rows.push(ParsedMarkdownTableRow::with_children(
row.into_iter().map(|column| vec![column]).collect(),
));
if let Some(row) = self.parse_table_row(source_range.clone(), node) {
body_rows.push(row);
}
}
}
}
@@ -1088,12 +1151,12 @@ impl<'a> MarkdownParser<'a> {
}
}
if !header_columns.is_empty() || !body_rows.is_empty() {
if !header_rows.is_empty() || !body_rows.is_empty() {
Some(ParsedMarkdownTable {
source_range,
body: body_rows,
column_alignments: Vec::default(),
header: ParsedMarkdownTableRow::with_children(header_columns),
header: header_rows,
})
} else {
None
@@ -1589,10 +1652,19 @@ mod tests {
ParsedMarkdown {
children: vec![ParsedMarkdownElement::Table(table(
0..366,
row(vec![text("Id", 0..366), text("Name ", 0..366)]),
vec![row(vec![
column(1, 1, true, text("Id", 0..366)),
column(1, 1, true, text("Name ", 0..366))
])],
vec![
row(vec![text("1", 0..366), text("Chris", 0..366)]),
row(vec![text("2", 0..366), text("Dennis", 0..366)]),
row(vec![
column(1, 1, false, text("1", 0..366)),
column(1, 1, false, text("Chris", 0..366))
]),
row(vec![
column(1, 1, false, text("2", 0..366)),
column(1, 1, false, text("Dennis", 0..366))
]),
],
))],
},
@@ -1622,10 +1694,16 @@ mod tests {
ParsedMarkdown {
children: vec![ParsedMarkdownElement::Table(table(
0..240,
row(vec![]),
vec![],
vec![
row(vec![text("1", 0..240), text("Chris", 0..240)]),
row(vec![text("2", 0..240), text("Dennis", 0..240)]),
row(vec![
column(1, 1, false, text("1", 0..240)),
column(1, 1, false, text("Chris", 0..240))
]),
row(vec![
column(1, 1, false, text("2", 0..240)),
column(1, 1, false, text("Dennis", 0..240))
]),
],
))],
},
@@ -1651,7 +1729,10 @@ mod tests {
ParsedMarkdown {
children: vec![ParsedMarkdownElement::Table(table(
0..150,
row(vec![text("Id", 0..150), text("Name", 0..150)]),
vec![row(vec![
column(1, 1, true, text("Id", 0..150)),
column(1, 1, true, text("Name", 0..150))
])],
vec![],
))],
},
@@ -1833,7 +1914,10 @@ Some other content
let expected_table = table(
0..48,
row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
vec![row(vec![
column(1, 1, true, text("Header 1", 1..11)),
column(1, 1, true, text("Header 2", 12..22)),
])],
vec![],
);
@@ -1853,10 +1937,19 @@ Some other content
let expected_table = table(
0..95,
row(vec![text("Header 1", 1..11), text("Header 2", 12..22)]),
vec![row(vec![
column(1, 1, true, text("Header 1", 1..11)),
column(1, 1, true, text("Header 2", 12..22)),
])],
vec![
row(vec![text("Cell 1", 49..59), text("Cell 2", 60..70)]),
row(vec![text("Cell 3", 73..83), text("Cell 4", 84..94)]),
row(vec![
column(1, 1, false, text("Cell 1", 49..59)),
column(1, 1, false, text("Cell 2", 60..70)),
]),
row(vec![
column(1, 1, false, text("Cell 3", 73..83)),
column(1, 1, false, text("Cell 4", 84..94)),
]),
],
);
@@ -2313,7 +2406,7 @@ fn main() {
fn table(
source_range: Range<usize>,
header: ParsedMarkdownTableRow,
header: Vec<ParsedMarkdownTableRow>,
body: Vec<ParsedMarkdownTableRow>,
) -> ParsedMarkdownTable {
ParsedMarkdownTable {
@@ -2324,8 +2417,22 @@ fn main() {
}
}
fn row(children: Vec<MarkdownParagraph>) -> ParsedMarkdownTableRow {
ParsedMarkdownTableRow { children }
fn row(columns: Vec<ParsedMarkdownTableColumn>) -> ParsedMarkdownTableRow {
ParsedMarkdownTableRow { columns }
}
fn column(
col_span: usize,
row_span: usize,
is_header: bool,
children: MarkdownParagraph,
) -> ParsedMarkdownTableColumn {
ParsedMarkdownTableColumn {
col_span,
row_span,
is_header,
children,
}
}
impl PartialEq for ParsedMarkdownTable {