diff --git a/command_string_test.go b/command_string_test.go new file mode 100644 index 0000000..ab670bc --- /dev/null +++ b/command_string_test.go @@ -0,0 +1,236 @@ +// 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 ( + "testing" + + "github.com/ZaparooProject/go-zapscript" + "github.com/google/go-cmp/cmp" +) + +func TestCommandString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + want string + cmd zapscript.Command + }{ + { + name: "name only", + cmd: zapscript.Command{Name: "stop"}, + want: "**stop", + }, + { + name: "single arg", + cmd: zapscript.Command{Name: "launch", Args: []string{"/games/snes/mario.sfc"}}, + want: "**launch:/games/snes/mario.sfc", + }, + { + name: "multiple args", + cmd: zapscript.Command{Name: "greet", Args: []string{"hi", "there"}}, + want: "**greet:hi,there", + }, + { + name: "arg with comma needs quoting", + cmd: zapscript.Command{Name: "say", Args: []string{"hello, world"}}, + want: `**say:"hello, world"`, + }, + { + name: "arg with colon needs quoting", + cmd: zapscript.Command{Name: "say", Args: []string{"key:value"}}, + want: `**say:"key:value"`, + }, + { + name: "arg with newline re-escapes", + cmd: zapscript.Command{Name: "echo", Args: []string{"hello\nworld"}}, + want: `**echo:"hello^nworld"`, + }, + { + name: "arg with tab re-escapes", + cmd: zapscript.Command{Name: "echo", Args: []string{"one\ttwo"}}, + want: `**echo:"one^ttwo"`, + }, + { + name: "arg with caret re-escapes", + cmd: zapscript.Command{Name: "echo", Args: []string{"2^3"}}, + want: `**echo:"2^^3"`, + }, + { + name: "with advanced args", + cmd: zapscript.Command{ + Name: "launch", + Args: []string{"game.exe"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{"platform": "win"}), + }, + want: "**launch:game.exe?platform=win", + }, + { + name: "advanced args sorted", + cmd: zapscript.Command{ + Name: "launch", + Args: []string{"game.exe"}, + AdvArgs: zapscript.NewAdvArgs(map[string]string{"platform": "win", "fullscreen": "yes", "lang": "en"}), + }, + want: "**launch:game.exe?fullscreen=yes&lang=en&platform=win", + }, + { + name: "advanced args only", + cmd: zapscript.Command{ + Name: "example", + AdvArgs: zapscript.NewAdvArgs(map[string]string{"debug": "true"}), + }, + want: "**example?debug=true", + }, + { + name: "input.keyboard macro", + cmd: zapscript.Command{Name: "input.keyboard", Args: []string{"a", "b", "c"}}, + want: "**input.keyboard:abc", + }, + { + name: "input.keyboard with extensions", + cmd: zapscript.Command{Name: "input.keyboard", Args: []string{"{f1}", "a", "{ctrl+q}"}}, + want: "**input.keyboard:{f1}a{ctrl+q}", + }, + { + name: "input.gamepad macro", + cmd: zapscript.Command{Name: "input.gamepad", Args: []string{"^", "^", "V", "V", "<", ">"}}, + want: "**input.gamepad:^^VV<>", + }, + { + name: "arg with double quote", + cmd: zapscript.Command{Name: "echo", Args: []string{`say "hi"`}}, + want: `**echo:"say ^"hi^""`, + }, + { + name: "arg with carriage return", + cmd: zapscript.Command{Name: "echo", Args: []string{"line1\rline2"}}, + want: `**echo:"line1^rline2"`, + }, + { + name: "adv arg value with comma", + cmd: zapscript.Command{ + Name: "cmd", + AdvArgs: zapscript.NewAdvArgs(map[string]string{"list": "a,b,c"}), + }, + want: `**cmd?list="a,b,c"`, + }, + { + name: "adv arg value with colon", + cmd: zapscript.Command{ + Name: "cmd", + AdvArgs: zapscript.NewAdvArgs(map[string]string{"addr": "host:8080"}), + }, + want: `**cmd?addr="host:8080"`, + }, + { + name: "adv arg value with newline", + cmd: zapscript.Command{ + Name: "cmd", + AdvArgs: zapscript.NewAdvArgs(map[string]string{"msg": "hello\nworld"}), + }, + want: `**cmd?msg="hello^nworld"`, + }, + { + name: "adv arg value with tab", + cmd: zapscript.Command{ + Name: "cmd", + AdvArgs: zapscript.NewAdvArgs(map[string]string{"data": "col1\tcol2"}), + }, + want: `**cmd?data="col1^tcol2"`, + }, + { + name: "adv arg value with caret", + cmd: zapscript.Command{ + Name: "cmd", + AdvArgs: zapscript.NewAdvArgs(map[string]string{"expr": "2^3"}), + }, + want: `**cmd?expr="2^^3"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := tt.cmd.String() + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("Command.String() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestCommandString_RoundTrip(t *testing.T) { + t.Parallel() + + // Ensure parse → String() → parse preserves command semantics + inputs := []string{ + "**stop", + "**launch:/games/snes/mario.sfc", + "**greet:hi,there", + `**say:"hello, world"`, + "**launch:game.exe?platform=win", + "**input.keyboard:abc{f1}{enter}", + "**input.gamepad:^^VV<><>", + "**delay:500", + "**launch.random:SNES", + "**http.get:https://example.com/api", + } + + for _, input := range inputs { + t.Run(input, func(t *testing.T) { + t.Parallel() + + reader1 := zapscript.NewParser(input) + script1, err := reader1.ParseScript() + if err != nil { + t.Fatalf("first parse failed: %v", err) + } + // Each input is a single command + if len(script1.Cmds) != 1 { + t.Fatalf("expected 1 command, got %d", len(script1.Cmds)) + } + + str := script1.Cmds[0].String() + + reader2 := zapscript.NewParser(str) + script2, err := reader2.ParseScript() + if err != nil { + t.Fatalf("second parse of %q failed: %v", str, err) + } + if len(script2.Cmds) != 1 { + t.Fatalf("expected 1 command from re-parse, got %d", len(script2.Cmds)) + } + + cmd1 := script1.Cmds[0] + cmd2 := script2.Cmds[0] + + if diff := cmp.Diff(cmd1.Name, cmd2.Name); diff != "" { + t.Errorf("round-trip Name mismatch (-original +reparsed):\n input: %s\n string(): %s\n%s", + input, str, diff) + } + if diff := cmp.Diff(cmd1.Args, cmd2.Args); diff != "" { + t.Errorf("round-trip Args mismatch (-original +reparsed):\n input: %s\n string(): %s\n%s", + input, str, diff) + } + if diff := cmp.Diff(cmd1.AdvArgs.Raw(), cmd2.AdvArgs.Raw()); diff != "" { + t.Errorf("round-trip AdvArgs mismatch (-original +reparsed):\n input: %s\n string(): %s\n%s", + input, str, diff) + } + }) + } +} diff --git a/reader.go b/reader.go index 3f7b7f6..23d7304 100644 --- a/reader.go +++ b/reader.go @@ -22,6 +22,8 @@ import ( "errors" "fmt" "io" + "sort" + "strings" "unicode/utf8" ) @@ -94,6 +96,109 @@ type Command struct { Args []string } +// argNeedsQuoting returns true if the arg contains characters that require +// double-quoting to be safely represented in ZapScript. +func argNeedsQuoting(s string) bool { + for _, ch := range s { + switch ch { + case SymArgSep, SymArgStart, SymAdvArgStart, SymAdvArgSep, + SymAdvArgEq, SymArgDoubleQuote, SymCmdSep, SymEscapeSeq, + '\n', '\r', '\t': + return true + } + } + return false +} + +// escapeArg re-escapes control characters using ZapScript escape sequences +// and wraps the arg in double quotes. +func escapeArg(s string) string { + var b strings.Builder + _, _ = b.WriteRune('"') + for _, ch := range s { + switch ch { + case '"': + _, _ = b.WriteRune(SymEscapeSeq) + _, _ = b.WriteRune('"') + case '\n': + _, _ = b.WriteRune(SymEscapeSeq) + _, _ = b.WriteRune('n') + case '\r': + _, _ = b.WriteRune(SymEscapeSeq) + _, _ = b.WriteRune('r') + case '\t': + _, _ = b.WriteRune(SymEscapeSeq) + _, _ = b.WriteRune('t') + case SymEscapeSeq: + _, _ = b.WriteRune(SymEscapeSeq) + _, _ = b.WriteRune(SymEscapeSeq) + default: + _, _ = b.WriteRune(ch) + } + } + _, _ = b.WriteRune('"') + return b.String() +} + +// String returns the canonical ZapScript representation of the command. +// The output is valid ZapScript that can be re-parsed to produce an +// equivalent Command. +func (c Command) String() string { + var b strings.Builder + _, _ = b.WriteString("**") + _, _ = b.WriteString(c.Name) + + if len(c.Args) > 0 { + _, _ = b.WriteRune(SymArgStart) + + if isInputMacroCmd(c.Name) { + // Input macro commands concatenate args directly + for _, arg := range c.Args { + _, _ = b.WriteString(arg) + } + } else { + for i, arg := range c.Args { + if i > 0 { + _, _ = b.WriteRune(SymArgSep) + } + if argNeedsQuoting(arg) { + _, _ = b.WriteString(escapeArg(arg)) + } else { + _, _ = b.WriteString(arg) + } + } + } + } + + if !c.AdvArgs.IsEmpty() { + _, _ = b.WriteRune(SymAdvArgStart) + + // Collect and sort keys for deterministic output + var keys []string + c.AdvArgs.Range(func(key Key, _ string) bool { + keys = append(keys, string(key)) + return true + }) + sort.Strings(keys) + + for i, key := range keys { + if i > 0 { + _, _ = b.WriteRune(SymAdvArgSep) + } + _, _ = b.WriteString(key) + _, _ = b.WriteRune(SymAdvArgEq) + value := c.AdvArgs.Get(Key(key)) + if argNeedsQuoting(value) { + _, _ = b.WriteString(escapeArg(value)) + } else { + _, _ = b.WriteString(value) + } + } + } + + return b.String() +} + type Script struct { Traits map[string]any `json:"traits,omitempty"` Cmds []Command `json:"cmds"`