Skip to content

Commit 9624cda

Browse files
feat: add Wait to wait for expected output (#257)
* add Match and MatchAny commands * allow changing Match value * switch to MatchLine and MatchScreen * remove leftover code * add wait and regex to lexer * add parseWait * Add wait option defaults * add execute for wait * allow setting WAitTimeout and WaitPattern * remove MATCH_LINE and MATCH_SCREEN * add description to Buffer method * add wait to parser test and fix setting parsing * update TestCommand * improve error output for Wait timeout * don't require regex (fall back to WaitPattern) * update signatures with errors * set default wait timeout to 15s * Update testing.go Co-authored-by: Christian Rocha <[email protected]> --------- Co-authored-by: Christian Rocha <[email protected]>
1 parent a6c47c8 commit 9624cda

File tree

12 files changed

+252
-21
lines changed

12 files changed

+252
-21
lines changed

command.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"io"
77
"os"
88
"os/exec"
9+
"regexp"
910
"strconv"
1011
"strings"
1112
"time"
@@ -76,6 +77,7 @@ var CommandFuncs = map[parser.CommandType]CommandFunc{
7677
token.COPY: ExecuteCopy,
7778
token.PASTE: ExecutePaste,
7879
token.ENV: ExecuteEnv,
80+
token.WAIT: ExecuteWait,
7981
}
8082

8183
// ExecuteNoop is a no-op command that does nothing.
@@ -111,6 +113,71 @@ func ExecuteKey(k input.Key) CommandFunc {
111113
}
112114
}
113115

116+
// WaitTick is the amount of time to wait between checking for a match.
117+
const WaitTick = 10 * time.Millisecond
118+
119+
// ExecuteWait is a CommandFunc that waits for a regex match for the given amount of time.
120+
func ExecuteWait(c parser.Command, v *VHS) error {
121+
scope, rxStr, ok := strings.Cut(c.Args, " ")
122+
rx := v.Options.WaitPattern
123+
if ok {
124+
// This is validated on parse so using MustCompile reduces noise.
125+
rx = regexp.MustCompile(rxStr)
126+
}
127+
128+
timeout := v.Options.WaitTimeout
129+
if c.Options != "" {
130+
t, err := time.ParseDuration(c.Options)
131+
if err != nil {
132+
// Shouldn't be possible due to parse validation.
133+
return fmt.Errorf("failed to parse duration: %w", err)
134+
}
135+
timeout = t
136+
}
137+
138+
checkT := time.NewTicker(WaitTick)
139+
defer checkT.Stop()
140+
timeoutT := time.NewTimer(timeout)
141+
defer timeoutT.Stop()
142+
143+
for {
144+
var last string
145+
switch scope {
146+
case "Line":
147+
line, err := v.CurrentLine()
148+
if err != nil {
149+
return fmt.Errorf("failed to get current line: %w", err)
150+
}
151+
last = line
152+
153+
if rx.MatchString(line) {
154+
return nil
155+
}
156+
case "Screen":
157+
lines, err := v.Buffer()
158+
if err != nil {
159+
return fmt.Errorf("failed to get buffer: %w", err)
160+
}
161+
last = strings.Join(lines, "\n")
162+
163+
if rx.MatchString(last) {
164+
return nil
165+
}
166+
default:
167+
// Should be impossible due to parse validation, but we don't want to
168+
// hang if it does happen due to a bug.
169+
return fmt.Errorf("invalid scope %q", scope)
170+
}
171+
172+
select {
173+
case <-checkT.C:
174+
continue
175+
case <-timeoutT.C:
176+
return fmt.Errorf("timeout waiting for %q to match %s; last value was: %s", c.Args, rx.String(), last)
177+
}
178+
}
179+
}
180+
114181
// ExecuteCtrl is a CommandFunc that presses the argument keys and/or modifiers
115182
// with the ctrl key held down on the running instance of vhs.
116183
func ExecuteCtrl(c parser.Command, v *VHS) error {
@@ -371,6 +438,8 @@ var Settings = map[string]CommandFunc{
371438
"WindowBar": ExecuteSetWindowBar,
372439
"WindowBarSize": ExecuteSetWindowBarSize,
373440
"BorderRadius": ExecuteSetBorderRadius,
441+
"WaitPattern": ExecuteSetWaitPattern,
442+
"WaitTimeout": ExecuteSetWaitTimeout,
374443
"CursorBlink": ExecuteSetCursorBlink,
375444
}
376445

@@ -521,6 +590,26 @@ func ExecuteSetTypingSpeed(c parser.Command, v *VHS) error {
521590
return nil
522591
}
523592

593+
// ExecuteSetWaitTimeout applies the default wait timeout on the vhs.
594+
func ExecuteSetWaitTimeout(c parser.Command, v *VHS) error {
595+
waitTimeout, err := time.ParseDuration(c.Args)
596+
if err != nil {
597+
return fmt.Errorf("failed to parse wait timeout: %w", err)
598+
}
599+
v.Options.WaitTimeout = waitTimeout
600+
return nil
601+
}
602+
603+
// ExecuteSetWaitPattern applies the default wait pattern on the vhs.
604+
func ExecuteSetWaitPattern(c parser.Command, v *VHS) error {
605+
rx, err := regexp.Compile(c.Args)
606+
if err != nil {
607+
return fmt.Errorf("failed to compile regexp: %w", err)
608+
}
609+
v.Options.WaitPattern = rx
610+
return nil
611+
}
612+
524613
// ExecuteSetPadding applies the padding on the vhs.
525614
func ExecuteSetPadding(c parser.Command, v *VHS) error {
526615
padding, err := strconv.Atoi(c.Args)

command_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ import (
88
)
99

1010
func TestCommand(t *testing.T) {
11-
const numberOfCommands = 28
11+
const numberOfCommands = 29
1212
if len(parser.CommandTypes) != numberOfCommands {
1313
t.Errorf("Expected %d commands, got %d", numberOfCommands, len(parser.CommandTypes))
1414
}
1515

16-
const numberOfCommandFuncs = 28
16+
const numberOfCommandFuncs = 29
1717
if len(CommandFuncs) != numberOfCommandFuncs {
1818
t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs))
1919
}

lexer/lexer.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ func (l *Lexer) NextToken() token.Token {
6767
tok.Type = token.STRING
6868
tok.Literal = l.readString('"')
6969
l.readChar()
70+
case '/':
71+
tok.Type = token.REGEX
72+
tok.Literal = l.readString('/')
73+
l.readChar()
7074
default:
7175
if isDigit(l.ch) || (isDot(l.ch) && isDigit(l.peekChar())) {
7276
tok.Literal = l.readNumber()

lexer/lexer_test.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ Ctrl+C
2424
Enter
2525
Sleep .1
2626
Sleep 100ms
27-
Sleep 2`
27+
Sleep 2
28+
Wait+Screen@1m /foobar/`
2829

2930
tests := []struct {
3031
expectedType token.Type
@@ -71,6 +72,13 @@ Sleep 2`
7172
{token.MILLISECONDS, "ms"},
7273
{token.SLEEP, "Sleep"},
7374
{token.NUMBER, "2"},
75+
{token.WAIT, "Wait"},
76+
{token.PLUS, "+"},
77+
{token.STRING, "Screen"},
78+
{token.AT, "@"},
79+
{token.NUMBER, "1"},
80+
{token.MINUTES, "m"},
81+
{token.REGEX, "foobar"},
7482
}
7583

7684
l := New(input)

man.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ The following is a list of all possible commands in VHS:
4343
* %PageDown% [repeat]
4444
* %Hide%
4545
* %Show%
46+
* %Wait%[+Screen][@<timeout>] /<regexp>/
4647
* %Escape%
4748
* %Alt%+<key>
4849
* %Space% [repeat]
@@ -72,6 +73,8 @@ The following is a list of all possible setting commands in VHS:
7273
* Set %Padding% <number>
7374
* Set %Framerate% <number>
7475
* Set %PlaybackSpeed% <float>
76+
* Set %WaitTimeout% <time>
77+
* Set %WaitPattern% <regexp>
7578
`
7679
manBugs = "See GitHub Issues: <https://github.com/charmbracelet/vhs/issues>"
7780

parser/parser.go

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import (
44
"fmt"
55
"os"
66
"path/filepath"
7+
"regexp"
78
"strconv"
89
"strings"
10+
"time"
911

1012
"github.com/charmbracelet/vhs/lexer"
1113
"github.com/charmbracelet/vhs/token"
@@ -47,6 +49,7 @@ var CommandTypes = []CommandType{ //nolint: deadcode
4749
token.TAB,
4850
token.TYPE,
4951
token.UP,
52+
token.WAIT,
5053
token.SOURCE,
5154
token.SCREENSHOT,
5255
token.COPY,
@@ -55,13 +58,7 @@ var CommandTypes = []CommandType{ //nolint: deadcode
5558
}
5659

5760
// String returns the string representation of the command.
58-
func (c CommandType) String() string {
59-
if len(c) < 1 {
60-
return ""
61-
}
62-
s := string(c)
63-
return string(s[0]) + strings.ToLower(s[1:])
64-
}
61+
func (c CommandType) String() string { return token.ToCamel(string(c)) }
6562

6663
// Command represents a command with options and arguments.
6764
type Command struct {
@@ -170,6 +167,8 @@ func (p *Parser) parseCommand() Command {
170167
return p.parseRequire()
171168
case token.SHOW:
172169
return p.parseShow()
170+
case token.WAIT:
171+
return p.parseWait()
173172
case token.SOURCE:
174173
return p.parseSource()
175174
case token.SCREENSHOT:
@@ -186,6 +185,45 @@ func (p *Parser) parseCommand() Command {
186185
}
187186
}
188187

188+
func (p *Parser) parseWait() Command {
189+
cmd := Command{Type: token.WAIT}
190+
191+
if p.peek.Type == token.PLUS {
192+
p.nextToken()
193+
if p.peek.Type != token.STRING || (p.peek.Literal != "Line" && p.peek.Literal != "Screen") {
194+
p.errors = append(p.errors, NewError(p.peek, "Wait+ expects Line or Screen"))
195+
return cmd
196+
}
197+
cmd.Args = p.peek.Literal
198+
p.nextToken()
199+
} else {
200+
cmd.Args = "Line"
201+
}
202+
203+
cmd.Options = p.parseSpeed()
204+
if cmd.Options != "" {
205+
dur, _ := time.ParseDuration(cmd.Options)
206+
if dur <= 0 {
207+
p.errors = append(p.errors, NewError(p.peek, "Wait expects positive duration"))
208+
return cmd
209+
}
210+
}
211+
212+
if p.peek.Type != token.REGEX {
213+
// fallback to default
214+
return cmd
215+
}
216+
p.nextToken()
217+
if _, err := regexp.Compile(p.cur.Literal); err != nil {
218+
p.errors = append(p.errors, NewError(p.cur, fmt.Sprintf("Invalid regular expression '%s': %v", p.cur.Literal, err)))
219+
return cmd
220+
}
221+
222+
cmd.Args += " " + p.cur.Literal
223+
224+
return cmd
225+
}
226+
189227
// parseSpeed parses a typing speed indication.
190228
//
191229
// i.e. @<time>
@@ -227,10 +265,11 @@ func (p *Parser) parseTime() string {
227265
p.nextToken()
228266
} else {
229267
p.errors = append(p.errors, NewError(p.cur, "Expected time after "+p.cur.Literal))
268+
return ""
230269
}
231270

232271
// Allow TypingSpeed to have bare units (e.g. 50ms, 100ms)
233-
if p.peek.Type == token.MILLISECONDS || p.peek.Type == token.SECONDS {
272+
if p.peek.Type == token.MILLISECONDS || p.peek.Type == token.SECONDS || p.peek.Type == token.MINUTES {
234273
t += p.peek.Literal
235274
p.nextToken()
236275
} else {
@@ -393,6 +432,15 @@ func (p *Parser) parseSet() Command {
393432
p.nextToken()
394433

395434
switch p.cur.Type {
435+
case token.WAIT_TIMEOUT:
436+
cmd.Args = p.parseTime()
437+
case token.WAIT_PATTERN:
438+
cmd.Args = p.peek.Literal
439+
_, err := regexp.Compile(p.peek.Literal)
440+
if err != nil {
441+
p.errors = append(p.errors, NewError(p.peek, "Invalid regexp pattern: "+p.peek.Literal))
442+
}
443+
p.nextToken()
396444
case token.LOOP_OFFSET:
397445
cmd.Args = p.peek.Literal
398446
p.nextToken()

parser/parser_test.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
func TestParser(t *testing.T) {
1313
input := `
1414
Set TypingSpeed 100ms
15+
Set WaitTimeout 1m
16+
Set WaitPattern /foo/
1517
Type "echo 'Hello, World!'"
1618
Enter
1719
@@ -28,10 +30,15 @@ Ctrl+C
2830
Ctrl+L
2931
Alt+.
3032
Sleep 100ms
31-
Sleep 3`
33+
Sleep 3
34+
Wait
35+
Wait+Screen
36+
Wait@100ms /foobar/`
3237

3338
expected := []Command{
3439
{Type: token.SET, Options: "TypingSpeed", Args: "100ms"},
40+
{Type: token.SET, Options: "WaitTimeout", Args: "1m"},
41+
{Type: token.SET, Options: "WaitPattern", Args: "foo"},
3542
{Type: token.TYPE, Options: "", Args: "echo 'Hello, World!'"},
3643
{Type: token.ENTER, Options: "", Args: "1"},
3744
{Type: token.BACKSPACE, Options: "0.1s", Args: "5"},
@@ -49,6 +56,9 @@ Sleep 3`
4956
{Type: token.ALT, Options: "", Args: "."},
5057
{Type: token.SLEEP, Args: "100ms"},
5158
{Type: token.SLEEP, Args: "3s"},
59+
{Type: token.WAIT, Args: "Line"},
60+
{Type: token.WAIT, Args: "Screen"},
61+
{Type: token.WAIT, Options: "100ms", Args: "Line foobar"},
5262
}
5363

5464
l := lexer.New(input)
@@ -57,7 +67,7 @@ Sleep 3`
5767
cmds := p.Parse()
5868

5969
if len(cmds) != len(expected) {
60-
t.Fatalf("Expected %d commands, got %d", len(expected), len(cmds))
70+
t.Fatalf("Expected %d commands, got %d; %v", len(expected), len(cmds), cmds)
6171
}
6272

6373
for i, cmd := range cmds {

syntax.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ func Highlight(c parser.Command, faint bool) string {
2626
}
2727

2828
switch c.Type {
29+
case token.REGEX:
30+
argsStyle = StringStyle
2931
case token.SET:
3032
optionsStyle = KeywordStyle
3133
if isNumber(c.Args) {

0 commit comments

Comments
 (0)