detached ffef9a9

fix(vimkeys): make canonical grammar unambiguous

commit
ffef9a916743910a69d9be4499ea97066a6ed664
author
brice · · 2026-07-29 05:42 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
b856ec1
signature
unsigned
5 files · +238 -77
M internal/domain/vimkeys/key.go +2 -0
@@ -84,6 +84,8 @@ func angleAliasForSym(sym string) string {
8484 return sym
8585 case "<":
8686 return "lt"
87+ case ">":
88+ return "gt"
8789 default:
8890 return ""
8991 }
@@ -84,6 +84,8 @@ func angleAliasForSym(sym string) string {
84 return sym84 return sym
85 case "<":85 case "<":
86 return "lt"86 return "lt"
87+ case ">":
88+ return "gt"
87 default:89 default:
88 return ""90 return ""
89 }91 }
M internal/domain/vimkeys/key_test.go +2 -0
@@ -34,6 +34,8 @@ func TestKeyString(t *testing.T) {
3434 {name: "alt Delete", key: Key{Sym: "Delete", Mods: ModAlt}, want: "<A-Delete>"},
3535 {name: "ctrl Left", key: Key{Sym: "Left", Mods: ModCtrl}, want: "<C-Left>"},
3636 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
37+ {name: "literal gt", key: Key{Sym: ">"}, want: "<gt>"},
38+ {name: "ctrl gt", key: Key{Sym: ">", Mods: ModCtrl}, want: "<C-gt>"},
3739 {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
3840 {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
3941 {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
@@ -34,6 +34,8 @@ func TestKeyString(t *testing.T) {
34 {name: "alt Delete", key: Key{Sym: "Delete", Mods: ModAlt}, want: "<A-Delete>"},34 {name: "alt Delete", key: Key{Sym: "Delete", Mods: ModAlt}, want: "<A-Delete>"},
35 {name: "ctrl Left", key: Key{Sym: "Left", Mods: ModCtrl}, want: "<C-Left>"},35 {name: "ctrl Left", key: Key{Sym: "Left", Mods: ModCtrl}, want: "<C-Left>"},
36 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},36 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
37+ {name: "literal gt", key: Key{Sym: ">"}, want: "<gt>"},
38+ {name: "ctrl gt", key: Key{Sym: ">", Mods: ModCtrl}, want: "<C-gt>"},
37 {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},39 {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
38 {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},40 {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
39 {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},41 {name: "ctrl shift", key: Key{Sym: "a", Mods: ModCtrl | ModShift}, want: "<C-S-a>"},
M internal/domain/vimkeys/parse.go +4 -70
@@ -244,15 +244,8 @@ func parseRawSequence(s string) (Sequence, error) {
244244 i += width
245245 continue
246246 }
247- // Named keys (Space, Esc, Tab, …) are canonical only inside <...>.
248- // Bare prefixes must not be greedily consumed so printable runs like
249- // "Spacej" (Shift+s,p,a,c,e,j) round-trip via Sequence.String().
250- if key, width, ok := tryParseCanonicalAt(s, i); ok {
251- seq = append(seq, key)
252- i += width
253- continue
254- }
255-
247+ // Raw text is runes plus <...> tokens (and whole-string legacy atoms/chords
248+ // handled above). Bare C-/S-/A- must not steal printable runs like "C-d".
256249 r, width := utf8.DecodeRuneInString(s[i:])
257250 key, err := keyFromRune(r)
258251 if err != nil {
@@ -284,67 +277,6 @@ func parseAngleAt(s string, i int) (Key, int, error) {
284277 return key, closeIdx - i + 1, nil
285278 }
286279
287-func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
288- if i >= len(s) {
289- return Key{}, 0, false
290- }
291- switch s[i] {
292- case 'C', 'S', 'A':
293- default:
294- return Key{}, 0, false
295- }
296- if i+1 >= len(s) || s[i+1] != '-' {
297- return Key{}, 0, false
298- }
299-
300- end := len(s)
301- nextDash := strings.IndexByte(s[i:], '-')
302- if nextDash >= 0 {
303- nextDash += i
304- if nextDash+1 < len(s) {
305- switch s[nextDash+1] {
306- case 'C', 'S', 'A':
307- end = nextDash
308- }
309- }
310- }
311-
312- token := s[i:end]
313- key, err := parseCanonicalToken(token)
314- if err != nil {
315- return Key{}, 0, false
316- }
317- return key, end - i, true
318-}
319-
320-func parseCanonicalToken(token string) (Key, error) {
321- parts := strings.Split(token, "-")
322- if len(parts) < 2 {
323- return Key{}, ErrBadBinding
324- }
325-
326- var mods Mods
327- for i := 0; i < len(parts)-1; i++ {
328- switch parts[i] {
329- case "C":
330- mods |= ModCtrl
331- case "S":
332- mods |= ModShift
333- case "A":
334- mods |= ModAlt
335- default:
336- return Key{}, ErrBadBinding
337- }
338- }
339-
340- symPart := parts[len(parts)-1]
341- sym, err := symFromToken(symPart, true)
342- if err != nil {
343- return Key{}, err
344- }
345- return Key{Sym: sym, Mods: mods}, nil
346-}
347-
348280 func keyFromRune(r rune) (Key, error) {
349281 if r >= 'A' && r <= 'Z' {
350282 return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
@@ -381,6 +313,8 @@ func symFromSpecialAlias(token string) (string, bool) {
381313 return "Space", true
382314 case "lt":
383315 return "<", true
316+ case "gt":
317+ return ">", true
384318 case "plus":
385319 return "+", true
386320 default:
@@ -244,15 +244,8 @@ func parseRawSequence(s string) (Sequence, error) {
244 i += width244 i += width
245 continue245 continue
246 }246 }
247- // Named keys (Space, Esc, Tab, …) are canonical only inside <...>.247+ // Raw text is runes plus <...> tokens (and whole-string legacy atoms/chords
248- // Bare prefixes must not be greedily consumed so printable runs like248+ // handled above). Bare C-/S-/A- must not steal printable runs like "C-d".
249- // "Spacej" (Shift+s,p,a,c,e,j) round-trip via Sequence.String().
250- if key, width, ok := tryParseCanonicalAt(s, i); ok {
251- seq = append(seq, key)
252- i += width
253- continue
254- }
255-
256 r, width := utf8.DecodeRuneInString(s[i:])249 r, width := utf8.DecodeRuneInString(s[i:])
257 key, err := keyFromRune(r)250 key, err := keyFromRune(r)
258 if err != nil {251 if err != nil {
@@ -284,67 +277,6 @@ func parseAngleAt(s string, i int) (Key, int, error) {
284 return key, closeIdx - i + 1, nil277 return key, closeIdx - i + 1, nil
285 }278 }
286 279
287-func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
288- if i >= len(s) {
289- return Key{}, 0, false
290- }
291- switch s[i] {
292- case 'C', 'S', 'A':
293- default:
294- return Key{}, 0, false
295- }
296- if i+1 >= len(s) || s[i+1] != '-' {
297- return Key{}, 0, false
298- }
299-
300- end := len(s)
301- nextDash := strings.IndexByte(s[i:], '-')
302- if nextDash >= 0 {
303- nextDash += i
304- if nextDash+1 < len(s) {
305- switch s[nextDash+1] {
306- case 'C', 'S', 'A':
307- end = nextDash
308- }
309- }
310- }
311-
312- token := s[i:end]
313- key, err := parseCanonicalToken(token)
314- if err != nil {
315- return Key{}, 0, false
316- }
317- return key, end - i, true
318-}
319-
320-func parseCanonicalToken(token string) (Key, error) {
321- parts := strings.Split(token, "-")
322- if len(parts) < 2 {
323- return Key{}, ErrBadBinding
324- }
325-
326- var mods Mods
327- for i := 0; i < len(parts)-1; i++ {
328- switch parts[i] {
329- case "C":
330- mods |= ModCtrl
331- case "S":
332- mods |= ModShift
333- case "A":
334- mods |= ModAlt
335- default:
336- return Key{}, ErrBadBinding
337- }
338- }
339-
340- symPart := parts[len(parts)-1]
341- sym, err := symFromToken(symPart, true)
342- if err != nil {
343- return Key{}, err
344- }
345- return Key{Sym: sym, Mods: mods}, nil
346-}
347-
348 func keyFromRune(r rune) (Key, error) {280 func keyFromRune(r rune) (Key, error) {
349 if r >= 'A' && r <= 'Z' {281 if r >= 'A' && r <= 'Z' {
350 return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil282 return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
@@ -381,6 +313,8 @@ func symFromSpecialAlias(token string) (string, bool) {
381 return "Space", true313 return "Space", true
382 case "lt":314 case "lt":
383 return "<", true315 return "<", true
316+ case "gt":
317+ return ">", true
384 case "plus":318 case "plus":
385 return "+", true319 return "+", true
386 default:320 default:
M internal/domain/vimkeys/parse_test.go +227 -4
@@ -36,6 +36,8 @@ func TestParseBinding_SingleKeys(t *testing.T) {
3636 {input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},
3737 {input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},
3838 {input: "<lt>", want: Sequence{{Sym: "<"}}},
39+ {input: "<gt>", want: Sequence{{Sym: ">"}}},
40+ {input: "<C-gt>", want: Sequence{{Sym: ">", Mods: ModCtrl}}},
3941 {input: "<Plus>", want: Sequence{{Sym: "+"}}},
4042 {input: "enter", want: Sequence{{Sym: "CR"}}},
4143 {input: "escape", want: Sequence{{Sym: "Esc"}}},
@@ -136,6 +138,10 @@ func TestParseBinding_CanonicalString(t *testing.T) {
136138 {input: "<C-Home>", want: "<C-Home>"},
137139 {input: "<A-PageDown>", want: "<A-PageDown>"},
138140 {input: "<lt>", want: "<lt>"},
141+ {input: "<gt>", want: "<gt>"},
142+ {input: ">", want: "<gt>"},
143+ {input: "ctrl+>", want: "<C-gt>"},
144+ {input: "<C-gt>", want: "<C-gt>"},
139145 {input: "gO", want: "gO"},
140146 }
141147 for _, tt := range tests {
@@ -217,14 +223,36 @@ func TestParseBinding_LooksLikeChordButRaw(t *testing.T) {
217223 }
218224 }
219225
220-func TestParseBinding_CanonicalInlineModifiers(t *testing.T) {
226+func TestParseBinding_BareModifierLikeIsLiteral(t *testing.T) {
227+ // Modifiers are canonical only inside <...>; bare "C-d" is Shift+c, '-', d.
221228 tests := []struct {
222229 input string
223230 want Sequence
224231 }{
225- {input: "C-d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
226- {input: "S-j", want: Sequence{{Sym: "j", Mods: ModShift}}},
227- {input: "A-k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
232+ {
233+ input: "C-d",
234+ want: Sequence{
235+ {Sym: "c", Mods: ModShift},
236+ {Sym: "-"},
237+ {Sym: "d"},
238+ },
239+ },
240+ {
241+ input: "S-j",
242+ want: Sequence{
243+ {Sym: "s", Mods: ModShift},
244+ {Sym: "-"},
245+ {Sym: "j"},
246+ },
247+ },
248+ {
249+ input: "A-k",
250+ want: Sequence{
251+ {Sym: "a", Mods: ModShift},
252+ {Sym: "-"},
253+ {Sym: "k"},
254+ },
255+ },
228256 }
229257 for _, tt := range tests {
230258 t.Run(tt.input, func(t *testing.T) {
@@ -235,10 +263,100 @@ func TestParseBinding_CanonicalInlineModifiers(t *testing.T) {
235263 if !got.Equal(tt.want) {
236264 t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
237265 }
266+ if got := got.String(); got != tt.input {
267+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.input)
268+ }
269+ })
270+ }
271+}
272+
273+func TestParseBinding_CtrlGtRoundTrip(t *testing.T) {
274+ tests := []struct {
275+ input string
276+ want Sequence
277+ wantString string
278+ }{
279+ {
280+ input: "ctrl+>",
281+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
282+ wantString: "<C-gt>",
283+ },
284+ {
285+ input: "<C-gt>",
286+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
287+ wantString: "<C-gt>",
288+ },
289+ {
290+ input: "<S-gt>",
291+ want: Sequence{{Sym: ">", Mods: ModShift}},
292+ wantString: "<S-gt>",
293+ },
294+ {
295+ input: "<A-gt>",
296+ want: Sequence{{Sym: ">", Mods: ModAlt}},
297+ wantString: "<A-gt>",
298+ },
299+ {
300+ input: ">",
301+ want: Sequence{{Sym: ">"}},
302+ wantString: "<gt>",
303+ },
304+ {
305+ input: "<gt>",
306+ want: Sequence{{Sym: ">"}},
307+ wantString: "<gt>",
308+ },
309+ }
310+ for _, tt := range tests {
311+ t.Run(tt.input, func(t *testing.T) {
312+ first, err := ParseBinding(tt.input)
313+ if err != nil {
314+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
315+ }
316+ if !first.Equal(tt.want) {
317+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
318+ }
319+ if got := first.String(); got != tt.wantString {
320+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
321+ }
322+ second, err := ParseBinding(first.String())
323+ if err != nil {
324+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
325+ }
326+ if !first.Equal(second) {
327+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
328+ }
238329 })
239330 }
240331 }
241332
333+func TestParseBinding_ShiftCHyphenDRoundTrip(t *testing.T) {
334+ input := "<S-c>-d"
335+ want := Sequence{
336+ {Sym: "c", Mods: ModShift},
337+ {Sym: "-"},
338+ {Sym: "d"},
339+ }
340+ first, err := ParseBinding(input)
341+ if err != nil {
342+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
343+ }
344+ if !first.Equal(want) {
345+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
346+ }
347+ canonical := first.String()
348+ if canonical != "C-d" {
349+ t.Fatalf("String() = %q, want %q", canonical, "C-d")
350+ }
351+ second, err := ParseBinding(canonical)
352+ if err != nil {
353+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
354+ }
355+ if !first.Equal(second) {
356+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
357+ }
358+}
359+
242360 func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
243361 tests := []struct {
244362 input string
@@ -248,6 +366,7 @@ func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
248366 {input: "<Space>j", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},
249367 {input: "<CR>j", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},
250368 {input: "<lt>j", want: Sequence{{Sym: "<"}, {Sym: "j"}}},
369+ {input: "<gt>j", want: Sequence{{Sym: ">"}, {Sym: "j"}}},
251370 {input: "<Plus>j", want: Sequence{{Sym: "+"}, {Sym: "j"}}},
252371 {input: "j<Tab>", want: Sequence{{Sym: "j"}, {Sym: "Tab"}}},
253372 {input: "<BackSpace>j", want: Sequence{{Sym: "BackSpace"}, {Sym: "j"}}},
@@ -439,9 +558,17 @@ func TestParseBinding_RoundTrip(t *testing.T) {
439558 "<S-Home>",
440559 "<A-PageUp>",
441560 "<lt>",
561+ "<gt>",
562+ ">",
563+ "ctrl+>",
564+ "<C-gt>",
442565 "<C-d>j",
443566 "<Escape>j",
444567 "<C-S-a>",
568+ "<S-c>-d",
569+ "C-d",
570+ "S-j",
571+ "A-k",
445572 "Spacej",
446573 "Escj",
447574 "Tabx",
@@ -499,6 +626,8 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
499626 {{Sym: "BackSpace", Mods: ModShift}},
500627 {{Sym: "Delete", Mods: ModAlt}},
501628 {{Sym: "<"}},
629+ {{Sym: ">"}},
630+ {{Sym: ">", Mods: ModCtrl}},
502631 {{Sym: "+"}},
503632 {{Sym: "-", Mods: ModCtrl}},
504633 {{Sym: "-", Mods: ModCtrl | ModShift}},
@@ -530,9 +659,100 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
530659 {Sym: "b"},
531660 {Sym: "x"},
532661 },
662+ // Bare modifier-like text must stay literal (no C-/S-/A- stealing).
663+ {
664+ {Sym: "c", Mods: ModShift},
665+ {Sym: "-"},
666+ {Sym: "d"},
667+ },
668+ }
669+ for _, seq := range corpus {
670+ canonical := seq.String()
671+ t.Run(canonical, func(t *testing.T) {
672+ got, err := ParseBinding(canonical)
673+ if err != nil {
674+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
675+ }
676+ if !got.Equal(seq) {
677+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
678+ }
679+ })
533680 }
681+}
682+
683+// TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key
684+// surface that Sequence.String can emit: letters × modifier combos, printable
685+// ASCII punctuation in runtime-canonical modifier states, named symbols ×
686+// modifier combos, and adjacent pairs that can spell modifier/named prefixes.
687+func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
688+ allMods := []Mods{
689+ 0,
690+ ModCtrl,
691+ ModShift,
692+ ModAlt,
693+ ModCtrl | ModShift,
694+ ModCtrl | ModAlt,
695+ ModShift | ModAlt,
696+ ModCtrl | ModShift | ModAlt,
697+ }
698+
699+ var corpus []Sequence
700+
701+ for c := 'a'; c <= 'z'; c++ {
702+ sym := string(c)
703+ for _, mods := range allMods {
704+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
705+ }
706+ }
707+
708+ punctuation := []string{
709+ "!", `"`, "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/",
710+ ":", ";", "<", "=", ">", "?", "@", "[", `\`, "]", "^", "_", "`", "{", "|", "}", "~",
711+ }
712+ for _, sym := range punctuation {
713+ for _, mods := range allMods {
714+ // Unmodified punctuation is always legal; modified forms are emitted
715+ // as angle keys and must round-trip.
716+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
717+ }
718+ }
719+
720+ named := []string{
721+ "CR", "Esc", "Space", "Tab", "BackSpace", "Delete",
722+ "Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown",
723+ }
724+ for _, sym := range named {
725+ for _, mods := range allMods {
726+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
727+ }
728+ }
729+
730+ // Representative adjacent pairs that can spell modifier/named prefixes.
731+ // Trailing letters avoid exact legacy atom collisions (enter/escape/…).
732+ adjacentPairs := []Sequence{
733+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},
734+ {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},
735+ {{Sym: "a", Mods: ModShift}, {Sym: "-"}, {Sym: "k"}},
736+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "a"}},
737+ {{Sym: "s", Mods: ModShift}, {Sym: "p"}, {Sym: "a"}, {Sym: "c"}, {Sym: "e"}, {Sym: "j"}},
738+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
739+ {{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"}},
741+ {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
742+ {{Sym: ">"}, {Sym: "j"}},
743+ {{Sym: "<"}, {Sym: "j"}},
744+ {{Sym: "g"}, {Sym: "o", Mods: ModShift}},
745+ {{Sym: "]"}, {Sym: "]"}},
746+ }
747+ corpus = append(corpus, adjacentPairs...)
748+
749+ seen := make(map[string]struct{}, len(corpus))
534750 for _, seq := range corpus {
535751 canonical := seq.String()
752+ if _, ok := seen[canonical]; ok {
753+ continue
754+ }
755+ seen[canonical] = struct{}{}
536756 t.Run(canonical, func(t *testing.T) {
537757 got, err := ParseBinding(canonical)
538758 if err != nil {
@@ -541,6 +761,9 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
541761 if !got.Equal(seq) {
542762 t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
543763 }
764+ if again := got.String(); again != canonical {
765+ t.Fatalf("canonical unstable %q -> %q", canonical, again)
766+ }
544767 })
545768 }
546769 }
@@ -36,6 +36,8 @@ func TestParseBinding_SingleKeys(t *testing.T) {
36 {input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},36 {input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},
37 {input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},37 {input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},
38 {input: "<lt>", want: Sequence{{Sym: "<"}}},38 {input: "<lt>", want: Sequence{{Sym: "<"}}},
39+ {input: "<gt>", want: Sequence{{Sym: ">"}}},
40+ {input: "<C-gt>", want: Sequence{{Sym: ">", Mods: ModCtrl}}},
39 {input: "<Plus>", want: Sequence{{Sym: "+"}}},41 {input: "<Plus>", want: Sequence{{Sym: "+"}}},
40 {input: "enter", want: Sequence{{Sym: "CR"}}},42 {input: "enter", want: Sequence{{Sym: "CR"}}},
41 {input: "escape", want: Sequence{{Sym: "Esc"}}},43 {input: "escape", want: Sequence{{Sym: "Esc"}}},
@@ -136,6 +138,10 @@ func TestParseBinding_CanonicalString(t *testing.T) {
136 {input: "<C-Home>", want: "<C-Home>"},138 {input: "<C-Home>", want: "<C-Home>"},
137 {input: "<A-PageDown>", want: "<A-PageDown>"},139 {input: "<A-PageDown>", want: "<A-PageDown>"},
138 {input: "<lt>", want: "<lt>"},140 {input: "<lt>", want: "<lt>"},
141+ {input: "<gt>", want: "<gt>"},
142+ {input: ">", want: "<gt>"},
143+ {input: "ctrl+>", want: "<C-gt>"},
144+ {input: "<C-gt>", want: "<C-gt>"},
139 {input: "gO", want: "gO"},145 {input: "gO", want: "gO"},
140 }146 }
141 for _, tt := range tests {147 for _, tt := range tests {
@@ -217,14 +223,36 @@ func TestParseBinding_LooksLikeChordButRaw(t *testing.T) {
217 }223 }
218 }224 }
219 225
220-func TestParseBinding_CanonicalInlineModifiers(t *testing.T) {226+func TestParseBinding_BareModifierLikeIsLiteral(t *testing.T) {
227+ // Modifiers are canonical only inside <...>; bare "C-d" is Shift+c, '-', d.
221 tests := []struct {228 tests := []struct {
222 input string229 input string
223 want Sequence230 want Sequence
224 }{231 }{
225- {input: "C-d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},232+ {
226- {input: "S-j", want: Sequence{{Sym: "j", Mods: ModShift}}},233+ input: "C-d",
227- {input: "A-k", want: Sequence{{Sym: "k", Mods: ModAlt}}},234+ want: Sequence{
235+ {Sym: "c", Mods: ModShift},
236+ {Sym: "-"},
237+ {Sym: "d"},
238+ },
239+ },
240+ {
241+ input: "S-j",
242+ want: Sequence{
243+ {Sym: "s", Mods: ModShift},
244+ {Sym: "-"},
245+ {Sym: "j"},
246+ },
247+ },
248+ {
249+ input: "A-k",
250+ want: Sequence{
251+ {Sym: "a", Mods: ModShift},
252+ {Sym: "-"},
253+ {Sym: "k"},
254+ },
255+ },
228 }256 }
229 for _, tt := range tests {257 for _, tt := range tests {
230 t.Run(tt.input, func(t *testing.T) {258 t.Run(tt.input, func(t *testing.T) {
@@ -235,10 +263,100 @@ func TestParseBinding_CanonicalInlineModifiers(t *testing.T) {
235 if !got.Equal(tt.want) {263 if !got.Equal(tt.want) {
236 t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)264 t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
237 }265 }
266+ if got := got.String(); got != tt.input {
267+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.input)
268+ }
269+ })
270+ }
271+}
272+
273+func TestParseBinding_CtrlGtRoundTrip(t *testing.T) {
274+ tests := []struct {
275+ input string
276+ want Sequence
277+ wantString string
278+ }{
279+ {
280+ input: "ctrl+>",
281+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
282+ wantString: "<C-gt>",
283+ },
284+ {
285+ input: "<C-gt>",
286+ want: Sequence{{Sym: ">", Mods: ModCtrl}},
287+ wantString: "<C-gt>",
288+ },
289+ {
290+ input: "<S-gt>",
291+ want: Sequence{{Sym: ">", Mods: ModShift}},
292+ wantString: "<S-gt>",
293+ },
294+ {
295+ input: "<A-gt>",
296+ want: Sequence{{Sym: ">", Mods: ModAlt}},
297+ wantString: "<A-gt>",
298+ },
299+ {
300+ input: ">",
301+ want: Sequence{{Sym: ">"}},
302+ wantString: "<gt>",
303+ },
304+ {
305+ input: "<gt>",
306+ want: Sequence{{Sym: ">"}},
307+ wantString: "<gt>",
308+ },
309+ }
310+ for _, tt := range tests {
311+ t.Run(tt.input, func(t *testing.T) {
312+ first, err := ParseBinding(tt.input)
313+ if err != nil {
314+ t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
315+ }
316+ if !first.Equal(tt.want) {
317+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
318+ }
319+ if got := first.String(); got != tt.wantString {
320+ t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
321+ }
322+ second, err := ParseBinding(first.String())
323+ if err != nil {
324+ t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
325+ }
326+ if !first.Equal(second) {
327+ t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
328+ }
238 })329 })
239 }330 }
240 }331 }
241 332
333+func TestParseBinding_ShiftCHyphenDRoundTrip(t *testing.T) {
334+ input := "<S-c>-d"
335+ want := Sequence{
336+ {Sym: "c", Mods: ModShift},
337+ {Sym: "-"},
338+ {Sym: "d"},
339+ }
340+ first, err := ParseBinding(input)
341+ if err != nil {
342+ t.Fatalf("ParseBinding(%q) error = %v", input, err)
343+ }
344+ if !first.Equal(want) {
345+ t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
346+ }
347+ canonical := first.String()
348+ if canonical != "C-d" {
349+ t.Fatalf("String() = %q, want %q", canonical, "C-d")
350+ }
351+ second, err := ParseBinding(canonical)
352+ if err != nil {
353+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
354+ }
355+ if !first.Equal(second) {
356+ t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
357+ }
358+}
359+
242 func TestParseBinding_SpecialNamesInSequence(t *testing.T) {360 func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
243 tests := []struct {361 tests := []struct {
244 input string362 input string
@@ -248,6 +366,7 @@ func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
248 {input: "<Space>j", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},366 {input: "<Space>j", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},
249 {input: "<CR>j", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},367 {input: "<CR>j", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},
250 {input: "<lt>j", want: Sequence{{Sym: "<"}, {Sym: "j"}}},368 {input: "<lt>j", want: Sequence{{Sym: "<"}, {Sym: "j"}}},
369+ {input: "<gt>j", want: Sequence{{Sym: ">"}, {Sym: "j"}}},
251 {input: "<Plus>j", want: Sequence{{Sym: "+"}, {Sym: "j"}}},370 {input: "<Plus>j", want: Sequence{{Sym: "+"}, {Sym: "j"}}},
252 {input: "j<Tab>", want: Sequence{{Sym: "j"}, {Sym: "Tab"}}},371 {input: "j<Tab>", want: Sequence{{Sym: "j"}, {Sym: "Tab"}}},
253 {input: "<BackSpace>j", want: Sequence{{Sym: "BackSpace"}, {Sym: "j"}}},372 {input: "<BackSpace>j", want: Sequence{{Sym: "BackSpace"}, {Sym: "j"}}},
@@ -439,9 +558,17 @@ func TestParseBinding_RoundTrip(t *testing.T) {
439 "<S-Home>",558 "<S-Home>",
440 "<A-PageUp>",559 "<A-PageUp>",
441 "<lt>",560 "<lt>",
561+ "<gt>",
562+ ">",
563+ "ctrl+>",
564+ "<C-gt>",
442 "<C-d>j",565 "<C-d>j",
443 "<Escape>j",566 "<Escape>j",
444 "<C-S-a>",567 "<C-S-a>",
568+ "<S-c>-d",
569+ "C-d",
570+ "S-j",
571+ "A-k",
445 "Spacej",572 "Spacej",
446 "Escj",573 "Escj",
447 "Tabx",574 "Tabx",
@@ -499,6 +626,8 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
499 {{Sym: "BackSpace", Mods: ModShift}},626 {{Sym: "BackSpace", Mods: ModShift}},
500 {{Sym: "Delete", Mods: ModAlt}},627 {{Sym: "Delete", Mods: ModAlt}},
501 {{Sym: "<"}},628 {{Sym: "<"}},
629+ {{Sym: ">"}},
630+ {{Sym: ">", Mods: ModCtrl}},
502 {{Sym: "+"}},631 {{Sym: "+"}},
503 {{Sym: "-", Mods: ModCtrl}},632 {{Sym: "-", Mods: ModCtrl}},
504 {{Sym: "-", Mods: ModCtrl | ModShift}},633 {{Sym: "-", Mods: ModCtrl | ModShift}},
@@ -530,9 +659,100 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
530 {Sym: "b"},659 {Sym: "b"},
531 {Sym: "x"},660 {Sym: "x"},
532 },661 },
662+ // Bare modifier-like text must stay literal (no C-/S-/A- stealing).
663+ {
664+ {Sym: "c", Mods: ModShift},
665+ {Sym: "-"},
666+ {Sym: "d"},
667+ },
668+ }
669+ for _, seq := range corpus {
670+ canonical := seq.String()
671+ t.Run(canonical, func(t *testing.T) {
672+ got, err := ParseBinding(canonical)
673+ if err != nil {
674+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
675+ }
676+ if !got.Equal(seq) {
677+ t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
678+ }
679+ })
533 }680 }
681+}
682+
683+// TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key
684+// surface that Sequence.String can emit: letters × modifier combos, printable
685+// ASCII punctuation in runtime-canonical modifier states, named symbols ×
686+// modifier combos, and adjacent pairs that can spell modifier/named prefixes.
687+func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
688+ allMods := []Mods{
689+ 0,
690+ ModCtrl,
691+ ModShift,
692+ ModAlt,
693+ ModCtrl | ModShift,
694+ ModCtrl | ModAlt,
695+ ModShift | ModAlt,
696+ ModCtrl | ModShift | ModAlt,
697+ }
698+
699+ var corpus []Sequence
700+
701+ for c := 'a'; c <= 'z'; c++ {
702+ sym := string(c)
703+ for _, mods := range allMods {
704+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
705+ }
706+ }
707+
708+ punctuation := []string{
709+ "!", `"`, "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/",
710+ ":", ";", "<", "=", ">", "?", "@", "[", `\`, "]", "^", "_", "`", "{", "|", "}", "~",
711+ }
712+ for _, sym := range punctuation {
713+ for _, mods := range allMods {
714+ // Unmodified punctuation is always legal; modified forms are emitted
715+ // as angle keys and must round-trip.
716+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
717+ }
718+ }
719+
720+ named := []string{
721+ "CR", "Esc", "Space", "Tab", "BackSpace", "Delete",
722+ "Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown",
723+ }
724+ for _, sym := range named {
725+ for _, mods := range allMods {
726+ corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
727+ }
728+ }
729+
730+ // Representative adjacent pairs that can spell modifier/named prefixes.
731+ // Trailing letters avoid exact legacy atom collisions (enter/escape/…).
732+ adjacentPairs := []Sequence{
733+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},
734+ {{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},
735+ {{Sym: "a", Mods: ModShift}, {Sym: "-"}, {Sym: "k"}},
736+ {{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "a"}},
737+ {{Sym: "s", Mods: ModShift}, {Sym: "p"}, {Sym: "a"}, {Sym: "c"}, {Sym: "e"}, {Sym: "j"}},
738+ {{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
739+ {{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"}},
741+ {{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
742+ {{Sym: ">"}, {Sym: "j"}},
743+ {{Sym: "<"}, {Sym: "j"}},
744+ {{Sym: "g"}, {Sym: "o", Mods: ModShift}},
745+ {{Sym: "]"}, {Sym: "]"}},
746+ }
747+ corpus = append(corpus, adjacentPairs...)
748+
749+ seen := make(map[string]struct{}, len(corpus))
534 for _, seq := range corpus {750 for _, seq := range corpus {
535 canonical := seq.String()751 canonical := seq.String()
752+ if _, ok := seen[canonical]; ok {
753+ continue
754+ }
755+ seen[canonical] = struct{}{}
536 t.Run(canonical, func(t *testing.T) {756 t.Run(canonical, func(t *testing.T) {
537 got, err := ParseBinding(canonical)757 got, err := ParseBinding(canonical)
538 if err != nil {758 if err != nil {
@@ -541,6 +761,9 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
541 if !got.Equal(seq) {761 if !got.Equal(seq) {
542 t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)762 t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
543 }763 }
764+ if again := got.String(); again != canonical {
765+ t.Fatalf("canonical unstable %q -> %q", canonical, again)
766+ }
544 })767 })
545 }768 }
546 }769 }
M internal/ui/input/vimkeys_dispatch_test.go +3 -3
@@ -126,7 +126,7 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
126126 // Losing raw key must not be marked owned (insert failed).
127127 cfg := &entity.PageModeConfig{
128128 Actions: map[string]entity.ActionBinding{
129- "zzz-later": {Keys: []string{"C-d"}},
129+ "zzz-later": {Keys: []string{"<c-d>"}},
130130 "aaa-first": {Keys: []string{"<C-d>"}},
131131 "page-scroll-down": {Keys: []string{"j"}},
132132 },
@@ -136,8 +136,8 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
136136 if !owned["<C-d>"] {
137137 t.Fatal("winning raw key <C-d> should be owned after successful Insert")
138138 }
139- if owned["C-d"] {
140- t.Fatal("conflicting raw key C-d must not be marked owned when Insert fails")
139+ if owned["<c-d>"] {
140+ t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
141141 }
142142
143143 seq, err := vimkeys.ParseBinding("<C-d>")
@@ -126,7 +126,7 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
126 // Losing raw key must not be marked owned (insert failed).126 // Losing raw key must not be marked owned (insert failed).
127 cfg := &entity.PageModeConfig{127 cfg := &entity.PageModeConfig{
128 Actions: map[string]entity.ActionBinding{128 Actions: map[string]entity.ActionBinding{
129- "zzz-later": {Keys: []string{"C-d"}},129+ "zzz-later": {Keys: []string{"<c-d>"}},
130 "aaa-first": {Keys: []string{"<C-d>"}},130 "aaa-first": {Keys: []string{"<C-d>"}},
131 "page-scroll-down": {Keys: []string{"j"}},131 "page-scroll-down": {Keys: []string{"j"}},
132 },132 },
@@ -136,8 +136,8 @@ func TestBuildPageModeTrie_ConflictKeepsFirstSortedWinner(t *testing.T) {
136 if !owned["<C-d>"] {136 if !owned["<C-d>"] {
137 t.Fatal("winning raw key <C-d> should be owned after successful Insert")137 t.Fatal("winning raw key <C-d> should be owned after successful Insert")
138 }138 }
139- if owned["C-d"] {139+ if owned["<c-d>"] {
140- t.Fatal("conflicting raw key C-d must not be marked owned when Insert fails")140+ t.Fatal("conflicting raw key <c-d> must not be marked owned when Insert fails")
141 }141 }
142 142
143 seq, err := vimkeys.ParseBinding("<C-d>")143 seq, err := vimkeys.ParseBinding("<C-d>")

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