diff --git a/cmd/build.go b/cmd/build.go index 06348fc..406801c 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -33,12 +33,21 @@ func Note(file, text string) error { // Exec appends a code block, executes it, and appends the output. // It returns the captured output, the process exit code, and any error. +// If lang has a " {table}" suffix, the output is parsed as TSV and rendered +// as a markdown table instead of a plain output fence. func Exec(file, lang, code, workdir string) (string, int, error) { if _, err := os.Stat(file); err != nil { return "", 1, fmt.Errorf("file not found: %s", file) } - output, exitCode, err := execpkg.Run(lang, code, workdir) + // Detect {table} annotation and strip it for execution. + isTable := strings.HasSuffix(lang, " {table}") + runLang := lang + if isTable { + runLang = strings.TrimSuffix(lang, " {table}") + } + + output, exitCode, err := execpkg.Run(runLang, code, workdir) if err != nil { return "", exitCode, fmt.Errorf("running code: %w", err) } @@ -48,9 +57,18 @@ func Exec(file, lang, code, workdir string) (string, int, error) { return "", exitCode, err } - codeBlock := markdown.CodeBlock{Lang: lang, Code: code} - outputBlock := markdown.OutputBlock{Content: output} - blocks = append(blocks, codeBlock, outputBlock) + codeBlock := markdown.CodeBlock{Lang: runLang, Code: code, IsTable: isTable} + var outputBlk markdown.Block + if isTable { + headers, rows, parseErr := execpkg.ParseTSV(output) + if parseErr != nil { + return output, exitCode, fmt.Errorf("parsing table output: %w", parseErr) + } + outputBlk = markdown.TableOutputBlock{Headers: headers, Rows: rows} + } else { + outputBlk = markdown.OutputBlock{Content: output} + } + blocks = append(blocks, codeBlock, outputBlk) if err := writeBlocks(file, blocks); err != nil { return output, exitCode, err @@ -58,7 +76,7 @@ func Exec(file, lang, code, workdir string) (string, int, error) { docID := documentID(blocks) if docID != "" { - postSection(docID, "exec", []markdown.Block{codeBlock, outputBlock}) + postSection(docID, "exec", []markdown.Block{codeBlock, outputBlk}) } return output, exitCode, nil diff --git a/cmd/build_test.go b/cmd/build_test.go index 059a84a..0f785e5 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -348,6 +348,81 @@ func TestImageMarkdownRefEscapedBang(t *testing.T) { } } +func TestExecTable(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + if err := Init(file, "Test", "dev"); err != nil { + t.Fatal(err) + } + + code := `printf 'name\tage\nAlice\t30\nBob\t25\n'` + output, exitCode, err := Exec(file, "bash {table}", code, "") + if err != nil { + t.Fatal(err) + } + if exitCode != 0 { + t.Errorf("expected exit code 0, got %d", exitCode) + } + if output != "name\tage\nAlice\t30\nBob\t25\n" { + t.Errorf("unexpected output: %q", output) + } + + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + + s := string(content) + if !strings.Contains(s, "```bash {table}") { + t.Errorf("expected table code block in file, got: %s", s) + } + if !strings.Contains(s, "| name | age |") { + t.Errorf("expected table header in file, got: %s", s) + } + if !strings.Contains(s, "| Alice | 30 |") { + t.Errorf("expected table row in file, got: %s", s) + } + // Should NOT contain an output fence + if strings.Contains(s, "```output") { + t.Errorf("should not have output fence for table exec, got: %s", s) + } +} + +func TestPopTableEntry(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + if err := Init(file, "Test", "dev"); err != nil { + t.Fatal(err) + } + + code := `printf 'name\tage\nAlice\t30\n'` + if _, _, err := Exec(file, "bash {table}", code, ""); err != nil { + t.Fatal(err) + } + + // Verify table is in the file + content, _ := os.ReadFile(file) + if !strings.Contains(string(content), "| name | age |") { + t.Fatal("expected table in file before pop") + } + + // Pop should remove both the code block and the table output + if err := Pop(file); err != nil { + t.Fatal(err) + } + + content, _ = os.ReadFile(file) + s := string(content) + if strings.Contains(s, "{table}") { + t.Errorf("expected code block to be removed after pop, got: %s", s) + } + if strings.Contains(s, "| name | age |") { + t.Errorf("expected table to be removed after pop, got: %s", s) + } +} + func TestImageMarkdownRefBadPath(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "demo.md") diff --git a/cmd/extract.go b/cmd/extract.go index d8d12c9..f26a1db 100644 --- a/cmd/extract.go +++ b/cmd/extract.go @@ -36,12 +36,18 @@ func Extract(file, outputFile string) ([]string, error) { if b.IsImage { commands = append(commands, fmt.Sprintf("showboat image %s %s", quotedTarget, shellQuote(b.Code))) } else { - commands = append(commands, fmt.Sprintf("showboat exec %s %s %s", quotedTarget, b.Lang, shellQuote(b.Code))) + lang := b.Lang + if b.IsTable { + lang += " {table}" + } + commands = append(commands, fmt.Sprintf("showboat exec %s %s %s", quotedTarget, shellQuote(lang), shellQuote(b.Code))) } case markdown.OutputBlock: // Skip: generated by running code blocks case markdown.ImageOutputBlock: // Skip: generated by running image scripts + case markdown.TableOutputBlock: + // Skip: generated by running table code blocks } } diff --git a/cmd/extract_test.go b/cmd/extract_test.go index 91d24b4..59e3e6e 100644 --- a/cmd/extract_test.go +++ b/cmd/extract_test.go @@ -68,6 +68,32 @@ func TestExtractOutputOverride(t *testing.T) { } } +func TestExtractTable(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + if err := Init(file, "Test", "dev"); err != nil { + t.Fatal(err) + } + code := `printf 'name\tage\nAlice\t30\n'` + if _, _, err := Exec(file, "bash {table}", code, ""); err != nil { + t.Fatal(err) + } + + commands, err := Extract(file, "") + if err != nil { + t.Fatal(err) + } + + if len(commands) != 2 { + t.Fatalf("expected 2 commands, got %d: %v", len(commands), commands) + } + // The exec command should include {table} in the language + if !strings.Contains(commands[1], "{table}") { + t.Errorf("expected exec command to include {table}, got: %s", commands[1]) + } +} + func TestExtractShellQuote(t *testing.T) { tests := []struct { input string diff --git a/cmd/pop.go b/cmd/pop.go index 2189e74..9d02c21 100644 --- a/cmd/pop.go +++ b/cmd/pop.go @@ -32,7 +32,7 @@ func Pop(file string) error { last := blocks[len(blocks)-1] switch last.(type) { - case markdown.OutputBlock, markdown.ImageOutputBlock: + case markdown.OutputBlock, markdown.ImageOutputBlock, markdown.TableOutputBlock: // Output blocks are always preceded by a code block — remove both. if len(blocks) >= 2 { blocks = blocks[:len(blocks)-2] diff --git a/exec/table.go b/exec/table.go new file mode 100644 index 0000000..a92c64e --- /dev/null +++ b/exec/table.go @@ -0,0 +1,26 @@ +package exec + +import ( + "fmt" + "strings" +) + +// ParseTSV parses tab-separated output into headers and rows. +// The first line is treated as the header row. Empty trailing lines are ignored. +func ParseTSV(output string) ([]string, [][]string, error) { + lines := strings.Split(output, "\n") + // Remove empty trailing lines + for len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) == 0 { + return nil, nil, fmt.Errorf("empty output: no header row found") + } + + headers := strings.Split(lines[0], "\t") + var rows [][]string + for _, line := range lines[1:] { + rows = append(rows, strings.Split(line, "\t")) + } + return headers, rows, nil +} diff --git a/exec/table_test.go b/exec/table_test.go new file mode 100644 index 0000000..291d458 --- /dev/null +++ b/exec/table_test.go @@ -0,0 +1,74 @@ +package exec + +import ( + "testing" +) + +func TestParseTSV(t *testing.T) { + input := "name\tage\nAlice\t30\nBob\t25\n" + headers, rows, err := ParseTSV(input) + if err != nil { + t.Fatal(err) + } + if len(headers) != 2 || headers[0] != "name" || headers[1] != "age" { + t.Errorf("unexpected headers: %v", headers) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + if rows[0][0] != "Alice" || rows[0][1] != "30" { + t.Errorf("unexpected row 0: %v", rows[0]) + } + if rows[1][0] != "Bob" || rows[1][1] != "25" { + t.Errorf("unexpected row 1: %v", rows[1]) + } +} + +func TestParseTSVSingleColumn(t *testing.T) { + input := "count\n42\n" + headers, rows, err := ParseTSV(input) + if err != nil { + t.Fatal(err) + } + if len(headers) != 1 || headers[0] != "count" { + t.Errorf("unexpected headers: %v", headers) + } + if len(rows) != 1 || rows[0][0] != "42" { + t.Errorf("unexpected rows: %v", rows) + } +} + +func TestParseTSVHeaderOnly(t *testing.T) { + input := "name\tage\n" + headers, rows, err := ParseTSV(input) + if err != nil { + t.Fatal(err) + } + if len(headers) != 2 { + t.Errorf("expected 2 headers, got %d", len(headers)) + } + if len(rows) != 0 { + t.Errorf("expected 0 rows, got %d", len(rows)) + } +} + +func TestParseTSVEmpty(t *testing.T) { + _, _, err := ParseTSV("") + if err == nil { + t.Error("expected error for empty input") + } +} + +func TestParseTSVTrailingNewlines(t *testing.T) { + input := "a\tb\n1\t2\n\n" + headers, rows, err := ParseTSV(input) + if err != nil { + t.Fatal(err) + } + if len(headers) != 2 { + t.Errorf("expected 2 headers, got %d", len(headers)) + } + if len(rows) != 1 { + t.Errorf("expected 1 row, got %d", len(rows)) + } +} diff --git a/help.txt b/help.txt index 6db0960..9076600 100644 --- a/help.txt +++ b/help.txt @@ -9,6 +9,7 @@ Usage: showboat init Create a new demo document showboat note <file> [text] Append commentary (text or stdin) showboat exec <file> <lang> [code] Run code and capture output + showboat exec <file> '<lang> {table}' [code] Run code, render output as table showboat image <file> <path> Copy image into document showboat image <file> '![alt](path)' Copy image with alt text showboat pop <file> Remove the most recent entry @@ -31,6 +32,25 @@ Exec output: $ echo $? 1 +Table output: + When the language argument includes a {table} suffix, the exec command expects + the script to produce tab-separated (TSV) output. The first line is treated as + column headers and subsequent lines as data rows. The output is rendered as a + markdown pipe table instead of a plain output fence. + + Any language works — the script just needs to print TSV to stdout: + + $ showboat exec demo.md 'bash {table}' "printf 'name\tage\nAlice\t30\nBob\t25\n'" + name age + Alice 30 + Bob 25 + + Common patterns: + SQLite: showboat exec d.md 'bash {table}' "sqlite3 -header -separator $'\t' db 'SELECT * FROM t'" + Postgres: showboat exec d.md 'bash {table}' "psql -A -F $'\t' -c 'SELECT * FROM t'" + Python: showboat exec d.md 'python3 {table}' "import csv,sys; ..." + PySpark: showboat exec d.md 'python3 {table}' "df.toPandas().to_csv(sys.stdout,sep='\t',index=False)" + Image: The "image" command accepts a path to an image file or a markdown image reference of the form ![alt text](path). The image is copied into the same @@ -82,6 +102,9 @@ Example: # Redo it correctly showboat exec demo.md python3 "print('Hello from Python')" + # Run a query and render the result as a table + showboat exec demo.md 'python3 {table}' "print('name\tage\nAlice\t30\nBob\t25')" + # Add a screenshot showboat image demo.md screenshot.png @@ -118,6 +141,15 @@ Resulting markdown format: Hello from Python ``` + ```python3 {table} + print('name\tage\nAlice\t30\nBob\t25') + ``` + + | name | age | + | --- | --- | + | Alice | 30 | + | Bob | 25 | + ```bash {image} screenshot.png ``` diff --git a/markdown/blocks.go b/markdown/blocks.go index 37b31b1..79a5c15 100644 --- a/markdown/blocks.go +++ b/markdown/blocks.go @@ -27,6 +27,7 @@ type CodeBlock struct { Lang string Code string IsImage bool + IsTable bool } func (b CodeBlock) Type() string { return "code" } @@ -45,3 +46,11 @@ type ImageOutputBlock struct { } func (b ImageOutputBlock) Type() string { return "output-image" } + +// TableOutputBlock is captured tabular output rendered as a markdown table. +type TableOutputBlock struct { + Headers []string + Rows [][]string +} + +func (b TableOutputBlock) Type() string { return "output-table" } diff --git a/markdown/blocks_test.go b/markdown/blocks_test.go index 857a706..77a1b30 100644 --- a/markdown/blocks_test.go +++ b/markdown/blocks_test.go @@ -36,3 +36,19 @@ func TestTitleBlock(t *testing.T) { t.Errorf("expected type title, got %s", b.Type()) } } + +func TestTableOutputBlock(t *testing.T) { + b := TableOutputBlock{ + Headers: []string{"name", "age"}, + Rows: [][]string{{"Alice", "30"}, {"Bob", "25"}}, + } + if b.Type() != "output-table" { + t.Errorf("expected type output-table, got %s", b.Type()) + } + if len(b.Headers) != 2 { + t.Errorf("expected 2 headers, got %d", len(b.Headers)) + } + if len(b.Rows) != 2 { + t.Errorf("expected 2 rows, got %d", len(b.Rows)) + } +} diff --git a/markdown/parser.go b/markdown/parser.go index b10e885..18fd191 100644 --- a/markdown/parser.go +++ b/markdown/parser.go @@ -92,9 +92,13 @@ func Parse(r io.Reader) ([]Block, error) { // Code block. Check for {image} suffix. lang := info isImage := false + isTable := false if strings.HasSuffix(lang, " {image}") { lang = strings.TrimSuffix(lang, " {image}") isImage = true + } else if strings.HasSuffix(lang, " {table}") { + lang = strings.TrimSuffix(lang, " {table}") + isTable = true } var codeLines []string for i < len(lines) && lines[i] != closingFence { @@ -106,6 +110,7 @@ func Parse(r io.Reader) ([]Block, error) { Lang: lang, Code: strings.Join(codeLines, "\n"), IsImage: isImage, + IsTable: isTable, }) } @@ -124,6 +129,20 @@ func Parse(r io.Reader) ([]Block, error) { } } + // Table output: a pipe table (| header | ... | followed by | --- | ... |). + if isTableRow(lines[i]) && i+1 < len(lines) && isTableSeparator(lines[i+1]) { + headers := parseTableRow(lines[i]) + i += 2 // past header and separator lines + var rows [][]string + for i < len(lines) && isTableRow(lines[i]) { + rows = append(rows, parseTableRow(lines[i])) + i++ + } + blocks = append(blocks, TableOutputBlock{Headers: headers, Rows: rows}) + skipSeparator() + continue + } + // Commentary block: accumulate lines until a fence, image output, or EOF. var textLines []string for i < len(lines) { @@ -150,6 +169,43 @@ func Parse(r io.Reader) ([]Block, error) { return blocks, nil } +// isTableRow returns true if the line looks like a markdown table row: | ... | +func isTableRow(line string) bool { + trimmed := strings.TrimSpace(line) + return strings.HasPrefix(trimmed, "|") && strings.HasSuffix(trimmed, "|") +} + +// isTableSeparator returns true if the line is a markdown table separator row +// like | --- | --- |. +func isTableSeparator(line string) bool { + if !isTableRow(line) { + return false + } + cells := parseTableRow(line) + for _, cell := range cells { + stripped := strings.TrimSpace(cell) + stripped = strings.Trim(stripped, ":-") + if stripped != "" { + return false + } + } + return len(cells) > 0 +} + +// parseTableRow splits a pipe-delimited table row into cell values. +func parseTableRow(line string) []string { + trimmed := strings.TrimSpace(line) + // Strip leading and trailing pipes + trimmed = strings.TrimPrefix(trimmed, "|") + trimmed = strings.TrimSuffix(trimmed, "|") + parts := strings.Split(trimmed, "|") + cells := make([]string, len(parts)) + for i, p := range parts { + cells[i] = strings.TrimSpace(p) + } + return cells +} + // parseImageRef extracts the alt text and filename from a markdown image // reference of the form ![alt](filename). func parseImageRef(line string) (alt, filename string) { diff --git a/markdown/parser_test.go b/markdown/parser_test.go index db985f8..91bff4d 100644 --- a/markdown/parser_test.go +++ b/markdown/parser_test.go @@ -257,6 +257,122 @@ func TestRoundTripWithDocumentID(t *testing.T) { } } +func TestParseTableCodeBlock(t *testing.T) { + input := "```python3 {table}\nprint('hello')\n```\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + code, ok := blocks[0].(CodeBlock) + if !ok { + t.Fatalf("expected CodeBlock, got %T", blocks[0]) + } + if code.Lang != "python3" { + t.Errorf("expected lang 'python3', got %q", code.Lang) + } + if !code.IsTable { + t.Error("expected IsTable=true") + } + if code.IsImage { + t.Error("expected IsImage=false") + } +} + +func TestParseTableOutput(t *testing.T) { + input := "```python3 {table}\nprint('hello')\n```\n\n| name | age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 2 { + t.Fatalf("expected 2 blocks, got %d: %+v", len(blocks), blocks) + } + code, ok := blocks[0].(CodeBlock) + if !ok { + t.Fatalf("expected CodeBlock, got %T", blocks[0]) + } + if !code.IsTable { + t.Error("expected IsTable=true") + } + + tbl, ok := blocks[1].(TableOutputBlock) + if !ok { + t.Fatalf("expected TableOutputBlock, got %T", blocks[1]) + } + if len(tbl.Headers) != 2 || tbl.Headers[0] != "name" || tbl.Headers[1] != "age" { + t.Errorf("unexpected headers: %v", tbl.Headers) + } + if len(tbl.Rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(tbl.Rows)) + } + if tbl.Rows[0][0] != "Alice" || tbl.Rows[0][1] != "30" { + t.Errorf("unexpected row 0: %v", tbl.Rows[0]) + } + if tbl.Rows[1][0] != "Bob" || tbl.Rows[1][1] != "25" { + t.Errorf("unexpected row 1: %v", tbl.Rows[1]) + } +} + +func TestParseTableOutputHeadersOnly(t *testing.T) { + input := "| name | age |\n| --- | --- |\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d: %+v", len(blocks), blocks) + } + tbl, ok := blocks[0].(TableOutputBlock) + if !ok { + t.Fatalf("expected TableOutputBlock, got %T", blocks[0]) + } + if len(tbl.Headers) != 2 { + t.Errorf("expected 2 headers, got %d", len(tbl.Headers)) + } + if len(tbl.Rows) != 0 { + t.Errorf("expected 0 rows, got %d", len(tbl.Rows)) + } +} + +func TestRoundTripWithTable(t *testing.T) { + input := "# Demo\n\n*2026-02-06T00:00:00Z by Showboat v0.3.0*\n\nQuerying the database.\n\n```python3 {table}\nimport sqlite3\n```\n\n| name | age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |\n\nDone.\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 5 { + t.Fatalf("expected 5 blocks, got %d: %+v", len(blocks), blocks) + } + // Verify block types + if _, ok := blocks[0].(TitleBlock); !ok { + t.Errorf("block 0: expected TitleBlock, got %T", blocks[0]) + } + if _, ok := blocks[1].(CommentaryBlock); !ok { + t.Errorf("block 1: expected CommentaryBlock, got %T", blocks[1]) + } + if cb, ok := blocks[2].(CodeBlock); !ok || !cb.IsTable { + t.Errorf("block 2: expected CodeBlock with IsTable=true, got %T %+v", blocks[2], blocks[2]) + } + if _, ok := blocks[3].(TableOutputBlock); !ok { + t.Errorf("block 3: expected TableOutputBlock, got %T", blocks[3]) + } + if _, ok := blocks[4].(CommentaryBlock); !ok { + t.Errorf("block 4: expected CommentaryBlock, got %T", blocks[4]) + } + + var buf strings.Builder + err = Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + if buf.String() != input { + t.Errorf("round trip mismatch.\nexpected:\n%s\ngot:\n%s", input, buf.String()) + } +} + func TestRoundTrip(t *testing.T) { input := "# Demo\n\n*2026-02-06T00:00:00Z by Showboat v0.3.0*\n\nLet's begin.\n\n```bash\necho hi\n```\n\n```output\nhi\n```\n\nDone.\n" blocks, err := Parse(strings.NewReader(input)) diff --git a/markdown/writer.go b/markdown/writer.go index 06d8593..6a65d4b 100644 --- a/markdown/writer.go +++ b/markdown/writer.go @@ -44,6 +44,8 @@ func writeBlock(w io.Writer, block Block) error { lang := b.Lang if b.IsImage { lang += " {image}" + } else if b.IsTable { + lang += " {table}" } _, err := fmt.Fprintf(w, "```%s\n%s\n```\n", lang, b.Code) return err @@ -54,6 +56,23 @@ func writeBlock(w io.Writer, block Block) error { case ImageOutputBlock: _, err := fmt.Fprintf(w, "![%s](%s)\n", b.AltText, b.Filename) return err + case TableOutputBlock: + if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(b.Headers, " | ")); err != nil { + return err + } + seps := make([]string, len(b.Headers)) + for i := range seps { + seps[i] = "---" + } + if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(seps, " | ")); err != nil { + return err + } + for _, row := range b.Rows { + if _, err := fmt.Fprintf(w, "| %s |\n", strings.Join(row, " | ")); err != nil { + return err + } + } + return nil default: return fmt.Errorf("unknown block type: %T", block) } diff --git a/markdown/writer_test.go b/markdown/writer_test.go index 391a936..0d2d94c 100644 --- a/markdown/writer_test.go +++ b/markdown/writer_test.go @@ -158,6 +158,75 @@ func TestWriteTitleWithDocumentIDNoVersion(t *testing.T) { } } +func TestWriteTableCodeBlock(t *testing.T) { + var buf strings.Builder + blocks := []Block{ + CodeBlock{Lang: "python3", Code: "print('hello')", IsTable: true}, + } + err := Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + expected := "```python3 {table}\nprint('hello')\n```\n" + if buf.String() != expected { + t.Errorf("expected:\n%q\ngot:\n%q", expected, buf.String()) + } +} + +func TestWriteTableOutputBlock(t *testing.T) { + var buf strings.Builder + blocks := []Block{ + TableOutputBlock{ + Headers: []string{"name", "age"}, + Rows: [][]string{{"Alice", "30"}, {"Bob", "25"}}, + }, + } + err := Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + expected := "| name | age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |\n" + if buf.String() != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, buf.String()) + } +} + +func TestWriteTableOutputBlockSingleColumn(t *testing.T) { + var buf strings.Builder + blocks := []Block{ + TableOutputBlock{ + Headers: []string{"count"}, + Rows: [][]string{{"42"}}, + }, + } + err := Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + expected := "| count |\n| --- |\n| 42 |\n" + if buf.String() != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, buf.String()) + } +} + +func TestWriteTableOutputBlockEmpty(t *testing.T) { + var buf strings.Builder + blocks := []Block{ + TableOutputBlock{ + Headers: []string{"name", "age"}, + Rows: [][]string{}, + }, + } + err := Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + expected := "| name | age |\n| --- | --- |\n" + if buf.String() != expected { + t.Errorf("expected:\n%s\ngot:\n%s", expected, buf.String()) + } +} + func TestWriteFullDocument(t *testing.T) { var buf strings.Builder blocks := []Block{