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 ( | |||
| 14 | 14 | ModAlt | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | const ( | |
| 18 | + | symCR = "CR" | |
| 19 | + | symEsc = "Esc" | |
| 20 | + | ) | |
| 21 | + | ||
| 17 | 22 | // Key is a single key event with symbol and modifiers. | |
| 18 | 23 | type Key struct { | |
| 19 | 24 | Sym string | |
| @@ -41,6 +46,9 @@ func (k Key) String() string { | |||
| 41 | 46 | } | |
| 42 | 47 | ||
| 43 | 48 | if k.Mods == 0 { | |
| 49 | + | if k.Sym == "+" { | |
| 50 | + | return "<Plus>" | |
| 51 | + | } | |
| 44 | 52 | if alias := angleAliasForSym(k.Sym); alias != "" { | |
| 45 | 53 | return "<" + alias + ">" | |
| 46 | 54 | } | |
| @@ -51,14 +59,55 @@ func (k Key) String() string { | |||
| 51 | 59 | } | |
| 52 | 60 | ||
| 53 | 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 | 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 | 74 | 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 | 80 | b.WriteString(key.String()) | |
| 58 | 81 | } | |
| 59 | 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 | 111 | // Equal reports whether two sequences contain the same keys. | |
| 63 | 112 | func (s Sequence) Equal(other Sequence) bool { | |
| 64 | 113 | if len(s) != len(other) { | |
| @@ -74,9 +123,9 @@ func (s Sequence) Equal(other Sequence) bool { | |||
| 74 | 123 | ||
| 75 | 124 | func angleAliasForSym(sym string) string { | |
| 76 | 125 | switch sym { | |
| 77 | - | case "CR": | |
| 126 | + | case symCR: | |
| 78 | 127 | return "Return" | |
| 79 | - | case "Esc": | |
| 128 | + | case symEsc: | |
| 80 | 129 | return "Escape" | |
| 81 | 130 | case "Space", "Tab", "BackSpace", "Delete", | |
| 82 | 131 | "Left", "Right", "Up", "Down", | |
| @@ -14,6 +14,11 @@ const ( | |||||
| 14 | ModAlt | 14 | 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 string | 24 | 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.Builder | 74 | 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) { | |||
| 14 | 14 | {name: "shift letter", key: Key{Sym: "j", Mods: ModShift}, want: "J"}, | |
| 15 | 15 | {name: "ctrl letter", key: Key{Sym: "d", Mods: ModCtrl}, want: "<C-d>"}, | |
| 16 | 16 | {name: "ctrl plus", key: Key{Sym: "+", Mods: ModCtrl}, want: "<C-+>"}, | |
| 17 | + | {name: "bare plus", key: Key{Sym: "+"}, want: "<Plus>"}, | |
| 17 | 18 | {name: "shift glyph", key: Key{Sym: "!"}, want: "!"}, | |
| 18 | 19 | {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"}, | |
| 19 | 20 | {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) { | |||
| 30 | 30 | func tryParseLegacyAtom(s string) (Key, bool) { | |
| 31 | 31 | switch strings.ToLower(s) { | |
| 32 | 32 | case "enter", "return": | |
| 33 | - | return Key{Sym: "CR"}, true | |
| 33 | + | return Key{Sym: symCR}, true | |
| 34 | 34 | case "escape", "esc": | |
| 35 | - | return Key{Sym: "Esc"}, true | |
| 35 | + | return Key{Sym: symEsc}, true | |
| 36 | 36 | default: | |
| 37 | 37 | return Key{}, false | |
| 38 | 38 | } | |
| @@ -306,9 +306,9 @@ func symFromSpecialAlias(token string) (string, bool) { | |||
| 306 | 306 | lower := strings.ToLower(token) | |
| 307 | 307 | switch lower { | |
| 308 | 308 | case "cr", "return": | |
| 309 | - | return "CR", true | |
| 309 | + | return symCR, true | |
| 310 | 310 | case "esc", "escape": | |
| 311 | - | return "Esc", true | |
| 311 | + | return symEsc, true | |
| 312 | 312 | case "space": | |
| 313 | 313 | return "Space", true | |
| 314 | 314 | case "lt": | |
| @@ -374,5 +374,16 @@ func symFromAlias(token string) (string, error) { | |||
| 374 | 374 | if sym, ok := symFromSpecialAlias(token); ok { | |
| 375 | 375 | 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 | 388 | return "", ErrBadBinding | |
| 378 | 389 | } | |
| @@ -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"}, true | 33 | + | return Key{Sym: symCR}, true |
| 34 | case "escape", "esc": | 34 | case "escape", "esc": | ||
| 35 | - | return Key{Sym: "Esc"}, true | 35 | + | return Key{Sym: symEsc}, true |
| 36 | default: | 36 | default: | ||
| 37 | return Key{}, false | 37 | 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", true | 309 | + | return symCR, true |
| 310 | case "esc", "escape": | 310 | case "esc", "escape": | ||
| 311 | - | return "Esc", true | 311 | + | return symEsc, true |
| 312 | case "space": | 312 | case "space": | ||
| 313 | return "Space", true | 313 | 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, nil | 375 | 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 "", ErrBadBinding | 388 | return "", ErrBadBinding | ||
| 378 | } | 389 | } | ||
M internal/domain/vimkeys/parse_test.go +403 -2
| @@ -2,6 +2,7 @@ package vimkeys | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | + | "strings" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -116,6 +117,8 @@ func TestParseBinding_CanonicalString(t *testing.T) { | |||
| 116 | 117 | {input: "ctrl+-", want: "<C-->"}, | |
| 117 | 118 | {input: "<C-->", want: "<C-->"}, | |
| 118 | 119 | {input: "<C-d>", want: "<C-d>"}, | |
| 120 | + | {input: "<Plus>", want: "<Plus>"}, | |
| 121 | + | {input: "+", want: "<Plus>"}, | |
| 119 | 122 | {input: "<CR>", want: "<Return>"}, | |
| 120 | 123 | {input: "<Return>", want: "<Return>"}, | |
| 121 | 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 | 560 | func TestParseBinding_MultiModifierRoundTrip(t *testing.T) { | |
| 503 | 561 | input := "<C-S-a>" | |
| 504 | 562 | first, err := ParseBinding(input) | |
| @@ -629,6 +687,8 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) { | |||
| 629 | 687 | {{Sym: ">"}}, | |
| 630 | 688 | {{Sym: ">", Mods: ModCtrl}}, | |
| 631 | 689 | {{Sym: "+"}}, | |
| 690 | + | {{Sym: "+", Mods: ModCtrl}}, | |
| 691 | + | {{Sym: "+", Mods: ModCtrl | ModShift}}, | |
| 632 | 692 | {{Sym: "-", Mods: ModCtrl}}, | |
| 633 | 693 | {{Sym: "-", Mods: ModCtrl | ModShift}}, | |
| 634 | 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 | 1078 | // TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key | |
| 684 | 1079 | // surface that Sequence.String can emit: letters × modifier combos, printable | |
| 685 | 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. | |
| 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. | |
| 732 | 1127 | adjacentPairs := []Sequence{ | |
| 733 | 1128 | {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}}, | |
| 734 | 1129 | {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}}, | |
| @@ -738,6 +1133,12 @@ func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) { | |||
| 738 | 1133 | {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}}, | |
| 739 | 1134 | {{Sym: "t", Mods: ModShift}, {Sym: "a"}, {Sym: "b"}, {Sym: "x"}}, | |
| 740 | 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 | 1142 | {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}}, | |
| 742 | 1143 | {{Sym: ">"}, {Sym: "j"}}, | |
| 743 | 1144 | {{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 Key | 1078 | // TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key | ||
| 684 | // surface that Sequence.String can emit: letters × modifier combos, printable | 1079 | // 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"}}, | ||