summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
Diffstat (limited to 'internal/lsp')
-rw-r--r--internal/lsp/completion_state.go20
-rw-r--r--internal/lsp/document_test.go3
-rw-r--r--internal/lsp/handlers.go174
-rw-r--r--internal/lsp/handlers_codeaction.go95
-rw-r--r--internal/lsp/handlers_completion.go25
-rw-r--r--internal/lsp/handlers_utils.go171
-rw-r--r--internal/lsp/llm_client_registry.go9
-rw-r--r--internal/lsp/server.go20
8 files changed, 238 insertions, 279 deletions
diff --git a/internal/lsp/completion_state.go b/internal/lsp/completion_state.go
index 692eafe..5c2716f 100644
--- a/internal/lsp/completion_state.go
+++ b/internal/lsp/completion_state.go
@@ -69,18 +69,16 @@ func (s *completionState) takePendingCompletion(key string) []CompletionItem {
return cpy
}
-// cacheGet returns the cached value for key. A read lock is sufficient for
-// cache misses. On a hit we must promote to a write lock so touchLocked can
-// update the LRU order.
+// cacheGet returns the cached value for key. Uses a single write lock to
+// avoid a TOCTOU race between the lookup and the LRU touch — the key could
+// be evicted between an RUnlock and a subsequent Lock promotion.
func (s *completionState) cacheGet(key string) (string, bool) {
- s.stateMu.RLock()
+ s.stateMu.Lock()
+ defer s.stateMu.Unlock()
v, ok := s.compCache[key]
- s.stateMu.RUnlock()
if !ok {
return "", false
}
- s.stateMu.Lock()
- defer s.stateMu.Unlock()
s.touchLocked(key)
return v, true
}
@@ -105,17 +103,15 @@ func (s *completionState) cachePut(key, value string) {
s.touchLocked(key)
}
+// touchLocked moves key to the end of the LRU order list.
+// Uses delete-and-append: remove the existing entry in-place, then append.
func (s *completionState) touchLocked(key string) {
- idx := -1
for i, k := range s.compCacheOrder {
if k == key {
- idx = i
+ s.compCacheOrder = append(s.compCacheOrder[:i], s.compCacheOrder[i+1:]...)
break
}
}
- if idx >= 0 {
- s.compCacheOrder = append(append([]string{}, s.compCacheOrder[:idx]...), s.compCacheOrder[idx+1:]...)
- }
s.compCacheOrder = append(s.compCacheOrder, key)
}
diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go
index 0d19f29..e6fba40 100644
--- a/internal/lsp/document_test.go
+++ b/internal/lsp/document_test.go
@@ -8,6 +8,7 @@ import (
"testing"
"codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/llmutils"
)
func newTestServer() *Server {
@@ -42,7 +43,7 @@ func newTestServer() *Server {
docs: make(map[string]*document),
cfg: cfg,
codeActionSubsystem: codeActionSubsystem{
- llmClientRegistry: llmClientRegistry{llmProvider: canonicalProvider(cfg.Provider)},
+ llmClientRegistry: llmClientRegistry{llmProvider: llmutils.CanonicalProvider(cfg.Provider)},
},
completionSubsystem: completionSubsystem{completionState: completionState{}},
}
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index ad2f98d..3b3f8e0 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -6,6 +6,8 @@ import (
"fmt"
"strings"
"unicode/utf8"
+
+ "codeberg.org/snonux/hexai/internal/logging"
)
func (s *Server) handle(req Request) {
@@ -18,10 +20,6 @@ func (s *Server) handle(req Request) {
}
}
-// handleInitialize moved to handlers_init.go
-
-// 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
// a line comment (//, #, --). Returns the instruction string and the selection
@@ -95,99 +93,11 @@ func (s *Server) findFirstInstructionInLine(line string) (instr string, cleaned
return best.text, cleaned, true
}
-// diagnosticsInRange parses the CodeAction context and returns diagnostics
-// that overlap the given selection range. If the context is missing or does
-// not contain diagnostics, returns an empty slice.
-// CodeAction-related handlers and helpers moved to handlers_codeaction.go
-
-// extractRangeText moved to handlers_utils.go
-
-// handleInitialized moved to handlers_init.go
-
-// handleShutdown moved to handlers_init.go
-
-// handleExit moved to handlers_init.go
-
-// handleDidOpen moved to handlers_document.go
-
-// handleDidChange moved to handlers_document.go
-
-// handleDidClose moved to handlers_document.go
-
-// handleCompletion moved to handlers_completion.go
-
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)
}
-// docBeforeAfter returns the full document text split at the given position.
-// The returned strings are the text before the cursor (inclusive of anything
-// left of the position) and the text after the cursor.
-// docBeforeAfter moved to handlers_document.go
-
-// extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter
-// if provided by the client; when absent it returns zeros.
-// extractTriggerInfo moved to handlers_completion.go
-
-// --- in-editor chat (";C ...") ---
-
-// detectAndHandleChat scans the current document for any line that starts with
-// ";C" and appears to be awaiting a response (i.e., followed by a blank line
-// and no non-empty answer line yet). If found, it asks the LLM and inserts the
-// answer below the blank line, leaving exactly one empty line between prompt
-// and response.
-// detectAndHandleChat moved to handlers_document.go
-
-// applyChatEdits removes the triggering punctuation at end of the line and
-// inserts two newlines followed by a new line with the response prefixed.
-// applyChatEdits moved to handlers_document.go
-
-// buildChatHistory walks upwards from the current line to collect the most recent
-// Q/A pairs in the in-editor transcript. It returns messages in chronological order
-// ending with the current user prompt. Limits to a small number of pairs to control tokens.
-// buildChatHistory moved to handlers_document.go
-
-// stripTrailingTrigger removes a single trailing punctuation from the set
-// [?,!,:] or both semicolons if present at end, mirroring the inline trigger rules.
-// stripTrailingTrigger moved to handlers_document.go
-
-// clientApplyEdit sends a workspace/applyEdit request to the client.
-// clientApplyEdit moved to handlers_document.go
-
-// nextReqID returns a unique json.RawMessage id for server-initiated requests.
-// nextReqID moved to handlers_document.go
-
-// --- completion helpers ---
-
-// buildDocString moved to handlers_completion.go
-
-// logCompletionContext moved to handlers_completion.go
-
-// tryLLMCompletion moved to handlers_completion.go
-
-// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion.
-// parseManualInvoke moved to handlers_completion.go
-
-// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL.
-// shouldSuppressForChatTriggerEOL moved to handlers_completion.go
-
-// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply.
-// prefixHeuristicAllows moved to handlers_completion.go
-
-// tryProviderNativeCompletion attempts provider-native completion and returns items when successful.
-// tryProviderNativeCompletion moved to handlers_completion.go
-
-// buildCompletionMessages constructs the LLM messages for completion.
-// buildCompletionMessages moved to handlers_completion.go
-
-// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules.
-// postProcessCompletion moved to handlers_completion.go
-
-// busyCompletionItem builds a visible, non-inserting completion item indicating
-// that an LLM request is already in flight.
-// removed: previous single in-flight LLM busy gate and busy item
-
func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string {
// Normalize left-of-cursor by trimming trailing spaces/tabs
idx := p.Position.Character
@@ -246,10 +156,14 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool {
TriggerCharacter string `json:"triggerCharacter,omitempty"`
}
if raw, ok := p.Context.(json.RawMessage); ok {
- _ = json.Unmarshal(raw, &ctx)
+ if err := json.Unmarshal(raw, &ctx); err != nil {
+ logging.Logf("lsp ", "handleCompletion: unmarshal raw context: %v", err)
+ }
} else {
b, _ := json.Marshal(p.Context)
- _ = json.Unmarshal(b, &ctx)
+ if err := json.Unmarshal(b, &ctx); err != nil {
+ logging.Logf("lsp ", "handleCompletion: unmarshal context: %v", err)
+ }
}
// If configured and the line contains a bare double-open marker (e.g., '>>!' with no '>>!text>'),
// do not treat as a trigger source.
@@ -330,78 +244,6 @@ func containsAny(haystack string, seqs []string) bool {
return false
}
-// small helpers to keep tryLLMCompletion short
-// LLM stats helpers moved to handlers_utils.go
-
-// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
-// Supported form (inclusive):
-// - ";...;" where there is no space immediately after the first ';'
-// and no space immediately before the last ';'. An optional single space
-// after the trailing ';' is also removed for cleanliness.
-//
-// Multiple markers per line are supported.
-// Inline prompt removal helpers moved to handlers_utils.go
-
-// inParamList moved to handlers_utils.go
-
-// buildPrompts moved to handlers_utils.go
-
-// computeTextEditAndFilter moved to handlers_utils.go
-
-// computeWordStart moved to handlers_utils.go
-
-// 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.
-// lineHasInlinePrompt moved to handlers_utils.go
-
-// leadingIndent returns the run of leading spaces/tabs from the provided line.
-// 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.
-// applyIndent moved to handlers_utils.go
-
-// 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;".
-// isBareDoubleSemicolon moved to handlers_utils.go
-
-// stripDuplicateAssignmentPrefix removes a duplicated assignment prefix (e.g.,
-// "name :=") from the beginning of the model suggestion when that same prefix
-// already appears immediately to the left of the cursor on the current line.
-// Also handles simple '=' assignments.
-// stripDuplicateAssignmentPrefix moved to handlers_utils.go
-
-// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated
-// at the beginning of its suggestion. It compares the entire text to the left of the
-// cursor (prefixBeforeCursor) against the suggestion, trimming whitespace appropriately,
-// and strips the longest sensible overlap. This prevents cases like:
-//
-// prefix: "func New "
-// suggestion:"func New() *Type"
-//
-// resulting in duplicates like "func New func New() *Type".
-// stripDuplicateGeneralPrefix moved to handlers_utils.go
-
-// isIdentBoundary moved to handlers_utils.go
-
-// stripCodeFences removes surrounding Markdown code fences from a model
-// response when the entire output is wrapped, e.g. starting with "```go" or
-// "```" and ending with "```". It returns the inner content unchanged.
-// stripCodeFences moved to handlers_utils.go
-
-// stripInlineCodeSpan returns only the contents of the first inline backtick
-// code span if present, e.g., "some text `x := y()` more" -> "x := y()".
-// If no matching pair of backticks exists, it returns the input unchanged.
-// This is intended for code completion responses where the model may wrap a
-// small snippet in single backticks among prose.
-// stripInlineCodeSpan moved to handlers_utils.go
-
-// labelForCompletion moved to handlers_utils.go
-
func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem {
return []CompletionItem{{
Label: "hexai-complete",
diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go
index 8b16fcd..1d8a36f 100644
--- a/internal/lsp/handlers_codeaction.go
+++ b/internal/lsp/handlers_codeaction.go
@@ -25,6 +25,15 @@ type codeActionPayload struct {
Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
}
+type customActionPayload struct {
+ Type string `json:"type"`
+ ID string `json:"id"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Selection string `json:"selection"`
+ Diagnostics []Diagnostic `json:"diagnostics,omitempty"`
+}
+
// CodeActionHandler builds and resolves code actions for a specific action type.
type CodeActionHandler interface {
Build(s *Server, p CodeActionParams, selection string) []CodeAction
@@ -103,58 +112,64 @@ func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams,
return
}
diags := s.diagnosticsInRange(p.Context, p.Range)
+
for _, ca := range customs {
title := strings.TrimSpace(ca.Title)
if title == "" {
continue
}
+
scope := strings.TrimSpace(strings.ToLower(ca.Scope))
if scope == "diagnostics" {
- if len(diags) == 0 {
- continue
- }
- payload := struct {
- Type string `json:"type"`
- ID string `json:"id"`
- URI string `json:"uri"`
- Range Range `json:"range"`
- Selection string `json:"selection"`
- Diagnostics []Diagnostic `json:"diagnostics"`
- }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags}
- raw, ok := s.marshalCodeActionData(payload)
- if !ok {
- continue
- }
- kind := ca.Kind
- if strings.TrimSpace(kind) == "" {
- kind = "quickfix"
- }
- *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})
- continue
- }
- // default: selection
- if strings.TrimSpace(sel) == "" {
- continue
- }
- payload := struct {
- Type string `json:"type"`
- ID string `json:"id"`
- URI string `json:"uri"`
- Range Range `json:"range"`
- Selection string `json:"selection"`
- }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel}
- raw, ok := s.marshalCodeActionData(payload)
- if !ok {
- continue
- }
- kind := ca.Kind
- if strings.TrimSpace(kind) == "" {
- kind = "refactor"
+ s.appendCustomActionForDiagnostics(actions, p, sel, diags, ca, title)
+ } else {
+ s.appendCustomActionForSelection(actions, p, sel, ca, title)
}
+ }
+}
+
+func (s *Server) appendCustomActionForDiagnostics(actions *[]CodeAction, p CodeActionParams, sel string, diags []Diagnostic, ca appconfig.CustomAction, title string) {
+ if len(diags) == 0 {
+ return
+ }
+ payload := customActionPayload{
+ Type: "custom",
+ ID: ca.ID,
+ URI: p.TextDocument.URI,
+ Range: p.Range,
+ Selection: sel,
+ Diagnostics: diags,
+ }
+ if raw, ok := s.marshalCodeActionData(payload); ok {
+ kind := s.resolveCodeActionKind(ca.Kind, "quickfix")
+ *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})
+ }
+}
+
+func (s *Server) appendCustomActionForSelection(actions *[]CodeAction, p CodeActionParams, sel string, ca appconfig.CustomAction, title string) {
+ if strings.TrimSpace(sel) == "" {
+ return
+ }
+ payload := customActionPayload{
+ Type: "custom",
+ ID: ca.ID,
+ URI: p.TextDocument.URI,
+ Range: p.Range,
+ Selection: sel,
+ }
+ if raw, ok := s.marshalCodeActionData(payload); ok {
+ kind := s.resolveCodeActionKind(ca.Kind, "refactor")
*actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw})
}
}
+func (s *Server) resolveCodeActionKind(kind, fallback string) string {
+ if strings.TrimSpace(kind) == "" {
+ return fallback
+ }
+ return kind
+}
+
func (s *Server) codeActionHandlers() map[string]CodeActionHandler {
return map[string]CodeActionHandler{
"rewrite": codeActionHandler{build: buildRewriteActions, resolve: resolveRewriteCodeAction},
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go
index 8ef67ab..aa22fc2 100644
--- a/internal/lsp/handlers_completion.go
+++ b/internal/lsp/handlers_completion.go
@@ -10,6 +10,7 @@ import (
"time"
"codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/llmutils"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/stats"
)
@@ -88,10 +89,14 @@ func extractTriggerInfo(p CompletionParams) (kind int, ch string) {
TriggerCharacter string `json:"triggerCharacter,omitempty"`
}
if raw, ok := p.Context.(json.RawMessage); ok {
- _ = json.Unmarshal(raw, &ctx)
+ if err := json.Unmarshal(raw, &ctx); err != nil {
+ logging.Logf("lsp ", "extractTriggerInfo: unmarshal raw context: %v", err)
+ }
} else {
b, _ := json.Marshal(p.Context)
- _ = json.Unmarshal(b, &ctx)
+ if err := json.Unmarshal(b, &ctx); err != nil {
+ logging.Logf("lsp ", "extractTriggerInfo: unmarshal context: %v", err)
+ }
}
return ctx.TriggerKind, ctx.TriggerCharacter
}
@@ -283,7 +288,7 @@ func (s *Server) runCompletionForSpec(ctx context.Context, plan completionPlan,
modelKey := spec.effectiveModel(client.DefaultModel())
providerKey := spec.provider
if providerKey == "" {
- providerKey = canonicalProvider(client.Name())
+ providerKey = llmutils.CanonicalProvider(client.Name())
}
cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelKey
if cached, ok := s.completionCacheGet(cacheKey); ok && strings.TrimSpace(cached) != "" {
@@ -326,7 +331,7 @@ func (s *Server) executeChatCompletion(ctx context.Context, plan completionPlan,
detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed)
providerKey := spec.provider
if providerKey == "" {
- providerKey = canonicalProvider(client.Name())
+ providerKey = llmutils.CanonicalProvider(client.Name())
}
cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed
s.completionCachePut(cacheKey, cleaned)
@@ -343,10 +348,14 @@ func parseManualInvoke(ctx any) bool {
TriggerKind int `json:"triggerKind"`
}
if raw, ok := ctx.(json.RawMessage); ok {
- _ = json.Unmarshal(raw, &c)
+ if err := json.Unmarshal(raw, &c); err != nil {
+ logging.Logf("lsp ", "parseManualInvoke: unmarshal raw context: %v", err)
+ }
} else {
b, _ := json.Marshal(ctx)
- _ = json.Unmarshal(b, &c)
+ if err := json.Unmarshal(b, &c); err != nil {
+ logging.Logf("lsp ", "parseManualInvoke: unmarshal context: %v", err)
+ }
}
return c.TriggerKind == 1
}
@@ -429,7 +438,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio
})
provider := spec.provider
if provider == "" {
- provider = canonicalProvider(cfg.Provider)
+ provider = llmutils.CanonicalProvider(cfg.Provider)
}
logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", provider, path)
ctx2, cancel2 := context.WithTimeout(ctx, 15*time.Second)
@@ -476,7 +485,7 @@ func (s *Server) tryProviderNativeCompletion(ctx context.Context, plan completio
detail := fmt.Sprintf("Hexai %s:%s", client.Name(), modelUsed)
providerKey := provider
if providerKey == "" {
- providerKey = canonicalProvider(client.Name())
+ providerKey = llmutils.CanonicalProvider(client.Name())
}
cacheKey := plan.cacheKey + "|" + providerKey + ":" + modelUsed
s.completionCachePut(cacheKey, cleaned)
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
index bede7a0..66e2ed1 100644
--- a/internal/lsp/handlers_utils.go
+++ b/internal/lsp/handlers_utils.go
@@ -4,6 +4,7 @@ package lsp
import (
"context"
"fmt"
+ "os"
"strings"
"time"
"unicode/utf8"
@@ -60,7 +61,7 @@ func (s *Server) buildRequestSpecs(surface surfaceKind) []requestSpec {
if provider == "" {
provider = cfg.Provider
}
- provider = canonicalProvider(provider)
+ provider = llmutils.CanonicalProvider(provider)
fallbackModel := entry.Model
if fallbackModel == "" {
fallbackModel = strings.TrimSpace(llmutils.DefaultModelForProvider(cfg, provider))
@@ -87,7 +88,7 @@ func (s *Server) primaryRequestSpec(surface surfaceKind) requestSpec {
specs := s.buildRequestSpecs(surface)
if len(specs) == 0 {
cfg := s.currentConfig()
- provider := canonicalProvider(cfg.Provider)
+ 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())}}
}
@@ -99,10 +100,6 @@ func (s *Server) buildRequestSpec(surface surfaceKind) requestSpec {
return s.primaryRequestSpec(surface)
}
-func canonicalProvider(name string) string {
- return llmutils.CanonicalProvider(name)
-}
-
func surfaceConfigsFor(cfg appconfig.App, surface surfaceKind) []appconfig.SurfaceConfig {
switch surface {
case surfaceCompletion:
@@ -173,7 +170,17 @@ func (s *Server) logLLMStats(model string) {
}
scopeReqs := snap.ScopeReqs(provider, modelName)
scopeRPM := snap.ScopeRPM(provider, modelName)
- s.emitGlobalStatus(snap.Global.Reqs, snap.RPM, snap.Global.Sent, snap.Global.Recv, provider, modelName, scopeRPM, scopeReqs, snap.Window)
+ 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,
+ })
}
}
}
@@ -342,17 +349,8 @@ func applyIndent(indent, suggestion string) string {
// 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) {
- if openStr == "" {
- openStr = string(open)
- }
- if openStr == "" {
- return "", 0, 0, false
- }
- openChar := open
- if openChar == 0 {
- openChar = openStr[0]
- }
- doubleSeqs := doubleOpenSequences(openStr, openChar, close)
+ openChar, doubleSeqs := prepareInlineTagParsing(openStr, open, close)
+
pos := 0
for pos < len(line) {
j := strings.IndexByte(line[pos:], openChar)
@@ -364,10 +362,12 @@ func findStrictInlineTag(line string, openStr string, open, close byte) (string,
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) {
@@ -379,6 +379,7 @@ func findStrictInlineTag(line string, openStr string, open, close byte) (string,
break
}
}
+
next := line[contentStart]
if next == ' ' {
pos = contentStart + 1
@@ -388,26 +389,55 @@ func findStrictInlineTag(line string, openStr string, open, close byte) (string,
pos = contentStart + 1
continue
}
+
k := strings.IndexByte(line[contentStart:], close)
if k < 0 {
return "", 0, 0, false
}
closeIdx := contentStart + k
- if closeIdx-1 >= contentStart && line[closeIdx-1] == ' ' {
+
+ if closeIdx > contentStart && line[closeIdx-1] == ' ' {
pos = closeIdx + 1
continue
}
+
inner := strings.TrimSpace(line[contentStart:closeIdx])
if inner == "" {
pos = closeIdx + 1
continue
}
- end := closeIdx + 1
- return inner, j, end, true
+
+ 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;".
@@ -622,62 +652,86 @@ func promptRemovalEditsForLine(line string, lineNum int, openStr string, open, c
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 {
- if openStr == "" {
- openStr = string(open)
- }
- if openStr == "" {
- return false
- }
- seqs := doubleOpenSequences(openStr, open, close)
+ seqs := validDoubleOpenSequences(openStr, open, close)
if len(seqs) == 0 {
return false
}
+
pos := 0
for pos < len(line) {
- found := -1
- var seq string
- for _, cand := range seqs {
- if cand == "" {
- continue
- }
- if idx := strings.Index(line[pos:], cand); idx >= 0 {
- abs := pos + idx
- if found < 0 || abs < found {
- found = abs
- seq = cand
- }
- }
- }
- if found < 0 {
+ foundAt, seq := findEarliestSequence(line, pos, seqs)
+ if foundAt < 0 {
return false
}
- contentStart := found + len(seq)
+
+ 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-1 >= 0 && line[closeIdx-1] == ' ' {
+ 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)
@@ -755,3 +809,30 @@ func utf16OffsetToByteOffset(s string, utf16Offset int) int {
}
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
+}
diff --git a/internal/lsp/llm_client_registry.go b/internal/lsp/llm_client_registry.go
index 53fa25f..6b9c722 100644
--- a/internal/lsp/llm_client_registry.go
+++ b/internal/lsp/llm_client_registry.go
@@ -6,6 +6,7 @@ import (
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
+ "codeberg.org/snonux/hexai/internal/llmutils"
"codeberg.org/snonux/hexai/internal/logging"
)
@@ -25,9 +26,9 @@ func newLLMClientRegistry() llmClientRegistry {
}
func (r *llmClientRegistry) applyOptions(client llm.Client, configuredProvider string) {
- provider := canonicalProvider(configuredProvider)
+ provider := llmutils.CanonicalProvider(configuredProvider)
if client != nil {
- if name := canonicalProvider(client.Name()); name != "" {
+ if name := llmutils.CanonicalProvider(client.Name()); name != "" {
provider = name
}
}
@@ -45,13 +46,13 @@ func (r *llmClientRegistry) current() llm.Client {
}
func (r *llmClientRegistry) clientFor(spec requestSpec, cfg appconfig.App, build llmClientBuilder) llm.Client {
- provider := canonicalProvider(spec.provider)
+ provider := llmutils.CanonicalProvider(spec.provider)
r.clientsMu.RLock()
baseProvider := r.llmProvider
baseClient := r.llmClient
if baseClient != nil && strings.TrimSpace(baseProvider) == "" {
- baseProvider = canonicalProvider(baseClient.Name())
+ baseProvider = llmutils.CanonicalProvider(baseClient.Name())
}
if provider == "" {
provider = baseProvider
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 9c476ed..c266e91 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -77,10 +77,24 @@ type llmStatsSubsystem struct {
startTime time.Time
}
+// GlobalStatus bundles the fields for a global status update,
+// replacing a long parameter list.
+type GlobalStatus struct {
+ Reqs int64
+ RPM float64
+ Sent int64
+ Recv int64
+ Provider string
+ Model string
+ ScopeRPM float64
+ ScopeReqs int64
+ Window time.Duration
+}
+
// StatusSink receives status updates from the LSP server.
type StatusSink interface {
SetLLMStart(provider, model string) error
- SetGlobal(reqs int64, rpm float64, sent int64, recv int64, provider, model string, scopeRPM float64, scopeReqs int64, window time.Duration) error
+ SetGlobal(gs GlobalStatus) error
}
// ServerOptions collects configuration for NewServer to avoid long parameter lists.
@@ -334,9 +348,9 @@ func (s *Server) emitLLMStartStatus(provider, model string) {
}
}
-func (s *Server) emitGlobalStatus(reqs int64, rpm float64, sent int64, recv int64, provider, model string, scopeRPM float64, scopeReqs int64, window time.Duration) {
+func (s *Server) emitGlobalStatus(gs GlobalStatus) {
if s.statusSink != nil {
- _ = s.statusSink.SetGlobal(reqs, rpm, sent, recv, provider, model, scopeRPM, scopeReqs, window)
+ _ = s.statusSink.SetGlobal(gs)
}
}