fix(vimkeys): preserve canonical matcher invariants
- commit
- e7d570f43127650750fec989a96e923daf0b2975
- author
- brice · · 2026-07-28 17:42 UTC
- committer
- bnema · · 2026-08-14 02:42 UTC
- parent
- 7d5dafe
- signature
- unsigned
4 files · +147 -52
M internal/domain/vimkeys/matcher.go +2 -2
| @@ -108,10 +108,10 @@ func (m *Matcher) feedSequenceKey(key Key) Result { | |||
| 108 | 108 | ||
| 109 | 109 | exact := m.node.hasAction | |
| 110 | 110 | children := len(m.node.children) > 0 | |
| 111 | + | m.ambiguous = exact && children | |
| 111 | 112 | ||
| 112 | 113 | switch { | |
| 113 | - | case exact && children: | |
| 114 | - | m.ambiguous = true | |
| 114 | + | case m.ambiguous: | |
| 115 | 115 | return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()} | |
| 116 | 116 | case exact: | |
| 117 | 117 | result := Result{ | |
| @@ -108,10 +108,10 @@ func (m *Matcher) feedSequenceKey(key Key) Result { | |||||
| 108 | 108 | ||||
| 109 | exact := m.node.hasAction | 109 | exact := m.node.hasAction | ||
| 110 | children := len(m.node.children) > 0 | 110 | children := len(m.node.children) > 0 | ||
| 111 | + | m.ambiguous = exact && children | |||
| 111 | 112 | ||||
| 112 | switch { | 113 | switch { | ||
| 113 | - | case exact && children: | 114 | + | case m.ambiguous: |
| 114 | - | m.ambiguous = true | |||
| 115 | return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()} | 115 | return Result{Kind: ResultPending, Count: normalizedCount(m.count), Pending: m.Pending()} | ||
| 116 | case exact: | 116 | case exact: | ||
| 117 | result := Result{ | 117 | result := Result{ | ||
M internal/domain/vimkeys/matcher_test.go +34 -0
| @@ -235,6 +235,40 @@ func TestMatcher_AutomaticReset(t *testing.T) { | |||
| 235 | 235 | } | |
| 236 | 236 | } | |
| 237 | 237 | ||
| 238 | + | func TestMatcher_AmbiguityClearsOnTransition(t *testing.T) { | |
| 239 | + | trie := NewTrie() | |
| 240 | + | for _, binding := range []struct { | |
| 241 | + | seq Sequence | |
| 242 | + | action string | |
| 243 | + | }{ | |
| 244 | + | {seq: Sequence{{Sym: "]"}}, action: "single-bracket"}, | |
| 245 | + | {seq: Sequence{{Sym: "]"}, {Sym: "]"}, {Sym: "x"}}, action: "triple-bracket"}, | |
| 246 | + | } { | |
| 247 | + | if err := trie.Insert(binding.seq, binding.action); err != nil { | |
| 248 | + | t.Fatalf("Insert(%v) error = %v", binding.seq, err) | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | m := NewMatcher(trie) | |
| 253 | + | m.Reset() | |
| 254 | + | ||
| 255 | + | result := m.Feed(Key{Sym: "]"}) | |
| 256 | + | if result.Kind != ResultPending { | |
| 257 | + | t.Fatalf("after ] Feed kind = %v, want %v", result.Kind, ResultPending) | |
| 258 | + | } | |
| 259 | + | if !m.Ambiguous() { | |
| 260 | + | t.Fatal("Ambiguous() after ] = false, want true") | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | result = m.Feed(Key{Sym: "]"}) | |
| 264 | + | if result.Kind != ResultPending { | |
| 265 | + | t.Fatalf("after ]] Feed kind = %v, want %v", result.Kind, ResultPending) | |
| 266 | + | } | |
| 267 | + | if m.Ambiguous() { | |
| 268 | + | t.Fatal("Ambiguous() after ]] = true, want false") | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 238 | 272 | func TestMatcher_AmbiguousShortLong(t *testing.T) { | |
| 239 | 273 | m := NewMatcher(testTrie(t)) | |
| 240 | 274 | ||
| @@ -235,6 +235,40 @@ func TestMatcher_AutomaticReset(t *testing.T) { | |||||
| 235 | } | 235 | } | ||
| 236 | } | 236 | } | ||
| 237 | 237 | ||||
| 238 | + | func TestMatcher_AmbiguityClearsOnTransition(t *testing.T) { | |||
| 239 | + | trie := NewTrie() | |||
| 240 | + | for _, binding := range []struct { | |||
| 241 | + | seq Sequence | |||
| 242 | + | action string | |||
| 243 | + | }{ | |||
| 244 | + | {seq: Sequence{{Sym: "]"}}, action: "single-bracket"}, | |||
| 245 | + | {seq: Sequence{{Sym: "]"}, {Sym: "]"}, {Sym: "x"}}, action: "triple-bracket"}, | |||
| 246 | + | } { | |||
| 247 | + | if err := trie.Insert(binding.seq, binding.action); err != nil { | |||
| 248 | + | t.Fatalf("Insert(%v) error = %v", binding.seq, err) | |||
| 249 | + | } | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | m := NewMatcher(trie) | |||
| 253 | + | m.Reset() | |||
| 254 | + | ||||
| 255 | + | result := m.Feed(Key{Sym: "]"}) | |||
| 256 | + | if result.Kind != ResultPending { | |||
| 257 | + | t.Fatalf("after ] Feed kind = %v, want %v", result.Kind, ResultPending) | |||
| 258 | + | } | |||
| 259 | + | if !m.Ambiguous() { | |||
| 260 | + | t.Fatal("Ambiguous() after ] = false, want true") | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | result = m.Feed(Key{Sym: "]"}) | |||
| 264 | + | if result.Kind != ResultPending { | |||
| 265 | + | t.Fatalf("after ]] Feed kind = %v, want %v", result.Kind, ResultPending) | |||
| 266 | + | } | |||
| 267 | + | if m.Ambiguous() { | |||
| 268 | + | t.Fatal("Ambiguous() after ]] = true, want false") | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 238 | func TestMatcher_AmbiguousShortLong(t *testing.T) { | 272 | func TestMatcher_AmbiguousShortLong(t *testing.T) { | ||
| 239 | m := NewMatcher(testTrie(t)) | 273 | m := NewMatcher(testTrie(t)) | ||
| 240 | 274 | ||||
M internal/domain/vimkeys/parse.go +56 -50
| @@ -22,9 +22,6 @@ func ParseBinding(s string) (Sequence, error) { | |||
| 22 | 22 | return nil, ErrEmptyBinding | |
| 23 | 23 | } | |
| 24 | 24 | ||
| 25 | - | if strings.HasPrefix(s, "<") { | |
| 26 | - | return parseAngleBinding(s) | |
| 27 | - | } | |
| 28 | 25 | if looksLikeChordAttempt(s) { | |
| 29 | 26 | if !isLegacyChord(s) { | |
| 30 | 27 | return nil, ErrBadBinding | |
| @@ -138,46 +135,35 @@ func parseLegacyChord(s string) (Key, error) { | |||
| 138 | 135 | return Key{Sym: sym, Mods: mods}, nil | |
| 139 | 136 | } | |
| 140 | 137 | ||
| 141 | - | func parseAngleBinding(s string) (Sequence, error) { | |
| 142 | - | if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") || len(s) < 3 { | |
| 143 | - | return nil, ErrBadBinding | |
| 144 | - | } | |
| 145 | - | inner := s[1 : len(s)-1] | |
| 146 | - | if inner == "" { | |
| 147 | - | return nil, ErrBadBinding | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | key, err := parseAngleToken(inner) | |
| 151 | - | if err != nil { | |
| 152 | - | return nil, err | |
| 153 | - | } | |
| 154 | - | return Sequence{key}, nil | |
| 155 | - | } | |
| 156 | - | ||
| 157 | 138 | func parseAngleToken(inner string) (Key, error) { | |
| 158 | 139 | if strings.Contains(inner, "-") { | |
| 159 | 140 | parts := strings.Split(inner, "-") | |
| 160 | - | if len(parts) != 2 { | |
| 141 | + | if len(parts) < 2 { | |
| 161 | 142 | return Key{}, ErrBadBinding | |
| 162 | 143 | } | |
| 163 | - | modPart := strings.TrimSpace(parts[0]) | |
| 164 | - | symPart := strings.TrimSpace(parts[1]) | |
| 165 | - | if modPart == "" || symPart == "" { | |
| 144 | + | symPart := strings.TrimSpace(parts[len(parts)-1]) | |
| 145 | + | if symPart == "" { | |
| 166 | 146 | return Key{}, ErrBadBinding | |
| 167 | 147 | } | |
| 168 | 148 | ||
| 169 | 149 | var mods Mods | |
| 170 | - | for _, ch := range modPart { | |
| 171 | - | switch ch { | |
| 172 | - | case 'C', 'c': | |
| 173 | - | mods |= ModCtrl | |
| 174 | - | case 'S', 's': | |
| 175 | - | mods |= ModShift | |
| 176 | - | case 'A', 'a': | |
| 177 | - | mods |= ModAlt | |
| 178 | - | default: | |
| 150 | + | for _, modPart := range parts[:len(parts)-1] { | |
| 151 | + | modPart = strings.TrimSpace(modPart) | |
| 152 | + | if modPart == "" { | |
| 179 | 153 | return Key{}, ErrBadBinding | |
| 180 | 154 | } | |
| 155 | + | for _, ch := range modPart { | |
| 156 | + | switch ch { | |
| 157 | + | case 'C', 'c': | |
| 158 | + | mods |= ModCtrl | |
| 159 | + | case 'S', 's': | |
| 160 | + | mods |= ModShift | |
| 161 | + | case 'A', 'a': | |
| 162 | + | mods |= ModAlt | |
| 163 | + | default: | |
| 164 | + | return Key{}, ErrBadBinding | |
| 165 | + | } | |
| 166 | + | } | |
| 181 | 167 | } | |
| 182 | 168 | ||
| 183 | 169 | sym, err := symFromToken(symPart, true) | |
| @@ -199,6 +185,15 @@ var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"} | |||
| 199 | 185 | func parseRawSequence(s string) (Sequence, error) { | |
| 200 | 186 | seq := make(Sequence, 0, len(s)) | |
| 201 | 187 | for i := 0; i < len(s); { | |
| 188 | + | if s[i] == '<' { | |
| 189 | + | key, width, err := parseAngleAt(s, i) | |
| 190 | + | if err != nil { | |
| 191 | + | return nil, err | |
| 192 | + | } | |
| 193 | + | seq = append(seq, key) | |
| 194 | + | i += width | |
| 195 | + | continue | |
| 196 | + | } | |
| 202 | 197 | if key, width, ok := tryParseSpecialAt(s, i); ok { | |
| 203 | 198 | seq = append(seq, key) | |
| 204 | 199 | i += width | |
| @@ -221,6 +216,26 @@ func parseRawSequence(s string) (Sequence, error) { | |||
| 221 | 216 | return seq, nil | |
| 222 | 217 | } | |
| 223 | 218 | ||
| 219 | + | func parseAngleAt(s string, i int) (Key, int, error) { | |
| 220 | + | if i >= len(s) || s[i] != '<' { | |
| 221 | + | return Key{}, 0, ErrBadBinding | |
| 222 | + | } | |
| 223 | + | closeIdx := strings.IndexByte(s[i+1:], '>') | |
| 224 | + | if closeIdx < 0 { | |
| 225 | + | return Key{}, 0, ErrBadBinding | |
| 226 | + | } | |
| 227 | + | closeIdx += i + 1 | |
| 228 | + | inner := s[i+1 : closeIdx] | |
| 229 | + | if inner == "" { | |
| 230 | + | return Key{}, 0, ErrBadBinding | |
| 231 | + | } | |
| 232 | + | key, err := parseAngleToken(inner) | |
| 233 | + | if err != nil { | |
| 234 | + | return Key{}, 0, err | |
| 235 | + | } | |
| 236 | + | return key, closeIdx - i + 1, nil | |
| 237 | + | } | |
| 238 | + | ||
| 224 | 239 | func tryParseSpecialAt(s string, i int) (Key, int, bool) { | |
| 225 | 240 | for _, name := range sequenceSpecialNames { | |
| 226 | 241 | if !strings.HasPrefix(s[i:], name) { | |
| @@ -248,25 +263,16 @@ func tryParseCanonicalAt(s string, i int) (Key, int, bool) { | |||
| 248 | 263 | return Key{}, 0, false | |
| 249 | 264 | } | |
| 250 | 265 | ||
| 251 | - | end := i | |
| 252 | - | for end < len(s) { | |
| 253 | - | nextDash := strings.IndexByte(s[end:], '-') | |
| 254 | - | if nextDash < 0 { | |
| 255 | - | end = len(s) | |
| 256 | - | break | |
| 257 | - | } | |
| 258 | - | nextDash += end | |
| 259 | - | if nextDash+1 >= len(s) { | |
| 260 | - | end = len(s) | |
| 261 | - | break | |
| 262 | - | } | |
| 263 | - | switch s[nextDash+1] { | |
| 264 | - | case 'C', 'S', 'A': | |
| 265 | - | end = nextDash | |
| 266 | - | default: | |
| 267 | - | end = len(s) | |
| 266 | + | end := len(s) | |
| 267 | + | nextDash := strings.IndexByte(s[i:], '-') | |
| 268 | + | if nextDash >= 0 { | |
| 269 | + | nextDash += i | |
| 270 | + | if nextDash+1 < len(s) { | |
| 271 | + | switch s[nextDash+1] { | |
| 272 | + | case 'C', 'S', 'A': | |
| 273 | + | end = nextDash | |
| 274 | + | } | |
| 268 | 275 | } | |
| 269 | - | break | |
| 270 | 276 | } | |
| 271 | 277 | ||
| 272 | 278 | token := s[i:end] | |
| @@ -22,9 +22,6 @@ func ParseBinding(s string) (Sequence, error) { | |||||
| 22 | return nil, ErrEmptyBinding | 22 | return nil, ErrEmptyBinding | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| 25 | - | if strings.HasPrefix(s, "<") { | |||
| 26 | - | return parseAngleBinding(s) | |||
| 27 | - | } | |||
| 28 | if looksLikeChordAttempt(s) { | 25 | if looksLikeChordAttempt(s) { | ||
| 29 | if !isLegacyChord(s) { | 26 | if !isLegacyChord(s) { | ||
| 30 | return nil, ErrBadBinding | 27 | return nil, ErrBadBinding | ||
| @@ -138,46 +135,35 @@ func parseLegacyChord(s string) (Key, error) { | |||||
| 138 | return Key{Sym: sym, Mods: mods}, nil | 135 | return Key{Sym: sym, Mods: mods}, nil | ||
| 139 | } | 136 | } | ||
| 140 | 137 | ||||
| 141 | - | func parseAngleBinding(s string) (Sequence, error) { | |||
| 142 | - | if !strings.HasPrefix(s, "<") || !strings.HasSuffix(s, ">") || len(s) < 3 { | |||
| 143 | - | return nil, ErrBadBinding | |||
| 144 | - | } | |||
| 145 | - | inner := s[1 : len(s)-1] | |||
| 146 | - | if inner == "" { | |||
| 147 | - | return nil, ErrBadBinding | |||
| 148 | - | } | |||
| 149 | - | ||||
| 150 | - | key, err := parseAngleToken(inner) | |||
| 151 | - | if err != nil { | |||
| 152 | - | return nil, err | |||
| 153 | - | } | |||
| 154 | - | return Sequence{key}, nil | |||
| 155 | - | } | |||
| 156 | - | ||||
| 157 | func parseAngleToken(inner string) (Key, error) { | 138 | func parseAngleToken(inner string) (Key, error) { | ||
| 158 | if strings.Contains(inner, "-") { | 139 | if strings.Contains(inner, "-") { | ||
| 159 | parts := strings.Split(inner, "-") | 140 | parts := strings.Split(inner, "-") | ||
| 160 | - | if len(parts) != 2 { | 141 | + | if len(parts) < 2 { |
| 161 | return Key{}, ErrBadBinding | 142 | return Key{}, ErrBadBinding | ||
| 162 | } | 143 | } | ||
| 163 | - | modPart := strings.TrimSpace(parts[0]) | 144 | + | symPart := strings.TrimSpace(parts[len(parts)-1]) |
| 164 | - | symPart := strings.TrimSpace(parts[1]) | 145 | + | if symPart == "" { |
| 165 | - | if modPart == "" || symPart == "" { | |||
| 166 | return Key{}, ErrBadBinding | 146 | return Key{}, ErrBadBinding | ||
| 167 | } | 147 | } | ||
| 168 | 148 | ||||
| 169 | var mods Mods | 149 | var mods Mods | ||
| 170 | - | for _, ch := range modPart { | 150 | + | for _, modPart := range parts[:len(parts)-1] { |
| 171 | - | switch ch { | 151 | + | modPart = strings.TrimSpace(modPart) |
| 172 | - | case 'C', 'c': | 152 | + | if modPart == "" { |
| 173 | - | mods |= ModCtrl | |||
| 174 | - | case 'S', 's': | |||
| 175 | - | mods |= ModShift | |||
| 176 | - | case 'A', 'a': | |||
| 177 | - | mods |= ModAlt | |||
| 178 | - | default: | |||
| 179 | return Key{}, ErrBadBinding | 153 | return Key{}, ErrBadBinding | ||
| 180 | } | 154 | } | ||
| 155 | + | for _, ch := range modPart { | |||
| 156 | + | switch ch { | |||
| 157 | + | case 'C', 'c': | |||
| 158 | + | mods |= ModCtrl | |||
| 159 | + | case 'S', 's': | |||
| 160 | + | mods |= ModShift | |||
| 161 | + | case 'A', 'a': | |||
| 162 | + | mods |= ModAlt | |||
| 163 | + | default: | |||
| 164 | + | return Key{}, ErrBadBinding | |||
| 165 | + | } | |||
| 166 | + | } | |||
| 181 | } | 167 | } | ||
| 182 | 168 | ||||
| 183 | sym, err := symFromToken(symPart, true) | 169 | sym, err := symFromToken(symPart, true) | ||
| @@ -199,6 +185,15 @@ var sequenceSpecialNames = []string{"Space", "Esc", "CR", "lt", "Plus"} | |||||
| 199 | func parseRawSequence(s string) (Sequence, error) { | 185 | func parseRawSequence(s string) (Sequence, error) { | ||
| 200 | seq := make(Sequence, 0, len(s)) | 186 | seq := make(Sequence, 0, len(s)) | ||
| 201 | for i := 0; i < len(s); { | 187 | for i := 0; i < len(s); { | ||
| 188 | + | if s[i] == '<' { | |||
| 189 | + | key, width, err := parseAngleAt(s, i) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return nil, err | |||
| 192 | + | } | |||
| 193 | + | seq = append(seq, key) | |||
| 194 | + | i += width | |||
| 195 | + | continue | |||
| 196 | + | } | |||
| 202 | if key, width, ok := tryParseSpecialAt(s, i); ok { | 197 | if key, width, ok := tryParseSpecialAt(s, i); ok { | ||
| 203 | seq = append(seq, key) | 198 | seq = append(seq, key) | ||
| 204 | i += width | 199 | i += width | ||
| @@ -221,6 +216,26 @@ func parseRawSequence(s string) (Sequence, error) { | |||||
| 221 | return seq, nil | 216 | return seq, nil | ||
| 222 | } | 217 | } | ||
| 223 | 218 | ||||
| 219 | + | func parseAngleAt(s string, i int) (Key, int, error) { | |||
| 220 | + | if i >= len(s) || s[i] != '<' { | |||
| 221 | + | return Key{}, 0, ErrBadBinding | |||
| 222 | + | } | |||
| 223 | + | closeIdx := strings.IndexByte(s[i+1:], '>') | |||
| 224 | + | if closeIdx < 0 { | |||
| 225 | + | return Key{}, 0, ErrBadBinding | |||
| 226 | + | } | |||
| 227 | + | closeIdx += i + 1 | |||
| 228 | + | inner := s[i+1 : closeIdx] | |||
| 229 | + | if inner == "" { | |||
| 230 | + | return Key{}, 0, ErrBadBinding | |||
| 231 | + | } | |||
| 232 | + | key, err := parseAngleToken(inner) | |||
| 233 | + | if err != nil { | |||
| 234 | + | return Key{}, 0, err | |||
| 235 | + | } | |||
| 236 | + | return key, closeIdx - i + 1, nil | |||
| 237 | + | } | |||
| 238 | + | ||||
| 224 | func tryParseSpecialAt(s string, i int) (Key, int, bool) { | 239 | func tryParseSpecialAt(s string, i int) (Key, int, bool) { | ||
| 225 | for _, name := range sequenceSpecialNames { | 240 | for _, name := range sequenceSpecialNames { | ||
| 226 | if !strings.HasPrefix(s[i:], name) { | 241 | if !strings.HasPrefix(s[i:], name) { | ||
| @@ -248,25 +263,16 @@ func tryParseCanonicalAt(s string, i int) (Key, int, bool) { | |||||
| 248 | return Key{}, 0, false | 263 | return Key{}, 0, false | ||
| 249 | } | 264 | } | ||
| 250 | 265 | ||||
| 251 | - | end := i | 266 | + | end := len(s) |
| 252 | - | for end < len(s) { | 267 | + | nextDash := strings.IndexByte(s[i:], '-') |
| 253 | - | nextDash := strings.IndexByte(s[end:], '-') | 268 | + | if nextDash >= 0 { |
| 254 | - | if nextDash < 0 { | 269 | + | nextDash += i |
| 255 | - | end = len(s) | 270 | + | if nextDash+1 < len(s) { |
| 256 | - | break | 271 | + | switch s[nextDash+1] { |
| 257 | - | } | 272 | + | case 'C', 'S', 'A': |
| 258 | - | nextDash += end | 273 | + | end = nextDash |
| 259 | - | if nextDash+1 >= len(s) { | 274 | + | } |
| 260 | - | end = len(s) | |||
| 261 | - | break | |||
| 262 | - | } | |||
| 263 | - | switch s[nextDash+1] { | |||
| 264 | - | case 'C', 'S', 'A': | |||
| 265 | - | end = nextDash | |||
| 266 | - | default: | |||
| 267 | - | end = len(s) | |||
| 268 | } | 275 | } | ||
| 269 | - | break | |||
| 270 | } | 276 | } | ||
| 271 | 277 | ||||
| 272 | token := s[i:end] | 278 | token := s[i:end] | ||
M internal/domain/vimkeys/parse_test.go +55 -0
| @@ -236,6 +236,58 @@ func TestParseBinding_PrintableGlyphs(t *testing.T) { | |||
| 236 | 236 | } | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | + | func TestParseBinding_MixedAngleSequences(t *testing.T) { | |
| 240 | + | tests := []struct { | |
| 241 | + | input string | |
| 242 | + | want Sequence | |
| 243 | + | }{ | |
| 244 | + | { | |
| 245 | + | input: "<C-d>j", | |
| 246 | + | want: Sequence{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}}, | |
| 247 | + | }, | |
| 248 | + | { | |
| 249 | + | input: "<Escape>j", | |
| 250 | + | want: Sequence{{Sym: "Esc"}, {Sym: "j"}}, | |
| 251 | + | }, | |
| 252 | + | } | |
| 253 | + | for _, tt := range tests { | |
| 254 | + | t.Run(tt.input, func(t *testing.T) { | |
| 255 | + | got, err := ParseBinding(tt.input) | |
| 256 | + | if err != nil { | |
| 257 | + | t.Fatalf("ParseBinding(%q) error = %v", tt.input, err) | |
| 258 | + | } | |
| 259 | + | if !got.Equal(tt.want) { | |
| 260 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want) | |
| 261 | + | } | |
| 262 | + | }) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestParseBinding_MultiModifierRoundTrip(t *testing.T) { | |
| 267 | + | input := "<C-S-a>" | |
| 268 | + | first, err := ParseBinding(input) | |
| 269 | + | if err != nil { | |
| 270 | + | t.Fatalf("ParseBinding(%q) error = %v", input, err) | |
| 271 | + | } | |
| 272 | + | want := Sequence{{Sym: "a", Mods: ModCtrl | ModShift}} | |
| 273 | + | if !first.Equal(want) { | |
| 274 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | canonical := first.String() | |
| 278 | + | if canonical != "<C-S-a>" { | |
| 279 | + | t.Fatalf("String() = %q, want %q", canonical, "<C-S-a>") | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | second, err := ParseBinding(canonical) | |
| 283 | + | if err != nil { | |
| 284 | + | t.Fatalf("ParseBinding(%q) error = %v", canonical, err) | |
| 285 | + | } | |
| 286 | + | if !first.Equal(second) { | |
| 287 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 239 | 291 | func TestParseBinding_RoundTrip(t *testing.T) { | |
| 240 | 292 | inputs := []string{ | |
| 241 | 293 | "j", | |
| @@ -249,6 +301,9 @@ func TestParseBinding_RoundTrip(t *testing.T) { | |||
| 249 | 301 | "<Esc>", | |
| 250 | 302 | "<Space>", | |
| 251 | 303 | "<lt>", | |
| 304 | + | "<C-d>j", | |
| 305 | + | "<Escape>j", | |
| 306 | + | "<C-S-a>", | |
| 252 | 307 | } | |
| 253 | 308 | for _, input := range inputs { | |
| 254 | 309 | t.Run(input, func(t *testing.T) { | |
| @@ -236,6 +236,58 @@ func TestParseBinding_PrintableGlyphs(t *testing.T) { | |||||
| 236 | } | 236 | } | ||
| 237 | } | 237 | } | ||
| 238 | 238 | ||||
| 239 | + | func TestParseBinding_MixedAngleSequences(t *testing.T) { | |||
| 240 | + | tests := []struct { | |||
| 241 | + | input string | |||
| 242 | + | want Sequence | |||
| 243 | + | }{ | |||
| 244 | + | { | |||
| 245 | + | input: "<C-d>j", | |||
| 246 | + | want: Sequence{{Sym: "d", Mods: ModCtrl}, {Sym: "j"}}, | |||
| 247 | + | }, | |||
| 248 | + | { | |||
| 249 | + | input: "<Escape>j", | |||
| 250 | + | want: Sequence{{Sym: "Esc"}, {Sym: "j"}}, | |||
| 251 | + | }, | |||
| 252 | + | } | |||
| 253 | + | for _, tt := range tests { | |||
| 254 | + | t.Run(tt.input, func(t *testing.T) { | |||
| 255 | + | got, err := ParseBinding(tt.input) | |||
| 256 | + | if err != nil { | |||
| 257 | + | t.Fatalf("ParseBinding(%q) error = %v", tt.input, err) | |||
| 258 | + | } | |||
| 259 | + | if !got.Equal(tt.want) { | |||
| 260 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", tt.input, got, tt.want) | |||
| 261 | + | } | |||
| 262 | + | }) | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestParseBinding_MultiModifierRoundTrip(t *testing.T) { | |||
| 267 | + | input := "<C-S-a>" | |||
| 268 | + | first, err := ParseBinding(input) | |||
| 269 | + | if err != nil { | |||
| 270 | + | t.Fatalf("ParseBinding(%q) error = %v", input, err) | |||
| 271 | + | } | |||
| 272 | + | want := Sequence{{Sym: "a", Mods: ModCtrl | ModShift}} | |||
| 273 | + | if !first.Equal(want) { | |||
| 274 | + | t.Fatalf("ParseBinding(%q) = %v, want %v", input, first, want) | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | canonical := first.String() | |||
| 278 | + | if canonical != "<C-S-a>" { | |||
| 279 | + | t.Fatalf("String() = %q, want %q", canonical, "<C-S-a>") | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | second, err := ParseBinding(canonical) | |||
| 283 | + | if err != nil { | |||
| 284 | + | t.Fatalf("ParseBinding(%q) error = %v", canonical, err) | |||
| 285 | + | } | |||
| 286 | + | if !first.Equal(second) { | |||
| 287 | + | t.Fatalf("round trip %q -> %q -> %v, want %v", input, canonical, second, first) | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | ||||
| 239 | func TestParseBinding_RoundTrip(t *testing.T) { | 291 | func TestParseBinding_RoundTrip(t *testing.T) { | ||
| 240 | inputs := []string{ | 292 | inputs := []string{ | ||
| 241 | "j", | 293 | "j", | ||
| @@ -249,6 +301,9 @@ func TestParseBinding_RoundTrip(t *testing.T) { | |||||
| 249 | "<Esc>", | 301 | "<Esc>", | ||
| 250 | "<Space>", | 302 | "<Space>", | ||
| 251 | "<lt>", | 303 | "<lt>", | ||
| 304 | + | "<C-d>j", | |||
| 305 | + | "<Escape>j", | |||
| 306 | + | "<C-S-a>", | |||
| 252 | } | 307 | } | ||
| 253 | for _, input := range inputs { | 308 | for _, input := range inputs { | ||
| 254 | t.Run(input, func(t *testing.T) { | 309 | t.Run(input, func(t *testing.T) { | ||