diff --git a/crates/nuwiki-core/src/ast/block.rs b/crates/nuwiki-core/src/ast/block.rs index c7affb7..2a12d09 100644 --- a/crates/nuwiki-core/src/ast/block.rs +++ b/crates/nuwiki-core/src/ast/block.rs @@ -118,11 +118,29 @@ pub struct DefinitionItemNode { pub definitions: Vec>, } +#[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, 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, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/nuwiki-core/src/ast/mod.rs b/crates/nuwiki-core/src/ast/mod.rs index 0c8dc86..35ca6c4 100644 --- a/crates/nuwiki-core/src/ast/mod.rs +++ b/crates/nuwiki-core/src/ast/mod.rs @@ -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, diff --git a/crates/nuwiki-core/src/render/html.rs b/crates/nuwiki-core/src/render/html.rs index 54c648b..7c404bf 100644 --- a/crates/nuwiki-core/src/render/html.rs +++ b/crates/nuwiki-core/src/render/html.rs @@ -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"\n")?; @@ -326,6 +326,7 @@ impl HtmlRenderer { n: &TableRowNode, row_idx: usize, layout: &[Vec], + alignments: &[crate::ast::TableAlign], w: &mut dyn Write, ) -> io::Result<()> { w.write_all(b"")?; @@ -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, "") diff --git a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs index ab8867d..afcc8a6 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/lexer.rs @@ -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), /// 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 = 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']' { diff --git a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs index c364a68..a38b0da 100644 --- a/crates/nuwiki-core/src/syntax/vimwiki/parser.rs +++ b/crates/nuwiki-core/src/syntax/vimwiki/parser.rs @@ -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 = Vec::new(); let mut next_is_header = false; let mut has_header = false; + let mut alignments: Vec = 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(_) diff --git a/crates/nuwiki-core/tests/vimwiki_lexer.rs b/crates/nuwiki-core/tests/vimwiki_lexer.rs index 236506e..cdaee99 100644 --- a/crates/nuwiki-core/tests/vimwiki_lexer.rs +++ b/crates/nuwiki-core/tests/vimwiki_lexer.rs @@ -352,7 +352,14 @@ fn simple_table_row() { #[test] fn table_header_separator_row() { - assert_eq!(lex("|---|---|\n"), vec![TableHeaderRow, Newline]); + use nuwiki_core::ast::TableAlign; + assert_eq!( + lex("|---|---|\n"), + vec![ + TableHeaderRow(vec![TableAlign::Default, TableAlign::Default]), + Newline + ] + ); } #[test] diff --git a/crates/nuwiki-core/tests/vimwiki_table_alignment.rs b/crates/nuwiki-core/tests/vimwiki_table_alignment.rs new file mode 100644 index 0000000..5bd6bf9 --- /dev/null +++ b/crates/nuwiki-core/tests/vimwiki_table_alignment.rs @@ -0,0 +1,57 @@ +//! Cluster 7 — Markdown-style alignment markers on the header +//! separator row (`|:--|--:|:--:|`) propagate to the AST and HTML. + +use nuwiki_core::ast::{BlockNode, TableAlign}; +use nuwiki_core::render::{HtmlRenderer, Renderer}; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +#[test] +fn separator_row_with_no_anchors_yields_defaults() { + let doc = parse("| h1 | h2 |\n|----|----|\n| a | b |\n"); + let table = match &doc.children[0] { + BlockNode::Table(t) => t, + _ => panic!("expected table"), + }; + assert_eq!( + table.alignments, + vec![TableAlign::Default, TableAlign::Default] + ); +} + +#[test] +fn left_right_center_anchors_parsed() { + let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n"); + let table = match &doc.children[0] { + BlockNode::Table(t) => t, + _ => panic!("expected table"), + }; + assert_eq!( + table.alignments, + vec![TableAlign::Left, TableAlign::Right, TableAlign::Center] + ); +} + +#[test] +fn html_renderer_emits_text_align_style() { + let doc = parse("| a | b | c |\n|:---|---:|:---:|\n| 1 | 2 | 3 |\n"); + let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); + assert!( + out.contains(r#" a "#), + "got: {out}" + ); + assert!(out.contains(r#" b "#)); + assert!(out.contains(r#" c "#)); + assert!(out.contains(r#" 1 "#)); +} + +#[test] +fn render_without_alignment_omits_style_attr() { + let doc = parse("| a | b |\n|---|---|\n| 1 | 2 |\n"); + let out = HtmlRenderer::new().render_to_string(&doc).unwrap(); + assert!(!out.contains("text-align")); +} diff --git a/crates/nuwiki-core/tests/visitor.rs b/crates/nuwiki-core/tests/visitor.rs index ad5ba1c..071c20b 100644 --- a/crates/nuwiki-core/tests/visitor.rs +++ b/crates/nuwiki-core/tests/visitor.rs @@ -149,6 +149,7 @@ fn visitor_descends_into_every_node() { let table = BlockNode::Table(TableNode { span: Span::default(), has_header: false, + alignments: Vec::new(), rows: vec![TableRowNode { span: Span::default(), is_header: false, diff --git a/crates/nuwiki-lsp/tests/parity_cluster_1.rs b/crates/nuwiki-lsp/tests/parity_cluster_1.rs index 1a3beff..9d68ae3 100644 --- a/crates/nuwiki-lsp/tests/parity_cluster_1.rs +++ b/crates/nuwiki-lsp/tests/parity_cluster_1.rs @@ -111,6 +111,7 @@ fn table(rows: Vec>, has_header: bool) -> DocumentNode { span, rows, has_header, + alignments: Vec::new(), })], } }