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
20 changes: 20 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ 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.

If a command is incomplete according to the shell parser, following transcript
lines are treated as part of the same command until the command is complete.
Only the first line uses the `$` opcode:

```cmdt
$ cat > fixture.txt <<'EOF'
alpha
beta
EOF

$ cat fixture.txt
1 alpha
1 beta
```

This supports shell forms such as heredocs, multiline quoted strings, and
multiline shell blocks. Lines inside an incomplete command 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:
Expand Down
15 changes: 15 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ 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:

```cmdt
$ cat > config.txt <<'EOF'
name = "example"
enabled = true
EOF

$ mytool config.txt
1 loaded example
```

The heredoc body is part of the shell command, so its lines do not need
transcript opcodes.

## Go Tests

You can embed `*.cmdt` scripts in Go tests:
Expand Down
55 changes: 51 additions & 4 deletions internal/core/interp.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package core
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"strconv"
"strings"

"mvdan.cc/sh/v3/syntax"
)

// Interprets a transcript file.
Expand All @@ -22,6 +25,9 @@ type Interpreter struct {
// Private state.
acceptResults bool
prevFD int // stdout (1), stderr (2) or none (0).

pendingCommandLineno int
pendingCommandLines []string
}

// Handler provides callbacks for processing transcript operations.
Expand Down Expand Up @@ -79,12 +85,18 @@ func (t *Interpreter) ExecTranscript(ctx context.Context, r io.Reader) error {
if err := scanner.Err(); err != nil {
return fmt.Errorf("scanning: %w", err)
}
if t.pendingCommandLineno != 0 {
return t.syntaxErrorAtLinef(t.pendingCommandLineno, "unterminated command")
}

return t.flushCommand(ctx)
}

func (t *Interpreter) ExecLine(ctx context.Context, text string) error {
hdlr := t.Handler
if t.pendingCommandLineno != 0 {
return t.appendCommandLine(ctx, text)
}
if strings.TrimSpace(text) == "" || text[0] == '#' {
return hdlr.HandleComment(ctx, text)
}
Expand All @@ -99,10 +111,7 @@ 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)
return t.startCommand(ctx, payload)

case "1", "2":
if !t.acceptResults {
Expand Down Expand Up @@ -165,6 +174,40 @@ func (t *Interpreter) ExecLine(ctx context.Context, text string) error {
}
}

func (t *Interpreter) startCommand(ctx context.Context, command string) error {
t.pendingCommandLineno = t.Lineno
t.pendingCommandLines = []string{command}
return t.finishCommandIfComplete(ctx)
}

func (t *Interpreter) appendCommandLine(ctx context.Context, text string) error {
t.pendingCommandLines = append(t.pendingCommandLines, text)
return t.finishCommandIfComplete(ctx)
}

func (t *Interpreter) finishCommandIfComplete(ctx context.Context) error {
command := strings.Join(t.pendingCommandLines, "\n")
if commandIncomplete(command) {
return nil
}

t.Command = command
t.CommandLineno = t.pendingCommandLineno
t.acceptResults = true
t.pendingCommandLineno = 0
t.pendingCommandLines = nil
return t.Handler.HandleRun(ctx, command)
}

func commandIncomplete(command string) bool {
_, err := parseStmt(command)
if syntax.IsIncomplete(err) {
return true
}
var parseErr syntax.ParseError
return errors.As(err, &parseErr) && strings.HasPrefix(parseErr.Text, "unclosed here-document ")
}

func (t *Interpreter) flushCommand(ctx context.Context) error {
if t.CommandLineno == 0 {
return nil
Expand All @@ -176,3 +219,7 @@ func (t *Interpreter) flushCommand(ctx context.Context) error {
func (t *Interpreter) syntaxErrorf(message string, v ...any) error {
return fmt.Errorf("syntax error on line %d: "+message, append([]any{t.Lineno}, v...)...)
}

func (t *Interpreter) syntaxErrorAtLinef(lineno int, message string, v ...any) error {
return fmt.Errorf("syntax error on line %d: "+message, append([]any{lineno}, v...)...)
}
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 be treated as a single shell command.
$ 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 be treated as a single shell command.
$ printf '%s' 'line one
line two
'
1 line one
1 line two

# Multiline shell blocks should be treated as a single shell command.
$ 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 blocks 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