diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-06 11:57:45 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-06 11:57:45 +0300 |
| commit | a48079fae6bb19d7c931f275901670cd5839ab5c (patch) | |
| tree | 5788a3e8cac34ffca9d39b0c4b5df720e869b578 /internal/lsp | |
| parent | fb267966f7840df222338f57023273a993a73c9a (diff) | |
chore(version): bump to 0.6.0; configurable prompts via config + testsv0.6.0
Diffstat (limited to 'internal/lsp')
| -rw-r--r-- | internal/lsp/build_prompts_table_test.go | 32 | ||||
| -rw-r--r-- | internal/lsp/chat_prompt_test.go | 36 | ||||
| -rw-r--r-- | internal/lsp/codeaction_prompts_test.go | 102 | ||||
| -rw-r--r-- | internal/lsp/completion_messages_test.go | 16 | ||||
| -rw-r--r-- | internal/lsp/document_test.go | 35 | ||||
| -rw-r--r-- | internal/lsp/handlers_codeaction.go | 89 | ||||
| -rw-r--r-- | internal/lsp/handlers_completion.go | 50 | ||||
| -rw-r--r-- | internal/lsp/handlers_document.go | 6 | ||||
| -rw-r--r-- | internal/lsp/handlers_utils.go | 29 | ||||
| -rw-r--r-- | internal/lsp/helpers_more_test.go | 21 | ||||
| -rw-r--r-- | internal/lsp/provider_native_success_test.go | 31 | ||||
| -rw-r--r-- | internal/lsp/server.go | 74 | ||||
| -rw-r--r-- | internal/lsp/testhelper_capture_llm_test.go | 18 |
13 files changed, 415 insertions, 124 deletions
diff --git a/internal/lsp/build_prompts_table_test.go b/internal/lsp/build_prompts_table_test.go index 7e8e5e7..06a3743 100644 --- a/internal/lsp/build_prompts_table_test.go +++ b/internal/lsp/build_prompts_table_test.go @@ -3,18 +3,22 @@ package lsp import "testing" func TestBuildPrompts_Table(t *testing.T) { - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 5, Character: 7}} - cases := []struct { - name string - inParams bool - }{ - {"generic", false}, - {"in_params", true}, - } - for _, c := range cases { - sys, user := buildPrompts(c.inParams, p, "above", "current", "below", "func ctx") - if sys == "" || user == "" { - t.Fatalf("%s: prompts empty", c.name) - } - } + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 5, Character: 7}} + cases := []struct { + name string + inParams bool + }{ + {"generic", false}, + {"in_params", true}, + } + for _, c := range cases { + s := newTestServer() + msgs := s.buildCompletionMessages(false, false, "", c.inParams, p, "above", "current", "below", "func ctx") + if len(msgs) < 2 || msgs[0].Role != "system" || msgs[1].Role != "user" { + t.Fatalf("%s: unexpected messages", c.name) + } + if msgs[0].Content == "" || msgs[1].Content == "" { + t.Fatalf("%s: prompts empty", c.name) + } + } } diff --git a/internal/lsp/chat_prompt_test.go b/internal/lsp/chat_prompt_test.go new file mode 100644 index 0000000..f0f5446 --- /dev/null +++ b/internal/lsp/chat_prompt_test.go @@ -0,0 +1,36 @@ +package lsp + +import ( + "bytes" + "testing" + "time" +) + +func TestDetectAndHandleChat_UsesConfiguredSystemPrompt(t *testing.T) { + s := newTestServer() + cap := &captureLLM{} + s.llmClient = cap + s.promptChatSystem = "CHAT-SYS" + uri := "file:///chat.txt" + // Avoid nil writer in applyChatEdits + var out bytes.Buffer + s.out = &out + // Line that should trigger chat: ends with '>' and previous char in prefixes + s.setDocument(uri, "help?>\n") + s.detectAndHandleChat(uri) + // Wait briefly for async goroutine to call Chat + for i := 0; i < 20 && len(cap.msgs) == 0; i++ { + time.Sleep(10 * time.Millisecond) + } + if len(cap.msgs) == 0 { + t.Fatalf("expected Chat to be called") + } + if cap.msgs[0].Role != "system" || cap.msgs[0].Content != "CHAT-SYS" { + t.Fatalf("unexpected system msg: %+v", cap.msgs[0]) + } + // Last should be user with prompt without trailing '>' + last := cap.msgs[len(cap.msgs)-1] + if last.Role != "user" || last.Content != "help?" { + t.Fatalf("unexpected last user msg: %+v", last) + } +} diff --git a/internal/lsp/codeaction_prompts_test.go b/internal/lsp/codeaction_prompts_test.go new file mode 100644 index 0000000..6b2ce8c --- /dev/null +++ b/internal/lsp/codeaction_prompts_test.go @@ -0,0 +1,102 @@ +package lsp + +import ( + "encoding/json" + "testing" +) + +func TestResolveCodeAction_UsesRewritePrompts(t *testing.T) { + s := newTestServer() + cap := &captureLLM{} + s.llmClient = cap + s.promptRewriteSystem = "RSYS" + s.promptRewriteUser = "RUSER {{instruction}} {{selection}}" + 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} + _, _ = s.resolveCodeAction(ca) + if len(cap.msgs) < 2 { + t.Fatalf("expected chat messages") + } + if cap.msgs[0].Content != "RSYS" || cap.msgs[1].Role != "user" || cap.msgs[1].Content != "RUSER do it var a" { + t.Fatalf("unexpected rewrite prompts: %#v", cap.msgs) + } +} + +func TestResolveCodeAction_UsesDiagnosticsPrompts(t *testing.T) { + s := newTestServer() + cap := &captureLLM{} + s.llmClient = cap + s.promptDiagnosticsSystem = "DSYS" + s.promptDiagnosticsUser = "DUSER {{diagnostics}} {{selection}}" + 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"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "diagnostics", URI: uri, Range: Range{Start: Position{Line: 1}}, Selection: "var a", Diagnostics: []Diagnostic{{Message: "oops1"}, {Message: "oops2"}}} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: resolve diagnostics", Data: raw} + _, _ = s.resolveCodeAction(ca) + if len(cap.msgs) < 2 { + t.Fatalf("expected chat messages") + } + if cap.msgs[0].Content != "DSYS" || cap.msgs[1].Role != "user" { + t.Fatalf("unexpected diagnostics prompts: %#v", cap.msgs) + } + if got := cap.msgs[1].Content; !(contains(got, "oops1") && contains(got, "oops2") && contains(got, "var a")) { + t.Fatalf("diagnostics/user content mismatch: %q", got) + } +} + +func TestResolveCodeAction_UsesDocumentPrompts(t *testing.T) { + s := newTestServer() + cap := &captureLLM{} + s.llmClient = cap + s.promptDocumentSystem = "DOCSYS" + s.promptDocumentUser = "DOCUSER {{selection}}" + 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"` + Selection string `json:"selection"` + }{Type: "document", URI: uri, Range: Range{Start: Position{Line: 1}}, Selection: "var a"} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: document selection", Data: raw} + _, _ = s.resolveCodeAction(ca) + if len(cap.msgs) < 2 { + t.Fatalf("expected chat messages") + } + if cap.msgs[0].Content != "DOCSYS" || cap.msgs[1].Content != "DOCUSER var a" { + t.Fatalf("unexpected document prompts: %#v", cap.msgs) + } +} + +func TestGenerateGoTest_UsesPrompts(t *testing.T) { + s := newTestServer() + cap := &captureLLM{} + s.llmClient = cap + s.promptGoTestSystem = "GTSYS" + s.promptGoTestUser = "GTUSER {{function}}" + _ = s.generateGoTestFunction("func Add(a,b int) int {return a+b}") + if len(cap.msgs) < 2 { + t.Fatalf("expected chat messages") + } + if cap.msgs[0].Content != "GTSYS" || !contains(cap.msgs[1].Content, "func Add") { + t.Fatalf("unexpected gotest prompts: %#v", cap.msgs) + } +} + diff --git a/internal/lsp/completion_messages_test.go b/internal/lsp/completion_messages_test.go index 28908d5..37d4a8d 100644 --- a/internal/lsp/completion_messages_test.go +++ b/internal/lsp/completion_messages_test.go @@ -58,12 +58,16 @@ 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 TestBuildCompletionMessages_InParams_UsesParamPrompts(t *testing.T) { + s := newTestServer() + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x"}, Position: Position{Line: 0, Character: 5}} + msgs := s.buildCompletionMessages(false, false, "", true, p, "a", "func f(x)", "c", "func f(x)") + if len(msgs) < 2 || msgs[0].Role != "system" || msgs[1].Role != "user" { + t.Fatalf("unexpected messages") + } + if !contains(msgs[0].Content, "function signatures") || !contains(msgs[1].Content, "parameter list") { + t.Fatalf("unexpected in-params prompts: %#v", msgs) + } } func TestPostProcessCompletion_CodeFencesAndDuplicates(t *testing.T) { diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go index 00e4548..c8b6e2e 100644 --- a/internal/lsp/document_test.go +++ b/internal/lsp/document_test.go @@ -9,15 +9,32 @@ import ( ) func newTestServer() *Server { - s := &Server{ - logger: log.New(io.Discard, "", 0), - docs: make(map[string]*document), - inlineOpen: ">", - inlineClose: ">", - chatSuffix: ">", - chatPrefixes: []string{"?", "!", ":", ";"}, - } - // Keep package-level helpers in sync for tests using free functions + s := &Server{ + logger: log.New(io.Discard, "", 0), + docs: make(map[string]*document), + inlineOpen: ">", + inlineClose: ">", + chatSuffix: ">", + chatPrefixes: []string{"?", "!", ":", ";"}, + } + // Default prompt templates (mirror app defaults) + s.promptCompSysParams = "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types." + s.promptCompUserParams = "Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: {{function}}\nCurrent line (cursor at {{char}}): {{current}}" + s.promptCompSysGeneral = "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression)." + s.promptCompUserGeneral = "Provide the next likely code to insert at the cursor.\nFile: {{file}}\nFunction/context: {{function}}\nAbove line: {{above}}\nCurrent line (cursor at character {{char}}): {{current}}\nBelow line: {{below}}\nOnly return the completion snippet." + s.promptCompSysInline = "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only." + s.promptCompExtraHeader = "Additional context:\n{{context}}" + s.promptNativeCompletion = "// Path: {{path}}\n{{before}}" + s.promptChatSystem = "You are a helpful coding assistant. Answer concisely and clearly." + s.promptRewriteSystem = "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable." + s.promptDiagnosticsSystem = "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes." + s.promptDocumentSystem = "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks." + s.promptRewriteUser = "Instruction: {{instruction}}\n\nSelected code to transform:\n{{selection}}" + s.promptDiagnosticsUser = "Diagnostics to resolve (selection only):\n{{diagnostics}}\n\nSelected code:\n{{selection}}" + s.promptDocumentUser = "Add documentation comments to this code:\n{{selection}}" + s.promptGoTestSystem = "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic." + s.promptGoTestUser = "Function under test:\n{{function}}" + // Keep package-level helpers in sync for tests using free functions inlineOpenChar = '>' inlineCloseChar = '>' chatSuffixChar = '>' diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go index 27020a0..762190f 100644 --- a/internal/lsp/handlers_codeaction.go +++ b/internal/lsp/handlers_codeaction.go @@ -98,12 +98,12 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { return ca, false } switch payload.Type { - case "rewrite": - sys := "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable." - user := fmt.Sprintf("Instruction: %s\n\nSelected code to transform:\n%s", payload.Instruction, payload.Selection) - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + case "rewrite": + sys := s.promptRewriteSystem + user := renderTemplate(s.promptRewriteUser, map[string]string{"instruction": payload.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.llmClient.Chat(ctx, messages, opts...); err == nil { if out := stripCodeFences(strings.TrimSpace(text)); out != "" { @@ -114,38 +114,37 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { } else { logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) } - case "diagnostics": - sys := "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes." - var b strings.Builder - b.WriteString("Diagnostics to resolve (selection only):\n") - 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) - } - } - b.WriteString("\nSelected code:\n") - b.WriteString(payload.Selection) - ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}} - opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(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 diagnostics llm error: %v", err) - } - case "document": - sys := "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks." - user := "Add documentation comments to this code:\n" + payload.Selection - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + case "diagnostics": + sys := s.promptDiagnosticsSystem + 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(s.promptDiagnosticsUser, map[string]string{"diagnostics": diagList, "selection": payload.Selection}) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.llmClient.Chat(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 diagnostics llm error: %v", err) + } + case "document": + sys := s.promptDocumentSystem + user := renderTemplate(s.promptDocumentUser, map[string]string{"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.llmClient.Chat(ctx, messages, opts...); err == nil { if out := stripCodeFences(strings.TrimSpace(text)); out != "" { @@ -467,13 +466,13 @@ func findGoFunctionAtLine(lines []string, idx int) (int, int) { // generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable. func (s *Server) generateGoTestFunction(funcCode string) string { - if s.llmClient != nil { - sys := "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic." - user := "Function under test:\n" + funcCode - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() + if s.llmClient != nil { + sys := s.promptGoTestSystem + user := renderTemplate(s.promptGoTestUser, map[string]string{"function": funcCode}) + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { cleaned := strings.TrimSpace(stripCodeFences(out)) if cleaned != "" { diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go index c6b7d3d..0d48bc0 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -225,9 +225,13 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if !ok { return nil, false } - before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) - path := strings.TrimPrefix(p.TextDocument.URI, "file://") - prompt := "// Path: " + path + "\n" + before + before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + path := strings.TrimPrefix(p.TextDocument.URI, "file://") + // Build provider-native prompt from template + prompt := renderTemplate(s.promptNativeCompletion, map[string]string{ + "path": path, + "before": before, + }) lang := "" temp := 0.0 if s.codingTemperature != nil { @@ -336,18 +340,34 @@ func (s *Server) waitForThrottle(ctx context.Context) bool { // buildCompletionMessages constructs the LLM messages for completion. func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message { - sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx) - messages := []llm.Message{ - {Role: "system", Content: sysPrompt}, - {Role: "user", Content: userPrompt}, - } - if hasExtra && extraText != "" { - messages = append(messages, llm.Message{Role: "user", Content: "Additional context:\n" + extraText}) - } - if inlinePrompt { - messages[0].Content = "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only." - } - return messages + // Vars for templates + vars := map[string]string{ + "file": p.TextDocument.URI, + "function": funcCtx, + "above": above, + "current": current, + "below": below, + "char": fmt.Sprintf("%d", p.Position.Character), + } + sys := s.promptCompSysGeneral + userTpl := s.promptCompUserGeneral + if inParams { + sys = s.promptCompSysParams + userTpl = s.promptCompUserParams + } + if inlinePrompt && strings.TrimSpace(s.promptCompSysInline) != "" { + sys = s.promptCompSysInline + } + user := renderTemplate(userTpl, vars) + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + if hasExtra && strings.TrimSpace(extraText) != "" { + extra := renderTemplate(s.promptCompExtraHeader, map[string]string{"context": extraText}) + if strings.TrimSpace(extra) == "" { + extra = extraText + } + messages = append(messages, llm.Message{Role: "user", Content: extra}) + } + return messages } // postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. diff --git a/internal/lsp/handlers_document.go b/internal/lsp/handlers_document.go index 6a90919..26b78c0 100644 --- a/internal/lsp/handlers_document.go +++ b/internal/lsp/handlers_document.go @@ -156,9 +156,9 @@ func (s *Server) detectAndHandleChat(uri string) { go func(prompt string, remove int) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() - sys := "You are a helpful coding assistant. Answer concisely and clearly." - // Build short conversation history from the document above this line - history := s.buildChatHistory(uri, lineIdx, prompt) + sys := s.promptChatSystem + // Build short conversation history from the document above this line + history := s.buildChatHistory(uri, lineIdx, prompt) msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...) opts := s.llmRequestOpts() logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel()) diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go index 30a21a5..eafd058 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -2,12 +2,11 @@ package lsp import ( - "fmt" - "strings" - "time" + "strings" + "time" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) // Configurable inline trigger characters (default to '>') used by free helpers below. @@ -73,15 +72,17 @@ func inParamList(current string, cursor int) bool { return open >= 0 && cursor > open && (close == -1 || cursor <= close) } -func buildPrompts(inParams bool, p CompletionParams, above, current, below, funcCtx string) (string, string) { - if inParams { - sys := "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types." - user := fmt.Sprintf("Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: %s\nCurrent line (cursor at %d): %s", funcCtx, p.Position.Character, current) - return sys, user - } - sys := "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression)." - user := fmt.Sprintf("Provide the next likely code to insert at the cursor.\nFile: %s\nFunction/context: %s\nAbove line: %s\nCurrent line (cursor at character %d): %s\nBelow line: %s\nOnly return the completion snippet.", p.TextDocument.URI, funcCtx, above, p.Position.Character, current, below) - return sys, user +// renderTemplate performs simple {{var}} replacement in a template string. +func renderTemplate(t string, vars map[string]string) string { + if t == "" { + return t + } + out := t + for k, v := range vars { + placeholder := "{{" + k + "}}" + out = strings.ReplaceAll(out, placeholder, v) + } + return out } func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { diff --git a/internal/lsp/helpers_more_test.go b/internal/lsp/helpers_more_test.go index a0b0c26..1bd56d0 100644 --- a/internal/lsp/helpers_more_test.go +++ b/internal/lsp/helpers_more_test.go @@ -101,16 +101,17 @@ func TestCollectPromptRemovalEdits_MultiLine(t *testing.T) { } } -func TestInParamListAndBuildPrompts(t *testing.T) { - cur := "func add(a int, b string) int" - if !inParamList(cur, 12) { - t.Fatalf("expected in param list") - } - p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 0, Character: 5}} - sys, user := buildPrompts(false, p, "above", "current", "below", "func add") - if sys == "" || user == "" { - t.Fatalf("prompts empty") - } +func TestInParamListAndBuildCompletionMessages(t *testing.T) { + cur := "func add(a int, b string) int" + if !inParamList(cur, 12) { + t.Fatalf("expected in param list") + } + s := newTestServer() + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///x.go"}, Position: Position{Line: 0, Character: 5}} + msgs := s.buildCompletionMessages(false, false, "", false, p, "above", "current", "below", "func add") + if len(msgs) < 2 || msgs[0].Content == "" || msgs[1].Content == "" { + t.Fatalf("messages empty") + } } func TestLabelForCompletion(t *testing.T) { diff --git a/internal/lsp/provider_native_success_test.go b/internal/lsp/provider_native_success_test.go index dd1abcd..bfcb0b6 100644 --- a/internal/lsp/provider_native_success_test.go +++ b/internal/lsp/provider_native_success_test.go @@ -60,3 +60,34 @@ func TestProviderNativeCompletion_IndentWithDoubleOpen(t *testing.T) { t.Fatalf("expected indentation applied, got %q", got) } } + +type fakeCompleterCapture struct{ lastPrompt string } + +func (fakeCompleterCapture) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { return "", nil } +func (fakeCompleterCapture) Name() string { return "prov" } +func (fakeCompleterCapture) DefaultModel() string { return "m" } +func (f *fakeCompleterCapture) CodeCompletion(_ context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) { + f.lastPrompt = prompt + return []string{"SUG"}, nil +} + +func TestProviderNativeCompletion_UsesPromptTemplate(t *testing.T) { + s := newTestServer() + cap := &fakeCompleterCapture{} + s.llmClient = cap + s.promptNativeCompletion = "NATIVE {{path}} {{before}}" + uri := "file:///x.go" + s.setDocument(uri, "AAA\nBBB\nCCC") + current := "fmt." + // Cursor at line 1, char 1 -> before should be "AAA\nB" + p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: uri}, Position: Position{Line: 1, Character: 1}} + if _, ok := s.tryProviderNativeCompletion(current, p, "", "", "func f(){}", "doc", false, "", false); !ok { + t.Fatalf("expected provider-native path") + } + if cap.lastPrompt == "" { + t.Fatalf("expected captured prompt") + } + if cap.lastPrompt != "NATIVE /x.go AAA\nB" { + t.Fatalf("unexpected prompt: %q", cap.lastPrompt) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index fa4467b..caaac29 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -58,7 +58,29 @@ type Server struct { inlineOpen string inlineClose string chatSuffix string - chatPrefixes []string + chatPrefixes []string + + // Prompt templates + // Completion + promptCompSysGeneral string + promptCompSysParams string + promptCompSysInline string + promptCompUserGeneral string + promptCompUserParams string + promptCompExtraHeader string + // Provider-native code completion + promptNativeCompletion string + // In-editor chat + promptChatSystem string + // Code actions + promptRewriteSystem string + promptDiagnosticsSystem string + promptDocumentSystem string + promptRewriteUser string + promptDiagnosticsUser string + promptDocumentUser string + promptGoTestSystem string + promptGoTestUser string } // ServerOptions collects configuration for NewServer to avoid long parameter lists. @@ -79,8 +101,26 @@ type ServerOptions struct { // Inline/chat triggers InlineOpen string InlineClose string - ChatSuffix string - ChatPrefixes []string + ChatSuffix string + ChatPrefixes []string + + // Prompt templates + PromptCompSysGeneral string + PromptCompSysParams string + PromptCompSysInline string + PromptCompUserGeneral string + PromptCompUserParams string + PromptCompExtraHeader string + PromptNativeCompletion string + PromptChatSystem string + PromptRewriteSystem string + PromptDiagnosticsSystem string + PromptDocumentSystem string + PromptRewriteUser string + PromptDiagnosticsUser string + PromptDocumentUser string + PromptGoTestSystem string + PromptGoTestUser string } func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { @@ -139,11 +179,29 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) } else { s.chatSuffix = opts.ChatSuffix } - if len(opts.ChatPrefixes) == 0 { - s.chatPrefixes = []string{"?", "!", ":", ";"} - } else { - s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) - } + if len(opts.ChatPrefixes) == 0 { + s.chatPrefixes = []string{"?", "!", ":", ";"} + } else { + s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) + } + + // Prompts + s.promptCompSysGeneral = opts.PromptCompSysGeneral + s.promptCompSysParams = opts.PromptCompSysParams + s.promptCompSysInline = opts.PromptCompSysInline + s.promptCompUserGeneral = opts.PromptCompUserGeneral + s.promptCompUserParams = opts.PromptCompUserParams + s.promptCompExtraHeader = opts.PromptCompExtraHeader + s.promptNativeCompletion = opts.PromptNativeCompletion + s.promptChatSystem = opts.PromptChatSystem + s.promptRewriteSystem = opts.PromptRewriteSystem + s.promptDiagnosticsSystem = opts.PromptDiagnosticsSystem + s.promptDocumentSystem = opts.PromptDocumentSystem + s.promptRewriteUser = opts.PromptRewriteUser + s.promptDiagnosticsUser = opts.PromptDiagnosticsUser + s.promptDocumentUser = opts.PromptDocumentUser + s.promptGoTestSystem = opts.PromptGoTestSystem + s.promptGoTestUser = opts.PromptGoTestUser // Assign package-level inline trigger chars for free helper functions if s.inlineOpen != "" { diff --git a/internal/lsp/testhelper_capture_llm_test.go b/internal/lsp/testhelper_capture_llm_test.go new file mode 100644 index 0000000..3274141 --- /dev/null +++ b/internal/lsp/testhelper_capture_llm_test.go @@ -0,0 +1,18 @@ +package lsp + +import ( + "context" + + "codeberg.org/snonux/hexai/internal/llm" +) + +// captureLLM captures messages sent to Chat for assertions. +type captureLLM struct{ msgs []llm.Message } + +func (c *captureLLM) Chat(_ context.Context, m []llm.Message, _ ...llm.RequestOption) (string, error) { + c.msgs = append([]llm.Message{}, m...) + return "OK", nil +} +func (*captureLLM) Name() string { return "cap" } +func (*captureLLM) DefaultModel() string { return "m" } + |
