summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-06 10:25:36 +0300
committerPaul Buetow <paul@buetow.org>2025-09-06 10:25:36 +0300
commit5be9532cfa630f4aacd8d879c3e4f5cc316da0fa (patch)
tree0a901680fccd1e2703ffdbd9284ccff932be1d67 /internal
parent70f1d0e78c57dfa5beae779b3d392b6e6fa44c14 (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')
-rw-r--r--internal/appconfig/config.go49
-rw-r--r--internal/hexailsp/run.go4
-rw-r--r--internal/llm/copilot_http_test.go5
-rw-r--r--internal/llm/ollama_test.go8
-rw-r--r--internal/llm/openai_http_test.go8
-rw-r--r--internal/llm/openai_sse_negative_test.go3
-rw-r--r--internal/lsp/codeaction_test.go2
-rw-r--r--internal/lsp/completion_prefix_strip_test.go102
-rw-r--r--internal/lsp/debounce_throttle_more_test.go36
-rw-r--r--internal/lsp/document_test.go27
-rw-r--r--internal/lsp/handlers.go15
-rw-r--r--internal/lsp/handlers_completion.go51
-rw-r--r--internal/lsp/handlers_document.go79
-rw-r--r--internal/lsp/handlers_end_to_end_test.go4
-rw-r--r--internal/lsp/handlers_helpers_test.go56
-rw-r--r--internal/lsp/handlers_test.go78
-rw-r--r--internal/lsp/handlers_utils.go259
-rw-r--r--internal/lsp/helpers_inline_prompt_test.go58
-rw-r--r--internal/lsp/helpers_more_test.go22
-rw-r--r--internal/lsp/init_and_trigger_test.go5
-rw-r--r--internal/lsp/instruction_table_test.go3
-rw-r--r--internal/lsp/llm_stats_test.go11
-rw-r--r--internal/lsp/postprocess_indent_test.go5
-rw-r--r--internal/lsp/provider_native_success_test.go21
-rw-r--r--internal/lsp/server.go40
-rw-r--r--internal/lsp/transport_test.go15
-rw-r--r--internal/lsp/triggers_config_test.go74
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/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", "<!-- fix --> 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("x<do>y"); !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") }
+}