diff --git a/docs/reference.md b/docs/reference.md index 8a535dd..991e45d 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -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: diff --git a/docs/user-guide.md b/docs/user-guide.md index 7f05077..4445476 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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: diff --git a/internal/core/interp.go b/internal/core/interp.go index 142c90b..7a39eca 100644 --- a/internal/core/interp.go +++ b/internal/core/interp.go @@ -3,10 +3,13 @@ package core import ( "bufio" "context" + "errors" "fmt" "io" "strconv" "strings" + + "mvdan.cc/sh/v3/syntax" ) // Interprets a transcript file. @@ -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. @@ -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) } @@ -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 { @@ -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 @@ -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...)...) +} diff --git a/tests/multiline-command-blocks/test.cmdt b/tests/multiline-command-blocks/test.cmdt new file mode 100644 index 0000000..8c7ec86 --- /dev/null +++ b/tests/multiline-command-blocks/test.cmdt @@ -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 diff --git a/tests/update-multiline-command-blocks/source.cmdt b/tests/update-multiline-command-blocks/source.cmdt new file mode 100644 index 0000000..708cc4b --- /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..ea8dc73 --- /dev/null +++ b/tests/update-multiline-command-blocks/test.cmdt @@ -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