Skip to content
Merged
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
18 changes: 18 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 42 additions & 3 deletions internal/core/interp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand All @@ -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")
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
14 changes: 13 additions & 1 deletion internal/core/recorder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
}
Expand Down
38 changes: 38 additions & 0 deletions tests/multiline-command-blocks/test.cmdt
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions tests/update-multiline-command-blocks/source.cmdt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
$ cat > fixture.txt <<'EOF'
> alpha
> beta
> EOF

$ cat fixture.txt
1 stale
11 changes: 11 additions & 0 deletions tests/update-multiline-command-blocks/test.cmdt
Original file line number Diff line number Diff line change
@@ -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
Loading