diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-06 10:25:36 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-06 10:25:36 +0300 |
| commit | 5be9532cfa630f4aacd8d879c3e4f5cc316da0fa (patch) | |
| tree | 0a901680fccd1e2703ffdbd9284ccff932be1d67 /internal | |
| parent | 70f1d0e78c57dfa5beae779b3d392b6e6fa44c14 (diff) | |
feat(lsp): configurable inline/chat triggers; switch inline markers to >text>/>>text>; update docs and example config; tests updated to new triggers and raise LSP coverage to >=85%; chore: remove semicolon legacy; chore(mage): auto-refresh coverage daily if docs/coverage.out is older than 24h
Diffstat (limited to 'internal')
27 files changed, 698 insertions, 342 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 2110831..d19ea18 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -36,6 +36,13 @@ type App struct { TriggerCharacters []string `json:"trigger_characters"` Provider string `json:"provider"` + // Inline prompt trigger characters (default: >text> and >>text>) + InlineOpen string `json:"inline_open"` + InlineClose string `json:"inline_close"` + // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) + ChatSuffix string `json:"chat_suffix"` + ChatPrefixes []string `json:"chat_prefixes"` + // Provider-specific options OpenAIBaseURL string `json:"openai_base_url"` OpenAIModel string `json:"openai_model"` @@ -69,6 +76,11 @@ func newDefaultConfig() App { ManualInvokeMinPrefix: 0, CompletionDebounceMs: 200, CompletionThrottleMs: 0, + // Inline/chat trigger defaults + InlineOpen: ">", + InlineClose: ">", + ChatSuffix: ">", + ChatPrefixes: []string{"?", "!", ":", ";"}, } } @@ -151,12 +163,24 @@ func (a *App) mergeBasics(other *App) { } if other.CompletionDebounceMs > 0 { a.CompletionDebounceMs = other.CompletionDebounceMs } if other.CompletionThrottleMs > 0 { a.CompletionThrottleMs = other.CompletionThrottleMs } - if len(other.TriggerCharacters) > 0 { - a.TriggerCharacters = slices.Clone(other.TriggerCharacters) - } - if s := strings.TrimSpace(other.Provider); s != "" { - a.Provider = s - } + if len(other.TriggerCharacters) > 0 { + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + } + if s := strings.TrimSpace(other.InlineOpen); s != "" { + a.InlineOpen = s + } + if s := strings.TrimSpace(other.InlineClose); s != "" { + a.InlineClose = s + } + if s := strings.TrimSpace(other.ChatSuffix); s != "" { + a.ChatSuffix = s + } + if len(other.ChatPrefixes) > 0 { + a.ChatPrefixes = slices.Clone(other.ChatPrefixes) + } + if s := strings.TrimSpace(other.Provider); s != "" { + a.Provider = s + } } // mergeProviderFields merges per-provider configuration. @@ -269,6 +293,19 @@ func loadFromEnv(logger *log.Logger) *App { } any = true } + if s := getenv("HEXAI_INLINE_OPEN"); s != "" { out.InlineOpen = s; any = true } + if s := getenv("HEXAI_INLINE_CLOSE"); s != "" { out.InlineClose = s; any = true } + if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" { out.ChatSuffix = s; any = true } + if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" { + parts := strings.Split(s, ",") + out.ChatPrefixes = nil + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + out.ChatPrefixes = append(out.ChatPrefixes, t) + } + } + any = true + } if s := getenv("HEXAI_PROVIDER"); s != "" { out.Provider = s; any = true } diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index 0df8256..c12018f 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -118,5 +118,9 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix, CompletionDebounceMs: cfg.CompletionDebounceMs, CompletionThrottleMs: cfg.CompletionThrottleMs, + InlineOpen: cfg.InlineOpen, + InlineClose: cfg.InlineClose, + ChatSuffix: cfg.ChatSuffix, + ChatPrefixes: cfg.ChatPrefixes, } } diff --git a/internal/llm/copilot_http_test.go b/internal/llm/copilot_http_test.go index 53f831c..180e43e 100644 --- a/internal/llm/copilot_http_test.go +++ b/internal/llm/copilot_http_test.go @@ -10,12 +10,14 @@ import ( "testing" "time" "encoding/base64" + "os" ) type rtFunc2 func(*http.Request) (*http.Response, error) func (f rtFunc2) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } func TestCopilot_EnsureSession_AndChat_Success(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } // Mock chat endpoint chatSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { t.Fatalf("unexpected path: %s", r.URL.Path) } @@ -73,6 +75,7 @@ func TestCopilot_CodeCompletion_Success(t *testing.T) { } func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } // Chat multi-choice: return two choices; client returns first content srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ @@ -109,6 +112,7 @@ func TestCopilot_Chat_MultiChoice_And_ErrorBody(t *testing.T) { } func TestCopilot_Chat_NoChoices_Error(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{}}) })) @@ -127,6 +131,7 @@ func TestCopilot_Chat_NoChoices_Error(t *testing.T) { } func TestCopilot_Chat_DecodeError_StatusOK(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } // Chat returns 200 but invalid JSON; expect decode error srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "{invalid") diff --git a/internal/llm/ollama_test.go b/internal/llm/ollama_test.go index 8d77a58..15f9cff 100644 --- a/internal/llm/ollama_test.go +++ b/internal/llm/ollama_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" "time" + "os" ) func TestBuildOllamaRequest_OptionsAndStream(t *testing.T) { @@ -40,6 +41,7 @@ func TestOllama_NameAndModel(t *testing.T) { } func TestOllamaChat_Success(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/api/chat" { t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) } w.Header().Set("Content-Type", "application/json") @@ -54,6 +56,7 @@ func TestOllamaChat_Success(t *testing.T) { } func TestOllamaChat_EmptyContent(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"message": map[string]string{"role":"assistant","content":""}, "done": true}) })) @@ -66,6 +69,7 @@ func TestOllamaChat_EmptyContent(t *testing.T) { } func TestOllamaChat_Non2xx(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } // API error string ts1 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(400) @@ -102,6 +106,7 @@ func TestOllamaChat_HTTPError(t *testing.T) { } func TestOllamaChat_DecodeError(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("{bad json}")) })) @@ -119,6 +124,7 @@ func TestHandleOllamaNon2xx_OK(t *testing.T) { } func TestOllamaChatStream_Success(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") // two JSON objects back-to-back @@ -136,6 +142,7 @@ func TestOllamaChatStream_Success(t *testing.T) { } func TestOllamaChatStream_ErrorEvent(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"error":"oops"}) })) @@ -148,6 +155,7 @@ func TestOllamaChatStream_ErrorEvent(t *testing.T) { } func TestOllamaChatStream_DecodeError(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("{not json}")) })) diff --git a/internal/llm/openai_http_test.go b/internal/llm/openai_http_test.go index 808bb2b..ac7b897 100644 --- a/internal/llm/openai_http_test.go +++ b/internal/llm/openai_http_test.go @@ -9,9 +9,11 @@ import ( "testing" "strings" "time" + "os" ) func TestOpenAI_Chat_Success(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { t.Fatalf("unexpected path: %s", r.URL.Path) } _ = json.NewEncoder(w).Encode(map[string]any{"choices": []map[string]any{{"index":0, "message": map[string]string{"role":"assistant","content":"OK"}}}}) @@ -29,6 +31,7 @@ func TestOpenAI_Chat_MissingKey(t *testing.T) { } func TestOpenAI_ChatStream_SSE(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Return SSE-like stream w.Header().Set("Content-Type", "text/event-stream") @@ -49,6 +52,7 @@ func TestHandleOpenAINon2xx_NoErrorBody(t *testing.T) { } func TestOpenAI_ChatStream_SSE_ErrorChunk(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") io.WriteString(w, "data: {\"error\":{\"message\":\"oops\"}}\n\n") @@ -64,6 +68,7 @@ func TestOpenAI_ChatStream_SSE_ErrorChunk(t *testing.T) { } func TestOpenAI_Chat_NoChoices_Error(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{"choices": []any{}}) })) @@ -76,6 +81,7 @@ func TestOpenAI_Chat_NoChoices_Error(t *testing.T) { } func TestOpenAI_ChatStream_SSE_EmptyDelta_NoError(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") io.WriteString(w, "data: {\\\"choices\\\":[{\\\"delta\\\":{\\\"content\\\":\\\"\\\"}}]}\\n\\n") @@ -92,6 +98,7 @@ func TestOpenAI_ChatStream_SSE_EmptyDelta_NoError(t *testing.T) { } func TestOpenAI_Chat_DecodeError_StatusOK(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } // Return status 200 but invalid JSON body; Chat should return an error srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) @@ -106,6 +113,7 @@ func TestOpenAI_Chat_DecodeError_StatusOK(t *testing.T) { } func TestOpenAI_Chat_MultiChoiceAndErrorBody(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } // Multi-choice success: return two choices with different finish reasons srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ diff --git a/internal/llm/openai_sse_negative_test.go b/internal/llm/openai_sse_negative_test.go index 22b938c..8da5526 100644 --- a/internal/llm/openai_sse_negative_test.go +++ b/internal/llm/openai_sse_negative_test.go @@ -6,9 +6,11 @@ import ( "net/http" "net/http/httptest" "testing" + "os" ) func TestOpenAI_ChatStream_SSE_MalformedChunk(t *testing.T) { + if os.Getenv("HEXAI_TEST_SKIP_NET") == "1" { t.Skip("skip network-bound tests in restricted environments") } // Malformed JSON chunk should be skipped; no onDelta calls; no error. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") @@ -24,4 +26,3 @@ func TestOpenAI_ChatStream_SSE_MalformedChunk(t *testing.T) { } if got != "" { t.Fatalf("expected no deltas for malformed chunk, got %q", got) } } - diff --git a/internal/lsp/codeaction_test.go b/internal/lsp/codeaction_test.go index 5a74d66..4de0790 100644 --- a/internal/lsp/codeaction_test.go +++ b/internal/lsp/codeaction_test.go @@ -22,7 +22,7 @@ func TestBuildRewriteCodeAction_LazyAndResolves(t *testing.T) { s := newTestServer() s.llmClient = fakeLLM{resp: "REWRITTEN"} p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Range: Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 4}}} - sel := ";rewrite;\nold code" + sel := ">rewrite>\nold code" ca := s.buildRewriteCodeAction(p, sel) if ca == nil { t.Fatalf("expected code action") diff --git a/internal/lsp/completion_prefix_strip_test.go b/internal/lsp/completion_prefix_strip_test.go index 99a08d6..e8e70f5 100644 --- a/internal/lsp/completion_prefix_strip_test.go +++ b/internal/lsp/completion_prefix_strip_test.go @@ -55,30 +55,30 @@ func TestTryLLMCompletion_ManualInvokeAfterWhitespace_Allows(t *testing.T) { } } -func TestTryLLMCompletion_InlineSemicolonPromptAlwaysTriggers(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - s.llmClient = fakeLLM{resp: "replacement"} - line := "prefix ;do something; suffix" - // No trigger char immediately before cursor; place cursor at end - p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://inline.go"}} - items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") - if !ok || len(items) == 0 { - t.Fatalf("expected completion to trigger on inline ;text; prompt") - } +func TestTryLLMCompletion_InlinePromptAlwaysTriggers(t *testing.T) { + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + s.llmClient = fakeLLM{resp: "replacement"} + line := "prefix >do something> suffix" + // No trigger char immediately before cursor; place cursor at end + p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://inline.go"}} + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok || len(items) == 0 { + t.Fatalf("expected completion to trigger on inline >text> prompt") + } } -func TestTryLLMCompletion_DoubleSemicolonEmpty_DoesNotAutoTrigger(t *testing.T) { +func TestTryLLMCompletion_DoubleOpenEmpty_DoesNotAutoTrigger(t *testing.T) { s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} fake := &countingLLM{} s.llmClient = fake - line := ";; " // empty content after ';;' should not force-trigger + line := ">> " // empty content after double-open should not force-trigger p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://empty-inline.go"}} items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") if !ok { t.Fatalf("expected ok=true for non-trigger path") } if len(items) != 0 { - t.Fatalf("expected no items when inline ';;' is empty") + t.Fatalf("expected no items when inline double-open is empty") } if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) @@ -86,63 +86,63 @@ func TestTryLLMCompletion_DoubleSemicolonEmpty_DoesNotAutoTrigger(t *testing.T) } func TestHasDoubleSemicolonTrigger_Variants(t *testing.T) { - if hasDoubleSemicolonTrigger(";;") { - t.Fatalf("bare ';;' should not trigger") - } - if hasDoubleSemicolonTrigger(";; ;") { - t.Fatalf("';;' followed by space should not trigger") - } - if hasDoubleSemicolonTrigger(";;;") { - t.Fatalf("';;;' should not trigger (no content)") - } - if !hasDoubleSemicolonTrigger(";;x;") { - t.Fatalf("expected trigger for ';;x;' pattern") - } + if hasDoubleOpenTrigger(">>") { + t.Fatalf("bare double-open should not trigger") + } + if hasDoubleOpenTrigger(">> ") { + t.Fatalf("double-open followed by space should not trigger") + } + if hasDoubleOpenTrigger(">>>") { + t.Fatalf("';;;' should not trigger (no content)") + } + if !hasDoubleOpenTrigger(">>x>") { + t.Fatalf("expected trigger for ';;x;' pattern") + } } -func TestBareDoubleSemicolonPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - fake := &countingLLM{} - s.llmClient = fake - // Place a '.' earlier but also include bare ';;' at end; should not auto-trigger - line := "obj. call ;;" +func TestBareDoubleOpenPreventsAutoTriggerEvenWithOtherTriggers(t *testing.T) { + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + // Place a '.' earlier but also include bare double-open at end; should not auto-trigger + line := "obj. call >>" p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds.go"}} items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") if !ok { t.Fatalf("expected ok=true (handled), but not auto-triggering") } - if len(items) != 0 { - t.Fatalf("expected no items due to bare ';;'") - } + if len(items) != 0 { + t.Fatalf("expected no items due to bare double-open") + } if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } } -func TestBareDoubleSemicolonOnNextLine_PreventsAutoTrigger(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - fake := &countingLLM{} - s.llmClient = fake - current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")" - below := ";;" +func TestBareDoubleOpenOnNextLine_PreventsAutoTrigger(t *testing.T) { + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + current := "expression := flag.String(\"expression\", \"\", \"Expression to evaluate\")" + below := ">>" p := CompletionParams{Position: Position{Line: 0, Character: len(current)}, TextDocument: TextDocumentIdentifier{URI: "file://nextline.go"}} items, ok := s.tryLLMCompletion(p, "", current, below, "", "", false, "") if !ok { t.Fatalf("expected ok=true handled") } - if len(items) != 0 { - t.Fatalf("expected no items due to bare ';;' on next line") - } + if len(items) != 0 { + t.Fatalf("expected no items due to bare double-open on next line") + } if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } } -func TestBareDoubleSemicolonPreventsManualInvoke(t *testing.T) { - s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} - fake := &countingLLM{} - s.llmClient = fake - line := ";;" +func TestBareDoubleOpenPreventsManualInvoke(t *testing.T) { + s := &Server{maxTokens: 32, triggerChars: []string{".", ":", "/", "_"}, compCache: make(map[string]string)} + fake := &countingLLM{} + s.llmClient = fake + line := ">>" p := CompletionParams{Position: Position{Line: 0, Character: len(line)}, TextDocument: TextDocumentIdentifier{URI: "file://bare-ds-manual.go"}} // Simulate manual invoke p.Context = json.RawMessage([]byte(`{"triggerKind":1}`)) @@ -150,9 +150,9 @@ func TestBareDoubleSemicolonPreventsManualInvoke(t *testing.T) { if !ok { t.Fatalf("expected ok=true (handled)") } - if len(items) != 0 { - t.Fatalf("expected no items for bare ';;' even with manual invoke") - } + if len(items) != 0 { + t.Fatalf("expected no items for bare double-open even with manual invoke") + } if fake.calls != 0 { t.Fatalf("LLM should not be called; calls=%d", fake.calls) } diff --git a/internal/lsp/debounce_throttle_more_test.go b/internal/lsp/debounce_throttle_more_test.go new file mode 100644 index 0000000..cb11ea4 --- /dev/null +++ b/internal/lsp/debounce_throttle_more_test.go @@ -0,0 +1,36 @@ +package lsp + +import ( + "context" + "testing" + "time" +) + +func TestWaitForDebounce_WaitsRoughlyDebounce(t *testing.T) { + s := newTestServer() + s.completionDebounce = 20 * time.Millisecond + s.mu.Lock() + s.lastInput = time.Now() + s.mu.Unlock() + start := time.Now() + s.waitForDebounce(context.Background()) + if elapsed := time.Since(start); elapsed < 15*time.Millisecond { + t.Fatalf("debounce did not wait long enough: %v", elapsed) + } +} + +func TestWaitForThrottle_WaitsRoughlyInterval(t *testing.T) { + s := newTestServer() + s.throttleInterval = 20 * time.Millisecond + s.mu.Lock() + s.lastLLMCall = time.Now() + s.mu.Unlock() + start := time.Now() + if !s.waitForThrottle(context.Background()) { + t.Fatalf("waitForThrottle returned false") + } + if elapsed := time.Since(start); elapsed < 15*time.Millisecond { + t.Fatalf("throttle did not wait long enough: %v", elapsed) + } +} + diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go index 4bd96e2..5fee18b 100644 --- a/internal/lsp/document_test.go +++ b/internal/lsp/document_test.go @@ -9,10 +9,20 @@ import ( ) func newTestServer() *Server { - return &Server{ - logger: log.New(io.Discard, "", 0), - docs: make(map[string]*document), - } + s := &Server{ + logger: log.New(io.Discard, "", 0), + docs: make(map[string]*document), + inlineOpen: ">", + inlineClose: ">", + chatSuffix: ">", + chatPrefixes: []string{"?","!",":",";"}, + } + // Keep package-level helpers in sync for tests using free functions + inlineOpenChar = '>' + inlineCloseChar = '>' + chatSuffixChar = '>' + chatPrefixSingles = []string{"?","!",":",";"} + return s } func TestSplitLines(t *testing.T) { @@ -60,6 +70,15 @@ func TestLineContext_EmptyDoc(t *testing.T) { } } +func TestDocBeforeAfter_ClampsIndices(t *testing.T) { + s := newTestServer() + uri := "file:///clamp.go" + s.setDocument(uri, "abc\nxyz") + // Position beyond document length should be clamped safely + before, after := s.docBeforeAfter(uri, Position{Line: 99, Character: 99}) + if before == "" && after == "" { t.Fatalf("expected some text with clamped indices") } +} + func TestTrimLen(t *testing.T) { long := strings.Repeat("a", 205) got := trimLen(long) diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 547be67..5e7d86d 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -51,7 +51,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b text string } cands := []cand{} - if t, l, r, ok := findStrictSemicolonTag(line); ok { + if t, l, r, ok := findStrictInlineTag(line); ok { cands = append(cands, cand{start: l, end: r, text: t}) } if i := strings.Index(line, "/*"); i >= 0 { @@ -298,8 +298,9 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) } - // If the line contains a bare ';;' (no ';;text;'), do not treat as a trigger source. - if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) { + // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), + // do not treat as a trigger source. + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) { return false } // TriggerKind 1 = Invoked (manual). Always allow manual invoke. @@ -326,10 +327,10 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { if idx <= 0 || idx > len(current) { return false } - // Bare ';;' should not trigger via fallback char either - if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) { - return false - } + // Bare double-open should not trigger via fallback char either (only when configured) + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) { + return false + } ch := string(current[idx-1]) for _, c := range s.triggerChars { if c == ch { diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go index 576fc3d..036e591 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -93,10 +93,10 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true } - if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) && !manualInvoke { - logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) - return []CompletionItem{}, true - } + if (isBareDoubleOpen(current) || isBareDoubleOpen(below)) { + logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) + return []CompletionItem{}, true + } if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) { logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) @@ -163,14 +163,19 @@ func parseManualInvoke(ctx any) bool { // shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { - if t := strings.TrimRight(current, " \t"); len(t) >= 2 && t[len(t)-1] == '>' { - prev := t[len(t)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' { - logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) - return true - } - } - return false + t := strings.TrimRight(current, " \t") + if s.chatSuffix == "" { return false } + if strings.HasSuffix(t, s.chatSuffix) { + if len(t) < len(s.chatSuffix)+1 { return false } + prev := string(t[len(t)-len(s.chatSuffix)-1]) + for _, pf := range s.chatPrefixes { + if prev == pf { + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + } + } + } + return false } // prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. @@ -244,12 +249,12 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) } - if cleaned != "" && hasDoubleSemicolonTrigger(current) { - indent := leadingIndent(current) - if indent != "" { - cleaned = applyIndent(indent, cleaned) - } - } + if cleaned != "" && hasDoubleOpenTrigger(current) { + indent := leadingIndent(current) + if indent != "" { + cleaned = applyIndent(indent, cleaned) + } + } if strings.TrimSpace(cleaned) != "" { key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) s.completionCachePut(key, cleaned) @@ -354,10 +359,10 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) } - if cleaned != "" && hasDoubleSemicolonTrigger(currentLine) { - if indent := leadingIndent(currentLine); indent != "" { - cleaned = applyIndent(indent, cleaned) - } - } + if cleaned != "" && hasDoubleOpenTrigger(currentLine) { + if indent := leadingIndent(currentLine); indent != "" { + cleaned = applyIndent(indent, cleaned) + } + } return cleaned } diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go index 5b83d78..3f9d4b0 100644 --- a/internal/lsp/handlers_document.go +++ b/ |
