diff --git a/docs/reference.md b/docs/reference.md index 8a535dd..5fb0613 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -20,6 +20,24 @@ 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. + ### `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..bf7aceb 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 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' +> 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..b8bdba6 --- /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. +$ printf '%s' 'line one +> line two +> ' +1 line one +1 line two + +# Multiline shell blocks. +$ 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