summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-24 23:21:43 +0300
committerPaul Buetow <paul@buetow.org>2025-09-24 23:21:43 +0300
commitc3c71345db9086392cd9b7529c7f5287009c226e (patch)
treed227894ab900d6050cbe1418984526088a692db5 /internal/lsp
parent127844a4ee481590ef53b6777d34bf2114cb3ab1 (diff)
Add runtime config store and reload command
Diffstat (limited to 'internal/lsp')
-rw-r--r--internal/lsp/chat_commands.go63
-rw-r--r--internal/lsp/chat_commands_test.go82
-rw-r--r--internal/lsp/chat_context_mode_test.go22
-rw-r--r--internal/lsp/chat_prompt_test.go4
-rw-r--r--internal/lsp/chat_trigger_suppression_test.go5
-rw-r--r--internal/lsp/codeaction_custom_errors_test.go17
-rw-r--r--internal/lsp/codeaction_custom_test.go38
-rw-r--r--internal/lsp/codeaction_prompts_test.go24
-rw-r--r--internal/lsp/completion_cache_test.go8
-rw-r--r--internal/lsp/completion_codex_path_test.go10
-rw-r--r--internal/lsp/completion_messages_test.go2
-rw-r--r--internal/lsp/completion_prefix_strip_test.go64
-rw-r--r--internal/lsp/context.go8
-rw-r--r--internal/lsp/context_test.go14
-rw-r--r--internal/lsp/debounce_throttle_more_test.go8
-rw-r--r--internal/lsp/debounce_throttle_test.go21
-rw-r--r--internal/lsp/document_test.go86
-rw-r--r--internal/lsp/handlers.go27
-rw-r--r--internal/lsp/handlers_codeaction.go149
-rw-r--r--internal/lsp/handlers_completion.go78
-rw-r--r--internal/lsp/handlers_document.go38
-rw-r--r--internal/lsp/handlers_end_to_end_test.go14
-rw-r--r--internal/lsp/handlers_init.go11
-rw-r--r--internal/lsp/handlers_utils.go61
-rw-r--r--internal/lsp/helpers_inline_prompt_test.go12
-rw-r--r--internal/lsp/init_and_trigger_test.go13
-rw-r--r--internal/lsp/llm_request_opts_test.go2
-rw-r--r--internal/lsp/provider_native_success_test.go4
-rw-r--r--internal/lsp/server.go385
-rw-r--r--internal/lsp/server_test.go87
-rw-r--r--internal/lsp/triggers_config_test.go25
31 files changed, 869 insertions, 513 deletions
diff --git a/internal/lsp/chat_commands.go b/internal/lsp/chat_commands.go
new file mode 100644
index 0000000..31347e9
--- /dev/null
+++ b/internal/lsp/chat_commands.go
@@ -0,0 +1,63 @@
+package lsp
+
+import (
+ "fmt"
+ "strings"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
+)
+
+type chatCommandResult struct {
+ message string
+}
+
+func (s *Server) chatCommandResponse(uri string, lineIdx int, prompt string) (chatCommandResult, bool) {
+ trimmed := strings.TrimSpace(s.stripTrailingTrigger(prompt))
+ if trimmed == "" || !strings.HasPrefix(trimmed, "/") {
+ return chatCommandResult{}, false
+ }
+
+ switch {
+ case strings.HasPrefix(trimmed, "/reload"):
+ return s.handleReloadCommand(), true
+ case strings.HasPrefix(trimmed, "/help"):
+ return s.handleHelpCommand(), true
+ default:
+ return chatCommandResult{message: fmt.Sprintf("Unknown command %q. Try /help?>", trimmed)}, true
+ }
+}
+
+func (s *Server) handleHelpCommand() chatCommandResult {
+ lines := []string{
+ "Available slash commands:",
+ "- /reload?> reload configuration from file (ignores env overrides)",
+ }
+ return chatCommandResult{message: strings.Join(lines, "\n")}
+}
+
+func (s *Server) handleReloadCommand() chatCommandResult {
+ if s.configStore == nil {
+ return chatCommandResult{message: "Reload unavailable: no config store"}
+ }
+ changes, err := s.configStore.Reload(s.logger, appconfig.LoadOptions{IgnoreEnv: true})
+ if err != nil {
+ s.logger.Printf("config reload failed: %v", err)
+ return chatCommandResult{message: fmt.Sprintf("Reload failed: %v", err)}
+ }
+ summary := formatReloadSummary(changes)
+ s.logger.Print(summary)
+ return chatCommandResult{message: summary}
+}
+
+func formatReloadSummary(changes []runtimeconfig.Change) string {
+ if len(changes) == 0 {
+ return "Reloaded config (no changes detected)."
+ }
+ lines := make([]string, 0, len(changes)+1)
+ lines = append(lines, fmt.Sprintf("Reloaded config (%d changes):", len(changes)))
+ for _, ch := range changes {
+ lines = append(lines, fmt.Sprintf("- %s: %s → %s", ch.Key, ch.Old, ch.New))
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/lsp/chat_commands_test.go b/internal/lsp/chat_commands_test.go
new file mode 100644
index 0000000..bedfaed
--- /dev/null
+++ b/internal/lsp/chat_commands_test.go
@@ -0,0 +1,82 @@
+package lsp
+
+import (
+ "bytes"
+ "log"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
+ "codeberg.org/snonux/hexai/internal/runtimeconfig"
+)
+
+func TestFormatReloadSummary(t *testing.T) {
+ changes := []runtimeconfig.Change{
+ {Key: "max_tokens", Old: "200", New: "128"},
+ {Key: "provider", Old: "openai", New: "ollama"},
+ }
+ got := formatReloadSummary(changes)
+ if !strings.Contains(got, "Reloaded config (2 changes):") {
+ t.Fatalf("expected change count line, got %q", got)
+ }
+ if !strings.Contains(got, "max_tokens: 200") || !strings.Contains(got, "provider: openai") {
+ t.Fatalf("expected formatted entries, got %q", got)
+ }
+}
+
+func TestHandleHelpCommandListsReload(t *testing.T) {
+ s := newTestServer()
+ res := s.handleHelpCommand()
+ if !strings.Contains(res.message, "/reload?>") {
+ t.Fatalf("expected reload command in help output: %q", res.message)
+ }
+}
+
+func TestHandleReloadCommandReloadsStore(t *testing.T) {
+ tmp := t.TempDir()
+ configDir := filepath.Join(tmp, "hexai")
+ if err := os.MkdirAll(configDir, 0o755); err != nil {
+ t.Fatalf("mkdir: %v", err)
+ }
+ configPath := filepath.Join(configDir, "config.toml")
+ if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 64\n"), 0o644); err != nil {
+ t.Fatalf("write config: %v", err)
+ }
+
+ t.Setenv("XDG_CONFIG_HOME", tmp)
+ t.Setenv("HEXAI_MAX_TOKENS", "321")
+
+ var logBuf bytes.Buffer
+ logger := log.New(&logBuf, "", 0)
+
+ initial := appconfig.Load(logger)
+ if initial.MaxTokens != 321 {
+ t.Fatalf("expected env override to win initial load, got %d", initial.MaxTokens)
+ }
+
+ store := runtimeconfig.New(initial)
+
+ s := newTestServer()
+ s.logger = logger
+ s.configStore = store
+
+ if err := os.WriteFile(configPath, []byte("[general]\nmax_tokens = 128\n"), 0o644); err != nil {
+ t.Fatalf("update config: %v", err)
+ }
+
+ res := s.handleReloadCommand()
+ if !strings.Contains(res.message, "Reloaded config (1 changes):") {
+ t.Fatalf("unexpected reload summary: %q", res.message)
+ }
+ if !strings.Contains(res.message, "max_tokens: 321") || !strings.Contains(res.message, "128") {
+ t.Fatalf("expected diff for max_tokens: %q", res.message)
+ }
+ if store.Snapshot().MaxTokens != 128 {
+ t.Fatalf("expected snapshot to reflect new value, got %d", store.Snapshot().MaxTokens)
+ }
+ if !strings.Contains(logBuf.String(), "Reloaded config") {
+ t.Fatalf("expected summary logged, got %q", logBuf.String())
+ }
+}
diff --git a/internal/lsp/chat_context_mode_test.go b/internal/lsp/chat_context_mode_test.go
index 85fa4a9..895c2f3 100644
--- a/internal/lsp/chat_context_mode_test.go
+++ b/internal/lsp/chat_context_mode_test.go
@@ -11,9 +11,9 @@ import (
func TestChat_RespectsContextModeWindow(t *testing.T) {
s := newTestServer()
// Configure window mode with small window
- s.contextMode = "window"
- s.windowLines = 2
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "window"
+ s.cfg.ContextWindowLines = 2
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -54,8 +54,8 @@ func TestChat_RespectsContextModeWindow(t *testing.T) {
func TestChat_ContextModeMinimal_NoExtra(t *testing.T) {
s := newTestServer()
- s.contextMode = "minimal"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "minimal"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -78,8 +78,8 @@ func TestChat_ContextModeMinimal_NoExtra(t *testing.T) {
func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) {
s := newTestServer()
- s.contextMode = "always-full"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "always-full"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -108,8 +108,8 @@ func TestChat_ContextModeAlwaysFull_AddsExtra(t *testing.T) {
func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) {
s := newTestServer()
- s.contextMode = "file-on-new-func"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "file-on-new-func"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
@@ -129,8 +129,8 @@ func TestChat_ContextModeFileOnNewFunc_NoExtraWithoutSignature(t *testing.T) {
func TestChat_ContextModeFileOnNewFunc_WithSignature_AddsExtra(t *testing.T) {
s := newTestServer()
- s.contextMode = "file-on-new-func"
- s.maxContextTokens = 2000
+ s.cfg.ContextMode = "file-on-new-func"
+ s.cfg.MaxContextTokens = 2000
cap := &captureLLM{}
s.llmClient = cap
var out bytes.Buffer
diff --git a/internal/lsp/chat_prompt_test.go b/internal/lsp/chat_prompt_test.go
index 25767ab..1f7b266 100644
--- a/internal/lsp/chat_prompt_test.go
+++ b/internal/lsp/chat_prompt_test.go
@@ -10,7 +10,9 @@ func TestDetectAndHandleChat_UsesConfiguredSystemPrompt(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptChatSystem = "CHAT-SYS"
+ cfg := s.cfg
+ cfg.PromptChatSystem = "CHAT-SYS"
+ s.cfg = cfg
uri := "file:///chat.txt"
// Avoid nil writer in applyChatEdits
var out bytes.Buffer
diff --git a/internal/lsp/chat_trigger_suppression_test.go b/internal/lsp/chat_trigger_suppression_test.go
index 8d016d1..9f9f5bc 100644
--- a/internal/lsp/chat_trigger_suppression_test.go
+++ b/internal/lsp/chat_trigger_suppression_test.go
@@ -4,7 +4,10 @@ import "testing"
// Ensure completion is suppressed when a chat trigger is at EOL (?>,!>,:>,;>)
func TestCompletionSuppressedOnChatTriggerEOL(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
+ s := newTestServer()
+ s.cfg.MaxTokens = 32
+ s.cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.compCache = make(map[string]string)
initServerDefaults(s)
s.llmClient = &countingLLM{}
tests := []string{"What now?>", "Explain!>", "Refactor:>", "note ;>"}
diff --git a/internal/lsp/codeaction_custom_errors_test.go b/internal/lsp/codeaction_custom_errors_test.go
index ca6111f..c572542 100644
--- a/internal/lsp/codeaction_custom_errors_test.go
+++ b/internal/lsp/codeaction_custom_errors_test.go
@@ -7,13 +7,16 @@ import (
"errors"
"testing"
+ "codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/llm"
)
func TestResolveCodeAction_Custom_UnknownID(t *testing.T) {
s := newTestServer()
// No matching custom action configured
- s.customActions = []CustomAction{{ID: "known", Title: "Known", Instruction: "x"}}
+ cfg := s.cfg
+ cfg.CustomActions = []appconfig.CustomAction{{ID: "known", Title: "Known", Instruction: "x"}}
+ s.cfg = cfg
uri := "file:///t.go"
payload := struct {
Type string `json:"type"`
@@ -41,7 +44,9 @@ func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) {
// empty output case
s1 := newTestServer()
s1.llmClient = fakeLLM{resp: " \n\n"}
- s1.customActions = []CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}}
+ cfg1 := s1.cfg
+ cfg1.CustomActions = []appconfig.CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}}
+ s1.cfg = cfg1
raw1, _ := json.Marshal(struct {
Type, ID, URI, Selection string
Range Range
@@ -53,7 +58,9 @@ func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) {
// error case
s2 := newTestServer()
s2.llmClient = errLLM{}
- s2.customActions = []CustomAction{{ID: "err", Title: "Err", Instruction: "x"}}
+ cfg2 := s2.cfg
+ cfg2.CustomActions = []appconfig.CustomAction{{ID: "err", Title: "Err", Instruction: "x"}}
+ s2.cfg = cfg2
raw2, _ := json.Marshal(struct {
Type, ID, URI, Selection string
Range Range
@@ -67,10 +74,12 @@ func TestHandleCodeAction_Custom_SelectionSuppressedWhenEmpty(t *testing.T) {
s := newTestServer()
s.llmClient = fakeLLM{resp: "IGN"}
// One selection-scoped and one diagnostics-scoped custom
- s.customActions = []CustomAction{
+ cfg := s.cfg
+ cfg.CustomActions = []appconfig.CustomAction{
{ID: "sel", Title: "Sel", Scope: "selection", Instruction: "x"},
{ID: "diag", Title: "Diag", Scope: "diagnostics", User: "{{diagnostics}}"},
}
+ s.cfg = cfg
uri := "file:///t.go"
s.setDocument(uri, "package p\nfunc f(){}\n")
// Empty selection range (start==end)
diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go
index 1ea4c3c..ea8ae82 100644
--- a/internal/lsp/codeaction_custom_test.go
+++ b/internal/lsp/codeaction_custom_test.go
@@ -7,6 +7,8 @@ import (
"log"
"strings"
"testing"
+
+ "codeberg.org/snonux/hexai/internal/appconfig"
)
// local copy of captureResponse for this test file
@@ -27,24 +29,23 @@ func capResp(t *testing.T, buf *bytes.Buffer) Response {
func TestHandleCodeAction_ListsCustomActions(t *testing.T) {
var out bytes.Buffer
- s := &Server{
- logger: log.New(io.Discard, "", 0),
- docs: make(map[string]*document),
- out: &out,
- inlineOpen: ">",
- inlineClose: ">",
- inlineOpenChar: '>',
- inlineCloseChar: '>',
- chatSuffix: ">",
- chatSuffixChar: '>',
- chatPrefixes: []string{"?", "!", ":", ";"},
+ cfg := appconfig.App{
+ InlineOpen: ">",
+ InlineClose: ">",
+ ChatSuffix: ">",
+ ChatPrefixes: []string{"?", "!", ":", ";"},
+ CustomActions: []appconfig.CustomAction{
+ {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
+ {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"},
+ },
}
- s.llmClient = fakeLLM{resp: "IGN"}
- // Inject two custom actions
- s.customActions = []CustomAction{
- {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
- {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"},
+ s := &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ out: &out,
+ cfg: cfg,
}
+ s.llmClient = fakeLLM{resp: "ok"}
// Prepare document and params
uri := "file:///t.go"
s.setDocument(uri, "package x\n\nfunc f(){}\n")
@@ -82,11 +83,12 @@ func TestHandleCodeAction_ListsCustomActions(t *testing.T) {
func TestResolveCodeAction_CustomInstructionAndUser(t *testing.T) {
s := newTestServer()
s.llmClient = fakeLLM{resp: "REPLACED"}
- // one instruction-based and one user-based
- s.customActions = []CustomAction{
+ cfg := s.cfg
+ cfg.CustomActions = []appconfig.CustomAction{
{ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"},
{ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix: {{diagnostics}}\n{{selection}}"},
}
+ s.cfg = cfg
uri := "file:///t.go"
p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 3}}}
diff --git a/internal/lsp/codeaction_prompts_test.go b/internal/lsp/codeaction_prompts_test.go
index bbfad10..c5fd5e2 100644
--- a/internal/lsp/codeaction_prompts_test.go
+++ b/internal/lsp/codeaction_prompts_test.go
@@ -9,8 +9,10 @@ func TestResolveCodeAction_UsesRewritePrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptRewriteSystem = "RSYS"
- s.promptRewriteUser = "RUSER {{instruction}} {{selection}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionRewriteSystem = "RSYS"
+ cfg.PromptCodeActionRewriteUser = "RUSER {{instruction}} {{selection}}"
+ s.cfg = cfg
uri := "file:///x.go"
s.setDocument(uri, "package p\nvar a=1\n")
payload := struct {
@@ -35,8 +37,10 @@ func TestResolveCodeAction_UsesDiagnosticsPrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptDiagnosticsSystem = "DSYS"
- s.promptDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionDiagnosticsSystem = "DSYS"
+ cfg.PromptCodeActionDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}"
+ s.cfg = cfg
uri := "file:///x.go"
s.setDocument(uri, "package p\nvar a=1\n")
payload := struct {
@@ -64,8 +68,10 @@ func TestResolveCodeAction_UsesDocumentPrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptDocumentSystem = "DOCSYS"
- s.promptDocumentUser = "DOCUSER {{selection}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionDocumentSystem = "DOCSYS"
+ cfg.PromptCodeActionDocumentUser = "DOCUSER {{selection}}"
+ s.cfg = cfg
uri := "file:///x.go"
s.setDocument(uri, "package p\nvar a=1\n")
payload := struct {
@@ -89,8 +95,10 @@ func TestGenerateGoTest_UsesPrompts(t *testing.T) {
s := newTestServer()
cap := &captureLLM{}
s.llmClient = cap
- s.promptGoTestSystem = "GTSYS"
- s.promptGoTestUser = "GTUSER {{function}}"
+ cfg := s.cfg
+ cfg.PromptCodeActionGoTestSystem = "GTSYS"
+ cfg.PromptCodeActionGoTestUser = "GTUSER {{function}}"
+ s.cfg = cfg
_ = s.generateGoTestFunction("func Add(a,b int) int {return a+b}")
if len(cap.msgs) < 2 {
t.Fatalf("expected chat messages")
diff --git a/internal/lsp/completion_cache_test.go b/internal/lsp/completion_cache_test.go
index 65631f9..057b5c5 100644
--- a/internal/lsp/completion_cache_test.go
+++ b/internal/lsp/completion_cache_test.go
@@ -12,9 +12,13 @@ import (
func TestCompletionCache_IgnoresWhitespaceBeforeCursor(t *testing.T) {
var buf bytes.Buffer
logger := log.New(&buf, "", 0)
- s := NewServer(bytes.NewBuffer(nil), &buf, logger, ServerOptions{})
+ s := newTestServer()
+ s.logger = logger
+ s.out = &buf
logging.Bind(logger)
- s.triggerChars = []string{" ", "."}
+ cfg := s.cfg
+ cfg.TriggerCharacters = []string{" ", "."}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
diff --git a/internal/lsp/completion_codex_path_test.go b/internal/lsp/completion_codex_path_test.go
index 6c0a60f..ea27c6e 100644
--- a/internal/lsp/completion_codex_path_test.go
+++ b/internal/lsp/completion_codex_path_test.go
@@ -39,7 +39,10 @@ func (f *fakeCodeLLM) Name() string { return "fake" }
func (f *fakeCodeLLM) DefaultModel() string { return "m" }
func TestTryLLMCompletion_PrefersCodeCompleterOverChat(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)}
+ s := newTestServer()
+ s.cfg.MaxTokens = 32
+ s.cfg.TriggerCharacters = []string{"."}
+ s.compCache = make(map[string]string)
initServerDefaults(s)
fake := &fakeCodeLLM{result: "DoThing()"}
s.llmClient = fake
@@ -58,7 +61,10 @@ func TestTryLLMCompletion_PrefersCodeCompleterOverChat(t *testing.T) {
}
func TestTryLLMCompletion_FallsBackToChatOnCodeCompleterError(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{"."}, compCache: make(map[string]string)}
+ s := newTestServer()
+ s.cfg.MaxTokens = 32
+ s.cfg.TriggerCharacters = []string{"."}
+ s.compCache = make(map[string]string)
initServerDefaults(s)
fake := &fakeCodeLLM{result: "DoThing()", codeErr: errors.New("boom")}
s.llmClient = fake
diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go
index 20aac69..f0c693c 100644
--- a/internal/lsp/completion_messages_test.go
+++ b/internal/lsp/completion_messages_test.go
@@ -37,7 +37,7 @@ func TestBuildCompletionMessages_ExtraContextIncluded(t *testing.T) {
func TestPrefixHeuristic_AllVariants(t *testing.T) {
s := newTestServer()
// manual invoke requires at least min prefix; set to 2
- s.manualInvokeMinPrefix = 2
+ s.cfg.ManualInvokeMinPrefix = 2
cur := "a"
p := CompletionParams{Position: Position{Line: 0, Character: 1}}
if s.prefixHeuristicAllows(false, cur, p, true) {
diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go
index acc7921..6173d6f 100644
--- a/internal/lsp/completion_prefix_strip_test.go
+++ b/internal/lsp/completion_prefix_strip_test.go
@@ -41,8 +41,12 @@ func TestStripDuplicateAssignmentPrefix_AssignAndWalrus(t *testing.T) {
}
func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
s.llmClient = fakeLLM{resp: tut.MultilineFunctionSuggestion()}
line := "func fib(i int) " // cursor after space
p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://x.go"}}
@@ -58,8 +62,12 @@ func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) {
}
func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
s.llmClient = fakeLLM{resp: "replacement"}
line := "prefix >do something> suffix"
// No trigger char immediately before cursor; place cursor at end
@@ -71,17 +79,12 @@ func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) {
}
func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) {
- s := &Server{
- maxTokens: 32,
- triggerChars: []string{".", ":", "/", "_"},
- compCache: make(map[string]string),
- inlineOpen: ">",
- inlineClose: ">",
- inlineOpenChar: '>',
- inlineCloseChar: '>',
- }
- initServerDefaults(s)
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
line := ">> " // empty content after double-open should not force-trigger
@@ -114,15 +117,12 @@ func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) {
}
func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) {
- s := &Server{
- maxTokens: 32,
- triggerChars: []string{".", ":", "/", "_"},
- compCache: make(map[string]string),
- inlineOpen: ">",
- inlineClose: ">",
- inlineOpenChar: '>',
- inlineCloseChar: '>',
- }
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
// Place a '.' earlier but also include bare double-open at end; should not auto-trigger
@@ -141,8 +141,12 @@ func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) {
}
func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")"
@@ -161,8 +165,12 @@ func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) {
}
func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) {
- s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)}
- initServerDefaults(s)
+ s := newTestServer()
+ s.compCache = make(map[string]string)
+ cfg := s.cfg
+ cfg.MaxTokens = 32
+ cfg.TriggerCharacters = []string{".", ":", "/", "_"}
+ s.cfg = cfg
fake := &countingLLM{}
s.llmClient = fake
line := ">>"
diff --git a/internal/lsp/context.go b/internal/lsp/context.go
index 5a4983c..8b584fb 100644
--- a/internal/lsp/context.go
+++ b/internal/lsp/context.go
@@ -14,7 +14,7 @@ import (
// - file-on-new-func: include full file only when defining a new function
// - always-full: always include the full file
func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) {
- mode := s.contextMode
+ mode := s.contextMode()
switch mode {
case "minimal":
return "", false
@@ -40,7 +40,7 @@ func (s *Server) windowContext(uri string, pos Position) string {
return ""
}
n := len(d.lines)
- half := s.windowLines / 2
+ half := s.windowLines() / 2
start := pos.Line - half
if start < 0 {
start = 0
@@ -50,7 +50,7 @@ func (s *Server) windowContext(uri string, pos Position) string {
end = n
}
text := strings.Join(d.lines[start:end], "\n")
- return truncateToApproxTokens(text, s.maxContextTokens)
+ return truncateToApproxTokens(text, s.maxContextTokens())
}
func (s *Server) fullFileContext(uri string) string {
@@ -59,7 +59,7 @@ func (s *Server) fullFileContext(uri string) string {
logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri)
return ""
}
- return truncateToApproxTokens(d.text, s.maxContextTokens)
+ return truncateToApproxTokens(d.text, s.maxContextTokens())
}
// truncateToApproxTokens naively truncates the input to fit approx N tokens.
diff --git a/internal/lsp/context_test.go b/internal/lsp/context_test.go
index dcda042..875eec9 100644
--- a/internal/lsp/context_test.go
+++ b/internal/lsp/context_test.go
@@ -9,8 +9,8 @@ import (
func TestWindowContext_Bounds(t *testing.T) {
s := newTestServer()
- s.windowLines = 4 // half=2
- s.maxContextTokens = 9999
+ s.cfg.ContextWindowLines = 4 // half=2
+ s.cfg.MaxContextTokens = 9999
lines := make([]string, 10)
for i := 0; i < 10; i++ {
lines[i] = "L" + strconv.Itoa(i)
@@ -28,7 +28,7 @@ func TestWindowContext_Bounds(t *testing.T) {
func TestBuildAdditionalContext_Minimal(t *testing.T) {
s := newTestServer()
- s.contextMode = "minimal"
+ s.cfg.ContextMode = "minimal"
if ctx, ok := s.buildAdditionalContext(false, "file:///x.go", Position{}); ok || ctx != "" {
t.Fatalf("expected no context in minimal mode; got ok=%v ctx=%q", ok, ctx)
}
@@ -36,8 +36,8 @@ func TestBuildAdditionalContext_Minimal(t *testing.T) {
func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) {
s := newTestServer()
- s.contextMode = "file-on-new-func"
- s.maxContextTokens = 9999
+ s.cfg.ContextMode = "file-on-new-func"
+ s.cfg.MaxContextTokens = 9999
uri := "file:///x.go"
body := "package x\n\nfunc a(){}\n"
s.setDocument(uri, body)
@@ -51,8 +51,8 @@ func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) {
func TestBuildAdditionalContext_AlwaysFull(t *testing.T) {
s := newTestServer()
- s.contextMode = "always-full"
- s.maxContextTokens = 9999
+ s.cfg.ContextMode = "always-full"
+ s.cfg.MaxContextTokens = 9999
uri := "file:///x.go"
body := "line1\nline2\n"
s.setDocument(uri, body)
diff --git a/internal/lsp/debounce_throttle_more_test.go b/internal/lsp/debounce_throttle_more_test.go
index ed61336..7657cab 100644
--- a/internal/lsp/debounce_throttle_more_test.go
+++ b/internal/lsp/debounce_throttle_more_test.go
@@ -8,7 +8,9 @@ import (
func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) {
s := newTestServer()
- s.completionDebounce = 20 * time.Millisecond
+ cfg := s.cfg
+ cfg.CompletionDebounceMs = 20
+ s.cfg = cfg
s.mu.Lock()
s.lastInput = time.Now()
s.mu.Unlock()
@@ -21,7 +23,9 @@ func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) {
func TestWaitForThrottle_WaitsRoughlyInterval(t *testing.T) {
s := newTestServer()
- s.throttleInterval = 20 * time.Millisecond
+ cfg := s.cfg
+ cfg.CompletionThrottleMs = 20
+ s.cfg = cfg
s.mu.Lock()
s.lastLLMCall = time.Now()
s.mu.Unlock()
diff --git a/internal/lsp/debounce_throttle_test.go b/internal/lsp/debounce_throttle_test.go
index 0b49b1b..81a2c1a 100644
--- a/internal/lsp/debounce_throttle_test.go
+++ b/internal/lsp/debounce_throttle_test.go
@@ -22,9 +22,11 @@ func (t *timeLLM) DefaultModel() string { return "m" }
func TestCompletionDebounce_WaitsUntilQuiet(t *testing.T) {
s := newTestServer()
s.compCache = make(map[string]string)
- s.triggerChars = []string{".", ":", "/", "_"}
- s.maxTokens = 32
- s.completionDebounce = 30 * time.Millisecond
+ cfg := s.cfg