summaryrefslogtreecommitdiff
path: root/internal/lsp/handlers_end_to_end_test.go
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-09-06 10:56:27 +0300
committerPaul Buetow <paul@buetow.org>2025-09-06 10:56:27 +0300
commit320de746f7a2985b60c8564a0e65bdf231e840b7 (patch)
treee70bcf50813dba411afa2934e774383124bbc99e /internal/lsp/handlers_end_to_end_test.go
parent06247527d5170f329b454b42f59a3e4434ab1f4b (diff)
use gofumpt
Diffstat (limited to 'internal/lsp/handlers_end_to_end_test.go')
-rw-r--r--internal/lsp/handlers_end_to_end_test.go454
1 files changed, 246 insertions, 208 deletions
diff --git a/internal/lsp/handlers_end_to_end_test.go b/internal/lsp/handlers_end_to_end_test.go
index fd66a3c..32cb488 100644
--- a/internal/lsp/handlers_end_to_end_test.go
+++ b/internal/lsp/handlers_end_to_end_test.go
@@ -1,243 +1,281 @@
package lsp
import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "log"
- "strings"
- "testing"
- "time"
- tut "codeberg.org/snonux/hexai/internal/testutil"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "strings"
+ "testing"
+ "time"
+
+ tut "codeberg.org/snonux/hexai/internal/testutil"
)
// captureResponse decodes a single LSP Response from the server's output buffer.
func captureResponse(t *testing.T, buf *bytes.Buffer) Response {
- t.Helper()
- raw := buf.String()
- // strip Content-Length header framing
- 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 resp Response
- if err := json.Unmarshal([]byte(body), &resp); err != nil {
- t.Fatalf("unmarshal response: %v", err)
- }
- return resp
+ t.Helper()
+ raw := buf.String()
+ // strip Content-Length header framing
+ 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 resp Response
+ if err := json.Unmarshal([]byte(body), &resp); err != nil {
+ t.Fatalf("unmarshal response: %v", err)
+ }
+ return resp
}
// captureRequest decodes a single JSON-RPC Request from the server's output buffer.
func captureRequest(t *testing.T, buf *bytes.Buffer) Request {
- t.Helper()
- raw := buf.String()
- // 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
- }
- t.Fatalf("no request found in output")
- return Request{}
+ t.Helper()
+ raw := buf.String()
+ // 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
+ }
+ t.Fatalf("no request found in output")
+ return Request{}
}
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
- uri := "file:///x.go"
- src := "package p\n\nfunc add(a,b int) int { return a+b }\n"
- s.setDocument(uri, src)
-
- // Select the function line
- p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line:2, Character:0}, End: Position{Line:2, Character:len("func add(a,b int) int { return a+b }")}}}
- b, _ := json.Marshal(p)
- req := Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "textDocument/codeAction", Params: b}
-
- // Invoke directly
- out.Reset()
- s.handleCodeAction(req)
- resp := captureResponse(t, &out)
- // Decode result into []CodeAction
- var actions []CodeAction
- rb, _ := json.Marshal(resp.Result)
- if err := json.Unmarshal(rb, &actions); err != nil {
- t.Fatalf("decode actions: %v", err)
- }
- if len(actions) == 0 { t.Fatalf("expected some actions") }
- // Ensure our Hexai actions are present
- hasDoc := false
- hasGoTest := false
- for _, a := range actions {
- if strings.Contains(strings.ToLower(a.Title), "hexai:") {
- if strings.Contains(a.Title, "document code") { hasDoc = true }
- if strings.Contains(a.Title, "implement unit test") { hasGoTest = true }
- }
- }
- if !hasDoc || !hasGoTest {
- t.Fatalf("expected both Hexai actions, got %+v", actions)
- }
+ // 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
+ uri := "file:///x.go"
+ src := "package p\n\nfunc add(a,b int) int { return a+b }\n"
+ s.setDocument(uri, src)
+
+ // Select the function line
+ p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 2, Character: len("func add(a,b int) int { return a+b }")}}}
+ b, _ := json.Marshal(p)
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "textDocument/codeAction", Params: b}
+
+ // Invoke directly
+ out.Reset()
+ s.handleCodeAction(req)
+ resp := captureResponse(t, &out)
+ // Decode result into []CodeAction
+ var actions []CodeAction
+ rb, _ := json.Marshal(resp.Result)
+ if err := json.Unmarshal(rb, &actions); err != nil {
+ t.Fatalf("decode actions: %v", err)
+ }
+ if len(actions) == 0 {
+ t.Fatalf("expected some actions")
+ }
+ // Ensure our Hexai actions are present
+ hasDoc := false
+ hasGoTest := false
+ for _, a := range actions {
+ if strings.Contains(strings.ToLower(a.Title), "hexai:") {
+ if strings.Contains(a.Title, "document code") {
+ hasDoc = true
+ }
+ if strings.Contains(a.Title, "implement unit test") {
+ hasGoTest = true
+ }
+ }
+ }
+ if !hasDoc || !hasGoTest {
+ t.Fatalf("expected both Hexai actions, got %+v", actions)
+ }
}
func TestHandleCodeActionResolve_Document(t *testing.T) {
- var out bytes.Buffer
- s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
- s.llmClient = fakeLLM{resp: "// doc\nfunc f(){}"}
- uri := "file:///x.go"
- s.setDocument(uri, "package p\nfunc f(){}\n")
- // Build a document code action payload
- payload := struct {
- Type string `json:"type"`
- URI string `json:"uri"`
- Range Range `json:"range"`
- Selection string `json:"selection"`
- }{Type: "document", URI: uri, Range: Range{Start: Position{Line:1}, End: Position{Line:1, Character: 10}}, Selection: "func f(){}"}
- raw, _ := json.Marshal(payload)
- ca := CodeAction{Title: "Hexai: document code", Data: raw}
- b, _ := json.Marshal(ca)
- req := Request{JSONRPC: "2.0", ID: json.RawMessage("2"), Method: "codeAction/resolve", Params: b}
-
- out.Reset()
- s.handleCodeActionResolve(req)
- resp := captureResponse(t, &out)
- var resolved CodeAction
- rb, _ := json.Marshal(resp.Result)
- if err := json.Unmarshal(rb, &resolved); err != nil {
- t.Fatalf("decode resolved: %v", err)
- }
- if resolved.Edit == nil { t.Fatalf("expected resolved edit") }
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ s.llmClient = fakeLLM{resp: "// doc\nfunc f(){}"}
+ uri := "file:///x.go"
+ s.setDocument(uri, "package p\nfunc f(){}\n")
+ // Build a document code action payload
+ payload := struct {
+ Type string `json:"type"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Selection string `json:"selection"`
+ }{Type: "document", URI: uri, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 10}}, Selection: "func f(){}"}
+ raw, _ := json.Marshal(payload)
+ ca := CodeAction{Title: "Hexai: document code", Data: raw}
+ b, _ := json.Marshal(ca)
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("2"), Method: "codeAction/resolve", Params: b}
+
+ out.Reset()
+ s.handleCodeActionResolve(req)
+ resp := captureResponse(t, &out)
+ var resolved CodeAction
+ rb, _ := json.Marshal(resp.Result)
+ if err := json.Unmarshal(rb, &resolved); err != nil {
+ t.Fatalf("decode resolved: %v", err)
+ }
+ if resolved.Edit == nil {
+ t.Fatalf("expected resolved edit")
+ }
}
func TestHandleCodeAction_NoLLMOrEmptySelection_ReturnsEmpty(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"
- s.setDocument(uri, "package p\n\n")
- // Empty selection
- p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line:1}, End: Position{Line:1}}}
- b, _ := json.Marshal(p)
- req := Request{JSONRPC: "2.0", ID: json.RawMessage("4"), Method: "textDocument/codeAction", Params: b}
- out.Reset()
- s.handleCodeAction(req)
- resp := captureResponse(t, &out)
- var actions []CodeAction
- rb, _ := json.Marshal(resp.Result)
- _ = json.Unmarshal(rb, &actions)
- if len(actions) != 0 { t.Fatalf("expected no actions for empty selection, got %d", len(actions)) }
-
- // No llm client: should also return empty even if selection non-empty
- p2 := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line:0}, End: Position{Line:0, Character:7}}}
- out.Reset()
- req2 := Request{JSONRPC: "2.0", ID: json.RawMessage("5"), Method: "textDocument/codeAction", Params: mustJSON(p2)}
- s.handleCodeAction(req2)
- resp2 := captureResponse(t, &out)
- var actions2 []CodeAction
- rb2, _ := json.Marshal(resp2.Result)
- _ = json.Unmarshal(rb2, &actions2)
- if len(actions2) != 0 { t.Fatalf("expected no actions when llm is nil") }
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ uri := "file:///x.go"
+ s.setDocument(uri, "package p\n\n")
+ // Empty selection
+ p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1}}}
+ b, _ := json.Marshal(p)
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("4"), Method: "textDocument/codeAction", Params: b}
+ out.Reset()
+ s.handleCodeAction(req)
+ resp := captureResponse(t, &out)
+ var actions []CodeAction
+ rb, _ := json.Marshal(resp.Result)
+ _ = json.Unmarshal(rb, &actions)
+ if len(actions) != 0 {
+ t.Fatalf("expected no actions for empty selection, got %d", len(actions))
+ }
+
+ // No llm client: should also return empty even if selection non-empty
+ p2 := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 0}, End: Position{Line: 0, Character: 7}}}
+ out.Reset()
+ req2 := Request{JSONRPC: "2.0", ID: json.RawMessage("5"), Method: "textDocument/codeAction", Params: mustJSON(p2)}
+ s.handleCodeAction(req2)
+ resp2 := captureResponse(t, &out)
+ var actions2 []CodeAction
+ rb2, _ := json.Marshal(resp2.Result)
+ _ = json.Unmarshal(rb2, &actions2)
+ if len(actions2) != 0 {
+ t.Fatalf("expected no actions when llm is nil")
+ }
}
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) }
+ 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") }
+ 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 := 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
- s.setDocument(uri, "What time?>\n\n")
- out.Reset()
- s.detectAndHandleChat(uri)
- // Allow async goroutine to write the request
- for i := 0; i < 20 && out.Len() == 0; i++ { time.Sleep(10 * time.Millisecond) }
- if out.Len() == 0 { t.Fatalf("no output written by detectAndHandleChat") }
- // Expect a workspace/applyEdit request to be written
- req := captureRequest(t, &out)
- if req.Method != "workspace/applyEdit" { t.Fatalf("expected workspace/applyEdit, got %s", req.Method) }
- var params ApplyWorkspaceEditParams
- if err := json.Unmarshal(req.Params, &params); err != nil { t.Fatalf("decode params: %v", err) }
- we := params.Edit
- if len(we.Changes) == 0 { t.Fatalf("expected changes in edit") }
- edits := we.Changes[uri]
- if len(edits) != 2 { t.Fatalf("expected 2 edits (delete+insert), got %d", len(edits)) }
- if !strings.Contains(edits[1].NewText, "> Hello") || !strings.Contains(edits[1].NewText, "multi-line reply") {
- t.Fatalf("expected multi-line reply insertion, got %q", edits[1].NewText)
- }
+ var out bytes.Buffer
+ 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
+ s.setDocument(uri, "What time?>\n\n")
+ out.Reset()
+ s.detectAndHandleChat(uri)
+ // Allow async goroutine to write the request
+ for i := 0; i < 20 && out.Len() == 0; i++ {
+ time.Sleep(10 * time.Millisecond)
+ }
+ if out.Len() == 0 {
+ t.Fatalf("no output written by detectAndHandleChat")
+ }
+ // Expect a workspace/applyEdit request to be written
+ req := captureRequest(t, &out)
+ if req.Method != "workspace/applyEdit" {
+ t.Fatalf("expected workspace/applyEdit, got %s", req.Method)
+ }
+ var params ApplyWorkspaceEditParams
+ if err := json.Unmarshal(req.Params, &params); err != nil {
+ t.Fatalf("decode params: %v", err)
+ }
+ we := params.Edit
+ if len(we.Changes) == 0 {
+ t.Fatalf("expected changes in edit")
+ }
+ edits := we.Changes[uri]
+ if len(edits) != 2 {
+ t.Fatalf("expected 2 edits (delete+insert), got %d", len(edits))
+ }
+ if !strings.Contains(edits[1].NewText, "> Hello") || !strings.Contains(edits[1].NewText, "multi-line reply") {
+ t.Fatalf("expected multi-line reply insertion, got %q", edits[1].NewText)
+ }
}
func TestHandleCodeActionResolve_Diagnostics(t *testing.T) {
- var out bytes.Buffer
- s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
- s.llmClient = fakeLLM{resp: "fixed"}
- uri := "file:///x.go"
- s.setDocument(uri, "package p\nvar x = 1\n")
- payload := struct {
- Type string `json:"type"`
- URI string `json:"uri"`
- Range Range `json:"range"`
- Selection string `json:"selection"`
- Diagnostics []Diagnostic `json:"diagnostics"`
- }{Type: "diagnostics", URI: uri, Range: Range{Start: Position{Line:1}, End: Position{Line:1, Character: 10}}, Selection: "var x = 1", Diagnostics: []Diagnostic{{Range: Range{Start: Position{Line:1}, End: Position{Line:1, Character:5}}, Message: "bad"}}}
- raw, _ := json.Marshal(payload)
- ca := CodeAction{Title: "Hexai: resolve diagnostics", Data: raw}
- b, _ := json.Marshal(ca)
- req := Request{JSONRPC: "2.0", ID: json.RawMessage("3"), Method: "codeAction/resolve", Params: b}
- out.Reset()
- s.handleCodeActionResolve(req)
- resp := captureResponse(t, &out)
- var resolved CodeAction
- rb, _ := json.Marshal(resp.Result)
- if err := json.Unmarshal(rb, &resolved); err != nil { t.Fatalf("decode resolved: %v", err) }
- if resolved.Edit == nil { t.Fatalf("expected resolved edit for diagnostics") }
+ var out bytes.Buffer
+ s := &Server{logger: log.New(io.Discard, "", 0), docs: make(map[string]*document), out: &out}
+ s.llmClient = fakeLLM{resp: "fixed"}
+ uri := "file:///x.go"
+ s.setDocument(uri, "package p\nvar x = 1\n")
+ payload := struct {
+ Type string `json:"type"`
+ URI string `json:"uri"`
+ Range Range `json:"range"`
+ Selection string `json:"selection"`
+ Diagnostics []Diagnostic `json:"diagnostics"`
+ }{Type: "diagnostics", URI: uri, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 10}}, Selection: "var x = 1", Diagnostics: []Diagnostic{{Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 5}}, Message: "bad"}}}
+ raw, _ := json.Marshal(payload)
+ ca := CodeAction{Title: "Hexai: resolve diagnostics", Data: raw}
+ b, _ := json.Marshal(ca)
+ req := Request{JSONRPC: "2.0", ID: json.RawMessage("3"), Method: "codeAction/resolve", Params: b}
+ out.Reset()
+ s.handleCodeActionResolve(req)
+ resp := captureResponse(t, &out)
+ var resolved CodeAction
+ rb, _ := json.Marshal(resp.Result)
+ if err := json.Unmarshal(rb, &resolved); err != nil {
+ t.Fatalf("decode resolved: %v", err)
+ }
+ if resolved.Edit == nil {
+ t.Fatalf("expected resolved edit for diagnostics")
+ }
}