summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-04 14:24:36 +0300
committerPaul Buetow <paul@buetow.org>2025-09-04 14:24:36 +0300
commitd68e5b3b188585fe234d0ce295ec7f054c8bad5f (patch)
tree974e067d9894f0da38513acdc27b56729b0f06e4 /internal
parent3c322b7046669a77c276ce05469bfc2db0b446b2 (diff)
tests(lsp): push coverage over 80%\n- Add init/trigger, chat history, document handler, transport readMessage, and rewrite resolve tests\n- Cover deferShowDocument and shutdown reply\n- Now ~81.2% coverage for internal/lsp
Diffstat (limited to 'internal')
-rw-r--r--internal/lsp/chat_history_test.go27
-rw-r--r--internal/lsp/codeaction_more_test.go18
-rw-r--r--internal/lsp/codegen_helpers_test.go15
-rw-r--r--internal/lsp/completion_messages_test.go17
-rw-r--r--internal/lsp/document_handlers_test.go61
-rw-r--r--internal/lsp/fallback_items_test.go12
-rw-r--r--internal/lsp/gotest_append_test.go28
-rw-r--r--internal/lsp/handlers_end_to_end_test.go61
-rw-r--r--internal/lsp/init_and_trigger_tests.go52
-rw-r--r--internal/lsp/init_shutdown_test.go20
-rw-r--r--internal/lsp/log_context_test.go15
-rw-r--r--internal/lsp/transport_test.go27
12 files changed, 344 insertions, 9 deletions
diff --git a/internal/lsp/chat_history_test.go b/internal/lsp/chat_history_test.go
new file mode 100644
index 0000000..0e9fed5
--- /dev/null
+++ b/internal/lsp/chat_history_test.go
@@ -0,0 +1,27 @@
+package lsp
+
+import "testing"
+
+func TestStripTrailingTrigger(t *testing.T) {
+ if got := stripTrailingTrigger("what?"); got != "what" { t.Fatalf("should remove trailing ?") }
+ if got := stripTrailingTrigger("what?>"); got != "what?" { t.Fatalf("should drop trailing > when preceded by ?") }
+ if got := stripTrailingTrigger("ok!>"); got != "ok!" { t.Fatalf("should drop > after !") }
+ if got := stripTrailingTrigger("note:>"); got != "note:" { t.Fatalf("should drop > after :") }
+ if got := stripTrailingTrigger("go;>"); got != "go;" { t.Fatalf("should drop > after ;") }
+}
+
+func TestBuildChatHistory_OrderAndLimit(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///chat.txt"
+ // Conversation: q1, > a1, blank, q2, > a2 lines, then current prompt
+ doc := "q1\n> a1\n\nq2\n> a2\n\n"
+ s.setDocument(uri, doc)
+ msgs := s.buildChatHistory(uri, 5, "q3")
+ // Expect: user q1, assistant a1, user q2, assistant a2, user q3
+ if len(msgs) != 5 || msgs[0].Role != "user" || msgs[1].Role != "assistant" || msgs[2].Role != "user" || msgs[3].Role != "assistant" || msgs[4].Role != "user" {
+ t.Fatalf("unexpected roles: %+v", msgs)
+ }
+ if msgs[0].Content != "q1" || msgs[1].Content != "a1" || msgs[2].Content != "q2" || msgs[3].Content != "a2" || msgs[4].Content != "q3" {
+ t.Fatalf("unexpected contents: %+v", msgs)
+ }
+}
diff --git a/internal/lsp/codeaction_more_test.go b/internal/lsp/codeaction_more_test.go
index e19c699..387afb5 100644
--- a/internal/lsp/codeaction_more_test.go
+++ b/internal/lsp/codeaction_more_test.go
@@ -22,6 +22,23 @@ func TestBuildDocumentCodeAction_AndResolve(t *testing.T) {
if len(edits) != 1 || strings.TrimSpace(edits[0].NewText) == "" { t.Fatalf("expected replacement text") }
}
+func TestResolveCodeAction_Rewrite(t *testing.T) {
+ s := newTestServer()
+ s.llmClient = fakeLLM{resp: "rewritten"}
+ uri := "file:///x.go"
+ s.setDocument(uri, "package p\nvar a=1\n")
+ payload := struct {
+ Type string `json:"type"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Instruction string `json:"instruction"`
+ Selection string `json:"selection"`
+ }{Type: "rewrite", URI: uri, Range: Range{Start: Position{Line:1}, End: Position{Line:1, Character: 5}}, Instruction: "do it", Selection: "var a"}
+ raw, _ := json.Marshal(payload)
+ ca := CodeAction{Title: "Hexai: rewrite selection", Data: raw}
+ if resolved, ok := s.resolveCodeAction(ca); !ok || resolved.Edit == nil { t.Fatalf("expected resolved rewrite edit") }
+}
+
func TestBuildGoUnitTestCodeAction_AndResolveCreate(t *testing.T) {
s := newTestServer()
// place files under a temp dir to avoid collisions
@@ -66,4 +83,3 @@ func TestDocBeforeAfter(t *testing.T) {
before, after := s.docBeforeAfter(uri, Position{Line:1, Character:1})
if before != "ab\nc" || after != "d\nef" { t.Fatalf("before=%q after=%q", before, after) }
}
-
diff --git a/internal/lsp/codegen_helpers_test.go b/internal/lsp/codegen_helpers_test.go
new file mode 100644
index 0000000..d897953
--- /dev/null
+++ b/internal/lsp/codegen_helpers_test.go
@@ -0,0 +1,15 @@
+package lsp
+
+import "testing"
+
+func TestParseGoPackageName(t *testing.T) {
+ lines := []string{"// comment", "package mypkg // trailing"}
+ if got := parseGoPackageName(lines); got != "mypkg" { t.Fatalf("got %q", got) }
+ if got := parseGoPackageName([]string{"no package"}); got != "" { t.Fatalf("expected empty") }
+}
+
+func TestDeriveGoFuncName(t *testing.T) {
+ if got := deriveGoFuncName("func Sum(a int) int { return a }"); got != "Sum" { t.Fatalf("got %q", got) }
+ if got := deriveGoFuncName("func (t *Type) Method(x int) {}"); got != "Method" { t.Fatalf("got %q", got) }
+}
+
diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go
index eb16ccc..e9ec3e5 100644
--- a/internal/lsp/completion_messages_test.go
+++ b/internal/lsp/completion_messages_test.go
@@ -44,6 +44,22 @@ func TestBuildDocString_Contents(t *testing.T) {
}
}
+func TestBuildPrompts_InParams(t *testing.T) {
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:0, Character:5}}
+ sys, user := buildPrompts(true, p, "a", "func f(x)", "c", "func f(x)")
+ if !contains(sys, "function signatures") || !contains(user, "parameter list") { t.Fatalf("unexpected in-params prompts") }
+}
+
+func TestPostProcessCompletion_CodeFencesAndDuplicates(t *testing.T) {
+ s := newTestServer()
+ // code fences
+ cleaned := s.postProcessCompletion("```go\nname := value\n```", "", "")
+ if cleaned == "" { t.Fatalf("expected non-empty after fence removal") }
+ // duplicate assignment prefix strip
+ cleaned2 := s.postProcessCompletion("name := other", "name := ", "name := ")
+ if cleaned2 == "" || cleaned2 == "name := other" { t.Fatalf("expected duplicate assignment prefix stripped: %q", cleaned2) }
+}
+
func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (stringIndex(s, sub) >= 0))) }
func stringIndex(s, sub string) int { return len([]rune(s[:])) - len([]rune(s[:])) + (func() int { return intIndex(s, sub) })() }
func intIndex(s, sub string) int { return Index(s, sub) }
@@ -55,4 +71,3 @@ func Index(s, sub string) int {
}
return -1
}
-
diff --git a/internal/lsp/document_handlers_test.go b/internal/lsp/document_handlers_test.go
new file mode 100644
index 0000000..bb12dd2
--- /dev/null
+++ b/internal/lsp/document_handlers_test.go
@@ -0,0 +1,61 @@
+package lsp
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "log"
+ "testing"
+ "time"
+)
+
+func TestDidOpenChangeClose_UpdateDocs(t *testing.T) {
+ s := newTestServer()
+ uri := "file:///x.go"
+ // didOpen
+ open := DidOpenTextDocumentParams{TextDocument: TextDocumentItem{URI: uri, Text: "a\n"}}
+ s.handleDidOpen(Request{JSONRPC: "2.0", Method: "textDocument/didOpen", Params: mustJSON(open)})
+ if s.getDocument(uri) == nil { t.Fatalf("doc not opened") }
+ // didChange
+ ch := DidChangeTextDocumentParams{TextDocument: VersionedTextDocumentIdentifier{URI: uri}, ContentChanges: []TextDocumentContentChangeEvent{{Text: "b\n"}}}
+ s.handleDidChange(Request{JSONRPC: "2.0", Method: "textDocument/didChange", Params: mustJSON(ch)})
+ if d := s.getDocument(uri); d == nil || d.text != "b\n" { t.Fatalf("doc not changed") }
+ // didClose
+ s.handleDidClose(Request{JSONRPC: "2.0", Method: "textDocument/didClose", Params: mustJSON(DidCloseTextDocumentParams{TextDocument: TextDocumentIdentifier{URI: uri}})})
+ if s.getDocument(uri) != nil { t.Fatalf("doc not closed") }
+}
+
+func TestClientShowDocument_WritesRequest(t *testing.T) {
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ uri := "file:///x.go"
+ sel := Range{Start: Position{Line: 1}, End: Position{Line: 2}}
+ out.Reset()
+ s.clientShowDocument(uri, &sel)
+ req := captureRequest(t, &out)
+ if req.Method != "window/showDocument" { t.Fatalf("got %s", req.Method) }
+}
+
+func TestHandleExecuteCommand_ShowDocument(t *testing.T) {
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ uri := "file:///x.go"
+ r := Range{Start: Position{Line:0}, End: Position{Line:0}}
+ args := []any{uri, r}
+ params := ExecuteCommandParams{Command: "hexai.showDocument", Arguments: args}
+ s.handleExecuteCommand(Request{JSONRPC: "2.0", ID: json.RawMessage("11"), Method: "workspace/executeCommand", Params: mustJSON(params)})
+ req := captureRequest(t, &out)
+ if req.Method != "window/showDocument" { t.Fatalf("expected showDocument after executeCommand, got %s", req.Method) }
+}
+
+func TestDeferShowDocument_WritesLater(t *testing.T) {
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ uri := "file:///x.go"
+ out.Reset()
+ s.deferShowDocument(uri, Range{Start: Position{Line:0}, End: Position{Line:0}})
+ // wait >120ms per implementation
+ time.Sleep(160 * time.Millisecond)
+ req := captureRequest(t, &out)
+ if req.Method != "window/showDocument" { t.Fatalf("expected showDocument, got %s", req.Method) }
+}
diff --git a/internal/lsp/fallback_items_test.go b/internal/lsp/fallback_items_test.go
new file mode 100644
index 0000000..0ce3542
--- /dev/null
+++ b/internal/lsp/fallback_items_test.go
@@ -0,0 +1,12 @@
+package lsp
+
+import "testing"
+
+func TestFallbackCompletionItems(t *testing.T) {
+ s := newTestServer()
+ items := s.fallbackCompletionItems("doc")
+ if len(items) != 1 || items[0].Label != "hexai-complete" || items[0].InsertText != "hexai" {
+ t.Fatalf("unexpected fallback items: %+v", items)
+ }
+}
+
diff --git a/internal/lsp/gotest_append_test.go b/internal/lsp/gotest_append_test.go
new file mode 100644
index 0000000..4fff684
--- /dev/null
+++ b/internal/lsp/gotest_append_test.go
@@ -0,0 +1,28 @@
+package lsp
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestResolveGoTest_AppendsToExisting(t *testing.T) {
+ s := newTestServer()
+ dir := t.TempDir()
+ src := filepath.Join(dir, "m.go")
+ uri := "file://" + src
+ s.setDocument(uri, "package m\n\nfunc F(){}\n")
+ // Create existing test file
+ testPath := filepath.Join(dir, "m_test.go")
+ if err := os.WriteFile(testPath, []byte("package m\n\nimport \"testing\"\n\n"), 0o644); err != nil { t.Fatal(err) }
+ // LLM path to increase generateGoTestFunction coverage
+ s.llmClient = fakeLLM{resp: "func TestF(t *testing.T) {}"}
+ we, testURI, jump, ok := s.resolveGoTest(uri, Position{Line:2})
+ if !ok || len(we.Changes) == 0 { t.Fatalf("expected append edit") }
+ if !strings.HasSuffix(testURI, "_test.go") { t.Fatalf("unexpected uri: %s", testURI) }
+ edits := we.Changes[testURI]
+ if len(edits) != 1 || !strings.Contains(edits[0].NewText, "TestF") { t.Fatalf("expected append with TestF") }
+ if jump.Start.Line < 0 { t.Fatalf("expected non-negative jump line") }
+}
+
diff --git a/internal/lsp/handlers_end_to_end_test.go b/internal/lsp/handlers_end_to_end_test.go
index 9767fa6..ba4a0bc 100644
--- a/internal/lsp/handlers_end_to_end_test.go
+++ b/internal/lsp/handlers_end_to_end_test.go
@@ -3,6 +3,7 @@ package lsp
import (
"bytes"
"encoding/json"
+ "fmt"
"io"
"log"
"strings"
@@ -29,14 +30,35 @@ func captureResponse(t *testing.T, buf *bytes.Buffer) Response {
func captureRequest(t *testing.T, buf *bytes.Buffer) Request {
t.Helper()
raw := buf.String()
- idx := strings.Index(raw, "\r\n\r\n")
- if idx < 0 { t.Fatalf("no header/body separator in %q", raw) }
- body := raw[idx+4:]
- var req Request
- if err := json.Unmarshal([]byte(body), &req); err != nil {
- t.Fatalf("unmarshal request: %v", err)
+ // There may be multiple framed messages concatenated; scan for each
+ off := 0
+ for off < len(raw) {
+ rest := raw[off:]
+ idx := strings.Index(rest, "\r\n\r\n")
+ if idx < 0 { break }
+ body := rest[idx+4:]
+ // Content-Length header indicates body length; parse length from header
+ hdr := rest[:idx]
+ clen := 0
+ for _, line := range strings.Split(hdr, "\r\n") {
+ if strings.HasPrefix(strings.ToLower(line), "content-length:") {
+ var n int
+ _, _ = fmt.Sscanf(line, "Content-Length: %d", &n)
+ clen = n
+ break
+ }
+ }
+ if clen <= 0 || clen > len(body) { clen = len(body) }
+ piece := body[:clen]
+ var req Request
+ _ = json.Unmarshal([]byte(piece), &req)
+ if req.Method != "" {
+ return req
+ }
+ off += idx + 4 + clen
}
- return req
+ t.Fatalf("no request found in output")
+ return Request{}
}
func TestHandleCodeAction_ListsHexaiActions(t *testing.T) {
@@ -140,6 +162,31 @@ func TestHandleCodeAction_NoLLMOrEmptySelection_ReturnsEmpty(t *testing.T) {
func mustJSON(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
+func TestHandle_UnknownMethod_ReturnsError(t *testing.T) {
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out, handlers: map[string]func(Request){}}
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("9"), Method: "no/such"}
+ out.Reset()
+ s.handle(req)
+ resp := captureResponse(t, &out)
+ if resp.Error == nil || resp.Error.Code != -32601 { t.Fatalf("expected method not found error, got %+v", resp.Error) }
+}
+
+func TestHandle_Dispatch_Initialize(t *testing.T) {
+ var out bytes.Buffer
+ // Build a server via constructor to ensure handlers map is populated
+ s := NewServer(bytes.NewReader(nil), &out, log.New(io.Discard, "", 0), ServerOptions{})
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("13"), Method: "initialize"}
+ out.Reset()
+ s.handle(req)
+ resp := captureResponse(t, &out)
+ var init InitializeResult
+ b, _ := json.Marshal(resp.Result)
+ _ = json.Unmarshal(b, &init)
+ if init.Capabilities.CodeActionProvider == nil || init.Capabilities.CompletionProvider == nil { t.Fatalf("missing capabilities") }
+}
+
+
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}
diff --git a/internal/lsp/init_and_trigger_tests.go b/internal/lsp/init_and_trigger_tests.go
new file mode 100644
index 0000000..cdc907e
--- /dev/null
+++ b/internal/lsp/init_and_trigger_tests.go
@@ -0,0 +1,52 @@
+package lsp
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "log"
+ "testing"
+)
+
+func TestHandleInitialize_Capabilities(t *testing.T) {
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ s.triggerChars = []string{".", ":"}
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("7"), Method: "initialize"}
+ out.Reset()
+ s.handleInitialize(req)
+ resp := captureResponse(t, &out)
+ var init InitializeResult
+ b, _ := json.Marshal(resp.Result)
+ if err := json.Unmarshal(b, &init); err != nil { t.Fatalf("decode init: %v", err) }
+ if init.Capabilities.CodeActionProvider == nil { t.Fatalf("expected codeActionProvider") }
+ // CodeActionProvider is any; re-marshal to struct
+ var cap struct{ ResolveProvider bool `json:"resolveProvider"` }
+ cb, _ := json.Marshal(init.Capabilities.CodeActionProvider)
+ _ = json.Unmarshal(cb, &cap)
+ if !cap.ResolveProvider { t.Fatalf("expected resolveProvider=true") }
+ if init.Capabilities.CompletionProvider == nil || len(init.Capabilities.CompletionProvider.TriggerCharacters) == 0 {
+ t.Fatalf("expected trigger characters") }
+}
+
+func TestIsTriggerEvent_Variants(t *testing.T) {
+ s := newTestServer()
+ s.triggerChars = []string{".", ":"}
+ // 1) Manual invoke via context
+ ctx := struct{ TriggerKind int `json:"triggerKind"` }{TriggerKind:1}
+ raw, _ := json.Marshal(ctx)
+ p := CompletionParams{Position: Position{Line:0, Character:1}, Context: json.RawMessage(raw)}
+ if !s.isTriggerEvent(p, "a") { t.Fatalf("manual invoke should trigger") }
+ // 2) TriggerCharacter present and allowed
+ ctx2 := struct{ TriggerKind int `json:"triggerKind"`; TriggerCharacter string `json:"triggerCharacter"` }{TriggerKind:2, TriggerCharacter: "."}
+ raw2, _ := json.Marshal(ctx2)
+ p2 := CompletionParams{Position: Position{Line:0, Character:1}, Context: json.RawMessage(raw2)}
+ if !s.isTriggerEvent(p2, "a.") { t.Fatalf("trigger char should trigger") }
+ // 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
+ p4 := CompletionParams{Position: Position{Line:0, Character:2}}
+ if s.isTriggerEvent(p4, ";;") { t.Fatalf("bare ;; should not trigger") }
+}
+
diff --git a/internal/lsp/init_shutdown_test.go b/internal/lsp/init_shutdown_test.go
new file mode 100644
index 0000000..7b08f2c
--- /dev/null
+++ b/internal/lsp/init_shutdown_test.go
@@ -0,0 +1,20 @@
+package lsp
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "log"
+ "testing"
+)
+
+func TestHandleShutdown_Replies(t *testing.T) {
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("12"), Method: "shutdown"}
+ out.Reset()
+ s.handleShutdown(req)
+ resp := captureResponse(t, &out)
+ if string(resp.ID) != "12" || resp.Error != nil { t.Fatalf("unexpected shutdown response: %+v", resp) }
+}
+
diff --git a/internal/lsp/log_context_test.go b/internal/lsp/log_context_test.go
new file mode 100644
index 0000000..0bc4ed3
--- /dev/null
+++ b/internal/lsp/log_context_test.go
@@ -0,0 +1,15 @@
+package lsp
+
+import (
+ "io"
+ "log"
+ "testing"
+)
+
+func TestLogCompletionContext(t *testing.T) {
+ s := newTestServer()
+ s.logger = log.New(io.Discard, "", 0)
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line:1, Character:2}}
+ s.logCompletionContext(p, "a", "b", "c", "f")
+}
+
diff --git a/internal/lsp/transport_test.go b/internal/lsp/transport_test.go
new file mode 100644
index 0000000..0a01acd
--- /dev/null
+++ b/internal/lsp/transport_test.go
@@ -0,0 +1,27 @@
+package lsp
+
+import (
+ "bufio"
+ "bytes"
+ "testing"
+)
+
+func TestReadMessage_ParsesContentLength(t *testing.T) {
+ body := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize"}`)
+ frame := []byte("Content-Length: ")
+ frame = append(frame, []byte(stringInt(len(body)))...)
+ frame = append(frame, []byte("\r\n\r\n")...)
+ frame = append(frame, body...)
+ s := &Server{in: bufio.NewReader(bytes.NewReader(frame))}
+ got, err := s.readMessage()
+ if err != nil || string(got) != string(body) { t.Fatalf("readMessage failed: %v %q", err, string(got)) }
+}
+
+func stringInt(n int) string {
+ if n == 0 { return "0" }
+ var b [20]byte
+ i := len(b)
+ for n > 0 { i--; b[i] = byte('0' + n%10); n /= 10 }
+ return string(b[i:])
+}
+