diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-17 11:28:19 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-17 11:28:19 +0200 |
| commit | 6f1c8bf7a36eb7044ed7aad30f84664cbbf0d303 (patch) | |
| tree | dd2ac6e1433177fb59c167a12fa0b4b91132f34a | |
| parent | 10562cc510f64d5ac38aeb76f03e18eb76cca40f (diff) | |
Fix bugs, remove duplication, and clean up code quality issues
- Log swallowed JSON unmarshal errors in stats and LSP handlers
- Fix debug log file handle leak in tmuxedit (return closer from initDebugLog)
- Check f.Close() errors on write paths in promptstore and tmuxedit
- Fix cacheGet TOCTOU race by using single write lock
- Fix readInput to use passed stdin reader instead of os.Stdin.Stat()
- Remove 45 'moved to' comment tombstones from lsp/handlers.go
- Deduplicate canonicalProvider wrappers (use llmutils.CanonicalProvider directly)
- Remove SetWindow side effect from stats.TakeSnapshot (pure read now)
- Move duplicated splitLines to textutil.SplitLinesBytes
- Collapse StatusSink.SetGlobal 10 params into GlobalStatus struct
- Simplify LRU touchLocked to in-place delete-and-append
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| -rw-r--r-- | internal/hexaiaction/prompts.go | 8 | ||||
| -rw-r--r-- | internal/hexaiaction/run.go | 32 | ||||
| -rw-r--r-- | internal/hexaicli/run.go | 27 | ||||
| -rw-r--r-- | internal/hexailsp/run.go | 7 | ||||
| -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 | ||||
| -rw-r--r-- | internal/promptstore/store.go | 30 | ||||
| -rw-r--r-- | internal/stats/stats.go | 13 | ||||
| -rw-r--r-- | internal/textutil/textutil.go | 26 | ||||
| -rw-r--r-- | internal/tmuxedit/history.go | 30 | ||||
| -rw-r--r-- | internal/tmuxedit/history_test.go | 4 | ||||
| -rw-r--r-- | internal/tmuxedit/run.go | 19 |
18 files changed, 356 insertions, 357 deletions
diff --git a/internal/hexaiaction/prompts.go b/internal/hexaiaction/prompts.go index 8cf8c48..03a9441 100644 --- a/internal/hexaiaction/prompts.go +++ b/internal/hexaiaction/prompts.go @@ -38,10 +38,6 @@ func providerOf(c any) string { return "llm" } -func canonicalProvider(name string) string { - return llmutils.CanonicalProvider(name) -} - // selectActionTemperature resolves the effective temperature for a code action, // delegating GPT-5 override logic to llmutils.ResolveTemperature. func selectActionTemperature(cfg actionConfig, provider string, entry appconfig.SurfaceConfig, model string) (float64, bool) { @@ -148,14 +144,14 @@ func reqOptsFrom(cfg actionConfig) requestArgs { if core.MaxTokens > 0 { opts = append(opts, llm.WithMaxTokens(core.MaxTokens)) } - provider := canonicalProvider(core.Provider) + provider := llmutils.CanonicalProvider(core.Provider) entries := providers.CodeActionConfigs if len(entries) == 0 { entries = []appconfig.SurfaceConfig{{Provider: core.Provider, Model: strings.TrimSpace(llmutils.DefaultModelForProvider(fullCfg, provider))}} } primary := entries[0] if strings.TrimSpace(primary.Provider) != "" { - provider = canonicalProvider(primary.Provider) + provider = llmutils.CanonicalProvider(primary.Provider) } model := strings.TrimSpace(primary.Model) if model == "" { diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go index 5b8bbc2..84cb9b1 100644 --- a/internal/hexaiaction/run.go +++ b/internal/hexaiaction/run.go @@ -16,6 +16,38 @@ import ( "codeberg.org/snonux/hexai/internal/tmux" ) +// tmuxActionError formats an error with the hexai-tmux-action prefix for stderr output. +type tmuxActionError struct { + inner error +} + +func (e tmuxActionError) Error() string { + return logging.AnsiBase + "hexai-tmux-action: " + e.inner.Error() + logging.AnsiReset +} + +func (e tmuxActionError) Unwrap() error { + return e.inner +} + +// logTmuxActionError logs an error to stderr with the hexai-tmux-action prefix. +func logTmuxActionError(stderr io.Writer, err error) error { + _, _ = fmt.Fprintf(stderr, "%v\n", tmuxActionError{err}) + return err +} + +// requireInput validates that input selection is not empty. +func requireInput(sel string) error { + if strings.TrimSpace(sel) == "" { + return fmt.Errorf("no input provided on stdin") + } + return nil +} + +// logAndReturnError logs an error to stderr and returns it (hexaiaction pattern). +func logAndReturnError(stderr io.Writer, err error) error { + return logTmuxActionError(stderr, err) +} + type configPathKey struct{} type actionChoice struct { diff --git a/internal/hexaicli/run.go b/internal/hexaicli/run.go index 806f824..9c7ba73 100644 --- a/internal/hexaicli/run.go +++ b/internal/hexaicli/run.go @@ -55,7 +55,7 @@ func buildCLIJobs(cfg appconfig.App) ([]cliJob, error) { if provider == "" { provider = cfg.Provider } - provider = canonicalProvider(provider) + provider = llmutils.CanonicalProvider(provider) derived := llmutils.ConfigForProvider(cfg, provider, entry.Model) req := buildCLIRequest(entry, provider, derived) jobs = append(jobs, cliJob{index: i, provider: provider, entry: entry, cfg: derived, req: req}) @@ -92,10 +92,6 @@ func cliTemperatureFromEntry(cfg appconfig.App, provider string, entry appconfig return llmutils.ResolveTemperature(provider, model, entry.Temperature, cfg.CodingTemperature) } -func canonicalProvider(name string) string { - return llmutils.CanonicalProvider(name) -} - // Run executes the Hexai CLI behavior given arguments and I/O streams. // It assumes flags have already been parsed by the caller. func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { @@ -461,10 +457,29 @@ func filterJobsBySelection(jobs []cliJob, indices []int) ([]cliJob, error) { return filtered, nil } +// stater is the subset of *os.File needed to detect piped vs terminal input. +type stater interface { + Stat() (os.FileInfo, error) +} + +// isPipedInput reports whether stdin is a pipe or file (not a terminal). +// For *os.File it checks the file mode; for any other io.Reader it +// optimistically returns true since non-file readers are typically +// in-memory buffers used in tests. +func isPipedInput(stdin io.Reader) bool { + if f, ok := stdin.(stater); ok { + fi, err := f.Stat() + return err == nil && (fi.Mode()&os.ModeCharDevice) == 0 + } + // Non-file readers (e.g. strings.NewReader in tests) always have data. + return true +} + // readInput reads from stdin and args, then combines them per CLI rules. +// It uses the passed stdin reader (not os.Stdin) to detect piped input. func readInput(stdin io.Reader, args []string) (string, error) { var stdinData string - if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 { + if isPipedInput(stdin) { data, readErr := io.ReadAll(stdin) if readErr != nil { return "", fmt.Errorf("hexai: failed to read stdin: %w", readErr) diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index 68d2954..ec5dbba 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -36,8 +36,11 @@ func (tmuxStatusSink) SetLLMStart(provider, model string) error { return tmx.SetStatus(tmx.FormatLLMStartStatus(provider, model)) } -func (tmuxStatusSink) SetGlobal(reqs int64, rpm float64, sent int64, recv int64, provider, model string, scopeRPM float64, scopeReqs int64, window time.Duration) error { - status := tmx.FormatGlobalStatusColored(reqs, rpm, sent, recv, provider, model, scopeRPM, scopeReqs, window) +func (tmuxStatusSink) SetGlobal(gs lsp.GlobalStatus) error { + status := tmx.FormatGlobalStatusColored( + gs.Reqs, gs.RPM, gs.Sent, gs.Recv, + gs.Provider, gs.Model, gs.ScopeRPM, gs.ScopeReqs, gs.Window, + ) return tmx.SetStatus(status) } 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) } } diff --git a/internal/promptstore/store.go b/internal/promptstore/store.go index 1597b36..6cabebd 100644 --- a/internal/promptstore/store.go +++ b/internal/promptstore/store.go @@ -10,6 +10,8 @@ import ( "strings" "sync" "time" + + "codeberg.org/snonux/hexai/internal/textutil" ) // PromptStore defines the interface for prompt storage operations. @@ -182,13 +184,14 @@ func (s *JSONLStore) Create(prompt *Prompt) error { if err != nil { return fmt.Errorf("open user.jsonl: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() // best-effort on error paths if _, err := f.Write(data); err != nil { return fmt.Errorf("write user.jsonl: %w", err) } - return nil + // Check Close error to catch deferred-write failures (e.g. disk full). + return f.Close() } // Update modifies an existing prompt in user.jsonl. @@ -377,7 +380,7 @@ func (s *JSONLStore) loadPromptsFromFile(filename string) ([]Prompt, error) { } var prompts []Prompt - lines := splitLines(data) + lines := textutil.SplitLinesBytes(data) for i, line := range lines { if len(line) == 0 { continue @@ -416,27 +419,6 @@ func (s *JSONLStore) writePromptsToFile(filename string, prompts []Prompt) error return nil } -// splitLines splits data into lines (handles both \n and \r\n). -// Copied from tmuxedit/history.go pattern. -func splitLines(data []byte) [][]byte { - var lines [][]byte - start := 0 - for i := 0; i < len(data); i++ { - if data[i] == '\n' { - end := i - if end > start && data[end-1] == '\r' { - end-- - } - lines = append(lines, data[start:end]) - start = i + 1 - } - } - if start < len(data) { - lines = append(lines, data[start:]) - } - return lines -} - // backupUserPrompts creates a timestamped backup of user.jsonl before any write operation. // Automatically manages backup retention based on maxBackups setting. func (s *JSONLStore) backupUserPrompts() error { diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 742a5be..d79025a 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -154,11 +154,14 @@ func lockStatsFile(ctx context.Context, dir string) (func() error, error) { } // readStatsFile loads the on-disk stats file, returning a fresh File if it is -// missing or has an incompatible version. +// missing, corrupt, or has an incompatible version. func readStatsFile(path string) File { var sf File if b, err := os.ReadFile(path); err == nil { - _ = json.Unmarshal(b, &sf) + if uerr := json.Unmarshal(b, &sf); uerr != nil { + fmt.Fprintf(os.Stderr, "stats: corrupt stats file %s: %v, starting fresh\n", path, uerr) + return File{Version: fileVersion} + } } if sf.Version != fileVersion { sf = File{Version: fileVersion} @@ -233,7 +236,9 @@ func acquireFileLock(ctx context.Context, f *os.File) (func() error, error) { } } -// Snapshot reads and aggregates events within the configured window. +// TakeSnapshot reads the stats file and aggregates events within the stored +// window (falling back to the process-level Window() if the file has none). +// This is a pure read — it does not mutate global state. func TakeSnapshot() (Snapshot, error) { dir, err := CacheDir() if err != nil { @@ -254,8 +259,6 @@ func TakeSnapshot() (Snapshot, error) { win := time.Duration(sf.WindowSeconds) * time.Second if win <= 0 { win = Window() - } else { - SetWindow(win) // align process with file window if changed elsewhere } cutoff := time.Now().Add(-win) snap := Snapshot{Providers: make(map[string]ProviderEntry), Window: win} diff --git a/internal/textutil/textutil.go b/internal/textutil/textutil.go index 1e9da3c..e68353d 100644 --- a/internal/textutil/textutil.go +++ b/internal/textutil/textutil.go @@ -1,6 +1,30 @@ package textutil -import "strings" +import ( + "strings" +) + +// SplitLinesBytes splits data into lines, handling both \n and \r\n endings. +// The returned slices share the underlying data array. +func SplitLinesBytes(data []byte) [][]byte { + var lines [][]byte + start := 0 + for i := 0; i < len(data); i++ { + if data[i] == '\n' { + end := i + if end > start && data[end-1] == '\r' { + end-- + } + lines = append(lines, data[start:end]) + start = i + 1 + } + } + // Handle last line if it doesn't end with newline + if start < len(data) { + lines = append(lines, data[start:]) + } + return lines +} // RenderTemplate performs simple {{var}} replacement in a template string. func RenderTemplate(t string, vars map[string]string) string { diff --git a/internal/tmuxedit/history.go b/internal/tmuxedit/history.go index eab4db2..eac3114 100644 --- a/internal/tmuxedit/history.go +++ b/internal/tmuxedit/history.go @@ -9,6 +9,7 @@ import ( "time" "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/textutil" ) // HistoryEntry represents a single submission to the AI agent via tmux popup. @@ -52,14 +53,15 @@ func AppendHistory(text, agent, cwd string) error { if err != nil { return fmt.Errorf("cannot open history file: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() // best-effort on error paths // Write entry if _, err := f.Write(data); err != nil { return fmt.Errorf("cannot write history entry: %w", err) } - return nil + // Check Close error to catch deferred-write failures (e.g. disk full). + return f.Close() } // GetHistory retrieves the most recent history entries (up to limit). @@ -84,7 +86,7 @@ func GetHistory(limit int) ([]HistoryEntry, error) { // Parse JSONL line by line var entries []HistoryEntry - lines := splitLines(data) + lines := textutil.SplitLinesBytes(data) for i, line := range lines { if len(line) == 0 { continue // Skip empty lines @@ -107,25 +109,3 @@ func GetHistory(limit int) ([]HistoryEntry, error) { return entries, nil } - -// splitLines splits data into lines (handles both \n and \r\n) -func splitLines(data []byte) [][]byte { - var lines [][]byte - start := 0 - for i := 0; i < len(data); i++ { - if data[i] == '\n' { - // Include everything before the newline (excluding \r if present) - end := i - if end > start && data[end-1] == '\r' { - end-- - } - lines = append(lines, data[start:end]) - start = i + 1 - } - } - // Handle last line if it doesn't end with newline - if start < len(data) { - lines = append(lines, data[start:]) - } - return lines -} diff --git a/internal/tmuxedit/history_test.go b/internal/tmuxedit/history_test.go index b9d59d3..f6d6d7d 100644 --- a/internal/tmuxedit/history_test.go +++ b/internal/tmuxedit/history_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "testing" "time" + + "codeberg.org/snonux/hexai/internal/textutil" ) func TestAppendHistory(t *testing.T) { @@ -164,7 +166,7 @@ func TestSplitLines(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := splitLines([]byte(tt.input)) + got := textutil.SplitLinesBytes([]byte(tt.input)) gotStr := make([]string, len(got)) for i, b := range got { gotStr[i] = string(b) diff --git a/internal/tmuxedit/run.go b/internal/tmuxedit/run.go index efa503b..da484df 100644 --- a/internal/tmuxedit/run.go +++ b/internal/tmuxedit/run.go @@ -112,22 +112,23 @@ func loadConfig(configPath string) appconfig.App { var debugLog *log.Logger // initDebugLog creates a debug log file in the state directory -// (~/.local/state/hexai/hexai-tmux-edit.log). Returns an error if the -// state directory cannot be resolved. Silently skips logging if the -// log file itself cannot be opened. -func initDebugLog() error { +// (~/.local/state/hexai/hexai-tmux-edit.log). Returns a closer for the +// log file handle and an error if the state directory cannot be resolved. +// Silently skips logging (returns a no-op closer) if the log file cannot +// be opened. +func initDebugLog() (func(), error) { stateDir, err := appconfig.StateDir() if err != nil { - return fmt.Errorf("cannot create state directory: %w", err) + return nil, fmt.Errorf("cannot create state directory: %w", err) } logPath := filepath.Join(stateDir, "hexai-tmux-edit.log") f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { - return nil + return func() {}, nil } debugLog = log.New(f, "", log.LstdFlags|log.Lmicroseconds) - return nil + return func() { _ = f.Close() }, nil } func dbg(format string, args ...any) { @@ -140,9 +141,11 @@ func dbg(format string, args ...any) { // It resolves the agent (by name or auto-detect), extracts the current // prompt, opens the editor popup, then clears and sends the result. func runWithConfig(opts Options, cfg appconfig.App) error { - if err := initDebugLog(); err != nil { + closeLog, err := initDebugLog() + if err != nil { return fmt.Errorf("init debug log: %w", err) } + defer closeLog() dbg("=== hexai-tmux-edit start ===") dbg("opts: pane=%q agent=%q config=%q", opts.Pane, opts.Agent, opts.ConfigPath) |
