diff options
Diffstat (limited to 'internal/lsp')
| -rw-r--r-- | internal/lsp/codeaction_custom_errors_test.go | 92 | ||||
| -rw-r--r-- | internal/lsp/codeaction_custom_test.go | 110 | ||||
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 108 | ||||
| -rw-r--r-- | internal/lsp/server.go | 21 |
4 files changed, 330 insertions, 1 deletions
diff --git a/internal/lsp/codeaction_custom_errors_test.go b/internal/lsp/codeaction_custom_errors_test.go new file mode 100644 index 0000000..2f42f65 --- /dev/null +++ b/internal/lsp/codeaction_custom_errors_test.go @@ -0,0 +1,92 @@ +package lsp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + "codeberg.org/snonux/hexai/internal/llm" +) + +func TestResolveCodeAction_Custom_UnknownID(t *testing.T) { + s := newTestServer() + // No matching custom action configured + s.customActions = []CustomAction{{ID: "known", Title: "Known", Instruction: "x"}} + uri := "file:///t.go" + payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "custom", ID: "missing", URI: uri, Range: Range{}, Selection: "abc"} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: X", Data: raw} + if _, ok := s.resolveCodeAction(ca); ok { + t.Fatalf("expected resolve to fail for unknown custom id") + } +} + +type errLLM struct{} +func (errLLM) Chat(_ context.Context, _ []llm.Message, _ ...llm.RequestOption) (string, error) { return "", errors.New("boom") } +func (errLLM) Name() string { return "prov" } +func (errLLM) DefaultModel() string { return "m" } + +func TestResolveCodeAction_Custom_EmptyAndError(t *testing.T) { + // empty output case + s1 := newTestServer() + s1.llmClient = fakeLLM{resp: " \n\n"} + s1.customActions = []CustomAction{{ID: "empty", Title: "Empty", Instruction: "x"}} + raw1, _ := json.Marshal(struct{ Type, ID, URI, Selection string; Range Range }{"custom", "empty", "file:///t.go", "sel", Range{}}) + if resolved, ok := s1.resolveCodeAction(CodeAction{Data: raw1}); ok || resolved.Edit != nil { + t.Fatalf("expected no edit for empty llm output") + } + + // error case + s2 := newTestServer() + s2.llmClient = errLLM{} + s2.customActions = []CustomAction{{ID: "err", Title: "Err", Instruction: "x"}} + raw2, _ := json.Marshal(struct{ Type, ID, URI, Selection string; Range Range }{"custom", "err", "file:///t.go", "sel", Range{}}) + if resolved, ok := s2.resolveCodeAction(CodeAction{Data: raw2}); ok || resolved.Edit != nil { + t.Fatalf("expected no edit for llm error") + } +} + +func TestHandleCodeAction_Custom_SelectionSuppressedWhenEmpty(t *testing.T) { + s := newTestServer() + s.llmClient = fakeLLM{resp: "IGN"} + // One selection-scoped and one diagnostics-scoped custom + s.customActions = []CustomAction{ + {ID: "sel", Title: "Sel", Scope: "selection", Instruction: "x"}, + {ID: "diag", Title: "Diag", Scope: "diagnostics", User: "{{diagnostics}}"}, + } + uri := "file:///t.go" + s.setDocument(uri, "package p\nfunc f(){}\n") + // Empty selection range (start==end) + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1}}} + // include a diagnostic so diagnostics action is allowed + ctx := CodeActionContext{Diagnostics: []Diagnostic{{Range: Range{Start: Position{Line: 1}}, Message: "x"}}} + rawCtx, _ := json.Marshal(ctx) + p.Context = json.RawMessage(rawCtx) + // Build request + req := Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "textDocument/codeAction"} + req.Params, _ = json.Marshal(p) + // capture + var out bytes.Buffer + s.out = &out + s.handleCodeAction(req) + resp := captureResponse(t, &out) + rb, _ := json.Marshal(resp.Result) + var actions []CodeAction + _ = json.Unmarshal(rb, &actions) + seenSel, seenDiag := false, false + for _, a := range actions { + if a.Title == "Hexai: Sel" { seenSel = true } + if a.Title == "Hexai: Diag" { seenDiag = true } + } + if seenSel || !seenDiag { + t.Fatalf("expected only diagnostics custom when selection is empty; got %+v", actions) + } +} diff --git a/internal/lsp/codeaction_custom_test.go b/internal/lsp/codeaction_custom_test.go new file mode 100644 index 0000000..7baf993 --- /dev/null +++ b/internal/lsp/codeaction_custom_test.go @@ -0,0 +1,110 @@ +package lsp + +import ( + "bytes" + "encoding/json" + "io" + "log" + "strings" + "testing" +) + +// local copy of captureResponse for this test file +func capResp(t *testing.T, buf *bytes.Buffer) Response { + 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 resp Response + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + return resp +} + +func TestHandleCodeAction_ListsCustomActions(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: "IGN"} + // Inject two custom actions + s.customActions = []CustomAction{ + {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"}, + {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix:\n{{diagnostics}}\n\n{{selection}}"}, + } + // Prepare document and params + uri := "file:///t.go" + s.setDocument(uri, "package x\n\nfunc f(){}\n") + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 2}, End: Position{Line: 2, Character: 5}}} + // Include diagnostics context so diagnostics-scoped action appears + ctx := CodeActionContext{Diagnostics: []Diagnostic{{Range: Range{Start: Position{Line: 2}}, Message: "warn"}}} + raw, _ := json.Marshal(ctx) + p.Context = json.RawMessage(raw) + + // Call handler + req := Request{JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "textDocument/codeAction"} + req.Params, _ = json.Marshal(p) + out.Reset() + s.handleCodeAction(req) + resp := capResp(t, &out) + var actions []CodeAction + rb, _ := json.Marshal(resp.Result) + if err := json.Unmarshal(rb, &actions); err != nil { + t.Fatalf("decode: %v", err) + } + var seenSel, seenDiag bool + for _, a := range actions { + if a.Title == "Hexai: Extract function" { + seenSel = true + } + if a.Title == "Hexai: Fix diagnostics" { + seenDiag = true + } + } + if !seenSel || !seenDiag { + t.Fatalf("expected both custom actions, got %+v", actions) + } +} + +func TestResolveCodeAction_CustomInstructionAndUser(t *testing.T) { + s := newTestServer() + s.llmClient = fakeLLM{resp: "REPLACED"} + // one instruction-based and one user-based + s.customActions = []CustomAction{ + {ID: "extract", Title: "Extract function", Scope: "selection", Kind: "refactor.extract", Instruction: "Extract into function"}, + {ID: "fix", Title: "Fix diagnostics", Scope: "diagnostics", Kind: "quickfix", User: "Fix: {{diagnostics}}\n{{selection}}"}, + } + uri := "file:///t.go" + p := CodeActionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Range: Range{Start: Position{Line: 1}, End: Position{Line: 1, Character: 3}}} + + // Build selection-scoped custom action payload + selPayload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "custom", ID: "extract", URI: uri, Range: p.Range, Selection: "abc"} + raw1, _ := json.Marshal(selPayload) + ca1 := CodeAction{Title: "Hexai: Extract function", Data: raw1} + if resolved, ok := s.resolveCodeAction(ca1); !ok || resolved.Edit == nil { + t.Fatalf("expected resolve for instruction-based custom action") + } + + // Build diagnostics-scoped custom action payload + diagPayload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "custom", ID: "fix", URI: uri, Range: p.Range, Selection: "abc", Diagnostics: []Diagnostic{{Message: "lint"}}} + raw2, _ := json.Marshal(diagPayload) + ca2 := CodeAction{Title: "Hexai: Fix diagnostics", Data: raw2} + if resolved, ok := s.resolveCodeAction(ca2); !ok || resolved.Edit == nil { + t.Fatalf("expected resolve for user-based custom action") + } +} diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go index e1c2720..9bc3f51 100644 --- a/internal/lsp/handlers_codeaction.go +++ b/internal/lsp/handlers_codeaction.go @@ -31,7 +31,7 @@ func (s *Server) handleCodeAction(req Request) { } sel := extractRangeText(d, p.Range) - actions := make([]CodeAction, 0, 5) + actions := make([]CodeAction, 0, 8) if a := s.buildRewriteCodeAction(p, sel); a != nil { actions = append(actions, *a) } @@ -47,11 +47,65 @@ func (s *Server) handleCodeAction(req Request) { if a := s.buildSimplifyCodeAction(p, sel); a != nil { actions = append(actions, *a) } + // Custom actions from config + s.appendCustomActions(&actions, p, sel) if len(req.ID) != 0 { s.reply(req.ID, actions, nil) } } +// appendCustomActions adds user-defined actions depending on scope and availability. +func (s *Server) appendCustomActions(actions *[]CodeAction, p CodeActionParams, sel string) { + if len(s.customActions) == 0 { + return + } + diags := s.diagnosticsInRange(p.Context, p.Range) + for _, ca := range s.customActions { + title := strings.TrimSpace(ca.Title) + if title == "" { + continue + } + scope := strings.TrimSpace(strings.ToLower(ca.Scope)) + if scope == "diagnostics" { + if len(diags) == 0 { + continue + } + payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags} + raw, _ := json.Marshal(payload) + kind := ca.Kind + if strings.TrimSpace(kind) == "" { + kind = "quickfix" + } + *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw}) + continue + } + // default: selection + if strings.TrimSpace(sel) == "" { + continue + } + payload := struct { + Type string `json:"type"` + ID string `json:"id"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "custom", ID: ca.ID, URI: p.TextDocument.URI, Range: p.Range, Selection: sel} + raw, _ := json.Marshal(payload) + kind := ca.Kind + if strings.TrimSpace(kind) == "" { + kind = "refactor" + } + *actions = append(*actions, CodeAction{Title: "Hexai: " + title, Kind: kind, Data: raw}) + } +} + func (s *Server) buildSimplifyCodeAction(p CodeActionParams, sel string) *CodeAction { if strings.TrimSpace(sel) == "" { return nil @@ -106,6 +160,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { } var payload struct { Type string `json:"type"` + ID string `json:"id"` URI string `json:"uri"` Range Range `json:"range"` Instruction string `json:"instruction,omitempty"` @@ -200,6 +255,57 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { } else { logging.Logf("lsp ", "codeAction simplify llm error: %v", err) } + case "custom": + // Lookup action by ID + var action *CustomAction + for i := range s.customActions { + if s.customActions[i].ID == payload.ID { + action = &s.customActions[i] + break + } + } + if action == nil { + return ca, false + } + // Build messages + var sys, user string + if strings.TrimSpace(action.User) != "" { + if strings.TrimSpace(action.System) != "" { + sys = action.System + } else { + sys = s.promptRewriteSystem + } + var diagList string + if len(payload.Diagnostics) > 0 { + var b strings.Builder + for i, dgn := range payload.Diagnostics { + if dgn.Source != "" { + fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) + } else { + fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) + } + } + diagList = b.String() + } + user = renderTemplate(action.User, map[string]string{"selection": payload.Selection, "diagnostics": diagList}) + } else { + // Use rewrite templates with fixed instruction + sys = s.promptRewriteSystem + user = renderTemplate(s.promptRewriteUser, map[string]string{"instruction": action.Instruction, "selection": payload.Selection}) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.chatWithStats(ctx, messages, opts...); err == nil { + if out := stripCodeFences(strings.TrimSpace(text)); out != "" { + edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} + ca.Edit = &edit + return ca, true + } + } else { + logging.Logf("lsp ", "codeAction custom id=%s llm error: %v", action.ID, err) + } } return ca, false } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 97d7de7..e3728c8 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -83,6 +83,9 @@ type Server struct { promptGoTestUser string promptSimplifySystem string promptSimplifyUser string + + // Custom actions configured by user + customActions []CustomAction } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -125,6 +128,20 @@ type ServerOptions struct { PromptGoTestUser string PromptSimplifySystem string PromptSimplifyUser string + + // Custom actions + CustomActions []CustomAction +} + +// CustomAction mirrors user-defined code actions passed from config. +type CustomAction struct { + ID string + Title string + Kind string + Scope string // "selection" | "diagnostics" + Instruction string // if set, use rewrite templates + System string // optional when User is set + User string // if set, use this user template } func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { @@ -209,6 +226,10 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.promptSimplifySystem = opts.PromptSimplifySystem s.promptSimplifyUser = opts.PromptSimplifyUser + if len(opts.CustomActions) > 0 { + s.customActions = append([]CustomAction{}, opts.CustomActions...) + } + // Assign package-level inline trigger chars for free helper functions if s.inlineOpen != "" { inlineOpenChar = s.inlineOpen[0] |
