From a5084aae57496cf91de61b6aa19cd335f8969733 Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Sun, 5 Jul 2026 14:36:00 -0700 Subject: [PATCH 1/2] support explicit command continuations --- docs/reference.md | 19 ++++++++ docs/user-guide.md | 13 ++++++ internal/core/interp.go | 45 +++++++++++++++++-- internal/core/recorder.go | 14 +++++- tests/multiline-command-blocks/test.cmdt | 38 ++++++++++++++++ .../source.cmdt | 7 +++ .../update-multiline-command-blocks/test.cmdt | 11 +++++ 7 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 tests/multiline-command-blocks/test.cmdt create mode 100644 tests/update-multiline-command-blocks/source.cmdt create mode 100644 tests/update-multiline-command-blocks/test.cmdt diff --git a/docs/reference.md b/docs/reference.md index 8a535dd..c1c23e1 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -20,6 +20,25 @@ Run a shell command. Commands are interpreted by `mvdan.cc/sh` (a bash-like shell in Go), so quoting, parameter expansion, and redirections generally work as expected. +### `>` command continuation + +Continue the previous command. Continuation lines are appended to the command +with newline separators before the command is run: + +```cmdt +$ cat > fixture.txt <<'EOF' +> alpha +> beta +> EOF + +$ cat fixture.txt +1 alpha +1 beta +``` + +Continuation payloads are raw shell text, even if they begin with transcript +opcodes such as `$`, `1`, `2`, `%`, `?`, or `#`. + ### `1` / `2` output Match an output line from the previous command: diff --git a/docs/user-guide.md b/docs/user-guide.md index 7f05077..6b3a501 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -66,6 +66,19 @@ If output is large, or if the output is binary, transcripts can reference an external file via `1<`/`2<`. `transcript update` will automatically create numbered `*.bin` files for binary output. +Commands may also use shell heredocs to create readable multiline fixtures. +Use `>` continuation lines for the heredoc body: + +```cmdt +$ cat > config.txt <<'EOF' +> name = "example" +> enabled = true +> EOF + +$ mytool config.txt +1 loaded example +``` + ## Go Tests You can embed `*.cmdt` scripts in Go tests: diff --git a/internal/core/interp.go b/internal/core/interp.go index 142c90b..dcc2e57 100644 --- a/internal/core/interp.go +++ b/internal/core/interp.go @@ -22,6 +22,9 @@ type Interpreter struct { // Private state. acceptResults bool prevFD int // stdout (1), stderr (2) or none (0). + + commandPending bool + commandLines []string } // Handler provides callbacks for processing transcript operations. @@ -86,6 +89,9 @@ func (t *Interpreter) ExecTranscript(ctx context.Context, r io.Reader) error { func (t *Interpreter) ExecLine(ctx context.Context, text string) error { hdlr := t.Handler if strings.TrimSpace(text) == "" || text[0] == '#' { + if err := t.runPendingCommand(ctx); err != nil { + return err + } return hdlr.HandleComment(ctx, text) } parts := strings.SplitN(text, " ", 2) @@ -99,12 +105,22 @@ func (t *Interpreter) ExecLine(ctx context.Context, text string) error { if err := t.flushCommand(ctx); err != nil { return err } - t.Command = payload t.CommandLineno = t.Lineno - t.acceptResults = true - return hdlr.HandleRun(ctx, payload) + t.commandPending = true + t.commandLines = []string{payload} + return nil + + case ">": + if !t.commandPending { + return t.syntaxErrorf("unexpected command continuation") + } + t.commandLines = append(t.commandLines, payload) + return nil case "1", "2": + if err := t.runPendingCommand(ctx); err != nil { + return err + } if !t.acceptResults { return t.syntaxErrorf("unexpected output check") } @@ -113,6 +129,9 @@ func (t *Interpreter) ExecLine(ctx context.Context, text string) error { return hdlr.HandleOutput(ctx, fd, payload) case "1<", "2<": + if err := t.runPendingCommand(ctx); err != nil { + return err + } if !t.acceptResults { return t.syntaxErrorf("unexpected file output check") } @@ -121,6 +140,9 @@ func (t *Interpreter) ExecLine(ctx context.Context, text string) error { return hdlr.HandleFileOutput(ctx, fd, payload) case "?": + if err := t.runPendingCommand(ctx); err != nil { + return err + } if !t.acceptResults { return t.syntaxErrorf("unexpected exit status check") } @@ -133,6 +155,9 @@ func (t *Interpreter) ExecLine(ctx context.Context, text string) error { return err case "%": + if err := t.runPendingCommand(ctx); err != nil { + return err + } parts := strings.SplitN(payload, " ", 2) directive := parts[0] var payload string @@ -165,10 +190,24 @@ func (t *Interpreter) ExecLine(ctx context.Context, text string) error { } } +func (t *Interpreter) runPendingCommand(ctx context.Context) error { + if !t.commandPending { + return nil + } + t.Command = strings.Join(t.commandLines, "\n") + t.commandPending = false + t.commandLines = nil + t.acceptResults = true + return t.Handler.HandleRun(ctx, t.Command) +} + func (t *Interpreter) flushCommand(ctx context.Context) error { if t.CommandLineno == 0 { return nil } + if err := t.runPendingCommand(ctx); err != nil { + return err + } t.prevFD = 0 return t.Handler.HandleEnd(ctx) } diff --git a/internal/core/recorder.go b/internal/core/recorder.go index b0d741d..e795d9b 100644 --- a/internal/core/recorder.go +++ b/internal/core/recorder.go @@ -188,7 +188,7 @@ func (rec *Recorder) RunCommand(ctx context.Context, command string) (*CommandRe fmt.Fprintln(&rec.Transcript) rec.needsBlank = false } - fmt.Fprintf(&rec.Transcript, "$ %s\n", command) + rec.recordCommand(command) afterCommandMark := rec.Transcript.Len() // Execute command and record output. @@ -246,6 +246,18 @@ func (rec *Recorder) RecordComment(text string) { rec.needsBlank = false } +func (rec *Recorder) recordCommand(command string) { + lines := strings.Split(command, "\n") + fmt.Fprintf(&rec.Transcript, "$ %s\n", lines[0]) + for _, line := range lines[1:] { + if line == "" { + fmt.Fprintln(&rec.Transcript, ">") + } else { + fmt.Fprintf(&rec.Transcript, "> %s\n", line) + } + } +} + func (rec *Recorder) Exited() bool { return rec.runner.Exited() } diff --git a/tests/multiline-command-blocks/test.cmdt b/tests/multiline-command-blocks/test.cmdt new file mode 100644 index 0000000..161c2f4 --- /dev/null +++ b/tests/multiline-command-blocks/test.cmdt @@ -0,0 +1,38 @@ +# Multiline heredoc commands should use explicit continuation opcodes. +$ cat > fixture.txt <<'EOF' +> alpha +> beta +> # This is fixture data, not a transcript comment. +> $ This is fixture data, not a transcript command. +> 1 This is fixture data, not expected output. +> EOF + +$ cat fixture.txt +1 alpha +1 beta +1 # This is fixture data, not a transcript comment. +1 $ This is fixture data, not a transcript command. +1 1 This is fixture data, not expected output. + +# Multiline quoted strings should use explicit continuation opcodes. +$ printf '%s' 'line one +> line two +> ' +1 line one +1 line two + +# Multiline shell blocks should use explicit continuation opcodes. +$ if true; then +> echo selected +> fi +1 selected + +# Multiple heredocs in one command should consume their bodies in shell order. +$ cat > left.txt <<'LEFT' && cat > right.txt <<'RIGHT' +> left value +> LEFT +> right value +> RIGHT + +$ paste -d / left.txt right.txt +1 left value/right value diff --git a/tests/update-multiline-command-blocks/source.cmdt b/tests/update-multiline-command-blocks/source.cmdt new file mode 100644 index 0000000..a64ee3d --- /dev/null +++ b/tests/update-multiline-command-blocks/source.cmdt @@ -0,0 +1,7 @@ +$ cat > fixture.txt <<'EOF' +> alpha +> beta +> EOF + +$ cat fixture.txt +1 stale diff --git a/tests/update-multiline-command-blocks/test.cmdt b/tests/update-multiline-command-blocks/test.cmdt new file mode 100644 index 0000000..942e305 --- /dev/null +++ b/tests/update-multiline-command-blocks/test.cmdt @@ -0,0 +1,11 @@ +# Update should preserve multiline command continuations while refreshing assertions. + +$ transcript update --dry-run source.cmdt +1 $ cat > fixture.txt <<'EOF' +1 > alpha +1 > beta +1 > EOF +1 +1 $ cat fixture.txt +1 1 alpha +1 1 beta From 06dce769b3dd5941a6d1f92f4af08bfce392ca88 Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Sun, 5 Jul 2026 15:32:30 -0700 Subject: [PATCH 2/2] tidy continuation docs --- docs/reference.md | 3 +-- docs/user-guide.md | 4 ++-- tests/multiline-command-blocks/test.cmdt | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/reference.md b/docs/reference.md index c1c23e1..5fb0613 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -36,8 +36,7 @@ $ cat fixture.txt 1 beta ``` -Continuation payloads are raw shell text, even if they begin with transcript -opcodes such as `$`, `1`, `2`, `%`, `?`, or `#`. +Continuation payloads are raw shell text. ### `1` / `2` output diff --git a/docs/user-guide.md b/docs/user-guide.md index 6b3a501..bf7aceb 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -66,8 +66,8 @@ If output is large, or if the output is binary, transcripts can reference an external file via `1<`/`2<`. `transcript update` will automatically create numbered `*.bin` files for binary output. -Commands may also use shell heredocs to create readable multiline fixtures. -Use `>` continuation lines for the heredoc body: +Commands may be multiline, such as when using shell heredocs to create +readable fixtures. Use `>` continuation lines after the first command line: ```cmdt $ cat > config.txt <<'EOF' diff --git a/tests/multiline-command-blocks/test.cmdt b/tests/multiline-command-blocks/test.cmdt index 161c2f4..b8bdba6 100644 --- a/tests/multiline-command-blocks/test.cmdt +++ b/tests/multiline-command-blocks/test.cmdt @@ -14,14 +14,14 @@ $ cat fixture.txt 1 $ This is fixture data, not a transcript command. 1 1 This is fixture data, not expected output. -# Multiline quoted strings should use explicit continuation opcodes. +# Multiline quoted strings. $ printf '%s' 'line one > line two > ' 1 line one 1 line two -# Multiline shell blocks should use explicit continuation opcodes. +# Multiline shell blocks. $ if true; then > echo selected > fi