diff options
Diffstat (limited to 'internal/lsp')
| -rw-r--r-- | internal/lsp/completion_state.go | 20 | ||||
| -rw-r--r-- | internal/lsp/document_test.go | 3 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 174 | ||||
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 95 | ||||
| -rw-r--r-- | internal/lsp/handlers_completion.go | 25 | ||||
| -rw-r--r-- | internal/lsp/handlers_utils.go | 171 | ||||
| -rw-r--r-- | internal/lsp/llm_client_registry.go | 9 | ||||
| -rw-r--r-- | internal/lsp/server.go | 20 |
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) } } |
