From 320de746f7a2985b60c8564a0e65bdf231e840b7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 10:56:27 +0300 Subject: use gofumpt --- internal/lsp/handlers_end_to_end_test.go | 454 +++++++++++++++++-------------- 1 file changed, 246 insertions(+), 208 deletions(-) (limited to 'internal/lsp/handlers_end_to_end_test.go') 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, ¶ms); 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, ¶ms); 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") + } } -- cgit v1.2.3