// Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). package lsp import ( "context" "fmt" "os" "strings" "time" "unicode/utf8" "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/llmutils" "codeberg.org/snonux/hexai/internal/logging" "codeberg.org/snonux/hexai/internal/stats" "codeberg.org/snonux/hexai/internal/textutil" ) type surfaceKind string const ( surfaceCompletion surfaceKind = "completion" surfaceCodeAction surfaceKind = "code_action" surfaceChat surfaceKind = "chat" ) type requestSpec struct { provider string entry appconfig.SurfaceConfig fallbackModel string options []llm.RequestOption index int } func (r requestSpec) effectiveModel(defaultModel string) string { if m := strings.TrimSpace(r.entry.Model); m != "" { return m } if f := strings.TrimSpace(r.fallbackModel); f != "" { return f } return strings.TrimSpace(defaultModel) } func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec { cfg := s.currentConfig() entries := surfaceConfigsFor(cfg, surface) if len(entries) == 0 { entries = []appconfig.SurfaceConfig{{Provider: cfg.Provider}} } maxTokens := s.maxTokens() specs := make([]requestSpec, 0, len(entries)) for idx, raw := range entries { entry := appconfig.SurfaceConfig{ Provider: strings.TrimSpace(raw.Provider), Model: strings.TrimSpace(raw.Model), Temperature: raw.Temperature, } provider := entry.Provider if provider == "" { provider = cfg.Provider } provider = llmutils.CanonicalProvider(provider) fallbackModel := entry.Model if fallbackModel == "" { fallbackModel = strings.TrimSpace(llmutils.DefaultModelForProvider(cfg, provider)) } opts := []llm.RequestOption{llm.WithMaxTokens(maxTokens)} if entry.Model != "" { opts = append(opts, llm.WithModel(entry.Model)) } if temp, ok := chooseSurfaceTemperature(cfg, entry, provider, fallbackModel); ok { opts = append(opts, llm.WithTemperature(temp)) } specs = append(specs, requestSpec{ provider: provider, entry: entry, fallbackModel: fallbackModel, options: opts, index: idx, }) } return specs } func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec { specs := s.buildRequestSpecs(surface) if len(specs) == 0 { cfg := s.currentConfig() provider := llmutils.CanonicalProvider(cfg.Provider) fallback := strings.TrimSpace(llmutils.DefaultModelForProvider(cfg, provider)) return requestSpec{provider: provider, fallbackModel: fallback, options: []llm.RequestOption{llm.WithMaxTokens(s.maxTokens())}} } return specs[0] } // buildRequestSpec is retained for consumers expecting a single-entry helper. func (s *Server) buildRequestSpec(surface surfaceKind) requestSpec { return s.primaryRequestSpec(surface) } func surfaceConfigsFor(cfg appconfig.App, surface surfaceKind) []appconfig.SurfaceConfig { switch surface { case surfaceCompletion: return cfg.CompletionConfigs case surfaceCodeAction: return cfg.CodeActionConfigs case surfaceChat: return cfg.ChatConfigs default: return nil } } // chooseSurfaceTemperature resolves the effective temperature for a surface // request, delegating GPT-5 override logic to llmutils.ResolveTemperature. func chooseSurfaceTemperature(cfg appconfig.App, entry appconfig.SurfaceConfig, provider string, fallbackModel string) (float64, bool) { effectiveModel := strings.TrimSpace(entry.Model) if effectiveModel == "" { effectiveModel = strings.TrimSpace(fallbackModel) } return llmutils.ResolveTemperature(provider, effectiveModel, entry.Temperature, cfg.CodingTemperature) } // incSentCounters atomically increments request count and sent bytes. func (s *Server) incSentCounters(n int) { s.llmReqTotal.Add(1) s.llmSentBytesTotal.Add(int64(n)) } // incRecvCounters atomically increments response count and received bytes. func (s *Server) incRecvCounters(n int) { s.llmRespTotal.Add(1) s.llmRespBytesTotal.Add(int64(n)) } // logLLMStats logs local LLM traffic counters and the global stats snapshot. // Counter reads are atomic so no server-wide lock is needed. func (s *Server) logLLMStats(model string) { reqs := s.llmReqTotal.Load() sentTot := s.llmSentBytesTotal.Load() recvTot := s.llmRespBytesTotal.Load() avgSent := int64(0) if reqs > 0 { avgSent = sentTot / reqs } respTotal := s.llmRespTotal.Load() avgRecv := int64(0) if respTotal > 0 { avgRecv = recvTot / respTotal } mins := time.Since(s.startTime).Minutes() if mins <= 0 { mins = 0.001 } rpmLocal := float64(reqs) / mins sentPerMin := float64(sentTot) / mins recvPerMin := float64(recvTot) / mins // Log local process counters logging.Logf("lsp ", "llm stats (local) 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, rpmLocal, sentPerMin, recvPerMin) // Global snapshot for tmux status snap, err := stats.TakeSnapshot() if err == nil { if client := s.currentLLMClient(); client != nil { provider := client.Name() modelName := strings.TrimSpace(model) if modelName == "" { modelName = client.DefaultModel() } scopeReqs := snap.ScopeReqs(provider, modelName) scopeRPM := snap.ScopeRPM(provider, modelName) s.emitGlobalStatus(GlobalStatus{ Reqs: snap.Global.Reqs, RPM: snap.RPM, Sent: snap.Global.Sent, Recv: snap.Global.Recv, Provider: provider, Model: modelName, ScopeRPM: scopeRPM, ScopeReqs: scopeReqs, Window: snap.Window, }) } } } // 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) } // renderTemplate performs simple {{var}} replacement in a template string. func renderTemplate(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } 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 } } cursorByte := utf16OffsetToByteOffset(current, p.Position.Character) startByte := computeWordStart(current, cursorByte) // TextEdit ranges use UTF-16 offsets; for ASCII identifiers byte == UTF-16. te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startByte}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned} filter := strings.TrimLeft(current[startByte:cursorByte], " \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 == '_' } // chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec requestSpec, msgs []llm.Message) (string, error) { // Count bytes sent sent := 0 for _, m := range msgs { sent += len(m.Content) } s.incSentCounters(sent) // Debounce/throttle if configured (reuse completion gates) s.waitForDebounce(ctx) if !s.waitForThrottle(ctx) { return "", context.Canceled } // Perform request client := s.clientFor(spec) if client == nil { provider := strings.TrimSpace(spec.provider) if provider == "" { provider = strings.TrimSpace(s.currentConfig().Provider) } if provider == "" { return "", fmt.Errorf("llm client unavailable; check the configured provider and required API key") } return "", fmt.Errorf("llm client unavailable for provider %q; check the configured provider and required API key", provider) } modelUsed := spec.effectiveModel(client.DefaultModel()) txt, err := client.Chat(ctx, msgs, spec.options...) if err != nil { s.logLLMStats(modelUsed) return "", err } s.incRecvCounters(len(txt)) // Update global stats cache _ = stats.Update(ctx, client.Name(), modelUsed, sent, len(txt)) s.logLLMStats(modelUsed) return txt, nil } // Inline prompt utilities func lineHasInlinePrompt(line string, openStr string, open, close byte) bool { if openStr == "" { openStr = string(open) } if _, _, _, ok := findStrictInlineTag(line, openStr, open, close); ok { return true } return hasDoubleOpenTrigger(line, openStr, open, close) } func doubleOpenSequences(openStr string, open, close byte) []string { seen := make(map[string]struct{}, 2) var seqs []string if openStr != "" && close != 0 { seq := openStr + string(close) if _, ok := seen[seq]; !ok { seen[seq] = struct{}{} seqs = append(seqs, seq) } } if openStr != "" && open != 0 { seq := string(open) + openStr if len(seq) > len(openStr) { if _, ok := seen[seq]; !ok { seen[seq] = struct{}{} seqs = append(seqs, seq) } } } return seqs } 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") } // --- Inline marker parsing and general string utilities --- // findStrictInlineTag finds >!text> (configurable), with no space after the first // opening marker and no space immediately before the closing marker. Returns the // text between markers, the start index, the end index just after closing, and ok. func findStrictInlineTag(line string, openStr string, open, close byte) (string, int, int, bool) { openChar, doubleSeqs := prepareInlineTagParsing(openStr, open, close) pos := 0 for pos < len(line) { j := strings.IndexByte(line[pos:], openChar) if j < 0 { return "", 0, 0, false } j += pos if !strings.HasPrefix(line[j:], openStr) { pos = j + 1 continue } contentStart := j + len(openStr) if contentStart >= len(line) { return "", 0, 0, false } doubleHit := false for _, seq := range doubleSeqs { if strings.HasPrefix(line[j:], seq) { doubleHit = true contentStart += len(seq) - len(openStr) if contentStart >= len(line) { return "", 0, 0, false } break } } next := line[contentStart] if next == ' ' { pos = contentStart + 1 continue } if !doubleHit && next == close { pos = contentStart + 1 continue } k := strings.IndexByte(line[contentStart:], close) if k < 0 { return "", 0, 0, false } closeIdx := contentStart + k if closeIdx > contentStart && line[closeIdx-1] == ' ' { pos = closeIdx + 1 continue } inner := strings.TrimSpace(line[contentStart:closeIdx]) if inner == "" { pos = closeIdx + 1 continue } return inner, j, closeIdx + 1, true } return "", 0, 0, false } // prepareInlineTagParsing initializes parsing state. Returns openChar and doubleSeqs. func prepareInlineTagParsing(openStr string, open, close byte) (byte, []string) { if openStr == "" { openStr = string(open) } if openStr == "" { return 0, nil } openChar := open if openChar == 0 { openChar = openStr[0] } return openChar, doubleOpenSequences(openStr, openChar, close) } // handleDoubleSequence checks for and handles double-open sequences. // Returns (doubleHit, adjustedContentStart). func handleDoubleSequence(line string, markerPos int, doubleSeqs []string, contentStart int, openStr string) (bool, int) { for _, seq := range doubleSeqs { if strings.HasPrefix(line[markerPos:], seq) { return true, contentStart + len(seq) - len(openStr) } } return false, contentStart } // isBareDoubleSemicolon reports whether the line contains a standalone // double-semicolon marker with no inline content (";;" possibly with only // whitespace after it). It explicitly excludes the valid form ";;text;". func isBareDoubleOpen(line string, openStr string, open, close byte) bool { t := strings.TrimSpace(line) if openStr == "" { openStr = string(open) } if openStr == "" { return false } for _, seq := range doubleOpenSequences(openStr, open, close) { if strings.HasPrefix(t, seq) { rest := strings.TrimSpace(t[len(seq):]) if rest == "" || rest == string(close) { return true } } } return false } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string { s2 := strings.TrimLeft(suggestion, " \t") // Prefer := if present at end of prefix if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) { tail := prefixBeforeCursor[idx+2:] if strings.TrimSpace(tail) == "" { start := idx - 1 for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') { start-- } start++ seg := strings.TrimRight(prefixBeforeCursor[start:idx+2], " \t") if strings.HasPrefix(s2, seg) { return strings.TrimLeft(s2[len(seg):], " \t") } } } // Fallback to plain '=' if present if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 { if idx <= 0 || prefixBeforeCursor[idx-1] != ':' { // not := tail := prefixBeforeCursor[idx+1:] if strings.TrimSpace(tail) == "" { start := idx - 1 for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') { start-- } start++ seg := strings.TrimRight(prefixBeforeCursor[start:idx+1], " \t") if strings.HasPrefix(s2, seg) { return strings.TrimLeft(s2[len(seg):], " \t") } } } } return suggestion } // stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string { if suggestion == "" { return suggestion } s := strings.TrimLeft(suggestion, " \t") p := strings.TrimRight(prefixBeforeCursor, " \t") if p != "" && strings.HasPrefix(s, p) { return strings.TrimLeft(s[len(p):], " \t") } for k := len(p) - 1; k > 0; k-- { if !isIdentBoundary(p[k-1]) { continue } suf := strings.TrimLeft(p[k:], " \t") if suf == "" { continue } if strings.HasPrefix(s, suf) { return strings.TrimLeft(s[len(suf):], " \t") } } return suggestion } func isIdentBoundary(ch byte) bool { return !isIdentChar(ch) } // stripCodeFences removes surrounding Markdown code fences from a model response. func stripCodeFences(s string) string { return textutil.StripCodeFences(s) } // stripInlineCodeSpan returns the contents of the first inline backtick code span if present. func stripInlineCodeSpan(s string) string { t := strings.TrimSpace(s) if t == "" { return t } i := strings.IndexByte(t, '`') if i < 0 { return t } jrel := strings.IndexByte(t[i+1:], '`') if jrel < 0 { return t } j := i + 1 + jrel return t[i+1 : j] } // labelForCompletion picks a short, readable label for the completion list. func labelForCompletion(cleaned, filter string) string { label := trimLen(firstLine(cleaned)) if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) { return filter } return label } // extractRangeText returns the exact text within the given document range. // It performs bounds checks on line indices and character offsets, returning // an empty string when the range is invalid (e.g. negative lines, out-of-bounds // lines, or an empty document). func extractRangeText(d *document, r Range) string { if d == nil || len(d.lines) == 0 { return "" } // Clamp line indices to valid bounds. if r.Start.Line < 0 || r.End.Line < 0 || r.Start.Line >= len(d.lines) { return "" } if r.End.Line >= len(d.lines) { r.End.Line = len(d.lines) - 1 r.End.Character = len(d.lines[r.End.Line]) } if r.Start.Line > r.End.Line { return "" } if r.Start.Line == r.End.Line { return extractSingleLineRange(d.lines[r.Start.Line], r) } return extractMultiLineRange(d.lines, r) } // extractSingleLineRange handles the case where start and end are on the same line. // Character offsets are clamped to the line length. func extractSingleLineRange(line string, r Range) string { if r.Start.Character < 0 { r.Start.Character = 0 } if r.End.Character > len(line) { r.End.Character = len(line) } if r.Start.Character > r.End.Character { return "" } return line[r.Start.Character:r.End.Character] } // extractMultiLineRange handles ranges spanning multiple lines, clamping // character offsets on the first and last lines. func extractMultiLineRange(lines []string, r Range) string { var b strings.Builder // first line first := lines[r.Start.Line] if r.Start.Character < 0 { r.Start.Character = 0 } if r.Start.Character > len(first) { r.Start.Character = len(first) } b.WriteString(first[r.Start.Character:]) b.WriteString("\n") // middle lines for i := r.Start.Line + 1; i < r.End.Line; i++ { b.WriteString(lines[i]) if i+1 <= r.End.Line { b.WriteString("\n") } } // last line last := lines[r.End.Line] if r.End.Character < 0 { r.End.Character = 0 } if r.End.Character > len(last) { r.End.Character = len(last) } b.WriteString(last[:r.End.Character]) return b.String() } // 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 openStr, _, openChar, closeChar := s.inlineMarkers() for i, line := range d.lines { edits = append(edits, promptRemovalEditsForLine(line, i, openStr, openChar, closeChar)...) } return edits } func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, close byte) []TextEdit { if hasDoubleOpenTrigger(line, openStr, open, close) { return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} } return collectSemicolonMarkers(line, lineNum, openStr, open, close) } // hasDoubleOpenTrigger reports whether line contains a valid double-open trigger. func hasDoubleOpenTrigger(line string, openStr string, open, close byte) bool { seqs := validDoubleOpenSequences(openStr, open, close) if len(seqs) == 0 { return false } pos := 0 for pos < len(line) { foundAt, seq := findEarliestSequence(line, pos, seqs) if foundAt < 0 { return false } contentStart := foundAt + len(seq) if contentStart >= len(line) { return false } first := line[contentStart] if first == ' ' || first == close || first == open { pos = contentStart + 1 continue } if contentStart+1 >= len(line) { return false } k := strings.IndexByte(line[contentStart+1:], close) if k < 0 { return false } closeIdx := contentStart + 1 + k if closeIdx > 0 && line[closeIdx-1] == ' ' { pos = closeIdx + 1 continue } return true } return false } // validDoubleOpenSequences returns non-empty double-open sequences. func validDoubleOpenSequences(openStr string, open, close byte) []string { seqs := doubleOpenSequences(openStr, open, close) var result []string for _, s := range seqs { if s != "" { result = append(result, s) } } return result } // findEarliestSequence finds the earliest sequence in line starting at pos. // Returns (position, sequence) or (-1, "") if none found. func findEarliestSequence(line string, pos int, seqs []string) (int, string) { foundAt := -1 var foundSeq string for _, cand := range seqs { if idx := strings.Index(line[pos:], cand); idx >= 0 { abs := pos + idx if foundAt < 0 || abs < foundAt { foundAt = abs foundSeq = cand } } } if foundAt < 0 { return -1, "" } return foundAt, foundSeq } func collectSemicolonMarkers(line string, lineNum int, openStr string, open, close byte) []TextEdit { if openStr == "" { openStr = string(open) } if openStr == "" { return nil } var edits []TextEdit start := 0 doubleSeqs := doubleOpenSequences(openStr, open, close) for start < len(line) { j := strings.Index(line[start:], openStr) if j < 0 { break } j += start contentStart := j + len(openStr) if contentStart >= len(line) { break } next := line[contentStart] if next == ' ' { start = j + 1 continue } skipDouble := false for _, seq := range doubleSeqs { if strings.HasPrefix(line[j:], seq) { skipDouble = true break } } if skipDouble { start = j + 1 continue } k := strings.IndexByte(line[contentStart:], close) if k < 0 { break } closeIdx := contentStart + k if closeIdx-1 < contentStart || line[closeIdx-1] == ' ' { start = closeIdx + 1 continue } if closeIdx == contentStart { start = 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: ""}) start = endChar } return edits } // utf16OffsetToByteOffset converts an LSP UTF-16 code-unit offset to a byte // offset within a Go (UTF-8) string. BMP characters (most code) are 1 UTF-16 // unit, while supplementary characters (e.g. emoji) are 2. Returns len(s) // if the offset exceeds the string length. func utf16OffsetToByteOffset(s string, utf16Offset int) int { byteIdx := 0 units := 0 for byteIdx < len(s) && units < utf16Offset { r, size := utf8.DecodeRuneInString(s[byteIdx:]) byteIdx += size if r >= 0x10000 { units += 2 // surrogate pair in UTF-16 } else { units++ } } return byteIdx } // --- Error handling helpers --- // fileOpenError formats an error for file opening failures. // Wraps the original error with path context. func fileOpenError(path string, err error) error { return fmt.Errorf("cannot open %s: %w", path, err) } // ensureDirectory creates a directory if it doesn't exist. // Returns an error if directory creation fails. func ensureDirectory(path string) error { return os.MkdirAll(path, 0o755) } // directoryCreateError formats an error for directory creation failures. func directoryCreateError(path string, err error) error { return fmt.Errorf("cannot create %s: %w", path, err) } // requireLLMClient checks if LLM client is available, returning an error if not. func requireLLMClient(client llm.Client) error { if client == nil { return fmt.Errorf("llm client unavailable") } return nil }