Skip to content

Commit bc87bb7

Browse files
authored
fix: add expression eval to input macros, fuzz infrastructure, String() fixes (#14)
* fix: add expression evaluation support to input macro commands Input macro commands (input.keyboard, input.gamepad) processed characters one at a time with no expression handling, causing [[expression]] syntax to be split into individual characters instead of being evaluated. Closes #2 * feat: add fuzz testing infrastructure and fix Command.String() round-trip bugs Add FuzzParseTagFilters and FuzzCommandString fuzz targets. The round-trip fuzzer (parse → String() → reparse) found and fixed several serialization bugs in Command.String(): - Normalize command name before isInputMacroCmd check (mixed case used wrong parser) - Quote args containing *, [, #, {, ' in String() output - Escape [ inside quoted args to prevent expression parsing - Escape \, ?, [, {, | in input macro String() output - Quote explicit empty string args as "" to distinguish from no args - Skip empty arg when colon has no content (argWritten tracking) Add fuzz task to Taskfile.yml matching zaparoo-core's pattern with configurable FUZZ_TIME. Add dedicated nightly fuzz.yml workflow with crash artifact upload and automatic issue creation. Remove inline fuzz from CI (corpus regressions still run via go test). * fix: address PR review feedback - Add paired explicit-empty test cases (**cmd:"" and **cmd:'') next to the empty-colon test to document the argWritten behavior contract - Replace hasArgs with len(args) > 0 in parseAutoLaunchCmd to preserve explicit empty args from quoted input - Normalize command name in String() before isInputMacroCmd check for consistent serialization of programmatically-constructed commands - Remove unused hasArgs function
1 parent 6ee51d7 commit bc87bb7

21 files changed

Lines changed: 408 additions & 38 deletions

.github/workflows/ci.yml

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -61,27 +61,6 @@ jobs:
6161
- name: Run tests
6262
run: go test -v -race -timeout=10m ./...
6363

64-
fuzz:
65-
needs: changes
66-
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'
67-
timeout-minutes: 10
68-
runs-on: ubuntu-latest
69-
steps:
70-
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
71-
72-
- name: Set up Go
73-
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
74-
with:
75-
go-version-file: go.mod
76-
cache: true
77-
78-
- name: Run fuzz tests (quick)
79-
run: |
80-
# Run each fuzz test for 10 seconds to catch obvious issues
81-
go test -fuzz=FuzzParseScript -fuzztime=10s .
82-
go test -fuzz=FuzzParseExpressions -fuzztime=10s .
83-
go test -fuzz=FuzzEvalExpressions -fuzztime=10s .
84-
8564
lint:
8665
needs: changes
8766
if: needs.changes.outputs.code == 'true' || github.event_name != 'pull_request'

.github/workflows/fuzz.yml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
name: Fuzz Testing
2+
3+
on:
4+
schedule:
5+
- cron: "0 2 * * *"
6+
workflow_dispatch:
7+
inputs:
8+
fuzz-time:
9+
description: "Time per fuzz target (Go duration format)"
10+
required: false
11+
default: "2m"
12+
13+
concurrency:
14+
group: ${{ github.workflow }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
issues: write
20+
21+
jobs:
22+
fuzz:
23+
name: Fuzz
24+
runs-on: ubuntu-latest
25+
timeout-minutes: 120
26+
27+
steps:
28+
- name: Checkout code
29+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
30+
31+
- name: Set up Go
32+
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
33+
with:
34+
go-version-file: go.mod
35+
cache: true
36+
37+
- name: Set up Task
38+
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v2
39+
40+
- name: Download Go modules
41+
run: go mod download
42+
43+
- name: Run fuzz tests
44+
run: task fuzz
45+
env:
46+
FUZZ_TIME: ${{ inputs.fuzz-time || '2m' }}
47+
48+
- name: Upload crash corpus
49+
if: failure()
50+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
51+
with:
52+
name: fuzz-crashes
53+
path: "**/testdata/fuzz/**"
54+
if-no-files-found: ignore
55+
56+
- name: Create issue on failure
57+
if: failure()
58+
env:
59+
GH_TOKEN: ${{ github.token }}
60+
run: |
61+
gh issue create \
62+
--title "Nightly fuzz: crash found ($(date -u +%Y-%m-%d))" \
63+
--label "bug" \
64+
--body "$(cat <<'EOF'
65+
The nightly fuzz run found a crash. The failing corpus entry is attached as an artifact on the workflow run.
66+
67+
**Run:** ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
68+
69+
Download the `fuzz-crashes` artifact from that run and place the corpus file into the appropriate `testdata/fuzz/FuzzName/` directory to reproduce locally:
70+
71+
```
72+
go test -run FuzzName/corpus_filename .
73+
```
74+
EOF
75+
)"

Taskfile.yml

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,28 @@ tasks:
1515
desc: Run tests
1616
cmds:
1717
- go test -race ./...
18+
19+
fuzz:
20+
desc: Run fuzz tests (default 30s per test, use FUZZ_TIME to override)
21+
vars:
22+
FUZZ_TIME: '{{default "30s" .FUZZ_TIME}}'
23+
cmds:
24+
- |
25+
exit_code=0
26+
targets=(
27+
"FuzzParseScript"
28+
"FuzzParseExpressions"
29+
"FuzzEvalExpressions"
30+
"FuzzParseTagFilters"
31+
"FuzzCommandString"
32+
)
33+
for target in "${targets[@]}"; do
34+
echo "=== ${target} ==="
35+
if go test -run "^$" -fuzz="${target}" -fuzztime={{.FUZZ_TIME}} .; then
36+
echo "PASS: ${target}"
37+
else
38+
echo "FAIL: ${target}"
39+
exit_code=1
40+
fi
41+
done
42+
exit $exit_code

arguments.go

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
8383
args = make([]string, 0)
8484
advArgs = make(map[string]string)
8585

86+
macroLoop:
8687
for {
8788
ch, err := sr.read()
8889
if err != nil {
@@ -111,7 +112,8 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
111112
break
112113
}
113114

114-
if ch == SymInputMacroExtStart {
115+
switch ch {
116+
case SymInputMacroExtStart:
115117
extName := string(ch)
116118
var extBuilder strings.Builder
117119
for {
@@ -131,7 +133,14 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
131133
extName += extBuilder.String()
132134
args = append(args, extName)
133135
continue
134-
} else if ch == SymAdvArgStart {
136+
case SymExpressionStart:
137+
exprValue, exprErr := sr.parseExpression()
138+
if exprErr != nil {
139+
return args, advArgs, exprErr
140+
}
141+
args = append(args, exprValue)
142+
continue
143+
case SymAdvArgStart:
135144
newAdvArgs, buf, err := sr.parseAdvArgs()
136145
if errors.Is(err, ErrInvalidAdvArgName) {
137146
// if an adv arg name is invalid, fallback on treating it
@@ -147,10 +156,10 @@ func (sr *ScriptReader) parseInputMacroArg() (args []string, advArgs map[string]
147156
advArgs = newAdvArgs
148157

149158
// advanced args are always the last part of a command
150-
break
159+
break macroLoop
160+
default:
161+
args = append(args, string(ch))
151162
}
152-
153-
args = append(args, string(ch))
154163
}
155164

156165
return args, advArgs, nil
@@ -269,6 +278,9 @@ func (sr *ScriptReader) parseArgs(
269278
advArgs = make(map[string]string)
270279
currentArg := prefix
271280
argStart := sr.pos
281+
// tracks whether content was explicitly written, distinguishing
282+
// "**cmd:" (no content, no arg) from "**cmd:''" (explicit empty arg)
283+
argWritten := prefix != ""
272284

273285
argsLoop:
274286
for {
@@ -286,13 +298,15 @@ argsLoop:
286298
return args, advArgs, quotedErr
287299
}
288300
currentArg = quotedArg
301+
argWritten = true
289302
continue argsLoop
290303
case argStart == sr.pos-1 && ch == SymJSONStart:
291304
jsonArg, jsonErr := sr.parseJSONArg()
292305
if jsonErr != nil {
293306
return args, advArgs, jsonErr
294307
}
295308
currentArg = jsonArg
309+
argWritten = true
296310
continue argsLoop
297311
case ch == SymEscapeSeq:
298312
// escaping next character
@@ -301,9 +315,11 @@ argsLoop:
301315
return args, advArgs, escapeErr
302316
} else if next == "" {
303317
currentArg += string(SymEscapeSeq)
318+
argWritten = true
304319
continue argsLoop
305320
}
306321
currentArg += next
322+
argWritten = true
307323
continue argsLoop
308324
}
309325

@@ -321,6 +337,7 @@ argsLoop:
321337
args = append(args, currentArg)
322338
currentArg = ""
323339
argStart = sr.pos
340+
argWritten = false
324341
continue argsLoop
325342
case ch == SymAdvArgStart:
326343
newAdvArgs, buf, err := sr.parseAdvArgs()
@@ -344,18 +361,21 @@ argsLoop:
344361
return args, advArgs, err
345362
}
346363
currentArg += exprValue
364+
argWritten = true
347365
continue argsLoop
348366
default:
349367
currentArg += string(ch)
368+
if !isWhitespace(ch) {
369+
argWritten = true
370+
}
350371
continue argsLoop
351372
}
352373
}
353374

354375
currentArg = strings.TrimSpace(currentArg)
355-
if !onlyAdvArgs {
356-
// if a cmd was called with ":" it will always have at least 1 blank arg
376+
if !onlyAdvArgs && (currentArg != "" || argWritten) {
357377
args = append(args, currentArg)
358-
} else if currentArg != "" {
378+
} else if onlyAdvArgs && currentArg != "" {
359379
// fallback content from invalid adv args should still be preserved
360380
args = append(args, currentArg)
361381
}

parser.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ commandLoop:
135135
break commandLoop
136136
}
137137

138+
cmd.Name = normalizeCmdName(cmd.Name)
139+
138140
onlyAdvArgs := false
139141
if ch == SymAdvArgStart {
140142
// roll it back to trigger adv arg parsing in parseArgs
@@ -180,7 +182,7 @@ commandLoop:
180182
return cmd, string(buf), ErrEmptyCmdName
181183
}
182184

183-
cmd.Name = strings.ToLower(cmd.Name)
185+
cmd.Name = normalizeCmdName(cmd.Name)
184186

185187
return cmd, string(buf), nil
186188
}
@@ -201,7 +203,9 @@ func (sr *ScriptReader) ParseScript() (Script, error) {
201203
}
202204
cmd := Command{
203205
Name: ZapScriptCmdLaunch,
204-
Args: args,
206+
}
207+
if len(args) > 0 {
208+
cmd.Args = args
205209
}
206210
if len(advArgs) > 0 {
207211
cmd.AdvArgs = NewAdvArgs(advArgs)

parser_coverage_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,86 @@ func TestParseInputMacroArgs(t *testing.T) {
161161
},
162162
},
163163
},
164+
// Expression handling in input macros
165+
{
166+
name: "input with expression",
167+
input: `**input.keyboard:[[var]]`,
168+
want: zapscript.Script{
169+
Cmds: []zapscript.Command{
170+
{Name: "input.keyboard", Args: []string{zapscript.TokExpStart + "var" + zapscript.TokExprEnd}},
171+
},
172+
},
173+
},
174+
{
175+
name: "input with expression between chars",
176+
input: `**input.keyboard:a[[var]]b`,
177+
want: zapscript.Script{
178+
Cmds: []zapscript.Command{
179+
{Name: "input.keyboard", Args: []string{
180+
"a", zapscript.TokExpStart + "var" + zapscript.TokExprEnd, "b",
181+
}},
182+
},
183+
},
184+
},
185+
{
186+
name: "input with single bracket not expression",
187+
input: `**input.keyboard:a[b`,
188+
want: zapscript.Script{
189+
Cmds: []zapscript.Command{
190+
{Name: "input.keyboard", Args: []string{"a", "[", "b"}},
191+
},
192+
},
193+
},
194+
{
195+
name: "input with multiple expressions",
196+
input: `**input.keyboard:[[a]][[b]]`,
197+
want: zapscript.Script{
198+
Cmds: []zapscript.Command{
199+
{Name: "input.keyboard", Args: []string{
200+
zapscript.TokExpStart + "a" + zapscript.TokExprEnd,
201+
zapscript.TokExpStart + "b" + zapscript.TokExprEnd,
202+
}},
203+
},
204+
},
205+
},
206+
{
207+
name: "input with expression and advanced args",
208+
input: `**input.keyboard:[[var]]?delay=100`,
209+
want: zapscript.Script{
210+
Cmds: []zapscript.Command{
211+
{
212+
Name: "input.keyboard",
213+
Args: []string{zapscript.TokExpStart + "var" + zapscript.TokExprEnd},
214+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"delay": "100"}),
215+
},
216+
},
217+
},
218+
},
219+
{
220+
name: "input with escape sequence and expression",
221+
input: `**input.keyboard:\n[[var]]`,
222+
want: zapscript.Script{
223+
Cmds: []zapscript.Command{
224+
{Name: "input.keyboard", Args: []string{
225+
"n", zapscript.TokExpStart + "var" + zapscript.TokExprEnd,
226+
}},
227+
},
228+
},
229+
},
230+
{
231+
name: "input with unmatched expression",
232+
input: `**input.keyboard:[[var`,
233+
wantErr: zapscript.ErrUnmatchedExpression,
234+
},
235+
{
236+
name: "input gamepad with expression",
237+
input: `**input.gamepad:[[var]]`,
238+
want: zapscript.Script{
239+
Cmds: []zapscript.Command{
240+
{Name: "input.gamepad", Args: []string{zapscript.TokExpStart + "var" + zapscript.TokExprEnd}},
241+
},
242+
},
243+
},
164244
}
165245

166246
for _, tt := range tests {

0 commit comments

Comments
 (0)