From 1722851d2eb1cd5712c106772db939d2710a07f7 Mon Sep 17 00:00:00 2001 From: "Victor M. Varela" Date: Fri, 3 Jul 2026 17:31:49 +0200 Subject: [PATCH] feat: add HTML table output format (-O html) (issue #173) Add html to the output format list. Render SQL query results as an HTML element with optional and CSS class support. - Add html variant to OutputFormat enum - Implement streaming HTML writer in OutputWriter (begin/writeRow/end) - HTML-escape all cell values, column names, and class attributes via writeXmlEscaped (reused from xml.zig) - NULL values render as empty cells, custom via --null-value - --header controls section - --html-class sets CSS class on
- Update README.md flags table - Update docs/sql-pipe.1.scd (options + examples) - 8 integration tests Closes #173 --- README.md | 5 ++-- build.zig | 73 +++++++++++++++++++++++++++++++++++++++++++++ docs/sql-pipe.1.scd | 48 +++++++++++++++++++++++++---- src/args.zig | 22 ++++++++++---- src/format.zig | 53 +++++++++++++++++++++++++++++++- src/main.zig | 9 ++++-- 6 files changed, 193 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e6e04aa..191de94 100644 --- a/README.md +++ b/README.md @@ -322,10 +322,10 @@ When `-f` is used, all positional arguments are treated as data files (no positi | `-d`, `--delimiter ` | Input field delimiter (single character, default `,`) | | `--tsv` | Alias for `--delimiter '\t'` | | `-I`, `--input-format ` | Input format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`. Overrides file extension auto-detection. | -| `-O`, `--output-format ` | Output format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`, `markdown` (alias: `md`), `sql` | +| `-O`, `--output-format ` | Output format: `csv` (default), `tsv`, `json`, `ndjson`, `xml`, `markdown` (alias: `md`), `html`, `sql` | | `--sql-table ` | Target table name for `-O sql` INSERT output (default: `t`) | | `--no-type-inference` | Treat all columns as TEXT (skip auto-detection) | -| `-H`, `--header` | Print column names as the first output row | +| `-H`, `--header` | Print column names as the first output row (CSV/TSV/HTML) | | `--json` | Alias for `--output-format json` (mutually exclusive with `-H`) | | `--max-rows ` | Stop if more than `n` data rows are read (exit 1) | | `--validate` | Parse the entire input and print a summary (`OK: rows, columns (col TYPE, ...)`) to stdout. Exit 0 on success, exit 2 on parse error. No query required. Compatible with `--delimiter`, `--tsv`, `--no-type-inference`, `-I`/`--input-format` (csv, tsv, json, ndjson, xml). JSON/NDJSON/XML columns are reported as TEXT. | @@ -341,6 +341,7 @@ When `-f` is used, all positional arguments are treated as data files (no positi | `--table` | Force pretty-printed table output (auto-detected when stdout is a TTY). Requires CSV/TSV output format. | | `--no-table` | Force CSV output even when stdout is a TTY | | `--null-value ` | Custom NULL representation in CSV/TSV/table output (default: `NULL`). JSON always uses native `null`. | +| `--html-class ` | CSS class name for the HTML `
` element (`-O html` only) | | `-f`, `--file ` | Read SQL query from file instead of command line | | `-v`, `--verbose` | Print `Loaded rows in s` to stderr after loading (always on TTY; forced with flag) | | `-s`, `--silent` | Suppress `Loaded rows in s` and the progress counter from stderr unconditionally. Cannot be combined with `-v`/`--verbose` | diff --git a/build.zig b/build.zig index 47f7b7f..598af7a 100644 --- a/build.zig +++ b/build.zig @@ -2241,6 +2241,79 @@ pub fn build(b: *std.Build) void { test_markdown_empty.step.dependOn(b.getInstallStep()); test_step.dependOn(&test_markdown_empty.step); + // ─── HTML output integration tests (issue #173) ────────────────────────────── + + // Integration test 173a: Basic HTML output + const test_html_basic = b.addSystemCommand(&.{ + "bash", "-c", + \\result=$(printf 'name,age\nAlice,30\nBob,25\n' | ./zig-out/bin/sql-pipe -O html 'SELECT * FROM t ORDER BY name') + \\echo "$result" | grep -Fq '
' && echo "$result" | grep -Fq '' && echo "$result" | grep -Fq '' && echo "$result" | grep -Fq '
Alice30
' + }); + test_html_basic.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_basic.step); + + // Integration test 173b: HTML output with --header produces + const test_html_header = b.addSystemCommand(&.{ + "bash", "-c", + \\result=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe -O html --header 'SELECT * FROM t') + \\echo "$result" | grep -Fq 'nameage' + }); + test_html_header.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_header.step); + + // Integration test 173c: HTML output with --html-class + const test_html_class = b.addSystemCommand(&.{ + "bash", "-c", + \\result=$(printf 'name,age\nAlice,30\n' | ./zig-out/bin/sql-pipe -O html --html-class 'my-table' 'SELECT * FROM t') + \\echo "$result" | grep -Fq '' + }); + test_html_class.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_class.step); + + // Integration test 173d: NULL renders as empty cell in HTML + const test_html_null = b.addSystemCommand(&.{ + "bash", "-c", + \\result=$(printf 'name,age\nAlice,30\nBob,\n' | ./zig-out/bin/sql-pipe -O html 'SELECT * FROM t ORDER BY name') + \\echo "$result" | grep -Fq '' + }); + test_html_null.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_null.step); + + // Integration test 173e: HTML escaping of special characters + const test_html_escape = b.addSystemCommand(&.{ + "bash", "-c", + \\result=$(printf 'name,data\nAlice,""\n' | ./zig-out/bin/sql-pipe -O html 'SELECT * FROM t') + \\echo "$result" | grep -Fq '<script>' + }); + test_html_escape.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_escape.step); + + // Integration test 173f: --null-value works with HTML + const test_html_null_value = b.addSystemCommand(&.{ + "bash", "-c", + \\result=$(printf 'name,age\nAlice,30\nBob,\n' | ./zig-out/bin/sql-pipe -O html --null-value 'N/A' 'SELECT * FROM t ORDER BY name') + \\echo "$result" | grep -Fq '' + }); + test_html_null_value.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_null_value.step); + + // Integration test 173g: --html-class missing value exits with error + const test_html_class_missing = b.addSystemCommand(&.{ + "bash", "-c", + \\./zig-out/bin/sql-pipe -O html --html-class 2>&1 | grep -q 'html-class requires a value' + }); + test_html_class_missing.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_class_missing.step); + + // Integration test 173h: --html-class attribute escaping + const test_html_class_escape = b.addSystemCommand(&.{ + "bash", "-c", + \\result=$(printf 'name\nAlice\n' | ./zig-out/bin/sql-pipe -O html --html-class 'a"class' 'SELECT * FROM t') + \\echo "$result" | grep -Fq 'class="a"class"' + }); + test_html_class_escape.step.dependOn(b.getInstallStep()); + test_step.dependOn(&test_html_class_escape.step); + // ─── SQL INSERT output integration tests (issue #174) ───────────────────── // Integration test 174a: Basic SQL INSERT output diff --git a/docs/sql-pipe.1.scd b/docs/sql-pipe.1.scd index 441ad8a..a1ae203 100644 --- a/docs/sql-pipe.1.scd +++ b/docs/sql-pipe.1.scd @@ -67,7 +67,7 @@ OPTIONS *-O, --output-format* Set the output format: *csv* (default), *tsv*, *json*, *ndjson*, - *xml*, *markdown* (alias: *md*), or *sql*. + *xml*, *markdown* (alias: *md*), *html*, or *sql*. *--sql-table* Target table name for *-O sql* INSERT output (default: *t*). @@ -81,7 +81,9 @@ OPTIONS Print a header row with column names as the first line of output. Column names are taken from the SQL column alias or original name. The output remains valid CSV that can be piped back into sql-pipe. Header output is - off by default. Cannot be combined with *--json*. + off by default. Cannot be combined with *--json*. Works with CSV, TSV, + and HTML output formats. For *-O html*, controls whether a ** + section is emitted. *--json* Output results as a JSON array of objects instead of CSV. Each row @@ -178,10 +180,17 @@ OPTIONS with *--columns*, *--validate*, *--sample*, *--stats*, *--schema*, and *--output*. *--null-value* - Custom representation for SQL NULL values in CSV, TSV, and table - output. By default, NULL values are printed as the string *NULL*. - Pass an empty string (*--null-value ''*), *N/A*, or any other label. - Has no effect on JSON output (JSON always outputs native *null*). + Custom representation for SQL NULL values in CSV, TSV, table, and + HTML output. By default, NULL values are printed as the string *NULL* + in CSV/TSV/table, or as an empty cell in HTML/markdown. Pass an empty + string (*--null-value ''*), *N/A*, or any other label. Has no effect on + JSON output (JSON always outputs native *null*). + + *--html-class* + When using *-O html*, set a CSS class attribute on the *
N/A
* + element. The value is escaped for safe insertion into the HTML + attribute. Example: *--html-class 'data-table sortable'* produces + *
*. *-f, --file* Read the SQL query from instead of the command line. When @@ -313,6 +322,33 @@ EXAMPLES | Bob | 25 |++ | Carol | 35 | + Output results as an HTML table: + + $ printf 'name,age\nAlice,30\nBob,25' \ + | sql-pipe -O html 'SELECT * FROM t' + + Output:++ +
++ + ++ + ++ + ++ + ++ +
Alice30
Bob25
+ + Output with column headers and a CSS class: + + $ printf 'name,age\nAlice,30\nBob,25' \ + | sql-pipe -O html --header --html-class 'results' 'SELECT * FROM t' + + Output:++ + ++ + ++ + ++ + ++ + ++ + ++ +
nameage
Alice30
Bob25
+ Custom NULL representation (replace NULL with empty string): $ printf 'name,email\nAlice,a@b.com\nBob,\nCarol,c@d.com' \ diff --git a/src/args.zig b/src/args.zig index b6cb4c8..5ae78e4 100644 --- a/src/args.zig +++ b/src/args.zig @@ -77,6 +77,7 @@ pub const SqlPipeError = error{ MissingSqlTableValue, ExplainWithFlags, MissingNullValue, + MissingHtmlClassValue, }; pub const ParsedArgs = struct { @@ -126,7 +127,9 @@ pub const ParsedArgs = struct { table_mode: TableMode = .auto, /// Target table name for SQL INSERT output (default: "t"). sql_table: []const u8 = "t", - /// Custom string for NULL values in output (null = format default: "NULL" for CSV/TSV/table, "" for markdown). + /// CSS class name for the HTML element (default: "" = no class). + html_class: []const u8 = "", + /// Custom string for NULL values in output (default: "NULL" for CSV/TSV/table, "" for markdown). null_value: ?[]const u8 = null, }; @@ -236,11 +239,11 @@ pub fn printUsage(writer: *std.Io.Writer) !void { \\ --tsv Alias for --delimiter '\t' \\ -I, --input-format Input format: csv (default), tsv, json, ndjson, xml \\ Overrides file extension auto-detection; stdin always uses this value - \\ -O, --output-format Output format: csv (default), tsv, json, ndjson, xml, markdown (alias: md), sql + \\ -O, --output-format Output format: csv (default), tsv, json, ndjson, xml, markdown (alias: md), html, sql \\ --json Alias for --output-format json \\ --sql-table Target table name for -O sql INSERT output (default: t) \\ --no-type-inference Treat all columns as TEXT (CSV input only) - \\ -H, --header Print column names as the first output row (CSV/TSV output only) + \\ -H, --header Print column names as the first output row (CSV/TSV/HTML) \\ --max-rows Stop if more than data rows are read (exit 1) \\ -v, --verbose Force row count to stderr (shown automatically on TTY) \\ With --columns: show inferred type per column @@ -274,6 +277,7 @@ pub fn printUsage(writer: *std.Io.Writer) !void { \\ --table Force pretty-printed table output (auto-detected on TTY) \\ --no-table Force CSV output even when stdout is a TTY \\ --null-value Custom NULL representation in output (default: "NULL" for CSV/TSV/table) + \\ --html-class CSS class name for the HTML
element (-O html only) \\ -f, --file Read SQL query from file instead of command line \\ -h, --help Show this help message and exit \\ -V, --version Show version and exit @@ -358,6 +362,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP var explain = false; var table_mode: TableMode = .auto; var sql_table: []const u8 = "t"; + var html_class: []const u8 = ""; var seen_dashdash = false; var positional_args: std.ArrayList([]const u8) = .empty; defer positional_args.deinit(allocator); @@ -499,6 +504,12 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP null_value = args[i]; } else if (std.mem.startsWith(u8, arg, "--null-value=")) { null_value = arg["--null-value=".len..]; + } else if (std.mem.eql(u8, arg, "--html-class")) { + i += 1; + if (i >= args.len) return error.MissingHtmlClassValue; + html_class = args[i]; + } else if (std.mem.startsWith(u8, arg, "--html-class=")) { + html_class = arg["--html-class=".len..]; } else if (std.mem.eql(u8, arg, "--no-table")) { table_mode = .never; } else if (std.mem.eql(u8, arg, "-f") or std.mem.eql(u8, arg, "--file")) { @@ -574,8 +585,8 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP } } - // Non-CSV/TSV output format is mutually exclusive with --header - if (output_format != .csv and output_format != .tsv and header) + // Non-CSV/TSV/HTML output format is mutually exclusive with --header + if (output_format != .csv and output_format != .tsv and output_format != .html and header) return error.IncompatibleFlags; // --output is mutually exclusive with --columns (--columns always writes to stdout) @@ -722,6 +733,7 @@ pub fn parseArgs(allocator: std.mem.Allocator, args: []const [:0]const u8) (SqlP .explain = explain, .table_mode = table_mode, .sql_table = sql_table, + .html_class = html_class, .null_value = null_value, } }; } diff --git a/src/format.zig b/src/format.zig index aa3591c..d7779fb 100644 --- a/src/format.zig +++ b/src/format.zig @@ -49,6 +49,7 @@ pub const OutputFormat = enum { ndjson, xml, markdown, + html, sql, /// Parse a format name string. @@ -71,6 +72,8 @@ pub const WriteOpts = struct { xml_row: []const u8 = "row", /// Target table name for SQL INSERT output (default: "t"). sql_table: []const u8 = "t", + /// CSS class name for the HTML
element (default: "" = no class). + html_class: []const u8 = "", /// Custom NULL representation (null = format default). null_value: ?[]const u8 = null, }; @@ -136,7 +139,7 @@ pub const OutputWriter = struct { // Collect column-name pointers for formats that need them per row. switch (self.format) { - .json, .ndjson, .xml, .sql => { + .json, .ndjson, .xml, .html, .sql => { const names = try allocator.alloc([*:0]const u8, @intCast(col_count)); var i: c_int = 0; while (i < col_count) : (i += 1) { @@ -156,6 +159,26 @@ pub const OutputWriter = struct { switch (self.format) { .json => try writer.writeByte('['), .xml => try xml_mod.writeXmlHeader(writer, self.opts.xml_root), + .html => { + try writer.writeAll(" 0) { + try writer.writeAll(" class=\""); + try xml_mod.writeXmlEscaped(writer, self.opts.html_class); + try writer.writeByte('"'); + } + try writer.writeAll(">\n"); + if (self.opts.header) { + try writer.writeAll(""); + var i: c_int = 0; + while (i < col_count) : (i += 1) { + try writer.writeAll(""); + } + try writer.writeAll("\n"); + } + try writer.writeAll("\n"); + }, else => {}, } } @@ -184,6 +207,7 @@ pub const OutputWriter = struct { self.opts.null_value, ), .sql => try self.writeSqlRow(stmt, writer), + .html => try writeHtmlRow(stmt, self.col_count, writer, self.opts.null_value), .markdown => unreachable, // handled before OutputWriter in execQuery } } @@ -196,6 +220,7 @@ pub const OutputWriter = struct { switch (self.format) { .json => try writer.writeAll("]\n"), .xml => try xml_mod.writeXmlFooter(writer, self.opts.xml_root), + .html => try writer.writeAll("\n
"); + try xml_mod.writeXmlEscaped(writer, std.mem.span(self.col_names[@intCast(i)])); + try writer.writeAll("
\n"), else => {}, } } @@ -329,3 +354,29 @@ fn csvPrintHeaderRow( } try writer.writeByte('\n'); } + +// ── HTML output helpers ───────────────────────────────────────────────────── + +/// Write one HTML table data row from the current SQLITE_ROW. +fn writeHtmlRow( + stmt: *c.sqlite3_stmt, + col_count: c_int, + writer: *std.Io.Writer, + null_value: ?[]const u8, +) !void { + try writer.writeAll(""); + var i: c_int = 0; + while (i < col_count) : (i += 1) { + try writer.writeAll(""); + if (c.sqlite3_column_type(stmt, i) == c.SQLITE_NULL) { + const text = null_value orelse ""; + try xml_mod.writeXmlEscaped(writer, text); + } else { + if (sqlite_mod.columnText(stmt, i)) |text| { + try xml_mod.writeXmlEscaped(writer, text); + } + } + try writer.writeAll(""); + } + try writer.writeAll("\n"); +} diff --git a/src/main.zig b/src/main.zig index dfc6d35..2dc6955 100644 --- a/src/main.zig +++ b/src/main.zig @@ -55,6 +55,7 @@ fn execQuery( xml_root: []const u8, xml_row: []const u8, sql_table: []const u8, + html_class: []const u8, null_value: ?[]const u8, use_table: bool, ) (SqlPipeError || std.mem.Allocator.Error || error{WriteFailed, StepFailed})!void { @@ -85,6 +86,7 @@ fn execQuery( .xml_root = xml_root, .xml_row = xml_row, .sql_table = sql_table, + .html_class = html_class, .null_value = null_value, }); defer out_writer.deinit(allocator); @@ -233,7 +235,7 @@ fn run( printQueryPlan(allocator, db, query, main_table, stderr_writer); } - execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.output_format, parsed.xml_root, parsed.xml_row, parsed.sql_table, parsed.null_value, use_table) catch { + execQuery(allocator, db, query, stdout_writer, parsed.header, parsed.output_format, parsed.xml_root, parsed.xml_row, parsed.sql_table, parsed.html_class, parsed.null_value, use_table) catch { stdout_writer.flush() catch |err| std.log.err("failed to flush output before fatal: {}", .{err}); sqlite_mod.fatalSqlWithContext(allocator, db, main_table, std.mem.span(c.sqlite3_errmsg(db)), stderr_writer); }; @@ -267,11 +269,11 @@ pub fn main(init: std.process.Init.Minimal) void { const args_result = parseArgs(args_arena.allocator(), args) catch |err| { switch (err) { - error.IncompatibleFlags => fatal("--header cannot be combined with non-CSV/TSV output format", stderr_writer, .usage, .{}), + error.IncompatibleFlags => fatal("--header cannot be combined with non-CSV/TSV/HTML output format", stderr_writer, .usage, .{}), error.SilentVerboseConflict => fatal("--silent cannot be combined with --verbose", stderr_writer, .usage, .{}), error.InvalidMaxRows => fatal("--max-rows must be a positive integer", stderr_writer, .usage, .{}), error.InvalidInputFormat => fatal("unknown input format; supported: csv, tsv, json, ndjson, xml", stderr_writer, .usage, .{}), - error.InvalidOutputFormat => fatal("unknown output format; supported: csv, tsv, json, ndjson, xml, markdown (md), sql", stderr_writer, .usage, .{}), + error.InvalidOutputFormat => fatal("unknown output format; supported: csv, tsv, json, ndjson, xml, markdown (md), html, sql", stderr_writer, .usage, .{}), error.ColumnsWithQuery => fatal("--columns cannot be combined with a query argument", stderr_writer, .usage, .{}), error.ValidateWithQuery => fatal("--validate cannot be combined with a query argument", stderr_writer, .usage, .{}), error.InvalidOutputPath => fatal("--output requires a non-empty file path", stderr_writer, .usage, .{}), @@ -301,6 +303,7 @@ pub fn main(init: std.process.Init.Minimal) void { error.TableWithNonCsv => fatal("--table requires CSV or TSV output format (not compatible with --json, -O json, etc.)", stderr_writer, .usage, .{}), error.ExplainWithFlags => fatal("--explain cannot be combined with --columns, --validate, --sample, --stats, --schema, or --output", stderr_writer, .usage, .{}), error.MissingNullValue => fatal("--null-value requires a value", stderr_writer, .usage, .{}), + error.MissingHtmlClassValue => fatal("--html-class requires a value", stderr_writer, .usage, .{}), else => {}, } printUsage(stderr_writer) catch |werr| std.log.err("failed to write usage: {}", .{werr});