3 files changed,
293 insertions(+),
6 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-07-11 12:58:15 +0300
Authored at:
2026-06-29 22:16:31 +0300
Change ID:
poppzyupyorxyvvmprymntqslvnynnkr
Parent:
9369172
A
internal/lsp/completion.go
··· 1 +package lsp 2 + 3 +import ( 4 + "context" 5 + "slices" 6 + "strings" 7 + 8 + "go.lsp.dev/protocol" 9 + 10 + "olexsmir.xyz/clerk/journal/semantic" 11 +) 12 + 13 +func (s *server) Completion(ctx context.Context, params *protocol.CompletionParams) (protocol.CompletionResult, error) { 14 + text, ok := s.getDocText(params.TextDocument.URI) 15 + if !ok { 16 + return &protocol.CompletionList{IsIncomplete: false}, nil 17 + } 18 + 19 + lines := strings.Split(text, "\n") 20 + lineIdx := int(params.Position.Line) 21 + if lineIdx >= len(lines) { 22 + lineIdx = len(lines) - 1 23 + } 24 + if lineIdx < 0 { 25 + lineIdx = 0 26 + } 27 + 28 + currentLine := lines[lineIdx] 29 + col := min(params.Position.Character, uint32(len(currentLine))) 30 + 31 + prefix := currentLine[:col] 32 + kind, word := detectKind(prefix) 33 + 34 + s.semaMu.RLock() 35 + sema := s.semanticCtx 36 + s.semaMu.RUnlock() 37 + 38 + var items []protocol.CompletionItem 39 + switch kind { 40 + case complAccount: 41 + items = completeAccounts(sema, word) 42 + case complPayee: 43 + items = completePayees(sema, word) 44 + case complCommodity: 45 + items = completeCommodities(sema, word) 46 + case complKeyword: 47 + items = completeKeywords(word) 48 + } 49 + if items == nil { 50 + items = []protocol.CompletionItem{} 51 + } 52 + 53 + return &protocol.CompletionList{ 54 + IsIncomplete: false, 55 + Items: items, 56 + }, nil 57 +} 58 + 59 +type completionKind int 60 + 61 +const ( 62 + complNone completionKind = iota 63 + complAccount 64 + complPayee 65 + complCommodity 66 + complKeyword 67 +) 68 + 69 +func detectKind(prefix string) (completionKind, string) { 70 + if prefix == "" { 71 + return complNone, "" 72 + } 73 + 74 + trimmed := strings.TrimLeft(prefix, " \t") 75 + if trimmed == "" { 76 + return complNone, "" 77 + } 78 + 79 + c := prefix[0] 80 + 81 + // posting line: starts with indentation 82 + if c == ' ' || c == '\t' { 83 + content := strings.TrimLeft(trimmed, "!* ([") 84 + if _, after, ok := strings.Cut(content, " "); ok { 85 + // after := strings.TrimLeft(after, " \t") 86 + if n := strings.LastIndexAny(after, " \t"); n >= 0 { // last field (after whitespace) 87 + return complCommodity, after[n+1:] 88 + } 89 + return complCommodity, after 90 + } 91 + return complAccount, content 92 + } 93 + 94 + // directive keywords at line start 95 + if kw, rest := matchKeyword(trimmed); kw != "" { 96 + switch kw { 97 + case "account", "alias": 98 + return complAccount, rest 99 + case "payee": 100 + return complPayee, rest 101 + case "commodity", "D", "P", "N": 102 + return complCommodity, rest 103 + } 104 + return complNone, "" 105 + } 106 + 107 + // after date, transaction header 108 + if c >= '0' && c <= '9' { 109 + return complPayee, skipTransactionPreamble(trimmed) 110 + } 111 + 112 + // partial keyword on unknown line 113 + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { 114 + return complKeyword, trimmed 115 + } 116 + 117 + return complNone, "" 118 +} 119 + 120 +func matchKeyword(trimmed string) (keyword, rest string) { 121 + i := 0 122 + for i < len(trimmed) && trimmed[i] != ' ' && trimmed[i] != '\t' { 123 + i++ 124 + } 125 + if i == 0 { 126 + return "", "" 127 + } 128 + word := trimmed[:i] 129 + switch word { 130 + case "account", "alias", "payee", "commodity", 131 + "include", "tag", "apply", "end", 132 + "year", "Y", "decimal-mark", 133 + "D", "P", "N", "C", "comment": 134 + default: 135 + return "", "" 136 + } 137 + rest = strings.TrimLeft(trimmed[i:], " \t") 138 + return word, rest 139 +} 140 + 141 +func skipTransactionPreamble(trimmed string) string { 142 + // Skip date (digits and date separators) 143 + i := 0 144 + for i < len(trimmed) { 145 + c := trimmed[i] 146 + if (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '.' { 147 + i++ 148 + } else { 149 + break 150 + } 151 + } 152 + after := trimmed[i:] 153 + 154 + // Skip whitespace, status markers, and code in parens 155 + for { 156 + before := after 157 + after = strings.TrimLeft(after, " \t") 158 + if len(after) > 0 && (after[0] == '*' || after[0] == '!') { 159 + after = after[1:] 160 + continue 161 + } 162 + if len(after) > 0 && after[0] == '(' { 163 + end := strings.IndexByte(after, ')') 164 + if end >= 0 { 165 + after = after[end+1:] 166 + continue 167 + } 168 + } 169 + if after == before { 170 + break 171 + } 172 + } 173 + return after 174 +} 175 + 176 +func completeAccounts(sema *semantic.Context, prefix string) []protocol.CompletionItem { 177 + if sema == nil || prefix == "" { 178 + return nil 179 + } 180 + var items []protocol.CompletionItem 181 + for name := range sema.Accounts { 182 + if strings.HasPrefix(name, prefix) { 183 + items = append(items, protocol.CompletionItem{ 184 + Label: name, 185 + Kind: protocol.CompletionItemKindVariable, 186 + // Detail: protocol.NewOptional("account"), 187 + }) 188 + } 189 + } 190 + slices.SortFunc(items, func(a, b protocol.CompletionItem) int { 191 + return strings.Compare(a.Label, b.Label) 192 + }) 193 + return items 194 +} 195 + 196 +func completePayees(sema *semantic.Context, prefix string) []protocol.CompletionItem { 197 + if sema == nil { 198 + return nil 199 + } 200 + var items []protocol.CompletionItem 201 + for name := range sema.Payees { 202 + if prefix != "" && !strings.HasPrefix(name, prefix) { 203 + continue 204 + } 205 + items = append(items, protocol.CompletionItem{ 206 + Label: name, 207 + Kind: protocol.CompletionItemKindText, 208 + // Detail: protocol.NewOptional("payee"), 209 + }) 210 + } 211 + slices.SortFunc(items, func(a, b protocol.CompletionItem) int { 212 + return strings.Compare(a.Label, b.Label) 213 + }) 214 + return items 215 +} 216 + 217 +func completeCommodities(sema *semantic.Context, prefix string) []protocol.CompletionItem { 218 + if sema == nil { 219 + return nil 220 + } 221 + var items []protocol.CompletionItem 222 + for name := range sema.Commodities { 223 + if prefix != "" && !strings.HasPrefix(name, prefix) { 224 + continue 225 + } 226 + items = append(items, protocol.CompletionItem{ 227 + Label: name, 228 + Kind: protocol.CompletionItemKindConstant, 229 + // Detail: protocol.NewOptional("commodity"), 230 + }) 231 + } 232 + slices.SortFunc(items, func(a, b protocol.CompletionItem) int { 233 + return strings.Compare(a.Label, b.Label) 234 + }) 235 + return items 236 +} 237 + 238 +// keywords 239 + 240 +type keywordInfo struct { 241 + label, detail string 242 +} 243 + 244 +var directiveKeywords = []keywordInfo{ 245 + {"account", "define an account"}, 246 + {"alias", "alias an account name"}, 247 + {"apply", "apply a directive"}, 248 + {"comment", "comment block"}, 249 + {"commodity", "define a commodity"}, 250 + {"decimal-mark", "set decimal mark"}, 251 + {"end", "end a scoped directive"}, 252 + {"include", "include another file"}, 253 + {"payee", "define a payee"}, 254 + {"tag", "define a tag"}, 255 + {"year", "set default year"}, 256 + {"C", "commodity conversion"}, 257 + {"D", "set default commodity"}, 258 + {"N", "ignored price"}, 259 + {"P", "market price"}, 260 + {"Y", "set default year"}, 261 +} 262 + 263 +func completeKeywords(prefix string) []protocol.CompletionItem { 264 + var items []protocol.CompletionItem 265 + for _, kw := range directiveKeywords { 266 + if !strings.HasPrefix(kw.label, prefix) { 267 + continue 268 + } 269 + items = append(items, protocol.CompletionItem{ 270 + Label: kw.label, 271 + Kind: protocol.CompletionItemKindKeyword, 272 + Detail: protocol.NewOptional(kw.detail), 273 + }) 274 + } 275 + return items 276 +}
M
internal/lsp/diagnostics.go
··· 39 39 return 40 40 } 41 41 42 + var allFiles []*journal.ParsedFile 42 43 var finds []linter.Find 43 44 for _, root := range roots { 44 - finds = append(finds, s.linter.Run(semantic.Build(journal.CollectFiles(root)))...) 45 + files := journal.CollectFiles(root) 46 + c := semantic.Build(files) 47 + finds = append(finds, s.linter.Run(c)...) 48 + allFiles = append(allFiles, files...) 45 49 } 46 50 finds = dedupFinds(finds) 51 + 52 + s.semaMu.Lock() 53 + s.semanticCtx = semantic.Build(allFiles) 54 + s.semaMu.Unlock() 47 55 48 56 diagsByFile := groupFindsByFile(finds) 49 57 activeFiles := activeFileSet(s.openDocs, s.loader.Ordered())
M
internal/lsp/server.go
··· 12 12 "olexsmir.xyz/clerk/internal/linter" 13 13 "olexsmir.xyz/clerk/journal" 14 14 "olexsmir.xyz/clerk/journal/printer" 15 + "olexsmir.xyz/clerk/journal/semantic" 15 16 ) 16 17 17 18 const name = "clerk" ··· 36 37 linter *linter.Linter 37 38 debouncer *time.Timer 38 39 39 - mu sync.Mutex 40 - openDocs map[uri.URI]docState 40 + mu sync.Mutex 41 + openDocs map[uri.URI]docState 42 + semanticCtx *semantic.Context 43 + semaMu sync.RWMutex 41 44 } 42 45 43 46 func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) { ··· 52 55 Change: new(protocol.TextDocumentSyncKindFull), 53 56 }, 54 57 DocumentFormattingProvider: &protocol.DocumentFormattingOptions{}, 55 - // CompletionProvider: &protocol.CompletionOptions{ 56 - // TriggerCharacters: []string{" ", ":", "/"}, 57 - // }, 58 + CompletionProvider: &protocol.CompletionOptions{ 59 + TriggerCharacters: []string{":", "/"}, 60 + }, 58 61 }, 59 62 }, nil 60 63 }