summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-17 11:28:19 +0200
committerPaul Buetow <paul@buetow.org>2026-03-17 11:28:19 +0200
commit6f1c8bf7a36eb7044ed7aad30f84664cbbf0d303 (patch)
treedd2ac6e1433177fb59c167a12fa0b4b91132f34a
parent10562cc510f64d5ac38aeb76f03e18eb76cca40f (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.go8
-rw-r--r--internal/hexaiaction/run.go32
-rw-r--r--internal/hexaicli/run.go27
-rw-r--r--internal/hexailsp/run.go7
-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
-rw-r--r--internal/promptstore/store.go30
-rw-r--r--internal/stats/stats.go13
-rw-r--r--internal/textutil/textutil.go26
-rw-r--r--internal/tmuxedit/history.go30
-rw-r--r--internal/tmuxedit/history_test.go4
-rw-r--r--internal/tmuxedit/run.go19
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