diff --git a/arguments.go b/arguments.go index a08feba..e36595d 100644 --- a/arguments.go +++ b/arguments.go @@ -18,7 +18,10 @@ package zapscript import ( "encoding/json" "errors" + "fmt" + "strconv" "strings" + "unicode/utf8" ) func (sr *ScriptReader) parseJSONArg() (string, error) { @@ -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 { @@ -101,6 +105,10 @@ macroLoop: break } + totalLen++ + if totalLen > InputMacroMaxKeys { + return args, advArgs, ErrInputMacroTooLong + } args = append(args, string(next)) continue } @@ -114,30 +122,25 @@ 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: @@ -145,8 +148,12 @@ macroLoop: 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 { @@ -158,6 +165,10 @@ 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)) } } @@ -165,6 +176,249 @@ macroLoop: 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 diff --git a/command_string_test.go b/command_string_test.go index ab670bc..79eff83 100644 --- a/command_string_test.go +++ b/command_string_test.go @@ -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"`}}, @@ -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", diff --git a/models.go b/models.go index 3d9121e..d98c591 100644 --- a/models.go +++ b/models.go @@ -54,6 +54,7 @@ const ( ZapScriptCmdInputKeyboard = "input.keyboard" ZapScriptCmdInputGamepad = "input.gamepad" + ZapScriptCmdInputText = "input.text" ZapScriptCmdInputCoinP1 = "input.coinp1" ZapScriptCmdInputCoinP2 = "input.coinp2" ZapScriptCmdInputCoinP3 = "input.coinp3" diff --git a/parser.go b/parser.go index 4566da3..55ecde1 100644 --- a/parser.go +++ b/parser.go @@ -151,12 +151,18 @@ commandLoop: var advArgs map[string]string var err error - if isInputMacroCmd(cmd.Name) { + switch { + case isInputMacroCmd(cmd.Name): args, advArgs, err = sr.parseInputMacroArg() if err != nil { return cmd, string(buf), err } - } else { + case isInputRawCmd(cmd.Name): + args, err = sr.parseInputRawArg() + if err != nil { + return cmd, string(buf), err + } + default: args, advArgs, err = sr.parseArgs("", onlyAdvArgs, onlyOneArg) if err != nil { return cmd, string(buf), err diff --git a/parser_fuzz_test.go b/parser_fuzz_test.go index e3afd66..ac8b9bf 100644 --- a/parser_fuzz_test.go +++ b/parser_fuzz_test.go @@ -31,6 +31,19 @@ func FuzzParseScript(f *testing.F) { `**delay:1000`, `**launch.title:snes/Super Mario World`, `**cmd:arg1,arg2,arg3?key=value&other=thing`, + // Input macro — new grammar seeds + `**input.keyboard:{a*5}`, + `**input.keyboard:{"hello"*2}`, + `**input.keyboard:{text:world*3}`, + `**input.keyboard:{delay:100}`, + `**input.keyboard:{_shift}ABC{^shift}`, + `**input.keyboard:{~enter:200}`, + `**input.keyboard:{hold:a:1s}`, + `**input.text:raw text with spaces`, + `**input.text:url?with=query`, + `**input.keyboard:{a*1001}`, + `**input.keyboard:{}`, + `**input.keyboard:{*5}`, // Chained commands `**launch:game||**delay:500||**notify:done`, // Generic launch (no ** prefix) @@ -247,10 +260,28 @@ func FuzzCommandString(f *testing.F) { // Quoted args `**cmd:"quoted arg"`, `**cmd:'single quotes'`, - // Input macro commands + // Input macro commands — basic `**input.keyboard:abc`, `**input.gamepad:abxy`, `**input.keyboard:a{enter}b`, + // Input macro commands — new grammar + `**input.keyboard:{a*5}`, + `**input.keyboard:{enter*3}`, + `**input.keyboard:{ctrl+c*3}`, + `**input.keyboard:{"hello"}`, + `**input.keyboard:{"hi"*2}`, + `**input.keyboard:{text:hi*3}`, + `**input.keyboard:{delay:500}`, + `**input.keyboard:{press:a}`, + `**input.keyboard:{release:a}`, + `**input.keyboard:{hold:a:500}`, + `**input.keyboard:{_shift}abc{^shift}`, + `**input.keyboard:{~a:200}`, + `**input.keyboard:a{delay:100}b?speed=50`, + `**input.text:hello world`, + `**input.text:https://example.com/search?q=foo`, + `**input.text:{"not parsed"}`, + `**input.text:{enter*5}`, // No args `**stop`, // Expressions (these won't round-trip but shouldn't panic) diff --git a/parser_input_macro_test.go b/parser_input_macro_test.go new file mode 100644 index 0000000..b5b0161 --- /dev/null +++ b/parser_input_macro_test.go @@ -0,0 +1,499 @@ +// Copyright 2026 The Zaparoo Project Contributors. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package zapscript_test + +import ( + "errors" + "strings" + "testing" + + zapscript "github.com/ZaparooProject/go-zapscript" + "github.com/google/go-cmp/cmp" +) + +// kbd builds the expected Script for a single **input.keyboard command. +func kbd(args ...string) zapscript.Script { + return zapscript.Script{Cmds: []zapscript.Command{{Name: "input.keyboard", Args: args}}} +} + +// txt builds the expected Script for a single **input.text command. +func txt(args ...string) zapscript.Script { + return zapscript.Script{Cmds: []zapscript.Command{{Name: "input.text", Args: args}}} +} + +// diffOpts is the cmp option used consistently with parser_coverage_test.go. +var diffOpts = cmp.AllowUnexported(zapscript.AdvArgs{}) + +// ─── Regression test for issue #939 ────────────────────────────────────────── + +// TestInputMacro_Issue939_LeadingSpace confirms that the leading space in +// " *MENU" is preserved as a literal arg — the original bug produced only +// "*" because the shift-toggling corrupted the sequence on MiSTer. +func TestInputMacro_Issue939_LeadingSpace(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.keyboard: *MENU") + got, err := p.ParseScript() + if err != nil { + t.Fatalf("ParseScript() unexpected error: %v", err) + } + want := kbd(" ", "*", "M", "E", "N", "U") + if diff := cmp.Diff(want, got, diffOpts); diff != "" { + t.Errorf("ParseScript() mismatch (-want +got):\n%s", diff) + } +} + +// TestInputMacro_EscapeAtEOF verifies that a trailing backslash at end of +// input is appended as a literal backslash, not silently dropped. +func TestInputMacro_EscapeAtEOF(t *testing.T) { + t.Parallel() + p := zapscript.NewParser(`**input.keyboard:hello\`) + got, err := p.ParseScript() + if err != nil { + t.Fatalf("ParseScript() unexpected error: %v", err) + } + want := kbd("h", "e", "l", "l", "o", `\`) + if diff := cmp.Diff(want, got, diffOpts); diff != "" { + t.Errorf("ParseScript() mismatch (-want +got):\n%s", diff) + } +} + +// ─── New grammar: repeat, single-char brace, specials ──────────────────────── + +func TestInputMacroGrammar(t *testing.T) { + t.Parallel() + tests := []struct { + wantErr error + name string + input string + want zapscript.Script + }{ + // Single-char brace expands without braces; multi-char keeps braces. + { + name: "braced single char expands to bare char", + input: "**input.keyboard:{a}", + want: kbd("a"), + }, + { + name: "braced special keeps braces", + input: "**input.keyboard:{f1}", + want: kbd("{f1}"), + }, + { + name: "braced combo keeps braces", + input: "**input.keyboard:{ctrl+c}", + want: kbd("{ctrl+c}"), + }, + // Repeat *N. + { + name: "single char repeated 5 times", + input: "**input.keyboard:{a*5}", + want: kbd("a", "a", "a", "a", "a"), + }, + { + name: "special key repeated 3 times", + input: "**input.keyboard:{enter*3}", + want: kbd("{enter}", "{enter}", "{enter}"), + }, + { + name: "combo repeated 3 times", + input: "**input.keyboard:{ctrl+c*3}", + want: kbd("{ctrl+c}", "{ctrl+c}", "{ctrl+c}"), + }, + { + name: "repeat of 1 is a no-op", + input: "**input.keyboard:{a*1}", + want: kbd("a"), + }, + { + name: "asterisk after non-integer is literal", + input: "**input.keyboard:{a*b}", + want: kbd("{a*b}"), + }, + { + name: "repeat in mixed sequence", + input: "**input.keyboard:a{enter*2}b", + want: kbd("a", "{enter}", "{enter}", "b"), + }, + // Quoted literal {"text"[*N]}. + { + name: "quoted literal basic", + input: `**input.keyboard:{"hello"}`, + want: kbd("h", "e", "l", "l", "o"), + }, + { + name: "quoted literal with repeat", + input: `**input.keyboard:{"hi"*3}`, + want: kbd("h", "i", "h", "i", "h", "i"), + }, + { + name: "quoted literal empty produces no tokens", + input: `**input.keyboard:{""}`, + want: kbd(), + }, + { + name: "quoted literal asterisk is literal char", + input: `**input.keyboard:{"a*b"}`, + want: kbd("a", "*", "b"), + }, + { + name: "quoted literal plus is literal char", + input: `**input.keyboard:{"a+b"}`, + want: kbd("a", "+", "b"), + }, + { + name: "quoted literal escaped quote", + input: `**input.keyboard:{"say \"hi\""}`, + want: kbd("s", "a", "y", " ", `"`, "h", "i", `"`), + }, + { + name: "asterisk typed via quoted literal", + input: `**input.keyboard:{"*"*5}`, + want: kbd("*", "*", "*", "*", "*"), + }, + // text: verb — equivalent to quoted literal. + { + name: "text verb basic", + input: "**input.keyboard:{text:hi}", + want: kbd("h", "i"), + }, + { + name: "text verb with repeat", + input: "**input.keyboard:{text:ab*3}", + want: kbd("a", "b", "a", "b", "a", "b"), + }, + { + name: "text verb asterisk followed by non-integer is literal", + input: "**input.keyboard:{text:a*b}", + want: kbd("a", "*", "b"), + }, + { + name: "quoted and text verb are equivalent", + input: `**input.keyboard:{"hello"*2}`, + want: kbd("h", "e", "l", "l", "o", "h", "e", "l", "l", "o"), + }, + // delay pass-through. + { + name: "delay integer ms passes through", + input: "**input.keyboard:{delay:500}", + want: kbd("{delay:500}"), + }, + { + name: "delay human duration passes through", + input: "**input.keyboard:{delay:1s}", + want: kbd("{delay:1s}"), + }, + { + name: "delay in sequence", + input: "**input.keyboard:a{delay:100}b", + want: kbd("a", "{delay:100}", "b"), + }, + // Hold verbs and sigils. + { + name: "press verb passes through", + input: "**input.keyboard:{press:a}", + want: kbd("{press:a}"), + }, + { + name: "release verb passes through", + input: "**input.keyboard:{release:a}", + want: kbd("{release:a}"), + }, + { + name: "hold verb passes through", + input: "**input.keyboard:{hold:a:500}", + want: kbd("{hold:a:500}"), + }, + { + name: "press sigil passes through", + input: "**input.keyboard:{_a}", + want: kbd("{_a}"), + }, + { + name: "release sigil passes through", + input: "**input.keyboard:{^a}", + want: kbd("{^a}"), + }, + { + name: "hold sigil passes through", + input: "**input.keyboard:{~a:500}", + want: kbd("{~a:500}"), + }, + { + name: "hold-while sequence", + input: "**input.keyboard:{_right}bbb{^right}", + want: kbd("{_right}", "b", "b", "b", "{^right}"), + }, + // Command chaining: macro ends at ||. + { + name: "command terminator ends macro", + input: "**input.keyboard:ab||**stop", + want: zapscript.Script{Cmds: []zapscript.Command{ + {Name: "input.keyboard", Args: []string{"a", "b"}}, + {Name: "stop"}, + }}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := zapscript.NewParser(tt.input) + got, err := p.ParseScript() + if !errors.Is(err, tt.wantErr) { + t.Errorf("ParseScript() error = %v, wantErr = %v", err, tt.wantErr) + return + } + if tt.wantErr != nil { + return + } + if diff := cmp.Diff(tt.want, got, diffOpts); diff != "" { + t.Errorf("ParseScript() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// ─── Safety caps ───────────────────────────────────────────────────────────── + +func TestInputMacroCaps(t *testing.T) { + t.Parallel() + // wantErr: specific exported error checked with errors.Is. + // wantAnyErr: true when an error is expected but is an unexported fmt.Errorf. + tests := []struct { + wantErr error + name string + input string + wantAnyErr bool + }{ + // Per-repeat cap. + { + name: "key repeat exceeds cap", + input: "**input.keyboard:{a*1001}", + wantErr: zapscript.ErrInputMacroRepeatTooLarge, + }, + { + name: "quoted literal repeat exceeds cap", + input: `**input.keyboard:{"a"*1001}`, + wantErr: zapscript.ErrInputMacroRepeatTooLarge, + }, + // Quoted literal parse errors. + { + name: "single opening quote only", + input: `**input.keyboard:{"}`, + wantErr: zapscript.ErrUnmatchedQuote, + }, + { + name: "trailing non-asterisk after closing quote", + input: `**input.keyboard:{"hello"x}`, + wantAnyErr: true, + }, + { + name: "zero repeat in quoted literal", + input: `**input.keyboard:{"hello"*0}`, + wantAnyErr: true, + }, + { + name: "non-numeric repeat in quoted literal", + input: `**input.keyboard:{"hello"*abc}`, + wantAnyErr: true, + }, + // Total keys cap. + { + name: "total keys exceeded via key repeats", + input: "**input.keyboard:" + strings.Repeat("{a*1000}", 6), + wantErr: zapscript.ErrInputMacroTooLong, + }, + { + name: "total keys exceeded via bare chars", + input: "**input.keyboard:" + strings.Repeat("a", 5001), + wantErr: zapscript.ErrInputMacroTooLong, + }, + { + name: "total keys exceeded via pass-through token after full cap", + input: "**input.keyboard:" + strings.Repeat("{a*1000}", 5) + "{delay:1}", + wantErr: zapscript.ErrInputMacroTooLong, + }, + { + name: "total keys exceeded via sigil token after full cap", + input: "**input.keyboard:" + strings.Repeat("{a*1000}", 5) + "{_shift}", + wantErr: zapscript.ErrInputMacroTooLong, + }, + { + name: "total keys exceeded via literal expansion after full cap", + input: "**input.keyboard:" + strings.Repeat("{a*1000}", 5) + `{"b"}`, + wantErr: zapscript.ErrInputMacroTooLong, + }, + // Other error cases. + { + name: "empty braces", + input: "**input.keyboard:{}", + wantErr: zapscript.ErrUnmatchedInputMacroExt, + }, + { + name: "empty key after repeat suffix removal", + input: "**input.keyboard:{*5}", + wantErr: zapscript.ErrInputMacroEmptyKey, + }, + { + name: "unclosed quoted literal at EOF", + input: `**input.keyboard:{"unclosed`, + wantErr: zapscript.ErrUnmatchedInputMacroExt, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := zapscript.NewParser(tt.input) + _, err := p.ParseScript() + if tt.wantErr != nil && !errors.Is(err, tt.wantErr) { + t.Errorf("ParseScript() error = %v, wantErr = %v", err, tt.wantErr) + } + if tt.wantAnyErr && err == nil { + t.Error("ParseScript() expected an error, got nil") + } + }) + } +} + +// TestInputMacro_RepeatAtMax verifies that exactly 1000 tokens are produced at +// the cap boundary (not an error). +func TestInputMacro_RepeatAtMax(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.keyboard:{a*1000}") + script, err := p.ParseScript() + if err != nil { + t.Fatalf("ParseScript() unexpected error: %v", err) + } + if got := len(script.Cmds[0].Args); got != 1000 { + t.Errorf("len(Args) = %d, want 1000", got) + } +} + +// TestInputMacro_TotalKeysAtMax verifies that exactly 5000 tokens are accepted +// without error. +func TestInputMacro_TotalKeysAtMax(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.keyboard:" + strings.Repeat("{a*1000}", 5)) + script, err := p.ParseScript() + if err != nil { + t.Fatalf("ParseScript() unexpected error: %v", err) + } + if got := len(script.Cmds[0].Args); got != 5000 { + t.Errorf("len(Args) = %d, want 5000", got) + } +} + +// ─── input.text raw mode ───────────────────────────────────────────────────── + +func TestInputTextGrammar(t *testing.T) { + t.Parallel() + tests := []struct { + wantErr error + name string + input string + want zapscript.Script + }{ + { + name: "bare text produces individual char args", + input: "**input.text:hello", + want: txt("h", "e", "l", "l", "o"), + }, + { + name: "braces are literal chars", + input: "**input.text:{enter}", + want: txt("{", "e", "n", "t", "e", "r", "}"), + }, + { + name: "asterisk is a literal char", + input: "**input.text:a*5", + want: txt("a", "*", "5"), + }, + { + name: "question mark is literal — no adv-arg parsing", + input: "**input.text:what?", + want: txt("w", "h", "a", "t", "?"), + }, + { + name: "full URL is literal", + input: "**input.text:https://x.com?q=foo", + want: func() zapscript.Script { + chars := make([]string, 0, len("https://x.com?q=foo")) + for _, r := range "https://x.com?q=foo" { + chars = append(chars, string(r)) + } + return txt(chars...) + }(), + }, + { + name: "newline maps to {enter}", + input: "**input.text:a\nb", + want: txt("a", "{enter}", "b"), + }, + { + name: "tab maps to {tab}", + input: "**input.text:a\tb", + want: txt("a", "{tab}", "b"), + }, + { + name: "empty arg produces no tokens", + input: "**input.text:", + want: txt(), + }, + { + name: "speed arg is typed literally", + input: "**input.text:hello?speed=50", + want: func() zapscript.Script { + chars := make([]string, 0, len("hello?speed=50")) + for _, r := range "hello?speed=50" { + chars = append(chars, string(r)) + } + return txt(chars...) + }(), + }, + { + name: "command terminator ends raw text", + input: "**input.text:hello||**stop", + want: zapscript.Script{Cmds: []zapscript.Command{ + {Name: "input.text", Args: []string{"h", "e", "l", "l", "o"}}, + {Name: "stop"}, + }}, + }, + { + name: "total keys cap enforced in raw mode", + input: "**input.text:" + strings.Repeat("a", 5001), + wantErr: zapscript.ErrInputMacroTooLong, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := zapscript.NewParser(tt.input) + got, err := p.ParseScript() + if !errors.Is(err, tt.wantErr) { + t.Errorf("ParseScript() error = %v, wantErr = %v", err, tt.wantErr) + return + } + if tt.wantErr != nil { + return + } + if diff := cmp.Diff(tt.want, got, diffOpts); diff != "" { + t.Errorf("ParseScript() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/reader.go b/reader.go index b535686..3f76b35 100644 --- a/reader.go +++ b/reader.go @@ -155,7 +155,8 @@ func (c Command) String() string { if len(c.Args) > 0 { _, _ = b.WriteRune(SymArgStart) - if isInputMacroCmd(normalizeCmdName(c.Name)) { + switch { + case isInputMacroCmd(normalizeCmdName(c.Name)): // Input macro commands concatenate args directly for _, arg := range c.Args { if len(arg) > 1 && rune(arg[0]) == SymInputMacroExtStart && @@ -176,7 +177,21 @@ func (c Command) String() string { } } } - } else { + case isInputRawCmd(normalizeCmdName(c.Name)): + // Raw text: reverse the parseInputRawArg mappings so the output re-parses + // to the same args. {enter} → newline, {tab} → tab; all other args are + // single chars written as-is. + for _, arg := range c.Args { + switch arg { + case "{enter}": + _, _ = b.WriteRune('\n') + case "{tab}": + _, _ = b.WriteRune('\t') + default: + _, _ = b.WriteString(arg) + } + } + default: for i, arg := range c.Args { if i > 0 { _, _ = b.WriteRune(SymArgSep) diff --git a/symbols.go b/symbols.go index 876dbe7..46bb284 100644 --- a/symbols.go +++ b/symbols.go @@ -33,6 +33,18 @@ var ( ErrBadExpressionReturn = errors.New("expression return type not supported") ErrInvalidTraitKey = errors.New("invalid trait key") ErrUnmatchedArrayBracket = errors.New("unmatched array bracket") + + // Input macro expansion errors. + ErrInputMacroRepeatTooLarge = errors.New("input macro repeat count exceeds maximum") + ErrInputMacroTooLong = errors.New("input macro expanded key count exceeds maximum") + ErrInputMacroEmptyKey = errors.New("input macro key name is empty after repeat suffix removal") +) + +const ( + // InputMacroMaxRepeat is the maximum value for a single *N repeat expression. + InputMacroMaxRepeat = 1000 + // InputMacroMaxKeys is the maximum total number of expanded key tokens per macro. + InputMacroMaxKeys = 5000 ) const ( @@ -98,3 +110,10 @@ func isInputMacroCmd(name string) bool { return false } } + +// isInputRawCmd reports whether name is a raw-text input command. Raw commands +// treat their entire argument as literal text to type; no {} grammar or * repeat +// syntax is interpreted, and no adv-args are supported. +func isInputRawCmd(name string) bool { + return name == ZapScriptCmdInputText +}