diff --git a/pkg/config/keybinding.go b/pkg/config/keybinding.go new file mode 100644 index 000000000..bf605d44f --- /dev/null +++ b/pkg/config/keybinding.go @@ -0,0 +1,79 @@ +package config + +import ( + "encoding/json" + "fmt" + + "github.com/karimkhaleel/jsonschema" + "github.com/samber/lo" + "gopkg.in/yaml.v3" +) + +// Keybinding represents the value of a single keybinding entry in the user's +// config. It's a slice of key strings to allow alternates, but for backward +// compatibility (and because most bindings only have one key) it can be +// written in YAML/JSON as either a single scalar string or as a sequence of +// strings. +type Keybinding []string + +func (k *Keybinding) UnmarshalYAML(node *yaml.Node) error { + var ss []string + switch node.Kind { + case yaml.ScalarNode: + var s string + if err := node.Decode(&s); err != nil { + return err + } + ss = []string{s} + case yaml.SequenceNode: + if err := node.Decode(&ss); err != nil { + return err + } + default: + return fmt.Errorf("expected a string or a sequence of strings for keybinding, got %v", node.Tag) + } + // Drop empty and entries so clients never have to special-case + // them: an empty Keybinding means "no key bound", a non-empty one is + // guaranteed to contain only real keys. + *k = lo.Filter(ss, func(s string, _ int) bool { + return s != "" && s != "" + }) + return nil +} + +func (k Keybinding) MarshalYAML() (any, error) { + if len(k) == 1 { + return k[0], nil + } + // Render multi-key bindings in flow style (`[a, b]`) rather than the default + // block style, which is more compact and reads better in the generated docs. + node := &yaml.Node{ + Kind: yaml.SequenceNode, + Style: yaml.FlowStyle, + } + for _, s := range k { + node.Content = append(node.Content, &yaml.Node{ + Kind: yaml.ScalarNode, + Value: s, + }) + } + return node, nil +} + +func (k Keybinding) MarshalJSON() ([]byte, error) { + if len(k) == 1 { + return json.Marshal(k[0]) + } + return json.Marshal([]string(k)) +} + +// JSONSchema lets the schema generator describe this type as a union of a +// string and an array of strings instead of just an array. +func (Keybinding) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + OneOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "array", Items: &jsonschema.Schema{Type: "string"}}, + }, + } +} diff --git a/pkg/config/keybinding_test.go b/pkg/config/keybinding_test.go new file mode 100644 index 000000000..d3406412c --- /dev/null +++ b/pkg/config/keybinding_test.go @@ -0,0 +1,151 @@ +package config + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "gopkg.in/yaml.v3" +) + +func TestKeybindingUnmarshalYAML(t *testing.T) { + scenarios := []struct { + name string + input string + expected Keybinding + wantErr bool + }{ + { + name: "scalar string", + input: `q`, + expected: Keybinding{"q"}, + }, + { + name: "scalar with special characters", + input: ``, + expected: Keybinding{""}, + }, + { + name: "sequence with one element", + input: `[q]`, + expected: Keybinding{"q"}, + }, + { + name: "sequence with multiple elements", + input: `["q", ""]`, + expected: Keybinding{"q", ""}, + }, + { + name: "empty sequence", + input: `[]`, + expected: Keybinding{}, + }, + { + name: "scalar decodes to empty", + input: ``, + expected: Keybinding{}, + }, + { + name: "scalar empty string decodes to empty", + input: `""`, + expected: Keybinding{}, + }, + { + name: " entries are filtered out of a sequence", + input: `["q", "", ""]`, + expected: Keybinding{"q", ""}, + }, + { + name: "mapping is rejected", + input: `{key: q}`, + wantErr: true, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + var k Keybinding + err := yaml.Unmarshal([]byte(s.input), &k) + if s.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, s.expected, k) + }) + } +} + +func TestKeybindingMarshalYAML(t *testing.T) { + scenarios := []struct { + name string + input Keybinding + expected string + }{ + { + name: "single key emits a scalar", + input: Keybinding{"q"}, + expected: "q\n", + }, + { + name: "multiple keys emit a flow sequence", + input: Keybinding{"q", ""}, + expected: "[q, ]\n", + }, + { + name: "empty keybinding emits an empty sequence", + input: Keybinding{}, + expected: "[]\n", + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + out, err := yaml.Marshal(s.input) + assert.NoError(t, err) + assert.Equal(t, s.expected, string(out)) + }) + } +} + +func TestKeybindingMarshalJSON(t *testing.T) { + scenarios := []struct { + name string + input Keybinding + expected string + }{ + { + name: "single key emits a string", + input: Keybinding{"q"}, + expected: `"q"`, + }, + { + name: "multiple keys emit an array", + input: Keybinding{"q", "esc"}, + expected: `["q","esc"]`, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + out, err := json.Marshal(s.input) + assert.NoError(t, err) + assert.Equal(t, s.expected, string(out)) + }) + } +} + +func TestKeybindingYAMLRoundTrip(t *testing.T) { + scenarios := []Keybinding{ + {"q"}, + {"q", ""}, + {"", "", ""}, + } + for _, original := range scenarios { + out, err := yaml.Marshal(original) + assert.NoError(t, err) + var decoded Keybinding + assert.NoError(t, yaml.Unmarshal(out, &decoded)) + assert.Equal(t, original, decoded) + } +}