all repos

clerk @ 4047965

missing tooling for ledger/hledger
7 files changed, 205 insertions(+), 13 deletions(-)
lsp: latin to cyrillic transliteration
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
        +