From 0e38c442f9f8df9d2cbb436caa546fb542c8a87f Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 13 Jun 2026 17:45:36 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(input):=20add=20macro=20language=20?= =?UTF-8?q?=E2=80=94=20repeat,=20literals,=20delays,=20holds,=20raw=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the input macro grammar inside `{}` braces: - Repeat: `{a*5}`, `{enter*3}`, `{ctrl+c*2}` - Quoted literal: `{"hello world"*2}` — braces-safe, `\"` escapes the quote - Verb literal: `{text:content*N}` — same semantics as quoted form - Delay pass-through: `{delay:N}` forwarded to core emitter as a brace token - Hold/press/release pass-throughs: `{press:k}`, `{release:k}`, `{hold:k:dur}` - Sigil sugar: `{_k}` (press), `{^k}` (release), `{~k:dur}` (hold) - New command `input.text`: raw mode — no `{}`/`*` grammar, no adv-args, `\n`→`{enter}`, `\t`→`{tab}` Safety caps: per-repeat max 1000, total expanded keys per macro max 5000; both fail the command with a clear error rather than silently truncating. `Command.String()` gains an `isInputRawCmd` case so `input.text` args round-trip correctly (chars concatenated directly, no commas). --- arguments.go | 291 ++++++++++++++++++++++-- command_string_test.go | 12 + models.go | 1 + parser.go | 10 +- parser_fuzz_test.go | 33 ++- parser_input_macro_test.go | 443 +++++++++++++++++++++++++++++++++++++ reader.go | 12 +- symbols.go | 19 ++ 8 files changed, 797 insertions(+), 24 deletions(-) create mode 100644 parser_input_macro_test.go diff --git a/arguments.go b/arguments.go index a08feba..c490c00 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,24 +122,15 @@ 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() @@ -145,8 +144,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 +161,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 +172,252 @@ 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 + } + if repeat > InputMacroMaxRepeat { + return nil, fmt.Errorf("%w: %d (max %d)", ErrInputMacroRepeatTooLarge, repeat, InputMacroMaxRepeat) + } + 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..83b28df --- /dev/null +++ b/parser_input_macro_test.go @@ -0,0 +1,443 @@ +// 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 ( + "strings" + "testing" + + zapscript "github.com/ZaparooProject/go-zapscript" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// parseInputMacroArgs is a test helper that parses **input.keyboard: and +// returns the Args slice so tests can assert on expanded tokens directly. +func parseInputMacroArgs(t *testing.T, macro string) []string { + t.Helper() + p := zapscript.NewParser("**input.keyboard:" + macro) + script, err := p.ParseScript() + require.NoError(t, err) + require.Len(t, script.Cmds, 1) + return script.Cmds[0].Args +} + +// parseInputTextArgs is a test helper for **input.text:. +func parseInputTextArgs(t *testing.T, raw string) []string { + t.Helper() + p := zapscript.NewParser("**input.text:" + raw) + script, err := p.ParseScript() + require.NoError(t, err) + require.Len(t, script.Cmds, 1) + return script.Cmds[0].Args +} + +// ─── Backward-compatibility ────────────────────────────────────────────────── + +func TestInputMacro_BackwardCompat_BareChars(t *testing.T) { + t.Parallel() + // "hello" outside braces: each char is its own arg (unchanged behaviour) + got := parseInputMacroArgs(t, "hello") + assert.Equal(t, []string{"h", "e", "l", "l", "o"}, got) +} + +func TestInputMacro_BackwardCompat_Space(t *testing.T) { + t.Parallel() + // Spaces are literal (issue #939 context: " *MENU" keeps the leading space) + got := parseInputMacroArgs(t, " *MENU") + assert.Equal(t, []string{" ", "*", "M", "E", "N", "U"}, got) +} + +func TestInputMacro_BackwardCompat_BracedSpecial(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{enter}") + assert.Equal(t, []string{"{enter}"}, got) +} + +func TestInputMacro_BackwardCompat_Combo(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{ctrl+c}") + assert.Equal(t, []string{"{ctrl+c}"}, got) +} + +func TestInputMacro_BackwardCompat_EscapeSeq(t *testing.T) { + t.Parallel() + // \\ before a char escapes it + got := parseInputMacroArgs(t, `\{`) + assert.Equal(t, []string{"{"}, got) +} + +func TestInputMacro_BackwardCompat_AdvArgs(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.keyboard:ab?speed=50") + script, err := p.ParseScript() + require.NoError(t, err) + require.Len(t, script.Cmds, 1) + cmd := script.Cmds[0] + assert.Equal(t, []string{"a", "b"}, cmd.Args) + assert.Equal(t, "50", cmd.AdvArgs.Get("speed")) +} + +// ─── Single-key brace form ──────────────────────────────────────────────────── + +func TestInputMacro_BracedSingleChar(t *testing.T) { + t.Parallel() + // {a} expands to "a" — single-char token without braces + got := parseInputMacroArgs(t, "{a}") + assert.Equal(t, []string{"a"}, got) +} + +func TestInputMacro_BracedSpecialNoRepeat(t *testing.T) { + t.Parallel() + // {f1} passes through with braces (multi-char name) + got := parseInputMacroArgs(t, "{f1}") + assert.Equal(t, []string{"{f1}"}, got) +} + +// ─── Repeat: key *N ────────────────────────────────────────────────────────── + +func TestInputMacro_Repeat_SingleChar(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{a*5}") + assert.Equal(t, []string{"a", "a", "a", "a", "a"}, got) +} + +func TestInputMacro_Repeat_Special(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{enter*3}") + assert.Equal(t, []string{"{enter}", "{enter}", "{enter}"}, got) +} + +func TestInputMacro_Repeat_Combo(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{ctrl+c*3}") + assert.Equal(t, []string{"{ctrl+c}", "{ctrl+c}", "{ctrl+c}"}, got) +} + +func TestInputMacro_Repeat_One(t *testing.T) { + t.Parallel() + // *1 is valid and equals no repeat + got := parseInputMacroArgs(t, "{a*1}") + assert.Equal(t, []string{"a"}, got) +} + +func TestInputMacro_Repeat_AsteriskViaQuote(t *testing.T) { + t.Parallel() + // Correct way to type 5 asterisks + got := parseInputMacroArgs(t, `{"*"*5}`) + assert.Equal(t, []string{"*", "*", "*", "*", "*"}, got) +} + +func TestInputMacro_Repeat_InMixedSequence(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "a{enter*2}b") + assert.Equal(t, []string{"a", "{enter}", "{enter}", "b"}, got) +} + +// ─── Quoted literal ─────────────────────────────────────────────────────────── + +func TestInputMacro_QuotedLiteral_Basic(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, `{"hello"}`) + assert.Equal(t, []string{"h", "e", "l", "l", "o"}, got) +} + +func TestInputMacro_QuotedLiteral_WithRepeat(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, `{"hi"*3}`) + assert.Equal(t, []string{"h", "i", "h", "i", "h", "i"}, got) +} + +func TestInputMacro_QuotedLiteral_EscapedQuote(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, `{"say \"hi\""}`) + assert.Equal(t, []string{"s", "a", "y", " ", `"`, "h", "i", `"`}, got) +} + +func TestInputMacro_QuotedLiteral_AsteriskIsLiteral(t *testing.T) { + t.Parallel() + // Inside quotes, * is a literal character to type + got := parseInputMacroArgs(t, `{"a*b"}`) + assert.Equal(t, []string{"a", "*", "b"}, got) +} + +func TestInputMacro_QuotedLiteral_PlusIsLiteral(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, `{"a+b"}`) + assert.Equal(t, []string{"a", "+", "b"}, got) +} + +func TestInputMacro_QuotedLiteral_Empty(t *testing.T) { + t.Parallel() + // {""} — empty quoted literal produces no tokens + got := parseInputMacroArgs(t, `{""}`) + assert.Empty(t, got) +} + +// ─── text: verb ─────────────────────────────────────────────────────────────── + +func TestInputMacro_TextVerb_Basic(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{text:hi}") + assert.Equal(t, []string{"h", "i"}, got) +} + +func TestInputMacro_TextVerb_WithRepeat(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{text:ab*3}") + assert.Equal(t, []string{"a", "b", "a", "b", "a", "b"}, got) +} + +func TestInputMacro_TextVerb_AsteriskInContent(t *testing.T) { + t.Parallel() + // {text:a*b} — *b is not a valid repeat (b is not a number) so * is literal + got := parseInputMacroArgs(t, "{text:a*b}") + assert.Equal(t, []string{"a", "*", "b"}, got) +} + +func TestInputMacro_QuotedAndTextVerbAreEquivalent(t *testing.T) { + t.Parallel() + quote := parseInputMacroArgs(t, `{"hello"*2}`) + verb := parseInputMacroArgs(t, "{text:hello*2}") + assert.Equal(t, quote, verb) +} + +// ─── delay: pass-through ───────────────────────────────────────────────────── + +func TestInputMacro_Delay_PassThrough(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{delay:500}") + assert.Equal(t, []string{"{delay:500}"}, got) +} + +func TestInputMacro_Delay_HumanDuration(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{delay:1s}") + assert.Equal(t, []string{"{delay:1s}"}, got) +} + +func TestInputMacro_Delay_InSequence(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "a{delay:100}b") + assert.Equal(t, []string{"a", "{delay:100}", "b"}, got) +} + +// ─── Hold verbs and sigils ──────────────────────────────────────────────────── + +func TestInputMacro_HoldVerb_Press(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{press:a}") + assert.Equal(t, []string{"{press:a}"}, got) +} + +func TestInputMacro_HoldVerb_Release(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{release:a}") + assert.Equal(t, []string{"{release:a}"}, got) +} + +func TestInputMacro_HoldVerb_Hold(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{hold:a:500}") + assert.Equal(t, []string{"{hold:a:500}"}, got) +} + +func TestInputMacro_Sigil_Down(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{_a}") + assert.Equal(t, []string{"{_a}"}, got) +} + +func TestInputMacro_Sigil_Up(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{^a}") + assert.Equal(t, []string{"{^a}"}, got) +} + +func TestInputMacro_Sigil_Hold(t *testing.T) { + t.Parallel() + got := parseInputMacroArgs(t, "{~a:500}") + assert.Equal(t, []string{"{~a:500}"}, got) +} + +func TestInputMacro_HoldWhile_Sequence(t *testing.T) { + t.Parallel() + // {_right}bbb{^right} — hold right, tap b three times, release right + got := parseInputMacroArgs(t, "{_right}bbb{^right}") + assert.Equal(t, []string{"{_right}", "b", "b", "b", "{^right}"}, got) +} + +// ─── Safety caps ───────────────────────────────────────────────────────────── + +func TestInputMacro_RepeatCap(t *testing.T) { + t.Parallel() + // *1001 exceeds InputMacroMaxRepeat (1000) + p := zapscript.NewParser("**input.keyboard:{a*1001}") + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrInputMacroRepeatTooLarge, "want ErrInputMacroRepeatTooLarge, got: %v", err) +} + +func TestInputMacro_RepeatAtMax(t *testing.T) { + t.Parallel() + // *1000 is exactly at the cap — should succeed + p := zapscript.NewParser("**input.keyboard:{a*1000}") + script, err := p.ParseScript() + require.NoError(t, err) + assert.Len(t, script.Cmds[0].Args, 1000) +} + +func TestInputMacro_TotalKeysCap(t *testing.T) { + t.Parallel() + // 6 reps × 1000 = 6000 > InputMacroMaxKeys (5000) + p := zapscript.NewParser("**input.keyboard:{a*1000}{b*1000}{c*1000}{d*1000}{e*1000}{f*1000}") + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrInputMacroTooLong, "want ErrInputMacroTooLong, got: %v", err) +} + +func TestInputMacro_TotalKeysAtMax(t *testing.T) { + t.Parallel() + // 5 × 1000 = 5000 exactly at the cap — should succeed + p := zapscript.NewParser("**input.keyboard:{a*1000}{b*1000}{c*1000}{d*1000}{e*1000}") + script, err := p.ParseScript() + require.NoError(t, err) + assert.Len(t, script.Cmds[0].Args, 5000) +} + +func TestInputMacro_LiteralCharsCap(t *testing.T) { + t.Parallel() + // Plain chars outside braces also count toward the total + // 5001 'a' chars should exceed the cap + p := zapscript.NewParser("**input.keyboard:" + strings.Repeat("a", 5001)) + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrInputMacroTooLong) +} + +// ─── input.text raw mode ───────────────────────────────────────────────────── + +func TestInputText_Basic(t *testing.T) { + t.Parallel() + got := parseInputTextArgs(t, "hello") + assert.Equal(t, []string{"h", "e", "l", "l", "o"}, got) +} + +func TestInputText_BracesAreLiteral(t *testing.T) { + t.Parallel() + // {} syntax is NOT interpreted in input.text + got := parseInputTextArgs(t, "{enter}") + assert.Equal(t, []string{"{", "e", "n", "t", "e", "r", "}"}, got) +} + +func TestInputText_AsteriskIsLiteral(t *testing.T) { + t.Parallel() + got := parseInputTextArgs(t, "a*5") + assert.Equal(t, []string{"a", "*", "5"}, got) +} + +func TestInputText_QuestionMarkIsLiteral(t *testing.T) { + t.Parallel() + // ? is NOT parsed as adv-arg start in raw mode + got := parseInputTextArgs(t, "what?") + assert.Equal(t, []string{"w", "h", "a", "t", "?"}, got) +} + +func TestInputText_URLIsLiteral(t *testing.T) { + t.Parallel() + // A URL with ? should type literally, not trigger adv-arg parsing + got := parseInputTextArgs(t, "https://x.com/search?q=foo") + require.Len(t, got, 26) + // spot-check the ? character + assert.Equal(t, "?", got[20]) +} + +func TestInputText_NewlineMapsToEnter(t *testing.T) { + t.Parallel() + got := parseInputTextArgs(t, "a\nb") + assert.Equal(t, []string{"a", "{enter}", "b"}, got) +} + +func TestInputText_TabMapsToTab(t *testing.T) { + t.Parallel() + got := parseInputTextArgs(t, "a\tb") + assert.Equal(t, []string{"a", "{tab}", "b"}, got) +} + +func TestInputText_NoAdvArgs(t *testing.T) { + t.Parallel() + // input.text has no adv-args; a trailing ? is typed literally + p := zapscript.NewParser("**input.text:hello?speed=50") + script, err := p.ParseScript() + require.NoError(t, err) + cmd := script.Cmds[0] + // All chars including ? s p e e d = 5 0 are args + assert.Contains(t, cmd.Args, "?") + assert.Empty(t, cmd.AdvArgs.Get("speed")) +} + +func TestInputText_CapEnforced(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.text:" + strings.Repeat("a", 5001)) + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrInputMacroTooLong) +} + +func TestInputText_EmptyArg(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.text:") + script, err := p.ParseScript() + require.NoError(t, err) + assert.Empty(t, script.Cmds[0].Args) +} + +// ─── Error cases ───────────────────────────────────────────────────────────── + +func TestInputMacro_EmptyBraces(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.keyboard:{}") + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrUnmatchedInputMacroExt) +} + +func TestInputMacro_UnclosedBrace(t *testing.T) { + t.Parallel() + p := zapscript.NewParser("**input.keyboard:{enter") + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrUnmatchedInputMacroExt) +} + +func TestInputMacro_UnclosedQuotedLiteral(t *testing.T) { + t.Parallel() + // {"unclosed — EOF reached inside brace content before closing } + // parseInputMacroExtContent returns ErrUnmatchedInputMacroExt on EOF + p := zapscript.NewParser(`**input.keyboard:{"unclosed`) + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrUnmatchedInputMacroExt) +} + +func TestInputMacro_EmptyKeyAfterRepeat(t *testing.T) { + t.Parallel() + // {*5} — content "*5" → name="" after removing *5 → ErrInputMacroEmptyKey + p := zapscript.NewParser("**input.keyboard:{*5}") + _, err := p.ParseScript() + require.Error(t, err) + assert.ErrorIs(t, err, zapscript.ErrInputMacroEmptyKey) +} diff --git a/reader.go b/reader.go index b535686..f05a9fe 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,14 @@ func (c Command) String() string { } } } - } else { + case isInputRawCmd(normalizeCmdName(c.Name)): + // Raw text commands concatenate args directly with no escape processing. + // {enter}/{tab} are written literally and will reparse as individual chars — + // this is a known serialization limitation for the newline/tab mappings. + for _, arg := range c.Args { + _, _ = 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 +} From b70a5d5901d0cae8538359dc038742050eb7124f Mon Sep 17 00:00:00 2001 From: Callan Barrett Date: Sat, 13 Jun 2026 18:02:53 +0800 Subject: [PATCH 2/2] fix(input): address PR review findings - reader.go: reverse {enter}/{tab} mappings in Command.String() raw-mode serialization so round-trip parsing preserves the original tokens - arguments.go: increment totalLen for SymExpressionStart tokens so expression segments cannot bypass the InputMacroMaxKeys cap - parser_input_macro_test.go: rewrite as table-driven subtests using cmp.Diff following the project style; remove duplicates already covered by parser_coverage_test.go; add wantAnyErr for unexported error types --- arguments.go | 7 +- parser_input_macro_test.go | 858 ++++++++++++++++++++----------------- reader.go | 15 +- 3 files changed, 472 insertions(+), 408 deletions(-) diff --git a/arguments.go b/arguments.go index c490c00..e36595d 100644 --- a/arguments.go +++ b/arguments.go @@ -137,6 +137,10 @@ macroLoop: if exprErr != nil { return args, advArgs, exprErr } + totalLen++ + if totalLen > InputMacroMaxKeys { + return args, advArgs, ErrInputMacroTooLong + } args = append(args, exprValue) continue case SymAdvArgStart: @@ -219,9 +223,6 @@ func expandInputMacroExt(content string, totalLen *int) ([]string, error) { if err != nil { return nil, err } - if repeat > InputMacroMaxRepeat { - return nil, fmt.Errorf("%w: %d (max %d)", ErrInputMacroRepeatTooLarge, repeat, InputMacroMaxRepeat) - } return expandLiteralChars(text, repeat, totalLen) } diff --git a/parser_input_macro_test.go b/parser_input_macro_test.go index 83b28df..b5b0161 100644 --- a/parser_input_macro_test.go +++ b/parser_input_macro_test.go @@ -16,428 +16,484 @@ package zapscript_test import ( + "errors" "strings" "testing" zapscript "github.com/ZaparooProject/go-zapscript" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" + "github.com/google/go-cmp/cmp" ) -// parseInputMacroArgs is a test helper that parses **input.keyboard: and -// returns the Args slice so tests can assert on expanded tokens directly. -func parseInputMacroArgs(t *testing.T, macro string) []string { - t.Helper() - p := zapscript.NewParser("**input.keyboard:" + macro) - script, err := p.ParseScript() - require.NoError(t, err) - require.Len(t, script.Cmds, 1) - return script.Cmds[0].Args -} - -// parseInputTextArgs is a test helper for **input.text:. -func parseInputTextArgs(t *testing.T, raw string) []string { - t.Helper() - p := zapscript.NewParser("**input.text:" + raw) - script, err := p.ParseScript() - require.NoError(t, err) - require.Len(t, script.Cmds, 1) - return script.Cmds[0].Args -} - -// ─── Backward-compatibility ────────────────────────────────────────────────── - -func TestInputMacro_BackwardCompat_BareChars(t *testing.T) { - t.Parallel() - // "hello" outside braces: each char is its own arg (unchanged behaviour) - got := parseInputMacroArgs(t, "hello") - assert.Equal(t, []string{"h", "e", "l", "l", "o"}, got) -} - -func TestInputMacro_BackwardCompat_Space(t *testing.T) { - t.Parallel() - // Spaces are literal (issue #939 context: " *MENU" keeps the leading space) - got := parseInputMacroArgs(t, " *MENU") - assert.Equal(t, []string{" ", "*", "M", "E", "N", "U"}, got) -} - -func TestInputMacro_BackwardCompat_BracedSpecial(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{enter}") - assert.Equal(t, []string{"{enter}"}, got) -} - -func TestInputMacro_BackwardCompat_Combo(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{ctrl+c}") - assert.Equal(t, []string{"{ctrl+c}"}, got) -} - -func TestInputMacro_BackwardCompat_EscapeSeq(t *testing.T) { - t.Parallel() - // \\ before a char escapes it - got := parseInputMacroArgs(t, `\{`) - assert.Equal(t, []string{"{"}, got) -} - -func TestInputMacro_BackwardCompat_AdvArgs(t *testing.T) { - t.Parallel() - p := zapscript.NewParser("**input.keyboard:ab?speed=50") - script, err := p.ParseScript() - require.NoError(t, err) - require.Len(t, script.Cmds, 1) - cmd := script.Cmds[0] - assert.Equal(t, []string{"a", "b"}, cmd.Args) - assert.Equal(t, "50", cmd.AdvArgs.Get("speed")) -} - -// ─── Single-key brace form ──────────────────────────────────────────────────── - -func TestInputMacro_BracedSingleChar(t *testing.T) { - t.Parallel() - // {a} expands to "a" — single-char token without braces - got := parseInputMacroArgs(t, "{a}") - assert.Equal(t, []string{"a"}, got) -} - -func TestInputMacro_BracedSpecialNoRepeat(t *testing.T) { - t.Parallel() - // {f1} passes through with braces (multi-char name) - got := parseInputMacroArgs(t, "{f1}") - assert.Equal(t, []string{"{f1}"}, got) -} - -// ─── Repeat: key *N ────────────────────────────────────────────────────────── - -func TestInputMacro_Repeat_SingleChar(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{a*5}") - assert.Equal(t, []string{"a", "a", "a", "a", "a"}, got) -} - -func TestInputMacro_Repeat_Special(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{enter*3}") - assert.Equal(t, []string{"{enter}", "{enter}", "{enter}"}, got) -} - -func TestInputMacro_Repeat_Combo(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{ctrl+c*3}") - assert.Equal(t, []string{"{ctrl+c}", "{ctrl+c}", "{ctrl+c}"}, got) -} - -func TestInputMacro_Repeat_One(t *testing.T) { - t.Parallel() - // *1 is valid and equals no repeat - got := parseInputMacroArgs(t, "{a*1}") - assert.Equal(t, []string{"a"}, got) -} - -func TestInputMacro_Repeat_AsteriskViaQuote(t *testing.T) { - t.Parallel() - // Correct way to type 5 asterisks - got := parseInputMacroArgs(t, `{"*"*5}`) - assert.Equal(t, []string{"*", "*", "*", "*", "*"}, got) -} - -func TestInputMacro_Repeat_InMixedSequence(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "a{enter*2}b") - assert.Equal(t, []string{"a", "{enter}", "{enter}", "b"}, got) -} - -// ─── Quoted literal ─────────────────────────────────────────────────────────── - -func TestInputMacro_QuotedLiteral_Basic(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, `{"hello"}`) - assert.Equal(t, []string{"h", "e", "l", "l", "o"}, got) -} - -func TestInputMacro_QuotedLiteral_WithRepeat(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, `{"hi"*3}`) - assert.Equal(t, []string{"h", "i", "h", "i", "h", "i"}, got) -} - -func TestInputMacro_QuotedLiteral_EscapedQuote(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, `{"say \"hi\""}`) - assert.Equal(t, []string{"s", "a", "y", " ", `"`, "h", "i", `"`}, got) -} - -func TestInputMacro_QuotedLiteral_AsteriskIsLiteral(t *testing.T) { - t.Parallel() - // Inside quotes, * is a literal character to type - got := parseInputMacroArgs(t, `{"a*b"}`) - assert.Equal(t, []string{"a", "*", "b"}, got) -} - -func TestInputMacro_QuotedLiteral_PlusIsLiteral(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, `{"a+b"}`) - assert.Equal(t, []string{"a", "+", "b"}, got) -} - -func TestInputMacro_QuotedLiteral_Empty(t *testing.T) { - t.Parallel() - // {""} — empty quoted literal produces no tokens - got := parseInputMacroArgs(t, `{""}`) - assert.Empty(t, got) -} - -// ─── text: verb ─────────────────────────────────────────────────────────────── - -func TestInputMacro_TextVerb_Basic(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{text:hi}") - assert.Equal(t, []string{"h", "i"}, got) -} - -func TestInputMacro_TextVerb_WithRepeat(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{text:ab*3}") - assert.Equal(t, []string{"a", "b", "a", "b", "a", "b"}, got) -} - -func TestInputMacro_TextVerb_AsteriskInContent(t *testing.T) { - t.Parallel() - // {text:a*b} — *b is not a valid repeat (b is not a number) so * is literal - got := parseInputMacroArgs(t, "{text:a*b}") - assert.Equal(t, []string{"a", "*", "b"}, got) -} - -func TestInputMacro_QuotedAndTextVerbAreEquivalent(t *testing.T) { - t.Parallel() - quote := parseInputMacroArgs(t, `{"hello"*2}`) - verb := parseInputMacroArgs(t, "{text:hello*2}") - assert.Equal(t, quote, verb) -} - -// ─── delay: pass-through ───────────────────────────────────────────────────── - -func TestInputMacro_Delay_PassThrough(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{delay:500}") - assert.Equal(t, []string{"{delay:500}"}, got) -} - -func TestInputMacro_Delay_HumanDuration(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{delay:1s}") - assert.Equal(t, []string{"{delay:1s}"}, got) -} - -func TestInputMacro_Delay_InSequence(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "a{delay:100}b") - assert.Equal(t, []string{"a", "{delay:100}", "b"}, got) -} - -// ─── Hold verbs and sigils ──────────────────────────────────────────────────── - -func TestInputMacro_HoldVerb_Press(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{press:a}") - assert.Equal(t, []string{"{press:a}"}, got) -} - -func TestInputMacro_HoldVerb_Release(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{release:a}") - assert.Equal(t, []string{"{release:a}"}, got) -} - -func TestInputMacro_HoldVerb_Hold(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{hold:a:500}") - assert.Equal(t, []string{"{hold:a:500}"}, got) -} - -func TestInputMacro_Sigil_Down(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{_a}") - assert.Equal(t, []string{"{_a}"}, got) -} - -func TestInputMacro_Sigil_Up(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{^a}") - assert.Equal(t, []string{"{^a}"}, got) -} - -func TestInputMacro_Sigil_Hold(t *testing.T) { - t.Parallel() - got := parseInputMacroArgs(t, "{~a:500}") - assert.Equal(t, []string{"{~a:500}"}, got) -} - -func TestInputMacro_HoldWhile_Sequence(t *testing.T) { - t.Parallel() - // {_right}bbb{^right} — hold right, tap b three times, release right - got := parseInputMacroArgs(t, "{_right}bbb{^right}") - assert.Equal(t, []string{"{_right}", "b", "b", "b", "{^right}"}, got) +// 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 TestInputMacro_RepeatCap(t *testing.T) { - t.Parallel() - // *1001 exceeds InputMacroMaxRepeat (1000) - p := zapscript.NewParser("**input.keyboard:{a*1001}") - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrInputMacroRepeatTooLarge, "want ErrInputMacroRepeatTooLarge, got: %v", err) -} - +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() - // *1000 is exactly at the cap — should succeed p := zapscript.NewParser("**input.keyboard:{a*1000}") script, err := p.ParseScript() - require.NoError(t, err) - assert.Len(t, script.Cmds[0].Args, 1000) -} - -func TestInputMacro_TotalKeysCap(t *testing.T) { - t.Parallel() - // 6 reps × 1000 = 6000 > InputMacroMaxKeys (5000) - p := zapscript.NewParser("**input.keyboard:{a*1000}{b*1000}{c*1000}{d*1000}{e*1000}{f*1000}") - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrInputMacroTooLong, "want ErrInputMacroTooLong, got: %v", err) + 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() - // 5 × 1000 = 5000 exactly at the cap — should succeed - p := zapscript.NewParser("**input.keyboard:{a*1000}{b*1000}{c*1000}{d*1000}{e*1000}") + p := zapscript.NewParser("**input.keyboard:" + strings.Repeat("{a*1000}", 5)) script, err := p.ParseScript() - require.NoError(t, err) - assert.Len(t, script.Cmds[0].Args, 5000) -} - -func TestInputMacro_LiteralCharsCap(t *testing.T) { - t.Parallel() - // Plain chars outside braces also count toward the total - // 5001 'a' chars should exceed the cap - p := zapscript.NewParser("**input.keyboard:" + strings.Repeat("a", 5001)) - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrInputMacroTooLong) + 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 TestInputText_Basic(t *testing.T) { - t.Parallel() - got := parseInputTextArgs(t, "hello") - assert.Equal(t, []string{"h", "e", "l", "l", "o"}, got) -} - -func TestInputText_BracesAreLiteral(t *testing.T) { - t.Parallel() - // {} syntax is NOT interpreted in input.text - got := parseInputTextArgs(t, "{enter}") - assert.Equal(t, []string{"{", "e", "n", "t", "e", "r", "}"}, got) -} - -func TestInputText_AsteriskIsLiteral(t *testing.T) { - t.Parallel() - got := parseInputTextArgs(t, "a*5") - assert.Equal(t, []string{"a", "*", "5"}, got) -} - -func TestInputText_QuestionMarkIsLiteral(t *testing.T) { - t.Parallel() - // ? is NOT parsed as adv-arg start in raw mode - got := parseInputTextArgs(t, "what?") - assert.Equal(t, []string{"w", "h", "a", "t", "?"}, got) -} - -func TestInputText_URLIsLiteral(t *testing.T) { - t.Parallel() - // A URL with ? should type literally, not trigger adv-arg parsing - got := parseInputTextArgs(t, "https://x.com/search?q=foo") - require.Len(t, got, 26) - // spot-check the ? character - assert.Equal(t, "?", got[20]) -} - -func TestInputText_NewlineMapsToEnter(t *testing.T) { - t.Parallel() - got := parseInputTextArgs(t, "a\nb") - assert.Equal(t, []string{"a", "{enter}", "b"}, got) -} - -func TestInputText_TabMapsToTab(t *testing.T) { - t.Parallel() - got := parseInputTextArgs(t, "a\tb") - assert.Equal(t, []string{"a", "{tab}", "b"}, got) -} - -func TestInputText_NoAdvArgs(t *testing.T) { - t.Parallel() - // input.text has no adv-args; a trailing ? is typed literally - p := zapscript.NewParser("**input.text:hello?speed=50") - script, err := p.ParseScript() - require.NoError(t, err) - cmd := script.Cmds[0] - // All chars including ? s p e e d = 5 0 are args - assert.Contains(t, cmd.Args, "?") - assert.Empty(t, cmd.AdvArgs.Get("speed")) -} - -func TestInputText_CapEnforced(t *testing.T) { - t.Parallel() - p := zapscript.NewParser("**input.text:" + strings.Repeat("a", 5001)) - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrInputMacroTooLong) -} - -func TestInputText_EmptyArg(t *testing.T) { - t.Parallel() - p := zapscript.NewParser("**input.text:") - script, err := p.ParseScript() - require.NoError(t, err) - assert.Empty(t, script.Cmds[0].Args) -} - -// ─── Error cases ───────────────────────────────────────────────────────────── - -func TestInputMacro_EmptyBraces(t *testing.T) { - t.Parallel() - p := zapscript.NewParser("**input.keyboard:{}") - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrUnmatchedInputMacroExt) -} - -func TestInputMacro_UnclosedBrace(t *testing.T) { - t.Parallel() - p := zapscript.NewParser("**input.keyboard:{enter") - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrUnmatchedInputMacroExt) -} - -func TestInputMacro_UnclosedQuotedLiteral(t *testing.T) { - t.Parallel() - // {"unclosed — EOF reached inside brace content before closing } - // parseInputMacroExtContent returns ErrUnmatchedInputMacroExt on EOF - p := zapscript.NewParser(`**input.keyboard:{"unclosed`) - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrUnmatchedInputMacroExt) -} - -func TestInputMacro_EmptyKeyAfterRepeat(t *testing.T) { - t.Parallel() - // {*5} — content "*5" → name="" after removing *5 → ErrInputMacroEmptyKey - p := zapscript.NewParser("**input.keyboard:{*5}") - _, err := p.ParseScript() - require.Error(t, err) - assert.ErrorIs(t, err, zapscript.ErrInputMacroEmptyKey) +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 f05a9fe..3f76b35 100644 --- a/reader.go +++ b/reader.go @@ -178,11 +178,18 @@ func (c Command) String() string { } } case isInputRawCmd(normalizeCmdName(c.Name)): - // Raw text commands concatenate args directly with no escape processing. - // {enter}/{tab} are written literally and will reparse as individual chars — - // this is a known serialization limitation for the newline/tab mappings. + // 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 { - _, _ = b.WriteString(arg) + switch arg { + case "{enter}": + _, _ = b.WriteRune('\n') + case "{tab}": + _, _ = b.WriteRune('\t') + default: + _, _ = b.WriteString(arg) + } } default: for i, arg := range c.Args {