Skip to content

Commit 922d4e0

Browse files
authored
feat: add String() method to Command (#12)
* feat: add String() method to Command Returns the canonical ZapScript representation of a command, enabling round-trip parse → String() → parse. Handles quoting for args containing special characters, re-escapes control characters, and reconstructs input macro commands in their native concatenated format. Needed by zaparoo-core for ZapScript-aware allow_run matching. * fix: address lint findings in String() implementation - Sort adv arg keys for deterministic output - Escape adv arg values that need quoting - Fix fieldalignment in test struct - Use _, _ = pattern for strings.Builder writes - Use cmp.Diff in test assertions - Compare AdvArgs via Raw() instead of cmpopts.IgnoreUnexported * test: add edge case coverage and clean up comments - Add tests for double quote, carriage return in args - Add tests for adv arg values requiring quoting (comma, colon, newline, tab, caret) - Remove redundant what-level comments in round-trip test
1 parent a730494 commit 922d4e0

2 files changed

Lines changed: 341 additions & 0 deletions

File tree

command_string_test.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// Copyright 2026 The Zaparoo Project Contributors.
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// http://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
16+
package zapscript_test
17+
18+
import (
19+
"testing"
20+
21+
"github.com/ZaparooProject/go-zapscript"
22+
"github.com/google/go-cmp/cmp"
23+
)
24+
25+
func TestCommandString(t *testing.T) {
26+
t.Parallel()
27+
28+
tests := []struct {
29+
name string
30+
want string
31+
cmd zapscript.Command
32+
}{
33+
{
34+
name: "name only",
35+
cmd: zapscript.Command{Name: "stop"},
36+
want: "**stop",
37+
},
38+
{
39+
name: "single arg",
40+
cmd: zapscript.Command{Name: "launch", Args: []string{"/games/snes/mario.sfc"}},
41+
want: "**launch:/games/snes/mario.sfc",
42+
},
43+
{
44+
name: "multiple args",
45+
cmd: zapscript.Command{Name: "greet", Args: []string{"hi", "there"}},
46+
want: "**greet:hi,there",
47+
},
48+
{
49+
name: "arg with comma needs quoting",
50+
cmd: zapscript.Command{Name: "say", Args: []string{"hello, world"}},
51+
want: `**say:"hello, world"`,
52+
},
53+
{
54+
name: "arg with colon needs quoting",
55+
cmd: zapscript.Command{Name: "say", Args: []string{"key:value"}},
56+
want: `**say:"key:value"`,
57+
},
58+
{
59+
name: "arg with newline re-escapes",
60+
cmd: zapscript.Command{Name: "echo", Args: []string{"hello\nworld"}},
61+
want: `**echo:"hello^nworld"`,
62+
},
63+
{
64+
name: "arg with tab re-escapes",
65+
cmd: zapscript.Command{Name: "echo", Args: []string{"one\ttwo"}},
66+
want: `**echo:"one^ttwo"`,
67+
},
68+
{
69+
name: "arg with caret re-escapes",
70+
cmd: zapscript.Command{Name: "echo", Args: []string{"2^3"}},
71+
want: `**echo:"2^^3"`,
72+
},
73+
{
74+
name: "with advanced args",
75+
cmd: zapscript.Command{
76+
Name: "launch",
77+
Args: []string{"game.exe"},
78+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"platform": "win"}),
79+
},
80+
want: "**launch:game.exe?platform=win",
81+
},
82+
{
83+
name: "advanced args sorted",
84+
cmd: zapscript.Command{
85+
Name: "launch",
86+
Args: []string{"game.exe"},
87+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"platform": "win", "fullscreen": "yes", "lang": "en"}),
88+
},
89+
want: "**launch:game.exe?fullscreen=yes&lang=en&platform=win",
90+
},
91+
{
92+
name: "advanced args only",
93+
cmd: zapscript.Command{
94+
Name: "example",
95+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"debug": "true"}),
96+
},
97+
want: "**example?debug=true",
98+
},
99+
{
100+
name: "input.keyboard macro",
101+
cmd: zapscript.Command{Name: "input.keyboard", Args: []string{"a", "b", "c"}},
102+
want: "**input.keyboard:abc",
103+
},
104+
{
105+
name: "input.keyboard with extensions",
106+
cmd: zapscript.Command{Name: "input.keyboard", Args: []string{"{f1}", "a", "{ctrl+q}"}},
107+
want: "**input.keyboard:{f1}a{ctrl+q}",
108+
},
109+
{
110+
name: "input.gamepad macro",
111+
cmd: zapscript.Command{Name: "input.gamepad", Args: []string{"^", "^", "V", "V", "<", ">"}},
112+
want: "**input.gamepad:^^VV<>",
113+
},
114+
{
115+
name: "arg with double quote",
116+
cmd: zapscript.Command{Name: "echo", Args: []string{`say "hi"`}},
117+
want: `**echo:"say ^"hi^""`,
118+
},
119+
{
120+
name: "arg with carriage return",
121+
cmd: zapscript.Command{Name: "echo", Args: []string{"line1\rline2"}},
122+
want: `**echo:"line1^rline2"`,
123+
},
124+
{
125+
name: "adv arg value with comma",
126+
cmd: zapscript.Command{
127+
Name: "cmd",
128+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"list": "a,b,c"}),
129+
},
130+
want: `**cmd?list="a,b,c"`,
131+
},
132+
{
133+
name: "adv arg value with colon",
134+
cmd: zapscript.Command{
135+
Name: "cmd",
136+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"addr": "host:8080"}),
137+
},
138+
want: `**cmd?addr="host:8080"`,
139+
},
140+
{
141+
name: "adv arg value with newline",
142+
cmd: zapscript.Command{
143+
Name: "cmd",
144+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"msg": "hello\nworld"}),
145+
},
146+
want: `**cmd?msg="hello^nworld"`,
147+
},
148+
{
149+
name: "adv arg value with tab",
150+
cmd: zapscript.Command{
151+
Name: "cmd",
152+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"data": "col1\tcol2"}),
153+
},
154+
want: `**cmd?data="col1^tcol2"`,
155+
},
156+
{
157+
name: "adv arg value with caret",
158+
cmd: zapscript.Command{
159+
Name: "cmd",
160+
AdvArgs: zapscript.NewAdvArgs(map[string]string{"expr": "2^3"}),
161+
},
162+
want: `**cmd?expr="2^^3"`,
163+
},
164+
}
165+
166+
for _, tt := range tests {
167+
t.Run(tt.name, func(t *testing.T) {
168+
t.Parallel()
169+
got := tt.cmd.String()
170+
if diff := cmp.Diff(tt.want, got); diff != "" {
171+
t.Errorf("Command.String() mismatch (-want +got):\n%s", diff)
172+
}
173+
})
174+
}
175+
}
176+
177+
func TestCommandString_RoundTrip(t *testing.T) {
178+
t.Parallel()
179+
180+
// Ensure parse → String() → parse preserves command semantics
181+
inputs := []string{
182+
"**stop",
183+
"**launch:/games/snes/mario.sfc",
184+
"**greet:hi,there",
185+
`**say:"hello, world"`,
186+
"**launch:game.exe?platform=win",
187+
"**input.keyboard:abc{f1}{enter}",
188+
"**input.gamepad:^^VV<><>",
189+
"**delay:500",
190+
"**launch.random:SNES",
191+
"**http.get:https://example.com/api",
192+
}
193+
194+
for _, input := range inputs {
195+
t.Run(input, func(t *testing.T) {
196+
t.Parallel()
197+
198+
reader1 := zapscript.NewParser(input)
199+
script1, err := reader1.ParseScript()
200+
if err != nil {
201+
t.Fatalf("first parse failed: %v", err)
202+
}
203+
// Each input is a single command
204+
if len(script1.Cmds) != 1 {
205+
t.Fatalf("expected 1 command, got %d", len(script1.Cmds))
206+
}
207+
208+
str := script1.Cmds[0].String()
209+
210+
reader2 := zapscript.NewParser(str)
211+
script2, err := reader2.ParseScript()
212+
if err != nil {
213+
t.Fatalf("second parse of %q failed: %v", str, err)
214+
}
215+
if len(script2.Cmds) != 1 {
216+
t.Fatalf("expected 1 command from re-parse, got %d", len(script2.Cmds))
217+
}
218+
219+
cmd1 := script1.Cmds[0]
220+
cmd2 := script2.Cmds[0]
221+
222+
if diff := cmp.Diff(cmd1.Name, cmd2.Name); diff != "" {
223+
t.Errorf("round-trip Name mismatch (-original +reparsed):\n input: %s\n string(): %s\n%s",
224+
input, str, diff)
225+
}
226+
if diff := cmp.Diff(cmd1.Args, cmd2.Args); diff != "" {
227+
t.Errorf("round-trip Args mismatch (-original +reparsed):\n input: %s\n string(): %s\n%s",
228+
input, str, diff)
229+
}
230+
if diff := cmp.Diff(cmd1.AdvArgs.Raw(), cmd2.AdvArgs.Raw()); diff != "" {
231+
t.Errorf("round-trip AdvArgs mismatch (-original +reparsed):\n input: %s\n string(): %s\n%s",
232+
input, str, diff)
233+
}
234+
})
235+
}
236+
}

reader.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"errors"
2323
"fmt"
2424
"io"
25+
"sort"
26+
"strings"
2527
"unicode/utf8"
2628
)
2729

@@ -94,6 +96,109 @@ type Command struct {
9496
Args []string
9597
}
9698

99+
// argNeedsQuoting returns true if the arg contains characters that require
100+
// double-quoting to be safely represented in ZapScript.
101+
func argNeedsQuoting(s string) bool {
102+
for _, ch := range s {
103+
switch ch {
104+
case SymArgSep, SymArgStart, SymAdvArgStart, SymAdvArgSep,
105+
SymAdvArgEq, SymArgDoubleQuote, SymCmdSep, SymEscapeSeq,
106+
'\n', '\r', '\t':
107+
return true
108+
}
109+
}
110+
return false
111+
}
112+
113+
// escapeArg re-escapes control characters using ZapScript escape sequences
114+
// and wraps the arg in double quotes.
115+
func escapeArg(s string) string {
116+
var b strings.Builder
117+
_, _ = b.WriteRune('"')
118+
for _, ch := range s {
119+
switch ch {
120+
case '"':
121+
_, _ = b.WriteRune(SymEscapeSeq)
122+
_, _ = b.WriteRune('"')
123+
case '\n':
124+
_, _ = b.WriteRune(SymEscapeSeq)
125+
_, _ = b.WriteRune('n')
126+
case '\r':
127+
_, _ = b.WriteRune(SymEscapeSeq)
128+
_, _ = b.WriteRune('r')
129+
case '\t':
130+
_, _ = b.WriteRune(SymEscapeSeq)
131+
_, _ = b.WriteRune('t')
132+
case SymEscapeSeq:
133+
_, _ = b.WriteRune(SymEscapeSeq)
134+
_, _ = b.WriteRune(SymEscapeSeq)
135+
default:
136+
_, _ = b.WriteRune(ch)
137+
}
138+
}
139+
_, _ = b.WriteRune('"')
140+
return b.String()
141+
}
142+
143+
// String returns the canonical ZapScript representation of the command.
144+
// The output is valid ZapScript that can be re-parsed to produce an
145+
// equivalent Command.
146+
func (c Command) String() string {
147+
var b strings.Builder
148+
_, _ = b.WriteString("**")
149+
_, _ = b.WriteString(c.Name)
150+
151+
if len(c.Args) > 0 {
152+
_, _ = b.WriteRune(SymArgStart)
153+
154+
if isInputMacroCmd(c.Name) {
155+
// Input macro commands concatenate args directly
156+
for _, arg := range c.Args {
157+
_, _ = b.WriteString(arg)
158+
}
159+
} else {
160+
for i, arg := range c.Args {
161+
if i > 0 {
162+
_, _ = b.WriteRune(SymArgSep)
163+
}
164+
if argNeedsQuoting(arg) {
165+
_, _ = b.WriteString(escapeArg(arg))
166+
} else {
167+
_, _ = b.WriteString(arg)
168+
}
169+
}
170+
}
171+
}
172+
173+
if !c.AdvArgs.IsEmpty() {
174+
_, _ = b.WriteRune(SymAdvArgStart)
175+
176+
// Collect and sort keys for deterministic output
177+
var keys []string
178+
c.AdvArgs.Range(func(key Key, _ string) bool {
179+
keys = append(keys, string(key))
180+
return true
181+
})
182+
sort.Strings(keys)
183+
184+
for i, key := range keys {
185+
if i > 0 {
186+
_, _ = b.WriteRune(SymAdvArgSep)
187+
}
188+
_, _ = b.WriteString(key)
189+
_, _ = b.WriteRune(SymAdvArgEq)
190+
value := c.AdvArgs.Get(Key(key))
191+
if argNeedsQuoting(value) {
192+
_, _ = b.WriteString(escapeArg(value))
193+
} else {
194+
_, _ = b.WriteString(value)
195+
}
196+
}
197+
}
198+
199+
return b.String()
200+
}
201+
97202
type Script struct {
98203
Traits map[string]any `json:"traits,omitempty"`
99204
Cmds []Command `json:"cmds"`

0 commit comments

Comments
 (0)