Files
wehub-resource-sync 95f0813436
build / build (push) Failing after 0s
nightly / nightly (push) Failing after 0s
build / snapshot (push) Failing after 0s
lint / lint (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 13:02:37 +08:00

799 lines
18 KiB
Go

// Package parser provides a parser for the VHS Tape language.
package parser
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/charmbracelet/vhs/lexer"
"github.com/charmbracelet/vhs/token"
)
// NewError returns a new parser.Error with the given token and message.
func NewError(token token.Token, msg string) Error {
return Error{
Token: token,
Msg: msg,
}
}
// CommandType is a type that represents a command.
type CommandType token.Type
// CommandTypes is a list of the available commands that can be executed.
var CommandTypes = []CommandType{
token.BACKSPACE,
token.DELETE,
token.INSERT,
token.CTRL,
token.ALT,
token.DOWN,
token.ENTER,
token.ESCAPE,
token.ILLEGAL,
token.LEFT,
token.PAGE_UP,
token.PAGE_DOWN,
token.SCROLL_UP,
token.SCROLL_DOWN,
token.RIGHT,
token.SET,
token.OUTPUT,
token.SLEEP,
token.SPACE,
token.HIDE,
token.REQUIRE,
token.SHOW,
token.TAB,
token.TYPE,
token.UP,
token.WAIT,
token.SOURCE,
token.SCREENSHOT,
token.COPY,
token.PASTE,
token.ENV,
}
// String returns the string representation of the command.
func (c CommandType) String() string { return token.ToCamel(string(c)) }
// Command represents a command with options and arguments.
type Command struct {
Type CommandType
Options string
Args string
Source string
}
// String returns the string representation of the command.
// This includes the options and arguments of the command.
func (c Command) String() string {
if c.Options != "" {
return fmt.Sprintf("%s %s %s", c.Type, c.Options, c.Args)
}
return fmt.Sprintf("%s %s", c.Type, c.Options)
}
// Error represents an error with parsing a tape file.
// It tracks the token causing the error and a human readable error message.
type Error struct {
Token token.Token
Msg string
}
// String returns a human readable error message printing the token line number
// and message.
func (e Error) String() string {
return fmt.Sprintf("%2d:%-2d │ %s", e.Token.Line, e.Token.Column, e.Msg)
}
// Error implements the error interface.
func (e Error) Error() string {
return e.String()
}
// Parser is the structure that manages the parsing of tokens.
type Parser struct {
l *lexer.Lexer
errors []Error
cur token.Token
peek token.Token
}
// New returns a new Parser.
func New(l *lexer.Lexer) *Parser {
p := &Parser{l: l, errors: []Error{}}
// Read two tokens, so cur and peek are both set.
p.nextToken()
p.nextToken()
return p
}
// Parse takes an input string provided by the lexer and parses it into a
// list of commands.
func (p *Parser) Parse() []Command {
cmds := []Command{}
for p.cur.Type != token.EOF {
if p.cur.Type == token.COMMENT {
p.nextToken()
continue
}
cmds = append(cmds, p.parseCommand()...)
p.nextToken()
}
return cmds
}
// parseCommand parses a command.
func (p *Parser) parseCommand() []Command {
switch p.cur.Type {
case token.SPACE,
token.BACKSPACE,
token.DELETE,
token.INSERT,
token.ENTER,
token.ESCAPE,
token.TAB,
token.DOWN,
token.LEFT,
token.RIGHT,
token.UP,
token.PAGE_UP,
token.PAGE_DOWN,
token.SCROLL_UP,
token.SCROLL_DOWN:
return []Command{p.parseKeypress(p.cur.Type)}
case token.SET:
return []Command{p.parseSet()}
case token.OUTPUT:
return []Command{p.parseOutput()}
case token.SLEEP:
return []Command{p.parseSleep()}
case token.TYPE:
return []Command{p.parseType()}
case token.CTRL:
return []Command{p.parseCtrl()}
case token.ALT:
return []Command{p.parseAlt()}
case token.SHIFT:
return []Command{p.parseShift()}
case token.HIDE:
return []Command{p.parseHide()}
case token.REQUIRE:
return []Command{p.parseRequire()}
case token.SHOW:
return []Command{p.parseShow()}
case token.WAIT:
return []Command{p.parseWait()}
case token.SOURCE:
return p.parseSource()
case token.SCREENSHOT:
return []Command{p.parseScreenshot()}
case token.COPY:
return []Command{p.parseCopy()}
case token.PASTE:
return []Command{p.parsePaste()}
case token.ENV:
return []Command{p.parseEnv()}
default:
p.errors = append(p.errors, NewError(p.cur, "Invalid command: "+p.cur.Literal))
return []Command{{Type: token.ILLEGAL}}
}
}
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>
//
// This is optional (defaults to 100ms), thus skips (rather than error-ing)
// if the typing speed is not specified.
func (p *Parser) parseSpeed() string {
if p.peek.Type == token.AT {
p.nextToken()
return p.parseTime()
}
return ""
}
// parseRepeat parses an optional repeat count for a command.
//
// i.e. Backspace [count]
//
// This is optional (defaults to 1), thus skips (rather than error-ing)
// if the repeat count is not specified.
func (p *Parser) parseRepeat() string {
if p.peek.Type == token.NUMBER {
count := p.peek.Literal
p.nextToken()
return count
}
return "1"
}
// parseTime parses a time argument.
//
// <number>[ms]
func (p *Parser) parseTime() string {
var t string
if p.peek.Type == token.NUMBER {
t = p.peek.Literal
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 || p.peek.Type == token.MINUTES {
t += p.peek.Literal
p.nextToken()
} else {
t += "s"
}
return t
}
// parseCtrl parses a control command.
// A control command takes one or multiples characters and/or modifiers to type while ctrl is held down.
//
// Ctrl[+Alt][+Shift]+<char>
// E.g:
// Ctrl+Shift+O
// Ctrl+Alt+Shift+P
func (p *Parser) parseCtrl() Command {
var args []string
inModifierChain := true
for p.peek.Type == token.PLUS {
p.nextToken()
peek := p.peek
// Get key from keywords and check if it's a valid modifier
if k := token.Keywords[peek.Literal]; token.IsModifier(k) {
if !inModifierChain {
p.errors = append(p.errors, NewError(p.cur, "Modifiers must come before other characters"))
// Clear args so the error is returned
args = nil
continue
}
args = append(args, peek.Literal)
p.nextToken()
continue
}
inModifierChain = false
// Add key argument.
switch {
case peek.Type == token.ENTER,
peek.Type == token.SPACE,
peek.Type == token.BACKSPACE,
peek.Type == token.MINUS,
peek.Type == token.AT,
peek.Type == token.LEFT_BRACKET,
peek.Type == token.RIGHT_BRACKET,
peek.Type == token.CARET,
peek.Type == token.BACKSLASH,
peek.Type == token.LEFT,
peek.Type == token.RIGHT,
peek.Type == token.UP,
peek.Type == token.DOWN,
peek.Type == token.STRING && len(peek.Literal) == 1:
args = append(args, peek.Literal)
default:
// Key arguments with len > 1 are not valid
p.errors = append(p.errors,
NewError(p.cur, "Not a valid modifier"),
NewError(p.cur, "Invalid control argument: "+p.cur.Literal))
}
p.nextToken()
}
if len(args) == 0 {
p.errors = append(p.errors, NewError(p.cur, "Expected control character with args, got "+p.cur.Literal))
}
ctrlArgs := strings.Join(args, " ")
return Command{Type: token.CTRL, Args: ctrlArgs}
}
// parseAlt parses an alt command.
// An alt command takes a character to type while the modifier is held down.
//
// Alt+<character>
func (p *Parser) parseAlt() Command {
if p.peek.Type == token.PLUS {
p.nextToken()
if p.peek.Type == token.STRING ||
p.peek.Type == token.ENTER ||
p.peek.Type == token.LEFT_BRACKET ||
p.peek.Type == token.RIGHT_BRACKET ||
p.peek.Type == token.TAB {
c := p.peek.Literal
p.nextToken()
return Command{Type: token.ALT, Args: c}
}
}
p.errors = append(p.errors, NewError(p.cur, "Expected alt character, got "+p.cur.Literal))
return Command{Type: token.ALT}
}
// parseShift parses a shift command.
// A shift command takes one character and types while shift is held down.
//
// Shift+<char>
// E.g.
// Shift+A
// Shift+Tab
// Shift+Enter
func (p *Parser) parseShift() Command {
if p.peek.Type == token.PLUS {
p.nextToken()
if p.peek.Type == token.STRING ||
p.peek.Type == token.ENTER ||
p.peek.Type == token.LEFT_BRACKET ||
p.peek.Type == token.RIGHT_BRACKET ||
p.peek.Type == token.TAB {
c := p.peek.Literal
p.nextToken()
return Command{Type: token.SHIFT, Args: c}
}
}
p.errors = append(p.errors, NewError(p.cur, "Expected shift character, got "+p.cur.Literal))
return Command{Type: token.SHIFT}
}
// parseKeypress parses a repeatable and time adjustable keypress command.
// A keypress command takes an optional typing speed and optional count.
//
// Key[@<time>] [count]
func (p *Parser) parseKeypress(ct token.Type) Command {
cmd := Command{Type: CommandType(ct)}
cmd.Options = p.parseSpeed()
cmd.Args = p.parseRepeat()
return cmd
}
// parseOutput parses an output command.
// An output command takes a file path to which to output.
//
// Output <path>
func (p *Parser) parseOutput() Command {
cmd := Command{Type: token.OUTPUT}
if p.peek.Type != token.STRING {
p.errors = append(p.errors, NewError(p.cur, "Expected file path after output"))
return cmd
}
ext := filepath.Ext(p.peek.Literal)
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"))
}
}
cmd.Args = p.peek.Literal
p.nextToken()
return cmd
}
// parseSet parses a set command.
// A set command takes a setting name and a value.
//
// Set <setting> <value>
func (p *Parser) parseSet() Command {
cmd := Command{Type: token.SET}
if token.IsSetting(p.peek.Type) {
cmd.Options = p.peek.Literal
} else {
p.errors = append(p.errors, NewError(p.peek, "Unknown setting: "+p.peek.Literal))
}
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()
// Allow LoopOffset without '%'
// Set LoopOffset 20
cmd.Args += "%"
if p.peek.Type == token.PERCENT {
p.nextToken()
}
case token.TYPING_SPEED:
cmd.Args = p.peek.Literal
p.nextToken()
// Allow TypingSpeed to have bare units (e.g. 10ms)
// Set TypingSpeed 10ms
if p.peek.Type == token.MILLISECONDS ||
p.peek.Type == token.SECONDS {
cmd.Args += p.peek.Literal
p.nextToken()
} else if cmd.Options == "TypingSpeed" {
cmd.Args += "s"
}
case token.WINDOW_BAR:
cmd.Args = p.peek.Literal
p.nextToken()
windowBar := p.cur.Literal
if !isValidWindowBar(windowBar) {
p.errors = append(
p.errors,
NewError(p.cur, windowBar+" is not a valid bar style."),
)
}
case token.MARGIN_FILL:
cmd.Args = p.peek.Literal
p.nextToken()
marginFill := p.cur.Literal
// Check if margin color is a valid hex string
if strings.HasPrefix(marginFill, "#") {
_, err := strconv.ParseUint(marginFill[1:], 16, 64)
if err != nil || len(marginFill) != 7 {
p.errors = append(
p.errors,
NewError(
p.cur,
"\""+marginFill+"\" is not a valid color.",
),
)
}
}
case token.CURSOR_BLINK:
cmd.Args = p.peek.Literal
p.nextToken()
if p.cur.Type != token.BOOLEAN {
p.errors = append(
p.errors,
NewError(p.cur, "expected boolean value."),
)
}
default:
cmd.Args = p.peek.Literal
p.nextToken()
}
return cmd
}
// parseSleep parses a sleep command.
// A sleep command takes a time for how long to sleep.
//
// Sleep <time>
func (p *Parser) parseSleep() Command {
cmd := Command{Type: token.SLEEP}
cmd.Args = p.parseTime()
return cmd
}
// parseHide parses a Hide command.
//
// Hide
func (p *Parser) parseHide() Command {
cmd := Command{Type: token.HIDE}
return cmd
}
// parseRequire parses a Require command.
//
// Require
func (p *Parser) parseRequire() Command {
cmd := Command{Type: token.REQUIRE}
if p.peek.Type != token.STRING {
p.errors = append(p.errors, NewError(p.peek, p.cur.Literal+" expects one string"))
}
cmd.Args = p.peek.Literal
p.nextToken()
return cmd
}
// parseShow parses a Show command.
//
// Show
func (p *Parser) parseShow() Command {
cmd := Command{Type: token.SHOW}
return cmd
}
// parseType parses a type command.
// A type command takes a string to type.
//
// Type "string"
func (p *Parser) parseType() Command {
cmd := Command{Type: token.TYPE}
cmd.Options = p.parseSpeed()
if p.peek.Type != token.STRING {
p.errors = append(p.errors, NewError(p.peek, p.cur.Literal+" expects string"))
}
for p.peek.Type == token.STRING {
p.nextToken()
cmd.Args += p.cur.Literal
// If the next token is a string, add a space between them.
// Since tokens must be separated by a whitespace, this is most likely
// what the user intended.
//
// Although it is possible that there may be multiple spaces / tabs between
// the tokens, however if the user was intending to type multiple spaces
// they would need to use a string literal.
if p.peek.Type == token.STRING {
cmd.Args += " "
}
}
return cmd
}
// parseCopy parses a copy command
// A copy command takes a string to the clipboard
//
// Copy "string"
func (p *Parser) parseCopy() Command {
cmd := Command{Type: token.COPY}
if p.peek.Type != token.STRING {
p.errors = append(p.errors, NewError(p.peek, p.cur.Literal+" expects string"))
}
for p.peek.Type == token.STRING {
p.nextToken()
cmd.Args += p.cur.Literal
// If the next token is a string, add a space between them.
// Since tokens must be separated by a whitespace, this is most likely
// what the user intended.
//
// Although it is possible that there may be multiple spaces / tabs between
// the tokens, however if the user was intending to type multiple spaces
// they would need to use a string literal.
if p.peek.Type == token.STRING {
cmd.Args += " "
}
}
return cmd
}
// parsePaste parses paste command
// Paste Command the string from the clipboard buffer.
//
// Paste
func (p *Parser) parsePaste() Command {
cmd := Command{Type: token.PASTE}
return cmd
}
// parseEnv parses Env command
// Env command takes in a key-value pair which is set.
//
// Env key "value"
func (p *Parser) parseEnv() Command {
cmd := Command{Type: token.ENV}
cmd.Options = p.peek.Literal
p.nextToken()
if p.peek.Type != token.STRING {
p.errors = append(p.errors, NewError(p.peek, p.cur.Literal+" expects string"))
}
cmd.Args = p.peek.Literal
p.nextToken()
return cmd
}
// parseSource parses source command.
// Source command takes a tape path to include in current tape.
//
// Source <path>
func (p *Parser) parseSource() []Command {
cmd := Command{Type: token.SOURCE}
if p.peek.Type != token.STRING {
p.errors = append(p.errors, NewError(p.cur, "Expected path after Source"))
p.nextToken()
return []Command{cmd}
}
srcPath := p.peek.Literal
// Check if path has .tape extension
ext := filepath.Ext(srcPath)
if ext != ".tape" {
p.errors = append(p.errors, NewError(p.peek, "Expected file with .tape extension"))
p.nextToken()
return []Command{cmd}
}
// Check if tape exist
if _, err := os.Stat(srcPath); os.IsNotExist(err) { //nolint:gosec
notFoundErr := fmt.Sprintf("File %s not found", srcPath)
p.errors = append(p.errors, NewError(p.peek, notFoundErr))
p.nextToken()
return []Command{cmd}
}
// Check if source tape contains nested Source command
d, err := os.ReadFile(srcPath) //nolint:gosec
if err != nil {
readErr := fmt.Sprintf("Unable to read file: %s", srcPath)
p.errors = append(p.errors, NewError(p.peek, readErr))
p.nextToken()
return []Command{cmd}
}
srcTape := string(d)
// Check source tape is NOT empty
if len(srcTape) == 0 {
readErr := fmt.Sprintf("Source tape: %s is empty", srcPath)
p.errors = append(p.errors, NewError(p.peek, readErr))
p.nextToken()
return []Command{cmd}
}
srcLexer := lexer.New(srcTape)
srcParser := New(srcLexer)
// Check not nested source
srcCmds := srcParser.Parse()
for _, cmd := range srcCmds {
if cmd.Type == token.SOURCE {
p.errors = append(p.errors, NewError(p.peek, "Nested Source detected"))
p.nextToken()
return []Command{cmd}
}
}
// Check src errors
srcErrors := srcParser.Errors()
if len(srcErrors) > 0 {
p.errors = append(p.errors, NewError(p.peek, fmt.Sprintf("%s has %d errors", srcPath, len(srcErrors))))
p.nextToken()
return []Command{cmd}
}
filtered := make([]Command, 0)
for _, srcCmd := range srcCmds {
// Output have to be avoid in order to not overwrite output of the original tape.
if srcCmd.Type == token.SOURCE ||
srcCmd.Type == token.OUTPUT {
continue
}
filtered = append(filtered, srcCmd)
}
p.nextToken()
return filtered
}
// parseScreenshot parses screenshot command.
// Screenshot command takes a file path for storing screenshot.
//
// Screenshot <path>
func (p *Parser) parseScreenshot() Command {
cmd := Command{Type: token.SCREENSHOT}
if p.peek.Type != token.STRING {
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()
return cmd
}
cmd.Args = path
p.nextToken()
return cmd
}
// Errors returns any errors that occurred during parsing.
func (p *Parser) Errors() []Error {
return p.errors
}
// nextToken gets the next token from the lexer
// and updates the parser tokens accordingly.
func (p *Parser) nextToken() {
p.cur = p.peek
p.peek = p.l.NextToken()
}
// Check if a given windowbar type is valid.
func isValidWindowBar(w string) bool {
return w == "" ||
w == "Colorful" || w == "ColorfulRight" ||
w == "Rings" || w == "RingsRight"
}