Skip to content

Commit 6adadb1

Browse files
committed
fix: support norwegian characters øæå
1 parent 9ebb954 commit 6adadb1

File tree

3 files changed

+121
-14
lines changed

3 files changed

+121
-14
lines changed

internal/projects/filter.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package projects
33
import (
44
"sort"
55
"strings"
6+
"unicode"
67
)
78

89
func Filter(projects []Project, query string) []Project {
@@ -44,9 +45,6 @@ const (
4445
scoreWordBoundary = 3
4546
)
4647

47-
// FuzzyScore calculates a fuzzy match score between query and target.
48-
// Returns 0 if no match, higher scores indicate better matches.
49-
// Exported for testing.
5048
func FuzzyScore(query string, target string) int {
5149
if len(query) == 0 {
5250
return 1
@@ -55,34 +53,37 @@ func FuzzyScore(query string, target string) int {
5553
return 0
5654
}
5755

56+
queryRunes := []rune(query)
57+
targetRunes := []rune(target)
58+
5859
score := 0
5960
qi := 0
6061
prevMatch := -2
6162

62-
for ti := 0; ti < len(target) && qi < len(query); ti++ {
63-
if len(target)-ti < len(query)-qi {
63+
for ti := 0; ti < len(targetRunes) && qi < len(queryRunes); ti++ {
64+
if len(targetRunes)-ti < len(queryRunes)-qi {
6465
return 0
6566
}
6667

67-
if target[ti] == query[qi] {
68+
if targetRunes[ti] == queryRunes[qi] {
6869
score += scoreMatch
6970
if ti == prevMatch+1 {
7071
score += scoreConsecutive
7172
}
72-
if ti == 0 || !isLetter(target[ti-1]) {
73+
if ti == 0 || !isLetter(targetRunes[ti-1]) {
7374
score += scoreWordBoundary
7475
}
7576
prevMatch = ti
7677
qi++
7778
}
7879
}
7980

80-
if qi == len(query) {
81+
if qi == len(queryRunes) {
8182
return score
8283
}
8384
return 0
8485
}
8586

86-
func isLetter(b byte) bool {
87-
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
87+
func isLetter(r rune) bool {
88+
return unicode.IsLetter(r)
8889
}

internal/projects/projects_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,111 @@ func TestFilter_FuzzyPrefersWordBoundaryMatches(t *testing.T) {
445445
}
446446
}
447447

448+
func TestFilter_MatchesNorwegianCharacters(t *testing.T) {
449+
projects := []Project{
450+
{Name: "øl-project", Path: "/repos/øl-project"},
451+
{Name: "særen", Path: "/repos/særen"},
452+
{Name: "håland", Path: "/repos/håland"},
453+
}
454+
455+
result := Filter(projects, "øl")
456+
if len(result) != 1 {
457+
t.Errorf("expected 1 match for 'øl', got %d", len(result))
458+
}
459+
if result[0].Name != "øl-project" {
460+
t.Errorf("expected 'øl-project', got %q", result[0].Name)
461+
}
462+
463+
result = Filter(projects, "sæ")
464+
if len(result) != 1 {
465+
t.Errorf("expected 1 match for 'sæ', got %d", len(result))
466+
}
467+
if result[0].Name != "særen" {
468+
t.Errorf("expected 'særen', got %q", result[0].Name)
469+
}
470+
471+
result = Filter(projects, "hå")
472+
if len(result) != 1 {
473+
t.Errorf("expected 1 match for 'hå', got %d", len(result))
474+
}
475+
if result[0].Name != "håland" {
476+
t.Errorf("expected 'håland', got %q", result[0].Name)
477+
}
478+
}
479+
480+
func TestFilter_NorwegianCaseInsensitive(t *testing.T) {
481+
projects := []Project{
482+
{Name: "ØL-PROJECT", Path: "/repos/ØL-PROJECT"},
483+
{Name: "SÆREN", Path: "/repos/SÆREN"},
484+
{Name: "HÅLAND", Path: "/repos/HÅLAND"},
485+
}
486+
487+
tests := []struct {
488+
query string
489+
expect string
490+
}{
491+
{"øl", "ØL-PROJECT"},
492+
{"øL", "ØL-PROJECT"},
493+
{"ØL", "ØL-PROJECT"},
494+
{"sæ", "SÆREN"},
495+
{"SÆ", "SÆREN"},
496+
{"hå", "HÅLAND"},
497+
{"HÅ", "HÅLAND"},
498+
}
499+
500+
for _, tt := range tests {
501+
result := Filter(projects, tt.query)
502+
if len(result) != 1 {
503+
t.Errorf("query %q: expected 1 match, got %d", tt.query, len(result))
504+
continue
505+
}
506+
if result[0].Name != tt.expect {
507+
t.Errorf("query %q: expected %q, got %q", tt.query, tt.expect, result[0].Name)
508+
}
509+
}
510+
}
511+
512+
func TestFilter_NorwegianFuzzyMatching(t *testing.T) {
513+
projects := []Project{
514+
{Name: "første-prosjekt", Path: "/repos/første-prosjekt"},
515+
{Name: "anden-prosjekt", Path: "/repos/anden-prosjekt"},
516+
}
517+
518+
// "fp" should match "første-prosjekt"
519+
result := Filter(projects, "fp")
520+
if len(result) != 1 {
521+
t.Errorf("expected 1 match for 'fp', got %d", len(result))
522+
}
523+
if result[0].Name != "første-prosjekt" {
524+
t.Errorf("expected 'første-prosjekt', got %q", result[0].Name)
525+
}
526+
527+
// "ap" should match "anden-prosjekt"
528+
result = Filter(projects, "ap")
529+
if len(result) != 1 {
530+
t.Errorf("expected 1 match for 'ap', got %d", len(result))
531+
}
532+
if result[0].Name != "anden-prosjekt" {
533+
t.Errorf("expected 'anden-prosjekt', got %q", result[0].Name)
534+
}
535+
}
536+
537+
func TestFilter_NorwegianWordBoundary(t *testing.T) {
538+
projects := []Project{
539+
{Name: "xølx", Path: "/repos/xølx"},
540+
{Name: "øl-project", Path: "/repos/øl-project"},
541+
}
542+
543+
// "øl" at start should rank higher
544+
result := Filter(projects, "øl")
545+
if len(result) != 2 {
546+
t.Fatalf("expected 2 matches, got %d", len(result))
547+
}
548+
if result[0].Name != "øl-project" {
549+
t.Errorf("expected 'øl-project' first (word boundary), got %q", result[0].Name)
550+
}
551+
}
552+
448553
func TestScore_ScoreIsNonNegative(t *testing.T) {
449554
properties := gopter.NewProperties(nil)
450555

internal/tui/model.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import (
1212
)
1313

1414
type Icons struct {
15-
Dir string
15+
Dir string
1616
Term string
1717
}
1818

@@ -98,7 +98,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9898

9999
case key.Matches(msg, m.keys.Backspace):
100100
if len(m.query) > 0 {
101-
m.query = m.query[:len(m.query)-1]
101+
runes := []rune(m.query)
102+
m.query = string(runes[:len(runes)-1])
102103
m.filtered = projects.Filter(m.projects, m.query)
103104
m.cursor = 0
104105
}
@@ -113,8 +114,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113114
return m, nil
114115

115116
default:
116-
if len(msg.String()) == 1 {
117-
m.query += msg.String()
117+
if msg.Type == tea.KeyRunes {
118+
m.query += string(msg.Runes)
118119
m.filtered = projects.Filter(m.projects, m.query)
119120
m.cursor = 0
120121
}

0 commit comments

Comments
 (0)