7 files changed,
205 insertions(+),
13 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-08-19 18:44:13 +0300
Authored at:
2026-08-19 16:36:54 +0300
Change ID:
vykszxolwkwvyytyqsqsrknmlsvomwmx
Parent:
e0c2e3c
M
internal/lsp/config.go
··· 8 8 type Config struct { 9 9 SemanticHighlighting bool 10 10 11 + // LatinToCyrillicCompletion matches Latin input against Cyrillic labels. 12 + LatinToCyrillicCompletion bool 13 + 11 14 // TODO: Formatter 12 15 // TODO: Linter 13 16 } 14 17 15 18 var DefaultConfig = Config{ 16 - SemanticHighlighting: true, 19 + SemanticHighlighting: true, 20 + LatinToCyrillicCompletion: false, 17 21 } 18 22 19 23 func (c *Config) merge(v protocol.LSPAny) error { ··· 22 26 } 23 27 24 28 var patch struct { 25 - SemanticHighlighting *bool `json:"semantic_highlighting,case:ignore"` 29 + SemanticHighlighting *bool `json:"semantic_highlighting,case:ignore"` 30 + LatinToCyrillicCompletion *bool `json:"latin_to_cyrillic_completion,case:ignore"` 26 31 } 27 32 28 33 if err := json.Unmarshal(v, &patch); err != nil { ··· 31 36 if patch.SemanticHighlighting != nil { 32 37 c.SemanticHighlighting = *patch.SemanticHighlighting 33 38 } 39 + if patch.LatinToCyrillicCompletion != nil { 40 + c.LatinToCyrillicCompletion = *patch.LatinToCyrillicCompletion 41 + } 34 42 return nil 35 43 } 36 44 ··· 39 47 defer s.mu.RUnlock() 40 48 return s.config.SemanticHighlighting 41 49 } 50 + 51 +func (s *server) latinToCyrillicCompletionEnabled() bool { 52 + s.mu.RLock() 53 + defer s.mu.RUnlock() 54 + return s.config.LatinToCyrillicCompletion 55 +}
M
internal/lsp/config_test.go
··· 16 16 "nil settings keep defaults": {"", DefaultConfig, Config{SemanticHighlighting: true}}, 17 17 18 18 "disables provided option": {`{"semanticHighlighting": false}`, DefaultConfig, Config{SemanticHighlighting: false}}, 19 - "snake_case key": {`{"semantic_highlighting": false}`, DefaultConfig, Config{SemanticHighlighting: false}}, 19 + "snake_case key": {`{"latin_to_cyrillic_completion": true}`, DefaultConfig, Config{LatinToCyrillicCompletion: true, SemanticHighlighting: true}}, 20 20 "kebab-case key": {`{"semantic-highlighting": false}`, DefaultConfig, Config{SemanticHighlighting: false}}, 21 21 22 22 "malformed settings ignored": {`{`, DefaultConfig, DefaultConfig},
M
internal/lsp/testdata/completion-journal.txtar
··· 54 54 Витрати:Про^дукти ¥20 55 55 Активи:Готівка 56 56 57 +2024-01-16 sup^ 58 + 57 59 2024-01-16 Прод^укти 58 60 Витрати:Тр^анспорт €5 59 61 Активи:Готівка 60 62 63 + 61 64 commodity ¥^元 62 65 63 66 tag 客^户 64 - 65 67 66 68 -- expect -- 67 69 19:10 tag-value "a" ··· 73 75 Продукти 74 76 Супермаркет 75 77 Store 78 + sup 76 79 23:12 account "expenses:f" 77 80 expenses:food 78 81 28:16 account "assets:c" ··· 93 96 Супермаркет 94 97 52:13 account "Витрати:Про" 95 98 Витрати:Продукти 96 -55:15 payee "Прод" 99 +55:14 payee "sup" 100 + Супермаркет 101 + sup 102 +57:15 payee "Прод" 97 103 Продукти 98 -56:12 account "Витрати:Тр" 104 +58:12 account "Витрати:Тр" 99 105 Витрати:Транспорт 100 -59:11 commodity "¥" 106 +62:11 commodity "¥" 101 107 ¥ 102 -61:5 tag "客" 108 +64:5 tag "客" 103 109 客户
M
internal/lsp/textdocument_completion.go
··· 37 37 } 38 38 return &protocol.CompletionList{ 39 39 IsIncomplete: true, 40 - Items: cmplItems(an, detectedCtx, state.text, state.lineIdx, start, cursor), 40 + Items: cmplItems(an, detectedCtx, state.text, state.lineIdx, start, cursor, s.latinToCyrillicCompletionEnabled()), 41 41 }, nil 42 42 } 43 43 ··· 374 374 } 375 375 376 376 // cmplItems ranks candidates for the content against typed pattern 377 -func cmplItems(a *analyzer.Analysis, ctx cmplCtx, content string, li *lsputil.LineIndex, start, cursor int) []protocol.CompletionItem { 377 +func cmplItems( 378 + a *analyzer.Analysis, 379 + ctx cmplCtx, 380 + content string, 381 + li *lsputil.LineIndex, 382 + start, 383 + cursor int, 384 + transliterate bool, 385 +) []protocol.CompletionItem { 378 386 pattern := content[start:cursor] 379 387 380 388 var kind protocol.CompletionItemKind ··· 450 458 if n := len(a.Dates); n > 0 { 451 459 newest = dateToDays(a.Dates[n-1]) 452 460 } 453 - m := fuzzy.Compile(pattern) 461 + 462 + matcher := fuzzy.Compile(pattern) 463 + translMatcher, hasTransl := fuzzy.Matcher{}, false 464 + if transliterate { 465 + if t := latinToCyrillic(pattern); t != pattern { 466 + translMatcher, hasTransl = fuzzy.Compile(t), true 467 + } 468 + } 454 469 ranked := cands[:0] 455 470 for i := range cands { 456 - sc := m.Score(cands[i].label) 471 + sc := matcher.Score(cands[i].label) 472 + if hasTransl { 473 + sc = max(sc, translMatcher.Score(cands[i].label)) 474 + } 457 475 if sc != 0 { 458 476 sc *= 1 + math.Log1p(float64(cands[i].count)) 459 477 if cands[i].count > 0 && cands[i].lastUsedDays != 0 && newest != 0 { ··· 488 506 } 489 507 items := make([]protocol.CompletionItem, len(ranked)) 490 508 for i, r := range ranked { 509 + // nvim re-filters against the typed prefix, which a transliterated label never starts with; present the typed pattern as filterText. 510 + filterText := r.label 511 + if hasTransl && translMatcher.Score(r.label) > 0 { 512 + filterText = pattern 513 + } 491 514 it := protocol.CompletionItem{ 492 515 Label: r.label, 493 516 Kind: kind, 494 517 SortText: protocol.NewOptional(fmt.Sprintf("%04d", i)), 495 - FilterText: protocol.NewOptional(r.label), 518 + FilterText: protocol.NewOptional(filterText), 496 519 TextEdit: &protocol.TextEdit{ 497 520 Range: protocol.Range{ 498 521 Start: li.Position(start),
M
internal/lsp/textdocument_completion_test.go
··· 96 96 t.Run(tt, func(t *testing.T) { 97 97 ar := golden.Read(t, tt) 98 98 h := newTxtarHarness(t, ar) 99 + h.srv.applySettings([]byte(`{"latin_to_cyrillic_completion": true}`)) 99 100 100 101 var b strings.Builder 101 102 for i, c := range h.cursors { ··· 211 212 }) 212 213 } 213 214 } 215 + 216 +func BenchmarkCompletionTransliteration(b *testing.B) { 217 + tails := []string{"а", "б", "в", "г", "д", "е", "є", "ж", "з", "и", "і", "ї", "й", "к", "л", "м", "н", "о", "п", "р"} 218 + var sb strings.Builder 219 + for _, n := range []string{"Витрати", "Доходи", "Активи", "Капітал", "Зобовязання"} { 220 + for _, t := range tails { 221 + fmt.Fprintf(&sb, "account %s:%s%s\n", n, n, t) 222 + } 223 + } 224 + sb.WriteString("account vyt") 225 + content := sb.String() 226 + 227 + srv := NewServer("test") 228 + srv.server.openDoc(uri.URI("file:///test.journal"), content, 1, "journal") 229 + srv.server.analysisFor(uri.URI("file:///test.journal")) // warm the per-doc cache 230 + srv.server.applySettings([]byte(`{"latin_to_cyrillic_completion": true}`)) 231 + 232 + line, col := lsputil.LineCol(content, len(content)) 233 + params := &protocol.CompletionParams{ 234 + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ 235 + TextDocument: protocol.TextDocumentIdentifier{URI: uri.URI("file:///test.journal")}, 236 + Position: protocol.Position{Line: uint32(line), Character: uint32(col)}, 237 + }, 238 + } 239 + 240 + warm, err := srv.server.Completion(b.Context(), params) 241 + if err != nil { 242 + b.Fatal(err) 243 + } 244 + 245 + // Guard: the Cyrillic corpus completes only through the transliterated 246 + // pattern; without it the list collapses to the latin "vyt" placeholder. 247 + if got := len(warm.(*protocol.CompletionList).Items); got <= 1 { 248 + b.Fatalf("transliteration matching did not engage (items: %d)", got) 249 + } 250 + 251 + b.ReportAllocs() 252 + b.ResetTimer() 253 + for b.Loop() { 254 + if _, err := srv.server.Completion(b.Context(), params); err != nil { 255 + b.Fatal(err) 256 + } 257 + } 258 +}
A
internal/lsp/transliteration.go
··· 1 +package lsp 2 + 3 +import "strings" 4 + 5 +// latinToCyrillicTable maps latin input to Ukrainian Cyrillic. 6 +// Ordered longest-first so greedy scanning matches "shch" as щ before it can match "sh"+"ch". 7 +var latinToCyrillicTable = [...]struct{ latin, cyrillic string }{ 8 + {"shch", "щ"}, 9 + {"sch", "щ"}, 10 + {"dzh", "дж"}, 11 + {"zh", "ж"}, 12 + {"kh", "х"}, 13 + {"ts", "ц"}, 14 + {"ch", "ч"}, 15 + {"sh", "ш"}, 16 + {"dz", "дз"}, 17 + {"yu", "ю"}, 18 + {"ya", "я"}, 19 + {"ye", "є"}, 20 + {"yi", "ї"}, 21 + {"ji", "ї"}, 22 + {"a", "а"}, 23 + {"b", "б"}, 24 + {"v", "в"}, 25 + {"g", "г"}, 26 + {"d", "д"}, 27 + {"e", "е"}, 28 + {"z", "з"}, 29 + {"i", "і"}, 30 + {"j", "й"}, 31 + {"k", "к"}, 32 + {"l", "л"}, 33 + {"m", "м"}, 34 + {"n", "н"}, 35 + {"o", "о"}, 36 + {"p", "п"}, 37 + {"r", "р"}, 38 + {"s", "с"}, 39 + {"t", "т"}, 40 + {"u", "у"}, 41 + {"f", "ф"}, 42 + {"h", "х"}, 43 + {"c", "ц"}, 44 + {"q", "к"}, 45 + {"x", "кс"}, 46 + {"w", "в"}, 47 + {"y", "и"}, 48 +} 49 + 50 +func latinToCyrillic(pattern string) string { 51 + p := strings.ToLower(pattern) 52 + var b strings.Builder 53 + b.Grow(len(p)) 54 + for i := 0; i < len(p); { 55 + for _, kv := range latinToCyrillicTable { 56 + if strings.HasPrefix(p[i:], kv.latin) { 57 + b.WriteString(kv.cyrillic) 58 + i += len(kv.latin) 59 + goto next 60 + } 61 + } 62 + b.WriteByte(p[i]) 63 + i++ 64 + next: 65 + } 66 + return b.String() 67 +}
A
internal/lsp/transliteration_test.go
··· 1 +package lsp 2 + 3 +import "testing" 4 + 5 +func TestLatinToCyrillic(t *testing.T) { 6 + tests := map[string]string{ 7 + "": "", 8 + "vydatky": "видатки", 9 + "gotivka": "готівка", 10 + "kava": "кава", 11 + "vy": "ви", 12 + "tramvaj": "трамвай", 13 + "yizhak": "їжак", 14 + "jidlo": "їдло", 15 + "shchuka": "щука", 16 + "schuka": "щука", 17 + "khlib": "хліб", 18 + "hata": "хата", 19 + "groshi": "гроші", 20 + "yevgen": "євген", 21 + "cukor": "цукор", 22 + "dzerkalo": "дзеркало", 23 + "dzhaz": "джаз", 24 + "oleksij": "олексій", 25 + "KAVA": "кава", 26 + "кофe": "кофе", 27 + "кофе": "кофе", 28 + } 29 + for in, out := range tests { 30 + t.Run(in, func(t *testing.T) { 31 + if got := latinToCyrillic(in); got != out { 32 + t.Errorf("latinToCyrillic(%q) = %q, want %q", in, got, out) 33 + } 34 + }) 35 + } 36 +} 37 +