Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
292 changes: 273 additions & 19 deletions arguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ package zapscript
import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"unicode/utf8"
)

func (sr *ScriptReader) parseJSONArg() (string, error) {
Expand Down Expand Up @@ -82,6 +85,7 @@ func (sr *ScriptReader) parseJSONArg() (string, error) {
func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]string, err error) {
args = make([]string, 0)
advArgs = make(map[string]string)
totalLen := 0

macroLoop:
for {
Expand All @@ -101,6 +105,10 @@ macroLoop:
break
}

totalLen++
if totalLen > InputMacroMaxKeys {
return args, advArgs, ErrInputMacroTooLong
}
args = append(args, string(next))
continue
}
Expand All @@ -114,39 +122,38 @@ macroLoop:

switch ch {
case SymInputMacroExtStart:
extName := string(ch)
var extBuilder strings.Builder
for {
next, err := sr.read()
if err != nil {
return args, advArgs, err
} else if next == eof {
return args, advArgs, ErrUnmatchedInputMacroExt
}

_, _ = extBuilder.WriteString(string(next))

if next == SymInputMacroExtEnd {
break
}
content, readErr := sr.parseInputMacroExtContent()
if readErr != nil {
return args, advArgs, readErr
}
tokens, expandErr := expandInputMacroExt(content, &totalLen)
if expandErr != nil {
return args, advArgs, expandErr
}
extName += extBuilder.String()
args = append(args, extName)
args = append(args, tokens...)
continue
case SymExpressionStart:
exprValue, exprErr := sr.parseExpression()
if exprErr != nil {
return args, advArgs, exprErr
}
totalLen++
if totalLen > InputMacroMaxKeys {
return args, advArgs, ErrInputMacroTooLong
}
args = append(args, exprValue)
continue
case SymAdvArgStart:
newAdvArgs, buf, err := sr.parseAdvArgs()
if errors.Is(err, ErrInvalidAdvArgName) {
// if an adv arg name is invalid, fallback on treating it
// as a list of input args
for _, ch := range string(SymAdvArgStart) + buf {
args = append(args, string(ch))
for _, r := range string(SymAdvArgStart) + buf {
totalLen++
if totalLen > InputMacroMaxKeys {
return args, advArgs, ErrInputMacroTooLong
}
args = append(args, string(r))
}
continue
} else if err != nil {
Expand All @@ -158,13 +165,260 @@ macroLoop:
// advanced args are always the last part of a command
break macroLoop
default:
totalLen++
if totalLen > InputMacroMaxKeys {
return args, advArgs, ErrInputMacroTooLong
}
args = append(args, string(ch))
}
}

return args, advArgs, nil
}

// parseInputMacroExtContent reads characters from the reader until the closing
// SymInputMacroExtEnd ('}') and returns the raw content between the braces.
func (sr *ScriptReader) parseInputMacroExtContent() (string, error) {
var b strings.Builder
for {
ch, err := sr.read()
if err != nil {
return "", err
}
if ch == eof {
return "", ErrUnmatchedInputMacroExt
}
if ch == SymInputMacroExtEnd {
break
}
_, _ = b.WriteRune(ch)
}
return b.String(), nil
}

// expandInputMacroExt parses the raw content between '{' and '}' and returns the
// expanded token slice. totalLen is updated by the number of tokens added so the
// caller can enforce InputMacroMaxKeys across the whole macro.
//
// Grammar inside braces:
//
// {"text"[*N]} literal text, optionally repeated N times
// {text:content[*N]} same using verb form; content is typed literally
// {delay:dur} pass through as "{delay:dur}" — interpreted by core emitter
// {press:key} pass through as "{press:key}"
// {release:key} pass through as "{release:key}"
// {hold:key[:dur]} pass through as "{hold:key:dur}"
// {_key} sigil sugar for press, passed through
// {^key} sigil sugar for release, passed through
// {~key[:dur]} sigil sugar for hold, passed through
// {key[*N]} key/combo/special, optionally repeated N times
func expandInputMacroExt(content string, totalLen *int) ([]string, error) {
if content == "" {
return nil, ErrUnmatchedInputMacroExt
}

// Quoted literal: {"text"[*N]}
if content[0] == '"' {
text, repeat, err := parseQuotedLiteralWithRepeat(content)
if err != nil {
return nil, err
}
return expandLiteralChars(text, repeat, totalLen)
}

// text: verb — {text:content[*N]}
if strings.HasPrefix(content, "text:") {
raw := content[len("text:"):]
text, repeat, err := parseSuffixRepeat(raw)
if err != nil {
return nil, err
}
return expandLiteralChars(text, repeat, totalLen)
}

// Pass-through verb forms: delay, press, release, hold
if strings.HasPrefix(content, "delay:") ||
strings.HasPrefix(content, "press:") ||
strings.HasPrefix(content, "release:") ||
strings.HasPrefix(content, "hold:") {
*totalLen++
if *totalLen > InputMacroMaxKeys {
return nil, ErrInputMacroTooLong
}
return []string{"{" + content + "}"}, nil
}

// Sigil forms: {_key}, {^key}, {~key[:dur]}
if content != "" {
switch content[0] {
case '_', '^', '~':
*totalLen++
if *totalLen > InputMacroMaxKeys {
return nil, ErrInputMacroTooLong
}
return []string{"{" + content + "}"}, nil
}
}

// Key / combo / special with optional *N repeat.
name, repeat, err := parseSuffixRepeat(content)
if err != nil {
return nil, err
}
if name == "" {
return nil, ErrInputMacroEmptyKey
}

// Single-rune keys are appended without braces (e.g. "a", "*").
// Multi-rune names need braces so ParseKeyCombo recognises them.
var token string
if utf8.RuneCountInString(name) == 1 {
token = name
} else {
token = "{" + name + "}"
}

return expandTokenN(token, repeat, totalLen)
}

// parseInputRawArg reads the entire command argument as literal text — no '{}'
// grammar, no '*' repeat, no adv-args. Every rune is a key to type, with
// '\n' mapped to "{enter}" and '\t' mapped to "{tab}". The cap InputMacroMaxKeys
// still applies to prevent runaway sequences.
func (sr *ScriptReader) parseInputRawArg() ([]string, error) {
args := make([]string, 0)
totalLen := 0

for {
ch, err := sr.read()
if err != nil {
return args, err
}
if ch == eof {
break
}

eoc, err := sr.checkEndOfCmd(ch)
if err != nil {
return args, err
}
if eoc {
break
}

totalLen++
if totalLen > InputMacroMaxKeys {
return args, ErrInputMacroTooLong
}

switch ch {
case '\n':
args = append(args, "{enter}")
case '\t':
args = append(args, "{tab}")
default:
args = append(args, string(ch))
}
}

return args, nil
}

// parseSuffixRepeat splits "content*N" at the LAST '*' followed by a positive
// integer, returning (content, N, nil). If there is no such suffix, it returns
// (s, 1, nil) so callers get a no-op repeat. Returns an error if N > InputMacroMaxRepeat.
func parseSuffixRepeat(s string) (content string, n int, err error) {
idx := strings.LastIndex(s, "*")
if idx == -1 {
return s, 1, nil
}
rest := s[idx+1:]
n64, parseErr := strconv.ParseUint(rest, 10, 64)
if parseErr != nil || n64 == 0 {
return s, 1, nil //nolint:nilerr // non-integer after * means * is literal content
}
if n64 > uint64(InputMacroMaxRepeat) {
return "", 0, fmt.Errorf("%w: %d (max %d)", ErrInputMacroRepeatTooLarge, n64, InputMacroMaxRepeat)
}
return s[:idx], int(n64), nil
}

// parseQuotedLiteralWithRepeat parses the braces content that starts with '"'.
// Expected form: '"' text '"' ['*' N]. Inside the quotes '"' is escaped as '\"'.
func parseQuotedLiteralWithRepeat(content string) (text string, repeat int, err error) {
if len(content) < 2 {
return "", 0, ErrUnmatchedQuote
}

// Find the closing quote, honouring \" escapes.
closeIdx := -1
for i := 1; i < len(content); i++ {
if content[i] == '\\' {
i++ // skip next character — it is escaped
continue
}
if content[i] == '"' {
closeIdx = i
break
}
}
if closeIdx == -1 {
return "", 0, ErrUnmatchedQuote
}

rawText := content[1:closeIdx]
text = strings.ReplaceAll(rawText, `\"`, `"`)

rest := content[closeIdx+1:]
if rest == "" {
return text, 1, nil
}
if rest[0] != '*' {
return "", 0, fmt.Errorf("unexpected content after quoted literal: %q", rest)
}

n64, parseErr := strconv.ParseUint(rest[1:], 10, 64)
if parseErr != nil || n64 == 0 {
return "", 0, fmt.Errorf("invalid repeat count in quoted literal: %q", rest[1:])
}
if n64 > uint64(InputMacroMaxRepeat) {
return "", 0, fmt.Errorf("%w: %d (max %d)", ErrInputMacroRepeatTooLarge, n64, InputMacroMaxRepeat)
}
repeat = int(n64)

return text, repeat, nil
}

// expandLiteralChars expands text into individual rune tokens, repeated n times.
func expandLiteralChars(text string, n int, totalLen *int) ([]string, error) {
runes := []rune(text)
count := len(runes) * n
*totalLen += count
if *totalLen > InputMacroMaxKeys {
return nil, ErrInputMacroTooLong
}
result := make([]string, 0, count)
for range n {
for _, r := range runes {
result = append(result, string(r))
}
}
return result, nil
}

// expandTokenN returns a slice of n copies of token.
func expandTokenN(token string, n int, totalLen *int) ([]string, error) {
*totalLen += n
if *totalLen > InputMacroMaxKeys {
return nil, ErrInputMacroTooLong
}
result := make([]string, n)
for i := range result {
result[i] = token
}
return result, nil
}

func (sr *ScriptReader) parseAdvArgs() (advArgs map[string]string, remainingStr string, err error) {
advArgs = make(map[string]string)
inValue := false
Expand Down
12 changes: 12 additions & 0 deletions command_string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,16 @@ func TestCommandString(t *testing.T) {
cmd: zapscript.Command{Name: "input.gamepad", Args: []string{"^", "^", "V", "V", "<", ">"}},
want: "**input.gamepad:^^VV<>",
},
{
name: "input.text raw",
cmd: zapscript.Command{Name: "input.text", Args: []string{"h", "i", " ", "t", "h", "e", "r", "e"}},
want: "**input.text:hi there",
},
{
name: "input.text with url",
cmd: zapscript.Command{Name: "input.text", Args: []string{"x", "?", "y", "=", "1"}},
want: "**input.text:x?y=1",
},
{
name: "arg with double quote",
cmd: zapscript.Command{Name: "echo", Args: []string{`say "hi"`}},
Expand Down Expand Up @@ -186,6 +196,8 @@ func TestCommandString_RoundTrip(t *testing.T) {
"**launch:game.exe?platform=win",
"**input.keyboard:abc{f1}{enter}",
"**input.gamepad:^^VV<><>",
"**input.text:hello world",
"**input.text:url?q=foo",
"**delay:500",
"**launch.random:SNES",
"**http.get:https://example.com/api",
Expand Down
1 change: 1 addition & 0 deletions models.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const (

ZapScriptCmdInputKeyboard = "input.keyboard"
ZapScriptCmdInputGamepad = "input.gamepad"
ZapScriptCmdInputText = "input.text"
ZapScriptCmdInputCoinP1 = "input.coinp1"
ZapScriptCmdInputCoinP2 = "input.coinp2"
ZapScriptCmdInputCoinP3 = "input.coinp3"
Expand Down
Loading
Loading