v0.33.1 9577fee

parse_test.go RawHistoryFolder

1package vimkeys
2
3import (
4	"errors"
5	"strings"
6	"testing"
7)
8
9func TestParseBinding_SingleKeys(t *testing.T) {
10	tests := []struct {
11		input string
12		want  Sequence
13	}{
14		{input: "j", want: Sequence{{Sym: "j"}}},
15		{input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
16		{input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}},
17		{input: "ctrl+-", want: Sequence{{Sym: "-", Mods: ModCtrl}}},
18		{input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
19		{input: "<CR>", want: Sequence{{Sym: "CR"}}},
20		{input: "<Return>", want: Sequence{{Sym: "CR"}}},
21		{input: "<Esc>", want: Sequence{{Sym: "Esc"}}},
22		{input: "<Escape>", want: Sequence{{Sym: "Esc"}}},
23		{input: "<Space>", want: Sequence{{Sym: "Space"}}},
24		{input: "<Tab>", want: Sequence{{Sym: "Tab"}}},
25		{input: "<BackSpace>", want: Sequence{{Sym: "BackSpace"}}},
26		{input: "<Delete>", want: Sequence{{Sym: "Delete"}}},
27		{input: "<Left>", want: Sequence{{Sym: "Left"}}},
28		{input: "<Right>", want: Sequence{{Sym: "Right"}}},
29		{input: "<Up>", want: Sequence{{Sym: "Up"}}},
30		{input: "<Down>", want: Sequence{{Sym: "Down"}}},
31		{input: "<Home>", want: Sequence{{Sym: "Home"}}},
32		{input: "<End>", want: Sequence{{Sym: "End"}}},
33		{input: "<PageUp>", want: Sequence{{Sym: "PageUp"}}},
34		{input: "<PageDown>", want: Sequence{{Sym: "PageDown"}}},
35		{input: "<C-Tab>", want: Sequence{{Sym: "Tab", Mods: ModCtrl}}},
36		{input: "<S-BackSpace>", want: Sequence{{Sym: "BackSpace", Mods: ModShift}}},
37		{input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},
38		{input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},
39		{input: "<lt>", want: Sequence{{Sym: "<"}}},
40		{input: "<gt>", want: Sequence{{Sym: ">"}}},
41		{input: "<C-gt>", want: Sequence{{Sym: ">", Mods: ModCtrl}}},
42		{input: "<Plus>", want: Sequence{{Sym: "+"}}},
43		{input: "enter", want: Sequence{{Sym: "CR"}}},
44		{input: "escape", want: Sequence{{Sym: "Esc"}}},
45		{input: "Enter", want: Sequence{{Sym: "CR"}}},
46		{input: "Escape", want: Sequence{{Sym: "Esc"}}},
47	}
48	for _, tt := range tests {
49		t.Run(tt.input, func(t *testing.T) {
50			got, err := ParseBinding(tt.input)
51			if err != nil {
52				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
53			}
54			if !got.Equal(tt.want) {
55				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
56			}
57		})
58	}
59}
60
61func TestParseBinding_Sequences(t *testing.T) {
62	tests := []struct {
63		input string
64		want  Sequence
65	}{
66		{input: "]]", want: Sequence{{Sym: "]"}, {Sym: "]"}}},
67		{input: "yah", want: Sequence{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}}},
68		{input: "gO", want: Sequence{{Sym: "g"}, {Sym: "o", Mods: ModShift}}},
69	}
70	for _, tt := range tests {
71		t.Run(tt.input, func(t *testing.T) {
72			got, err := ParseBinding(tt.input)
73			if err != nil {
74				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
75			}
76			if !got.Equal(tt.want) {
77				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
78			}
79		})
80	}
81}
82
83func TestParseBinding_Malformed(t *testing.T) {
84	tests := []struct {
85		input string
86		want  error
87	}{
88		{input: "", want: ErrEmptyBinding},
89		{input: "   ", want: ErrEmptyBinding},
90		{input: "<", want: ErrBadBinding},
91		{input: "<>", want: ErrBadBinding},
92		{input: "<Bad>", want: ErrBadBinding},
93		{input: "shift+", want: ErrBadBinding},
94		{input: "ctrl+", want: ErrBadBinding},
95	}
96	for _, tt := range tests {
97		t.Run(tt.input, func(t *testing.T) {
98			_, err := ParseBinding(tt.input)
99			if err == nil {
100				t.Fatalf("ParseBinding(%q) expected error", tt.input)
101			}
102			if !errors.Is(err, tt.want) {
103				t.Fatalf("ParseBinding(%q) error = %v, want %v", tt.input, err, tt.want)
104			}
105		})
106	}
107}
108
109func TestParseBinding_CanonicalString(t *testing.T) {
110	tests := []struct {
111		input string
112		want  string
113	}{
114		{input: "j", want: "j"},
115		{input: "shift+j", want: "J"},
116		{input: "ctrl++", want: "<C-+>"},
117		{input: "ctrl+-", want: "<C-->"},
118		{input: "<C-->", want: "<C-->"},
119		{input: "<C-d>", want: "<C-d>"},
120		{input: "<Plus>", want: "<Plus>"},
121		{input: "+", want: "<Plus>"},
122		{input: "<CR>", want: "<Return>"},
123		{input: "<Return>", want: "<Return>"},
124		{input: "<Esc>", want: "<Escape>"},
125		{input: "<Escape>", want: "<Escape>"},
126		{input: "<Space>", want: "<Space>"},
127		{input: "<Tab>", want: "<Tab>"},
128		{input: "<BackSpace>", want: "<BackSpace>"},
129		{input: "<Delete>", want: "<Delete>"},
130		{input: "<Left>", want: "<Left>"},
131		{input: "<Right>", want: "<Right>"},
132		{input: "<Up>", want: "<Up>"},
133		{input: "<Down>", want: "<Down>"},
134		{input: "<Home>", want: "<Home>"},
135		{input: "<End>", want: "<End>"},
136		{input: "<PageUp>", want: "<PageUp>"},
137		{input: "<PageDown>", want: "<PageDown>"},
138		{input: "<C-Tab>", want: "<C-Tab>"},
139		{input: "<S-BackSpace>", want: "<S-BackSpace>"},
140		{input: "<A-Delete>", want: "<A-Delete>"},
141		{input: "<C-Home>", want: "<C-Home>"},
142		{input: "<A-PageDown>", want: "<A-PageDown>"},
143		{input: "<lt>", want: "<lt>"},
144		{input: "<gt>", want: "<gt>"},
145		{input: ">", want: "<gt>"},
146		{input: "ctrl+>", want: "<C-gt>"},
147		{input: "<C-gt>", want: "<C-gt>"},
148		{input: "gO", want: "gO"},
149	}
150	for _, tt := range tests {
151		t.Run(tt.input, func(t *testing.T) {
152			seq, err := ParseBinding(tt.input)
153			if err != nil {
154				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
155			}
156			if got := seq.String(); got != tt.want {
157				t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.want)
158			}
159		})
160	}
161}
162
163func TestParseBinding_LegacyChords(t *testing.T) {
164	tests := []struct {
165		input string
166		want  Sequence
167	}{
168		{input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
169		{input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
170		{input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
171		{input: "ctrl+J", want: Sequence{{Sym: "j", Mods: ModCtrl | ModShift}}},
172	}
173	for _, tt := range tests {
174		t.Run(tt.input, func(t *testing.T) {
175			got, err := ParseBinding(tt.input)
176			if err != nil {
177				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
178			}
179			if !got.Equal(tt.want) {
180				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
181			}
182		})
183	}
184}
185
186func TestParseBinding_MoreMalformed(t *testing.T) {
187	tests := []string{
188		"ctrl+shift+j+k",
189		"<C->",
190		"<C--d>",
191		"<Bad>",
192		"ctrl+",
193	}
194	for _, input := range tests {
195		t.Run(input, func(t *testing.T) {
196			_, err := ParseBinding(input)
197			if err == nil {
198				t.Fatalf("ParseBinding(%q) expected error", input)
199			}
200			if !errors.Is(err, ErrBadBinding) {
201				t.Fatalf("ParseBinding(%q) error = %v, want %v", input, err, ErrBadBinding)
202			}
203		})
204	}
205}
206
207func TestParseBinding_PlusAlone(t *testing.T) {
208	got, err := ParseBinding("+")
209	if err != nil {
210		t.Fatalf("ParseBinding error = %v", err)
211	}
212	want := Sequence{{Sym: "+"}}
213	if !got.Equal(want) {
214		t.Fatalf("ParseBinding = %v, want %v", got, want)
215	}
216}
217
218func TestParseBinding_LooksLikeChordButRaw(t *testing.T) {
219	got, err := ParseBinding("]c")
220	if err != nil {
221		t.Fatalf("ParseBinding error = %v", err)
222	}
223	want := Sequence{{Sym: "]"}, {Sym: "c"}}
224	if !got.Equal(want) {
225		t.Fatalf("ParseBinding = %v, want %v", got, want)
226	}
227}
228
229func TestParseBinding_BareModifierLikeIsLiteral(t *testing.T) {
230	// Modifiers are canonical only inside <...>; bare "C-d" is Shift+c, '-', d.
231	tests := []struct {
232		input string
233		want  Sequence
234	}{
235		{
236			input: "C-d",
237			want: Sequence{
238				{Sym: "c", Mods: ModShift},
239				{Sym: "-"},
240				{Sym: "d"},
241			},
242		},
243		{
244			input: "S-j",
245			want: Sequence{
246				{Sym: "s", Mods: ModShift},
247				{Sym: "-"},
248				{Sym: "j"},
249			},
250		},
251		{
252			input: "A-k",
253			want: Sequence{
254				{Sym: "a", Mods: ModShift},
255				{Sym: "-"},
256				{Sym: "k"},
257			},
258		},
259	}
260	for _, tt := range tests {
261		t.Run(tt.input, func(t *testing.T) {
262			got, err := ParseBinding(tt.input)
263			if err != nil {
264				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
265			}
266			if !got.Equal(tt.want) {
267				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
268			}
269			if got := got.String(); got != tt.input {
270				t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.input)
271			}
272		})
273	}
274}
275
276func TestParseBinding_CtrlGtRoundTrip(t *testing.T) {
277	tests := []struct {
278		input      string
279		want       Sequence
280		wantString string
281	}{
282		{
283			input:      "ctrl+>",
284			want:       Sequence{{Sym: ">", Mods: ModCtrl}},
285			wantString: "<C-gt>",
286		},
287		{
288			input:      "<C-gt>",
289			want:       Sequence{{Sym: ">", Mods: ModCtrl}},
290			wantString: "<C-gt>",
291		},
292		{
293			input:      "<S-gt>",
294			want:       Sequence{{Sym: ">", Mods: ModShift}},
295			wantString: "<S-gt>",
296		},
297		{
298			input:      "<A-gt>",
299			want:       Sequence{{Sym: ">", Mods: ModAlt}},
300			wantString: "<A-gt>",
301		},
302		{
303			input:      ">",
304			want:       Sequence{{Sym: ">"}},
305			wantString: "<gt>",
306		},
307		{
308			input:      "<gt>",
309			want:       Sequence{{Sym: ">"}},
310			wantString: "<gt>",
311		},
312	}
313	for _, tt := range tests {
314		t.Run(tt.input, func(t *testing.T) {
315			first, err := ParseBinding(tt.input)
316			if err != nil {
317				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
318			}
319			if !first.Equal(tt.want) {
320				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
321			}
322			if got := first.String(); got != tt.wantString {
323				t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
324			}
325			second, err := ParseBinding(first.String())
326			if err != nil {
327				t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
328			}
329			if !first.Equal(second) {
330				t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
331			}
332		})
333	}
334}
335
336func TestParseBinding_ShiftCHyphenDRoundTrip(t *testing.T) {
337	input := "<S-c>-d"
338	want := Sequence{
339		{Sym: "c", Mods: ModShift},
340		{Sym: "-"},
341		{Sym: "d"},
342	}
343	first, err := ParseBinding(input)
344	if err != nil {
345		t.Fatalf("ParseBinding(%q) error = %v", input, err)
346	}
347	if !first.Equal(want) {
348		t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
349	}
350	canonical := first.String()
351	if canonical != "C-d" {
352		t.Fatalf("String() = %q, want %q", canonical, "C-d")
353	}
354	second, err := ParseBinding(canonical)
355	if err != nil {
356		t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
357	}
358	if !first.Equal(second) {
359		t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
360	}
361}
362
363func TestParseBinding_SpecialNamesInSequence(t *testing.T) {
364	tests := []struct {
365		input string
366		want  Sequence
367	}{
368		{input: "j<Esc>", want: Sequence{{Sym: "j"}, {Sym: "Esc"}}},
369		{input: "<Space>j", want: Sequence{{Sym: "Space"}, {Sym: "j"}}},
370		{input: "<CR>j", want: Sequence{{Sym: "CR"}, {Sym: "j"}}},
371		{input: "<lt>j", want: Sequence{{Sym: "<"}, {Sym: "j"}}},
372		{input: "<gt>j", want: Sequence{{Sym: ">"}, {Sym: "j"}}},
373		{input: "<Plus>j", want: Sequence{{Sym: "+"}, {Sym: "j"}}},
374		{input: "j<Tab>", want: Sequence{{Sym: "j"}, {Sym: "Tab"}}},
375		{input: "<BackSpace>j", want: Sequence{{Sym: "BackSpace"}, {Sym: "j"}}},
376	}
377	for _, tt := range tests {
378		t.Run(tt.input, func(t *testing.T) {
379			got, err := ParseBinding(tt.input)
380			if err != nil {
381				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
382			}
383			if !got.Equal(tt.want) {
384				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
385			}
386		})
387	}
388}
389
390func TestParseBinding_BareNamedPrefixIsLiteral(t *testing.T) {
391	// Named keys are canonical only in <...>; bare "Spacej" is Shift+s + p + a + c + e + j.
392	seq := Sequence{
393		{Sym: "s", Mods: ModShift},
394		{Sym: "p"},
395		{Sym: "a"},
396		{Sym: "c"},
397		{Sym: "e"},
398		{Sym: "j"},
399	}
400	if got := seq.String(); got != "Spacej" {
401		t.Fatalf("seq.String() = %q, want %q", got, "Spacej")
402	}
403	got, err := ParseBinding("Spacej")
404	if err != nil {
405		t.Fatalf("ParseBinding(%q) error = %v", "Spacej", err)
406	}
407	if !got.Equal(seq) {
408		t.Fatalf("ParseBinding(%q) = %v, want %v", "Spacej", got, seq)
409	}
410}
411
412func TestParseBinding_PrintableGlyphs(t *testing.T) {
413	got, err := ParseBinding("!@#")
414	if err != nil {
415		t.Fatalf("ParseBinding error = %v", err)
416	}
417	want := Sequence{{Sym: "!"}, {Sym: "@"}, {Sym: "#"}}
418	if !got.Equal(want) {
419		t.Fatalf("ParseBinding = %v, want %v", got, want)
420	}
421}
422
423func TestParseBinding_MixedAngleSequences(t *testing.T) {
424	tests := []struct {
425		input string
426		want  Sequence
427	}{
428		{
429			input: "<C-d>j",
430			want:  Sequence{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
431		},
432		{
433			input: "<Escape>j",
434			want:  Sequence{{Sym: "Esc"}, {Sym: "j"}},
435		},
436	}
437	for _, tt := range tests {
438		t.Run(tt.input, func(t *testing.T) {
439			got, err := ParseBinding(tt.input)
440			if err != nil {
441				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
442			}
443			if !got.Equal(tt.want) {
444				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
445			}
446		})
447	}
448}
449
450func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) {
451	tests := []struct {
452		input      string
453		want       Sequence
454		wantString string
455	}{
456		{
457			input:      "ctrl+-",
458			want:       Sequence{{Sym: "-", Mods: ModCtrl}},
459			wantString: "<C-->",
460		},
461		{
462			input:      "<C-->",
463			want:       Sequence{{Sym: "-", Mods: ModCtrl}},
464			wantString: "<C-->",
465		},
466		{
467			input:      "<C-S-->",
468			want:       Sequence{{Sym: "-", Mods: ModCtrl | ModShift}},
469			wantString: "<C-S-->",
470		},
471		{
472			input:      "<C-A-->",
473			want:       Sequence{{Sym: "-", Mods: ModCtrl | ModAlt}},
474			wantString: "<C-A-->",
475		},
476		{
477			input:      "<C-S-A-->",
478			want:       Sequence{{Sym: "-", Mods: ModCtrl | ModShift | ModAlt}},
479			wantString: "<C-S-A-->",
480		},
481	}
482	for _, tt := range tests {
483		t.Run(tt.input, func(t *testing.T) {
484			first, err := ParseBinding(tt.input)
485			if err != nil {
486				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
487			}
488			if !first.Equal(tt.want) {
489				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want)
490			}
491			if got := first.String(); got != tt.wantString {
492				t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString)
493			}
494			second, err := ParseBinding(first.String())
495			if err != nil {
496				t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err)
497			}
498			if !first.Equal(second) {
499				t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first)
500			}
501		})
502	}
503}
504
505func 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
560func TestParseBinding_MultiModifierRoundTrip(t *testing.T) {
561	input := "<C-S-a>"
562	first, err := ParseBinding(input)
563	if err != nil {
564		t.Fatalf("ParseBinding(%q) error = %v", input, err)
565	}
566	want := Sequence{{Sym: "a", Mods: ModCtrl | ModShift}}
567	if !first.Equal(want) {
568		t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want)
569	}
570
571	canonical := first.String()
572	if canonical != "<C-S-a>" {
573		t.Fatalf("String() = %q, want %q", canonical, "<C-S-a>")
574	}
575
576	second, err := ParseBinding(canonical)
577	if err != nil {
578		t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
579	}
580	if !first.Equal(second) {
581		t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
582	}
583}
584
585func TestParseBinding_RoundTrip(t *testing.T) {
586	inputs := []string{
587		"j",
588		"shift+j",
589		"ctrl++",
590		"ctrl+-",
591		"<C-->",
592		"<C-S-->",
593		"<C-A-->",
594		"]]",
595		"yah",
596		"gO",
597		"<C-d>",
598		"<CR>",
599		"<Esc>",
600		"<Space>",
601		"<Tab>",
602		"<BackSpace>",
603		"<Delete>",
604		"<Left>",
605		"<Right>",
606		"<Up>",
607		"<Down>",
608		"<Home>",
609		"<End>",
610		"<PageUp>",
611		"<PageDown>",
612		"<C-Tab>",
613		"<S-BackSpace>",
614		"<A-Delete>",
615		"<C-Left>",
616		"<S-Home>",
617		"<A-PageUp>",
618		"<lt>",
619		"<gt>",
620		">",
621		"ctrl+>",
622		"<C-gt>",
623		"<C-d>j",
624		"<Escape>j",
625		"<C-S-a>",
626		"<S-c>-d",
627		"C-d",
628		"S-j",
629		"A-k",
630		"Spacej",
631		"Escj",
632		"Tabx",
633		"enter",
634		"escape",
635		"return",
636		"esc",
637	}
638	for _, input := range inputs {
639		t.Run(input, func(t *testing.T) {
640			first, err := ParseBinding(input)
641			if err != nil {
642				t.Fatalf("ParseBinding(%q) error = %v", input, err)
643			}
644			canonical := first.String()
645			second, err := ParseBinding(canonical)
646			if err != nil {
647				t.Fatalf("ParseBinding(%q) from canonical %q error = %v", input, canonical, err)
648			}
649			if !first.Equal(second) {
650				t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first)
651			}
652		})
653	}
654}
655
656// TestParseBinding_DeterministicRoundTripProperty asserts ParseBinding(seq.String()) == seq
657// over a fixed representative set of printable, named, and modifier sequences.
658// The corpus is deterministic (no flaky randomness).
659func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) {
660	corpus := []Sequence{
661		{{Sym: "j"}},
662		{{Sym: "j", Mods: ModShift}},
663		{{Sym: "]"}, {Sym: "]"}},
664		{{Sym: "y"}, {Sym: "a"}, {Sym: "h"}},
665		{{Sym: "g"}, {Sym: "o", Mods: ModShift}},
666		{{Sym: "d", Mods: ModCtrl}},
667		{{Sym: "a", Mods: ModCtrl | ModShift}},
668		{{Sym: "k", Mods: ModAlt}},
669		{{Sym: "CR"}},
670		{{Sym: "Esc"}},
671		{{Sym: "Space"}},
672		{{Sym: "Tab"}},
673		{{Sym: "BackSpace"}},
674		{{Sym: "Delete"}},
675		{{Sym: "Left"}},
676		{{Sym: "Right"}},
677		{{Sym: "Up"}},
678		{{Sym: "Down"}},
679		{{Sym: "Home"}},
680		{{Sym: "End"}},
681		{{Sym: "PageUp"}},
682		{{Sym: "PageDown"}},
683		{{Sym: "Tab", Mods: ModCtrl}},
684		{{Sym: "BackSpace", Mods: ModShift}},
685		{{Sym: "Delete", Mods: ModAlt}},
686		{{Sym: "<"}},
687		{{Sym: ">"}},
688		{{Sym: ">", Mods: ModCtrl}},
689		{{Sym: "+"}},
690		{{Sym: "+", Mods: ModCtrl}},
691		{{Sym: "+", Mods: ModCtrl | ModShift}},
692		{{Sym: "-", Mods: ModCtrl}},
693		{{Sym: "-", Mods: ModCtrl | ModShift}},
694		{{Sym: "-", Mods: ModCtrl | ModAlt}},
695		{{Sym: "!"}},
696		{{Sym: "@"}},
697		{{Sym: "#"}},
698		{{Sym: "Space"}, {Sym: "j"}},
699		{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
700		{{Sym: "Esc"}, {Sym: "j"}},
701		// Literal printable run that formerly collided with greedy "Space" recognition.
702		{
703			{Sym: "s", Mods: ModShift},
704			{Sym: "p"},
705			{Sym: "a"},
706			{Sym: "c"},
707			{Sym: "e"},
708			{Sym: "j"},
709		},
710		{
711			{Sym: "e", Mods: ModShift},
712			{Sym: "s"},
713			{Sym: "c"},
714			{Sym: "j"},
715		},
716		{
717			{Sym: "t", Mods: ModShift},
718			{Sym: "a"},
719			{Sym: "b"},
720			{Sym: "x"},
721		},
722		// Bare modifier-like text must stay literal (no C-/S-/A- stealing).
723		{
724			{Sym: "c", Mods: ModShift},
725			{Sym: "-"},
726			{Sym: "d"},
727		},
728		// Representative multi-byte printable runes (accented BMP + emoji).
729		{{Sym: "é"}},
730		{{Sym: "é", Mods: ModCtrl}},
731		{{Sym: "ñ", Mods: ModAlt}},
732		{{Sym: "😀"}},
733		{{Sym: "😀", Mods: ModCtrl}},
734	}
735	for _, seq := range corpus {
736		canonical := seq.String()
737		t.Run(canonical, func(t *testing.T) {
738			got, err := ParseBinding(canonical)
739			if err != nil {
740				t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
741			}
742			if !got.Equal(seq) {
743				t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
744			}
745		})
746	}
747}
748
749// TestParseBinding_LegacyAtomCollisionRoundTrip ensures rune sequences whose
750// naive Key.String concatenation would equal a legacy atom are emitted with
751// enough angle-literal tokens to round-trip without becoming CR/Esc.
752func TestParseBinding_LegacyAtomCollisionRoundTrip(t *testing.T) {
753	tests := []struct {
754		name       string
755		seq        Sequence
756		wantString string
757	}{
758		{
759			name: "Shift+e n t e r",
760			seq: Sequence{
761				{Sym: "e", Mods: ModShift},
762				{Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
763			},
764			wantString: "<S-e>nter",
765		},
766		{
767			name: "e n t e r",
768			seq: Sequence{
769				{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"},
770			},
771			wantString: "<e>nter",
772		},
773		{
774			name: "Shift+r e t u r n",
775			seq: Sequence{
776				{Sym: "r", Mods: ModShift},
777				{Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
778			},
779			wantString: "<S-r>eturn",
780		},
781		{
782			name: "r e t u r n",
783			seq: Sequence{
784				{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"},
785			},
786			wantString: "<r>eturn",
787		},
788		{
789			name: "Shift+e s c",
790			seq: Sequence{
791				{Sym: "e", Mods: ModShift},
792				{Sym: "s"}, {Sym: "c"},
793			},
794			wantString: "<S-e>sc",
795		},
796		{
797			name:       "e s c",
798			seq:        Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
799			wantString: "<e>sc",
800		},
801		{
802			name: "Shift+e s c a p e",
803			seq: Sequence{
804				{Sym: "e", Mods: ModShift},
805				{Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
806			},
807			wantString: "<S-e>scape",
808		},
809		{
810			name: "e s c a p e",
811			seq: Sequence{
812				{Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"},
813			},
814			wantString: "<e>scape",
815		},
816	}
817	for _, tt := range tests {
818		t.Run(tt.name, func(t *testing.T) {
819			if got := tt.seq.String(); got != tt.wantString {
820				t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
821			}
822			got, err := ParseBinding(tt.wantString)
823			if err != nil {
824				t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
825			}
826			if !got.Equal(tt.seq) {
827				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
828			}
829			if again := got.String(); again != tt.wantString {
830				t.Fatalf("canonical unstable %q -> %q", tt.wantString, again)
831			}
832		})
833	}
834}
835
836// TestParseBinding_LegacyChordCollisionRoundTrip ensures a rune sequence that
837// would spell a legacy chord via a literal '+' is emitted with <Plus> so it
838// cannot reparse as that chord.
839func TestParseBinding_LegacyChordCollisionRoundTrip(t *testing.T) {
840	tests := []struct {
841		name       string
842		seq        Sequence
843		wantString string
844	}{
845		{
846			name: "ctrl + d runes",
847			seq: Sequence{
848				{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"},
849				{Sym: "+"},
850				{Sym: "d"},
851			},
852			wantString: "ctrl<Plus>d",
853		},
854		{
855			name: "Ctrl + d mixed case start",
856			seq: Sequence{
857				{Sym: "c", Mods: ModShift},
858				{Sym: "t"}, {Sym: "r"}, {Sym: "l"},
859				{Sym: "+"},
860				{Sym: "d"},
861			},
862			wantString: "Ctrl<Plus>d",
863		},
864		{
865			name: "control + j runes",
866			seq: Sequence{
867				{Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
868				{Sym: "+"},
869				{Sym: "j"},
870			},
871			wantString: "control<Plus>j",
872		},
873		{
874			name: "shift + j runes",
875			seq: Sequence{
876				{Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
877				{Sym: "+"},
878				{Sym: "j"},
879			},
880			wantString: "shift<Plus>j",
881		},
882		{
883			name: "alt + k runes",
884			seq: Sequence{
885				{Sym: "a"}, {Sym: "l"}, {Sym: "t"},
886				{Sym: "+"},
887				{Sym: "k"},
888			},
889			wantString: "alt<Plus>k",
890		},
891	}
892	for _, tt := range tests {
893		t.Run(tt.name, func(t *testing.T) {
894			if got := tt.seq.String(); got != tt.wantString {
895				t.Fatalf("Sequence.String() = %q, want %q", got, tt.wantString)
896			}
897			got, err := ParseBinding(tt.wantString)
898			if err != nil {
899				t.Fatalf("ParseBinding(%q) error = %v", tt.wantString, err)
900			}
901			if !got.Equal(tt.seq) {
902				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.wantString, got, tt.seq)
903			}
904		})
905	}
906}
907
908// TestParseBinding_LegacyAtomsAndChordsStillAccepted preserves exact raw
909// legacy config inputs as named atoms / chords.
910func TestParseBinding_LegacyAtomsAndChordsStillAccepted(t *testing.T) {
911	tests := []struct {
912		input string
913		want  Sequence
914	}{
915		{input: "enter", want: Sequence{{Sym: "CR"}}},
916		{input: "return", want: Sequence{{Sym: "CR"}}},
917		{input: "Enter", want: Sequence{{Sym: "CR"}}},
918		{input: "Return", want: Sequence{{Sym: "CR"}}},
919		{input: "ENTER", want: Sequence{{Sym: "CR"}}},
920		{input: "escape", want: Sequence{{Sym: "Esc"}}},
921		{input: "esc", want: Sequence{{Sym: "Esc"}}},
922		{input: "Escape", want: Sequence{{Sym: "Esc"}}},
923		{input: "Esc", want: Sequence{{Sym: "Esc"}}},
924		{input: "ESC", want: Sequence{{Sym: "Esc"}}},
925		{input: "ctrl+d", want: Sequence{{Sym: "d", Mods: ModCtrl}}},
926		{input: "control+j", want: Sequence{{Sym: "j", Mods: ModCtrl}}},
927		{input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}},
928		{input: "alt+k", want: Sequence{{Sym: "k", Mods: ModAlt}}},
929	}
930	for _, tt := range tests {
931		t.Run(tt.input, func(t *testing.T) {
932			got, err := ParseBinding(tt.input)
933			if err != nil {
934				t.Fatalf("ParseBinding(%q) error = %v", tt.input, err)
935			}
936			if !got.Equal(tt.want) {
937				t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want)
938			}
939		})
940	}
941}
942
943// TestParseBinding_OneCharAngleLiteral accepts <e>/<S-e> while rejecting <Nope>.
944func TestParseBinding_OneCharAngleLiteral(t *testing.T) {
945	got, err := ParseBinding("<e>")
946	if err != nil {
947		t.Fatalf("ParseBinding(<e>) error = %v", err)
948	}
949	if want := (Sequence{{Sym: "e"}}); !got.Equal(want) {
950		t.Fatalf("ParseBinding(<e>) = %v, want %v", got, want)
951	}
952	got, err = ParseBinding("<S-e>")
953	if err != nil {
954		t.Fatalf("ParseBinding(<S-e>) error = %v", err)
955	}
956	if want := (Sequence{{Sym: "e", Mods: ModShift}}); !got.Equal(want) {
957		t.Fatalf("ParseBinding(<S-e>) = %v, want %v", got, want)
958	}
959	if _, err := ParseBinding("<Nope>"); !errors.Is(err, ErrBadBinding) {
960		t.Fatalf("ParseBinding(<Nope>) error = %v, want %v", err, ErrBadBinding)
961	}
962}
963
964// TestParseBinding_CtrlEAcuteRoundTrip covers the runtime Unicode canonical
965// invariant: ctrl+é / <C-é> parse to Key{Sym:"é", ModCtrl} and String
966// re-parses identically.
967func TestParseBinding_CtrlEAcuteRoundTrip(t *testing.T) {
968	want := Sequence{{Sym: "é", Mods: ModCtrl}}
969	for _, input := range []string{"ctrl+é", "<C-é>"} {
970		t.Run(input, func(t *testing.T) {
971			got, err := ParseBinding(input)
972			if err != nil {
973				t.Fatalf("ParseBinding(%q) error = %v", input, err)
974			}
975			if !got.Equal(want) {
976				t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want)
977			}
978			canonical := got.String()
979			if canonical != "<C-é>" {
980				t.Fatalf("String() = %q, want %q", canonical, "<C-é>")
981			}
982			again, err := ParseBinding(canonical)
983			if err != nil {
984				t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
985			}
986			if !again.Equal(want) {
987				t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want)
988			}
989		})
990	}
991}
992
993// TestParseBinding_ReplacementCharRoundTrip ensures valid printable U+FFFD is not
994// rejected by the single-rune token path (r == utf8.RuneError is U+FFFD itself).
995func TestParseBinding_ReplacementCharRoundTrip(t *testing.T) {
996	const repl = "\uFFFD"
997	want := Sequence{{Sym: repl, Mods: ModCtrl}}
998	for _, input := range []string{"ctrl+" + repl, "<C-" + repl + ">"} {
999		t.Run(input, func(t *testing.T) {
1000			got, err := ParseBinding(input)
1001			if err != nil {
1002				t.Fatalf("ParseBinding(%q) error = %v", input, err)
1003			}
1004			if !got.Equal(want) {
1005				t.Fatalf("ParseBinding(%q) = %v, want %v", input, got, want)
1006			}
1007			canonical := got.String()
1008			wantCanonical := "<C-" + repl + ">"
1009			if canonical != wantCanonical {
1010				t.Fatalf("String() = %q, want %q", canonical, wantCanonical)
1011			}
1012			again, err := ParseBinding(canonical)
1013			if err != nil {
1014				t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1015			}
1016			if !again.Equal(want) {
1017				t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, again, want)
1018			}
1019		})
1020	}
1021	modifiedWant := Sequence{{Sym: repl}}
1022	got, err := ParseBinding("<" + repl + ">")
1023	if err != nil {
1024		t.Fatalf("ParseBinding angle literal error = %v", err)
1025	}
1026	if !got.Equal(modifiedWant) {
1027		t.Fatalf("ParseBinding(<U+FFFD>) = %v, want %v", got, modifiedWant)
1028	}
1029}
1030
1031// TestParseBinding_LegacyCollisionModel systematically covers every exact
1032// legacy atom alias, modifier+plus chord spellings, case variants, and
1033// adjacent non-colliding sequences while retaining round-trip.
1034func TestParseBinding_LegacyCollisionModel(t *testing.T) {
1035	type atomCase struct {
1036		alias string
1037		seq   Sequence
1038	}
1039	atoms := []atomCase{
1040		{alias: "enter", seq: Sequence{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
1041		{alias: "Enter", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}}},
1042		{alias: "ENTER", seq: Sequence{
1043			{Sym: "e", Mods: ModShift}, {Sym: "n", Mods: ModShift}, {Sym: "t", Mods: ModShift},
1044			{Sym: "e", Mods: ModShift}, {Sym: "r", Mods: ModShift},
1045		}},
1046		{alias: "return", seq: Sequence{{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
1047		{alias: "Return", seq: Sequence{{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}}},
1048		{alias: "escape", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
1049		{alias: "Escape", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}}},
1050		{alias: "esc", seq: Sequence{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}}},
1051		{alias: "Esc", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}}},
1052		{alias: "ESC", seq: Sequence{{Sym: "e", Mods: ModShift}, {Sym: "s", Mods: ModShift}, {Sym: "c", Mods: ModShift}}},
1053	}
1054	for _, tt := range atoms {
1055		t.Run("atom/"+tt.alias, func(t *testing.T) {
1056			canonical := tt.seq.String()
1057			if strings.EqualFold(canonical, tt.alias) {
1058				t.Fatalf("Sequence.String() still collides with legacy atom %q", tt.alias)
1059			}
1060			got, err := ParseBinding(canonical)
1061			if err != nil {
1062				t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1063			}
1064			if !got.Equal(tt.seq) {
1065				t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1066			}
1067			raw, err := ParseBinding(tt.alias)
1068			if err != nil {
1069				t.Fatalf("ParseBinding(%q) error = %v", tt.alias, err)
1070			}
1071			if raw.Equal(tt.seq) {
1072				t.Fatalf("legacy atom %q must not equal rune sequence", tt.alias)
1073			}
1074		})
1075	}
1076
1077	chordSpellings := []struct {
1078		legacy string
1079		seq    Sequence
1080	}{
1081		{
1082			legacy: "ctrl+d",
1083			seq: Sequence{
1084				{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"},
1085			},
1086		},
1087		{
1088			legacy: "control+j",
1089			seq: Sequence{
1090				{Sym: "c"}, {Sym: "o"}, {Sym: "n"}, {Sym: "t"}, {Sym: "r"}, {Sym: "o"}, {Sym: "l"},
1091				{Sym: "+"}, {Sym: "j"},
1092			},
1093		},
1094		{
1095			legacy: "shift+a",
1096			seq: Sequence{
1097				{Sym: "s"}, {Sym: "h"}, {Sym: "i"}, {Sym: "f"}, {Sym: "t"},
1098				{Sym: "+"}, {Sym: "a"},
1099			},
1100		},
1101		{
1102			legacy: "alt+b",
1103			seq: Sequence{
1104				{Sym: "a"}, {Sym: "l"}, {Sym: "t"}, {Sym: "+"}, {Sym: "b"},
1105			},
1106		},
1107	}
1108	for _, tt := range chordSpellings {
1109		t.Run("chord/"+tt.legacy, func(t *testing.T) {
1110			canonical := tt.seq.String()
1111			if canonical == tt.legacy {
1112				t.Fatalf("Sequence.String() still equals legacy chord %q", tt.legacy)
1113			}
1114			got, err := ParseBinding(canonical)
1115			if err != nil {
1116				t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1117			}
1118			if !got.Equal(tt.seq) {
1119				t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, tt.seq)
1120			}
1121			chord, err := ParseBinding(tt.legacy)
1122			if err != nil {
1123				t.Fatalf("ParseBinding(%q) error = %v", tt.legacy, err)
1124			}
1125			if chord.Equal(tt.seq) {
1126				t.Fatalf("legacy chord %q must not equal rune sequence", tt.legacy)
1127			}
1128		})
1129	}
1130
1131	// Adjacent sequences that share prefixes but are not exact atoms.
1132	adjacent := []Sequence{
1133		{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}, {Sym: "j"}},
1134		{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1135		{{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}, {Sym: "j"}},
1136	}
1137	for _, seq := range adjacent {
1138		canonical := seq.String()
1139		t.Run("adjacent/"+canonical, func(t *testing.T) {
1140			got, err := ParseBinding(canonical)
1141			if err != nil {
1142				t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1143			}
1144			if !got.Equal(seq) {
1145				t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1146			}
1147		})
1148	}
1149}
1150
1151// TestParseBinding_ExhaustiveCanonicalModelRoundTrip covers the full legal Key
1152// surface that Sequence.String can emit: letters × modifier combos, printable
1153// ASCII punctuation in runtime-canonical modifier states, named symbols ×
1154// modifier combos, and adjacent pairs that can spell modifier/named prefixes.
1155func TestParseBinding_ExhaustiveCanonicalModelRoundTrip(t *testing.T) {
1156	allMods := []Mods{
1157		0,
1158		ModCtrl,
1159		ModShift,
1160		ModAlt,
1161		ModCtrl | ModShift,
1162		ModCtrl | ModAlt,
1163		ModShift | ModAlt,
1164		ModCtrl | ModShift | ModAlt,
1165	}
1166
1167	var corpus []Sequence
1168
1169	for c := 'a'; c <= 'z'; c++ {
1170		sym := string(c)
1171		for _, mods := range allMods {
1172			corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1173		}
1174	}
1175
1176	punctuation := []string{
1177		"!", `"`, "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/",
1178		":", ";", "<", "=", ">", "?", "@", "[", `\`, "]", "^", "_", "`", "{", "|", "}", "~",
1179	}
1180	for _, sym := range punctuation {
1181		for _, mods := range allMods {
1182			// Unmodified punctuation is always legal; modified forms are emitted
1183			// as angle keys and must round-trip.
1184			corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1185		}
1186	}
1187
1188	named := []string{
1189		"CR", "Esc", "Space", "Tab", "BackSpace", "Delete",
1190		"Left", "Right", "Up", "Down", "Home", "End", "PageUp", "PageDown",
1191	}
1192	for _, sym := range named {
1193		for _, mods := range allMods {
1194			corpus = append(corpus, Sequence{{Sym: sym, Mods: mods}})
1195		}
1196	}
1197
1198	// Representative adjacent pairs that can spell modifier/named prefixes,
1199	// including exact legacy atom/chord collision spellings.
1200	adjacentPairs := []Sequence{
1201		{{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "d"}},
1202		{{Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "j"}},
1203		{{Sym: "a", Mods: ModShift}, {Sym: "-"}, {Sym: "k"}},
1204		{{Sym: "c", Mods: ModShift}, {Sym: "-"}, {Sym: "s", Mods: ModShift}, {Sym: "-"}, {Sym: "a"}},
1205		{{Sym: "s", Mods: ModShift}, {Sym: "p"}, {Sym: "a"}, {Sym: "c"}, {Sym: "e"}, {Sym: "j"}},
1206		{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "j"}},
1207		{{Sym: "t", Mods: ModShift}, {Sym: "a"}, {Sym: "b"}, {Sym: "x"}},
1208		{{Sym: "r", Mods: ModShift}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}, {Sym: "x"}},
1209		{{Sym: "e"}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1210		{{Sym: "e", Mods: ModShift}, {Sym: "n"}, {Sym: "t"}, {Sym: "e"}, {Sym: "r"}},
1211		{{Sym: "e"}, {Sym: "s"}, {Sym: "c"}},
1212		{{Sym: "e", Mods: ModShift}, {Sym: "s"}, {Sym: "c"}, {Sym: "a"}, {Sym: "p"}, {Sym: "e"}},
1213		{{Sym: "r"}, {Sym: "e"}, {Sym: "t"}, {Sym: "u"}, {Sym: "r"}, {Sym: "n"}},
1214		{{Sym: "c"}, {Sym: "t"}, {Sym: "r"}, {Sym: "l"}, {Sym: "+"}, {Sym: "d"}},
1215		{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}},
1216		{{Sym: ">"}, {Sym: "j"}},
1217		{{Sym: "<"}, {Sym: "j"}},
1218		{{Sym: "g"}, {Sym: "o", Mods: ModShift}},
1219		{{Sym: "]"}, {Sym: "]"}},
1220	}
1221	corpus = append(corpus, adjacentPairs...)
1222
1223	seen := make(map[string]struct{}, len(corpus))
1224	for _, seq := range corpus {
1225		canonical := seq.String()
1226		if _, ok := seen[canonical]; ok {
1227			continue
1228		}
1229		seen[canonical] = struct{}{}
1230		t.Run(canonical, func(t *testing.T) {
1231			got, err := ParseBinding(canonical)
1232			if err != nil {
1233				t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
1234			}
1235			if !got.Equal(seq) {
1236				t.Fatalf("ParseBinding(%q) = %v, want %v", canonical, got, seq)
1237			}
1238			if again := got.String(); again != canonical {
1239				t.Fatalf("canonical unstable %q -> %q", canonical, again)
1240			}
1241		})
1242	}
1243}

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