fix(vimkeys): round trip modified hyphen keys
- commit
- b856ec1c4f15764151ed94d3e7f0eaf370e8f710
- author
- brice · · 2026-07-29 05:31 UTC
- committer
- bnema · · 2026-08-14 02:42 UTC
- parent
- c67625f
- signature
- unsigned
2 files · +107 -27
M internal/domain/vimkeys/parse.go +42 -27
| @@ -163,41 +163,56 @@ func buildLegacyChordKey(s string, parts []string) (Key, error) { | |||
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | 165 | func parseAngleToken(inner string) (Key, error) { | |
| 166 | - | if strings.Contains(inner, "-") { | |
| 167 | - | parts := strings.Split(inner, "-") | |
| 168 | - | if len(parts) < 2 { | |
| 169 | - | return Key{}, ErrBadBinding | |
| 170 | - | } | |
| 171 | - | symPart := strings.TrimSpace(parts[len(parts)-1]) | |
| 172 | - | if symPart == "" { | |
| 173 | - | return Key{}, ErrBadBinding | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | var mods Mods | |
| 177 | - | for _, modPart := range parts[:len(parts)-1] { | |
| 178 | - | modPart = strings.TrimSpace(modPart) | |
| 179 | - | if modPart == "" { | |
| 180 | - | return Key{}, ErrBadBinding | |
| 181 | - | } | |
| 182 | - | partMods, err := modsFromCompactModString(modPart) | |
| 183 | - | if err != nil { | |
| 184 | - | return Key{}, err | |
| 185 | - | } | |
| 186 | - | mods |= partMods | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | sym, err := symFromToken(symPart, true) | |
| 166 | + | if !strings.Contains(inner, "-") { | |
| 167 | + | sym, err := symFromAlias(inner) | |
| 190 | 168 | if err != nil { | |
| 191 | 169 | return Key{}, err | |
| 192 | 170 | } | |
| 193 | - | return Key{Sym: sym, Mods: mods}, nil | |
| 171 | + | return Key{Sym: sym}, nil | |
| 194 | 172 | } | |
| 195 | 173 | ||
| 196 | - | sym, err := symFromAlias(inner) | |
| 174 | + | mods, symPart, err := splitAngleModsAndSym(inner) | |
| 175 | + | if err != nil { | |
| 176 | + | return Key{}, err | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | sym, err := symFromToken(symPart, true) | |
| 197 | 180 | if err != nil { | |
| 198 | 181 | return Key{}, err | |
| 199 | 182 | } | |
| 200 | - | return Key{Sym: sym}, nil | |
| 183 | + | return Key{Sym: sym, Mods: mods}, nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func splitAngleModsAndSym(inner string) (Mods, string, error) { | |
| 187 | + | var modInner, symPart string | |
| 188 | + | if strings.HasSuffix(inner, "--") { | |
| 189 | + | modInner = inner[:len(inner)-2] | |
| 190 | + | symPart = "-" | |
| 191 | + | } else { | |
| 192 | + | lastDash := strings.LastIndexByte(inner, '-') | |
| 193 | + | if lastDash < 0 { | |
| 194 | + | return 0, "", ErrBadBinding | |
| 195 | + | } | |
| 196 | + | modInner = inner[:lastDash] | |
| 197 | + | symPart = inner[lastDash+1:] | |
| 198 | + | } | |
| 199 | + | if symPart == "" || modInner == "" { | |
| 200 | + | return 0, "", ErrBadBinding | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | var mods Mods | |
| 204 | + | for _, modPart := range strings.Split(modInner, "-") { | |
| 205 | + | modPart = strings.TrimSpace(modPart) | |
| 206 | + | if modPart == "" { | |
| 207 | + | return 0, "", ErrBadBinding | |
| 208 | + | } | |
| 209 | + | partMods, err := modsFromCompactModString(modPart) | |
| 210 | + | if err != nil { | |
| 211 | + | return 0, "", err | |
| 212 | + | } | |
| 213 | + | mods |= partMods | |
| 214 | + | } | |
| 215 | + | return mods, symPart, nil | |
| 201 | 216 | } | |
| 202 | 217 | ||
| 203 | 218 | func modsFromCompactModString(modPart string) (Mods, error) { | |
| @@ -163,41 +163,56 @@ func buildLegacyChordKey(s string, parts []string) (Key, error) { | |||||
| 163 | } | 163 | } | ||
| 164 | 164 | ||||
| 165 | func parseAngleToken(inner string) (Key, error) { | 165 | func parseAngleToken(inner string) (Key, error) { | ||
| 166 | - | if strings.Contains(inner, "-") { | 166 | + | if !strings.Contains(inner, "-") { |
| 167 | - | parts := strings.Split(inner, "-") | 167 | + | sym, err := symFromAlias(inner) |
| 168 | - | if len(parts) < 2 { | |||
| 169 | - | return Key{}, ErrBadBinding | |||
| 170 | - | } | |||
| 171 | - | symPart := strings.TrimSpace(parts[len(parts)-1]) | |||
| 172 | - | if symPart == "" { | |||
| 173 | - | return Key{}, ErrBadBinding | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | var mods Mods | |||
| 177 | - | for _, modPart := range parts[:len(parts)-1] { | |||
| 178 | - | modPart = strings.TrimSpace(modPart) | |||
| 179 | - | if modPart == "" { | |||
| 180 | - | return Key{}, ErrBadBinding | |||
| 181 | - | } | |||
| 182 | - | partMods, err := modsFromCompactModString(modPart) | |||
| 183 | - | if err != nil { | |||
| 184 | - | return Key{}, err | |||
| 185 | - | } | |||
| 186 | - | mods |= partMods | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | sym, err := symFromToken(symPart, true) | |||
| 190 | if err != nil { | 168 | if err != nil { | ||
| 191 | return Key{}, err | 169 | return Key{}, err | ||
| 192 | } | 170 | } | ||
| 193 | - | return Key{Sym: sym, Mods: mods}, nil | 171 | + | return Key{Sym: sym}, nil |
| 194 | } | 172 | } | ||
| 195 | 173 | ||||
| 196 | - | sym, err := symFromAlias(inner) | 174 | + | mods, symPart, err := splitAngleModsAndSym(inner) |
| 175 | + | if err != nil { | |||
| 176 | + | return Key{}, err | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | sym, err := symFromToken(symPart, true) | |||
| 197 | if err != nil { | 180 | if err != nil { | ||
| 198 | return Key{}, err | 181 | return Key{}, err | ||
| 199 | } | 182 | } | ||
| 200 | - | return Key{Sym: sym}, nil | 183 | + | return Key{Sym: sym, Mods: mods}, nil |
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func splitAngleModsAndSym(inner string) (Mods, string, error) { | |||
| 187 | + | var modInner, symPart string | |||
| 188 | + | if strings.HasSuffix(inner, "--") { | |||
| 189 | + | modInner = inner[:len(inner)-2] | |||
| 190 | + | symPart = "-" | |||
| 191 | + | } else { | |||
| 192 | + | lastDash := strings.LastIndexByte(inner, '-') | |||
| 193 | + | if lastDash < 0 { | |||
| 194 | + | return 0, "", ErrBadBinding | |||
| 195 | + | } | |||
| 196 | + | modInner = inner[:lastDash] | |||
| 197 | + | symPart = inner[lastDash+1:] | |||
| 198 | + | } | |||
| 199 | + | if symPart == "" || modInner == "" { | |||
| 200 | + | return 0, "", ErrBadBinding | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | var mods Mods | |||
| 204 | + | for _, modPart := range strings.Split(modInner, "-") { | |||
| 205 | + | modPart = strings.TrimSpace(modPart) | |||
| 206 | + | if modPart == "" { | |||
| 207 | + | return 0, "", ErrBadBinding | |||
| 208 | + | } | |||
| 209 | + | partMods, err := modsFromCompactModString(modPart) | |||
| 210 | + | if err != nil { | |||
| 211 | + | return 0, "", err | |||
| 212 | + | } | |||
| 213 | + | mods |= partMods | |||
| 214 | + | } | |||
| 215 | + | return mods, symPart, nil | |||
| 201 | } | 216 | } | ||
| 202 | 217 | ||||
| 203 | func modsFromCompactModString(modPart string) (Mods, error) { | 218 | func modsFromCompactModString(modPart string) (Mods, error) { | ||
M internal/domain/vimkeys/parse_test.go +65 -0
| @@ -13,6 +13,7 @@ func TestParseBinding_SingleKeys(t *testing.T) { | |||
| 13 | 13 | {input: "j", want: Sequence{{Sym: "j"}}}, | |
| 14 | 14 | {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}}, | |
| 15 | 15 | {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}}, | |
| 16 | + | {input: "ctrl+-", want: Sequence{{Sym: "-", Mods: ModCtrl}}}, | |
| 16 | 17 | {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}}, | |
| 17 | 18 | {input: "<CR>", want: Sequence{{Sym: "CR"}}}, | |
| 18 | 19 | {input: "<Return>", want: Sequence{{Sym: "CR"}}}, | |
| @@ -110,6 +111,8 @@ func TestParseBinding_CanonicalString(t *testing.T) { | |||
| 110 | 111 | {input: "j", want: "j"}, | |
| 111 | 112 | {input: "shift+j", want: "J"}, | |
| 112 | 113 | {input: "ctrl++", want: "<C-+>"}, | |
| 114 | + | {input: "ctrl+-", want: "<C-->"}, | |
| 115 | + | {input: "<C-->", want: "<C-->"}, | |
| 113 | 116 | {input: "<C-d>", want: "<C-d>"}, | |
| 114 | 117 | {input: "<CR>", want: "<Return>"}, | |
| 115 | 118 | {input: "<Return>", want: "<Return>"}, | |
| @@ -322,6 +325,61 @@ func TestParseBinding_MixedAngleSequences(t *testing.T) { | |||
| 322 | 325 | } | |
| 323 | 326 | } | |
| 324 | 327 | ||
| 328 | + | func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) { | |
| 329 | + | tests := []struct { | |
| 330 | + | input string | |
| 331 | + | want Sequence | |
| 332 | + | wantString string | |
| 333 | + | }{ | |
| 334 | + | { | |
| 335 | + | input: "ctrl+-", | |
| 336 | + | want: Sequence{{Sym: "-", Mods: ModCtrl}}, | |
| 337 | + | wantString: "<C-->", | |
| 338 | + | }, | |
| 339 | + | { | |
| 340 | + | input: "<C-->", | |
| 341 | + | want: Sequence{{Sym: "-", Mods: ModCtrl}}, | |
| 342 | + | wantString: "<C-->", | |
| 343 | + | }, | |
| 344 | + | { | |
| 345 | + | input: "<C-S-->", | |
| 346 | + | want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift}}, | |
| 347 | + | wantString: "<C-S-->", | |
| 348 | + | }, | |
| 349 | + | { | |
| 350 | + | input: "<C-A-->", | |
| 351 | + | want: Sequence{{Sym: "-", Mods: ModCtrl | ModAlt}}, | |
| 352 | + | wantString: "<C-A-->", | |
| 353 | + | }, | |
| 354 | + | { | |
| 355 | + | input: "<C-S-A-->", | |
| 356 | + | want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift | ModAlt}}, | |
| 357 | + | wantString: "<C-S-A-->", | |
| 358 | + | }, | |
| 359 | + | } | |
| 360 | + | for _, tt := range tests { | |
| 361 | + | t.Run(tt.input, func(t *testing.T) { | |
| 362 | + | first, err := ParseBinding(tt.input) | |
| 363 | + | if err != nil { | |
| 364 | + | t.Fatalf("ParseBinding(%q) error = %v", tt.input, err) | |
| 365 | + | } | |
| 366 | + | if !first.Equal(tt.want) { | |
| 367 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want) | |
| 368 | + | } | |
| 369 | + | if got := first.String(); got != tt.wantString { | |
| 370 | + | t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString) | |
| 371 | + | } | |
| 372 | + | second, err := ParseBinding(first.String()) | |
| 373 | + | if err != nil { | |
| 374 | + | t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err) | |
| 375 | + | } | |
| 376 | + | if !first.Equal(second) { | |
| 377 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first) | |
| 378 | + | } | |
| 379 | + | }) | |
| 380 | + | } | |
| 381 | + | } | |
| 382 | + | ||
| 325 | 383 | func TestParseBinding_MultiModifierRoundTrip(t *testing.T) { | |
| 326 | 384 | input := "<C-S-a>" | |
| 327 | 385 | first, err := ParseBinding(input) | |
| @@ -352,6 +410,10 @@ func TestParseBinding_RoundTrip(t *testing.T) { | |||
| 352 | 410 | "j", | |
| 353 | 411 | "shift+j", | |
| 354 | 412 | "ctrl++", | |
| 413 | + | "ctrl+-", | |
| 414 | + | "<C-->", | |
| 415 | + | "<C-S-->", | |
| 416 | + | "<C-A-->", | |
| 355 | 417 | "]]", | |
| 356 | 418 | "yah", | |
| 357 | 419 | "gO", | |
| @@ -438,6 +500,9 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) { | |||
| 438 | 500 | {{Sym: "Delete", Mods: ModAlt}}, | |
| 439 | 501 | {{Sym: "<"}}, | |
| 440 | 502 | {{Sym: "+"}}, | |
| 503 | + | {{Sym: "-", Mods: ModCtrl}}, | |
| 504 | + | {{Sym: "-", Mods: ModCtrl | ModShift}}, | |
| 505 | + | {{Sym: "-", Mods: ModCtrl | ModAlt}}, | |
| 441 | 506 | {{Sym: "!"}}, | |
| 442 | 507 | {{Sym: "@"}}, | |
| 443 | 508 | {{Sym: "#"}}, | |
| @@ -13,6 +13,7 @@ func TestParseBinding_SingleKeys(t *testing.T) { | |||||
| 13 | {input: "j", want: Sequence{{Sym: "j"}}}, | 13 | {input: "j", want: Sequence{{Sym: "j"}}}, | ||
| 14 | {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}}, | 14 | {input: "shift+j", want: Sequence{{Sym: "j", Mods: ModShift}}}, | ||
| 15 | {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}}, | 15 | {input: "ctrl++", want: Sequence{{Sym: "+", Mods: ModCtrl}}}, | ||
| 16 | + | {input: "ctrl+-", want: Sequence{{Sym: "-", Mods: ModCtrl}}}, | |||
| 16 | {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}}, | 17 | {input: "<C-d>", want: Sequence{{Sym: "d", Mods: ModCtrl}}}, | ||
| 17 | {input: "<CR>", want: Sequence{{Sym: "CR"}}}, | 18 | {input: "<CR>", want: Sequence{{Sym: "CR"}}}, | ||
| 18 | {input: "<Return>", want: Sequence{{Sym: "CR"}}}, | 19 | {input: "<Return>", want: Sequence{{Sym: "CR"}}}, | ||
| @@ -110,6 +111,8 @@ func TestParseBinding_CanonicalString(t *testing.T) { | |||||
| 110 | {input: "j", want: "j"}, | 111 | {input: "j", want: "j"}, | ||
| 111 | {input: "shift+j", want: "J"}, | 112 | {input: "shift+j", want: "J"}, | ||
| 112 | {input: "ctrl++", want: "<C-+>"}, | 113 | {input: "ctrl++", want: "<C-+>"}, | ||
| 114 | + | {input: "ctrl+-", want: "<C-->"}, | |||
| 115 | + | {input: "<C-->", want: "<C-->"}, | |||
| 113 | {input: "<C-d>", want: "<C-d>"}, | 116 | {input: "<C-d>", want: "<C-d>"}, | ||
| 114 | {input: "<CR>", want: "<Return>"}, | 117 | {input: "<CR>", want: "<Return>"}, | ||
| 115 | {input: "<Return>", want: "<Return>"}, | 118 | {input: "<Return>", want: "<Return>"}, | ||
| @@ -322,6 +325,61 @@ func TestParseBinding_MixedAngleSequences(t *testing.T) { | |||||
| 322 | } | 325 | } | ||
| 323 | } | 326 | } | ||
| 324 | 327 | ||||
| 328 | + | func TestParseBinding_CtrlHyphenRoundTrip(t *testing.T) { | |||
| 329 | + | tests := []struct { | |||
| 330 | + | input string | |||
| 331 | + | want Sequence | |||
| 332 | + | wantString string | |||
| 333 | + | }{ | |||
| 334 | + | { | |||
| 335 | + | input: "ctrl+-", | |||
| 336 | + | want: Sequence{{Sym: "-", Mods: ModCtrl}}, | |||
| 337 | + | wantString: "<C-->", | |||
| 338 | + | }, | |||
| 339 | + | { | |||
| 340 | + | input: "<C-->", | |||
| 341 | + | want: Sequence{{Sym: "-", Mods: ModCtrl}}, | |||
| 342 | + | wantString: "<C-->", | |||
| 343 | + | }, | |||
| 344 | + | { | |||
| 345 | + | input: "<C-S-->", | |||
| 346 | + | want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift}}, | |||
| 347 | + | wantString: "<C-S-->", | |||
| 348 | + | }, | |||
| 349 | + | { | |||
| 350 | + | input: "<C-A-->", | |||
| 351 | + | want: Sequence{{Sym: "-", Mods: ModCtrl | ModAlt}}, | |||
| 352 | + | wantString: "<C-A-->", | |||
| 353 | + | }, | |||
| 354 | + | { | |||
| 355 | + | input: "<C-S-A-->", | |||
| 356 | + | want: Sequence{{Sym: "-", Mods: ModCtrl | ModShift | ModAlt}}, | |||
| 357 | + | wantString: "<C-S-A-->", | |||
| 358 | + | }, | |||
| 359 | + | } | |||
| 360 | + | for _, tt := range tests { | |||
| 361 | + | t.Run(tt.input, func(t *testing.T) { | |||
| 362 | + | first, err := ParseBinding(tt.input) | |||
| 363 | + | if err != nil { | |||
| 364 | + | t.Fatalf("ParseBinding(%q) error = %v", tt.input, err) | |||
| 365 | + | } | |||
| 366 | + | if !first.Equal(tt.want) { | |||
| 367 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, first, tt.want) | |||
| 368 | + | } | |||
| 369 | + | if got := first.String(); got != tt.wantString { | |||
| 370 | + | t.Fatalf("ParseBinding(%q).String() = %q, want %q", tt.input, got, tt.wantString) | |||
| 371 | + | } | |||
| 372 | + | second, err := ParseBinding(first.String()) | |||
| 373 | + | if err != nil { | |||
| 374 | + | t.Fatalf("ParseBinding(%q) from canonical %q error = %v", tt.input, first.String(), err) | |||
| 375 | + | } | |||
| 376 | + | if !first.Equal(second) { | |||
| 377 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", tt.input, first.String(), second, first) | |||
| 378 | + | } | |||
| 379 | + | }) | |||
| 380 | + | } | |||
| 381 | + | } | |||
| 382 | + | ||||
| 325 | func TestParseBinding_MultiModifierRoundTrip(t *testing.T) { | 383 | func TestParseBinding_MultiModifierRoundTrip(t *testing.T) { | ||
| 326 | input := "<C-S-a>" | 384 | input := "<C-S-a>" | ||
| 327 | first, err := ParseBinding(input) | 385 | first, err := ParseBinding(input) | ||
| @@ -352,6 +410,10 @@ func TestParseBinding_RoundTrip(t *testing.T) { | |||||
| 352 | "j", | 410 | "j", | ||
| 353 | "shift+j", | 411 | "shift+j", | ||
| 354 | "ctrl++", | 412 | "ctrl++", | ||
| 413 | + | "ctrl+-", | |||
| 414 | + | "<C-->", | |||
| 415 | + | "<C-S-->", | |||
| 416 | + | "<C-A-->", | |||
| 355 | "]]", | 417 | "]]", | ||
| 356 | "yah", | 418 | "yah", | ||
| 357 | "gO", | 419 | "gO", | ||
| @@ -438,6 +500,9 @@ func TestParseBinding_DeterministicRoundTripProperty(t *testing.T) { | |||||
| 438 | {{Sym: "Delete", Mods: ModAlt}}, | 500 | {{Sym: "Delete", Mods: ModAlt}}, | ||
| 439 | {{Sym: "<"}}, | 501 | {{Sym: "<"}}, | ||
| 440 | {{Sym: "+"}}, | 502 | {{Sym: "+"}}, | ||
| 503 | + | {{Sym: "-", Mods: ModCtrl}}, | |||
| 504 | + | {{Sym: "-", Mods: ModCtrl | ModShift}}, | |||
| 505 | + | {{Sym: "-", Mods: ModCtrl | ModAlt}}, | |||
| 441 | {{Sym: "!"}}, | 506 | {{Sym: "!"}}, | ||
| 442 | {{Sym: "@"}}, | 507 | {{Sym: "@"}}, | ||
| 443 | {{Sym: "#"}}, | 508 | {{Sym: "#"}}, | ||