detached bf7a503

fix(vimkeys): escape legacy binding collisions

commit
bf7a5034f8487e7ab8eb6ea493c3646ff44df104
author
brice · · 2026-07-29 05:55 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
ffef9a9
signature
unsigned
4 files · +471 -9
M internal/domain/vimkeys/key.go +52 -3
@@ -14,6 +14,11 @@ const (
1414 ModAlt
1515 )
1616
17+const (
18+ symCR = "CR"
19+ symEsc = "Esc"
20+)
21+
1722 // Key is a single key event with symbol and modifiers.
1823 type Key struct {
1924 Sym string
@@ -41,6 +46,9 @@ func (k Key) String() string {
4146 }
4247
4348 if k.Mods == 0 {
49+ if k.Sym == "+" {
50+ return "<Plus>"
51+ }
4452 if alias := angleAliasForSym(k.Sym); alias != "" {
4553 return "<" + alias + ">"
4654 }
@@ -51,14 +59,55 @@ func (k Key) String() string {
5159 }
5260
5361 // String renders the full binding sequence in canonical form.
62+// When the naive concatenation would case-insensitively equal an exact legacy
63+// atom (enter/return/escape/esc), the first key is forced to an angle literal
64+// so ParseBinding cannot reabsorb the sequence as that atom.
5465 func (s Sequence) String() string {
66+ rendered := s.render(false)
67+ if collidesWithLegacyAtom(rendered) {
68+ return s.render(true)
69+ }
70+ return rendered
71+}
72+
73+func (s Sequence) render(forceAngleFirst bool) string {
5574 var b strings.Builder
56- for _, key := range s {
75+ for i, key := range s {
76+ if forceAngleFirst && i == 0 {
77+ b.WriteString(key.angleLiteralString())
78+ continue
79+ }
5780 b.WriteString(key.String())
5881 }
5982 return b.String()
6083 }
6184
85+func collidesWithLegacyAtom(s string) bool {
86+ switch strings.ToLower(s) {
87+ case "enter", "return", "escape", "esc":
88+ return true
89+ default:
90+ return false
91+ }
92+}
93+
94+// angleLiteralString forces angle-bracket form for keys that Key.String would
95+// otherwise emit as bare or shifted letters (e.g. "e" / "E").
96+func (k Key) angleLiteralString() string {
97+ if len(k.Sym) == 1 {
98+ c := k.Sym[0]
99+ if c >= 'a' && c <= 'z' {
100+ switch k.Mods {
101+ case 0:
102+ return "<" + k.Sym + ">"
103+ case ModShift:
104+ return formatAngleKey(k)
105+ }
106+ }
107+ }
108+ return k.String()
109+}
110+
62111 // Equal reports whether two sequences contain the same keys.
63112 func (s Sequence) Equal(other Sequence) bool {
64113 if len(s) != len(other) {
@@ -74,9 +123,9 @@ func (s Sequence) Equal(other Sequence) bool {
74123
75124 func angleAliasForSym(sym string) string {
76125 switch sym {
77- case "CR":
126+ case symCR:
78127 return "Return"
79- case "Esc":
128+ case symEsc:
80129 return "Escape"
81130 case "Space", "Tab", "BackSpace", "Delete",
82131 "Left", "Right", "Up", "Down",
@@ -14,6 +14,11 @@ const (
14 ModAlt14 ModAlt
15 )15 )
16 16
17+const (
18+ symCR = "CR"
19+ symEsc = "Esc"
20+)
21+
17 // Key is a single key event with symbol and modifiers.22 // Key is a single key event with symbol and modifiers.
18 type Key struct {23 type Key struct {
19 Sym string24 Sym string
@@ -41,6 +46,9 @@ func (k Key) String() string {
41 }46 }
42 47
43 if k.Mods == 0 {48 if k.Mods == 0 {
49+ if k.Sym == "+" {
50+ return "<Plus>"
51+ }
44 if alias := angleAliasForSym(k.Sym); alias != "" {52 if alias := angleAliasForSym(k.Sym); alias != "" {
45 return "<" + alias + ">"53 return "<" + alias + ">"
46 }54 }
@@ -51,14 +59,55 @@ func (k Key) String() string {
51 }59 }
52 60
53 // String renders the full binding sequence in canonical form.61 // String renders the full binding sequence in canonical form.
62+// When the naive concatenation would case-insensitively equal an exact legacy
63+// atom (enter/return/escape/esc), the first key is forced to an angle literal
64+// so ParseBinding cannot reabsorb the sequence as that atom.
54 func (s Sequence) String() string {65 func (s Sequence) String() string {
66+ rendered := s.render(false)
67+ if collidesWithLegacyAtom(rendered) {
68+ return s.render(true)
69+ }
70+ return rendered
71+}
72+
73+func (s Sequence) render(forceAngleFirst bool) string {
55 var b strings.Builder74 var b strings.Builder
56- for _, key := range s {75+ for i, key := range s {
76+ if forceAngleFirst && i == 0 {
77+ b.WriteString(key.angleLiteralString())
78+ continue
79+ }
57 b.WriteString(key.String())80 b.WriteString(key.String())
58 }81 }
59 return b.String()82 return b.String()
60 }83 }
61 84
85+func collidesWithLegacyAtom(s string) bool {
86+ switch strings.ToLower(s) {
87+ case "enter", "return", "escape", "esc":
88+ return true
89+ default:
90+ return false
91+ }
92+}
93+
94+// angleLiteralString forces angle-bracket form for keys that Key.String would
95+// otherwise emit as bare or shifted letters (e.g. "e" / "E").
96+func (k Key) angleLiteralString() string {
97+ if len(k.Sym) == 1 {
98+ c := k.Sym[0]
99+ if c >= 'a' && c <= 'z' {
100+ switch k.Mods {
101+ case 0:
102+ return "<" + k.Sym + ">"
103+ case ModShift:
104+ return formatAngleKey(k)
105+ }
106+ }
107+ }
108+ return k.String()
109+}
110+
62 // Equal reports whether two sequences contain the same keys.111 // Equal reports whether two sequences contain the same keys.
63 func (s Sequence) Equal(other Sequence) bool {112 func (s Sequence) Equal(other Sequence) bool {
64 if len(s) != len(other) {113 if len(s) != len(other) {
@@ -74,9 +123,9 @@ func (s Sequence) Equal(other Sequence) bool {
74 123
75 func angleAliasForSym(sym string) string {124 func angleAliasForSym(sym string) string {
76 switch sym {125 switch sym {
77- case "CR":126+ case symCR:
78 return "Return"127 return "Return"
79- case "Esc":128+ case symEsc:
80 return "Escape"129 return "Escape"
81 case "Space", "Tab", "BackSpace", "Delete",130 case "Space", "Tab", "BackSpace", "Delete",
82 "Left", "Right", "Up", "Down",131 "Left", "Right", "Up", "Down",
M internal/domain/vimkeys/key_test.go +1 -0
@@ -14,6 +14,7 @@ func TestKeyString(t *testing.T) {
1414 {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"},
1515 {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"},
1616 {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"},
17+ {name: "bare plus", key: Key{Sym: "+"}, want: "<Plus>"},
1718 {name: "shift glyph", key: Key{Sym: "!"}, want: "!"},
1819 {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
1920 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
@@ -14,6 +14,7 @@ func TestKeyString(t *testing.T) {
14 {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"},14 {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"},
15 {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"},15 {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"},
16 {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"},16 {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"},
17+ {name: "bare plus", key: Key{Sym: "+"}, want: "<Plus>"},
17 {name: "shift glyph", key: Key{Sym: "!"}, want: "!"},18 {name: "shift glyph", key: Key{Sym: "!"}, want: "!"},
18 {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},19 {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
19 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},20 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
M internal/domain/vimkeys/parse.go +15 -4
@@ -30,9 +30,9 @@ func ParseBinding(s string) (Sequence, error) {
3030 func tryParseLegacyAtom(s string) (Key, bool) {
3131 switch strings.ToLower(s) {
3232 case "enter", "return":
33- return Key{Sym: "CR"}, true
33+ return Key{Sym: symCR}, true
3434 case "escape", "esc":
35- return Key{Sym: "Esc"}, true
35+ return Key{Sym: symEsc}, true
3636 default:
3737 return Key{}, false
3838 }
@@ -306,9 +306,9 @@ func symFromSpecialAlias(token string) (string, bool) {
306306 lower := strings.ToLower(token)
307307 switch lower {
308308 case "cr", "return":
309- return "CR", true
309+ return symCR, true
310310 case "esc", "escape":
311- return "Esc", true
311+ return symEsc, true
312312 case "space":
313313 return "Space", true
314314 case "lt":
@@ -374,5 +374,16 @@ func symFromAlias(token string) (string, error) {
374374 if sym, ok := symFromSpecialAlias(token); ok {
375375 return sym, nil
376376 }
377+ // One-character literal angle tokens (<e>) disambiguate legacy-atom collisions.
378+ // Multi-character unknowns such as <Nope> remain invalid.
379+ if len(token) == 1 {
380+ ch := token[0]
381+ if ch >= 'a' && ch <= 'z' {
382+ return token, nil
383+ }
384+ if ch >= 'A' && ch <= 'Z' {
385+ return strings.ToLower(token), nil
386+ }
387+ }
377388 return "", ErrBadBinding
378389 }
@@ -30,9 +30,9 @@ func ParseBinding(s string) (Sequence, error) {
30 func tryParseLegacyAtom(s string) (Key, bool) {30 func tryParseLegacyAtom(s string) (Key, bool) {
31 switch strings.ToLower(s) {31 switch strings.ToLower(s) {
32 case "enter", "return":32 case "enter", "return":
33- return Key{Sym: "CR"}, true33+ return Key{Sym: symCR}, true
34 case "escape", "esc":34 case "escape", "esc":
35- return Key{Sym: "Esc"}, true35+ return Key{Sym: symEsc}, true
36 default:36 default:
37 return Key{}, false37 return Key{}, false
38 }38 }
@@ -306,9 +306,9 @@ func symFromSpecialAlias(token string) (string, bool) {
306 lower := strings.ToLower(token)306 lower := strings.ToLower(token)
307 switch lower {307 switch lower {
308 case "cr", "return":308 case "cr", "return":
309- return "CR", true309+ return symCR, true
310 case "esc", "escape":310 case "esc", "escape":
311- return "Esc", true311+ return symEsc, true
312 case "space":312 case "space":
313 return "Space", true313 return "Space", true
314 case "lt":314 case "lt":
@@ -374,5 +374,16 @@ func symFromAlias(token string) (string, error) {
374 if sym, ok := symFromSpecialAlias(token); ok {374 if sym, ok := symFromSpecialAlias(token); ok {
375 return sym, nil375 return sym, nil
376 }376 }
377+ // One-character literal angle tokens (<e>) disambiguate legacy-atom collisions.
378+ // Multi-character unknowns such as <Nope> remain invalid.
379+ if len(token) == 1 {
380+ ch := token[0]
381+ if ch >= 'a' && ch <= 'z' {
382+ return token, nil
383+ }
384+ if ch >= 'A' && ch <= 'Z' {
385+ return strings.ToLower(token), nil
386+ }
387+ }
377 return "", ErrBadBinding388 return "", ErrBadBinding
378 }389 }
M internal/domain/vimkeys/parse_test.go +403 -2
@@ -2,6 +2,7 @@ package vimkeys
22
33 import (
44 "errors"
5+ "strings"
56 "testing"
67 )
78
@@ -116,6 +117,8 @@ func TestParseBinding_CanonicalString(t *testing.T) {
116117 {input: "ctrl+-", want: "<C-->"},
117118 {input: "<C-->", want: "<C-->"},
118119 {input: "<C-d>", want: "<C-d>"},
120+ {input: "<Plus>", want: "<Plus>"},
121+ {input: "+", want: "<Plus>"},
119122 {input: "<CR>", want: "<Return>"},
120123 {input: "<Return>", want: "<Return>"},
121124 {input: "<Esc>", want: "<Escape>"},
@@ -499,6 +502,61 @@ func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) {
499502 }
500503 }
501504
505+func TestParseBinding_CtrlPlusRoundTrip(t *testing.T) {
506+ tests := []struct {
507+ input string
508+ want Sequence
509+ wantString string
510+ }{
511+ {
512+ input: "ctrl++",
513+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
514+ wantString: "<C-+>",
515+ },
516+ {
517+ input: "<C-+>",
518+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
519+ wantString: "<C-+>",
520+ },
521+ {
522+ input: "<C-S-+>",
523+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift}},
524+ wantString: "<C-S-+>",
525+ },
526+ {
527+ input: "<C-A-+>",
528+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModAlt}},
529+ wantString: "<C-A-+>",
530+ },
531+ {
532+ input: "<C-S-A-+>",
533+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift | ModAlt}},
534+ wantString: "<C-S-A-+>",
535+ },
536+ }
537+ for _, tt := range tests {
538+ t.Run(tt.input, func(t *testing.T) {
539+ first, err := ParseBinding(tt.input)
540+ if err != nil {
541+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
542+ }
543+ if !first.Equal(tt.want) {
544+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
545+ }
546+ if got := first.String(); got != tt.wantString {
547+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
548+ }
549+ second, err := ParseBinding(first.String())
550+ if err != nil {
551+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
552+ }
553+ if !first.Equal(second) {
554+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
555+ }
556+ })
557+ }
558+}
559+
502560 func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
503561 input := "<C-S-a>"
504562 first, err := ParseBinding(input)
@@ -629,6 +687,8 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
629687 {{Sym: ">"}},
630688 {{Sym: ">", Mods: ModCtrl}},
631689 {{Sym: "+"}},
690+ {{Sym: "+", Mods: ModCtrl}},
691+ {{Sym: "+", Mods: ModCtrl | ModShift}},
632692 {{Sym: "-", Mods: ModCtrl}},
633693 {{Sym: "-", Mods: ModCtrl | ModShift}},
634694 {{Sym: "-", Mods: ModCtrl | ModAlt}},
@@ -680,6 +740,341 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
680740 }
681741 }
682742
743+// TestParseBinding_LegacyAtomCollisionRoundTrip ensures rune sequences whose
744+// naive Key.String concatenation would equal a legacy atom are emitted with
745+// enough angle-literal tokens to round-trip without becoming CR/Esc.
746+func TestParseBinding_LegacyAtomCollisionRoundTrip(t *testing.T) {
747+ tests := []struct {
748+ name string
749+ seq Sequence
750+ wantString string
751+ }{
752+ {
753+ name: "Shift+e n t e r",
754+ seq: Sequence{
755+ {Sym: "e", Mods: ModShift},
756+ {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
757+ },
758+ wantString: "<S-e>nter",
759+ },
760+ {
761+ name: "e n t e r",
762+ seq: Sequence{
763+ {Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
764+ },
765+ wantString: "<e>nter",
766+ },
767+ {
768+ name: "Shift+r e t u r n",
769+ seq: Sequence{
770+ {Sym: "r", Mods: ModShift},
771+ {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
772+ },
773+ wantString: "<S-r>eturn",
774+ },
775+ {
776+ name: "r e t u r n",
777+ seq: Sequence{
778+ {Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
779+ },
780+ wantString: "<r>eturn",
781+ },
782+ {
783+ name: "Shift+e s c",
784+ seq: Sequence{
785+ {Sym: "e", Mods: ModShift},
786+ {Sym: "s"}, {Sym: "c"},
787+ },
788+ wantString: "<S-e>sc",
789+ },
790+ {
791+ name: "e s c",
792+ seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
793+ wantString: "<e>sc",
794+ },
795+ {
796+ name: "Shift+e s c a p e",
797+ seq: Sequence{
798+ {Sym: "e", Mods: ModShift},
799+ {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
800+ },
801+ wantString: "<S-e>scape",
802+ },
803+ {
804+ name: "e s c a p e",
805+ seq: Sequence{
806+ {Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
807+ },
808+ wantString: "<e>scape",
809+ },
810+ }
811+ for _, tt := range tests {
812+ t.Run(tt.name, func(t *testing.T) {
813+ if got := tt.seq.String(); got != tt.wantString {
814+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
815+ }
816+ got, err := ParseBinding(tt.wantString)
817+ if err != nil {
818+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
819+ }
820+ if !got.Equal(tt.seq) {
821+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
822+ }
823+ if again := got.String(); again != tt.wantString {
824+ t.Fatalf("canonical unstable %q -> %q", tt.wantString, again)
825+ }
826+ })
827+ }
828+}
829+
830+// TestParseBinding_LegacyChordCollisionRoundTrip ensures a rune sequence that
831+// would spell a legacy chord via a literal '+' is emitted with <Plus> so it
832+// cannot reparse as that chord.
833+func TestParseBinding_LegacyChordCollisionRoundTrip(t *testing.T) {
834+ tests := []struct {
835+ name string
836+ seq Sequence
837+ wantString string
838+ }{
839+ {
840+ name: "ctrl + d runes",
841+ seq: Sequence{
842+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
843+ {Sym: "+"},
844+ {Sym: "d"},
845+ },
846+ wantString: "ctrl<Plus>d",
847+ },
848+ {
849+ name: "Ctrl + d mixed case start",
850+ seq: Sequence{
851+ {Sym: "c", Mods: ModShift},
852+ {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
853+ {Sym: "+"},
854+ {Sym: "d"},
855+ },
856+ wantString: "Ctrl<Plus>d",
857+ },
858+ {
859+ name: "control + j runes",
860+ seq: Sequence{
861+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
862+ {Sym: "+"},
863+ {Sym: "j"},
864+ },
865+ wantString: "control<Plus>j",
866+ },
867+ {
868+ name: "shift + j runes",
869+ seq: Sequence{
870+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
871+ {Sym: "+"},
872+ {Sym: "j"},
873+ },
874+ wantString: "shift<Plus>j",
875+ },
876+ {
877+ name: "alt + k runes",
878+ seq: Sequence{
879+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"},
880+ {Sym: "+"},
881+ {Sym: "k"},
882+ },
883+ wantString: "alt<Plus>k",
884+ },
885+ }
886+ for _, tt := range tests {
887+ t.Run(tt.name, func(t *testing.T) {
888+ if got := tt.seq.String(); got != tt.wantString {
889+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
890+ }
891+ got, err := ParseBinding(tt.wantString)
892+ if err != nil {
893+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
894+ }
895+ if !got.Equal(tt.seq) {
896+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
897+ }
898+ })
899+ }
900+}
901+
902+// TestParseBinding_LegacyAtomsAndChordsStillAccepted preserves exact raw
903+// legacy config inputs as named atoms / chords.
904+func TestParseBinding_LegacyAtomsAndChordsStillAccepted(t *testing.T) {
905+ tests := []struct {
906+ input string
907+ want Sequence
908+ }{
909+ {input: "enter", want: Sequence{{Sym: "CR"}}},
910+ {input: "return", want: Sequence{{Sym: "CR"}}},
911+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
912+ {input: "Return", want: Sequence{{Sym: "CR"}}},
913+ {input: "ENTER", want: Sequence{{Sym: "CR"}}},
914+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
915+ {input: "esc", want: Sequence{{Sym: "Esc"}}},
916+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
917+ {input: "Esc", want: Sequence{{Sym: "Esc"}}},
918+ {input: "ESC", want: Sequence{{Sym: "Esc"}}},
919+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
920+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
921+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
922+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
923+ }
924+ for _, tt := range tests {
925+ t.Run(tt.input, func(t *testing.T) {
926+ got, err := ParseBinding(tt.input)
927+ if err != nil {
928+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
929+ }
930+ if !got.Equal(tt.want) {
931+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
932+ }
933+ })
934+ }
935+}
936+
937+// TestParseBinding_OneCharAngleLiteral accepts <e>/<S-e> while rejecting <Nope>.
938+func TestParseBinding_OneCharAngleLiteral(t *testing.T) {
939+ got, err := ParseBinding("<e>")
940+ if err != nil {
941+ t.Fatalf("ParseBinding(<e>) error = %v", err)
942+ }
943+ if want := (Sequence{{Sym: "e"}}); !got.Equal(want) {
944+ t.Fatalf("ParseBinding(<e>) = %v, want %v", got, want)
945+ }
946+ got, err = ParseBinding("<S-e>")
947+ if err != nil {
948+ t.Fatalf("ParseBinding(<S-e>) error = %v", err)
949+ }
950+ if want := (Sequence{{Sym: "e", Mods: ModShift}}); !got.Equal(want) {
951+ t.Fatalf("ParseBinding(<S-e>) = %v, want %v", got, want)
952+ }
953+ if _, err := ParseBinding("<Nope>"); !errors.Is(err, ErrBadBinding) {
954+ t.Fatalf("ParseBinding(<Nope>) error = %v, want %v", err, ErrBadBinding)
955+ }
956+}
957+
958+// TestParseBinding_LegacyCollisionModel systematically covers every exact
959+// legacy atom alias, modifier+plus chord spellings, case variants, and
960+// adjacent non-colliding sequences while retaining round-trip.
961+func TestParseBinding_LegacyCollisionModel(t *testing.T) {
962+ type atomCase struct {
963+ alias string
964+ seq Sequence
965+ }
966+ atoms := []atomCase{
967+ {alias: "enter", seq: Sequence{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
968+ {alias: "Enter", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
969+ {alias: "ENTER", seq: Sequence{
970+ {Sym: "e", Mods: ModShift}, {Sym: "n", Mods: ModShift}, {Sym: "t", Mods: ModShift},
971+ {Sym: "e", Mods: ModShift}, {Sym: "r", Mods: ModShift},
972+ }},
973+ {alias: "return", seq: Sequence{{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
974+ {alias: "Return", seq: Sequence{{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
975+ {alias: "escape", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
976+ {alias: "Escape", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
977+ {alias: "esc", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}}},
978+ {alias: "Esc", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}}},
979+ {alias: "ESC", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s", Mods: ModShift}, {Sym: "c", Mods: ModShift}}},
980+ }
981+ for _, tt := range atoms {
982+ t.Run("atom/"+tt.alias, func(t *testing.T) {
983+ canonical := tt.seq.String()
984+ if strings.EqualFold(canonical, tt.alias) {
985+ t.Fatalf("Sequence.String() still collides with legacy atom %q", tt.alias)
986+ }
987+ got, err := ParseBinding(canonical)
988+ if err != nil {
989+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
990+ }
991+ if !got.Equal(tt.seq) {
992+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
993+ }
994+ raw, err := ParseBinding(tt.alias)
995+ if err != nil {
996+ t.Fatalf("ParseBinding(%q) error = %v", tt.alias, err)
997+ }
998+ if raw.Equal(tt.seq) {
999+ t.Fatalf("legacy atom %q must not equal rune sequence", tt.alias)
1000+ }
1001+ })
1002+ }
1003+
1004+ chordSpellings := []struct {
1005+ legacy string
1006+ seq Sequence
1007+ }{
1008+ {
1009+ legacy: "ctrl+d",
1010+ seq: Sequence{
1011+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"},
1012+ },
1013+ },
1014+ {
1015+ legacy: "control+j",
1016+ seq: Sequence{
1017+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
1018+ {Sym: "+"}, {Sym: "j"},
1019+ },
1020+ },
1021+ {
1022+ legacy: "shift+a",
1023+ seq: Sequence{
1024+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
1025+ {Sym: "+"}, {Sym: "a"},
1026+ },
1027+ },
1028+ {
1029+ legacy: "alt+b",
1030+ seq: Sequence{
1031+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"}, {Sym: "+"}, {Sym: "b"},
1032+ },
1033+ },
1034+ }
1035+ for _, tt := range chordSpellings {
1036+ t.Run("chord/"+tt.legacy, func(t *testing.T) {
1037+ canonical := tt.seq.String()
1038+ if canonical == tt.legacy {
1039+ t.Fatalf("Sequence.String() still equals legacy chord %q", tt.legacy)
1040+ }
1041+ got, err := ParseBinding(canonical)
1042+ if err != nil {
1043+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1044+ }
1045+ if !got.Equal(tt.seq) {
1046+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1047+ }
1048+ chord, err := ParseBinding(tt.legacy)
1049+ if err != nil {
1050+ t.Fatalf("ParseBinding(%q) error = %v", tt.legacy, err)
1051+ }
1052+ if chord.Equal(tt.seq) {
1053+ t.Fatalf("legacy chord %q must not equal rune sequence", tt.legacy)
1054+ }
1055+ })
1056+ }
1057+
1058+ // Adjacent sequences that share prefixes but are not exact atoms.
1059+ adjacent := []Sequence{
1060+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}, {Sym: "j"}},
1061+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1062+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}, {Sym: "j"}},
1063+ }
1064+ for _, seq := range adjacent {
1065+ canonical := seq.String()
1066+ t.Run("adjacent/"+canonical, func(t *testing.T) {
1067+ got, err := ParseBinding(canonical)
1068+ if err != nil {
1069+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1070+ }
1071+ if !got.Equal(seq) {
1072+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1073+ }
1074+ })
1075+ }
1076+}
1077+
6831078 // TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key
6841079 // surface that Sequence.String can emit: letters × modifier combos, printable
6851080 // ASCII punctuation in runtime-canonical modifier states, named symbols ×
@@ -727,8 +1122,8 @@ func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
7271122 }
7281123 }
7291124
730- // Representative adjacent pairs that can spell modifier/named prefixes.
731- // Trailing letters avoid exact legacy atom collisions (enter/escape/…).
1125+ // Representative adjacent pairs that can spell modifier/named prefixes,
1126+ // including exact legacy atom/chord collision spellings.
7321127 adjacentPairs := []Sequence{
7331128 {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},
7341129 {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},
@@ -738,6 +1133,12 @@ func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
7381133 {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
7391134 {{Sym: "t", Mods: ModShift}, {Sym: "a"}, {Sym: "b"}, {Sym: "x"}},
7401135 {{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}, {Sym: "x"}},
1136+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1137+ {{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1138+ {{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
1139+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}},
1140+ {{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}},
1141+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}},
7411142 {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
7421143 {{Sym: ">"}, {Sym: "j"}},
7431144 {{Sym: "<"}, {Sym: "j"}},
@@ -2,6 +2,7 @@ package vimkeys
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "strings"
5 "testing"6 "testing"
6 )7 )
7 8
@@ -116,6 +117,8 @@ func TestParseBinding_CanonicalString(t *testing.T) {
116 {input: "ctrl+-", want: "<C-->"},117 {input: "ctrl+-", want: "<C-->"},
117 {input: "<C-->", want: "<C-->"},118 {input: "<C-->", want: "<C-->"},
118 {input: "<C-d>", want: "<C-d>"},119 {input: "<C-d>", want: "<C-d>"},
120+ {input: "<Plus>", want: "<Plus>"},
121+ {input: "+", want: "<Plus>"},
119 {input: "<CR>", want: "<Return>"},122 {input: "<CR>", want: "<Return>"},
120 {input: "<Return>", want: "<Return>"},123 {input: "<Return>", want: "<Return>"},
121 {input: "<Esc>", want: "<Escape>"},124 {input: "<Esc>", want: "<Escape>"},
@@ -499,6 +502,61 @@ func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) {
499 }502 }
500 }503 }
501 504
505+func TestParseBinding_CtrlPlusRoundTrip(t *testing.T) {
506+ tests := []struct {
507+ input string
508+ want Sequence
509+ wantString string
510+ }{
511+ {
512+ input: "ctrl++",
513+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
514+ wantString: "<C-+>",
515+ },
516+ {
517+ input: "<C-+>",
518+ want: Sequence{{Sym: "+", Mods: ModCtrl}},
519+ wantString: "<C-+>",
520+ },
521+ {
522+ input: "<C-S-+>",
523+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift}},
524+ wantString: "<C-S-+>",
525+ },
526+ {
527+ input: "<C-A-+>",
528+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModAlt}},
529+ wantString: "<C-A-+>",
530+ },
531+ {
532+ input: "<C-S-A-+>",
533+ want: Sequence{{Sym: "+", Mods: ModCtrl | ModShift | ModAlt}},
534+ wantString: "<C-S-A-+>",
535+ },
536+ }
537+ for _, tt := range tests {
538+ t.Run(tt.input, func(t *testing.T) {
539+ first, err := ParseBinding(tt.input)
540+ if err != nil {
541+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
542+ }
543+ if !first.Equal(tt.want) {
544+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
545+ }
546+ if got := first.String(); got != tt.wantString {
547+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
548+ }
549+ second, err := ParseBinding(first.String())
550+ if err != nil {
551+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
552+ }
553+ if !first.Equal(second) {
554+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
555+ }
556+ })
557+ }
558+}
559+
502 func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {560 func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
503 input := "<C-S-a>"561 input := "<C-S-a>"
504 first, err := ParseBinding(input)562 first, err := ParseBinding(input)
@@ -629,6 +687,8 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
629 {{Sym: ">"}},687 {{Sym: ">"}},
630 {{Sym: ">", Mods: ModCtrl}},688 {{Sym: ">", Mods: ModCtrl}},
631 {{Sym: "+"}},689 {{Sym: "+"}},
690+ {{Sym: "+", Mods: ModCtrl}},
691+ {{Sym: "+", Mods: ModCtrl | ModShift}},
632 {{Sym: "-", Mods: ModCtrl}},692 {{Sym: "-", Mods: ModCtrl}},
633 {{Sym: "-", Mods: ModCtrl | ModShift}},693 {{Sym: "-", Mods: ModCtrl | ModShift}},
634 {{Sym: "-", Mods: ModCtrl | ModAlt}},694 {{Sym: "-", Mods: ModCtrl | ModAlt}},
@@ -680,6 +740,341 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
680 }740 }
681 }741 }
682 742
743+// TestParseBinding_LegacyAtomCollisionRoundTrip ensures rune sequences whose
744+// naive Key.String concatenation would equal a legacy atom are emitted with
745+// enough angle-literal tokens to round-trip without becoming CR/Esc.
746+func TestParseBinding_LegacyAtomCollisionRoundTrip(t *testing.T) {
747+ tests := []struct {
748+ name string
749+ seq Sequence
750+ wantString string
751+ }{
752+ {
753+ name: "Shift+e n t e r",
754+ seq: Sequence{
755+ {Sym: "e", Mods: ModShift},
756+ {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
757+ },
758+ wantString: "<S-e>nter",
759+ },
760+ {
761+ name: "e n t e r",
762+ seq: Sequence{
763+ {Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
764+ },
765+ wantString: "<e>nter",
766+ },
767+ {
768+ name: "Shift+r e t u r n",
769+ seq: Sequence{
770+ {Sym: "r", Mods: ModShift},
771+ {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
772+ },
773+ wantString: "<S-r>eturn",
774+ },
775+ {
776+ name: "r e t u r n",
777+ seq: Sequence{
778+ {Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
779+ },
780+ wantString: "<r>eturn",
781+ },
782+ {
783+ name: "Shift+e s c",
784+ seq: Sequence{
785+ {Sym: "e", Mods: ModShift},
786+ {Sym: "s"}, {Sym: "c"},
787+ },
788+ wantString: "<S-e>sc",
789+ },
790+ {
791+ name: "e s c",
792+ seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
793+ wantString: "<e>sc",
794+ },
795+ {
796+ name: "Shift+e s c a p e",
797+ seq: Sequence{
798+ {Sym: "e", Mods: ModShift},
799+ {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
800+ },
801+ wantString: "<S-e>scape",
802+ },
803+ {
804+ name: "e s c a p e",
805+ seq: Sequence{
806+ {Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
807+ },
808+ wantString: "<e>scape",
809+ },
810+ }
811+ for _, tt := range tests {
812+ t.Run(tt.name, func(t *testing.T) {
813+ if got := tt.seq.String(); got != tt.wantString {
814+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
815+ }
816+ got, err := ParseBinding(tt.wantString)
817+ if err != nil {
818+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
819+ }
820+ if !got.Equal(tt.seq) {
821+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
822+ }
823+ if again := got.String(); again != tt.wantString {
824+ t.Fatalf("canonical unstable %q -> %q", tt.wantString, again)
825+ }
826+ })
827+ }
828+}
829+
830+// TestParseBinding_LegacyChordCollisionRoundTrip ensures a rune sequence that
831+// would spell a legacy chord via a literal '+' is emitted with <Plus> so it
832+// cannot reparse as that chord.
833+func TestParseBinding_LegacyChordCollisionRoundTrip(t *testing.T) {
834+ tests := []struct {
835+ name string
836+ seq Sequence
837+ wantString string
838+ }{
839+ {
840+ name: "ctrl + d runes",
841+ seq: Sequence{
842+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
843+ {Sym: "+"},
844+ {Sym: "d"},
845+ },
846+ wantString: "ctrl<Plus>d",
847+ },
848+ {
849+ name: "Ctrl + d mixed case start",
850+ seq: Sequence{
851+ {Sym: "c", Mods: ModShift},
852+ {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
853+ {Sym: "+"},
854+ {Sym: "d"},
855+ },
856+ wantString: "Ctrl<Plus>d",
857+ },
858+ {
859+ name: "control + j runes",
860+ seq: Sequence{
861+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
862+ {Sym: "+"},
863+ {Sym: "j"},
864+ },
865+ wantString: "control<Plus>j",
866+ },
867+ {
868+ name: "shift + j runes",
869+ seq: Sequence{
870+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
871+ {Sym: "+"},
872+ {Sym: "j"},
873+ },
874+ wantString: "shift<Plus>j",
875+ },
876+ {
877+ name: "alt + k runes",
878+ seq: Sequence{
879+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"},
880+ {Sym: "+"},
881+ {Sym: "k"},
882+ },
883+ wantString: "alt<Plus>k",
884+ },
885+ }
886+ for _, tt := range tests {
887+ t.Run(tt.name, func(t *testing.T) {
888+ if got := tt.seq.String(); got != tt.wantString {
889+ t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
890+ }
891+ got, err := ParseBinding(tt.wantString)
892+ if err != nil {
893+ t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
894+ }
895+ if !got.Equal(tt.seq) {
896+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
897+ }
898+ })
899+ }
900+}
901+
902+// TestParseBinding_LegacyAtomsAndChordsStillAccepted preserves exact raw
903+// legacy config inputs as named atoms / chords.
904+func TestParseBinding_LegacyAtomsAndChordsStillAccepted(t *testing.T) {
905+ tests := []struct {
906+ input string
907+ want Sequence
908+ }{
909+ {input: "enter", want: Sequence{{Sym: "CR"}}},
910+ {input: "return", want: Sequence{{Sym: "CR"}}},
911+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
912+ {input: "Return", want: Sequence{{Sym: "CR"}}},
913+ {input: "ENTER", want: Sequence{{Sym: "CR"}}},
914+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
915+ {input: "esc", want: Sequence{{Sym: "Esc"}}},
916+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
917+ {input: "Esc", want: Sequence{{Sym: "Esc"}}},
918+ {input: "ESC", want: Sequence{{Sym: "Esc"}}},
919+ {input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
920+ {input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
921+ {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
922+ {input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
923+ }
924+ for _, tt := range tests {
925+ t.Run(tt.input, func(t *testing.T) {
926+ got, err := ParseBinding(tt.input)
927+ if err != nil {
928+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
929+ }
930+ if !got.Equal(tt.want) {
931+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
932+ }
933+ })
934+ }
935+}
936+
937+// TestParseBinding_OneCharAngleLiteral accepts <e>/<S-e> while rejecting <Nope>.
938+func TestParseBinding_OneCharAngleLiteral(t *testing.T) {
939+ got, err := ParseBinding("<e>")
940+ if err != nil {
941+ t.Fatalf("ParseBinding(<e>) error = %v", err)
942+ }
943+ if want := (Sequence{{Sym: "e"}}); !got.Equal(want) {
944+ t.Fatalf("ParseBinding(<e>) = %v, want %v", got, want)
945+ }
946+ got, err = ParseBinding("<S-e>")
947+ if err != nil {
948+ t.Fatalf("ParseBinding(<S-e>) error = %v", err)
949+ }
950+ if want := (Sequence{{Sym: "e", Mods: ModShift}}); !got.Equal(want) {
951+ t.Fatalf("ParseBinding(<S-e>) = %v, want %v", got, want)
952+ }
953+ if _, err := ParseBinding("<Nope>"); !errors.Is(err, ErrBadBinding) {
954+ t.Fatalf("ParseBinding(<Nope>) error = %v, want %v", err, ErrBadBinding)
955+ }
956+}
957+
958+// TestParseBinding_LegacyCollisionModel systematically covers every exact
959+// legacy atom alias, modifier+plus chord spellings, case variants, and
960+// adjacent non-colliding sequences while retaining round-trip.
961+func TestParseBinding_LegacyCollisionModel(t *testing.T) {
962+ type atomCase struct {
963+ alias string
964+ seq Sequence
965+ }
966+ atoms := []atomCase{
967+ {alias: "enter", seq: Sequence{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
968+ {alias: "Enter", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
969+ {alias: "ENTER", seq: Sequence{
970+ {Sym: "e", Mods: ModShift}, {Sym: "n", Mods: ModShift}, {Sym: "t", Mods: ModShift},
971+ {Sym: "e", Mods: ModShift}, {Sym: "r", Mods: ModShift},
972+ }},
973+ {alias: "return", seq: Sequence{{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
974+ {alias: "Return", seq: Sequence{{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
975+ {alias: "escape", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
976+ {alias: "Escape", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
977+ {alias: "esc", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}}},
978+ {alias: "Esc", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}}},
979+ {alias: "ESC", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s", Mods: ModShift}, {Sym: "c", Mods: ModShift}}},
980+ }
981+ for _, tt := range atoms {
982+ t.Run("atom/"+tt.alias, func(t *testing.T) {
983+ canonical := tt.seq.String()
984+ if strings.EqualFold(canonical, tt.alias) {
985+ t.Fatalf("Sequence.String() still collides with legacy atom %q", tt.alias)
986+ }
987+ got, err := ParseBinding(canonical)
988+ if err != nil {
989+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
990+ }
991+ if !got.Equal(tt.seq) {
992+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
993+ }
994+ raw, err := ParseBinding(tt.alias)
995+ if err != nil {
996+ t.Fatalf("ParseBinding(%q) error = %v", tt.alias, err)
997+ }
998+ if raw.Equal(tt.seq) {
999+ t.Fatalf("legacy atom %q must not equal rune sequence", tt.alias)
1000+ }
1001+ })
1002+ }
1003+
1004+ chordSpellings := []struct {
1005+ legacy string
1006+ seq Sequence
1007+ }{
1008+ {
1009+ legacy: "ctrl+d",
1010+ seq: Sequence{
1011+ {Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"},
1012+ },
1013+ },
1014+ {
1015+ legacy: "control+j",
1016+ seq: Sequence{
1017+ {Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
1018+ {Sym: "+"}, {Sym: "j"},
1019+ },
1020+ },
1021+ {
1022+ legacy: "shift+a",
1023+ seq: Sequence{
1024+ {Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
1025+ {Sym: "+"}, {Sym: "a"},
1026+ },
1027+ },
1028+ {
1029+ legacy: "alt+b",
1030+ seq: Sequence{
1031+ {Sym: "a"}, {Sym: "l"}, {Sym: "t"}, {Sym: "+"}, {Sym: "b"},
1032+ },
1033+ },
1034+ }
1035+ for _, tt := range chordSpellings {
1036+ t.Run("chord/"+tt.legacy, func(t *testing.T) {
1037+ canonical := tt.seq.String()
1038+ if canonical == tt.legacy {
1039+ t.Fatalf("Sequence.String() still equals legacy chord %q", tt.legacy)
1040+ }
1041+ got, err := ParseBinding(canonical)
1042+ if err != nil {
1043+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1044+ }
1045+ if !got.Equal(tt.seq) {
1046+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1047+ }
1048+ chord, err := ParseBinding(tt.legacy)
1049+ if err != nil {
1050+ t.Fatalf("ParseBinding(%q) error = %v", tt.legacy, err)
1051+ }
1052+ if chord.Equal(tt.seq) {
1053+ t.Fatalf("legacy chord %q must not equal rune sequence", tt.legacy)
1054+ }
1055+ })
1056+ }
1057+
1058+ // Adjacent sequences that share prefixes but are not exact atoms.
1059+ adjacent := []Sequence{
1060+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}, {Sym: "j"}},
1061+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1062+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}, {Sym: "j"}},
1063+ }
1064+ for _, seq := range adjacent {
1065+ canonical := seq.String()
1066+ t.Run("adjacent/"+canonical, func(t *testing.T) {
1067+ got, err := ParseBinding(canonical)
1068+ if err != nil {
1069+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1070+ }
1071+ if !got.Equal(seq) {
1072+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1073+ }
1074+ })
1075+ }
1076+}
1077+
683 // TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key1078 // TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key
684 // surface that Sequence.String can emit: letters × modifier combos, printable1079 // surface that Sequence.String can emit: letters × modifier combos, printable
685 // ASCII punctuation in runtime-canonical modifier states, named symbols ×1080 // ASCII punctuation in runtime-canonical modifier states, named symbols ×
@@ -727,8 +1122,8 @@ func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
727 }1122 }
728 }1123 }
729 1124
730- // Representative adjacent pairs that can spell modifier/named prefixes.1125+ // Representative adjacent pairs that can spell modifier/named prefixes,
731- // Trailing letters avoid exact legacy atom collisions (enter/escape/…).1126+ // including exact legacy atom/chord collision spellings.
732 adjacentPairs := []Sequence{1127 adjacentPairs := []Sequence{
733 {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},1128 {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},
734 {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},1129 {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},
@@ -738,6 +1133,12 @@ func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
738 {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},1133 {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
739 {{Sym: "t", Mods: ModShift}, {Sym: "a"}, {Sym: "b"}, {Sym: "x"}},1134 {{Sym: "t", Mods: ModShift}, {Sym: "a"}, {Sym: "b"}, {Sym: "x"}},
740 {{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}, {Sym: "x"}},1135 {{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}, {Sym: "x"}},
1136+ {{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1137+ {{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1138+ {{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
1139+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}},
1140+ {{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}},
1141+ {{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}},
741 {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},1142 {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
742 {{Sym: ">"}, {Sym: "j"}},1143 {{Sym: ">"}, {Sym: "j"}},
743 {{Sym: "<"}, {Sym: "j"}},1144 {{Sym: "<"}, {Sym: "j"}},

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close