Markdown Table Generator — Rust source
Turn pipe, CSV, tab, semicolon, or space-separated data into a clean GitHub-Flavored Markdown table. Auto-detects the delimiter, pads columns, escapes pipes, and supports per-column alignment - all in your browser.
This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! markdown-table — pure logic, Rust polyglot showcase port.
//!
//! Language: Rust
//! Origin: CosmoDev polyglot showcase port of the `markdown-table` tool.
//! Ported from: src/lib/markdown-table.ts (the canonical, live TypeScript lib).
//!
//! Parsing and rendering are deterministic and depend only on their inputs.
//! This file is display source — part of CosmoDev's polyglot tool pages, where
//! the same pure logic is shown side-by-side across languages.
/// The accepted input delimiters. An enum (rather than a raw `char`) keeps the
/// public API self-documenting and exhaustive.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Delimiter {
Pipe,
Comma,
Tab,
Semicolon,
Space,
}
impl Delimiter {
/// The single character this delimiter splits on.
const fn as_char(self) -> char {
match self {
Delimiter::Pipe => '|',
Delimiter::Comma => ',',
Delimiter::Tab => '\t',
Delimiter::Semicolon => ';',
Delimiter::Space => ' ',
}
}
}
/// Per-column alignment carried by the GFM separator row.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Align {
Left,
Center,
Right,
None,
}
/// Options for [`to_markdown`].
pub struct ToMarkdownOptions {
/// When `true`, row 0 is the table header. When `false`, a blank header row
/// is synthesized so the output is still valid GFM.
pub header: bool,
/// Per-column alignment; entries beyond the column count are ignored,
/// missing entries default to [`Align::None`].
pub align: Vec<Align>,
}
/// Candidates considered during auto-detection, in priority order.
/// Structural delimiters (pipe, tab) outrank punctuation (`,` / `;`) outrank space.
const CANDIDATES: [Delimiter; 5] = [
Delimiter::Pipe,
Delimiter::Tab,
Delimiter::Semicolon,
Delimiter::Comma,
Delimiter::Space,
];
/// Structural weight per delimiter. Higher wins ties.
fn weight(d: Delimiter) -> f64 {
match d {
Delimiter::Pipe | Delimiter::Tab => 3.0,
Delimiter::Semicolon | Delimiter::Comma => 2.0,
Delimiter::Space => 1.0,
}
}
/// Split a single line by `delimiter`, trimming each resulting cell.
fn split_line(line: &str, delimiter: Delimiter) -> Vec<String> {
match delimiter {
Delimiter::Pipe => {
let trimmed = line.trim();
// Strip one leading/trailing pipe so "| a | b |" works.
let no_leading = trimmed.strip_prefix('|').unwrap_or(trimmed);
let no_trailing = no_leading.strip_suffix('|').unwrap_or(no_leading);
if no_trailing.is_empty() {
// A fully-empty line collapses to a single empty cell, not zero cells.
return vec![String::new()];
}
no_trailing.split('|').map(|c| c.trim().to_owned()).collect()
}
Delimiter::Space => {
// split_whitespace trims and splits on runs of whitespace, matching /\s+/.
line.split_whitespace().map(|s| s.to_owned()).collect()
}
_ => line
.split(delimiter.as_char())
.map(|c| c.trim().to_owned())
.collect(),
}
}
/// Parse `input` into a 2-D grid of trimmed cells. Blank lines are skipped;
/// each remaining line is split by `delimiter`.
pub fn parse_table(input: &str, delimiter: Delimiter) -> Vec<Vec<String>> {
input
.lines() // str::lines splits on \n and strips a trailing \r → matches /\r?\n/
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| split_line(l, delimiter))
.collect()
}
/// Count delimiter occurrences in a line. Whitespace counts *runs* of
/// whitespace, not individual space characters.
fn count_occurrences(line: &str, delimiter: Delimiter) -> usize {
match delimiter {
Delimiter::Space => line.split_whitespace().count().saturating_sub(1),
_ => line.chars().filter(|&ch| ch == delimiter.as_char()).count(),
}
}
/// Heuristic delimiter detection. Each candidate is scored by
/// `frequency × cross-line consistency × structural weight`, and the best wins.
/// Falls back to comma when nothing scores (single column or empty input).
pub fn detect_delimiter(sample: &str) -> Delimiter {
let lines: Vec<&str> = sample
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if lines.is_empty() {
return Delimiter::Comma;
}
let mut best = Delimiter::Comma;
let mut best_score = 0.0_f64;
for &d in CANDIDATES.iter() {
let counts: Vec<f64> = lines
.iter()
.map(|l| count_occurrences(l, d) as f64)
.collect();
let avg = counts.iter().sum::<f64>() / counts.len() as f64;
if avg == 0.0 {
continue;
}
// population variance across lines → lower means more consistent
let variance: f64 =
counts.iter().map(|&c| (c - avg).powi(2)).sum::<f64>() / counts.len() as f64;
let consistency = 1.0 / (1.0 + variance);
let score = avg * consistency * weight(d);
if score > best_score {
best_score = score;
best = d;
}
}
best
}
/// Collapse any newline (CRLF or LF) to a single space, then escape literal
/// pipes so they don't terminate the cell.
fn escape_cell(cell: &str) -> String {
cell.replace("\r\n", " ")
.replace('\n', " ")
.replace('|', "\\|")
}
/// Pad a cell to `width` honoring alignment. Center splits the slack with the
/// floor on the left. `width` is measured in `char`s so multi-byte cells line up.
fn pad(cell: &str, width: usize, align: Align) -> String {
let len = cell.chars().count();
if len >= width {
return cell.to_owned();
}
let diff = width - len;
let spaces = " ".repeat(diff);
match align {
Align::Right => format!("{}{}", spaces, cell),
Align::Center => {
let left = diff / 2;
format!("{}{}{}", " ".repeat(left), cell, " ".repeat(diff - left))
}
Align::Left | Align::None => format!("{}{}", cell, spaces),
}
}
/// Render a separator cell (`---`, `:--`, `--:`, `:-:`) of at least 3 dashes.
fn sep_cell(align: Align, width: usize) -> String {
let w = width.max(3);
match align {
Align::Center => format!(":{}:", "-".repeat(w - 2)),
Align::Right => format!("{}:", "-".repeat(w - 1)),
Align::Left => format!(":{}", "-".repeat(w - 1)),
Align::None => "-".repeat(w),
}
}
/// Render a 2-D grid as a GitHub-Flavored Markdown table. Cells are padded to
/// equal column widths (computed from the escaped text), literal `|` is
/// escaped, and the separator row carries the per-column alignment.
/// Returns an empty string for an empty grid.
pub fn to_markdown(rows: &[Vec<String>], opts: &ToMarkdownOptions) -> String {
if rows.is_empty() {
return String::new();
}
// Column count is the longest row.
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
// Escape every cell and normalize each row to the column count.
let grid: Vec<Vec<String>> = rows
.iter()
.map(|r| {
let mut out: Vec<String> = r.iter().map(|c| escape_cell(c)).collect();
out.resize(cols, String::new());
out
})
.collect();
// Per-column alignment: missing entries default to None.
let aligns: Vec<Align> = (0..cols)
.map(|i| opts.align.get(i).copied().unwrap_or(Align::None))
.collect();
// Per-column width: at least 3 (GFM separator minimum), grown to fit the
// widest escaped cell in the column.
let widths: Vec<usize> = (0..cols)
.map(|c| {
let max_len = grid
.iter()
.map(|r| r[c].chars().count())
.max()
.unwrap_or(0);
max_len.max(3)
})
.collect();
// Pads and joins one row of cells with the GFM pipe framing.
let render_line = |cells: &[String]| -> String {
let padded: Vec<String> = cells
.iter()
.enumerate()
.map(|(i, c)| pad(c, widths[i], aligns[i]))
.collect();
format!("| {} |", padded.join(" | "))
};
let separator = {
let parts: Vec<String> = aligns
.iter()
.enumerate()
.map(|(i, &a)| sep_cell(a, widths[i]))
.collect();
format!("| {} |", parts.join(" | "))
};
// When there is no header, synthesize a blank header row so the table is
// still valid GFM.
let (header, data_start) = if opts.header {
(render_line(&grid[0]), 1_usize)
} else {
(render_line(&vec![String::new(); cols]), 0_usize)
};
let mut out: Vec<String> = vec![header, separator];
out.extend(grid[data_start..].iter().map(render_line));
out.join("\n")
}
/// Transpose a grid (rows ↔ columns). Jagged grids are filled with empty strings.
pub fn transpose(rows: &[Vec<String>]) -> Vec<Vec<String>> {
if rows.is_empty() {
return Vec::new();
}
let cols = rows.iter().map(|r| r.len()).max().unwrap_or(0);
(0..cols)
.map(|c| {
rows.iter()
.map(|r| r.get(c).cloned().unwrap_or_default())
.collect()
})
.collect()
}
fn main() {
// Small end-to-end demo so this file is runnable as a showcase.
let sample = "name,role,team\nAda,engineer,platform\nLin,designer,brand";
let d = detect_delimiter(sample);
let grid = parse_table(sample, d);
let opts = ToMarkdownOptions {
header: true,
align: vec![Align::Left, Align::Left, Align::Center],
};
let md = to_markdown(&grid, &opts);
println!("Detected delimiter: '{}'", d.as_char());
println!("{}", md);
}
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →