From 5be9532cfa630f4aacd8d879c3e4f5cc316da0fa Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 10:25:36 +0300 Subject: 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 --- internal/appconfig/config.go | 49 ++++- internal/hexailsp/run.go | 4 + internal/llm/copilot_http_test.go | 5 + internal/llm/ollama_test.go | 8 + internal/llm/openai_http_test.go | 8 + internal/llm/openai_sse_negative_test.go | 3 +- internal/lsp/codeaction_test.go | 2 +- internal/lsp/completion_prefix_strip_test.go | 102 +++++------ internal/lsp/debounce_throttle_more_test.go | 36 ++++ internal/lsp/document_test.go | 27 ++- internal/lsp/handlers.go | 15 +- internal/lsp/handlers_completion.go | 51 +++--- internal/lsp/handlers_document.go | 79 ++++---- internal/lsp/handlers_end_to_end_test.go | 4 +- internal/lsp/handlers_helpers_test.go | 56 +++--- internal/lsp/handlers_test.go | 78 ++++---- internal/lsp/handlers_utils.go | 259 ++++++++++++++------------- internal/lsp/helpers_inline_prompt_test.go | 58 ++++++ internal/lsp/helpers_more_test.go | 22 ++- internal/lsp/init_and_trigger_test.go | 5 +- internal/lsp/instruction_table_test.go | 3 +- internal/lsp/llm_stats_test.go | 11 ++ internal/lsp/postprocess_indent_test.go | 5 +- internal/lsp/provider_native_success_test.go | 21 +++ internal/lsp/server.go | 40 ++++- internal/lsp/transport_test.go | 15 +- internal/lsp/triggers_config_test.go | 74 ++++++++ 27 files changed, 698 insertions(+), 342 deletions(-) create mode 100644 internal/lsp/debounce_throttle_more_test.go create mode 100644 internal/lsp/helpers_inline_prompt_test.go create mode 100644 internal/lsp/llm_stats_test.go create mode 100644 internal/lsp/triggers_config_test.go (limited to 'internal') 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/internal/lsp/handlers_document.go @@ -10,6 +10,11 @@ import ( "time" ) +// Package-level chat trigger vars for helpers without Server receiver. +// NewServer assigns these from configuration on startup. +var chatSuffixChar byte = '>' +var chatPrefixSingles = []string{"?", "!", ":", ";"} + func (s *Server) handleDidOpen(req Request) { var p DidOpenTextDocumentParams if err := json.Unmarshal(req.Params, &p); err == nil { @@ -92,7 +97,7 @@ func (s *Server) detectAndHandleChat(uri string) { if d == nil || len(d.lines) == 0 { return } - for i, raw := range d.lines { + for i, raw := range d.lines { // Find last non-space character index j := len(raw) - 1 for j >= 0 { @@ -102,14 +107,25 @@ func (s *Server) detectAndHandleChat(uri string) { } break } - if j < 1 { - continue - } // need at least two chars - pair := raw[j-1 : j+1] - isTrigger := pair == "?>" || pair == "!>" || pair == ":>" || pair == ";>" - if !isTrigger { - continue - } + if j < 0 { + continue + } + // Check suffix/prefix according to configuration + if s.chatSuffix == "" { + continue + } + // Last non-space must equal suffix + if string(raw[j]) != s.chatSuffix { + continue + } + // Require at least one char before suffix and that char must be in chatPrefixes + if j < 1 { continue } + prev := string(raw[j-1]) + isTrigger := false + for _, pfx := range s.chatPrefixes { + if prev == pfx { isTrigger = true; break } + } + if !isTrigger { continue } // Avoid double-answering: if the next non-empty line starts with '>' we skip. k := i + 1 for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { @@ -119,9 +135,9 @@ func (s *Server) detectAndHandleChat(uri string) { continue } // Derive prompt by removing only the trailing '>' - removeCount := 1 + removeCount := len(s.chatSuffix) base := raw[:j+1-removeCount] - prompt := strings.TrimSpace(base) + prompt := strings.TrimSpace(base) if prompt == "" { continue } @@ -230,26 +246,27 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. func stripTrailingTrigger(sx string) string { - s := strings.TrimRight(sx, " \t") - if len(s) >= 2 && s[len(s)-1] == '>' { // new triggers - prev := s[len(s)-2] - if prev == '?' || prev == '!' || prev == ':' || prev == ';' { - return strings.TrimRight(s[:len(s)-1], " \t") - } - } - if strings.HasSuffix(s, ";;") { // legacy inline cleanup used in history building - return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") - } - if len(s) == 0 { - return sx - } - last := s[len(s)-1] - switch last { // legacy: remove one trailing punctuation - case '?', '!', ':': - return strings.TrimRight(s[:len(s)-1], " \t") - default: - return sx - } + s := strings.TrimRight(sx, " \t") + if len(s) == 0 { + return sx + } + // Configurable suffix removal when preceded by configured prefixes + if len(s) >= 2 && s[len(s)-1] == chatSuffixChar { + prev := string(s[len(s)-2]) + for _, pf := range chatPrefixSingles { + if prev == pf { + return strings.TrimRight(s[:len(s)-1], " \t") + } + } + } + // Legacy: remove one trailing punctuation (?, !, :) to build history nicely + last := s[len(s)-1] + switch last { + case '?', '!', ':': + return strings.TrimRight(s[:len(s)-1], " \t") + default: + return sx + } } // clientApplyEdit sends a workspace/applyEdit request to the client. diff --git a/internal/lsp/handlers_end_to_end_test.go b/internal/lsp/handlers_end_to_end_test.go index 73478e9..fd66a3c 100644 --- a/internal/lsp/handlers_end_to_end_test.go +++ b/internal/lsp/handlers_end_to_end_test.go @@ -66,6 +66,8 @@ func TestHandleCodeAction_ListsHexaiActions(t *testing.T) { // Prepare server var out bytes.Buffer s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + s.chatSuffix = ">" + s.chatPrefixes = []string{"?","!",":",";"} s.llmClient = fakeLLM{resp: "// doc\nfunc add(a,b int) int { return a+b }"} // Document with a function @@ -190,7 +192,7 @@ func TestHandle_Dispatch_Initialize(t *testing.T) { func TestDetectAndHandleChat_InsertsReply(t *testing.T) { var out bytes.Buffer - s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out} + s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{}) s.llmClient = fakeLLM{resp: tut.MultilineChatReply()} uri := "file:///chat.go" // Place a prompt line with a supported trigger at EOL, then a blank line diff --git a/internal/lsp/handlers_helpers_test.go b/internal/lsp/handlers_helpers_test.go index eb7f273..24a9690 100644 --- a/internal/lsp/handlers_helpers_test.go +++ b/internal/lsp/handlers_helpers_test.go @@ -6,32 +6,32 @@ import ( ) func TestHasDoubleSemicolonTrigger(t *testing.T) { - cases := []struct { - line string - want bool - }{ - {";;todo; remove this", true}, - {"prefix ;;x; suffix", true}, - {";; spaced ;", false}, - {"no markers", false}, - {";;x ; space before close", false}, - } - for _, tc := range cases { - got := hasDoubleSemicolonTrigger(tc.line) - if got != tc.want { - t.Fatalf("hasDoubleSemicolonTrigger(%q)=%v want %v", tc.line, got, tc.want) - } - } + cases := []struct { + line string + want bool + }{ + {">>todo> remove this", true}, + {"prefix >>x> suffix", true}, + {">> spaced >", false}, + {"no markers", false}, + {">>x > space before close", false}, + } + for _, tc := range cases { + got := hasDoubleOpenTrigger(tc.line) + if got != tc.want { + t.Fatalf("hasDoubleOpenTrigger(%q)=%v want %v", tc.line, got, tc.want) + } + } } func TestCollectSemicolonMarkers(t *testing.T) { - line := "keep ;ok; this and ;another; that" - edits := collectSemicolonMarkers(line, 7) - if len(edits) != 2 { - t.Fatalf("expected 2 edits, got %d", len(edits)) - } - // Validate the first edit aligns with ;ok; - start := strings.Index(line, ";ok;") + line := "keep >ok> this and >another> that" + edits := collectSemicolonMarkers(line, 7) + if len(edits) != 2 { + t.Fatalf("expected 2 edits, got %d", len(edits)) + } + // Validate the first edit aligns with ;ok; + start := strings.Index(line, ">ok>") if start < 0 { t.Fatalf("test setup: missing ;ok;") } @@ -41,11 +41,11 @@ func TestCollectSemicolonMarkers(t *testing.T) { } func TestPromptRemovalEditsForLine_WholeLine(t *testing.T) { - line := ";;todo; remove this whole line" - edits := promptRemovalEditsForLine(line, 3) - if len(edits) != 1 { - t.Fatalf("expected 1 whole-line edit, got %d", len(edits)) - } + line := ">>todo> remove this whole line" + edits := promptRemovalEditsForLine(line, 3) + if len(edits) != 1 { + t.Fatalf("expected 1 whole-line edit, got %d", len(edits)) + } e := edits[0] if e.Range.Start.Line != 3 || e.Range.End.Line != 3 || e.Range.Start.Character != 0 || e.Range.End.Character != len(line) { t.Fatalf("unexpected range for whole-line removal: %+v", e.Range) diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go index 5b84254..8fdd34f 100644 --- a/internal/lsp/handlers_test.go +++ b/internal/lsp/handlers_test.go @@ -14,8 +14,8 @@ func TestFindFirstInstructionInLine_NoMarker(t *testing.T) { } } -func TestFindFirstInstructionInLine_StrictSemicolon_Basic(t *testing.T) { - line := "prefix ;rename var; suffix" +func TestFindFirstInstructionInLine_StrictInline_Basic(t *testing.T) { + line := "prefix >rename var> suffix" instr, cleaned, ok := findFirstInstructionInLine(line) if !ok { t.Fatalf("expected ok=true") @@ -29,8 +29,8 @@ func TestFindFirstInstructionInLine_StrictSemicolon_Basic(t *testing.T) { } } -func TestFindFirstInstructionInLine_StrictSemicolon_TrailingSpacesTrimmed(t *testing.T) { - line := "code;fix; \t\t" +func TestFindFirstInstructionInLine_StrictInline_TrailingSpacesTrimmed(t *testing.T) { + line := "code>fix> \t\t" instr, cleaned, ok := findFirstInstructionInLine(line) if !ok { t.Fatalf("expected ok=true") @@ -43,17 +43,17 @@ func TestFindFirstInstructionInLine_StrictSemicolon_TrailingSpacesTrimmed(t *tes } } -func TestFindFirstInstructionInLine_Semicolon_InvalidPatterns(t *testing.T) { - cases := []string{ - "prefix ; bad; suffix", // space after first ';' ⇒ invalid - "prefix ;bad ; suffix", // space before closing ';' ⇒ invalid - "prefix ; ; suffix", // empty inner ⇒ invalid - } - for _, line := range cases { - if instr, _, ok := findFirstInstructionInLine(line); ok && instr != "" { - t.Fatalf("%q: expected no semicolon instruction; got instr=%q", line, instr) - } - } +func TestFindFirstInstructionInLine_Inline_InvalidPatterns(t *testing.T) { + cases := []string{ + "prefix > bad> suffix", // space after first '>' ⇒ invalid + "prefix >bad > suffix", // space before closing '>' ⇒ invalid + "prefix > > suffix", // empty inner ⇒ invalid + } + for _, line := range cases { + if instr, _, ok := findFirstInstructionInLine(line); ok && instr != "" { + t.Fatalf("%q: expected no inline instruction; got instr=%q", line, instr) + } + } } func TestFindFirstInstructionInLine_CBlockComment(t *testing.T) { @@ -126,22 +126,22 @@ func TestFindFirstInstructionInLine_DoubleDash(t *testing.T) { } } -func TestFindFirstInstructionInLine_EarliestWins_CommentOverSemicolon(t *testing.T) { - line := "aa // comment ;not this; trailing" +func TestFindFirstInstructionInLine_EarliestWins_CommentOverInline(t *testing.T) { + line := "aa // comment >not this> trailing" instr, cleaned, ok := findFirstInstructionInLine(line) if !ok { t.Fatalf("expected ok=true") } - if instr != "comment ;not this; trailing" { - t.Fatalf("instr got %q want %q", instr, "comment ;not this; trailing") - } + if instr != "comment >not this> trailing" { + t.Fatalf("instr got %q want %q", instr, "comment >not this> trailing") + } if cleaned != "aa" { t.Fatalf("cleaned got %q want %q", cleaned, "aa") } } -func TestFindFirstInstructionInLine_EarliestWins_SemicolonOverComment(t *testing.T) { - line := "aa ;short; // comment" +func TestFindFirstInstructionInLine_EarliestWins_InlineOverComment(t *testing.T) { + line := "aa >short> // comment" instr, cleaned, ok := findFirstInstructionInLine(line) if !ok { t.Fatalf("expected ok=true") @@ -155,21 +155,21 @@ func TestFindFirstInstructionInLine_EarliestWins_SemicolonOverComment(t *testing } } -func TestFindStrictSemicolonTag_Various(t *testing.T) { - // basic - if text, l, r, ok := findStrictSemicolonTag("pre;do it;post"); !ok || text != "do it" || l != 3 || r != 10 { - t.Fatalf("unexpected: ok=%v text=%q l=%d r=%d", ok, text, l, r) - } - // at start - if text, l, r, ok := findStrictSemicolonTag(";x;"); !ok || text != "x" || l != 0 || r != 3 { - t.Fatalf("unexpected at start: ok=%v text=%q l=%d r=%d", ok, text, l, r) - } - // double opening ';' should still allow a tag starting at the second ';' - if text, _, _, ok := findStrictSemicolonTag("prefix ;;bad; suffix"); !ok || text != "bad" { - t.Fatalf("unexpected double-open handling: ok=%v text=%q", ok, text) - } - // inner spaces directly after first ';' or before last ';' invalidate the tag - if _, _, _, ok := findStrictSemicolonTag("a; inner ;b"); ok { - t.Fatalf("expected invalid strict tag due to spaces at boundaries") - } +func TestFindStrictInlineTag_Various(t *testing.T) { + // basic + if text, l, r, ok := findStrictInlineTag("pre>do it>post"); !ok || text != "do it" || l != 3 || r != 10 { + t.Fatalf("unexpected: ok=%v text=%q l=%d r=%d", ok, text, l, r) + } + // at start + if text, l, r, ok := findStrictInlineTag(">x>"); !ok || text != "x" || l != 0 || r != 3 { + t.Fatalf("unexpected at start: ok=%v text=%q l=%d r=%d", ok, text, l, r) + } + // double opening '>>' should still allow a tag starting at the second '>' + if text, _, _, ok := findStrictInlineTag("prefix >>bad> suffix"); !ok || text != "bad" { + t.Fatalf("unexpected double-open handling: ok=%v text=%q", ok, text) + } + // inner spaces directly after first '>' or before last '>' invalidate the tag + if _, _, _, ok := findStrictInlineTag("a> inner >b"); ok { + t.Fatalf("expected invalid strict tag due to spaces at boundaries") + } } diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go index 42b35a5..e2c35e3 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -9,6 +9,11 @@ import ( "time" ) +// Configurable inline trigger characters (default to '>') used by free helpers below. +// NewServer assigns these based on ServerOptions. +var inlineOpenChar byte = '>' +var inlineCloseChar byte = '>' + // llmRequestOpts builds request options from server settings. func (s *Server) llmRequestOpts() []llm.RequestOption { opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} @@ -124,10 +129,10 @@ func isIdentChar(ch byte) bool { // Inline prompt utilities func lineHasInlinePrompt(line string) bool { - if _, _, _, ok := findStrictSemicolonTag(line); ok { - return true - } - return hasDoubleSemicolonTrigger(line) + if _, _, _, ok := findStrictInlineTag(line); ok { + return true + } + return hasDoubleOpenTrigger(line) } func leadingIndent(line string) string { @@ -164,61 +169,64 @@ func applyIndent(indent, suggestion string) string { // --- Inline marker parsing and general string utilities --- -// findStrictSemicolonTag finds ;text; with no space after first ';' and no space -// before the last ';' on the given line. Returns the text between semicolons, -// the start index of the opening ';', the end index just after the closing ';', -// and whether it was found. -func findStrictSemicolonTag(line string) (string, int, int, bool) { - pos := 0 - for pos < len(line) { - j := strings.Index(line[pos:], ";") - if j < 0 { - return "", 0, 0, false - } - j += pos - // ensure single ';' (not ';;') and non-space after - if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' { - pos = j + 1 - continue - } - k := strings.Index(line[j+1:], ";") - if k < 0 { - return "", 0, 0, false - } - closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { - pos = closeIdx + 1 - continue - } - inner := strings.TrimSpace(line[j+1 : closeIdx]) - if inner == "" { - pos = closeIdx + 1 - continue - } - end := closeIdx + 1 - return inner, j, end, true - } - return "", 0, 0, false +// findStrictInlineTag finds >text> (configurable), with no space after the first +// 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) (string, int, int, bool) { + pos := 0 + for pos < len(line) { + // find opening marker + j := strings.IndexByte(line[pos:], inlineOpenChar) + if j < 0 { + return "", 0, 0, false + } + j += pos + // ensure single open (not double) and non-space after + if j+1 >= len(line) || line[j+1] == inlineOpenChar || line[j+1] == ' ' { + pos = j + 1 + continue + } + // find closing marker + k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 { + return "", 0, 0, false + } + closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { + pos = closeIdx + 1 + continue + } + inner := strings.TrimSpace(line[j+1 : closeIdx]) + if inner == "" { + pos = closeIdx + 1 + continue + } + end := closeIdx + 1 + return inner, j, end, true + } + return "", 0, 0, false } // 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;". -func isBareDoubleSemicolon(line string) bool { - t := strings.TrimSpace(line) - if !strings.Contains(t, ";;") { - return false - } - if hasDoubleSemicolonTrigger(t) { - return false - } - if strings.HasPrefix(t, ";;") { - rest := strings.TrimSpace(t[2:]) - if rest == "" || rest == ";" { - return true - } - } - return false +func isBareDoubleOpen(line string) bool { + t := strings.TrimSpace(line) + // check for double-open pattern + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + if !strings.Contains(t, dbl) { + return false + } + if hasDoubleOpenTrigger(t) { + return false + } + if strings.HasPrefix(t, dbl) { + rest := strings.TrimSpace(t[len(dbl):]) + if rest == "" || rest == ";" { + return true + } + } + return false } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. @@ -401,79 +409,82 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit { } func promptRemovalEditsForLine(line string, lineNum int) []TextEdit { - if hasDoubleSemicolonTrigger(line) { - return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} - } - return collectSemicolonMarkers(line, lineNum) + if hasDoubleOpenTrigger(line) { + return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} + } + return collectSemicolonMarkers(line, lineNum) } -func hasDoubleSemicolonTrigger(line string) bool { - pos := 0 - for pos < len(line) { - j := strings.Index(line[pos:], ";;") - if j < 0 { - return false - } - j += pos - contentStart := j + 2 - if contentStart >= len(line) { - return false - } - first := line[contentStart] - if first == ' ' || first == ';' { - pos = contentStart + 1 - continue - } - k := strings.Index(line[contentStart+1:], ";") - if k < 0 { - return false - } - closeIdx := contentStart + 1 + k - if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' { - pos = closeIdx + 1 - continue - } - return true - } - return false +func hasDoubleOpenTrigger(line string) bool { + pos := 0 + for pos < len(line) { + // look for double-open sequence + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + j := strings.Index(line[pos:], dbl) + if j < 0 { + return false + } + j += pos + contentStart := j + len(dbl) + if contentStart >= len(line) { + return false + } + first := line[contentStart] + if first == ' ' || first == inlineOpenChar { + pos = contentStart + 1 + continue + } + // find closing + k := strings.IndexByte(line[contentStart+1:], inlineCloseChar) + if k < 0 { + return false + } + closeIdx := contentStart + 1 + k + if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' { + pos = closeIdx + 1 + continue + } + return true + } + return false } func collectSemicolonMarkers(line string, lineNum int) []TextEdit { - var edits []TextEdit - startSemi := 0 - for startSemi < len(line) { - j := strings.Index(line[startSemi:], ";") - if j < 0 { - break - } - j += startSemi - k := strings.Index(line[j+1:], ";") - if k < 0 { - break - } - if j+1 >= len(line) || line[j+1] == ' ' { - startSemi = j + 1 - continue - } - if line[j+1] == ';' { - startSemi = j + 2 - continue - } - closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { - startSemi = closeIdx + 1 - continue - } - if closeIdx-(j+1) < 1 { - startSemi = closeIdx + 1 - continue - } - endChar := closeIdx + 1 - if endChar < len(line) && line[endChar] == ' ' { - endChar++ - } - edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) - startSemi = endChar - } - return edits + var edits []TextEdit + startSemi := 0 + for startSemi < len(line) { + j := strings.IndexByte(line[startSemi:], inlineOpenChar) + if j < 0 { + break + } + j += startSemi + k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 { + break + } + if j+1 >= len(line) || line[j+1] == ' ' { + startSemi = j + 1 + continue + } + if line[j+1] == inlineOpenChar { // skip double-open start + startSemi = j + 2 + continue + } + closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { + startSemi = closeIdx + 1 + continue + } + if closeIdx-(j+1) < 1 { + startSemi = closeIdx + 1 + continue + } + endChar := closeIdx + 1 + if endChar < len(line) && line[endChar] == ' ' { + endChar++ + } + edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) + startSemi = endChar + } + return edits } diff --git a/internal/lsp/helpers_inline_prompt_test.go b/internal/lsp/helpers_inline_prompt_test.go new file mode 100644 index 0000000..81312b4 --- /dev/null +++ b/internal/lsp/helpers_inline_prompt_test.go @@ -0,0 +1,58 @@ +package lsp + +import ( + "encoding/json" + "testing" +) + +func TestLineHasInlinePrompt_BasicAndDoubleOpen(t *testing.T) { + // Basic inline + if !lineHasInlinePrompt("do >task> now") { + t.Fatalf("expected inline prompt detection for >text>") + } + // Double-open variant should be recognized as inline prompt too + if !lineHasInlinePrompt(">>replace>") { + t.Fatalf("expected inline prompt detection for >>text>") + } +} + +func TestIsTriggerEvent_TriggerCharNotAllowed(t *testing.T) { + s := newTestServer() + s.triggerChars = []string{"."} // only dot allowed + p := CompletionParams{Position: Position{Line:0, Character:3}} + if s.isTriggerEvent(p, "ab:") { // ':' not in triggerChars + t.Fatalf("expected false when TriggerCharacter not configured") + } +} + +func TestShouldSuppressForChatTriggerEOL_EmptySuffix_NoSuppression(t *testing.T) { + s := newTestServer() + s.chatSuffix = "" // disabled + p := CompletionParams{Position: Position{Line:0, Character:5}} + if s.shouldSuppressForChatTriggerEOL("What?>", p) { + t.Fatalf("expected no suppression when chat suffix is empty") + } +} + +func TestIsTriggerEvent_TriggerCharacterMissing_ReturnsFalse(t *testing.T) { + s := newTestServer() + // Context says TriggerCharacter, but none provided + ctx := struct{ TriggerKind int `json:"triggerKind"` }{TriggerKind: 2} + raw, _ := json.Marshal(ctx) + p := CompletionParams{Position: Position{Line:0, Character:1}, Context: json.RawMessage(raw)} + if s.isTriggerEvent(p, "a") { + t.Fatalf("expected false when TriggerCharacter kind with empty char") + } +} + +func TestIsTriggerEvent_TriggerForIncomplete_FallsBackToChar(t *testing.T) { + s := newTestServer() + s.triggerChars = []string{"."} + // TriggerKind=3 should consult fallback char check + ctx := struct{ TriggerKind int `json:"triggerKind"` }{TriggerKind: 3} + raw, _ := json.Marshal(ctx) + p := CompletionParams{Position: Position{Line:0, Character:2}, Context: json.RawMessage(raw)} + if !s.isTriggerEvent(p, "x.") { + t.Fatalf("expected true via fallback char for TriggerForIncomplete") + } +} diff --git a/internal/lsp/helpers_more_test.go b/internal/lsp/helpers_more_test.go index 64631f7..28d78a4 100644 --- a/internal/lsp/helpers_more_test.go +++ b/internal/lsp/helpers_more_test.go @@ -14,9 +14,9 @@ func TestLeadingAndApplyIndent(t *testing.T) { if out == "" || out[:2] != " " { t.Fatalf("applyIndent failed: %q", out) } } -func TestFindStrictSemicolonTag(t *testing.T) { - if _, _, _, ok := findStrictSemicolonTag(";do this; next"); !ok { t.Fatalf("expected strict tag") } - if _, _, _, ok := findStrictSemicolonTag("; spaced ;"); ok { t.Fatalf("should ignore spaced tag") } +func TestFindStrictInlineTag(t *testing.T) { + if _, _, _, ok := findStrictInlineTag(">do this> next"); !ok { t.Fatalf("expected strict tag") } + if _, _, _, ok := findStrictInlineTag("> spaced >"); ok { t.Fatalf("should ignore spaced tag") } } // hasDoubleSemicolonTrigger tested elsewhere @@ -34,6 +34,10 @@ func TestExtractRangeText(t *testing.T) { // multi-line got = extractRangeText(d, Range{Start: Position{Line:0, Character:0}, End: Position{Line:2, Character:2}}) if got != "a\nbc\nxy" { t.Fatalf("got %q", got) } + // invalid range (start after end) returns empty string + if got := extractRangeText(d, Range{Start: Position{Line:1, Character:5}, End: Position{Line:1, Character:2}}); got != "" { + t.Fatalf("expected empty for invalid range, got %q", got) + } } func TestRangesOverlapAndOrder(t *testing.T) { @@ -47,18 +51,18 @@ func TestRangesOverlapAndOrder(t *testing.T) { } func TestPromptRemovalEditsForLine(t *testing.T) { - edits := promptRemovalEditsForLine(";;do thing;", 3) + edits := promptRemovalEditsForLine(">>do thing>", 3) if len(edits) != 1 || edits[0].Range.Start.Line != 3 { t.Fatalf("expected full-line removal for double-semicolon") } - edits2 := promptRemovalEditsForLine(";act; and ;b;", 1) + edits2 := promptRemovalEditsForLine(">act> and >b>", 1) if len(edits2) == 0 { t.Fatalf("expected edits to remove strict markers") } } func TestCollectPromptRemovalEdits_MultiLine(t *testing.T) { s := newTestServer() uri := "file:///t.go" - s.setDocument(uri, "a\n;do; x\n;;wipe;\nend") + s.setDocument(uri, "a\n>do> x\n>>wipe>\nend") edits := s.collectPromptRemovalEdits(uri) if len(edits) < 2 { t.Fatalf("expected >=2 edits, got %d", len(edits)) } } @@ -89,9 +93,9 @@ func TestComputeTextEditAndFilter(t *testing.T) { if te2 == nil || te2.Range.Start.Character == 0 { t.Fatalf("expected param-range edit") } } -func TestIsBareDoubleSemicolon(t *testing.T) { - if !isBareDoubleSemicolon(";; ") { t.Fatalf("expected true") } - if isBareDoubleSemicolon(";;x;") { t.Fatalf("expected false for content form") } +func TestIsBareDoubleOpen(t *testing.T) { + if !isBareDoubleOpen(">> ") { t.Fatalf("expected true") } + if isBareDoubleOpen(">>x>") { t.Fatalf("expected false for content form") } } func TestIsDefiningNewFunction(t *testing.T) { diff --git a/internal/lsp/init_and_trigger_test.go b/internal/lsp/init_and_trigger_test.go index cdc907e..64253a9 100644 --- a/internal/lsp/init_and_trigger_test.go +++ b/internal/lsp/init_and_trigger_test.go @@ -45,8 +45,7 @@ func TestIsTriggerEvent_Variants(t *testing.T) { // 3) Fallback char left of cursor p3 := CompletionParams{Position: Position{Line:0, Character:3}} if !s.isTriggerEvent(p3, "ab:") { t.Fatalf("fallback char should trigger") } - // 4) Bare ';;' disables trigger + // 4) Bare double-open disables trigger p4 := CompletionParams{Position: Position{Line:0, Character:2}} - if s.isTriggerEvent(p4, ";;") { t.Fatalf("bare ;; should not trigger") } + if s.isTriggerEvent(p4, ">>") { t.Fatalf("bare double-open should not trigger") } } - diff --git a/internal/lsp/instruction_table_test.go b/internal/lsp/instruction_table_test.go index e92ffde..06364db 100644 --- a/internal/lsp/instruction_table_test.go +++ b/internal/lsp/instruction_table_test.go @@ -8,7 +8,7 @@ func TestFindFirstInstructionInLine_Table(t *testing.T) { line string instr string }{ - {"strict_semicolon", ";do; trailing", "do"}, + {"strict_inline_marker", ">do> trailing", "do"}, {"c_block", "x /* add docs */ y", "add docs"}, {"html_comment", " code", "fix"}, {"slash_slash", "code // please refactor", "please refactor"}, @@ -22,4 +22,3 @@ func TestFindFirstInstructionInLine_Table(t *testing.T) { } } } - diff --git a/internal/lsp/llm_stats_test.go b/internal/lsp/llm_stats_test.go new file mode 100644 index 0000000..9e27823 --- /dev/null +++ b/internal/lsp/llm_stats_test.go @@ -0,0 +1,11 @@ +package lsp + +import "testing" + +func TestLogLLMStats_CoversCounters(t *testing.T) { + s := newTestServer() + s.incSentCounters(10) + s.incRecvCounters(20) + s.logLLMStats() // just ensure it does not panic and executes +} + diff --git a/internal/lsp/postprocess_indent_test.go b/internal/lsp/postprocess_indent_test.go index 4b4ad2a..b546068 100644 --- a/internal/lsp/postprocess_indent_test.go +++ b/internal/lsp/postprocess_indent_test.go @@ -2,13 +2,12 @@ package lsp import "testing" -func TestPostProcessCompletion_IndentWithDoubleSemicolon(t *testing.T) { +func TestPostProcessCompletion_IndentWithDoubleOpen(t *testing.T) { s := newTestServer() - cleaned := s.postProcessCompletion("a\nb", "", " ;;gen;") + cleaned := s.postProcessCompletion("a\nb", "", " >>gen>") // Expect each non-empty line to be indented by two spaces want := " a\n b" if cleaned != want { t.Fatalf("got %q want %q", cleaned, want) } } - diff --git a/internal/lsp/provider_native_success_test.go b/internal/lsp/provider_native_success_test.go index 7db3844..fd7afad 100644 --- a/internal/lsp/provider_native_success_test.go +++ b/internal/lsp/provider_native_success_test.go @@ -31,3 +31,24 @@ func TestProviderNativeCompletion_Success(t *testing.T) { } } +type fakeCompleterIndent struct{} + +func (fakeCompleterIndent) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { return "", nil } +func (fakeCompleterIndent) Name() string { return "prov" } +func (fakeCompleterIndent) DefaultModel() string { return "m" } +func (fakeCompleterIndent) CodeCompletion(context.Context, string, string, int, string, float64) ([]string, error) { + return []string{"a\nb"}, nil +} + +func TestProviderNativeCompletion_IndentWithDoubleOpen(t *testing.T) { + s := newTestServer() + s.llmClient = fakeCompleterIndent{} + current := " >>do>" // leading indent + double-open marker + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 0, Character: len(current)}} + items, ok := s.tryProviderNativeCompletion(current, p, "", "", "func f(){}", "doc", false, "", false) + if !ok || len(items) == 0 { t.Fatalf("expected provider-native items") } + if items[0].TextEdit == nil { t.Fatalf("expected text edit") } + if got := items[0].TextEdit.NewText; len(got) < 2 || got[:2] != " " { + t.Fatalf("expected indentation applied, got %q", got) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 7a1007e..e040d08 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -2,14 +2,15 @@ package lsp import ( - "bufio" - "encoding/json" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "io" - "log" - "sync" - "time" + "bufio" + "encoding/json" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "io" + "log" + "strings" + "sync" + "time" ) // Server implements a minimal LSP over stdio. @@ -51,6 +52,12 @@ type Server struct { // Dispatch table for JSON-RPC methods → handler functions handlers map[string]func(Request) + + // Configurable trigger characters + inlineOpen string + inlineClose string + chatSuffix string + chatPrefixes []string } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -67,6 +74,12 @@ type ServerOptions struct { ManualInvokeMinPrefix int CompletionDebounceMs int CompletionThrottleMs int + + // Inline/chat triggers + InlineOpen string + InlineClose string + ChatSuffix string + ChatPrefixes []string } func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { @@ -109,6 +122,17 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) if opts.CompletionThrottleMs > 0 { s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond } + // Trigger character config (with sane defaults if missing) + if strings.TrimSpace(opts.InlineOpen) == "" { s.inlineOpen = ">" } else { s.inlineOpen = opts.InlineOpen } + if strings.TrimSpace(opts.InlineClose) == "" { s.inlineClose = ">" } else { s.inlineClose = opts.InlineClose } + if strings.TrimSpace(opts.ChatSuffix) == "" { s.chatSuffix = ">" } else { s.chatSuffix = opts.ChatSuffix } + if len(opts.ChatPrefixes) == 0 { s.chatPrefixes = []string{"?","!",":",";"} } else { s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) } + + // Assign package-level inline trigger chars for free helper functions + if s.inlineOpen != "" { inlineOpenChar = s.inlineOpen[0] } + if s.inlineClose != "" { inlineCloseChar = s.inlineClose[0] } + if s.chatSuffix != "" { chatSuffixChar = s.chatSuffix[0] } + if len(s.chatPrefixes) > 0 { chatPrefixSingles = append([]string{}, s.chatPrefixes...) } // Initialize dispatch table s.handlers = map[string]func(Request){ "initialize": s.handleInitialize, diff --git a/internal/lsp/transport_test.go b/internal/lsp/transport_test.go index 0a01acd..c00b405 100644 --- a/internal/lsp/transport_test.go +++ b/internal/lsp/transport_test.go @@ -17,6 +17,20 @@ func TestReadMessage_ParsesContentLength(t *testing.T) { if err != nil || string(got) != string(body) { t.Fatalf("readMessage failed: %v %q", err, string(got)) } } +func TestWriteMessage_FramesJSON(t *testing.T) { + var out bytes.Buffer + s := &Server{out: &out} + payload := struct{ JSONRPC string `json:"jsonrpc"`; Ping string `json:"ping"` }{JSONRPC: "2.0", Ping: "pong"} + s.writeMessage(payload) + got := out.String() + if !bytes.HasPrefix([]byte(got), []byte("Content-Length: ")) { t.Fatalf("missing Content-Length header: %q", got) } + // Header/body delimiter must be present + idx := bytes.Index([]byte(got), []byte("\r\n\r\n")) + if idx < 0 { t.Fatalf("missing CRLFCRLF delimiter: %q", got) } + body := got[idx+4:] + if body == "" || body[0] != '{' || body[len(body)-1] != '}' { t.Fatalf("body not JSON: %q", body) } +} + func stringInt(n int) string { if n == 0 { return "0" } var b [20]byte @@ -24,4 +38,3 @@ func stringInt(n int) string { for n > 0 { i--; b[i] = byte('0' + n%10); n /= 10 } return string(b[i:]) } - diff --git a/internal/lsp/triggers_config_test.go b/internal/lsp/triggers_config_test.go new file mode 100644 index 0000000..7fd6ecd --- /dev/null +++ b/internal/lsp/triggers_config_test.go @@ -0,0 +1,74 @@ +package lsp + +import ( + "bytes" + "encoding/json" + "io" + "log" + "testing" + "time" +) + +func TestShouldSuppressForChatTriggerEOL_CustomConfig(t *testing.T) { + s := newTestServer() + // Customize: only ")#" at EOL suppresses + s.chatSuffix = "#" + s.chatPrefixes = []string{")"} + + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:0, Character:6}} + if !s.shouldSuppressForChatTriggerEOL("ok)#", p) { + t.Fatalf("expected suppression for custom prefix+suffix at EOL") + } + if s.shouldSuppressForChatTriggerEOL("ok]#", p) { + t.Fatalf("did not expect suppression for non-matching prefix") + } +} + +func TestNewServer_AssignsTriggerGlobals_AndParsingUsesThem(t *testing.T) { + var out bytes.Buffer + s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{ + InlineOpen: "<", InlineClose: ">", ChatSuffix: ")", ChatPrefixes: []string{":"}, + }) + _ = s // ensure server constructed applies globals + if inlineOpenChar != '<' || inlineCloseChar != '>' { + t.Fatalf("inline markers not applied: %q %q", string(inlineOpenChar), string(inlineCloseChar)) + } + if chatSuffixChar != ')' || len(chatPrefixSingles) == 0 || chatPrefixSingles[0] != ":" { + t.Fatalf("chat markers not applied: suffix=%q prefixes=%v", string(chatSuffixChar), chatPrefixSingles) + } + if txt, l, r, ok := findStrictInlineTag("xy"); !ok || txt != "do" || l != 1 || r != 5 { + t.Fatalf("findStrictInlineTag failed: ok=%v txt=%q l=%d r=%d", ok, txt, l, r) + } + if got := stripTrailingTrigger("note:)"); got != "note:" { + t.Fatalf("stripTrailingTrigger failed: %q", got) + } +} + +func TestIsTriggerEvent_BareDoubleOpenBlocksEvenWithContextTriggerChar(t *testing.T) { + s := newTestServer() + s.inlineOpen = ">" // ensure bare ">>" check is active + s.triggerChars = []string{"."} + // LSP context indicates TriggerCharacter '.' but current line is bare ">>" + ctx := struct { + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter"` + }{TriggerKind: 2, TriggerCharacter: "."} + raw, _ := json.Marshal(ctx) + p := CompletionParams{Position: Position{Line: 0, Character: 2}, Context: json.RawMessage(raw)} + if s.isTriggerEvent(p, ">>") { + t.Fatalf("bare double-open should block trigger event even with context trigger char") + } +} + +func TestDetectAndHandleChat_CustomConfig_InsertsReply(t *testing.T) { + var out bytes.Buffer + s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{ChatSuffix: "#", ChatPrefixes: []string{")"}}) + s.llmClient = fakeLLM{resp: "Hello\nmulti-line reply"} + uri := "file:///chat2.go" + s.setDocument(uri, "ok)#\n\n") + out.Reset() + s.detectAndHandleChat(uri) + // Give time for applyEdit request + for i := 0; i < 20 && out.Len() == 0; i++ { time.Sleep(10 * time.Millisecond) } + if out.Len() == 0 { t.Fatalf("no output written for custom chat config") } +} -- cgit v1.2.3