summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-29 00:24:59 +0300
committerPaul Buetow <paul@buetow.org>2025-08-29 00:24:59 +0300
commit99db2d66c8baa72a0a6dd6e0fbaad9b20826483d (patch)
treec99908a62c94ce8f0c68be3496ca524a77637797
parent0c2994f0065090a4884b28dc27eb760db2dfaab3 (diff)
lsp: extract generic helpers to handlers_utils.go; tidy imports
-rw-r--r--internal/lsp/handlers.go245
-rw-r--r--internal/lsp/handlers_utils.go254
2 files changed, 265 insertions, 234 deletions
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index 774a94a..a7b0ac4 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -5,10 +5,7 @@ package lsp
import (
"encoding/json"
"fmt"
- "hexai/internal/llm"
- "hexai/internal/logging"
"strings"
- "time"
)
func (s *Server) handle(req Request) {
@@ -23,13 +20,7 @@ func (s *Server) handle(req Request) {
// handleInitialize moved to handlers_init.go
-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
-}
+// llmRequestOpts moved to handlers_utils.go
// instructionFromSelection extracts the first instruction from selection text.
// Preference order on each line: strict ;text; marker (no inner spaces), then
@@ -482,41 +473,7 @@ func (s *Server) makeCompletionItems(cleaned string, inParams bool, current stri
}
// small helpers to keep tryLLMCompletion short
-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)
-}
+// LLM stats helpers moved to handlers_utils.go
// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
// Supported form (inclusive):
@@ -525,209 +482,29 @@ func (s *Server) logLLMStats() {
// after the trailing ';' is also removed for cleanliness.
//
// Multiple markers per line are supported.
-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 // nothing after ';;'
- }
- // First content char cannot be space or another ';'
- first := line[contentStart]
- if first == ' ' || first == ';' {
- pos = contentStart + 1
- continue
- }
- // Require at least one content char before a closing ';'
- k := strings.Index(line[contentStart+1:], ";")
- if k < 0 {
- return false
- }
- closeIdx := contentStart + 1 + k
- // Disallow trailing space before closing ';'
- 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
-}
+// Inline prompt removal helpers moved to handlers_utils.go
-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)
-}
+// inParamList moved to handlers_utils.go
-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
-}
+// buildPrompts moved to handlers_utils.go
-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
-}
+// computeTextEditAndFilter moved to handlers_utils.go
-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
-}
+// computeWordStart moved to handlers_utils.go
-func isIdentChar(ch byte) bool {
- return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'
-}
+// isIdentChar moved to handlers_utils.go
// lineHasInlinePrompt returns true if the line contains an inline strict
// semicolon marker ;text; (no spaces at boundaries) or a double-semicolon
// pattern recognized by hasDoubleSemicolonTrigger.
-func lineHasInlinePrompt(line string) bool {
- if _, _, _, ok := findStrictSemicolonTag(line); ok {
- return true
- }
- return hasDoubleSemicolonTrigger(line)
-}
+// lineHasInlinePrompt moved to handlers_utils.go
// leadingIndent returns the run of leading spaces/tabs from the provided 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]
-}
+// leadingIndent moved to handlers_utils.go
// applyIndent prefixes each non-empty line of suggestion with the given indent
// unless it already starts with that indent.
-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")
-}
+// applyIndent moved to handlers_utils.go
// isBareDoubleSemicolon reports whether the line contains a standalone
// double-semicolon marker with no inline content (";;" possibly with only
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
+}