parity(7): table cell alignment markers (|:--|--:|:--:|)

The Markdown-style alignment anchors on a table's header-separator
row now propagate from the lexer through the parser into the AST,
and the HTML renderer emits a `style="text-align: …;"` attribute on
each affected `<th>` / `<td>`.

  |:--|   → left
  |--:|   → right
  |:--:|  → center
  |---|   → default (no style attr)

Encoding choice: rather than threading the source string into the
parser, the lexer now extracts per-cell alignment when it recognises
the separator row and carries the `Vec<TableAlign>` inline on the
`TableHeaderRow` token. The parser unpacks it into `TableNode`'s
new `alignments: Vec<TableAlign>` field. Cells past the end of the
vector render with the default alignment.

`TableAlign` lives in `nuwiki_core::ast::block` alongside the table
nodes and is re-exported via `nuwiki_core::ast`.

Tests: 4 new in crates/nuwiki-core/tests/vimwiki_table_alignment.rs
covering plain dashes, all three anchor flavours, the HTML output
shape, and the no-style-attr case. The existing
`vimwiki_lexer::table_header_separator_row` was updated to assert
the new payload shape.

Gates: 425 Rust / 39 Neovim / 12 Vim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:45:43 +00:00
parent 2562a046db
commit 214d54e3b9
9 changed files with 147 additions and 13 deletions
+18
View File
@@ -118,11 +118,29 @@ pub struct DefinitionItemNode {
pub definitions: Vec<Vec<InlineNode>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TableAlign {
Default,
Left,
Right,
Center,
}
impl Default for TableAlign {
fn default() -> Self {
Self::Default
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TableNode {
pub span: Span,
pub rows: Vec<TableRowNode>,
pub has_header: bool,
/// One entry per column, taken from a Markdown-style header
/// separator row (`|:--|--:|:--:|`). Empty when no alignment was
/// specified; cells past the end inherit `Default`.
pub alignments: Vec<TableAlign>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+2 -1
View File
@@ -12,7 +12,8 @@ pub mod visit;
pub use block::{
BlockNode, BlockquoteNode, CheckboxState, CommentNode, DefinitionItemNode, DefinitionListNode,
ErrorNode, HeadingNode, HorizontalRuleNode, ListItemNode, ListNode, ListSymbol, MathBlockNode,
ParagraphNode, PreformattedNode, TableCellNode, TableNode, TableRowNode, TagNode, TagScope,
ParagraphNode, PreformattedNode, TableAlign, TableCellNode, TableNode, TableRowNode, TagNode,
TagScope,
};
pub use inline::{
BoldItalicNode, BoldNode, CodeNode, ColorNode, InlineNode, ItalicNode, Keyword, KeywordNode,
+17 -2
View File
@@ -310,7 +310,7 @@ impl HtmlRenderer {
in_tbody = true;
}
}
self.render_table_row(row, row_idx, &layout, w)?;
self.render_table_row(row, row_idx, &layout, &n.alignments, w)?;
}
if in_thead {
w.write_all(b"</thead>\n")?;
@@ -326,6 +326,7 @@ impl HtmlRenderer {
n: &TableRowNode,
row_idx: usize,
layout: &[Vec<CellLayout>],
alignments: &[crate::ast::TableAlign],
w: &mut dyn Write,
) -> io::Result<()> {
w.write_all(b"<tr>")?;
@@ -341,7 +342,11 @@ impl HtmlRenderer {
match info {
CellLayout::Skip => continue,
CellLayout::Lead { colspan, rowspan } => {
self.render_table_cell(cell, n.is_header, colspan, rowspan, w)?;
let align = alignments
.get(col_idx)
.copied()
.unwrap_or(crate::ast::TableAlign::Default);
self.render_table_cell(cell, n.is_header, colspan, rowspan, align, w)?;
}
}
}
@@ -354,6 +359,7 @@ impl HtmlRenderer {
is_header: bool,
colspan: u32,
rowspan: u32,
align: crate::ast::TableAlign,
w: &mut dyn Write,
) -> io::Result<()> {
let tag = if is_header { "th" } else { "td" };
@@ -364,6 +370,15 @@ impl HtmlRenderer {
if rowspan > 1 {
write!(w, " rowspan=\"{rowspan}\"")?;
}
let align_attr = match align {
crate::ast::TableAlign::Left => Some("left"),
crate::ast::TableAlign::Right => Some("right"),
crate::ast::TableAlign::Center => Some("center"),
crate::ast::TableAlign::Default => None,
};
if let Some(a) = align_attr {
write!(w, " style=\"text-align: {a};\"")?;
}
write!(w, ">")?;
self.render_inlines(&n.children, w)?;
write!(w, "</{tag}>")
+35 -5
View File
@@ -68,8 +68,9 @@ pub enum VimwikiTokenKind {
// ----- Tables -----
/// `|` cell separator.
TableSep,
/// A header-separator row, e.g. `|---|---|`.
TableHeaderRow,
/// A header-separator row, e.g. `|---|---|`. Carries per-cell
/// Markdown-style alignment markers (`:--`, `--:`, `:--:`).
TableHeaderRow(Vec<crate::ast::TableAlign>),
/// A cell whose only content is `>`, meaning "merge with cell to the left".
TableColSpan,
/// A cell whose only content is `\/`, meaning "merge with cell above".
@@ -563,14 +564,20 @@ impl<'src> LexState<'src> {
return false;
}
// Header separator row: every cell content is dashes (e.g. "|---|---|").
// Header separator row: every cell is `-`s (with optional `:`
// anchors at one or both ends for alignment) or whitespace.
if trimmed
.split('|')
.filter(|s| !s.is_empty())
.all(|cell| cell.chars().all(|c| c == '-' || c == ' ' || c == '\t'))
.all(is_sep_cell)
{
let aligns: Vec<crate::ast::TableAlign> = trimmed
.split('|')
.filter(|s| !s.is_empty())
.map(parse_sep_alignment)
.collect();
let span = Span::new(self.line_start_pos(), self.line_end_pos(line));
self.push(VimwikiTokenKind::TableHeaderRow, span);
self.push(VimwikiTokenKind::TableHeaderRow(aligns), span);
self.emit_newline(line);
return true;
}
@@ -1199,6 +1206,29 @@ fn looks_like_list_marker(s: &str) -> bool {
list_marker_at(s).is_some_and(|(_, len)| s.as_bytes().get(len) == Some(&b' '))
}
/// Is this cell content a valid separator-cell body? Allows leading /
/// trailing `:` for alignment and inner runs of `-` plus whitespace.
fn is_sep_cell(cell: &str) -> bool {
let t = cell.trim();
if t.is_empty() {
return false;
}
let body = t.trim_start_matches(':').trim_end_matches(':');
!body.is_empty() && body.chars().all(|c| c == '-')
}
fn parse_sep_alignment(cell: &str) -> crate::ast::TableAlign {
let t = cell.trim();
let l = t.starts_with(':');
let r = t.ends_with(':');
match (l, r) {
(true, true) => crate::ast::TableAlign::Center,
(true, false) => crate::ast::TableAlign::Left,
(false, true) => crate::ast::TableAlign::Right,
_ => crate::ast::TableAlign::Default,
}
}
fn checkbox_at(s: &str) -> Option<(CheckboxState, usize)> {
let b = s.as_bytes();
if b.len() < 3 || b[0] != b'[' || b[2] != b']' {
@@ -210,7 +210,7 @@ impl<'a> ParseState<'a> {
K::HorizontalRule => self.parse_horizontal_rule(),
K::ListMarker { .. } => self.parse_list(),
K::BlockquoteMarker | K::BlockquoteIndent => self.parse_blockquote(),
K::TableSep | K::TableHeaderRow => self.parse_table(),
K::TableSep | K::TableHeaderRow(_) => self.parse_table(),
K::PreformattedOpen { .. } => self.parse_preformatted(),
K::MathBlockOpen { .. } => self.parse_math_block(),
K::CommentLine(_) => self.parse_single_comment(),
@@ -620,11 +620,15 @@ impl<'a> ParseState<'a> {
let mut rows: Vec<TableRowNode> = Vec::new();
let mut next_is_header = false;
let mut has_header = false;
let mut alignments: Vec<crate::ast::TableAlign> = Vec::new();
while let Some(t) = self.peek() {
match &t.kind {
K::TableHeaderRow => {
K::TableHeaderRow(aligns) => {
has_header = true;
if alignments.is_empty() {
alignments = aligns.clone();
}
if let Some(last) = rows.last_mut() {
last.is_header = true;
}
@@ -638,7 +642,6 @@ impl<'a> ParseState<'a> {
span_end = row.span.end;
rows.push(row);
if next_is_header {
// Header sep applies to the *previous* row, not this one.
next_is_header = false;
}
}
@@ -649,6 +652,7 @@ impl<'a> ParseState<'a> {
span: Span::new(span_start, span_end),
rows,
has_header,
alignments,
})
}
@@ -870,7 +874,7 @@ fn starts_new_block(kind: &K) -> bool {
| K::BlockquoteMarker
| K::BlockquoteIndent
| K::TableSep
| K::TableHeaderRow
| K::TableHeaderRow(_)
| K::PreformattedOpen { .. }
| K::MathBlockOpen { .. }
| K::CommentLine(_)