package lsp import ( "context" "slices" "strings" "go.lsp.dev/protocol" "olexsmir.xyz/clerk/journal/semantic" ) func (s *server) Completion(ctx context.Context, params *protocol.CompletionParams) (protocol.CompletionResult, error) { text, ok := s.getDocText(params.TextDocument.URI) if !ok { return &protocol.CompletionList{IsIncomplete: false}, nil } lines := strings.Split(text, "\n") lineIdx := int(params.Position.Line) if lineIdx >= len(lines) { lineIdx = len(lines) - 1 } if lineIdx < 0 { lineIdx = 0 } currentLine := lines[lineIdx] col := min(params.Position.Character, uint32(len(currentLine))) prefix := currentLine[:col] kind, word := detectKind(prefix) s.semaMu.RLock() sema := s.semanticCtx s.semaMu.RUnlock() var items []protocol.CompletionItem switch kind { case complAccount: items = completeAccounts(sema, word) case complPayee: items = completePayees(sema, word) case complCommodity: items = completeCommodities(sema, word) case complKeyword: items = completeKeywords(word) } if items == nil { items = []protocol.CompletionItem{} } return &protocol.CompletionList{ IsIncomplete: false, Items: items, }, nil } type completionKind int const ( complNone completionKind = iota complAccount complPayee complCommodity complKeyword ) func detectKind(prefix string) (completionKind, string) { if prefix == "" { return complNone, "" } trimmed := strings.TrimLeft(prefix, " \t") if trimmed == "" { return complNone, "" } c := prefix[0] // posting line: starts with indentation if c == ' ' || c == '\t' { content := strings.TrimLeft(trimmed, "!* ([") if _, after, ok := strings.Cut(content, " "); ok { // after := strings.TrimLeft(after, " \t") if n := strings.LastIndexAny(after, " \t"); n >= 0 { // last field (after whitespace) return complCommodity, after[n+1:] } return complCommodity, after } return complAccount, content } // directive keywords at line start if kw, rest := matchKeyword(trimmed); kw != "" { switch kw { case "account", "alias": return complAccount, rest case "payee": return complPayee, rest case "commodity", "D", "P", "N": return complCommodity, rest } return complNone, "" } // after date, transaction header if c >= '0' && c <= '9' { return complPayee, skipTransactionPreamble(trimmed) } // partial keyword on unknown line if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') { return complKeyword, trimmed } return complNone, "" } func matchKeyword(trimmed string) (keyword, rest string) { i := 0 for i < len(trimmed) && trimmed[i] != ' ' && trimmed[i] != '\t' { i++ } if i == 0 { return "", "" } word := trimmed[:i] switch word { case "account", "alias", "payee", "commodity", "include", "tag", "apply", "end", "year", "Y", "decimal-mark", "D", "P", "N", "C", "comment": default: return "", "" } rest = strings.TrimLeft(trimmed[i:], " \t") return word, rest } func skipTransactionPreamble(trimmed string) string { // Skip date (digits and date separators) i := 0 for i < len(trimmed) { c := trimmed[i] if (c >= '0' && c <= '9') || c == '/' || c == '-' || c == '.' { i++ } else { break } } after := trimmed[i:] // Skip whitespace, status markers, and code in parens for { before := after after = strings.TrimLeft(after, " \t") if len(after) > 0 && (after[0] == '*' || after[0] == '!') { after = after[1:] continue } if len(after) > 0 && after[0] == '(' { end := strings.IndexByte(after, ')') if end >= 0 { after = after[end+1:] continue } } if after == before { break } } return after } func completeAccounts(sema *semantic.Context, prefix string) []protocol.CompletionItem { if sema == nil || prefix == "" { return nil } var items []protocol.CompletionItem for name := range sema.Accounts { if strings.HasPrefix(name, prefix) { items = append(items, protocol.CompletionItem{ Label: name, Kind: protocol.CompletionItemKindVariable, // Detail: protocol.NewOptional("account"), }) } } slices.SortFunc(items, func(a, b protocol.CompletionItem) int { return strings.Compare(a.Label, b.Label) }) return items } func completePayees(sema *semantic.Context, prefix string) []protocol.CompletionItem { if sema == nil { return nil } var items []protocol.CompletionItem for name := range sema.Payees { if prefix != "" && !strings.HasPrefix(name, prefix) { continue } items = append(items, protocol.CompletionItem{ Label: name, Kind: protocol.CompletionItemKindText, // Detail: protocol.NewOptional("payee"), }) } slices.SortFunc(items, func(a, b protocol.CompletionItem) int { return strings.Compare(a.Label, b.Label) }) return items } func completeCommodities(sema *semantic.Context, prefix string) []protocol.CompletionItem { if sema == nil { return nil } var items []protocol.CompletionItem for name := range sema.Commodities { if prefix != "" && !strings.HasPrefix(name, prefix) { continue } items = append(items, protocol.CompletionItem{ Label: name, Kind: protocol.CompletionItemKindConstant, // Detail: protocol.NewOptional("commodity"), }) } slices.SortFunc(items, func(a, b protocol.CompletionItem) int { return strings.Compare(a.Label, b.Label) }) return items } // keywords type keywordInfo struct { label, detail string } var directiveKeywords = []keywordInfo{ {"account", "define an account"}, {"alias", "alias an account name"}, {"apply", "apply a directive"}, {"comment", "comment block"}, {"commodity", "define a commodity"}, {"decimal-mark", "set decimal mark"}, {"end", "end a scoped directive"}, {"include", "include another file"}, {"payee", "define a payee"}, {"tag", "define a tag"}, {"year", "set default year"}, {"C", "commodity conversion"}, {"D", "set default commodity"}, {"N", "ignored price"}, {"P", "market price"}, {"Y", "set default year"}, } func completeKeywords(prefix string) []protocol.CompletionItem { var items []protocol.CompletionItem for _, kw := range directiveKeywords { if !strings.HasPrefix(kw.label, prefix) { continue } items = append(items, protocol.CompletionItem{ Label: kw.label, Kind: protocol.CompletionItemKindKeyword, Detail: protocol.NewOptional(kw.detail), }) } return items }