diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-16 15:35:02 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-16 15:35:02 +0300 |
| commit | 6c8eb6876fe87553770de114ebd34649a0c6ec10 (patch) | |
| tree | 064517edaf9d59522bec7191a61362a853c195bd /internal/lsp/handlers.go | |
| parent | 1e1df8c204f6771719f85d8402128d72138bb863 (diff) | |
lsp: split monolithic server.go into modules; add configurable max tokens and context strategies (minimal|window|file-on-new-func|always-full); provide flags/env fallbacks; add unit tests for helpers and context; update README; remove obsolete files
Diffstat (limited to 'internal/lsp/handlers.go')
| -rw-r--r-- | internal/lsp/handlers.go | 251 |
1 files changed, 251 insertions, 0 deletions
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go new file mode 100644 index 0000000..8a782c4 --- /dev/null +++ b/internal/lsp/handlers.go @@ -0,0 +1,251 @@ +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "hexai/internal" + "hexai/internal/llm" + "os" + "strings" + "time" +) + +func (s *Server) handle(req Request) { + switch req.Method { + case "initialize": + s.handleInitialize(req) + case "initialized": + s.handleInitialized() + case "shutdown": + s.handleShutdown(req) + case "exit": + s.handleExit() + case "textDocument/didOpen": + s.handleDidOpen(req) + case "textDocument/didChange": + s.handleDidChange(req) + case "textDocument/didClose": + s.handleDidClose(req) + case "textDocument/completion": + s.handleCompletion(req) + default: + if len(req.ID) != 0 { + s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) + } + } +} + +func (s *Server) handleInitialize(req Request) { + res := InitializeResult{ + Capabilities: ServerCapabilities{ + TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull + CompletionProvider: &CompletionOptions{ + ResolveProvider: false, + // TODO: Make the trigger characters configurable + TriggerCharacters: []string{".", ":", "/", "_"}, + }, + }, + ServerInfo: &ServerInfo{Name: "hexai", Version: internal.Version}, + } + s.reply(req.ID, res, nil) +} + +func (s *Server) handleInitialized() { + s.logger.Println("client initialized") +} + +func (s *Server) handleShutdown(req Request) { + s.reply(req.ID, nil, nil) +} + +func (s *Server) handleExit() { + s.exited = true + os.Exit(0) +} + +func (s *Server) handleDidOpen(req Request) { + var p DidOpenTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + s.setDocument(p.TextDocument.URI, p.TextDocument.Text) + s.markActivity() + } +} + +func (s *Server) handleDidChange(req Request) { + var p DidChangeTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + if len(p.ContentChanges) > 0 { + s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text) + } + s.markActivity() + } +} + +func (s *Server) handleDidClose(req Request) { + var p DidCloseTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil { + s.deleteDocument(p.TextDocument.URI) + s.markActivity() + } +} + +func (s *Server) handleCompletion(req Request) { + var p CompletionParams + var docStr string + if err := json.Unmarshal(req.Params, &p); err == nil { + above, current, below, funcCtx := s.lineContext(p.TextDocument.URI, p.Position) + docStr = s.buildDocString(p, above, current, below, funcCtx) + if s.logContext { + s.logCompletionContext(p, above, current, below, funcCtx) + } + if s.llmClient != nil { + newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) + extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) + items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) + if ok { + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) + return + } + } + } + items := s.fallbackCompletionItems(docStr) + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) +} + +func (s *Server) reply(id json.RawMessage, result any, err *RespError) { + resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} + s.writeMessage(resp) +} + +// --- completion helpers --- + +func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string { + return fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", + p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) +} + +func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) { + s.logger.Printf("completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q", + p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) +} + +func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + + inParams := inParamList(current, p.Position.Character) + sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx) + messages := []llm.Message{ + {Role: "system", Content: sysPrompt}, + {Role: "user", Content: userPrompt}, + } + if hasExtra && extraText != "" { + messages = append(messages, llm.Message{Role: "user", Content: "Additional context:\n" + extraText}) + } + + text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.2)) + if err != nil { + s.logger.Printf("llm completion error: %v", err) + return nil, false + } + cleaned := strings.TrimSpace(text) + if cleaned == "" { + return nil, false + } + + te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) + label := labelForCompletion(cleaned, filter) + items := []CompletionItem{{ + Label: label, + Kind: 1, + Detail: "OpenAI through Hexai completion", + InsertTextFormat: 1, + FilterText: strings.TrimLeft(filter, " \t"), + TextEdit: te, + SortText: "0000", + Documentation: docStr, + }} + return items, true +} + +func inParamList(current string, cursor int) bool { + if !strings.Contains(current, "func ") { + return false + } + open := strings.Index(current, "(") + close := strings.Index(current, ")") + return open >= 0 && cursor > open && (close == -1 || cursor <= close) +} + +func buildPrompts(inParams bool, p CompletionParams, above, current, below, funcCtx string) (string, string) { + if inParams { + sys := "You are a terse Go code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types." + user := fmt.Sprintf("Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: %s\nCurrent line (cursor at %d): %s", funcCtx, p.Position.Character, current) + return sys, user + } + sys := "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks." + user := fmt.Sprintf("Provide the next likely code to insert at the cursor.\nFile: %s\nFunction/context: %s\nAbove line: %s\nCurrent line (cursor at character %d): %s\nBelow line: %s\nOnly return the completion snippet.", p.TextDocument.URI, funcCtx, above, p.Position.Character, current, below) + return sys, user +} + +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { + if inParams { + open := strings.Index(current, "(") + close := strings.Index(current, ")") + if open >= 0 { + left := open + 1 + right := len(current) + if close >= 0 && close >= left { + right = close + } + if p.Position.Character < right { + right = p.Position.Character + } + te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned} + var filter string + if left >= 0 && right >= left && right <= len(current) { + filter = strings.TrimLeft(current[left:right], " \t") + } + return te, filter + } + } + startChar := computeWordStart(current, p.Position.Character) + te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startChar}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned} + filter := strings.TrimLeft(current[startChar:p.Position.Character], " \t") + return te, filter +} + +func computeWordStart(current string, at int) int { + if at > len(current) { + at = len(current) + } + for at > 0 { + ch := current[at-1] + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' { + at-- + continue + } + break + } + return at +} + +func labelForCompletion(cleaned, filter string) string { + label := trimLen(firstLine(cleaned)) + if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) { + return filter + } + return label +} + +func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem { + return []CompletionItem{{ + Label: "hexai-complete", + Kind: 1, + Detail: "dummy completion", + InsertText: "hexai", + SortText: "9999", + Documentation: docStr, + }} +} |
