From fb267966f7840df222338f57023273a993a73c9a Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Sat, 6 Sep 2025 11:14:27 +0300 Subject: use TOML not JSON for configuration --- docs/coverage.html | 2838 ++++++++++++++++++++++++++++------------------------ 1 file changed, 1508 insertions(+), 1330 deletions(-) (limited to 'docs/coverage.html') diff --git a/docs/coverage.html b/docs/coverage.html index d940029..49b89df 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -59,7 +59,7 @@ - + @@ -83,19 +83,19 @@ - + - + - + - + @@ -178,117 +178,118 @@ func main() { } - @@ -788,6 +853,7 @@ package llm import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -797,7 +863,6 @@ import ( "strings" "time" - "encoding/base64" appver "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/logging" ) @@ -946,10 +1011,14 @@ func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64 } func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil { return nil, err } - for k, v := range headers { req.Header.Set(k, v) } - return c.httpClient.Do(req) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + return c.httpClient.Do(req) } func handleCopilotNon2xx(resp *http.Response, start time.Time) error { @@ -978,55 +1047,73 @@ func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatRespons // --- Copilot session token management --- type ghCopilotTokenResp struct { - Token string `json:"token"` + Token string `json:"token"` } func (c *copilotClient) ensureSession(ctx context.Context) error { - // If token valid for >60s, reuse - if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) { - return nil - } - if strings.TrimSpace(c.apiKey) == "" { - return errors.New("missing Copilot API key") - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) - if err != nil { return err } - req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "hexai/"+appver.Version) - resp, err := c.httpClient.Do(req) - if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("copilot token http error: %d", resp.StatusCode) - } - var out ghCopilotTokenResp - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return err } - if strings.TrimSpace(out.Token) == "" { return errors.New("empty copilot session token") } - // Parse JWT exp - exp := parseJWTExp(out.Token) - if exp.IsZero() { exp = time.Now().Add(10 * time.Minute) } - c.sessionToken = out.Token - c.tokenExpiry = exp - return nil + // If token valid for >60s, reuse + if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) { + return nil + } + if strings.TrimSpace(c.apiKey) == "" { + return errors.New("missing Copilot API key") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "hexai/"+appver.Version) + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("copilot token http error: %d", resp.StatusCode) + } + var out ghCopilotTokenResp + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return err + } + if strings.TrimSpace(out.Token) == "" { + return errors.New("empty copilot session token") + } + // Parse JWT exp + exp := parseJWTExp(out.Token) + if exp.IsZero() { + exp = time.Now().Add(10 * time.Minute) + } + c.sessionToken = out.Token + c.tokenExpiry = exp + return nil } var jwtExpRe = regexp.MustCompile(`"exp"\s*:\s*([0-9]+)`) // fallback if we can't base64 decode func parseJWTExp(token string) time.Time { - parts := strings.Split(token, ".") - if len(parts) < 2 { return time.Time{} } - b, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 { - if n, err2 := parseInt64(m[1]); err2 == nil { return time.Unix(n, 0) } + parts := strings.Split(token, ".") + if len(parts) < 2 { + return time.Time{} + } + b, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 { + if n, err2 := parseInt64(m[1]); err2 == nil { + return time.Unix(n, 0) + } + } + return time.Time{} + } + var payload struct { + Exp int64 `json:"exp"` } - return time.Time{} - } - var payload struct{ Exp int64 `json:"exp"` } - _ = json.Unmarshal(b, &payload) - if payload.Exp == 0 { return time.Time{} } - return time.Unix(payload.Exp, 0) + _ = json.Unmarshal(b, &payload) + if payload.Exp == 0 { + return time.Time{} + } + return time.Unix(payload.Exp, 0) } func parseInt64(s string) (int64, error) { var n int64; _, err := fmt.Sscan(s, &n); return n, err } @@ -1034,99 +1121,120 @@ func parseInt64(s string) (int64, error) { var n in // --- Copilot headers --- func (c *copilotClient) headersChat() map[string]string { - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "application/json", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GitHubCopilotChat/0.8.0", - "Editor-Plugin-Version": "copilot-chat/0.8.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "conversation-panel", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h + _ = c.ensureSession(context.Background()) + h := map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + "Authorization": "Bearer " + c.sessionToken, + "User-Agent": "GitHubCopilotChat/0.8.0", + "Editor-Plugin-Version": "copilot-chat/0.8.0", + "Editor-Version": "vscode/1.85.1", + "Openai-Intent": "conversation-panel", + "Openai-Organization": "github-copilot", + "VScode-MachineId": randHex(64), + "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + } + return h } func (c *copilotClient) headersGhost() map[string]string { - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "*/*", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GithubCopilot/1.155.0", - "Editor-Plugin-Version": "copilot/1.155.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "copilot-ghost", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h + _ = c.ensureSession(context.Background()) + h := map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "Accept": "*/*", + "Authorization": "Bearer " + c.sessionToken, + "User-Agent": "GithubCopilot/1.155.0", + "Editor-Plugin-Version": "copilot/1.155.0", + "Editor-Version": "vscode/1.85.1", + "Openai-Intent": "copilot-ghost", + "Openai-Organization": "github-copilot", + "VScode-MachineId": randHex(64), + "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + } + return h } func randHex(n int) string { - const hex = "0123456789abcdef" - b := make([]byte, n) - for i := range b { - b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] - } - return string(b) + const hex = "0123456789abcdef" + b := make([]byte, n) + for i := range b { + b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] + } + return string(b) } // --- Codex-style code completion --- // CodeCompletion implements CodeCompleter; returns up to n suggestions. func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) { - if strings.TrimSpace(c.apiKey) == "" { return nil, errors.New("missing Copilot API key") } - if err := c.ensureSession(ctx); err != nil { return nil, err } - if n <= 0 { n = 1 } - maxTokens := 500 - body := map[string]any{ - "extra": map[string]any{ - "language": language, - "next_indent": 0, - "prompt_tokens": 500, - "suffix_tokens": 400, - "trim_by_indentation": true, - }, - "max_tokens": maxTokens, - "n": n, - "nwo": "hexai", - "prompt": prompt, - "stop": []string{"\n\n"}, - "stream": true, - "suffix": suffix, - "temperature": temperature, - "top_p": 1, - } - buf, _ := json.Marshal(body) - url := "https://copilot-proxy.githubusercontent.com/v1/engines/copilot-codex/completions" - resp, err := c.postJSON(ctx, url, buf, c.headersGhost()) - if err != nil { return nil, err } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode) - } - // Read all and parse lines that start with "data: " accumulating by index - raw, _ := io.ReadAll(resp.Body) - byIndex := make(map[int]string) - lines := strings.Split(string(raw), "\n") - for _, ln := range lines { - if !strings.HasPrefix(ln, "data: ") { continue } - var evt struct{ Choices []struct{ Index int `json:"index"`; Text string `json:"text"` } `json:"choices"` } - if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil { continue } - for _, ch := range evt.Choices { byIndex[ch.Index] += ch.Text } - } - out := make([]string, 0, len(byIndex)) - for i := 0; i < n; i++ { - if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" { out = append(out, s) } - } - return out, nil + if strings.TrimSpace(c.apiKey) == "" { + return nil, errors.New("missing Copilot API key") + } + if err := c.ensureSession(ctx); err != nil { + return nil, err + } + if n <= 0 { + n = 1 + } + maxTokens := 500 + body := map[string]any{ + "extra": map[string]any{ + "language": language, + "next_indent": 0, + "prompt_tokens": 500, + "suffix_tokens": 400, + "trim_by_indentation": true, + }, + "max_tokens": maxTokens, + "n": n, + "nwo": "hexai", + "prompt": prompt, + "stop": []string{"\n\n"}, + "stream": true, + "suffix": suffix, + "temperature": temperature, + "top_p": 1, + } + buf, _ := json.Marshal(body) + url := "https://copilot-proxy.githubusercontent.com/v1/engines/copilot-codex/completions" + resp, err := c.postJSON(ctx, url, buf, c.headersGhost()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode) + } + // Read all and parse lines that start with "data: " accumulating by index + raw, _ := io.ReadAll(resp.Body) + byIndex := make(map[int]string) + lines := strings.Split(string(raw), "\n") + for _, ln := range lines { + if !strings.HasPrefix(ln, "data: ") { + continue + } + var evt struct { + Choices []struct { + Index int `json:"index"` + Text string `json:"text"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil { + continue + } + for _, ch := range evt.Choices { + byIndex[ch.Index] += ch.Text + } + } + out := make([]string, 0, len(byIndex)) + for i := 0; i < n; i++ { + if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" { + out = append(out, s) + } + } + return out, nil } // newLineDataReader wraps a streaming body and exposes a JSON decoder that @@ -1685,20 +1793,20 @@ type Client interface { // token-by-token streaming responses. Callers can type-assert to Streamer and // fall back to Client.Chat when not implemented. type Streamer interface { - // ChatStream sends chat messages and invokes onDelta with incremental text - // chunks as they are produced by the model. Implementations should call - // onDelta with empty strings sparingly (prefer only non-empty chunks). - ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error + // ChatStream sends chat messages and invokes onDelta with incremental text + // chunks as they are produced by the model. Implementations should call + // onDelta with empty strings sparingly (prefer only non-empty chunks). + ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error } // CodeCompleter is an optional interface for providers that support a // prompt/suffix code-completion API (e.g., Copilot Codex endpoint). Clients // can type-assert to this and prefer it over chat when available. type CodeCompleter interface { - // CodeCompletion requests up to n suggestions given a left-hand prompt and - // right-hand suffix around the cursor. Language is advisory and may be - // ignored. Temperature applies when provider supports it. - CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) + // CodeCompletion requests up to n suggestions given a left-hand prompt and + // right-hand suffix around the cursor. Language is advisory and may be + // ignored. Temperature applies when provider supports it. + CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) } // Options for a request. Providers may ignore unsupported fields. @@ -1714,65 +1822,65 @@ type RequestOption func(*Options) func WithModel(model string) RequestOption { return func(o *Options) { o.Model = model } } func WithTemperature(t float64) RequestOption { return func(o *Options) { o.Temperature = t } } -func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } +func WithMaxTokens(n int) RequestOption { return func(o *Options) { o.MaxTokens = n } } func WithStop(stop ...string) RequestOption { return func(o *Options) { o.Stop = append([]string{}, stop...) } } // Config defines provider configuration read from the Hexai config file. type Config struct { - Provider string - // OpenAI options - OpenAIBaseURL string - OpenAIModel string - OpenAITemperature *float64 - // Ollama options - OllamaBaseURL string - OllamaModel string - OllamaTemperature *float64 - // Copilot options - CopilotBaseURL string - CopilotModel string - CopilotTemperature *float64 + Provider string + // OpenAI options + OpenAIBaseURL string + OpenAIModel string + OpenAITemperature *float64 + // Ollama options + OllamaBaseURL string + OllamaModel string + OllamaTemperature *float64 + // Copilot options + CopilotBaseURL string + CopilotModel string + CopilotTemperature *float64 } // NewFromConfig creates an LLM client using only the supplied configuration. // The OpenAI API key is supplied separately and may be read from the environment // by the caller; other environment-based configuration is not used. -func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { - p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" { - p = "openai" - } - switch p { - case "openai": - if strings.TrimSpace(openAIAPIKey) == "" { - return nil, errors.New("missing OPENAI_API_KEY for provider openai") - } - // Set coding-friendly default temperature if none provided - if cfg.OpenAITemperature == nil { - t := 0.2 - cfg.OpenAITemperature = &t - } - return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil - case "ollama": - if cfg.OllamaTemperature == nil { - t := 0.2 - cfg.OllamaTemperature = &t - } - return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil - case "copilot": - if strings.TrimSpace(copilotAPIKey) == "" { - return nil, errors.New("missing COPILOT_API_KEY for provider copilot") - } - if cfg.CopilotTemperature == nil { - t := 0.2 - cfg.CopilotTemperature = &t - } - return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil - default: - return nil, errors.New("unknown LLM provider: " + p) - } +func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) { + p := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if p == "" { + p = "openai" + } + switch p { + case "openai": + if strings.TrimSpace(openAIAPIKey) == "" { + return nil, errors.New("missing OPENAI_API_KEY for provider openai") + } + // Set coding-friendly default temperature if none provided + if cfg.OpenAITemperature == nil { + t := 0.2 + cfg.OpenAITemperature = &t + } + return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil + case "ollama": + if cfg.OllamaTemperature == nil { + t := 0.2 + cfg.OllamaTemperature = &t + } + return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil + case "copilot": + if strings.TrimSpace(copilotAPIKey) == "" { + return nil, errors.New("missing COPILOT_API_KEY for provider copilot") + } + if cfg.CopilotTemperature == nil { + t := 0.2 + cfg.CopilotTemperature = &t + } + return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil + default: + return nil, errors.New("unknown LLM provider: " + p) + } } @@ -1800,7 +1908,8 @@ func NewChatLogger(provider string) ChatLogger { func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens int, stop []string, messages []struct { Role string Content string -}) { +}, +) { chatOrStream := "chat" if stream { chatOrStream = "stream" @@ -1824,13 +1933,13 @@ import ( // ANSI color utilities shared across Hexai. const ( - AnsiBgBlack = "\x1b[40m" - AnsiGrey = "\x1b[90m" - AnsiCyan = "\x1b[36m" - AnsiGreen = "\x1b[32m" - AnsiYellow = "\x1b[33m" - AnsiRed = "\x1b[31m" - AnsiReset = "\x1b[0m" + AnsiBgBlack = "\x1b[40m" + AnsiGrey = "\x1b[90m" + AnsiCyan = "\x1b[36m" + AnsiGreen = "\x1b[32m" + AnsiYellow = "\x1b[33m" + AnsiRed = "\x1b[31m" + AnsiReset = "\x1b[0m" ) // AnsiBase is the default style: black background + grey foreground. @@ -1843,11 +1952,11 @@ var std *log.Logger func Bind(l *log.Logger) { std = l } // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) { +func Logf(prefix, format string, args ...any) { if std == nil { return } - msg := fmt.Sprintf(format, args...) + msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset) } @@ -1864,7 +1973,7 @@ func PreviewForLog(s string) string { if len(s) <= logPreviewLimit { return s } - return s[:logPreviewLimit] + "…" + return s[:logPreviewLimit] + "…" } return s } @@ -1874,8 +1983,9 @@ func PreviewForLog(s string) string { package lsp import ( - "codeberg.org/snonux/hexai/internal/logging" "strings" + + "codeberg.org/snonux/hexai/internal/logging" ) // buildAdditionalContext builds extra context messages based on the configured mode. @@ -1969,7 +2079,7 @@ type document struct { lines []string } -func (s *Server) setDocument(uri, text string) { +func (s *Server) setDocument(uri, text string) { s.mu.Lock() defer s.mu.Unlock() s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} @@ -1981,20 +2091,20 @@ func (s *Server) deleteDocument(uri string) { delete(s.docs, uri) } -func (s *Server) markActivity() { +func (s *Server) markActivity() { s.mu.Lock() s.lastInput = time.Now() s.mu.Unlock() } -func (s *Server) getDocument(uri string) *document { +func (s *Server) getDocument(uri string) *document { s.mu.RLock() defer s.mu.RUnlock() return s.docs[uri] } // splitLines splits the input string into lines, normalizing line endings to '\n'. -func splitLines(sx string) []string { +func splitLines(sx string) []string { sx = strings.ReplaceAll(sx, "\r\n", "\n") return strings.Split(sx, "\n") } @@ -2004,28 +2114,28 @@ func (s *Server) lineContext(uri string, pos Position) (above, current, below, f if d == nil || len(d.lines) == 0 { return "", "", "", "" } - idx := pos.Line + idx := pos.Line if idx < 0 { idx = 0 } - if idx >= len(d.lines) { + if idx >= len(d.lines) { idx = len(d.lines) - 1 } - current = d.lines[idx] - if idx-1 >= 0 { + current = d.lines[idx] + if idx-1 >= 0 { above = d.lines[idx-1] } - if idx+1 < len(d.lines) { + if idx+1 < len(d.lines) { below = d.lines[idx+1] } - for i := idx; i >= 0; i-- { + for i := idx; i >= 0; i-- { line := strings.TrimSpace(d.lines[i]) - if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) { + if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) { funcCtx = line break } } - return + return above, current, below, funcCtx } // isDefiningNewFunction returns true when the cursor appears to be within @@ -2060,7 +2170,7 @@ func (s *Server) isDefiningNewFunction(uri string, pos Position) bool // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body - for i := sigStart; i <= idx; i++ { + for i := sigStart; i <= idx; i++ { line := d.lines[i] brace := strings.Index(line, "{") if brace >= 0 { @@ -2077,28 +2187,28 @@ func (s *Server) isDefiningNewFunction(uri string, pos Position) bool { - for _, n := range needles { - if strings.Contains(s, n) { + for _, n := range needles { + if strings.Contains(s, n) { return true } } return false } -func trimLen(s string) string { +func trimLen(s string) string { s = strings.TrimSpace(s) if len(s) > 200 { return s[:200] + "…" } - return s + return s } -func firstLine(s string) string { +func firstLine(s string) string { s = strings.ReplaceAll(s, "\r\n", "\n") - if idx := strings.IndexByte(s, '\n'); idx >= 0 { + if idx := strings.IndexByte(s, '\n'); idx >= 0 { return s[:idx] } - return s + return s } @@ -2155,7 +2265,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b text string } cands := []cand{} - if t, l, r, ok := findStrictSemicolonTag(line); ok { + 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 { @@ -2187,13 +2297,13 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b return "", line, false } // pick earliest start index - best := cands[0] + 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") + cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") return best.text, cleaned, true } @@ -2292,33 +2402,33 @@ func (s *Server) reply(id json.RawMessage, result any, err *RespError) { +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string { // Normalize left-of-cursor by trimming trailing spaces/tabs idx := p.Position.Character if idx > len(current) { idx = len(current) } - left := strings.TrimRight(current[:idx], " \t") + left := strings.TrimRight(current[:idx], " \t") right := "" if idx < len(current) { right = current[idx:] } - prov := "" + prov := "" model := "" - if s.llmClient != nil { + if s.llmClient != nil { prov = s.llmClient.Name() model = s.llmClient.DefaultModel() } - temp := "" + temp := "" if s.codingTemperature != nil { temp = fmt.Sprintf("%.3f", *s.codingTemperature) } - extra := "" + extra := "" if hasExtra { extra = strings.TrimSpace(extraText) } // Compose a key from essential context parts - return strings.Join([]string{ + return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -2347,13 +2457,13 @@ func (s *Server) completionCacheGet(key string) (string, bool) { +func (s *Server) completionCachePut(key, value string) { s.mu.Lock() defer s.mu.Unlock() - if s.compCache == nil { + if s.compCache == nil { s.compCache = make(map[string]string) } - if _, exists := s.compCache[key]; !exists { + if _, exists := s.compCache[key]; !exists { s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 { @@ -2362,7 +2472,7 @@ func (s *Server) completionCachePut(key, value string) - return + return } // update existing and mark most-recent s.compCache[key] = value @@ -2389,30 +2499,30 @@ func (s *Server) compCacheTouchLocked(key string) { // by typing one of our configured trigger characters. It checks the LSP // CompletionContext if provided and also falls back to inspecting the character // immediately to the left of the cursor. -func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { +func (s *Server) isTriggerEvent(p CompletionParams, current string) bool { // 1) Inspect LSP completion context if present - if p.Context != nil { + if p.Context != nil { var ctx struct { TriggerKind int `json:"triggerKind"` TriggerCharacter string `json:"triggerCharacter,omitempty"` } - if raw, ok := p.Context.(json.RawMessage); ok { + if raw, ok := p.Context.(json.RawMessage); ok { _ = json.Unmarshal(raw, &ctx) } else { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) } - // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), - // do not treat as a trigger source. - if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) { - return false - } - // TriggerKind 1 = Invoked (manual). Always allow manual invoke. - if ctx.TriggerKind == 1 { - return true - } + // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), + // do not treat as a trigger source. + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) { + return false + } + // TriggerKind 1 = Invoked (manual). Always allow manual invoke. + if ctx.TriggerKind == 1 { + return true + } // TriggerKind 2 is TriggerCharacter per LSP spec - if ctx.TriggerKind == 2 { + if ctx.TriggerKind == 2 { if ctx.TriggerCharacter != "" { for _, c := range s.triggerChars { if c == ctx.TriggerCharacter { @@ -2422,37 +2532,37 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool return false } // No character provided but reported as TriggerCharacter; be conservative - return false + return false } // For TriggerForIncomplete (3), require manual char check below } // 2) Fallback: check the character immediately prior to cursor - idx := p.Position.Character + idx := p.Position.Character if idx <= 0 || idx > len(current) { return false } - // Bare double-open should not trigger via fallback char either (only when configured) - if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) { - return false - } - ch := string(current[idx-1]) - for _, c := range s.triggerChars { - if c == ch { + // Bare double-open should not trigger via fallback char either (only when configured) + if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) { + return false + } + ch := string(current[idx-1]) + for _, c := range s.triggerChars { + if c == ch { return true } } - return false + return false } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem { +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem { te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) label := labelForCompletion(cleaned, filter) detail := "Hexai LLM completion" - if s.llmClient != nil { + if s.llmClient != nil { detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() } - return []CompletionItem{{ + return []CompletionItem{{ Label: label, Kind: 1, Detail: detail, @@ -2553,15 +2663,16 @@ func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem { @@ -2579,24 +2690,24 @@ func (s *Server) handleCodeAction(req Request) { } return } - sel := extractRangeText(d, p.Range) - - actions := make([]CodeAction, 0, 4) - if a := s.buildRewriteCodeAction(p, sel); a != nil { - actions = append(actions, *a) - } - if a := s.buildDiagnosticsCodeAction(p, sel); a != nil { - actions = append(actions, *a) - } - if a := s.buildDocumentCodeAction(p, sel); a != nil { - actions = append(actions, *a) - } - if a := s.buildGoUnitTestCodeAction(p); a != nil { - actions = append(actions, *a) - } - if len(req.ID) != 0 { - s.reply(req.ID, actions, nil) - } + sel := extractRangeText(d, p.Range) + + actions := make([]CodeAction, 0, 4) + if a := s.buildRewriteCodeAction(p, sel); a != nil { + actions = append(actions, *a) + } + if a := s.buildDiagnosticsCodeAction(p, sel); a != nil { + actions = append(actions, *a) + } + if a := s.buildDocumentCodeAction(p, sel); a != nil { + actions = append(actions, *a) + } + if a := s.buildGoUnitTestCodeAction(p); a != nil { + actions = append(actions, *a) + } + if len(req.ID) != 0 { + s.reply(req.ID, actions, nil) + } } func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction { @@ -2647,8 +2758,8 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { return ca, false } - switch payload.Type { - case "rewrite": + 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) @@ -2664,7 +2775,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) } - case "diagnostics": + 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") @@ -2690,34 +2801,34 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) { 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}} - 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 document llm error: %v", err) - } - case "go_test": - if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok { - ca.Edit = &edit - // After edit is applied, ask client to jump to new test function - ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} - // Also send a server-initiated showDocument shortly after resolve to cover - // clients that do not execute commands from code actions. - s.deferShowDocument(jumpURI, jumpRange) - return ca, true - } - } - return ca, false + 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}} + 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 document llm error: %v", err) + } + case "go_test": + if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok { + ca.Edit = &edit + // After edit is applied, ask client to jump to new test function + ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} + // Also send a server-initiated showDocument shortly after resolve to cover + // clients that do not execute commands from code actions. + s.deferShowDocument(jumpURI, jumpRange) + return ca, true + } + } + return ca, false } func (s *Server) handleCodeActionResolve(req Request) { @@ -2795,256 +2906,284 @@ func greaterPos(p, q Position) bool { // --- Go unit test code action --- func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction { - uri := p.TextDocument.URI - if uri == "" || !strings.HasSuffix(strings.TrimPrefix(uri, "file://"), ".go") { - return nil - } - // Skip if already a _test.go file - if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") { - return nil - } - // Heuristic: only offer when a function context is found above the cursor - _, _, _, funcCtx := s.lineContext(uri, p.Range.Start) - if !strings.Contains(funcCtx, "func ") { - return nil - } - payload := struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - }{Type: "go_test", URI: uri, Range: p.Range} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: implement unit test", Kind: "quickfix", Data: raw} - return &ca + uri := p.TextDocument.URI + if uri == "" || !strings.HasSuffix(strings.TrimPrefix(uri, "file://"), ".go") { + return nil + } + // Skip if already a _test.go file + if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") { + return nil + } + // Heuristic: only offer when a function context is found above the cursor + _, _, _, funcCtx := s.lineContext(uri, p.Range.Start) + if !strings.Contains(funcCtx, "func ") { + return nil + } + payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + }{Type: "go_test", URI: uri, Range: p.Range} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: implement unit test", Kind: "quickfix", Data: raw} + return &ca } // buildDocumentCodeAction offers to document the selected code by injecting comments. func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction { - if s.llmClient == nil { - return nil - } - if strings.TrimSpace(sel) == "" { - return nil - } - payload := struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - Selection string `json:"selection"` - }{Type: "document", URI: p.TextDocument.URI, Range: p.Range, Selection: sel} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: document code", Kind: "refactor.rewrite", Data: raw} - return &ca + if s.llmClient == nil { + return nil + } + if strings.TrimSpace(sel) == "" { + return nil + } + payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "document", URI: p.TextDocument.URI, Range: p.Range, Selection: sel} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: document code", Kind: "refactor.rewrite", Data: raw} + return &ca } func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, Range, bool) { - path := strings.TrimPrefix(uri, "file://") - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { - return WorkspaceEdit{}, "", Range{}, false - } - // Load source text - _, lines := s.loadFileText(uri) - if len(lines) == 0 { - return WorkspaceEdit{}, "", Range{}, false - } - pkg := parseGoPackageName(lines) - fnStart, fnEnd := findGoFunctionAtLine(lines, pos.Line) - if fnStart < 0 || fnEnd < fnStart { - return WorkspaceEdit{}, "", Range{}, false - } - funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n") - testFunc := s.generateGoTestFunction(funcCode) - if strings.TrimSpace(testFunc) == "" { - return WorkspaceEdit{}, "", Range{}, false - } - // Determine test file target - testPath := strings.TrimSuffix(path, ".go") + "_test.go" - testURI := "file://" + testPath - - // If test file exists, append test at EOF; otherwise, create a new file with package+import - if fileExists(testPath) { - // Build an insertion at end of file - _, tLines := s.loadFileText(testURI) - // Fallback when not open and cannot read: still insert at line 0 - lineIdx := 0 - col := 0 - if len(tLines) > 0 { - lineIdx = len(tLines) - 1 - col = len(tLines[lineIdx]) + path := strings.TrimPrefix(uri, "file://") + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { + return WorkspaceEdit{}, "", Range{}, false + } + // Load source text + _, lines := s.loadFileText(uri) + if len(lines) == 0 { + return WorkspaceEdit{}, "", Range{}, false + } + pkg := parseGoPackageName(lines) + fnStart, fnEnd := findGoFunctionAtLine(lines, pos.Line) + if fnStart < 0 || fnEnd < fnStart { + return WorkspaceEdit{}, "", Range{}, false + } + funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n") + testFunc := s.generateGoTestFunction(funcCode) + if strings.TrimSpace(testFunc) == "" { + return WorkspaceEdit{}, "", Range{}, false + } + // Determine test file target + testPath := strings.TrimSuffix(path, ".go") + "_test.go" + testURI := "file://" + testPath + + // If test file exists, append test at EOF; otherwise, create a new file with package+import + if fileExists(testPath) { + // Build an insertion at end of file + _, tLines := s.loadFileText(testURI) + // Fallback when not open and cannot read: still insert at line 0 + lineIdx := 0 + col := 0 + if len(tLines) > 0 { + lineIdx = len(tLines) - 1 + col = len(tLines[lineIdx]) + } + var b strings.Builder + // Ensure at least two newlines before the new test + if len(tLines) == 0 || (len(tLines) > 0 && !strings.HasSuffix(strings.Join(tLines, "\n"), "\n\n")) { + b.WriteString("\n\n") + } + b.WriteString(testFunc) + insert := b.String() + edit := TextEdit{Range: Range{Start: Position{Line: lineIdx, Character: col}, End: Position{Line: lineIdx, Character: col}}, NewText: insert} + we := WorkspaceEdit{Changes: map[string][]TextEdit{testURI: {edit}}} + // Compute jump range start + // Count how many prefix newlines added before the test function + prefixNL := 0 + if strings.HasPrefix(insert, "\n\n") { + prefixNL = 2 + } + startLine := lineIdx + prefixNL + // If we inserted with two newlines and last line wasn't blank, first newline moves to next line + if prefixNL > 0 { + startLine = lineIdx + prefixNL + } + jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} + return we, testURI, jump, true + } + // Create new file content + var content strings.Builder + if pkg == "" { + pkg = filepath.Base(filepath.Dir(path)) + } + content.WriteString("package ") + content.WriteString(pkg) + content.WriteString("\n\n") + content.WriteString("import (\n\t\"testing\"\n)\n\n") + content.WriteString(testFunc) + full := content.String() + // Use documentChanges with create + full content insert + create := CreateFile{Kind: "create", URI: testURI} + tde := TextDocumentEdit{TextDocument: VersionedTextDocumentIdentifier{URI: testURI}, Edits: []TextEdit{{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 0}}, NewText: full}}} + we := WorkspaceEdit{DocumentChanges: []any{create, tde}} + // Find start line of first test function + // Count lines before the substring "func Test" + pre := content.String() + idx := strings.Index(pre, "func Test") + startLine := 0 + if idx > 0 { + before := pre[:idx] + startLine = strings.Count(before, "\n") } - var b strings.Builder - // Ensure at least two newlines before the new test - if len(tLines) == 0 || (len(tLines) > 0 && !strings.HasSuffix(strings.Join(tLines, "\n"), "\n\n")) { - b.WriteString("\n\n") - } - b.WriteString(testFunc) - insert := b.String() - edit := TextEdit{Range: Range{Start: Position{Line: lineIdx, Character: col}, End: Position{Line: lineIdx, Character: col}}, NewText: insert} - we := WorkspaceEdit{Changes: map[string][]TextEdit{testURI: {edit}}} - // Compute jump range start - // Count how many prefix newlines added before the test function - prefixNL := 0 - if strings.HasPrefix(insert, "\n\n") { prefixNL = 2 } - startLine := lineIdx + prefixNL - // If we inserted with two newlines and last line wasn't blank, first newline moves to next line - if prefixNL > 0 { startLine = lineIdx + prefixNL } jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} return we, testURI, jump, true - } - // Create new file content - var content strings.Builder - if pkg == "" { pkg = filepath.Base(filepath.Dir(path)) } - content.WriteString("package ") - content.WriteString(pkg) - content.WriteString("\n\n") - content.WriteString("import (\n\t\"testing\"\n)\n\n") - content.WriteString(testFunc) - full := content.String() - // Use documentChanges with create + full content insert - create := CreateFile{Kind: "create", URI: testURI} - tde := TextDocumentEdit{TextDocument: VersionedTextDocumentIdentifier{URI: testURI}, Edits: []TextEdit{{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 0}}, NewText: full}}} - we := WorkspaceEdit{DocumentChanges: []any{create, tde}} - // Find start line of first test function - // Count lines before the substring "func Test" - pre := content.String() - idx := strings.Index(pre, "func Test") - startLine := 0 - if idx > 0 { - before := pre[:idx] - startLine = strings.Count(before, "\n") - } - jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} - return we, testURI, jump, true } // loadFileText returns the file content and lines. It prefers the open document; otherwise reads from disk. func (s *Server) loadFileText(uri string) (string, []string) { - if d := s.getDocument(uri); d != nil { - return d.text, append([]string{}, d.lines...) - } - path := strings.TrimPrefix(uri, "file://") - b, err := os.ReadFile(path) - if err != nil { - return "", nil - } - txt := string(b) - return txt, splitLines(txt) + if d := s.getDocument(uri); d != nil { + return d.text, append([]string{}, d.lines...) + } + path := strings.TrimPrefix(uri, "file://") + b, err := os.ReadFile(path) + if err != nil { + return "", nil + } + txt := string(b) + return txt, splitLines(txt) } func fileExists(path string) bool { - if _, err := os.Stat(path); err == nil { - return true - } - return false + if _, err := os.Stat(path); err == nil { + return true + } + return false } // parseGoPackageName returns the package name from file lines, or empty if not found. func parseGoPackageName(lines []string) string { - for _, ln := range lines { - t := strings.TrimSpace(ln) - if strings.HasPrefix(t, "package ") { - name := strings.TrimSpace(strings.TrimPrefix(t, "package ")) - // strip inline comments - if i := strings.Index(name, " "); i >= 0 { name = name[:i] } - if i := strings.Index(name, "\t"); i >= 0 { name = name[:i] } - if i := strings.Index(name, "//"); i >= 0 { name = strings.TrimSpace(name[:i]) } - return name + for _, ln := range lines { + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "package ") { + name := strings.TrimSpace(strings.TrimPrefix(t, "package ")) + // strip inline comments + if i := strings.Index(name, " "); i >= 0 { + name = name[:i] + } + if i := strings.Index(name, "\t"); i >= 0 { + name = name[:i] + } + if i := strings.Index(name, "//"); i >= 0 { + name = strings.TrimSpace(name[:i]) + } + return name + } } - } - return "" + return "" } // findGoFunctionAtLine finds the function enclosing or preceding line idx. Returns start and end line indexes. func findGoFunctionAtLine(lines []string, idx int) (int, int) { - if idx < 0 { idx = 0 } - if idx >= len(lines) { idx = len(lines)-1 } - // find signature start - start := -1 - for i := idx; i >= 0; i-- { - if strings.Contains(lines[i], "func ") { - start = i - break - } - if strings.Contains(lines[i], "}") { - break + if idx < 0 { + idx = 0 + } + if idx >= len(lines) { + idx = len(lines) - 1 + } + // find signature start + start := -1 + for i := idx; i >= 0; i-- { + if strings.Contains(lines[i], "func ") { + start = i + break + } + if strings.Contains(lines[i], "}") { + break + } } - } - if start == -1 { return -1, -1 } - // find first '{' - depth := 0 - seenOpen := false - for i := start; i < len(lines); i++ { - ln := lines[i] - for j := 0; j < len(ln); j++ { - switch ln[j] { - case '{': - depth++ - seenOpen = true - case '}': - if depth > 0 { depth-- } - if seenOpen && depth == 0 { - return start, i - } - } + if start == -1 { + return -1, -1 + } + // find first '{' + depth := 0 + seenOpen := false + for i := start; i < len(lines); i++ { + ln := lines[i] + for j := 0; j < len(ln); j++ { + switch ln[j] { + case '{': + depth++ + seenOpen = true + case '}': + if depth > 0 { + depth-- + } + if seenOpen && depth == 0 { + return start, i + } + } + } } - } - // if never saw '{', assume single-line prototype; return that line - if !seenOpen { - return start, start - } - return start, -1 + // if never saw '{', assume single-line prototype; return that line + if !seenOpen { + return start, start + } + return start, -1 } // 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 out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { - cleaned := strings.TrimSpace(stripCodeFences(out)) - if cleaned != "" { return cleaned } - } else { - logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + 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 out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil { + cleaned := strings.TrimSpace(stripCodeFences(out)) + if cleaned != "" { + return cleaned + } + } else { + logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + } + } + // Fallback stub + name := deriveGoFuncName(funcCode) + if name == "" { + name = "Function" } - } - // Fallback stub - name := deriveGoFuncName(funcCode) - if name == "" { name = "Function" } - return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name) + return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name) } // deriveGoFuncName extracts function or method name from code. func deriveGoFuncName(code string) string { - // look for line starting with func - line := firstLine(code) - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "func ") { return "" } - rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) - // method receiver - if strings.HasPrefix(rest, "(") { - // find ")" - if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) { - rest = strings.TrimSpace(rest[i+1:]) - } - } - // now rest should start with Name( - if i := strings.Index(rest, "("); i > 0 { - return strings.TrimSpace(rest[:i]) - } - return "" + // look for line starting with func + line := firstLine(code) + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "func ") { + return "" + } + rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) + // method receiver + if strings.HasPrefix(rest, "(") { + // find ")" + if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) { + rest = strings.TrimSpace(rest[i+1:]) + } + } + // now rest should start with Name( + if i := strings.Index(rest, "("); i > 0 { + return strings.TrimSpace(rest[:i]) + } + return "" } func exportName(name string) string { - if name == "" { return name } - r := []rune(name) - if r[0] >= 'a' && r[0] <= 'z' { - r[0] = r[0] - ('a' - 'A') - } - return string(r) + if name == "" { + return name + } + r := []rune(name) + if r[0] >= 'a' && r[0] <= 'z' { + r[0] = r[0] - ('a' - 'A') + } + return string(r) } @@ -3052,13 +3191,14 @@ func exportName(name string) string { package lsp import ( - "context" - "encoding/json" - "fmt" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "strings" - "time" + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) func (s *Server) handleCompletion(req Request) { @@ -3120,8 +3260,8 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below, } func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) { - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() inlinePrompt := lineHasInlinePrompt(current) if !inlinePrompt && !s.isTriggerEvent(p, current) { @@ -3143,20 +3283,20 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true } - if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) { - logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) - return []CompletionItem{}, true - } + if isBareDoubleOpen(current) || isBareDoubleOpen(below) { + logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) + return []CompletionItem{}, true + } if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) { logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return []CompletionItem{}, true } - // Provider-native path - if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok { - return items, true - } + // Provider-native path + if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok { + return items, true + } // Chat path messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx) @@ -3170,12 +3310,12 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun if s.codingTemperature != nil { opts = append(opts, llm.WithTemperature(*s.codingTemperature)) } - // Debounce and throttle before making the LLM call - s.waitForDebounce(ctx) - if !s.waitForThrottle(ctx) { - return nil, false - } - logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) + // Debounce and throttle before making the LLM call + s.waitForDebounce(ctx) + if !s.waitForThrottle(ctx) { + return nil, false + } + logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) text, err := s.llmClient.Chat(ctx, messages, opts...) if err != nil { @@ -3212,20 +3352,24 @@ func parseManualInvoke(ctx any) bool { } // shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. -func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { - t := strings.TrimRight(current, " \t") - if s.chatSuffix == "" { return false } - if strings.HasSuffix(t, s.chatSuffix) { - if len(t) < len(s.chatSuffix)+1 { return false } - prev := string(t[len(t)-len(s.chatSuffix)-1]) - for _, pf := range s.chatPrefixes { - if prev == pf { - logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) - return true - } +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool { + t := strings.TrimRight(current, " \t") + if s.chatSuffix == "" { + return false + } + if strings.HasSuffix(t, s.chatSuffix) { + if len(t) < len(s.chatSuffix)+1 { + return false + } + prev := string(t[len(t)-len(s.chatSuffix)-1]) + for _, pf := range s.chatPrefixes { + if prev == pf { + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + } + } } - } - return false + return false } // prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. @@ -3265,12 +3409,12 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp } // tryProviderNativeCompletion attempts provider-native completion and returns items when successful. -func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { +func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) { cc, ok := s.llmClient.(llm.CodeCompleter) if !ok { return nil, false } - before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) path := strings.TrimPrefix(p.TextDocument.URI, "file://") prompt := "// Path: " + path + "\n" + before lang := "" @@ -3278,34 +3422,34 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if s.codingTemperature != nil { temp = *s.codingTemperature } - prov := "" - if s.llmClient != nil { + prov := "" + if s.llmClient != nil { prov = s.llmClient.Name() } - logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) - ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel2() - - // Debounce and throttle prior to provider-native call - s.waitForDebounce(ctx2) - if !s.waitForThrottle(ctx2) { - return nil, false - } - suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) - if err == nil && len(suggestions) > 0 { + logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) + ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel2() + + // Debounce and throttle prior to provider-native call + s.waitForDebounce(ctx2) + if !s.waitForThrottle(ctx2) { + return nil, false + } + suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) + if err == nil && len(suggestions) > 0 { cleaned := strings.TrimSpace(suggestions[0]) - if cleaned != "" { + if cleaned != "" { cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - if cleaned != "" { + if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) } - if cleaned != "" && hasDoubleSemicolonTrigger(current) { + if cleaned != "" && hasDoubleOpenTrigger(current) { indent := leadingIndent(current) - if indent != "" { + if indent != "" { cleaned = applyIndent(indent, cleaned) } } - if strings.TrimSpace(cleaned) != "" { + if strings.TrimSpace(cleaned) != "" { key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) s.completionCachePut(key, cleaned) return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true @@ -3319,64 +3463,64 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, // waitForDebounce sleeps until there has been no input activity for at least // completionDebounce. If debounce is zero or ctx is done, it returns promptly. -func (s *Server) waitForDebounce(ctx context.Context) { - d := s.completionDebounce - if d <= 0 { - return - } - for { - s.mu.RLock() - last := s.lastInput - s.mu.RUnlock() - if last.IsZero() { - return - } - since := time.Since(last) - if since >= d { - return - } - rem := d - since - timer := time.NewTimer(rem) - select { - case <-ctx.Done(): - timer.Stop() - return - case <-timer.C: - // loop and re-evaluate in case input occurred during sleep +func (s *Server) waitForDebounce(ctx context.Context) { + d := s.completionDebounce + if d <= 0 { + return + } + for { + s.mu.RLock() + last := s.lastInput + s.mu.RUnlock() + if last.IsZero() { + return + } + since := time.Since(last) + if since >= d { + return + } + rem := d - since + timer := time.NewTimer(rem) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + // loop and re-evaluate in case input occurred during sleep + } } - } } // waitForThrottle enforces a minimum spacing between LLM calls. Returns false // if the context is canceled while waiting. -func (s *Server) waitForThrottle(ctx context.Context) bool { - interval := s.throttleInterval - if interval <= 0 { - return true - } - var wait time.Duration - for { - s.mu.Lock() - next := s.lastLLMCall.Add(interval) - now := time.Now() - if now.Before(next) { - wait = next.Sub(now) - s.mu.Unlock() - timer := time.NewTimer(wait) - select { - case <-ctx.Done(): - timer.Stop() - return false - case <-timer.C: - // try again to set the next call time - continue - } +func (s *Server) waitForThrottle(ctx context.Context) bool { + interval := s.throttleInterval + if interval <= 0 { + return true + } + var wait time.Duration + for { + s.mu.Lock() + next := s.lastLLMCall.Add(interval) + now := time.Now() + if now.Before(next) { + wait = next.Sub(now) + s.mu.Unlock() + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return false + case <-timer.C: + // try again to set the next call time + continue + } + } + // we are allowed to proceed now; record this call as the latest + s.lastLLMCall = now + s.mu.Unlock() + return true } - // we are allowed to proceed now; record this call as the latest - s.lastLLMCall = now - s.mu.Unlock() - return true - } } // buildCompletionMessages constructs the LLM messages for completion. @@ -3409,7 +3553,7 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current if cleaned != "" { cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) } - if cleaned != "" && hasDoubleSemicolonTrigger(currentLine) { + if cleaned != "" && hasDoubleOpenTrigger(currentLine) { if indent := leadingIndent(currentLine); indent != "" { cleaned = applyIndent(indent, cleaned) } @@ -3422,18 +3566,21 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current package lsp import ( - "context" - "encoding/json" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "strings" - "time" + "context" + "encoding/json" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) // Package-level chat trigger vars for helpers without Server receiver. // NewServer assigns these from configuration on startup. -var chatSuffixChar byte = '>' -var chatPrefixSingles = []string{"?", "!", ":", ";"} +var ( + chatSuffixChar byte = '>' + chatPrefixSingles = []string{"?", "!", ":", ";"} +) func (s *Server) handleDidOpen(req Request) { var p DidOpenTextDocumentParams @@ -3466,33 +3613,33 @@ func (s *Server) handleDidClose(req Request) { // docBeforeAfter returns the full document text split at the given position. // The returned strings are the text before the cursor (inclusive of anything // left of the position) and the text after the cursor. -func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { d := s.getDocument(uri) - if d == nil { + if d == nil { return "", "" } // Clamp indices - line := pos.Line + line := pos.Line if line < 0 { line = 0 } - if line >= len(d.lines) { + if line >= len(d.lines) { line = len(d.lines) - 1 } - col := pos.Character + col := pos.Character if col < 0 { col = 0 } - if col > len(d.lines[line]) { + if col > len(d.lines[line]) { col = len(d.lines[line]) } // Build before - var b strings.Builder - for i := 0; i < line; i++ { + var b strings.Builder + for i := 0; i < line; i++ { b.WriteString(d.lines[i]) b.WriteByte('\n') } - b.WriteString(d.lines[line][:col]) + b.WriteString(d.lines[line][:col]) before := b.String() // Build after var a strings.Builder @@ -3501,7 +3648,7 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) - return before, a.String() + return before, a.String() } // --- in-editor chat (";C ...") --- @@ -3509,61 +3656,68 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) { +func (s *Server) detectAndHandleChat(uri string) { if s.llmClient == nil { return } - d := s.getDocument(uri) + d := s.getDocument(uri) if d == nil || len(d.lines) == 0 { return } - for i, raw := range d.lines { + for i, raw := range d.lines { // Find last non-space character index j := len(raw) - 1 - for j >= 0 { + for j >= 0 { if raw[j] == ' ' || raw[j] == '\t' { j-- continue } - break + break + } + if j < 0 { + continue + } + // Check suffix/prefix according to configuration + if s.chatSuffix == "" { + continue + } + // Last non-space must equal suffix + if string(raw[j]) != s.chatSuffix { + continue + } + // Require at least one char before suffix and that char must be in chatPrefixes + if j < 1 { + continue + } + prev := string(raw[j-1]) + isTrigger := false + for _, pfx := range s.chatPrefixes { + if prev == pfx { + isTrigger = true + break + } + } + if !isTrigger { + continue } - if j < 0 { - continue - } - // Check suffix/prefix according to configuration - if s.chatSuffix == "" { - continue - } - // Last non-space must equal suffix - if string(raw[j]) != s.chatSuffix { - continue - } - // Require at least one char before suffix and that char must be in chatPrefixes - if j < 1 { continue } - prev := string(raw[j-1]) - isTrigger := false - for _, pfx := range s.chatPrefixes { - if prev == pfx { isTrigger = true; break } - } - if !isTrigger { continue } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { + k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" { k++ } - if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { + if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") { continue } // Derive prompt by removing only the trailing '>' - removeCount := len(s.chatSuffix) + removeCount := len(s.chatSuffix) base := raw[:j+1-removeCount] - prompt := strings.TrimSpace(base) + prompt := strings.TrimSpace(base) if prompt == "" { continue } - lineIdx := i + lineIdx := i lastIdx := j - go func(prompt string, remove int) { + 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." @@ -3577,26 +3731,26 @@ func (s *Server) detectAndHandleChat(uri string) { logging.Logf("lsp ", "chat llm error: %v", err) return } - out := strings.TrimSpace(stripCodeFences(text)) + out := strings.TrimSpace(stripCodeFences(text)) if out == "" { return } - s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) + s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out) }(prompt, removeCount) // Only handle one per change tick to avoid flooding - break + break } } // applyChatEdits removes the triggering punctuation at end of the line and // inserts two newlines followed by a new line with the response prefixed. -func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { +func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) { d := s.getDocument(uri) if d == nil { return } // 1) Delete the trailing punctuation (1 or 2 chars) - delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} + delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} // 2) Insert two newlines and the response at end-of-line, then one extra blank line insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} @@ -3612,12 +3766,12 @@ func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, remov // buildChatHistory walks upwards from the current line to collect the most recent // Q/A pairs in the in-editor transcript. Returns messages ending with current prompt. -func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message { d := s.getDocument(uri) if d == nil { return []llm.Message{{Role: "user", Content: currentPrompt}} } - type pair struct{ q, a string } + type pair struct{ q, a string } pairs := []pair{} i := lineIdx - 1 for i >= 0 && len(pairs) < 3 { @@ -3651,7 +3805,7 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i-- } - msgs := make([]llm.Message, 0, len(pairs)*2+1) + msgs := make([]llm.Message, 0, len(pairs)*2+1) for _, p := range pairs { if strings.TrimSpace(p.q) != "" { msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) @@ -3660,51 +3814,47 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) } } - msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) return msgs } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func stripTrailingTrigger(sx string) string { - s := strings.TrimRight(sx, " \t") - if len(s) == 0 { - return sx - } - // Configurable suffix removal when preceded by configured prefixes - if len(s) >= 2 && s[len(s)-1] == chatSuffixChar { - prev := string(s[len(s)-2]) - for _, pf := range chatPrefixSingles { - if prev == pf { - return strings.TrimRight(s[:len(s)-1], " \t") - } +func stripTrailingTrigger(sx string) string { + s := strings.TrimRight(sx, " \t") + if len(s) == 0 { + return sx + } + // Configurable suffix removal when preceded by configured prefixes + if len(s) >= 2 && s[len(s)-1] == chatSuffixChar { + prev := string(s[len(s)-2]) + for _, pf := range chatPrefixSingles { + if prev == pf { + return strings.TrimRight(s[:len(s)-1], " \t") + } + } + } + // Legacy: remove one trailing punctuation (?, !, :) to build history nicely + last := s[len(s)-1] + switch last { + case '?', '!', ':': + return strings.TrimRight(s[:len(s)-1], " \t") + default: + return sx } - } - // Legacy: inline cleanup for old semicolon form ";;" - if strings.HasSuffix(s, ";;") { - return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") - } - // Legacy: remove one trailing punctuation (?, !, :) to build history nicely - last := s[len(s)-1] - switch last { - case '?', '!', ':': - return strings.TrimRight(s[:len(s)-1], " \t") - default: - return sx - } } // clientApplyEdit sends a workspace/applyEdit request to the client. -func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { - params := ApplyWorkspaceEditParams{Label: label, Edit: edit} - id := s.nextReqID() - req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} - b, _ := json.Marshal(params) - req.Params = b - s.writeMessage(req) +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) { + params := ApplyWorkspaceEditParams{Label: label, Edit: edit} + id := s.nextReqID() + req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} + b, _ := json.Marshal(params) + req.Params = b + s.writeMessage(req) } // nextReqID returns a unique json.RawMessage id for server-initiated requests. -func (s *Server) nextReqID() json.RawMessage { +func (s *Server) nextReqID() json.RawMessage { s.mu.Lock() s.nextID++ idNum := s.nextID @@ -3715,29 +3865,29 @@ func (s *Server) nextReqID() json.RawMessage { // clientShowDocument asks the client to open/focus a document and select a range. func (s *Server) clientShowDocument(uri string, sel *Range) { - var params struct { - URI string `json:"uri"` - External bool `json:"external,omitempty"` - TakeFocus bool `json:"takeFocus,omitempty"` - Selection *Range `json:"selection,omitempty"` - } - params.URI = uri - params.TakeFocus = true - params.Selection = sel - id := s.nextReqID() - req := Request{JSONRPC: "2.0", ID: id, Method: "window/showDocument"} - b, _ := json.Marshal(params) - req.Params = b - s.writeMessage(req) + var params struct { + URI string `json:"uri"` + External bool `json:"external,omitempty"` + TakeFocus bool `json:"takeFocus,omitempty"` + Selection *Range `json:"selection,omitempty"` + } + params.URI = uri + params.TakeFocus = true + params.Selection = sel + id := s.nextReqID() + req := Request{JSONRPC: "2.0", ID: id, Method: "window/showDocument"} + b, _ := json.Marshal(params) + req.Params = b + s.writeMessage(req) } // deferShowDocument schedules a showDocument after a short delay to allow the client // time to apply any pending edits (e.g., create the file before focusing it). func (s *Server) deferShowDocument(uri string, sel Range) { - go func() { - time.Sleep(120 * time.Millisecond) - s.clientShowDocument(uri, &sel) - }() + go func() { + time.Sleep(120 * time.Millisecond) + s.clientShowDocument(uri, &sel) + }() } @@ -3745,46 +3895,46 @@ func (s *Server) deferShowDocument(uri string, sel Range) { - var p ExecuteCommandParams - if err := json.Unmarshal(req.Params, &p); err != nil { - s.reply(req.ID, nil, nil) - return - } - switch p.Command { - case "hexai.showDocument": - if len(p.Arguments) >= 2 { - uri, _ := p.Arguments[0].(string) - var r Range - // Convert second arg to Range via re-marshal to be robust across clients - if b, err := json.Marshal(p.Arguments[1]); err == nil { - _ = json.Unmarshal(b, &r) - } - if uri != "" { - s.clientShowDocument(uri, &r) - } + var p ExecuteCommandParams + if err := json.Unmarshal(req.Params, &p); err != nil { + s.reply(req.ID, nil, nil) + return + } + switch p.Command { + case "hexai.showDocument": + if len(p.Arguments) >= 2 { + uri, _ := p.Arguments[0].(string) + var r Range + // Convert second arg to Range via re-marshal to be robust across clients + if b, err := json.Marshal(p.Arguments[1]); err == nil { + _ = json.Unmarshal(b, &r) + } + if uri != "" { + s.clientShowDocument(uri, &r) + } + } + s.reply(req.ID, nil, nil) + return + default: + // Unknown command; no-op + s.reply(req.ID, nil, nil) + return } - s.reply(req.ID, nil, nil) - return - default: - // Unknown command; no-op - s.reply(req.ID, nil, nil) - return - } } - @@ -4316,15 +4469,16 @@ func collectSemicolonMarkers(line string, lineNum int) []TextEdit { + LogContext bool + MaxTokens int + ContextMode string + WindowLines int + MaxContextTokens int + + Client llm.Client + TriggerCharacters []string + CodingTemperature *float64 + ManualInvokeMinPrefix int + CompletionDebounceMs int + CompletionThrottleMs int + + // Inline/chat triggers + InlineOpen string + InlineClose string + ChatSuffix string + ChatPrefixes []string +} + +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext} maxTokens := opts.MaxTokens - if maxTokens <= 0 { + if maxTokens <= 0 { maxTokens = 500 } - s.maxTokens = maxTokens + s.maxTokens = maxTokens contextMode := opts.ContextMode - if contextMode == "" { + if contextMode == "" { contextMode = "file-on-new-func" } - windowLines := opts.WindowLines - if windowLines <= 0 { + windowLines := opts.WindowLines + if windowLines <= 0 { windowLines = 120 } - maxContextTokens := opts.MaxContextTokens - if maxContextTokens <= 0 { + maxContextTokens := opts.MaxContextTokens + if maxContextTokens <= 0 { maxContextTokens = 2000 } - s.contextMode = contextMode + s.contextMode = contextMode s.windowLines = windowLines s.maxContextTokens = maxContextTokens s.startTime = time.Now() s.llmClient = opts.Client - if len(opts.TriggerCharacters) == 0 { + if len(opts.TriggerCharacters) == 0 { // Defaults (no space to avoid auto-trigger after whitespace) s.triggerChars = []string{".", ":", "/", "_", ")", "{"} } else { s.triggerChars = append([]string{}, opts.TriggerCharacters...) } - s.codingTemperature = opts.CodingTemperature - s.compCache = make(map[string]string) - s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix - if opts.CompletionDebounceMs > 0 { - s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond - } - if opts.CompletionThrottleMs > 0 { - s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond - } - // Trigger character config (with sane defaults if missing) - if strings.TrimSpace(opts.InlineOpen) == "" { s.inlineOpen = ">" } else { s.inlineOpen = opts.InlineOpen } - if strings.TrimSpace(opts.InlineClose) == "" { s.inlineClose = ">" } else { s.inlineClose = opts.InlineClose } - if strings.TrimSpace(opts.ChatSuffix) == "" { s.chatSuffix = ">" } else { s.chatSuffix = opts.ChatSuffix } - if len(opts.ChatPrefixes) == 0 { s.chatPrefixes = []string{"?","!",":",";"} } else { s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) } - - // Assign package-level inline trigger chars for free helper functions - if s.inlineOpen != "" { inlineOpenChar = s.inlineOpen[0] } - if s.inlineClose != "" { inlineCloseChar = s.inlineClose[0] } - if s.chatSuffix != "" { chatSuffixChar = s.chatSuffix[0] } - if len(s.chatPrefixes) > 0 { chatPrefixSingles = append([]string{}, s.chatPrefixes...) } + s.codingTemperature = opts.CodingTemperature + s.compCache = make(map[string]string) + s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix + if opts.CompletionDebounceMs > 0 { + s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond + } + if opts.CompletionThrottleMs > 0 { + s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond + } + // Trigger character config (with sane defaults if missing) + if strings.TrimSpace(opts.InlineOpen) == "" { + s.inlineOpen = ">" + } else { + s.inlineOpen = opts.InlineOpen + } + if strings.TrimSpace(opts.InlineClose) == "" { + s.inlineClose = ">" + } else { + s.inlineClose = opts.InlineClose + } + if strings.TrimSpace(opts.ChatSuffix) == "" { + s.chatSuffix = ">" + } else { + s.chatSuffix = opts.ChatSuffix + } + if len(opts.ChatPrefixes) == 0 { + s.chatPrefixes = []string{"?", "!", ":", ";"} + } else { + s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) + } + + // Assign package-level inline trigger chars for free helper functions + if s.inlineOpen != "" { + inlineOpenChar = s.inlineOpen[0] + } + if s.inlineClose != "" { + inlineCloseChar = s.inlineClose[0] + } + if s.chatSuffix != "" { + chatSuffixChar = s.chatSuffix[0] + } + if len(s.chatPrefixes) > 0 { + chatPrefixSingles = append([]string{}, s.chatPrefixes...) + } // Initialize dispatch table - s.handlers = map[string]func(Request){ - "initialize": s.handleInitialize, - "initialized": func(_ Request) { s.handleInitialized() }, - "shutdown": s.handleShutdown, - "exit": func(_ Request) { s.handleExit() }, - "textDocument/didOpen": s.handleDidOpen, - "textDocument/didChange": s.handleDidChange, - "textDocument/didClose": s.handleDidClose, - "textDocument/completion": s.handleCompletion, - "textDocument/codeAction": s.handleCodeAction, - "codeAction/resolve": s.handleCodeActionResolve, + s.handlers = map[string]func(Request){ + "initialize": s.handleInitialize, + "initialized": func(_ Request) { s.handleInitialized() }, + "shutdown": s.handleShutdown, + "exit": func(_ Request) { s.handleExit() }, + "textDocument/didOpen": s.handleDidOpen, + "textDocument/didChange": s.handleDidChange, + "textDocument/didClose": s.handleDidClose, + "textDocument/completion": s.handleCompletion, + "textDocument/codeAction": s.handleCodeAction, + "codeAction/resolve": s.handleCodeActionResolve, "workspace/executeCommand": s.handleExecuteCommand, } - return s + return s } func (s *Server) Run() error { @@ -4496,11 +4674,12 @@ package lsp import ( "encoding/json" "fmt" - "codeberg.org/snonux/hexai/internal/logging" "io" "net/textproto" "strconv" "strings" + + "codeberg.org/snonux/hexai/internal/logging" ) func (s *Server) readMessage() ([]byte, error) { @@ -4539,18 +4718,18 @@ func (s *Server) readMessage() ([]byte, error) { return buf, nil } -func (s *Server) writeMessage(v any) { +func (s *Server) writeMessage(v any) { data, err := json.Marshal(v) if err != nil { logging.Logf("lsp ", "marshal error: %v", err) return } - header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) if _, err := io.WriteString(s.out, header); err != nil { logging.Logf("lsp ", "write header error: %v", err) return } - if _, err := s.out.Write(data); err != nil { + if _, err := s.out.Write(data); err != nil { logging.Logf("lsp ", "write body error: %v", err) return } @@ -4561,29 +4740,28 @@ func (s *Server) writeMessage(v any) { // MultilineDocBlock returns a realistic multi-line documentation block. func MultilineDocBlock() string { - return "// add adds two numbers\n// returns their sum" + return "// add adds two numbers\n// returns their sum" } // MultilineChatReply returns a multi-line assistant reply for chat tests. func MultilineChatReply() string { - return "Hello, world!\nThis is a multi-line reply." + return "Hello, world!\nThis is a multi-line reply." } // MultilineFunctionSuggestion returns a more realistic multi-line function body suggestion. func MultilineFunctionSuggestion() string { - return "(ctx context.Context, input string) (*CustData, error) {\n // TODO: implement\n return &CustData{}, nil\n}" + return "(ctx context.Context, input string) (*CustData, error) {\n // TODO: implement\n return &CustData{}, nil\n}" } // MarkdownCodeFence returns a fenced markdown snippet used in post-processing tests. func MarkdownCodeFence() string { - return "```go\nname := value\n```" + return "```go\nname := value\n```" } // MalformedJSON returns a deliberately malformed JSON string. func MalformedJSON() string { - return "{\"choices\":[{\"delta\":{\"content\":\"oops\"}}]" + return "{\"choices\":[{\"delta\":{\"content\":\"oops\"}}]" } - -- cgit v1.2.3