From 072c342b9b16fbf5d2d09acf988ad2557f114072 Mon Sep 17 00:00:00 2001 From: Trevin Chow Date: Fri, 3 Apr 2026 22:38:04 -0700 Subject: [PATCH] fix: support absolute paths in Output and Screenshot commands The lexer treats `/` as a regex delimiter, so `Output /tmp/demo.gif` tokenizes as REGEX("tmp") + STRING("demo.gif") and the parser rejects it. This also breaks the `vhs new` template which generates absolute Output paths. Add a shared parsePathArgument helper that reconstructs absolute paths from the REGEX + STRING token sequence when the parser expects a file path. Relative paths still use the existing STRING token path. Fixes #741 Co-Authored-By: Claude Opus 4.6 (1M context) --- parser/parser.go | 42 ++++++++++++++++++++++++++----------- parser/parser_test.go | 48 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/parser/parser.go b/parser/parser.go index 39a4dcb4..e1e9ce1a 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -417,23 +417,23 @@ func (p *Parser) parseKeypress(ct token.Type) Command { func (p *Parser) parseOutput() Command { cmd := Command{Type: token.OUTPUT} - if p.peek.Type != token.STRING { + path, ok := p.parsePathArgument() + if !ok { p.errors = append(p.errors, NewError(p.cur, "Expected file path after output")) return cmd } - ext := filepath.Ext(p.peek.Literal) + ext := filepath.Ext(path) if ext != "" { cmd.Options = ext } else { cmd.Options = ".png" - if !strings.HasSuffix(p.peek.Literal, "/") { - p.errors = append(p.errors, NewError(p.peek, "Expected folder with trailing slash")) + if !strings.HasSuffix(path, "/") { + p.errors = append(p.errors, NewError(p.cur, "Expected folder with trailing slash")) } } - cmd.Args = p.peek.Literal - p.nextToken() + cmd.Args = path return cmd } @@ -756,28 +756,46 @@ func (p *Parser) parseSource() []Command { func (p *Parser) parseScreenshot() Command { cmd := Command{Type: token.SCREENSHOT} - if p.peek.Type != token.STRING { + path, ok := p.parsePathArgument() + if !ok { p.errors = append(p.errors, NewError(p.cur, "Expected path after Screenshot")) p.nextToken() return cmd } - path := p.peek.Literal - // Check if path has .png extension ext := filepath.Ext(path) if ext != ".png" { - p.errors = append(p.errors, NewError(p.peek, "Expected file with .png extension")) - p.nextToken() + p.errors = append(p.errors, NewError(p.cur, "Expected file with .png extension")) return cmd } cmd.Args = path - p.nextToken() return cmd } +// parsePathArgument parses either a regular string path or an absolute path +// tokenized as REGEX followed by an optional STRING suffix. +func (p *Parser) parsePathArgument() (string, bool) { + switch p.peek.Type { + case token.STRING: + path := p.peek.Literal + p.nextToken() + return path, true + case token.REGEX: + path := "/" + p.peek.Literal + "/" + p.nextToken() + if p.peek.Type == token.STRING { + path += p.peek.Literal + p.nextToken() + } + return path, true + default: + return "", false + } +} + // Errors returns any errors that occurred during parsing. func (p *Parser) Errors() []Error { return p.errors diff --git a/parser/parser_test.go b/parser/parser_test.go index fd3a4467..6f09b3c0 100644 --- a/parser/parser_test.go +++ b/parser/parser_test.go @@ -229,6 +229,54 @@ func TestParseTapeFile(t *testing.T) { } } +func TestParseAbsolutePaths(t *testing.T) { + input := ` +Output /tmp/demo.gif +Output /tmp/demo.mp4 +Output /home/user/recordings/demo.webm +Output /frames/ +Output examples/out.gif +Output frames/ +Screenshot /tmp/screenshot.png +Screenshot examples/screenshot.png` + + expected := []Command{ + {Type: token.OUTPUT, Options: ".gif", Args: "/tmp/demo.gif"}, + {Type: token.OUTPUT, Options: ".mp4", Args: "/tmp/demo.mp4"}, + {Type: token.OUTPUT, Options: ".webm", Args: "/home/user/recordings/demo.webm"}, + {Type: token.OUTPUT, Options: ".png", Args: "/frames/"}, + {Type: token.OUTPUT, Options: ".gif", Args: "examples/out.gif"}, + {Type: token.OUTPUT, Options: ".png", Args: "frames/"}, + {Type: token.SCREENSHOT, Args: "/tmp/screenshot.png"}, + {Type: token.SCREENSHOT, Args: "examples/screenshot.png"}, + } + + l := lexer.New(input) + p := New(l) + + cmds := p.Parse() + + if len(p.Errors()) > 0 { + t.Fatalf("Expected no parser errors, got %v", p.Errors()) + } + + if len(cmds) != len(expected) { + t.Fatalf("Expected %d commands, got %d", len(expected), len(cmds)) + } + + for i, cmd := range cmds { + if cmd.Type != expected[i].Type { + t.Errorf("Expected command %d to be %s, got %s", i, expected[i].Type, cmd.Type) + } + if cmd.Args != expected[i].Args { + t.Errorf("Expected command %d to have args %s, got %s", i, expected[i].Args, cmd.Args) + } + if cmd.Options != expected[i].Options { + t.Errorf("Expected command %d to have options %s, got %s", i, expected[i].Options, cmd.Options) + } + } +} + func TestParseCtrl(t *testing.T) { tests := []struct { name string