Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Wait to wait for expected output #257

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
bb16d08
add Match and MatchAny commands
mastercactapus Nov 26, 2022
476a839
allow changing Match value
mastercactapus Nov 26, 2022
4a41562
switch to MatchLine and MatchScreen
mastercactapus Dec 11, 2022
b741ff8
Merge branch 'main' into match
mastercactapus Mar 12, 2023
78c0e37
remove leftover code
mastercactapus Mar 12, 2023
4d1b73d
Merge remote-tracking branch 'origin/main' into match
mastercactapus Apr 12, 2023
53ed73a
Merge remote-tracking branch 'origin/main' into match
mastercactapus May 24, 2023
7ae1fbb
add wait and regex to lexer
mastercactapus May 25, 2023
4213ef3
add parseWait
mastercactapus May 25, 2023
ffb4024
Add wait option defaults
mastercactapus May 25, 2023
d6216c2
add execute for wait
mastercactapus May 25, 2023
73a4c20
allow setting WAitTimeout and WaitPattern
mastercactapus May 25, 2023
8d0302f
remove MATCH_LINE and MATCH_SCREEN
mastercactapus May 25, 2023
5b60921
add description to Buffer method
mastercactapus May 25, 2023
944d605
add wait to parser test and fix setting parsing
mastercactapus May 25, 2023
d9bc087
update TestCommand
mastercactapus May 25, 2023
35b8ece
Merge remote-tracking branch 'origin/main' into match
mastercactapus Dec 17, 2023
263dbab
improve error output for Wait timeout
mastercactapus Dec 17, 2023
e65020c
don't require regex (fall back to WaitPattern)
mastercactapus Dec 17, 2023
c9836db
Merge branch 'main' into match
mastercactapus May 18, 2024
28a3c13
Merge remote-tracking branch 'upstream/main' into match
mastercactapus May 31, 2024
997662d
update signatures with errors
mastercactapus May 31, 2024
adaeead
set default wait timeout to 15s
mastercactapus Jun 6, 2024
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
89 changes: 89 additions & 0 deletions command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"os/exec"
"regexp"
"strconv"
"strings"
"time"
Expand Down Expand Up @@ -76,6 +77,7 @@ var CommandFuncs = map[parser.CommandType]CommandFunc{
token.COPY: ExecuteCopy,
token.PASTE: ExecutePaste,
token.ENV: ExecuteEnv,
token.WAIT: ExecuteWait,
}

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

// WaitTick is the amount of time to wait between checking for a match.
const WaitTick = 10 * time.Millisecond

// ExecuteWait is a CommandFunc that waits for a regex match for the given amount of time.
func ExecuteWait(c parser.Command, v *VHS) error {
scope, rxStr, ok := strings.Cut(c.Args, " ")
rx := v.Options.WaitPattern
if ok {
// This is validated on parse so using MustCompile reduces noise.
rx = regexp.MustCompile(rxStr)
}

timeout := v.Options.WaitTimeout
if c.Options != "" {
t, err := time.ParseDuration(c.Options)
if err != nil {
// Shouldn't be possible due to parse validation.
return fmt.Errorf("failed to parse duration: %w", err)
}
timeout = t
}

checkT := time.NewTicker(WaitTick)
defer checkT.Stop()
timeoutT := time.NewTimer(timeout)
defer timeoutT.Stop()

for {
var last string
switch scope {
case "Line":
line, err := v.CurrentLine()
if err != nil {
return fmt.Errorf("failed to get current line: %w", err)
}
last = line

if rx.MatchString(line) {
return nil
}
case "Screen":
lines, err := v.Buffer()
if err != nil {
return fmt.Errorf("failed to get buffer: %w", err)
}
last = strings.Join(lines, "\n")

if rx.MatchString(last) {
return nil
}
default:
// Should be impossible due to parse validation, but we don't want to
// hang if it does happen due to a bug.
return fmt.Errorf("invalid scope %q", scope)
}

select {
case <-checkT.C:
continue
case <-timeoutT.C:
return fmt.Errorf("timeout waiting for %q to match %s; last value was: %s", c.Args, rx.String(), last)
}
}
}

// ExecuteCtrl is a CommandFunc that presses the argument keys and/or modifiers
// with the ctrl key held down on the running instance of vhs.
func ExecuteCtrl(c parser.Command, v *VHS) error {
Expand Down Expand Up @@ -371,6 +438,8 @@ var Settings = map[string]CommandFunc{
"WindowBar": ExecuteSetWindowBar,
"WindowBarSize": ExecuteSetWindowBarSize,
"BorderRadius": ExecuteSetBorderRadius,
"WaitPattern": ExecuteSetWaitPattern,
"WaitTimeout": ExecuteSetWaitTimeout,
"CursorBlink": ExecuteSetCursorBlink,
}

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

// ExecuteSetWaitTimeout applies the default wait timeout on the vhs.
func ExecuteSetWaitTimeout(c parser.Command, v *VHS) error {
waitTimeout, err := time.ParseDuration(c.Args)
if err != nil {
return fmt.Errorf("failed to parse wait timeout: %w", err)
}
v.Options.WaitTimeout = waitTimeout
return nil
}

// ExecuteSetWaitPattern applies the default wait pattern on the vhs.
func ExecuteSetWaitPattern(c parser.Command, v *VHS) error {
rx, err := regexp.Compile(c.Args)
if err != nil {
return fmt.Errorf("failed to compile regexp: %w", err)
}
v.Options.WaitPattern = rx
return nil
}

// ExecuteSetPadding applies the padding on the vhs.
func ExecuteSetPadding(c parser.Command, v *VHS) error {
padding, err := strconv.Atoi(c.Args)
Expand Down
4 changes: 2 additions & 2 deletions command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import (
)

func TestCommand(t *testing.T) {
const numberOfCommands = 28
const numberOfCommands = 29
if len(parser.CommandTypes) != numberOfCommands {
t.Errorf("Expected %d commands, got %d", numberOfCommands, len(parser.CommandTypes))
}

const numberOfCommandFuncs = 28
const numberOfCommandFuncs = 29
if len(CommandFuncs) != numberOfCommandFuncs {
t.Errorf("Expected %d commands, got %d", numberOfCommandFuncs, len(CommandFuncs))
}
Expand Down
6 changes: 5 additions & 1 deletion lexer/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func (l *Lexer) readChar() {
func (l *Lexer) NextToken() token.Token {
l.skipWhitespace()

var tok = token.Token{Line: l.line, Column: l.column}
tok := token.Token{Line: l.line, Column: l.column}

switch l.ch {
case 0:
Expand Down Expand Up @@ -67,6 +67,10 @@ func (l *Lexer) NextToken() token.Token {
tok.Type = token.STRING
tok.Literal = l.readString('"')
l.readChar()
case '/':
tok.Type = token.REGEX
tok.Literal = l.readString('/')
l.readChar()
default:
if isDigit(l.ch) || (isDot(l.ch) && isDigit(l.peekChar())) {
tok.Literal = l.readNumber()
Expand Down
10 changes: 9 additions & 1 deletion lexer/lexer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ Ctrl+C
Enter
Sleep .1
Sleep 100ms
Sleep 2`
Sleep 2
Wait+Screen@1m /foobar/`

tests := []struct {
expectedType token.Type
Expand Down Expand Up @@ -71,6 +72,13 @@ Sleep 2`
{token.MILLISECONDS, "ms"},
{token.SLEEP, "Sleep"},
{token.NUMBER, "2"},
{token.WAIT, "Wait"},
{token.PLUS, "+"},
{token.STRING, "Screen"},
{token.AT, "@"},
{token.NUMBER, "1"},
{token.MINUTES, "m"},
{token.REGEX, "foobar"},
}

l := New(input)
Expand Down
3 changes: 3 additions & 0 deletions man.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ The following is a list of all possible commands in VHS:
* %PageDown% [repeat]
* %Hide%
* %Show%
* %Wait%[+Screen][@<timeout>] /<regexp>/
* %Escape%
* %Alt%+<key>
* %Space% [repeat]
Expand Down Expand Up @@ -72,6 +73,8 @@ The following is a list of all possible setting commands in VHS:
* Set %Padding% <number>
* Set %Framerate% <number>
* Set %PlaybackSpeed% <float>
* Set %WaitTimeout% <time>
* Set %WaitPattern% <regexp>
`
manBugs = "See GitHub Issues: <https://github.com/charmbracelet/vhs/issues>"

Expand Down
64 changes: 56 additions & 8 deletions parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

"github.com/charmbracelet/vhs/lexer"
"github.com/charmbracelet/vhs/token"
Expand Down Expand Up @@ -47,6 +49,7 @@ var CommandTypes = []CommandType{ //nolint: deadcode
token.TAB,
token.TYPE,
token.UP,
token.WAIT,
token.SOURCE,
token.SCREENSHOT,
token.COPY,
Expand All @@ -55,13 +58,7 @@ var CommandTypes = []CommandType{ //nolint: deadcode
}

// String returns the string representation of the command.
func (c CommandType) String() string {
if len(c) < 1 {
return ""
}
s := string(c)
return string(s[0]) + strings.ToLower(s[1:])
}
func (c CommandType) String() string { return token.ToCamel(string(c)) }

// Command represents a command with options and arguments.
type Command struct {
Expand Down Expand Up @@ -170,6 +167,8 @@ func (p *Parser) parseCommand() Command {
return p.parseRequire()
case token.SHOW:
return p.parseShow()
case token.WAIT:
return p.parseWait()
case token.SOURCE:
return p.parseSource()
case token.SCREENSHOT:
Expand All @@ -186,6 +185,45 @@ func (p *Parser) parseCommand() Command {
}
}

func (p *Parser) parseWait() Command {
cmd := Command{Type: token.WAIT}

if p.peek.Type == token.PLUS {
p.nextToken()
if p.peek.Type != token.STRING || (p.peek.Literal != "Line" && p.peek.Literal != "Screen") {
p.errors = append(p.errors, NewError(p.peek, "Wait+ expects Line or Screen"))
return cmd
}
cmd.Args = p.peek.Literal
p.nextToken()
} else {
cmd.Args = "Line"
}

cmd.Options = p.parseSpeed()
if cmd.Options != "" {
dur, _ := time.ParseDuration(cmd.Options)
if dur <= 0 {
p.errors = append(p.errors, NewError(p.peek, "Wait expects positive duration"))
return cmd
}
}

if p.peek.Type != token.REGEX {
// fallback to default
return cmd
}
p.nextToken()
if _, err := regexp.Compile(p.cur.Literal); err != nil {
p.errors = append(p.errors, NewError(p.cur, fmt.Sprintf("Invalid regular expression '%s': %v", p.cur.Literal, err)))
return cmd
}

cmd.Args += " " + p.cur.Literal

return cmd
}

// parseSpeed parses a typing speed indication.
//
// i.e. @<time>
Expand Down Expand Up @@ -227,10 +265,11 @@ func (p *Parser) parseTime() string {
p.nextToken()
} else {
p.errors = append(p.errors, NewError(p.cur, "Expected time after "+p.cur.Literal))
return ""
}

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

switch p.cur.Type {
case token.WAIT_TIMEOUT:
cmd.Args = p.parseTime()
case token.WAIT_PATTERN:
cmd.Args = p.peek.Literal
_, err := regexp.Compile(p.peek.Literal)
if err != nil {
p.errors = append(p.errors, NewError(p.peek, "Invalid regexp pattern: "+p.peek.Literal))
}
p.nextToken()
case token.LOOP_OFFSET:
cmd.Args = p.peek.Literal
p.nextToken()
Expand Down
14 changes: 12 additions & 2 deletions parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
func TestParser(t *testing.T) {
input := `
Set TypingSpeed 100ms
Set WaitTimeout 1m
Set WaitPattern /foo/
Type "echo 'Hello, World!'"
Enter
[email protected] 5
Expand All @@ -28,10 +30,15 @@ Ctrl+C
Ctrl+L
Alt+.
Sleep 100ms
Sleep 3`
Sleep 3
Wait
Wait+Screen
Wait@100ms /foobar/`

expected := []Command{
{Type: token.SET, Options: "TypingSpeed", Args: "100ms"},
{Type: token.SET, Options: "WaitTimeout", Args: "1m"},
{Type: token.SET, Options: "WaitPattern", Args: "foo"},
{Type: token.TYPE, Options: "", Args: "echo 'Hello, World!'"},
{Type: token.ENTER, Options: "", Args: "1"},
{Type: token.BACKSPACE, Options: "0.1s", Args: "5"},
Expand All @@ -49,6 +56,9 @@ Sleep 3`
{Type: token.ALT, Options: "", Args: "."},
{Type: token.SLEEP, Args: "100ms"},
{Type: token.SLEEP, Args: "3s"},
{Type: token.WAIT, Args: "Line"},
{Type: token.WAIT, Args: "Screen"},
{Type: token.WAIT, Options: "100ms", Args: "Line foobar"},
}

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

if len(cmds) != len(expected) {
t.Fatalf("Expected %d commands, got %d", len(expected), len(cmds))
t.Fatalf("Expected %d commands, got %d; %v", len(expected), len(cmds), cmds)
}

for i, cmd := range cmds {
Expand Down
2 changes: 2 additions & 0 deletions syntax.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ func Highlight(c parser.Command, faint bool) string {
}

switch c.Type {
case token.REGEX:
argsStyle = StringStyle
case token.SET:
optionsStyle = KeywordStyle
if isNumber(c.Args) {
Expand Down
Loading
Loading