// Markdown table rendering for `DataFrame`. The output follows the
// GitHub-flavored pipe-table layout (header / dashed separator / data
// rows) and pads every column to the maximum width of its header and
// rendered cells so the source text is also pleasant to read in a
// fixed-width terminal. Null cells render as the empty string,
// delegating to `Scalar::to_string`.
//
// GFM tables specifically, because they are the lingua
// franca of MoonFrame's two output targets — `README.md` snippets and
// JupyterBook-style notebooks — and they round-trip through every
// Markdown parser we care about without a dedicated renderer
// dependency. The renderer is a `DataFrame` method rather than an `io`
// entry point (the IO boundary: pure rendering lives in `frame`, only
// external serialisation stays in `io`); it remains a small,
// self-contained, dependency-free function.
///|
/// Render a `DataFrame` as a GitHub-flavored Markdown table.
///
/// The output is three blocks of pipe-bounded rows:
/// 1. header — column names, one cell per column;
/// 2. separator — dashes sized to each column;
/// 3. data — one row per record, in declaration order.
///
/// Column widths are aligned to `max(header, every rendered cell)`,
/// with a 3-character minimum so the dash separator never collapses
/// below the GFM-required floor. Null cells render as the empty string
/// (matching `Scalar::to_string`), so a null and a genuine empty-string
/// cell render identically — the renderer carries no null marker.
///
/// A frame with nothing to tabulate degrades gracefully:
/// - 0 columns → the empty string, at any height. A GFM table is built out
/// of cells, so a column-less frame has none to draw — including the `N×0`
/// frame, whose rows are real (`is_empty()` is `false`) but carry no
/// fields. The rendering cannot show them; `shape()` is what reports the
/// height of a column-less result;
/// - N columns / 0 rows → header + separator with no data rows.
///
/// `max_rows` caps the output at its first rows: when `self` has more rows
/// than that, the truncated count is appended below the table as
/// `... (N more rows)`, separated by a blank line — per the GFM tables
/// extension a table only ends at an empty line, so a banner glued to the
/// last row would parse as one more (single-cell) table ROW rather than
/// the paragraph below it the wording promises. A negative `max_rows` is
/// clamped to 0, so a frame that has rows still renders as header +
/// separator + the `... (N more rows)` banner; only a genuinely 0-row
/// frame is header + separator alone. Omitting `max_rows` renders every row.
///
/// Total — pure string assembly over already-validated frame data.
pub fn DataFrame::to_markdown(self : DataFrame, max_rows? : Int) -> String {
let nrows = self.nrows()
let shown = match max_rows {
None => nrows
Some(limit) => @order.clamp_take(limit, nrows)
}
let body = format_markdown(self, shown)
if shown < nrows {
"\{body}\n\{more_rows_banner(nrows - shown)}\n"
} else {
body
}
}
// ── internals ──────────────────────────────────────────────────────────
///|
/// Render the first `shown` rows of `df` as a Markdown table. The
/// header / separator are emitted whenever `df` has at least one
/// column, regardless of `shown` — a header-only table is still
/// useful shape information for callers limiting output.
fn format_markdown(df : DataFrame, shown : Int) -> String {
// `raw_names` supplies the column count and the unescaped header text;
// `names` is the GFM-escaped form used for both width computation and
// rendering, so a column name containing `|` / newline can't corrupt the
// table. Cell values come from the `cells` matrix below, not a name lookup.
let raw_names = df.columns()
let ncols = raw_names.length()
if ncols == 0 {
return ""
}
let names = raw_names.map(escape_md_cell)
// Pre-render every visible cell, GFM-escaped, so column widths can be
// computed in one forward pass before any layout. `table_cell_matrix` gives
// the raw per-row strings; escaping each here means the width alignment runs
// on the escaped form, accounting for the `|` → `\|` / `\` → `\\` expansions.
let cells : Array[Array[String]] = table_cell_matrix(df, shown).map(row => {
row.map(escape_md_cell)
})
let widths : Array[Int] = names.mapi((c, name) => {
let mut w = char_count(name)
for r in 0.. w {
w = cw
}
}
// GFM requires at least three dashes per separator cell — pad to
// a 3-char minimum so a short header (e.g. "a") doesn't produce
// `|-|` which some lenient parsers still accept but stricter ones
// (cmark, pulldown-cmark) reject.
if w < 3 {
w = 3
}
w
})
let buf = StringBuilder::new()
write_row(buf, names, widths)
// Separator row: dashes of `widths[c]` per column, padded with one
// space on each side to match the data-row layout.
buf.write_char('|')
for c in 0.. Unit {
buf.write_char('|')
for c, cell in row {
buf.write_char(' ')
buf.write_string(cell)
let pad = widths[c] - char_count(cell)
for _ in 0.. Int {
s.iter().count()
}
///|
/// Escape a value for a GFM table cell. A literal backslash is doubled
/// (`\` → `\\`) and a literal `|` is rewritten to `\|`, so neither a bare
/// pipe nor a backslash sitting just before a pipe can split the cell
/// into extra columns. (Without the backslash rule, a cell containing
/// `\|` would emit `\\|` — an escaped backslash followed by a *bare*,
/// table-splitting pipe.) CR / LF collapse to a single space so a
/// multi-line value can't terminate the table row mid-cell. Everything
/// else passes through. Width alignment runs on the escaped form (callers
/// escape before measuring), so the two-character expansions are
/// accounted for.
fn escape_md_cell(s : String) -> String {
let buf = StringBuilder::new()
for ch in s.iter() {
match ch {
'\\' => {
buf.write_char('\\')
buf.write_char('\\')
}
'|' => {
buf.write_char('\\')
buf.write_char('|')
}
'\n' | '\r' => buf.write_char(' ')
_ => buf.write_char(ch)
}
}
buf.to_string()
}