Skip to content

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 →