Skip to main content

moss_core/
csv_table.rs

1//! Pure CSV/TSV → HTML table renderer.
2//!
3//! No I/O: caller supplies the file content as a string. Called from
4//! src-tauri's Deferred-marker resolver for `![[data.csv]]` embeds via
5//! [`TableRenderer`](crate::resolve::embed_renderer::TableRenderer).
6
7/// Options controlling how a CSV/TSV payload is rendered as an HTML table.
8pub struct CsvTableOptions {
9    /// Column separator: `,` for CSV, `\t` for TSV.
10    pub separator: char,
11    /// Treat the first row as `<th>` headers.
12    pub has_header: bool,
13    /// Optional caption rendered inside a `<caption>` element.
14    pub caption: Option<String>,
15    /// CSS class applied to the wrapping `<table>` element.
16    pub class: String,
17    /// Optional `data-type` attribute applied to the wrapping `<table>` element
18    /// (v1 vocabulary: `.moss-embed[data-type="table"]`).
19    pub data_type: Option<String>,
20}
21
22/// Render a CSV/TSV string as HTML. Caller supplies options; content is
23/// HTML-escaped before emission.
24///
25/// The table is wrapped in the same `.moss-table-scroll` container the
26/// Markdown-table renderer uses (`ast::render`). CSV/TSV embeds are the
27/// data-heavy path, so they get the identical accessible horizontal-scroll
28/// affordance (keyboard-focusable, no page overflow on a narrow viewport) and
29/// share the default table styling — one consistent look for every moss table.
30pub fn render(content: &str, options: &CsvTableOptions) -> String {
31    let rows = parse_rows(content, options.separator);
32    let table = build_table(&rows, options);
33    format!("<div class=\"moss-table-scroll\" tabindex=\"0\">{table}</div>")
34}
35
36fn parse_rows(content: &str, sep: char) -> Vec<Vec<String>> {
37    // Minimal CSV parser: handles quoted fields with embedded commas, newlines,
38    // and escaped quotes (`""` → `"`). Not a full RFC 4180 parser but correct
39    // for well-formed authored data. Upgrade to the `csv` crate if needed.
40    let mut rows: Vec<Vec<String>> = Vec::new();
41    let mut row: Vec<String> = Vec::new();
42    let mut field = String::new();
43    let mut in_quotes = false;
44    let mut chars = content.chars().peekable();
45
46    while let Some(c) = chars.next() {
47        match (c, in_quotes) {
48            ('"', true) if chars.peek() == Some(&'"') => {
49                chars.next();
50                field.push('"');
51            }
52            ('"', true) => in_quotes = false,
53            ('"', false) => in_quotes = true,
54            (c, true) => field.push(c),
55            (c, false) if c == sep => {
56                row.push(std::mem::take(&mut field));
57            }
58            ('\n', false) => {
59                row.push(std::mem::take(&mut field));
60                rows.push(std::mem::take(&mut row));
61            }
62            ('\r', false) => { /* swallow; CRLF handled by \n branch */ }
63            (c, false) => field.push(c),
64        }
65    }
66    if !field.is_empty() || !row.is_empty() {
67        row.push(field);
68        rows.push(row);
69    }
70    rows
71}
72
73fn build_table(rows: &[Vec<String>], opts: &CsvTableOptions) -> String {
74    let data_type_attr = opts.data_type.as_deref()
75        .map(|t| format!(" data-type=\"{}\"", t))
76        .unwrap_or_default();
77    if rows.is_empty() {
78        return format!("<table class=\"{}\"{}></table>", opts.class, data_type_attr);
79    }
80    let mut out = String::new();
81    out.push_str(&format!("<table class=\"{}\"{}>", opts.class, data_type_attr));
82    if let Some(cap) = &opts.caption {
83        out.push_str(&format!("<caption>{}</caption>", escape(cap)));
84    }
85    let (header, body) = if opts.has_header {
86        (Some(&rows[0]), &rows[1..])
87    } else {
88        (None, rows)
89    };
90    if let Some(h) = header {
91        out.push_str("<thead><tr>");
92        for cell in h {
93            out.push_str(&format!("<th>{}</th>", escape(cell)));
94        }
95        out.push_str("</tr></thead>");
96    }
97    out.push_str("<tbody>");
98    for row in body {
99        out.push_str("<tr>");
100        for cell in row {
101            out.push_str(&format!("<td>{}</td>", escape(cell)));
102        }
103        out.push_str("</tr>");
104    }
105    out.push_str("</tbody></table>");
106    out
107}
108
109fn escape(s: &str) -> String {
110    s.replace('&', "&amp;")
111        .replace('<', "&lt;")
112        .replace('>', "&gt;")
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn opts_default() -> CsvTableOptions {
120        CsvTableOptions {
121            separator: ',',
122            has_header: true,
123            caption: None,
124            class: "moss-embed".to_string(),
125            data_type: Some("table".to_string()),
126        }
127    }
128
129    #[test]
130    fn test_csv_basic() {
131        let csv = "name,age\nAlice,30\nBob,25\n";
132        let out = render(csv, &opts_default());
133        assert!(out.contains("<thead><tr><th>name</th><th>age</th></tr></thead>"));
134        assert!(out.contains("<td>Alice</td><td>30</td>"));
135        assert!(out.contains("<td>Bob</td><td>25</td>"));
136        assert!(out.contains("class=\"moss-embed\" data-type=\"table\""));
137    }
138
139    #[test]
140    fn test_csv_no_header() {
141        let csv = "a,1\nb,2\n";
142        let opts = CsvTableOptions {
143            has_header: false,
144            ..opts_default()
145        };
146        let out = render(csv, &opts);
147        assert!(!out.contains("<thead>"), "got: {}", out);
148        assert!(out.contains("<td>a</td>"));
149    }
150
151    #[test]
152    fn test_csv_quoted_field_with_comma() {
153        let csv = "name,note\n\"Smith, J\",\"hi, world\"\n";
154        let out = render(csv, &opts_default());
155        assert!(out.contains("<td>Smith, J</td>"), "got: {}", out);
156        assert!(out.contains("<td>hi, world</td>"), "got: {}", out);
157    }
158
159    #[test]
160    fn test_csv_escaped_quote() {
161        let csv = "name\n\"he said \"\"hi\"\"\"\n";
162        let out = render(csv, &opts_default());
163        assert!(out.contains("<td>he said \"hi\"</td>"), "got: {}", out);
164    }
165
166    #[test]
167    fn test_csv_html_escape() {
168        let csv = "html\n<script>alert(1)</script>\n";
169        let out = render(csv, &opts_default());
170        assert!(!out.contains("<script>"), "raw script leaked: {}", out);
171        assert!(out.contains("&lt;script&gt;"), "got: {}", out);
172    }
173
174    #[test]
175    fn test_tsv_tab_separator() {
176        let tsv = "a\tb\n1\t2\n";
177        let opts = CsvTableOptions {
178            separator: '\t',
179            ..opts_default()
180        };
181        let out = render(tsv, &opts);
182        assert!(out.contains("<th>a</th>"));
183        assert!(out.contains("<th>b</th>"));
184        assert!(out.contains("<td>1</td><td>2</td>"));
185    }
186
187    #[test]
188    fn test_csv_with_caption() {
189        let csv = "a\n1\n";
190        let opts = CsvTableOptions {
191            caption: Some("My Data".to_string()),
192            ..opts_default()
193        };
194        let out = render(csv, &opts);
195        assert!(out.contains("<caption>My Data</caption>"), "got: {}", out);
196    }
197
198    #[test]
199    fn test_csv_empty() {
200        let out = render("", &opts_default());
201        // Wrapped in the shared scroll container; the table is still emitted.
202        assert!(
203            out.starts_with("<div class=\"moss-table-scroll\" tabindex=\"0\">"),
204            "got: {}",
205            out
206        );
207        assert!(out.contains("<table"), "got: {}", out);
208        assert!(out.ends_with("</table></div>"), "got: {}", out);
209    }
210
211    #[test]
212    fn test_csv_wrapped_in_scroll_container() {
213        let out = render("name,age\nAlice,30\n", &opts_default());
214        assert!(
215            out.starts_with("<div class=\"moss-table-scroll\" tabindex=\"0\">"),
216            "CSV embed must share the scroll wrapper: {out}"
217        );
218        assert!(out.ends_with("</div>"), "got: {}", out);
219        // Table + its class survive inside the wrapper.
220        assert!(out.contains("class=\"moss-embed\" data-type=\"table\""), "{out}");
221    }
222
223    #[test]
224    fn test_csv_crlf_line_endings() {
225        let csv = "a,b\r\n1,2\r\n";
226        let out = render(csv, &opts_default());
227        assert!(out.contains("<th>a</th>"), "got: {}", out);
228        assert!(out.contains("<th>b</th>"), "got: {}", out);
229        assert!(out.contains("<td>1</td><td>2</td>"), "got: {}", out);
230    }
231}