1package vimkeys
2
3import (
4 "strings"
5 "unicode"
6 "unicode/utf8"
7)
8
9func ParseBinding(s string) (Sequence, error) {
10 if !utf8.ValidString(s) {
11 return nil, ErrBadBinding
12 }
13 s = strings.TrimSpace(s)
14 if s == "" {
15 return nil, ErrEmptyBinding
16 }
17
18 if key, ok := tryParseLegacyAtom(s); ok {
19 return Sequence{key}, nil
20 }
21
22 if key, ok, err := tryParseAsLegacyChord(s); ok {
23 if err != nil {
24 return nil, err
25 }
26 return Sequence{key}, nil
27 }
28 return parseRawSequence(s)
29}
30
31// tryParseLegacyAtom treats raw UI key names enter/escape (and aliases) as single
32// keys so they remain compatible with the GTK shortcut table contract.
33func tryParseLegacyAtom(s string) (Key, bool) {
34 switch strings.ToLower(s) {
35 case "enter", "return":
36 return Key{Sym: symCR}, true
37 case "escape", "esc":
38 return Key{Sym: symEsc}, true
39 default:
40 return Key{}, false
41 }
42}
43
44func legacyChordParts(s string) []string {
45 parts := strings.Split(s, "+")
46 tokens := make([]string, 0, len(parts))
47 for _, part := range parts {
48 if part = strings.TrimSpace(part); part != "" {
49 tokens = append(tokens, part)
50 }
51 }
52 return tokens
53}
54
55func isModifierName(part string) bool {
56 switch strings.ToLower(part) {
57 case "ctrl", "control", "shift", "alt":
58 return true
59 default:
60 return false
61 }
62}
63
64func tryParseAsLegacyChord(s string) (Key, bool, error) {
65 if s == "+" {
66 return Key{Sym: "+"}, true, nil
67 }
68
69 parts := legacyChordParts(s)
70 if !legacyChordLooksLikeAttempt(s, parts) {
71 return Key{}, false, nil
72 }
73
74 key, err := buildLegacyChordKey(s, parts)
75 return key, true, err
76}
77
78func legacyChordLooksLikeAttempt(s string, parts []string) bool {
79 if strings.HasSuffix(s, "++") {
80 return true
81 }
82 for _, part := range parts {
83 if isModifierName(part) {
84 return true
85 }
86 }
87 return false
88}
89
90func validateLegacyChordLayout(s string, parts []string) error {
91 if !strings.Contains(s, "+") {
92 return ErrBadBinding
93 }
94 if strings.HasSuffix(s, "++") {
95 return nil
96 }
97 for i, part := range parts {
98 if isModifierName(part) {
99 continue
100 }
101 if i != len(parts)-1 {
102 return ErrBadBinding
103 }
104 }
105 return nil
106}
107
108func modsFromLegacyPart(part string) (Mods, bool) {
109 switch strings.ToLower(part) {
110 case "ctrl", "control":
111 return ModCtrl, true
112 case "shift":
113 return ModShift, true
114 case "alt":
115 return ModAlt, true
116 default:
117 return 0, false
118 }
119}
120
121func collectLegacyChordModsAndKey(s string, parts []string) (Mods, string, error) {
122 if err := validateLegacyChordLayout(s, parts); err != nil {
123 return 0, "", err
124 }
125
126 var mods Mods
127 keyPart := ""
128 for _, part := range parts {
129 if mod, ok := modsFromLegacyPart(part); ok {
130 mods |= mod
131 continue
132 }
133 if keyPart != "" {
134 return 0, "", ErrBadBinding
135 }
136 keyPart = part
137 }
138
139 if keyPart == "" && strings.HasSuffix(s, "++") {
140 keyPart = "+"
141 }
142 if keyPart == "" {
143 return 0, "", ErrBadBinding
144 }
145 return mods, keyPart, nil
146}
147
148func legacyKeyPartWithShift(keyPart string, mods Mods) (string, Mods) {
149 if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' {
150 return strings.ToLower(keyPart), mods | ModShift
151 }
152 return keyPart, mods
153}
154
155func buildLegacyChordKey(s string, parts []string) (Key, error) {
156 mods, keyPart, err := collectLegacyChordModsAndKey(s, parts)
157 if err != nil {
158 return Key{}, err
159 }
160 keyPart, mods = legacyKeyPartWithShift(keyPart, mods)
161 sym, err := symFromToken(keyPart, false)
162 if err != nil {
163 return Key{}, err
164 }
165 return Key{Sym: sym, Mods: mods}, nil
166}
167
168func parseAngleToken(inner string) (Key, error) {
169 if !strings.Contains(inner, "-") {
170 sym, err := symFromAlias(inner)
171 if err != nil {
172 return Key{}, err
173 }
174 return Key{Sym: sym}, nil
175 }
176
177 mods, symPart, err := splitAngleModsAndSym(inner)
178 if err != nil {
179 return Key{}, err
180 }
181
182 sym, err := symFromToken(symPart, true)
183 if err != nil {
184 return Key{}, err
185 }
186 return Key{Sym: sym, Mods: mods}, nil
187}
188
189func splitAngleModsAndSym(inner string) (Mods, string, error) {
190 var modInner, symPart string
191 if strings.HasSuffix(inner, "--") {
192 modInner = inner[:len(inner)-2]
193 symPart = "-"
194 } else {
195 lastDash := strings.LastIndexByte(inner, '-')
196 if lastDash < 0 {
197 return 0, "", ErrBadBinding
198 }
199 modInner = inner[:lastDash]
200 symPart = inner[lastDash+1:]
201 }
202 if symPart == "" || modInner == "" {
203 return 0, "", ErrBadBinding
204 }
205
206 var mods Mods
207 for _, modPart := range strings.Split(modInner, "-") {
208 modPart = strings.TrimSpace(modPart)
209 if modPart == "" {
210 return 0, "", ErrBadBinding
211 }
212 partMods, err := modsFromCompactModString(modPart)
213 if err != nil {
214 return 0, "", err
215 }
216 mods |= partMods
217 }
218 return mods, symPart, nil
219}
220
221func modsFromCompactModString(modPart string) (Mods, error) {
222 var mods Mods
223 for _, ch := range modPart {
224 switch ch {
225 case 'C', 'c':
226 mods |= ModCtrl
227 case 'S', 's':
228 mods |= ModShift
229 case 'A', 'a':
230 mods |= ModAlt
231 default:
232 return 0, ErrBadBinding
233 }
234 }
235 return mods, nil
236}
237
238func parseRawSequence(s string) (Sequence, error) {
239 seq := make(Sequence, 0, len(s))
240 for i := 0; i < len(s); {
241 if s[i] == '<' {
242 key, width, err := parseAngleAt(s, i)
243 if err != nil {
244 return nil, err
245 }
246 seq = append(seq, key)
247 i += width
248 continue
249 }
250 // Raw text is runes plus <...> tokens (and whole-string legacy atoms/chords
251 // handled above). Bare C-/S-/A- must not steal printable runs like "C-d".
252 r, width := utf8.DecodeRuneInString(s[i:])
253 key, err := keyFromRune(r)
254 if err != nil {
255 return nil, err
256 }
257 seq = append(seq, key)
258 i += width
259 }
260 return seq, nil
261}
262
263func parseAngleAt(s string, i int) (Key, int, error) {
264 if i >= len(s) || s[i] != '<' {
265 return Key{}, 0, ErrBadBinding
266 }
267 closeIdx := strings.IndexByte(s[i+1:], '>')
268 if closeIdx < 0 {
269 return Key{}, 0, ErrBadBinding
270 }
271 closeIdx += i + 1
272 inner := s[i+1 : closeIdx]
273 if inner == "" {
274 return Key{}, 0, ErrBadBinding
275 }
276 key, err := parseAngleToken(inner)
277 if err != nil {
278 return Key{}, 0, err
279 }
280 return key, closeIdx - i + 1, nil
281}
282
283func keyFromRune(r rune) (Key, error) {
284 if r >= 'A' && r <= 'Z' {
285 return Key{Sym: strings.ToLower(string(r)), Mods: ModShift}, nil
286 }
287 if shiftedGlyph, ok := shiftedGlyphForRune(r); ok {
288 return Key{Sym: shiftedGlyph}, nil
289 }
290 if r >= 'a' && r <= 'z' {
291 return Key{Sym: string(r)}, nil
292 }
293 if unicode.IsPrint(r) {
294 return Key{Sym: string(r)}, nil
295 }
296 return Key{}, ErrBadBinding
297}
298
299func shiftedGlyphForRune(r rune) (string, bool) {
300 switch r {
301 case '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '{', '}', '|', ':', '"', '~', '<', '>', '?':
302 return string(r), true
303 default:
304 return "", false
305 }
306}
307
308func symFromSpecialAlias(token string) (string, bool) {
309 lower := strings.ToLower(token)
310 switch lower {
311 case "cr", "return":
312 return symCR, true
313 case "esc", "escape":
314 return symEsc, true
315 case "space":
316 return "Space", true
317 case "lt":
318 return "<", true
319 case "gt":
320 return ">", true
321 case "plus":
322 return "+", true
323 default:
324 return namedSymFromAlias(lower)
325 }
326}
327
328func namedSymFromAlias(lower string) (string, bool) {
329 switch lower {
330 case "tab":
331 return "Tab", true
332 case "backspace":
333 return "BackSpace", true
334 case "delete":
335 return "Delete", true
336 case "left":
337 return "Left", true
338 case "right":
339 return "Right", true
340 case "up":
341 return "Up", true
342 case "down":
343 return "Down", true
344 case "home":
345 return "Home", true
346 case "end":
347 return "End", true
348 case "pageup":
349 return "PageUp", true
350 case "pagedown":
351 return "PageDown", true
352 default:
353 return "", false
354 }
355}
356
357func symFromToken(token string, allowSingleLetter bool) (string, error) {
358 if sym, ok := symFromSpecialAlias(token); ok {
359 return sym, nil
360 }
361 if allowSingleLetter {
362 if sym, ok := asciiLetterSym(token); ok {
363 return sym, nil
364 }
365 }
366 if sym, ok := singlePrintableRuneSym(token); ok {
367 return sym, nil
368 }
369 return "", ErrBadBinding
370}
371
372func symFromAlias(token string) (string, error) {
373 if sym, ok := symFromSpecialAlias(token); ok {
374 return sym, nil
375 }
376 // One-character / one-rune literal angle tokens (<e>, <é>) disambiguate
377 // legacy-atom collisions. Multi-rune unknowns such as <Nope> remain invalid.
378 if sym, ok := asciiLetterSym(token); ok {
379 return sym, nil
380 }
381 if sym, ok := singlePrintableRuneSym(token); ok {
382 return sym, nil
383 }
384 return "", ErrBadBinding
385}
386
387// asciiLetterSym normalizes a single ASCII letter token (A-Z -> a-z).
388func asciiLetterSym(token string) (string, bool) {
389 if len(token) != 1 {
390 return "", false
391 }
392 ch := token[0]
393 if ch >= 'A' && ch <= 'Z' {
394 return strings.ToLower(token), true
395 }
396 if ch >= 'a' && ch <= 'z' {
397 return token, true
398 }
399 return "", false
400}
401
402// singlePrintableRuneSym accepts exactly one valid UTF-8 printable rune.
403func singlePrintableRuneSym(token string) (string, bool) {
404 if token == "" || !utf8.ValidString(token) {
405 return "", false
406 }
407 r, size := utf8.DecodeRuneInString(token)
408 if size != len(token) {
409 return "", false
410 }
411 if !unicode.IsPrint(r) {
412 return "", false
413 }
414 return token, true
415}