From 5e966f50111adf6e2cb2683fe588f6fe033fa931 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 13:18:21 +0300 Subject: fix unit test coverage --- internal/lsp/build_prompts_table_test.go | 36 +++--- internal/lsp/chat_prompt_test.go | 58 ++++----- internal/lsp/codeaction_prompts_test.go | 171 +++++++++++++-------------- internal/lsp/completion_messages_test.go | 19 +-- internal/lsp/document_test.go | 52 ++++---- internal/lsp/handlers.go | 112 +++++++++--------- internal/lsp/handlers_codeaction.go | 88 +++++++------- internal/lsp/handlers_completion.go | 70 +++++------ internal/lsp/handlers_document.go | 6 +- internal/lsp/handlers_utils.go | 39 +----- internal/lsp/helpers_more_test.go | 20 ++-- internal/lsp/provider_native_success_test.go | 44 +++---- internal/lsp/server.go | 126 ++++++++++---------- internal/lsp/testhelper_capture_llm_test.go | 9 +- 14 files changed, 409 insertions(+), 441 deletions(-) (limited to 'internal/lsp') diff --git a/internal/lsp/build_prompts_table_test.go b/internal/lsp/build_prompts_table_test.go index 06a3743..bc4f031 100644 --- a/internal/lsp/build_prompts_table_test.go +++ b/internal/lsp/build_prompts_table_test.go @@ -3,22 +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 { - 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) - } - } + 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 index f0f5446..25767ab 100644 --- a/internal/lsp/chat_prompt_test.go +++ b/internal/lsp/chat_prompt_test.go @@ -1,36 +1,36 @@ package lsp import ( - "bytes" - "testing" - "time" + "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) - } + 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 index 6b2ce8c..bbfad10 100644 --- a/internal/lsp/codeaction_prompts_test.go +++ b/internal/lsp/codeaction_prompts_test.go @@ -1,102 +1,101 @@ package lsp import ( - "encoding/json" - "testing" + "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) - } + 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) - } + 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) - } + 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) - } + 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 37d4a8d..20aac69 100644 --- a/internal/lsp/completion_messages_test.go +++ b/internal/lsp/completion_messages_test.go @@ -59,15 +59,15 @@ func TestBuildDocString_Contents(t *testing.T) { } 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) - } + 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) { @@ -87,6 +87,7 @@ func TestPostProcessCompletion_CodeFencesAndDuplicates(t *testing.T) { func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (stringIndex(s, sub) >= 0))) } + func stringIndex(s, sub string) int { return len([]rune(s[:])) - len([]rune(s[:])) + (func() int { return intIndex(s, sub) })() } diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go index c8b6e2e..652d867 100644 --- a/internal/lsp/document_test.go +++ b/internal/lsp/document_test.go @@ -9,32 +9,32 @@ import ( ) func newTestServer() *Server { - 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 + 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.go b/internal/lsp/handlers.go index e85065b..420a694 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -2,9 +2,9 @@ package lsp import ( - "encoding/json" - "fmt" - "strings" + "encoding/json" + "fmt" + "strings" ) func (s *Server) handle(req Request) { @@ -26,14 +26,14 @@ func (s *Server) handle(req Request) { // a line comment (//, #, --). Returns the instruction string and the selection // text cleaned of the matched instruction marker or comment. func instructionFromSelection(sel string) (string, string) { - lines := splitLines(sel) - for idx, line := range lines { - if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { - lines[idx] = cleaned - return instr, strings.Join(lines, "\n") - } - } - return "", sel + lines := splitLines(sel) + for idx, line := range lines { + if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" { + lines[idx] = cleaned + return instr, strings.Join(lines, "\n") + } + } + return "", sel } // findFirstInstructionInLine returns the earliest instruction marker on the @@ -46,51 +46,51 @@ func instructionFromSelection(sel string) (string, string) { // - # text // - -- text func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) { - type cand struct { - start, end int - text string - } - cands := []cand{} - if t, l, r, ok := findStrictInlineTag(line); ok { - cands = append(cands, cand{start: l, end: r, text: t}) - } - if i := strings.Index(line, "/*"); i >= 0 { - if j := strings.Index(line[i+2:], "*/"); j >= 0 { - start := i - end := i + 2 + j + 2 - text := strings.TrimSpace(line[i+2 : i+2+j]) - cands = append(cands, cand{start: start, end: end, text: text}) - } - } - if i := strings.Index(line, ""); j >= 0 { - start := i - end := i + 4 + j + 3 - text := strings.TrimSpace(line[i+4 : i+4+j]) - cands = append(cands, cand{start: start, end: end, text: text}) - } - } - if i := strings.Index(line, "//"); i >= 0 { - cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) - } - if i := strings.Index(line, "#"); i >= 0 { - cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) - } - if i := strings.Index(line, "--"); i >= 0 { - cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) - } - if len(cands) == 0 { - return "", line, false - } - // pick earliest start index - best := cands[0] - for _, c := range cands[1:] { - if c.start >= 0 && (best.start < 0 || c.start < best.start) { - best = c - } - } - cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") - return best.text, cleaned, true + type cand struct { + start, end int + text string + } + cands := []cand{} + if t, l, r, ok := findStrictInlineTag(line); ok { + cands = append(cands, cand{start: l, end: r, text: t}) + } + if i := strings.Index(line, "/*"); i >= 0 { + if j := strings.Index(line[i+2:], "*/"); j >= 0 { + start := i + end := i + 2 + j + 2 + text := strings.TrimSpace(line[i+2 : i+2+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + } + } + if i := strings.Index(line, ""); j >= 0 { + start := i + end := i + 4 + j + 3 + text := strings.TrimSpace(line[i+4 : i+4+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + } + } + if i := strings.Index(line, "//"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + } + if i := strings.Index(line, "#"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) + } + if i := strings.Index(line, "--"); i >= 0 { + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + } + if len(cands) == 0 { + return "", line, false + } + // pick earliest start index + best := cands[0] + for _, c := range cands[1:] { + if c.start >= 0 && (best.start < 0 || c.start < best.start) { + best = c + } + } + cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + return best.text, cleaned, true } // diagnosticsInRange parses the CodeAction context and returns diagnostics diff --git a/internal/lsp/handlers_codeaction.go b/internal/lsp/handlers_codeaction.go index 762190f..17e92bc 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 := 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}} + 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,37 +114,37 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { } else { logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) } - 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}} + 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 != "" { @@ -466,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 := 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 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 0d48bc0..06c44fb 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -225,13 +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://") - // Build provider-native prompt from template - prompt := renderTemplate(s.promptNativeCompletion, map[string]string{ - "path": path, - "before": 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 { @@ -340,34 +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 { - // 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 + // 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 26b78c0..f3648b2 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 := s.promptChatSystem - // 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 eafd058..015e9c1 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -7,6 +7,7 @@ import ( "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/textutil" ) // Configurable inline trigger characters (default to '>') used by free helpers below. @@ -73,17 +74,7 @@ func inParamList(current string, cursor int) bool { } // 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 renderTemplate(t string, vars map[string]string) string { return textutil.RenderTemplate(t, vars) } func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) { if inParams { @@ -301,31 +292,7 @@ func isIdentBoundary(ch byte) bool { } // stripCodeFences removes surrounding Markdown code fences from a model response. -func stripCodeFences(s string) string { - t := strings.TrimSpace(s) - if t == "" { - return t - } - lines := splitLines(t) - start := 0 - for start < len(lines) && strings.TrimSpace(lines[start]) == "" { - start++ - } - end := len(lines) - 1 - for end >= 0 && strings.TrimSpace(lines[end]) == "" { - end-- - } - if start >= len(lines) || end < 0 || start > end { - return t - } - first := strings.TrimSpace(lines[start]) - last := strings.TrimSpace(lines[end]) - if strings.HasPrefix(first, "```") && last == "```" && end > start { - inner := strings.Join(lines[start+1:end], "\n") - return inner - } - return t -} +func stripCodeFences(s string) string { return textutil.StripCodeFences(s) } // stripInlineCodeSpan returns the contents of the first inline backtick code span if present. func stripInlineCodeSpan(s string) string { diff --git a/internal/lsp/helpers_more_test.go b/internal/lsp/helpers_more_test.go index 1bd56d0..160f91c 100644 --- a/internal/lsp/helpers_more_test.go +++ b/internal/lsp/helpers_more_test.go @@ -102,16 +102,16 @@ func TestCollectPromptRemovalEdits_MultiLine(t *testing.T) { } 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") - } + 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 bfcb0b6..ac227be 100644 --- a/internal/lsp/provider_native_success_test.go +++ b/internal/lsp/provider_native_success_test.go @@ -63,31 +63,33 @@ func TestProviderNativeCompletion_IndentWithDoubleOpen(t *testing.T) { type fakeCompleterCapture struct{ lastPrompt string } -func (fakeCompleterCapture) Chat(context.Context, []llm.Message, ...llm.RequestOption) (string, error) { return "", nil } +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 + 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) - } + 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 caaac29..1c3e676 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -58,29 +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 + // 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. @@ -101,26 +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 + // 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 { @@ -179,29 +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 + // 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 index 3274141..2e63ca7 100644 --- a/internal/lsp/testhelper_capture_llm_test.go +++ b/internal/lsp/testhelper_capture_llm_test.go @@ -1,18 +1,17 @@ package lsp import ( - "context" + "context" - "codeberg.org/snonux/hexai/internal/llm" + "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 + c.msgs = append([]llm.Message{}, m...) + return "OK", nil } func (*captureLLM) Name() string { return "cap" } func (*captureLLM) DefaultModel() string { return "m" } - -- cgit v1.2.3