summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-16 14:58:03 +0300
committerPaul Buetow <paul@buetow.org>2025-08-16 14:58:03 +0300
commit1e1df8c204f6771719f85d8402128d72138bb863 (patch)
tree20508d35f86625ff5b74b509176111ffde163605 /internal/lsp
parenta6a8b84690c50767f714b413496b5aeb45b31c21 (diff)
llm: add pluggable provider with OpenAI default; extensive logging; LSP completion integration with TextEdit, param-aware prompts; remove idle gating; label/filter improvements; docs update
Diffstat (limited to 'internal/lsp')
-rw-r--r--internal/lsp/server.go201
1 files changed, 167 insertions, 34 deletions
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 3949680..ec1a113 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -2,9 +2,11 @@ package lsp
import (
"bufio"
+ "context"
"encoding/json"
"fmt"
"hexai/internal"
+ "hexai/internal/llm"
"io"
"log"
"net/textproto"
@@ -12,6 +14,7 @@ import (
"strconv"
"strings"
"sync"
+ "time"
)
// JSON-RPC 2.0 structures (minimal)
@@ -61,27 +64,39 @@ type CompletionList struct {
}
type CompletionItem struct {
- Label string `json:"label"`
- Kind int `json:"kind,omitempty"`
- Detail string `json:"detail,omitempty"`
- InsertText string `json:"insertText,omitempty"`
- SortText string `json:"sortText,omitempty"`
- Documentation string `json:"documentation,omitempty"`
+ Label string `json:"label"`
+ Kind int `json:"kind,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ InsertText string `json:"insertText,omitempty"`
+ InsertTextFormat int `json:"insertTextFormat,omitempty"`
+ FilterText string `json:"filterText,omitempty"`
+ TextEdit *TextEdit `json:"textEdit,omitempty"`
+ SortText string `json:"sortText,omitempty"`
+ Documentation string `json:"documentation,omitempty"`
}
// Server implements a minimal LSP over stdio.
type Server struct {
- in *bufio.Reader
- out io.Writer
- logger *log.Logger
- exited bool
- mu sync.RWMutex
- docs map[string]*document
- logContext bool
+ in *bufio.Reader
+ out io.Writer
+ logger *log.Logger
+ exited bool
+ mu sync.RWMutex
+ docs map[string]*document
+ logContext bool
+ llmClient llm.Client
+ lastInput time.Time
}
func NewServer(r io.Reader, w io.Writer, logger *log.Logger, logContext bool) *Server {
- return &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext}
+ s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: logContext}
+ if c, err := llm.NewDefault(logger); err != nil {
+ // Keep running without LLM; completions will be basic.
+ s.logger.Printf("llm disabled: %v", err)
+ } else {
+ s.llmClient = c
+ }
+ return s
}
func (s *Server) Run() error {
@@ -137,6 +152,7 @@ func (s *Server) handle(req Request) {
var p DidOpenTextDocumentParams
if err := json.Unmarshal(req.Params, &p); err == nil {
s.setDocument(p.TextDocument.URI, p.TextDocument.Text)
+ s.markActivity()
}
case "textDocument/didChange":
var p DidChangeTextDocumentParams
@@ -144,32 +160,123 @@ func (s *Server) handle(req Request) {
if len(p.ContentChanges) > 0 {
s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text)
}
+ s.markActivity()
}
case "textDocument/didClose":
var p DidCloseTextDocumentParams
if err := json.Unmarshal(req.Params, &p); err == nil {
s.deleteDocument(p.TextDocument.URI)
+ s.markActivity()
}
- case "textDocument/completion":
- 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 = 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))
- if s.logContext {
- 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))
- }
- }
- items := []CompletionItem{{
- Label: "hexai-complete",
- Kind: 14,
- Detail: "dummy completion",
- InsertText: "hexai",
- SortText: "0000",
- Documentation: docStr,
- }}
- s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)
+ case "textDocument/completion":
+ 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 = 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))
+ if s.logContext {
+ 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))
+ }
+ // Previously: gated LLM calls until 1s idle. Removed to complete as you type.
+ // Try LLM-backed suggestion if available (always, no idle gating)
+ if s.llmClient != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
+ defer cancel()
+ // Tailor prompt if inside a Go function parameter list
+ inParams := false
+ if strings.Contains(current, "func ") {
+ open := strings.Index(current, "(")
+ close := strings.Index(current, ")")
+ if open >= 0 && p.Position.Character > open && (close == -1 || p.Position.Character <= close) {
+ inParams = true
+ }
+ }
+ sysPrompt := "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks."
+ userPrompt := 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)
+ if inParams {
+ sysPrompt = "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."
+ userPrompt = 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)
+ }
+ messages := []llm.Message{
+ {Role: "system", Content: sysPrompt},
+ {Role: "user", Content: userPrompt},
+ }
+ // keep completions small by default
+ text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(96), llm.WithTemperature(0.2))
+ if err == nil && strings.TrimSpace(text) != "" {
+ cleaned := strings.TrimSpace(text)
+ var te *TextEdit
+ var filter string
+ if inParams {
+ // Replace inside the parentheses
+ 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}
+ if left >= 0 && right >= left && right <= len(current) {
+ filter = strings.TrimLeft(current[left:right], " \t")
+ }
+ }
+ }
+ if te == nil {
+ // compute word start for replacement
+ startChar := p.Position.Character
+ if startChar > len(current) {
+ startChar = len(current)
+ }
+ for startChar > 0 {
+ ch := current[startChar-1]
+ if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' {
+ startChar--
+ continue
+ }
+ break
+ }
+ 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")
+ }
+ // Choose a label that starts with the current prefix when possible so the client doesn't filter it out.
+ label := trimLen(firstLine(cleaned))
+ if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) {
+ label = filter
+ }
+ items := []CompletionItem{{
+ Label: label,
+ Kind: 1,
+ Detail: "OpenAI completion",
+ InsertTextFormat: 1,
+ FilterText: strings.TrimLeft(filter, " \t"),
+ TextEdit: te,
+ SortText: "0000",
+ Documentation: docStr,
+ }}
+ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)
+ return
+ }
+ if err != nil {
+ s.logger.Printf("llm completion error: %v", err)
+ }
+ }
+ }
+ // Fallback basic/dummy completion
+ items := []CompletionItem{{
+ Label: "hexai-complete",
+ Kind: 1,
+ Detail: "dummy completion",
+ InsertText: "hexai",
+ SortText: "9999",
+ Documentation: docStr,
+ }}
+ s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)
default:
// Unknown method; reply with Method Not Found for requests that have an ID.
if len(req.ID) != 0 {
@@ -256,6 +363,12 @@ func (s *Server) deleteDocument(uri string) {
delete(s.docs, uri)
}
+func (s *Server) markActivity() {
+ s.mu.Lock()
+ s.lastInput = time.Now()
+ s.mu.Unlock()
+}
+
func (s *Server) getDocument(uri string) *document {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -314,6 +427,18 @@ type CompletionParams struct {
Context any `json:"context,omitempty"`
}
+// Range defines a text range in a document.
+type Range struct {
+ Start Position `json:"start"`
+ End Position `json:"end"`
+}
+
+// TextEdit represents a textual edit applicable to a document.
+type TextEdit struct {
+ Range Range `json:"range"`
+ NewText string `json:"newText"`
+}
+
func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) {
d := s.getDocument(uri)
if d == nil || len(d.lines) == 0 {
@@ -359,3 +484,11 @@ func trimLen(s string) string {
}
return s
}
+
+func firstLine(s string) string {
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ if idx := strings.IndexByte(s, '\n'); idx >= 0 {
+ return s[:idx]
+ }
+ return s
+}