From 2b5c8707aa71295bee9691802d83bee9a5d9d520 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 13:26:53 +0000 Subject: [PATCH] feat(core): implement table rendering in ANSIRenderer (core-markdown-parsing TASK-03) Two-pass column-width scan for alignment, box-drawing separators, and safe truncation at 80 columns for wide tables. https://claude.ai/code/session_01RinwPH9x34irgsdU7FoUNK --- Sources/Core/ANSIRenderer.swift | 63 +++++++++++++++++++++++++++++ docs/specs/core-markdown-parsing.md | 2 +- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/Sources/Core/ANSIRenderer.swift b/Sources/Core/ANSIRenderer.swift index b87e5f4..619b86e 100644 --- a/Sources/Core/ANSIRenderer.swift +++ b/Sources/Core/ANSIRenderer.swift @@ -15,6 +15,13 @@ public struct ANSIRenderer: MarkupVisitor { public init() {} + private func plainText(from markup: any Markup) -> String { + if let text = markup as? Text { + return text.string + } + return markup.children.map { plainText(from: $0) }.joined() + } + public func render(_ document: MarkdownDocument) -> String { var renderer = self return renderer.visit(document.document) @@ -133,6 +140,62 @@ public struct ANSIRenderer: MarkupVisitor { return items.joined(separator: "\n") } + public mutating func visitTable(_ table: Table) -> String { + let headRows = table.head.children.compactMap { $0 as? Table.Row } + let bodyRows = table.body.children.compactMap { $0 as? Table.Row } + let allRows = headRows + bodyRows + guard !allRows.isEmpty else { return "" } + + let cellTexts: [[String]] = allRows.map { row in + row.children.compactMap { $0 as? Table.Cell }.map { plainText(from: $0) } + } + let columnWidths = computeColumnWidths(from: cellTexts) + guard !columnWidths.isEmpty else { return "" } + + var lines: [String] = [] + lines.append(tableSeparator(columnWidths: columnWidths, left: "┌", mid: "┬", right: "┐")) + for rowIdx in headRows.indices { + lines.append(tableRow(cellTexts[rowIdx], columnWidths: columnWidths, bold: true)) + } + if !headRows.isEmpty { + lines.append(tableSeparator(columnWidths: columnWidths, left: "├", mid: "┼", right: "┤")) + } + let headCount = headRows.count + for rowIdx in bodyRows.indices { + lines.append(tableRow(cellTexts[headCount + rowIdx], columnWidths: columnWidths, bold: false)) + } + lines.append(tableSeparator(columnWidths: columnWidths, left: "└", mid: "┴", right: "┘")) + return lines.joined(separator: "\n") + } + + private func computeColumnWidths(from cellTexts: [[String]]) -> [Int] { + let columnCount = cellTexts.map(\.count).max() ?? 0 + guard columnCount > 0 else { return [] } + var widths = Array(repeating: 0, count: columnCount) + for row in cellTexts { + for (idx, text) in row.enumerated() where idx < columnCount { + widths[idx] = max(widths[idx], text.count) + } + } + return widths + } + + private func tableSeparator(columnWidths: [Int], left: String, mid: String, right: String) -> String { + let inner = columnWidths.map { String(repeating: "─", count: $0 + 2) }.joined(separator: mid) + let line = left + inner + right + return line.count > 80 ? String(line.prefix(80)) : line + } + + private func tableRow(_ cells: [String], columnWidths: [Int], bold: Bool) -> String { + let parts = columnWidths.indices.map { idx -> String in + let text = idx < cells.count ? cells[idx] : "" + let padded = text + String(repeating: " ", count: max(0, columnWidths[idx] - text.count)) + return bold ? " \(ANSI.bold)\(padded)\(ANSI.reset) " : " \(padded) " + } + let line = "│" + parts.joined(separator: "│") + "│" + return line.count > 80 ? String(line.prefix(80)) : line + } + public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> String { "\n" } diff --git a/docs/specs/core-markdown-parsing.md b/docs/specs/core-markdown-parsing.md index 46ba9bc..069d11c 100644 --- a/docs/specs/core-markdown-parsing.md +++ b/docs/specs/core-markdown-parsing.md @@ -58,7 +58,7 @@ Implements the `MarkdownDocument` model and a terminal renderer that converts pa - [ ] Tables with varying column widths align correctly. - [ ] A table wider than the terminal width does not crash — it truncates or wraps gracefully. -**Status:** todo +**Status:** done ---