summaryrefslogtreecommitdiff
path: root/internal/lsp/handlers_utils.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/lsp/handlers_utils.go')
-rw-r--r--internal/lsp/handlers_utils.go254
1 files changed, 254 insertions, 0 deletions
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
new file mode 100644
index 0000000..26a0780
--- /dev/null
+++ b/internal/lsp/handlers_utils.go
@@ -0,0 +1,254 @@
+// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters).
+package lsp
+
+import (
+ "fmt"
+ "hexai/internal/llm"
+ "hexai/internal/logging"
+ "strings"
+ "time"
+)
+
+// llmRequestOpts builds request options from server settings.
+func (s *Server) llmRequestOpts() []llm.RequestOption {
+ opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)}
+ if s.codingTemperature != nil {
+ opts = append(opts, llm.WithTemperature(*s.codingTemperature))
+ }
+ return opts
+}
+
+// small helpers for LLM traffic stats
+func (s *Server) incSentCounters(n int) {
+ s.mu.Lock()
+ s.llmReqTotal++
+ s.llmSentBytesTotal += int64(n)
+ s.mu.Unlock()
+}
+
+func (s *Server) incRecvCounters(n int) {
+ s.mu.Lock()
+ s.llmRespTotal++
+ s.llmRespBytesTotal += int64(n)
+ s.mu.Unlock()
+}
+
+func (s *Server) logLLMStats() {
+ s.mu.RLock()
+ avgSent := int64(0)
+ if s.llmReqTotal > 0 {
+ avgSent = s.llmSentBytesTotal / s.llmReqTotal
+ }
+ avgRecv := int64(0)
+ if s.llmRespTotal > 0 {
+ avgRecv = s.llmRespBytesTotal / s.llmRespTotal
+ }
+ reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal
+ s.mu.RUnlock()
+ mins := time.Since(s.startTime).Minutes()
+ if mins <= 0 {
+ mins = 0.001
+ }
+ rpm := float64(reqs) / mins
+ sentPerMin := float64(sentTot) / mins
+ recvPerMin := float64(recvTot) / mins
+ logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin)
+}
+
+// Completion prompt builders and filters
+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 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. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression)."
+ 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 isIdentChar(ch byte) bool {
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'
+}
+
+// Inline prompt utilities
+func lineHasInlinePrompt(line string) bool {
+ if _, _, _, ok := findStrictSemicolonTag(line); ok {
+ return true
+ }
+ return hasDoubleSemicolonTrigger(line)
+}
+
+func leadingIndent(line string) string {
+ i := 0
+ for i < len(line) {
+ if line[i] == ' ' || line[i] == '\t' {
+ i++
+ continue
+ }
+ break
+ }
+ if i == 0 {
+ return ""
+ }
+ return line[:i]
+}
+
+func applyIndent(indent, suggestion string) string {
+ if indent == "" || suggestion == "" {
+ return suggestion
+ }
+ lines := splitLines(suggestion)
+ for i, ln := range lines {
+ if strings.TrimSpace(ln) == "" {
+ continue
+ }
+ if strings.HasPrefix(ln, indent) {
+ continue
+ }
+ lines[i] = indent + ln
+ }
+ return strings.Join(lines, "\n")
+}
+
+// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
+func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit {
+ d := s.getDocument(uri)
+ if d == nil || len(d.lines) == 0 {
+ return nil
+ }
+ var edits []TextEdit
+ for i, line := range d.lines {
+ edits = append(edits, promptRemovalEditsForLine(line, i)...)
+ }
+ return edits
+}
+
+func promptRemovalEditsForLine(line string, lineNum int) []TextEdit {
+ if hasDoubleSemicolonTrigger(line) {
+ return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}}
+ }
+ return collectSemicolonMarkers(line, lineNum)
+}
+
+func hasDoubleSemicolonTrigger(line string) bool {
+ pos := 0
+ for pos < len(line) {
+ j := strings.Index(line[pos:], ";;")
+ if j < 0 {
+ return false
+ }
+ j += pos
+ contentStart := j + 2
+ if contentStart >= len(line) {
+ return false
+ }
+ first := line[contentStart]
+ if first == ' ' || first == ';' {
+ pos = contentStart + 1
+ continue
+ }
+ k := strings.Index(line[contentStart+1:], ";")
+ if k < 0 {
+ return false
+ }
+ closeIdx := contentStart + 1 + k
+ if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' {
+ pos = closeIdx + 1
+ continue
+ }
+ return true
+ }
+ return false
+}
+
+func collectSemicolonMarkers(line string, lineNum int) []TextEdit {
+ var edits []TextEdit
+ startSemi := 0
+ for startSemi < len(line) {
+ j := strings.Index(line[startSemi:], ";")
+ if j < 0 {
+ break
+ }
+ j += startSemi
+ k := strings.Index(line[j+1:], ";")
+ if k < 0 {
+ break
+ }
+ if j+1 >= len(line) || line[j+1] == ' ' {
+ startSemi = j + 1
+ continue
+ }
+ if line[j+1] == ';' {
+ startSemi = j + 2
+ continue
+ }
+ closeIdx := j + 1 + k
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
+ startSemi = closeIdx + 1
+ continue
+ }
+ if closeIdx-(j+1) < 1 {
+ startSemi = closeIdx + 1
+ continue
+ }
+ endChar := closeIdx + 1
+ if endChar < len(line) && line[endChar] == ' ' {
+ endChar++
+ }
+ edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""})
+ startSemi = endChar
+ }
+ return edits
+}