summaryrefslogtreecommitdiff
path: root/internal/lsp
diff options
context:
space:
mode:
Diffstat (limited to 'internal/lsp')
-rw-r--r--internal/lsp/codeaction_custom_errors_test.go92
-rw-r--r--internal/lsp/codeaction_custom_test.go110
-rw-r--r--internal/lsp/handlers_codeaction.go108
-rw-r--r--internal/lsp/server.go21
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]