Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Usage:
showboat note <file> [text] Append commentary (text or stdin)
showboat exec <file> <lang> [code] Run code and capture output
showboat image <file> [script] Run script, capture image output
showboat var set <key> <value> Set a persistent variable
showboat var get <key> Get a variable's value
showboat var list List all variable names
showboat var del <key> Delete a variable
showboat pop <file> Remove the most recent entry
showboat verify <file> [--output <new>] Re-run and diff all code blocks
showboat extract <file> [--filename <name>] Emit commands to recreate file
Expand Down Expand Up @@ -106,6 +110,21 @@ Extract:
they are regenerated by "exec". Use --filename <name> to substitute a
different filename in the emitted commands.

Variables:
Code blocks executed via "exec" can set and read persistent variables using
"showboat var". Variables are stored in a <file>.vars JSON file alongside the
markdown document. The showboat binary is automatically available on PATH
inside executed code blocks.

Variables persist across successive "exec" calls for the same document.
During "verify", the vars file is cleared at the start for a clean replay.

# Cell 1: start a server and record its PID
showboat exec demo.md bash 'python3 -m http.server 8000 & showboat var set PID $!'

# Cell 2: stop the server using the saved PID
showboat exec demo.md bash 'kill $(showboat var get PID)'

Stdin:
Commands accept input from stdin when the text/code argument is omitted.
For example:
Expand Down
6 changes: 4 additions & 2 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ func Exec(file, lang, code, workdir string) (string, int, error) {
return "", 1, fmt.Errorf("file not found: %s", file)
}

output, exitCode, err := execpkg.Run(lang, code, workdir)
varsFile := VarsFile(file)
output, exitCode, err := execpkg.Run(lang, code, workdir, varsFile)
if err != nil {
return "", exitCode, fmt.Errorf("running code: %w", err)
}
Expand Down Expand Up @@ -58,7 +59,8 @@ func Image(file, script, workdir string) error {
}

destDir := filepath.Dir(file)
filename, err := execpkg.RunImage(script, destDir, workdir)
varsFile := VarsFile(file)
filename, err := execpkg.RunImage(script, destDir, workdir, varsFile)
if err != nil {
return fmt.Errorf("running image script: %w", err)
}
Expand Down
113 changes: 113 additions & 0 deletions cmd/var.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)

// VarsFile returns the path to the vars file for a given markdown document.
func VarsFile(mdFile string) string {
abs, err := filepath.Abs(mdFile)
if err != nil {
abs = mdFile
}
return abs + ".vars"
}

// VarSet sets a variable in the vars file identified by SHOWBOAT_VARS.
func VarSet(key, value string) error {
file, err := varsFileFromEnv()
if err != nil {
return err
}
vars, err := loadVars(file)
if err != nil {
return err
}
vars[key] = value
return saveVars(file, vars)
}

// VarGet returns the value of a variable from the vars file.
func VarGet(key string) (string, error) {
file, err := varsFileFromEnv()
if err != nil {
return "", err
}
vars, err := loadVars(file)
if err != nil {
return "", err
}
val, ok := vars[key]
if !ok {
return "", fmt.Errorf("variable not set: %s", key)
}
return val, nil
}

// VarList returns all variable names sorted alphabetically.
func VarList() ([]string, error) {
file, err := varsFileFromEnv()
if err != nil {
return nil, err
}
vars, err := loadVars(file)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(vars))
for k := range vars {
keys = append(keys, k)
}
sort.Strings(keys)
return keys, nil
}

// VarDel deletes a variable from the vars file.
func VarDel(key string) error {
file, err := varsFileFromEnv()
if err != nil {
return err
}
vars, err := loadVars(file)
if err != nil {
return err
}
delete(vars, key)
return saveVars(file, vars)
}

func varsFileFromEnv() (string, error) {
file := os.Getenv("SHOWBOAT_VARS")
if file == "" {
return "", fmt.Errorf("SHOWBOAT_VARS not set (are you running inside showboat exec?)")
}
return file, nil
}

func loadVars(file string) (map[string]string, error) {
data, err := os.ReadFile(file)
if err != nil {
if os.IsNotExist(err) {
return make(map[string]string), nil
}
return nil, fmt.Errorf("reading vars file: %w", err)
}
var vars map[string]string
if err := json.Unmarshal(data, &vars); err != nil {
return nil, fmt.Errorf("parsing vars file: %w", err)
}
return vars, nil
}

func saveVars(file string, vars map[string]string) error {
data, err := json.MarshalIndent(vars, "", " ")
if err != nil {
return fmt.Errorf("encoding vars: %w", err)
}
data = append(data, '\n')
return os.WriteFile(file, data, 0644)
}
164 changes: 164 additions & 0 deletions cmd/var_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cmd

import (
"os"
"path/filepath"
"testing"
)

func TestVarsFile(t *testing.T) {
// VarsFile should append .vars to the absolute path
got := VarsFile("/tmp/demo.md")
if got != "/tmp/demo.md.vars" {
t.Errorf("expected /tmp/demo.md.vars, got %s", got)
}
}

func TestVarSetGet(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

if err := VarSet("FOO", "bar"); err != nil {
t.Fatal(err)
}

val, err := VarGet("FOO")
if err != nil {
t.Fatal(err)
}
if val != "bar" {
t.Errorf("expected bar, got %q", val)
}
}

func TestVarSetOverwrite(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

if err := VarSet("KEY", "first"); err != nil {
t.Fatal(err)
}
if err := VarSet("KEY", "second"); err != nil {
t.Fatal(err)
}

val, err := VarGet("KEY")
if err != nil {
t.Fatal(err)
}
if val != "second" {
t.Errorf("expected second, got %q", val)
}
}

func TestVarGetMissing(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

_, err := VarGet("NONEXISTENT")
if err == nil {
t.Error("expected error for missing variable")
}
}

func TestVarGetNoEnv(t *testing.T) {
t.Setenv("SHOWBOAT_VARS", "")

_, err := VarGet("FOO")
if err == nil {
t.Error("expected error when SHOWBOAT_VARS is not set")
}
}

func TestVarList(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

VarSet("ZEBRA", "z")
VarSet("APPLE", "a")
VarSet("MANGO", "m")

keys, err := VarList()
if err != nil {
t.Fatal(err)
}
if len(keys) != 3 {
t.Fatalf("expected 3 keys, got %d", len(keys))
}
if keys[0] != "APPLE" || keys[1] != "MANGO" || keys[2] != "ZEBRA" {
t.Errorf("expected sorted keys [APPLE MANGO ZEBRA], got %v", keys)
}
}

func TestVarListEmpty(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

keys, err := VarList()
if err != nil {
t.Fatal(err)
}
if len(keys) != 0 {
t.Errorf("expected 0 keys, got %d", len(keys))
}
}

func TestVarDel(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

VarSet("A", "1")
VarSet("B", "2")

if err := VarDel("A"); err != nil {
t.Fatal(err)
}

_, err := VarGet("A")
if err == nil {
t.Error("expected error after deleting A")
}

val, err := VarGet("B")
if err != nil {
t.Fatal(err)
}
if val != "2" {
t.Errorf("expected 2, got %q", val)
}
}

func TestVarDelMissing(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

// Deleting a non-existent key should not error
if err := VarDel("NONEXISTENT"); err != nil {
t.Errorf("expected no error deleting missing key, got: %v", err)
}
}

func TestVarsPersistFile(t *testing.T) {
dir := t.TempDir()
varsFile := filepath.Join(dir, "test.vars")
t.Setenv("SHOWBOAT_VARS", varsFile)

VarSet("PERSIST", "yes")

// Verify the file exists and is valid JSON
data, err := os.ReadFile(varsFile)
if err != nil {
t.Fatal(err)
}
s := string(data)
if s == "" {
t.Error("expected non-empty vars file")
}
}
8 changes: 7 additions & 1 deletion cmd/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"fmt"
"os"
"strings"

execpkg "github.com/simonw/showboat/exec"
Expand Down Expand Up @@ -33,6 +34,11 @@ func Verify(file, outputFile, workdir string) ([]Diff, error) {
return nil, err
}

// Start with a clean vars file so verify is a deterministic replay
varsFile := VarsFile(file)
os.Remove(varsFile)
defer os.Remove(varsFile)

var diffs []Diff

for i := 0; i < len(blocks); i++ {
Expand All @@ -42,7 +48,7 @@ func Verify(file, outputFile, workdir string) ([]Diff, error) {
}

// Execute the code block
output, _, err := execpkg.Run(cb.Lang, cb.Code, workdir)
output, _, err := execpkg.Run(cb.Lang, cb.Code, workdir, varsFile)
if err != nil {
return nil, fmt.Errorf("executing block %d: %w", i, err)
}
Expand Down
Loading