summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md5
-rw-r--r--cmd/hexai/main.go33
-rw-r--r--config.json.example2
-rw-r--r--internal/llm/ollama.go188
-rw-r--r--internal/llm/provider.go62
-rw-r--r--internal/logging/logging.go41
-rw-r--r--internal/lsp/context.go116
-rw-r--r--internal/lsp/context_test.go100
-rw-r--r--internal/lsp/document.go86
-rw-r--r--internal/lsp/document_test.go104
-rw-r--r--internal/lsp/handlers.go740
-rw-r--r--internal/lsp/handlers_test.go460
-rw-r--r--internal/lsp/server.go63
-rw-r--r--internal/lsp/transport.go38
-rw-r--r--internal/lsp/types.go58
15 files changed, 1110 insertions, 986 deletions
diff --git a/README.md b/README.md
index 9aeebca..07e916c 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,7 @@ At the moment this project is only in the proof of PoC phase.
## LLM provider
-Hexai exposes a simple LLM provider interface. It supports OpenAI and a local
-Ollama server. Provider selection and models are configured via a JSON
-configuration file.
+Hexai exposes a simple LLM provider interface. It supports OpenAI and a local Ollama server. Provider selection and models are configured via a JSON configuration file.
### Selecting a provider
@@ -71,6 +69,7 @@ except for `OPENAI_API_KEY`.
"max_context_tokens": 4000,
"log_preview_limit": 100,
"no_disk_io": true,
+ "trigger_characters": [".", ":", "/", "_", ";"],
"provider": "ollama", // or "openai"
// OpenAI-only options
"openai_model": "gpt-4.1",
diff --git a/cmd/hexai/main.go b/cmd/hexai/main.go
index 8e446a3..941460e 100644
--- a/cmd/hexai/main.go
+++ b/cmd/hexai/main.go
@@ -64,13 +64,14 @@ func main() {
}
server := lsp.NewServer(os.Stdin, os.Stdout, logger, lsp.ServerOptions{
- LogContext: *logPath != "",
- MaxTokens: cfg.MaxTokens,
- ContextMode: cfg.ContextMode,
- WindowLines: cfg.ContextWindowLines,
- MaxContextTokens: cfg.MaxContextTokens,
- NoDiskIO: cfg.NoDiskIO,
- Client: client,
+ LogContext: *logPath != "",
+ MaxTokens: cfg.MaxTokens,
+ ContextMode: cfg.ContextMode,
+ WindowLines: cfg.ContextWindowLines,
+ MaxContextTokens: cfg.MaxContextTokens,
+ NoDiskIO: cfg.NoDiskIO,
+ Client: client,
+ TriggerCharacters: cfg.TriggerCharacters,
})
if err := server.Run(); err != nil {
logger.Fatalf("server error: %v", err)
@@ -79,13 +80,14 @@ func main() {
// appConfig holds user-configurable settings.
type appConfig struct {
- MaxTokens int `json:"max_tokens"`
- ContextMode string `json:"context_mode"`
- ContextWindowLines int `json:"context_window_lines"`
- MaxContextTokens int `json:"max_context_tokens"`
- LogPreviewLimit int `json:"log_preview_limit"`
- NoDiskIO bool `json:"no_disk_io"`
- Provider string `json:"provider"`
+ MaxTokens int `json:"max_tokens"`
+ ContextMode string `json:"context_mode"`
+ ContextWindowLines int `json:"context_window_lines"`
+ MaxContextTokens int `json:"max_context_tokens"`
+ LogPreviewLimit int `json:"log_preview_limit"`
+ NoDiskIO bool `json:"no_disk_io"`
+ TriggerCharacters []string `json:"trigger_characters"`
+ Provider string `json:"provider"`
// Provider-specific options
OpenAIBaseURL string `json:"openai_base_url"`
OpenAIModel string `json:"openai_model"`
@@ -136,6 +138,9 @@ func loadConfig(logger *log.Logger) appConfig {
cfg.LogPreviewLimit = fileCfg.LogPreviewLimit
}
cfg.NoDiskIO = fileCfg.NoDiskIO
+ if len(fileCfg.TriggerCharacters) > 0 {
+ cfg.TriggerCharacters = append([]string{}, fileCfg.TriggerCharacters...)
+ }
if strings.TrimSpace(fileCfg.Provider) != "" {
cfg.Provider = fileCfg.Provider
}
diff --git a/config.json.example b/config.json.example
index 4dda9d0..a964947 100644
--- a/config.json.example
+++ b/config.json.example
@@ -5,6 +5,7 @@
"max_context_tokens": 4000,
"log_preview_limit": 100,
"no_disk_io": true,
+ "trigger_characters": [".", ":", "/", "_", ";"],
"provider": "openai",
@@ -14,4 +15,3 @@
"ollama_model": "qwen2.5-coder:latest",
"ollama_base_url": "http://localhost:11434"
}
-
diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go
index db3e06b..e8b75c9 100644
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -1,115 +1,133 @@
package llm
import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "time"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
- "hexai/internal/logging"
+ "hexai/internal/logging"
)
// ollamaClient implements Client against a local Ollama server.
type ollamaClient struct {
- httpClient *http.Client
- baseURL string
- defaultModel string
+ httpClient *http.Client
+ baseURL string
+ defaultModel string
}
func newOllama(baseURL, model string) Client {
- if strings.TrimSpace(baseURL) == "" {
- baseURL = "http://localhost:11434"
- }
- if strings.TrimSpace(model) == "" {
- model = "qwen2.5-coder:latest"
- }
- return &ollamaClient{
- httpClient: &http.Client{Timeout: 30 * time.Second},
- baseURL: strings.TrimRight(baseURL, "/"),
- defaultModel: model,
- }
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = "http://localhost:11434"
+ }
+ if strings.TrimSpace(model) == "" {
+ model = "qwen2.5-coder:latest"
+ }
+ return &ollamaClient{
+ httpClient: &http.Client{Timeout: 30 * time.Second},
+ baseURL: strings.TrimRight(baseURL, "/"),
+ defaultModel: model,
+ }
}
type ollamaChatRequest struct {
- Model string `json:"model"`
- Messages []oaMessage `json:"messages"`
- Stream bool `json:"stream"`
- Options any `json:"options,omitempty"`
+ Model string `json:"model"`
+ Messages []oaMessage `json:"messages"`
+ Stream bool `json:"stream"`
+ Options any `json:"options,omitempty"`
}
type ollamaChatResponse struct {
- Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
- } `json:"message"`
- Done bool `json:"done"`
- Error string `json:"error,omitempty"`
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ Done bool `json:"done"`
+ Error string `json:"error,omitempty"`
}
func (c *ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
- o := Options{Model: c.defaultModel}
- for _, opt := range opts { opt(&o) }
- if o.Model == "" { o.Model = c.defaultModel }
+ o := Options{Model: c.defaultModel}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.Model == "" {
+ o.Model = c.defaultModel
+ }
- start := time.Now()
- logging.Logf("llm/ollama ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
- for i, m := range messages {
- logging.Logf("llm/ollama ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
- }
+ start := time.Now()
+ logging.Logf("llm/ollama ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
+ for i, m := range messages {
+ logging.Logf("llm/ollama ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
+ }
- req := ollamaChatRequest{Model: o.Model, Stream: false}
- req.Messages = make([]oaMessage, len(messages))
- for i, m := range messages { req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} }
+ req := ollamaChatRequest{Model: o.Model, Stream: false}
+ req.Messages = make([]oaMessage, len(messages))
+ for i, m := range messages {
+ req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content}
+ }
- // Build options map only if any option is set
- optsMap := map[string]any{}
- if o.Temperature != 0 { optsMap["temperature"] = o.Temperature }
- if o.MaxTokens > 0 { optsMap["num_predict"] = o.MaxTokens }
- if len(o.Stop) > 0 { optsMap["stop"] = o.Stop }
- if len(optsMap) > 0 { req.Options = optsMap }
+ // Build options map only if any option is set
+ optsMap := map[string]any{}
+ if o.Temperature != 0 {
+ optsMap["temperature"] = o.Temperature
+ }
+ if o.MaxTokens > 0 {
+ optsMap["num_predict"] = o.MaxTokens
+ }
+ if len(o.Stop) > 0 {
+ optsMap["stop"] = o.Stop
+ }
+ if len(optsMap) > 0 {
+ req.Options = optsMap
+ }
- body, err := json.Marshal(req)
- if err != nil { return "", err }
+ body, err := json.Marshal(req)
+ if err != nil {
+ return "", err
+ }
- endpoint := c.baseURL + "/api/chat"
- logging.Logf("llm/ollama ", "POST %s", endpoint)
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
- if err != nil { return "", err }
- httpReq.Header.Set("Content-Type", "application/json")
+ endpoint := c.baseURL + "/api/chat"
+ logging.Logf("llm/ollama ", "POST %s", endpoint)
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
- resp, err := c.httpClient.Do(httpReq)
- if err != nil {
- logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return "", err
- }
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- var apiErr ollamaChatResponse
- _ = json.NewDecoder(resp.Body).Decode(&apiErr)
- if strings.TrimSpace(apiErr.Error) != "" {
- logging.Logf("llm/ollama ", "%sapi error status=%d msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("ollama error: %s (status %d)", apiErr.Error, resp.StatusCode)
- }
- logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("ollama http error: status %d", resp.StatusCode)
- }
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return "", err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ var apiErr ollamaChatResponse
+ _ = json.NewDecoder(resp.Body).Decode(&apiErr)
+ if strings.TrimSpace(apiErr.Error) != "" {
+ logging.Logf("llm/ollama ", "%sapi error status=%d msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("ollama error: %s (status %d)", apiErr.Error, resp.StatusCode)
+ }
+ logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("ollama http error: status %d", resp.StatusCode)
+ }
- var out ollamaChatResponse
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return "", err
- }
- if strings.TrimSpace(out.Message.Content) == "" {
- logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
- return "", errors.New("ollama: empty content")
- }
- content := out.Message.Content
- logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
- return content, nil
+ var out ollamaChatResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return "", err
+ }
+ if strings.TrimSpace(out.Message.Content) == "" {
+ logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
+ return "", errors.New("ollama: empty content")
+ }
+ content := out.Message.Content
+ logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
+ return content, nil
}
// Provider metadata
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
index c7367ed..6c6cf04 100644
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -1,9 +1,9 @@
package llm
import (
- "context"
- "errors"
- "strings"
+ "context"
+ "errors"
+ "strings"
)
// Message represents a chat-style prompt message.
@@ -15,12 +15,12 @@ type Message struct {
// Client is a minimal LLM provider interface.
// Future providers (Ollama, etc.) should implement this.
type Client interface {
- // Chat sends chat messages and returns the assistant text.
- Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error)
- // Name returns the provider's short name (e.g., "openai", "ollama").
- Name() string
- // DefaultModel returns the configured default model name.
- DefaultModel() string
+ // Chat sends chat messages and returns the assistant text.
+ Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error)
+ // Name returns the provider's short name (e.g., "openai", "ollama").
+ Name() string
+ // DefaultModel returns the configured default model name.
+ DefaultModel() string
}
// Options for a request. Providers may ignore unsupported fields.
@@ -43,32 +43,32 @@ func WithStop(stop ...string) RequestOption {
// Config defines provider configuration read from the Hexai config file.
type Config struct {
- Provider string
- // OpenAI options
- OpenAIBaseURL string
- OpenAIModel string
- // Ollama options
- OllamaBaseURL string
- OllamaModel string
+ Provider string
+ // OpenAI options
+ OpenAIBaseURL string
+ OpenAIModel string
+ // Ollama options
+ OllamaBaseURL string
+ OllamaModel string
}
// 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 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")
- }
- return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey), nil
- case "ollama":
- return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel), nil
- default:
- return nil, errors.New("unknown LLM provider: " + p)
- }
+ 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")
+ }
+ return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey), nil
+ case "ollama":
+ return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel), nil
+ default:
+ return nil, errors.New("unknown LLM provider: " + p)
+ }
}
diff --git a/internal/logging/logging.go b/internal/logging/logging.go
index 2e4bbc8..80231ab 100644
--- a/internal/logging/logging.go
+++ b/internal/logging/logging.go
@@ -1,18 +1,18 @@
package logging
import (
- "fmt"
- "log"
+ "fmt"
+ "log"
)
// ANSI color utilities shared across Hexai.
const (
- AnsiBgBlack = "\x1b[40m"
- AnsiGrey = "\x1b[90m"
- AnsiCyan = "\x1b[36m"
- AnsiGreen = "\x1b[32m"
- AnsiRed = "\x1b[31m"
- AnsiReset = "\x1b[0m"
+ AnsiBgBlack = "\x1b[40m"
+ AnsiGrey = "\x1b[90m"
+ AnsiCyan = "\x1b[36m"
+ AnsiGreen = "\x1b[32m"
+ AnsiRed = "\x1b[31m"
+ AnsiReset = "\x1b[0m"
)
// AnsiBase is the default style: black background + grey foreground.
@@ -26,11 +26,11 @@ 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) {
- if std == nil {
- return
- }
- msg := fmt.Sprintf(format, args...)
- std.Print(AnsiBase + prefix + msg + AnsiReset)
+ if std == nil {
+ return
+ }
+ msg := fmt.Sprintf(format, args...)
+ std.Print(AnsiBase + prefix + msg + AnsiReset)
}
// Logging configuration for previews (shared)
@@ -42,12 +42,11 @@ func SetLogPreviewLimit(n int) { logPreviewLimit = n }
// PreviewForLog returns the string truncated to the configured preview limit.
func PreviewForLog(s string) string {
- if logPreviewLimit > 0 {
- if len(s) <= logPreviewLimit {
- return s
- }
- return s[:logPreviewLimit] + "…"
- }
- return s
+ if logPreviewLimit > 0 {
+ if len(s) <= logPreviewLimit {
+ return s
+ }
+ return s[:logPreviewLimit] + "…"
+ }
+ return s
}
-
diff --git a/internal/lsp/context.go b/internal/lsp/context.go
index 8f345df..e746058 100644
--- a/internal/lsp/context.go
+++ b/internal/lsp/context.go
@@ -1,8 +1,8 @@
package lsp
import (
- "strings"
- "hexai/internal/logging"
+ "hexai/internal/logging"
+ "strings"
)
// buildAdditionalContext builds extra context messages based on the configured mode.
@@ -12,71 +12,71 @@ import (
// - file-on-new-func: include full file only when defining a new function
// - always-full: always include the full file
func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) {
- mode := s.contextMode
- switch mode {
- case "minimal":
- return "", false
- case "window":
- return s.windowContext(uri, pos), true
- case "file-on-new-func":
- if newFunc {
- return s.fullFileContext(uri), true
- }
- return "", false
- case "always-full":
- return s.fullFileContext(uri), true
- default:
- // fallback to minimal if unknown
- return "", false
- }
+ mode := s.contextMode
+ switch mode {
+ case "minimal":
+ return "", false
+ case "window":
+ return s.windowContext(uri, pos), true
+ case "file-on-new-func":
+ if newFunc {
+ return s.fullFileContext(uri), true
+ }
+ return "", false
+ case "always-full":
+ return s.fullFileContext(uri), true
+ default:
+ // fallback to minimal if unknown
+ return "", false
+ }
}
func (s *Server) windowContext(uri string, pos Position) string {
- d := s.getDocument(uri)
- if d == nil || len(d.lines) == 0 {
- logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri)
- return ""
- }
- n := len(d.lines)
- half := s.windowLines / 2
- start := pos.Line - half
- if start < 0 {
- start = 0
- }
- end := pos.Line + half + 1
- if end > n {
- end = n
- }
- text := strings.Join(d.lines[start:end], "\n")
- return truncateToApproxTokens(text, s.maxContextTokens)
+ d := s.getDocument(uri)
+ if d == nil || len(d.lines) == 0 {
+ logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri)
+ return ""
+ }
+ n := len(d.lines)
+ half := s.windowLines / 2
+ start := pos.Line - half
+ if start < 0 {
+ start = 0
+ }
+ end := pos.Line + half + 1
+ if end > n {
+ end = n
+ }
+ text := strings.Join(d.lines[start:end], "\n")
+ return truncateToApproxTokens(text, s.maxContextTokens)
}
func (s *Server) fullFileContext(uri string) string {
- d := s.getDocument(uri)
- if d == nil {
- logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri)
- return ""
- }
- return truncateToApproxTokens(d.text, s.maxContextTokens)
+ d := s.getDocument(uri)
+ if d == nil {
+ logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri)
+ return ""
+ }
+ return truncateToApproxTokens(d.text, s.maxContextTokens)
}
// truncateToApproxTokens naively truncates the input to fit approx N tokens.
// Uses 4 chars/token heuristic for speed and determinism.
func truncateToApproxTokens(text string, maxTokens int) string {
- if maxTokens <= 0 {
- return ""
- }
- maxChars := maxTokens * 4
- if len(text) <= maxChars {
- return text
- }
- // try to cut on a line boundary near maxChars
- cut := maxChars
- if cut > len(text) {
- cut = len(text)
- }
- if i := strings.LastIndex(text[:cut], "\n"); i > 0 {
- cut = i
- }
- return text[:cut]
+ if maxTokens <= 0 {
+ return ""
+ }
+ maxChars := maxTokens * 4
+ if len(text) <= maxChars {
+ return text
+ }
+ // try to cut on a line boundary near maxChars
+ cut := maxChars
+ if cut > len(text) {
+ cut = len(text)
+ }
+ if i := strings.LastIndex(text[:cut], "\n"); i > 0 {
+ cut = i
+ }
+ return text[:cut]
}
diff --git a/internal/lsp/context_test.go b/internal/lsp/context_test.go
index 32834b8..fe5d73b 100644
--- a/internal/lsp/context_test.go
+++ b/internal/lsp/context_test.go
@@ -1,69 +1,69 @@
package lsp
import (
- "strconv"
- "strings"
- "testing"
+ "strconv"
+ "strings"
+ "testing"
)
func TestWindowContext_Bounds(t *testing.T) {
- s := newTestServer()
- s.windowLines = 4 // half=2
- s.maxContextTokens = 9999
- lines := make([]string, 10)
- for i := 0; i < 10; i++ {
- lines[i] = "L" + strconv.Itoa(i)
- }
- text := strings.Join(lines, "\n")
- uri := "file:///w.go"
- s.setDocument(uri, text)
- got := s.windowContext(uri, Position{Line: 5, Character: 0})
- // expect lines 3..7 inclusive
- want := strings.Join(lines[3:8], "\n")
- if got != want {
- t.Fatalf("window context got %q want %q", got, want)
- }
+ s := newTestServer()
+ s.windowLines = 4 // half=2
+ s.maxContextTokens = 9999
+ lines := make([]string, 10)
+ for i := 0; i < 10; i++ {
+ lines[i] = "L" + strconv.Itoa(i)
+ }
+ text := strings.Join(lines, "\n")
+ uri := "file:///w.go"
+ s.setDocument(uri, text)
+ got := s.windowContext(uri, Position{Line: 5, Character: 0})
+ // expect lines 3..7 inclusive
+ want := strings.Join(lines[3:8], "\n")
+ if got != want {
+ t.Fatalf("window context got %q want %q", got, want)
+ }
}
func TestBuildAdditionalContext_Minimal(t *testing.T) {
- s := newTestServer()
- s.contextMode = "minimal"
- if ctx, ok := s.buildAdditionalContext(false, "file:///x.go", Position{}); ok || ctx != "" {
- t.Fatalf("expected no context in minimal mode; got ok=%v ctx=%q", ok, ctx)
- }
+ s := newTestServer()
+ s.contextMode = "minimal"
+ if ctx, ok := s.buildAdditionalContext(false, "file:///x.go", Position{}); ok || ctx != "" {
+ t.Fatalf("expected no context in minimal mode; got ok=%v ctx=%q", ok, ctx)
+ }
}
func TestBuildAdditionalContext_FileOnNewFunc(t *testing.T) {
- s := newTestServer()
- s.contextMode = "file-on-new-func"
- s.maxContextTokens = 9999
- uri := "file:///x.go"
- body := "package x\n\nfunc a(){}\n"
- s.setDocument(uri, body)
- if ctx, ok := s.buildAdditionalContext(true, uri, Position{}); !ok || ctx == "" {
- t.Fatalf("expected full context when new func; ok=%v ctx=%q", ok, ctx)
- }
- if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); ok || ctx != "" {
- t.Fatalf("expected no context when not new func; ok=%v ctx=%q", ok, ctx)
- }
+ s := newTestServer()
+ s.contextMode = "file-on-new-func"
+ s.maxContextTokens = 9999
+ uri := "file:///x.go"
+ body := "package x\n\nfunc a(){}\n"
+ s.setDocument(uri, body)
+ if ctx, ok := s.buildAdditionalContext(true, uri, Position{}); !ok || ctx == "" {
+ t.Fatalf("expected full context when new func; ok=%v ctx=%q", ok, ctx)
+ }
+ if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); ok || ctx != "" {
+ t.Fatalf("expected no context when not new func; ok=%v ctx=%q", ok, ctx)
+ }
}
func TestBuildAdditionalContext_AlwaysFull(t *testing.T) {
- s := newTestServer()
- s.contextMode = "always-full"
- s.maxContextTokens = 9999
- uri := "file:///x.go"
- body := "line1\nline2\n"
- s.setDocument(uri, body)
- if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); !ok || ctx == "" {
- t.Fatalf("expected context in always-full; ok=%v ctx=%q", ok, ctx)
- }
+ s := newTestServer()
+ s.contextMode = "always-full"
+ s.maxContextTokens = 9999
+ uri := "file:///x.go"
+ body := "line1\nline2\n"
+ s.setDocument(uri, body)
+ if ctx, ok := s.buildAdditionalContext(false, uri, Position{}); !ok || ctx == "" {
+ t.Fatalf("expected context in always-full; ok=%v ctx=%q", ok, ctx)
+ }
}
func TestTruncateToApproxTokens(t *testing.T) {
- text := strings.Repeat("abcd", 10) // 40 chars
- got := truncateToApproxTokens(text, 5) // ~20 chars
- if len(got) > 5*4 {
- t.Fatalf("truncate exceeded budget: got len=%d budget=%d", len(got), 5*4)
- }
+ text := strings.Repeat("abcd", 10) // 40 chars
+ got := truncateToApproxTokens(text, 5) // ~20 chars
+ if len(got) > 5*4 {
+ t.Fatalf("truncate exceeded budget: got len=%d budget=%d", len(got), 5*4)
+ }
}
diff --git a/internal/lsp/document.go b/internal/lsp/document.go
index e5eaf06..05f024f 100644
--- a/internal/lsp/document.go
+++ b/internal/lsp/document.go
@@ -1,8 +1,8 @@
package lsp
import (
- "strings"
- "time"
+ "strings"
+ "time"
)
// --- Document store and helpers ---
@@ -76,47 +76,47 @@ func (s *Server) lineContext(uri string, pos Position) (above, current, below, f
// Heuristic: find nearest preceding line containing "func "; ensure no '{'
// appears before the cursor across those lines.
func (s *Server) isDefiningNewFunction(uri string, pos Position) bool {
- d := s.getDocument(uri)
- if d == nil || len(d.lines) == 0 {
- return false
- }
- idx := pos.Line
- if idx < 0 {
- idx = 0
- }
- if idx >= len(d.lines) {
- idx = len(d.lines) - 1
- }
- // Find signature start
- sigStart := -1
- for i := idx; i >= 0; i-- {
- if strings.Contains(d.lines[i], "func ") {
- sigStart = i
- break
- }
- // stop if we hit a closing brace which likely ends a previous block
- if strings.Contains(d.lines[i], "}") {
- break
- }
- }
- if sigStart == -1 {
- return false
- }
- // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body
- for i := sigStart; i <= idx; i++ {
- line := d.lines[i]
- brace := strings.Index(line, "{")
- if brace >= 0 {
- if i < idx {
- return false // body started on a previous line
- }
- // same line as cursor: if brace position < cursor character, then already in body
- if pos.Character > brace {
- return false
- }
- }
- }
- return true
+ d := s.getDocument(uri)
+ if d == nil || len(d.lines) == 0 {
+ return false
+ }
+ idx := pos.Line
+ if idx < 0 {
+ idx = 0
+ }
+ if idx >= len(d.lines) {
+ idx = len(d.lines) - 1
+ }
+ // Find signature start
+ sigStart := -1
+ for i := idx; i >= 0; i-- {
+ if strings.Contains(d.lines[i], "func ") {
+ sigStart = i
+ break
+ }
+ // stop if we hit a closing brace which likely ends a previous block
+ if strings.Contains(d.lines[i], "}") {
+ break
+ }
+ }
+ if sigStart == -1 {
+ return false
+ }
+ // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body
+ for i := sigStart; i <= idx; i++ {
+ line := d.lines[i]
+ brace := strings.Index(line, "{")
+ if brace >= 0 {
+ if i < idx {
+ return false // body started on a previous line
+ }
+ // same line as cursor: if brace position < cursor character, then already in body
+ if pos.Character > brace {
+ return false
+ }
+ }
+ }
+ return true
}
func hasAny(s string, needles []string) bool {
diff --git a/internal/lsp/document_test.go b/internal/lsp/document_test.go
index 8d81a99..e8fa6bb 100644
--- a/internal/lsp/document_test.go
+++ b/internal/lsp/document_test.go
@@ -1,76 +1,76 @@
package lsp
import (
- "io"
- "log"
- "strings"
- "testing"
+ "io"
+ "log"
+ "strings"
+ "testing"
)
func newTestServer() *Server {
- return &Server{
- logger: log.New(io.Discard, "", 0),
- docs: make(map[string]*document),
- }
+ return &Server{
+ logger: log.New(io.Discard, "", 0),
+ docs: make(map[string]*document),
+ }
}
func TestSplitLines(t *testing.T) {
- in := "a\r\nb\nc"
- got := splitLines(in)
- want := []string{"a", "b", "c"}
- if len(got) != len(want) {
- t.Fatalf("len mismatch: got %d want %d", len(got), len(want))
- }
- for i := range want {
- if got[i] != want[i] {
- t.Fatalf("line %d: got %q want %q", i, got[i], want[i])
- }
- }
+ in := "a\r\nb\nc"
+ got := splitLines(in)
+ want := []string{"a", "b", "c"}
+ if len(got) != len(want) {
+ t.Fatalf("len mismatch: got %d want %d", len(got), len(want))
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Fatalf("line %d: got %q want %q", i, got[i], want[i])
+ }
+ }
}
func TestLineContext(t *testing.T) {
- s := newTestServer()
- src := "package main\n\nfunc add(a, b int) int {\n\treturn a + b\n}\n"
- uri := "file:///test.go"
- s.setDocument(uri, src)
+ s := newTestServer()
+ src := "package main\n\nfunc add(a, b int) int {\n\treturn a + b\n}\n"
+ uri := "file:///test.go"
+ s.setDocument(uri, src)
- // Position on the return line (line 3, zero-based)
- above, current, below, funcCtx := s.lineContext(uri, Position{Line: 3, Character: 0})
+ // Position on the return line (line 3, zero-based)
+ above, current, below, funcCtx := s.lineContext(uri, Position{Line: 3, Character: 0})
- if want := "func add(a, b int) int {"; funcCtx != want {
- t.Fatalf("funcCtx got %q want %q", funcCtx, want)
- }
- if want := "func add(a, b int) int {"; above != want {
- t.Fatalf("above got %q want %q", above, want)
- }
- if want := "\treturn a + b"; current != want {
- t.Fatalf("current got %q want %q", current, want)
- }
- if want := "}"; below != want {
- t.Fatalf("below got %q want %q", below, want)
- }
+ if want := "func add(a, b int) int {"; funcCtx != want {
+ t.Fatalf("funcCtx got %q want %q", funcCtx, want)
+ }
+ if want := "func add(a, b int) int {"; above != want {
+ t.Fatalf("above got %q want %q", above, want)
+ }
+ if want := "\treturn a + b"; current != want {
+ t.Fatalf("current got %q want %q", current, want)
+ }
+ if want := "}"; below != want {
+ t.Fatalf("below got %q want %q", below, want)
+ }
}
func TestLineContext_EmptyDoc(t *testing.T) {
- s := newTestServer()
- a, c, b, f := s.lineContext("file:///missing.go", Position{Line: 0, Character: 0})
- if a != "" || b != "" || c != "" || f != "" {
- t.Fatalf("expected all empty for missing doc; got above=%q current=%q below=%q func=%q", a, c, b, f)
- }
+ s := newTestServer()
+ a, c, b, f := s.lineContext("file:///missing.go", Position{Line: 0, Character: 0})
+ if a != "" || b != "" || c != "" || f != "" {
+ t.Fatalf("expected all empty for missing doc; got above=%q current=%q below=%q func=%q", a, c, b, f)
+ }
}
func TestTrimLen(t *testing.T) {
- long := strings.Repeat("a", 205)
- got := trimLen(long)
- want := strings.Repeat("a", 200) + "…"
- if got != want {
- t.Fatalf("trimLen got %q want %q", got, want)
- }
+ long := strings.Repeat("a", 205)
+ got := trimLen(long)
+ want := strings.Repeat("a", 200) + "…"
+ if got != want {
+ t.Fatalf("trimLen got %q want %q", got, want)
+ }
}
func TestFirstLine(t *testing.T) {
- s := "first line\r\nsecond line"
- if got := firstLine(s); got != "first line" {
- t.Fatalf("firstLine got %q want %q", got, "first line")
- }
+ s := "first line\r\nsecond line"
+ if got := firstLine(s); got != "first line" {
+ t.Fatalf("firstLine got %q want %q", got, "first line")
+ }
}
diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go
index dce0b8d..a16affb 100644
--- a/internal/lsp/handlers.go
+++ b/internal/lsp/handlers.go
@@ -13,11 +13,11 @@ import (
)
func (s *Server) handle(req Request) {
- switch req.Method {
- case "initialize":
- s.handleInitialize(req)
- case "initialized":
- s.handleInitialized()
+ switch req.Method {
+ case "initialize":
+ s.handleInitialize(req)
+ case "initialized":
+ s.handleInitialized()
case "shutdown":
s.handleShutdown(req)
case "exit":
@@ -28,15 +28,15 @@ func (s *Server) handle(req Request) {
s.handleDidChange(req)
case "textDocument/didClose":
s.handleDidClose(req)
- case "textDocument/completion":
- s.handleCompletion(req)
- case "textDocument/codeAction":
- s.handleCodeAction(req)
- default:
- if len(req.ID) != 0 {
- s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)})
- }
- }
+ case "textDocument/completion":
+ s.handleCompletion(req)
+ case "textDocument/codeAction":
+ s.handleCodeAction(req)
+ default:
+ if len(req.ID) != 0 {
+ s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)})
+ }
+ }
}
func (s *Server) handleInitialize(req Request) {
@@ -44,90 +44,97 @@ func (s *Server) handleInitialize(req Request) {
if s.llmClient != nil {
version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]"
}
- res := InitializeResult{
- Capabilities: ServerCapabilities{
- TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull
- CompletionProvider: &CompletionOptions{
- ResolveProvider: false,
- // TODO: Make the trigger characters configurable
- TriggerCharacters: []string{".", ":", "/", "_"},
- },
- CodeActionProvider: true,
- },
- ServerInfo: &ServerInfo{Name: "hexai", Version: version},
- }
- s.reply(req.ID, res, nil)
+ res := InitializeResult{
+ Capabilities: ServerCapabilities{
+ TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull
+ CompletionProvider: &CompletionOptions{
+ ResolveProvider: false,
+ TriggerCharacters: s.triggerChars,
+ },
+ CodeActionProvider: true,
+ },
+ ServerInfo: &ServerInfo{Name: "hexai", Version: version},
+ }
+ s.reply(req.ID, res, nil)
}
func (s *Server) handleCodeAction(req Request) {
- var p CodeActionParams
- if err := json.Unmarshal(req.Params, &p); err != nil {
- if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
- return
- }
- // Extract selected text
- d := s.getDocument(p.TextDocument.URI)
- if d == nil || len(d.lines) == 0 {
- if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
- return
- }
- sel := extractRangeText(d, p.Range)
- if strings.TrimSpace(sel) == "" || s.llmClient == nil {
- if len(req.ID) != 0 { s.reply(req.ID, []CodeAction{}, nil) }
- return
- }
-
- actions := make([]CodeAction, 0, 2)
-
- // Action 1: Rewrite selection based on first instruction in selection
- if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" {
- 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", instr, cleaned)
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
- if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil {
- out := strings.TrimSpace(text)
- if out != "" {
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}}
- actions = append(actions, CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Edit: &edit})
- }
- } else {
- logging.Logf("lsp ", "codeAction rewrite llm error: %v", err)
- }
- }
-
- // Action 2: Resolve diagnostics within selection
- if diags := s.diagnosticsInRange(p.Context, p.Range); len(diags) > 0 {
- // Compose a prompt listing diagnostics relevant to the selected code
- sys := "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes."
- var b strings.Builder
- b.WriteString("Diagnostics to resolve (selection only):\n")
- for i, dgn := range diags {
- // Minimal, user-facing summary; include source if present
- if dgn.Source != "" {
- fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message)
- } else {
- fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message)
- }
- }
- b.WriteString("\nSelected code:\n")
- b.WriteString(sel)
- ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
- defer cancel()
- messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}}
- if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil {
- out := strings.TrimSpace(text)
- if out != "" {
- edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}}
- actions = append(actions, CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Edit: &edit})
- }
- } else {
- logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err)
- }
- }
-
- if len(req.ID) != 0 { s.reply(req.ID, actions, nil) }
+ var p CodeActionParams
+ if err := json.Unmarshal(req.Params, &p); err != nil {
+ if len(req.ID) != 0 {
+ s.reply(req.ID, []CodeAction{}, nil)
+ }
+ return
+ }
+ // Extract selected text
+ d := s.getDocument(p.TextDocument.URI)
+ if d == nil || len(d.lines) == 0 {
+ if len(req.ID) != 0 {
+ s.reply(req.ID, []CodeAction{}, nil)
+ }
+ return
+ }
+ sel := extractRangeText(d, p.Range)
+ if strings.TrimSpace(sel) == "" || s.llmClient == nil {
+ if len(req.ID) != 0 {
+ s.reply(req.ID, []CodeAction{}, nil)
+ }
+ return
+ }
+
+ actions := make([]CodeAction, 0, 2)
+
+ // Action 1: Rewrite selection based on first instruction in selection
+ if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" {
+ 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", instr, cleaned)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}}
+ if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil {
+ out := strings.TrimSpace(text)
+ if out != "" {
+ edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}}
+ actions = append(actions, CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Edit: &edit})
+ }
+ } else {
+ logging.Logf("lsp ", "codeAction rewrite llm error: %v", err)
+ }
+ }
+
+ // Action 2: Resolve diagnostics within selection
+ if diags := s.diagnosticsInRange(p.Context, p.Range); len(diags) > 0 {
+ // Compose a prompt listing diagnostics relevant to the selected code
+ sys := "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes."
+ var b strings.Builder
+ b.WriteString("Diagnostics to resolve (selection only):\n")
+ for i, dgn := range diags {
+ // Minimal, user-facing summary; include source if present
+ if dgn.Source != "" {
+ fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message)
+ } else {
+ fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message)
+ }
+ }
+ b.WriteString("\nSelected code:\n")
+ b.WriteString(sel)
+ ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
+ defer cancel()
+ messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}}
+ if text, err := s.llmClient.Chat(ctx, messages, llm.WithMaxTokens(s.maxTokens), llm.WithTemperature(0.1)); err == nil {
+ out := strings.TrimSpace(text)
+ if out != "" {
+ edit := WorkspaceEdit{Changes: map[string][]TextEdit{p.TextDocument.URI: {{Range: p.Range, NewText: out}}}}
+ actions = append(actions, CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Edit: &edit})
+ }
+ } else {
+ logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err)
+ }
+ }
+
+ if len(req.ID) != 0 {
+ s.reply(req.ID, actions, nil)
+ }
}
// instructionFromSelection extracts the first instruction from selection text.
@@ -135,14 +142,14 @@ func (s *Server) handleCodeAction(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
@@ -155,40 +162,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 := findStrictSemicolonTag(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, "<!--"); i >= 0 {
- if j := strings.Index(line[i+4:], "-->"); 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 := findStrictSemicolonTag(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, "<!--"); i >= 0 {
+ if j := strings.Index(line[i+4:], "-->"); 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
}
// findStrictSemicolonTag finds ;text; with no space after first ';' and no space
@@ -196,91 +214,138 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b
// the start index of the opening ';', the end index just after the closing ';',
// and whether it was found.
func findStrictSemicolonTag(line string) (string, int, int, bool) {
- pos := 0
- for pos < len(line) {
- j := strings.Index(line[pos:], ";")
- if j < 0 { return "", 0, 0, false }
- j += pos
- // ensure single ';' (not ';;') and non-space after
- if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' { pos = j + 1; continue }
- k := strings.Index(line[j+1:], ";")
- if k < 0 { return "", 0, 0, false }
- closeIdx := j + 1 + k
- if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1; continue }
- inner := strings.TrimSpace(line[j+1 : closeIdx])
- if inner == "" { pos = closeIdx + 1; continue }
- end := closeIdx + 1
- return inner, j, end, true
- }
- return "", 0, 0, false
+ pos := 0
+ for pos < len(line) {
+ j := strings.Index(line[pos:], ";")
+ if j < 0 {
+ return "", 0, 0, false
+ }
+ j += pos
+ // ensure single ';' (not ';;') and non-space after
+ if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' {
+ pos = j + 1
+ continue
+ }
+ k := strings.Index(line[j+1:], ";")
+ if k < 0 {
+ return "", 0, 0, false
+ }
+ closeIdx := j + 1 + k
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
+ pos = closeIdx + 1
+ continue
+ }
+ inner := strings.TrimSpace(line[j+1 : closeIdx])
+ if inner == "" {
+ pos = closeIdx + 1
+ continue
+ }
+ end := closeIdx + 1
+ return inner, j, end, true
+ }
+ return "", 0, 0, false
}
// diagnosticsInRange parses the CodeAction context and returns diagnostics
// that overlap the given selection range. If the context is missing or does
// not contain diagnostics, returns an empty slice.
func (s *Server) diagnosticsInRange(ctxRaw json.RawMessage, sel Range) []Diagnostic {
- if len(ctxRaw) == 0 { return nil }
- var ctx CodeActionContext
- if err := json.Unmarshal(ctxRaw, &ctx); err != nil { return nil }
- if len(ctx.Diagnostics) == 0 { return nil }
- out := make([]Diagnostic, 0, len(ctx.Diagnostics))
- for _, d := range ctx.Diagnostics {
- if rangesOverlap(d.Range, sel) {
- out = append(out, d)
- }
- }
- return out
+ if len(ctxRaw) == 0 {
+ return nil
+ }
+ var ctx CodeActionContext
+ if err := json.Unmarshal(ctxRaw, &ctx); err != nil {
+ return nil
+ }
+ if len(ctx.Diagnostics) == 0 {
+ return nil
+ }
+ out := make([]Diagnostic, 0, len(ctx.Diagnostics))
+ for _, d := range ctx.Diagnostics {
+ if rangesOverlap(d.Range, sel) {
+ out = append(out, d)
+ }
+ }
+ return out
}
// rangesOverlap reports whether two LSP ranges overlap at all.
func rangesOverlap(a, b Range) bool {
- // Normalize ordering
- if greaterPos(a.Start, a.End) { a.Start, a.End = a.End, a.Start }
- if greaterPos(b.Start, b.End) { b.Start, b.End = b.End, b.Start }
- // a ends before b starts
- if lessPos(a.End, b.Start) { return false }
- // b ends before a starts
- if lessPos(b.End, a.Start) { return false }
- return true
+ // Normalize ordering
+ if greaterPos(a.Start, a.End) {
+ a.Start, a.End = a.End, a.Start
+ }
+ if greaterPos(b.Start, b.End) {
+ b.Start, b.End = b.End, b.Start
+ }
+ // a ends before b starts
+ if lessPos(a.End, b.Start) {
+ return false
+ }
+ // b ends before a starts
+ if lessPos(b.End, a.Start) {
+ return false
+ }
+ return true
}
func lessPos(p, q Position) bool {
- if p.Line != q.Line { return p.Line < q.Line }
- return p.Character < q.Character
+ if p.Line != q.Line {
+ return p.Line < q.Line
+ }
+ return p.Character < q.Character
}
func greaterPos(p, q Position) bool {
- if p.Line != q.Line { return p.Line > q.Line }
- return p.Character > q.Character
+ if p.Line != q.Line {
+ return p.Line > q.Line
+ }
+ return p.Character > q.Character
}
// extractRangeText returns the exact text within the given document range.
func extractRangeText(d *document, r Range) string {
- if r.Start.Line == r.End.Line {
- line := d.lines[r.Start.Line]
- if r.Start.Character < 0 { r.Start.Character = 0 }
- if r.End.Character > len(line) { r.End.Character = len(line) }
- if r.Start.Character > r.End.Character { return "" }
- return line[r.Start.Character:r.End.Character]
- }
- var b strings.Builder
- // first line
- first := d.lines[r.Start.Line]
- if r.Start.Character < 0 { r.Start.Character = 0 }
- if r.Start.Character > len(first) { r.Start.Character = len(first) }
- b.WriteString(first[r.Start.Character:])
- b.WriteString("\n")
- // middle lines
- for i := r.Start.Line + 1; i < r.End.Line; i++ {
- b.WriteString(d.lines[i])
- if i+1 <= r.End.Line { b.WriteString("\n") }
- }
- // last line
- last := d.lines[r.End.Line]
- if r.End.Character < 0 { r.End.Character = 0 }
- if r.End.Character > len(last) { r.End.Character = len(last) }
- b.WriteString(last[:r.End.Character])
- return b.String()
+ if r.Start.Line == r.End.Line {
+ line := d.lines[r.Start.Line]
+ if r.Start.Character < 0 {
+ r.Start.Character = 0
+ }
+ if r.End.Character > len(line) {
+ r.End.Character = len(line)
+ }
+ if r.Start.Character > r.End.Character {
+ return ""
+ }
+ return line[r.Start.Character:r.End.Character]
+ }
+ var b strings.Builder
+ // first line
+ first := d.lines[r.Start.Line]
+ if r.Start.Character < 0 {
+ r.Start.Character = 0
+ }
+ if r.Start.Character > len(first) {
+ r.Start.Character = len(first)
+ }
+ b.WriteString(first[r.Start.Character:])
+ b.WriteString("\n")
+ // middle lines
+ for i := r.Start.Line + 1; i < r.End.Line; i++ {
+ b.WriteString(d.lines[i])
+ if i+1 <= r.End.Line {
+ b.WriteString("\n")
+ }
+ }
+ // last line
+ last := d.lines[r.End.Line]
+ if r.End.Character < 0 {
+ r.End.Character = 0
+ }
+ if r.End.Character > len(last) {
+ r.End.Character = len(last)
+ }
+ b.WriteString(last[:r.End.Character])
+ return b.String()
}
func (s *Server) handleInitialized() {
@@ -435,13 +500,13 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
sentPerMin := float64(sentTot) / mins
recvPerMin := float64(recvTot) / mins
logging.Logf("lsp ", "llm stats reqs=%d avg_sent=%d avg_recv=%d sent_total=%d recv_total=%d rpm=%.2f sent_per_min=%.0f recv_per_min=%.0f", reqs, avgSent, avgRecv, sentTot, recvTot, rpm, sentPerMin, recvPerMin)
- cleaned := strings.TrimSpace(text)
- if cleaned != "" {
- cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned)
- }
- if cleaned == "" {
- return nil, false
- }
+ cleaned := strings.TrimSpace(text)
+ if cleaned != "" {
+ cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned)
+ }
+ if cleaned == "" {
+ return nil, false
+ }
te, filter := computeTextEditAndFilter(cleaned, inParams, current, p)
rm := s.collectPromptRemovalEdits(p.TextDocument.URI)
@@ -467,86 +532,97 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun
// collectPromptRemovalEdits returns edits to remove all inline prompt markers.
// Supported form (inclusive):
-// - ";...;" where there is no space immediately after the first ';'
-// and no space immediately before the last ';'. An optional single space
-// after the trailing ';' is also removed for cleanliness.
+// - ";...;" where there is no space immediately after the first ';'
+// and no space immediately before the last ';'. An optional single space
+// after the trailing ';' is also removed for cleanliness.
+//
// Multiple markers per line are supported.
func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit {
- d := s.getDocument(uri)
- if d == nil || len(d.lines) == 0 {
- return nil
- }
- var edits []TextEdit
- for i, line := range d.lines {
- // If the line contains a double-semicolon trigger of the form
- // ";;text;" (no space after the ";;" and no space before the closing ';'),
- // remove the entire line.
- removeWholeLine := false
- {
- pos := 0
- for pos < len(line) {
- j := strings.Index(line[pos:], ";;")
- if j < 0 { break }
- j += pos
- // ensure there's a non-space after the two semicolons
- if j+2 >= len(line) || line[j+2] == ' ' { pos = j + 2; continue }
- // find closing ';' after the content
- k := strings.Index(line[j+2:], ";")
- if k < 0 { break }
- closeIdx := j + 2 + k
- // ensure char before closing ';' is not a space
- if closeIdx-1 < 0 || line[closeIdx-1] == ' ' { pos = closeIdx + 1; continue }
- removeWholeLine = true
- break
- }
- }
- if removeWholeLine {
- edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: 0}, End: Position{Line: i, Character: len(line)}}, NewText: ""})
- continue
- }
- // Scan for ;...; markers that have no spaces directly inside the semicolons
- startSemi := 0
- for startSemi < len(line) {
- j := strings.Index(line[startSemi:], ";")
- if j < 0 {
- break
- }
- j += startSemi
- k := strings.Index(line[j+1:], ";")
- if k < 0 {
- break
- }
- // Require no space immediately after the first ';'
- if j+1 >= len(line) || line[j+1] == ' ' {
- startSemi = j + 1
- continue
- }
- // Ignore patterns that start with double semicolon here; handled above
- if line[j+1] == ';' {
- startSemi = j + 2
- continue
- }
- // Index of the closing ';'
- closeIdx := j + 1 + k
- // Require no space immediately before the closing ';'
- if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
- startSemi = closeIdx + 1
- continue
- }
- // Require at least one character between the semicolons
- if closeIdx-(j+1) < 1 {
- startSemi = closeIdx + 1
- continue
- }
- endChar := closeIdx + 1 // include trailing ';'
- if endChar < len(line) && line[endChar] == ' ' {
- endChar++
- }
- edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""})
- startSemi = endChar
- }
- }
- return edits
+ d := s.getDocument(uri)
+ if d == nil || len(d.lines) == 0 {
+ return nil
+ }
+ var edits []TextEdit
+ for i, line := range d.lines {
+ // If the line contains a double-semicolon trigger of the form
+ // ";;text;" (no space after the ";;" and no space before the closing ';'),
+ // remove the entire line.
+ removeWholeLine := false
+ {
+ pos := 0
+ for pos < len(line) {
+ j := strings.Index(line[pos:], ";;")
+ if j < 0 {
+ break
+ }
+ j += pos
+ // ensure there's a non-space after the two semicolons
+ if j+2 >= len(line) || line[j+2] == ' ' {
+ pos = j + 2
+ continue
+ }
+ // find closing ';' after the content
+ k := strings.Index(line[j+2:], ";")
+ if k < 0 {
+ break
+ }
+ closeIdx := j + 2 + k
+ // ensure char before closing ';' is not a space
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
+ pos = closeIdx + 1
+ continue
+ }
+ removeWholeLine = true
+ break
+ }
+ }
+ if removeWholeLine {
+ edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: 0}, End: Position{Line: i, Character: len(line)}}, NewText: ""})
+ continue
+ }
+ // Scan for ;...; markers that have no spaces directly inside the semicolons
+ startSemi := 0
+ for startSemi < len(line) {
+ j := strings.Index(line[startSemi:], ";")
+ if j < 0 {
+ break
+ }
+ j += startSemi
+ k := strings.Index(line[j+1:], ";")
+ if k < 0 {
+ break
+ }
+ // Require no space immediately after the first ';'
+ if j+1 >= len(line) || line[j+1] == ' ' {
+ startSemi = j + 1
+ continue
+ }
+ // Ignore patterns that start with double semicolon here; handled above
+ if line[j+1] == ';' {
+ startSemi = j + 2
+ continue
+ }
+ // Index of the closing ';'
+ closeIdx := j + 1 + k
+ // Require no space immediately before the closing ';'
+ if closeIdx-1 < 0 || line[closeIdx-1] == ' ' {
+ startSemi = closeIdx + 1
+ continue
+ }
+ // Require at least one character between the semicolons
+ if closeIdx-(j+1) < 1 {
+ startSemi = closeIdx + 1
+ continue
+ }
+ endChar := closeIdx + 1 // include trailing ';'
+ if endChar < len(line) && line[endChar] == ' ' {
+ endChar++
+ }
+ edits = append(edits, TextEdit{Range: Range{Start: Position{Line: i, Character: j}, End: Position{Line: i, Character: endChar}}, NewText: ""})
+ startSemi = endChar
+ }
+ }
+ return edits
}
func inParamList(current string, cursor int) bool {
@@ -559,14 +635,14 @@ func inParamList(current string, cursor int) bool {
}
func buildPrompts(inParams bool, p CompletionParams, above, current, below, funcCtx string) (string, string) {
- if inParams {
- sys := "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types."
- user := fmt.Sprintf("Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: %s\nCurrent line (cursor at %d): %s", funcCtx, p.Position.Character, current)
- return sys, user
- }
- sys := "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression)."
- user := fmt.Sprintf("Provide the next likely code to insert at the cursor.\nFile: %s\nFunction/context: %s\nAbove line: %s\nCurrent line (cursor at character %d): %s\nBelow line: %s\nOnly return the completion snippet.", p.TextDocument.URI, funcCtx, above, p.Position.Character, current, below)
- return sys, user
+ if inParams {
+ sys := "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types."
+ user := fmt.Sprintf("Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: %s\nCurrent line (cursor at %d): %s", funcCtx, p.Position.Character, current)
+ return sys, user
+ }
+ sys := "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression)."
+ user := fmt.Sprintf("Provide the next likely code to insert at the cursor.\nFile: %s\nFunction/context: %s\nAbove line: %s\nCurrent line (cursor at character %d): %s\nBelow line: %s\nOnly return the completion snippet.", p.TextDocument.URI, funcCtx, above, p.Position.Character, current, below)
+ return sys, user
}
func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) {
@@ -612,7 +688,7 @@ func computeWordStart(current string, at int) int {
}
func isIdentChar(ch byte) bool {
- return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'
+ return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_'
}
// stripDuplicateAssignmentPrefix removes a duplicated assignment prefix (e.g.,
@@ -620,42 +696,42 @@ func isIdentChar(ch byte) bool {
// already appears immediately to the left of the cursor on the current line.
// Also handles simple '=' assignments.
func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string {
- s2 := strings.TrimLeft(suggestion, " \t")
- // Prefer := if present at end of prefix
- if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) {
- // Ensure only spaces follow in prefix (cursor at end of prefix segment)
- tail := prefixBeforeCursor[idx+2:]
- if strings.TrimSpace(tail) == "" {
- // Move left to include identifier and spaces
- start := idx - 1
- for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') {
- start--
- }
- start++
- seg := strings.TrimRight(prefixBeforeCursor[start:idx+2], " \t")
- if strings.HasPrefix(s2, seg) {
- return strings.TrimLeft(s2[len(seg):], " \t")
- }
- }
- }
- // Fallback to plain '=' if present
- if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 {
- if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') { // not := (handled above)
- tail := prefixBeforeCursor[idx+1:]
- if strings.TrimSpace(tail) == "" {
- start := idx - 1
- for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') {
- start--
- }
- start++
- seg := strings.TrimRight(prefixBeforeCursor[start:idx+1], " \t")
- if strings.HasPrefix(s2, seg) {
- return strings.TrimLeft(s2[len(seg):], " \t")
- }
- }
- }
- }
- return suggestion
+ s2 := strings.TrimLeft(suggestion, " \t")
+ // Prefer := if present at end of prefix
+ if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) {
+ // Ensure only spaces follow in prefix (cursor at end of prefix segment)
+ tail := prefixBeforeCursor[idx+2:]
+ if strings.TrimSpace(tail) == "" {
+ // Move left to include identifier and spaces
+ start := idx - 1
+ for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') {
+ start--
+ }
+ start++
+ seg := strings.TrimRight(prefixBeforeCursor[start:idx+2], " \t")
+ if strings.HasPrefix(s2, seg) {
+ return strings.TrimLeft(s2[len(seg):], " \t")
+ }
+ }
+ }
+ // Fallback to plain '=' if present
+ if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 {
+ if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') { // not := (handled above)
+ tail := prefixBeforeCursor[idx+1:]
+ if strings.TrimSpace(tail) == "" {
+ start := idx - 1
+ for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') {
+ start--
+ }
+ start++
+ seg := strings.TrimRight(prefixBeforeCursor[start:idx+1], " \t")
+ if strings.HasPrefix(s2, seg) {
+ return strings.TrimLeft(s2[len(seg):], " \t")
+ }
+ }
+ }
+ }
+ return suggestion
}
func labelForCompletion(cleaned, filter string) string {
diff --git a/internal/lsp/handlers_test.go b/internal/lsp/handlers_test.go
index 613835a..0ba29cf 100644
--- a/internal/lsp/handlers_test.go
+++ b/internal/lsp/handlers_test.go
@@ -1,286 +1,298 @@
package lsp
import (
- "encoding/json"
- "strings"
- "testing"
+ "encoding/json"
+ "strings"
+ "testing"
)
func TestInParamList(t *testing.T) {
- line := "func foo(a int, b string) int {"
- if !inParamList(line, 15) { // inside params
- t.Fatalf("expected inParamList true for cursor inside params")
- }
- if inParamList(line, 2) { // before 'func'
- t.Fatalf("expected inParamList false for cursor before params")
- }
- if inParamList(line, len(line)) { // after ')'
- t.Fatalf("expected inParamList false for cursor after params")
- }
+ line := "func foo(a int, b string) int {"
+ if !inParamList(line, 15) { // inside params
+ t.Fatalf("expected inParamList true for cursor inside params")
+ }
+ if inParamList(line, 2) { // before 'func'
+ t.Fatalf("expected inParamList false for cursor before params")
+ }
+ if inParamList(line, len(line)) { // after ')'
+ t.Fatalf("expected inParamList false for cursor after params")
+ }
}
func TestComputeWordStart(t *testing.T) {
- current := "fmt.Prin"
- // Cursor after the word (index 8)
- got := computeWordStart(current, 8)
- // should stop after the dot at index 4
- if want := 4; got != want {
- t.Fatalf("computeWordStart got %d want %d", got, want)
- }
+ current := "fmt.Prin"
+ // Cursor after the word (index 8)
+ got := computeWordStart(current, 8)
+ // should stop after the dot at index 4
+ if want := 4; got != want {
+ t.Fatalf("computeWordStart got %d want %d", got, want)
+ }
}
func TestComputeTextEditAndFilter_InParams(t *testing.T) {
- current := "func foo(a int, b string) {" // ')' at index 26
- p := CompletionParams{Position: Position{Line: 10, Character: 20}}
- te, filter := computeTextEditAndFilter("x int, y string", true, current, p)
+ current := "func foo(a int, b string) {" // ')' at index 26
+ p := CompletionParams{Position: Position{Line: 10, Character: 20}}
+ te, filter := computeTextEditAndFilter("x int, y string", true, current, p)
- if te == nil {
- t.Fatalf("expected TextEdit")
- }
- // left should be after '(' which is at index 8
- if te.Range.Start.Line != 10 || te.Range.Start.Character != 9 {
- t.Fatalf("start got line=%d char=%d want line=10 char=9", te.Range.Start.Line, te.Range.Start.Character)
- }
- // right should clamp to cursor (20)
- if te.Range.End.Line != 10 || te.Range.End.Character != 20 {
- t.Fatalf("end got line=%d char=%d want line=10 char=20", te.Range.End.Line, te.Range.End.Character)
- }
- if filter == "" {
- t.Fatalf("expected non-empty filter inside params")
- }
+ if te == nil {
+ t.Fatalf("expected TextEdit")
+ }
+ // left should be after '(' which is at index 8
+ if te.Range.Start.Line != 10 || te.Range.Start.Character != 9 {
+ t.Fatalf("start got line=%d char=%d want line=10 char=9", te.Range.Start.Line, te.Range.Start.Character)
+ }
+ // right should clamp to cursor (20)
+ if te.Range.End.Line != 10 || te.Range.End.Character != 20 {
+ t.Fatalf("end got line=%d char=%d want line=10 char=20", te.Range.End.Line, te.Range.End.Character)
+ }
+ if filter == "" {
+ t.Fatalf("expected non-empty filter inside params")
+ }
}
func TestComputeTextEditAndFilter_Word(t *testing.T) {
- current := "fmt.Prin"
- p := CompletionParams{Position: Position{Line: 2, Character: len(current)}}
- te, filter := computeTextEditAndFilter("Println", false, current, p)
- if te == nil {
- t.Fatalf("expected TextEdit")
- }
- if te.Range.Start.Character != 4 || te.Range.End.Character != len(current) {
- t.Fatalf("range chars got %d..%d want 4..%d", te.Range.Start.Character, te.Range.End.Character, len(current))
- }
- if filter != "Prin" {
- t.Fatalf("filter got %q want %q", filter, "Prin")
- }
+ current := "fmt.Prin"
+ p := CompletionParams{Position: Position{Line: 2, Character: len(current)}}
+ te, filter := computeTextEditAndFilter("Println", false, current, p)
+ if te == nil {
+ t.Fatalf("expected TextEdit")
+ }
+ if te.Range.Start.Character != 4 || te.Range.End.Character != len(current) {
+ t.Fatalf("range chars got %d..%d want 4..%d", te.Range.Start.Character, te.Range.End.Character, len(current))
+ }
+ if filter != "Prin" {
+ t.Fatalf("filter got %q want %q", filter, "Prin")
+ }
}
func TestLabelForCompletion(t *testing.T) {
- if got := labelForCompletion("Println", "Pri"); got != "Println" {
- t.Fatalf("label mismatch got %q want %q", got, "Println")
- }
- if got := labelForCompletion("Println", "X"); got != "X" {
- t.Fatalf("label mismatch with filter got %q want %q", got, "X")
- }
- if got := labelForCompletion("Println\nmore", ""); got != "Println" {
- t.Fatalf("label firstLine got %q want %q", got, "Println")
- }
+ if got := labelForCompletion("Println", "Pri"); got != "Println" {
+ t.Fatalf("label mismatch got %q want %q", got, "Println")
+ }
+ if got := labelForCompletion("Println", "X"); got != "X" {
+ t.Fatalf("label mismatch with filter got %q want %q", got, "X")
+ }
+ if got := labelForCompletion("Println\nmore", ""); got != "Println" {
+ t.Fatalf("label firstLine got %q want %q", got, "Println")
+ }
}
func TestBuildPrompts_InParams(t *testing.T) {
- p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 12}}
- sys, user := buildPrompts(true, p, "above", "func foo(", "below", "func foo(")
- if sys == "" || user == "" {
- t.Fatalf("expected non-empty prompts")
- }
- if want := "function signatures"; !contains(sys, want) {
- t.Fatalf("system prompt missing %q: %q", want, sys)
- }
- if want := "parameter list"; !contains(user, want) {
- t.Fatalf("user prompt missing %q: %q", want, user)
- }
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 12}}
+ sys, user := buildPrompts(true, p, "above", "func foo(", "below", "func foo(")
+ if sys == "" || user == "" {
+ t.Fatalf("expected non-empty prompts")
+ }
+ if want := "function signatures"; !contains(sys, want) {
+ t.Fatalf("system prompt missing %q: %q", want, sys)
+ }
+ if want := "parameter list"; !contains(user, want) {
+ t.Fatalf("user prompt missing %q: %q", want, user)
+ }
}
func TestBuildPrompts_Outside(t *testing.T) {
- p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 5}}
- sys, user := buildPrompts(false, p, "ab", "cur", "be", "fnctx")
- if sys == "" || user == "" {
- t.Fatalf("expected non-empty prompts")
- }
- if want := "completion engine"; !contains(sys, want) {
- t.Fatalf("system prompt missing %q: %q", want, sys)
- }
- if want := "Provide the next likely code"; !contains(user, want) {
- t.Fatalf("user prompt missing %q: %q", want, user)
- }
+ p := CompletionParams{TextDocument: TextDocumentIdentifier{URI: "file:///t.go"}, Position: Position{Line: 1, Character: 5}}
+ sys, user := buildPrompts(false, p, "ab", "cur", "be", "fnctx")
+ if sys == "" || user == "" {
+ t.Fatalf("expected non-empty prompts")
+ }
+ if want := "completion engine"; !contains(sys, want) {
+ t.Fatalf("system prompt missing %q: %q", want, sys)
+ }
+ if want := "Provide the next likely code"; !contains(user, want) {
+ t.Fatalf("user prompt missing %q: %q", want, user)
+ }
}
func TestComputeTextEditAndFilter_NoParensFallback(t *testing.T) {
- current := "func foo bar" // no parentheses
- cursor := len(current)
- p := CompletionParams{Position: Position{Line: 0, Character: cursor}}
- te, filter := computeTextEditAndFilter("baz", true, current, p)
- if te == nil {
- t.Fatalf("expected TextEdit from fallback path")
- }
- // fallback should behave like word edit; start at last space + 1
- lastSpace := strings.LastIndex(current, " ")
- if te.Range.Start.Character != lastSpace+1 || te.Range.End.Character != cursor {
- t.Fatalf("range got %d..%d want %d..%d", te.Range.Start.Character, te.Range.End.Character, lastSpace+1, cursor)
- }
- if filter != "bar" {
- t.Fatalf("filter got %q want %q", filter, "bar")
- }
+ current := "func foo bar" // no parentheses
+ cursor := len(current)
+ p := CompletionParams{Position: Position{Line: 0, Character: cursor}}
+ te, filter := computeTextEditAndFilter("baz", true, current, p)
+ if te == nil {
+ t.Fatalf("expected TextEdit from fallback path")
+ }
+ // fallback should behave like word edit; start at last space + 1
+ lastSpace := strings.LastIndex(current, " ")
+ if te.Range.Start.Character != lastSpace+1 || te.Range.End.Character != cursor {
+ t.Fatalf("range got %d..%d want %d..%d", te.Range.Start.Character, te.Range.End.Character, lastSpace+1, cursor)
+ }
+ if filter != "bar" {
+ t.Fatalf("filter got %q want %q", filter, "bar")
+ }
}
// small helper to avoid importing strings
-func contains(s, sub string) bool { return len(s) >= len(sub) && (func() bool { i := 0; for i+len(sub) <= len(s) { if s[i:i+len(sub)] == sub { return true }; i++ }; return false })() }
-
-
-
+func contains(s, sub string) bool {
+ return len(s) >= len(sub) && (func() bool {
+ i := 0
+ for i+len(sub) <= len(s) {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ i++
+ }
+ return false
+ })()
+}
func TestCollectPromptRemovalEdits(t *testing.T) {
- s := newTestServer()
- uri := "file:///x.go"
- src := `keep ;tag; this and ;another; that
+ s := newTestServer()
+ uri := "file:///x.go"
+ src := `keep ;tag; this and ;another; that
no markers here`
- s.setDocument(uri, src)
- edits := s.collectPromptRemovalEdits(uri)
- if len(edits) != 2 {
- t.Fatalf("expected 2 edits, got %d", len(edits))
- }
- // First occurrence ;tag;
- e0 := edits[0]
- if e0.Range.Start.Line != 0 {
- t.Fatalf("e0 start line=%d want 0", e0.Range.Start.Line)
- }
- if s.getDocument(uri).lines[0][e0.Range.Start.Character:e0.Range.Start.Character+1] != ";" {
- t.Fatalf("e0 start not at ;")
- }
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 2 {
+ t.Fatalf("expected 2 edits, got %d", len(edits))
+ }
+ // First occurrence ;tag;
+ e0 := edits[0]
+ if e0.Range.Start.Line != 0 {
+ t.Fatalf("e0 start line=%d want 0", e0.Range.Start.Line)
+ }
+ if s.getDocument(uri).lines[0][e0.Range.Start.Character:e0.Range.Start.Character+1] != ";" {
+ t.Fatalf("e0 start not at ;")
+ }
}
func TestCollectPromptRemovalEdits_SkipSpacedMarkers(t *testing.T) {
- s := newTestServer()
- uri := "file:///y.go"
- // Only ;ok; should be removed; "; spaced ;" must be ignored
- src := `prefix ;ok; middle ; spaced ; suffix`
- s.setDocument(uri, src)
- edits := s.collectPromptRemovalEdits(uri)
- if len(edits) != 1 {
- t.Fatalf("expected 1 edit (only ;ok;), got %d", len(edits))
- }
- // Ensure the removed region starts at the first ';' of ;ok;
- line := s.getDocument(uri).lines[0]
- wantStart := strings.Index(line, ";ok;")
- if wantStart < 0 {
- t.Fatalf("test setup: could not find ;ok; in %q", line)
- }
- if edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != wantStart {
- t.Fatalf("unexpected first edit start: got line=%d char=%d want line=0 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, wantStart)
- }
+ s := newTestServer()
+ uri := "file:///y.go"
+ // Only ;ok; should be removed; "; spaced ;" must be ignored
+ src := `prefix ;ok; middle ; spaced ; suffix`
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 1 {
+ t.Fatalf("expected 1 edit (only ;ok;), got %d", len(edits))
+ }
+ // Ensure the removed region starts at the first ';' of ;ok;
+ line := s.getDocument(uri).lines[0]
+ wantStart := strings.Index(line, ";ok;")
+ if wantStart < 0 {
+ t.Fatalf("test setup: could not find ;ok; in %q", line)
+ }
+ if edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != wantStart {
+ t.Fatalf("unexpected first edit start: got line=%d char=%d want line=0 char=%d", edits[0].Range.Start.Line, edits[0].Range.Start.Character, wantStart)
+ }
}
func TestCollectPromptRemovalEdits_DoubleSemicolonRemovesWholeLine(t *testing.T) {
- s := newTestServer()
- uri := "file:///z.go"
- line0 := "keep"
- line1 := ";;todo; remove this whole line"
- line2 := "keep ;ok; end"
- src := strings.Join([]string{line0, line1, line2}, "\n")
- s.setDocument(uri, src)
- edits := s.collectPromptRemovalEdits(uri)
- if len(edits) != 2 {
- t.Fatalf("expected 2 edits (whole line + ;ok;), got %d", len(edits))
- }
- // Find the whole-line removal for line1
- found := false
- for _, e := range edits {
- if e.Range.Start.Line == 1 && e.Range.Start.Character == 0 && e.Range.End.Line == 1 && e.Range.End.Character == len(line1) {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("did not find whole-line removal edit for line 1")
- }
+ s := newTestServer()
+ uri := "file:///z.go"
+ line0 := "keep"
+ line1 := ";;todo; remove this whole line"
+ line2 := "keep ;ok; end"
+ src := strings.Join([]string{line0, line1, line2}, "\n")
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 2 {
+ t.Fatalf("expected 2 edits (whole line + ;ok;), got %d", len(edits))
+ }
+ // Find the whole-line removal for line1
+ found := false
+ for _, e := range edits {
+ if e.Range.Start.Line == 1 && e.Range.Start.Character == 0 && e.Range.End.Line == 1 && e.Range.End.Character == len(line1) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("did not find whole-line removal edit for line 1")
+ }
}
func TestCollectPromptRemovalEdits_SkipSpacedDouble(t *testing.T) {
- s := newTestServer()
- uri := "file:///w.go"
- src := "prefix ;; spaced ; suffix"
- s.setDocument(uri, src)
- edits := s.collectPromptRemovalEdits(uri)
- if len(edits) != 0 {
- t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits))
- }
+ s := newTestServer()
+ uri := "file:///w.go"
+ src := "prefix ;; spaced ; suffix"
+ s.setDocument(uri, src)
+ edits := s.collectPromptRemovalEdits(uri)
+ if len(edits) != 0 {
+ t.Fatalf("expected 0 edits for spaced double-semicolon trigger, got %d", len(edits))
+ }
}
func TestInstructionFromSelection_OrderPreference(t *testing.T) {
- // Earliest wins within a line
- line := "code /*block first*/ // later ;tag;"
- instr, cleaned := instructionFromSelection(line)
- if instr != "block first" {
- t.Fatalf("want block comment instr, got %q", instr)
- }
- if strings.Contains(cleaned, "block first") {
- t.Fatalf("cleaned should not contain the block comment")
- }
+ // Earliest wins within a line
+ line := "code /*block first*/ // later ;tag;"
+ instr, cleaned := instructionFromSelection(line)
+ if instr != "block first" {
+ t.Fatalf("want block comment instr, got %q", instr)
+ }
+ if strings.Contains(cleaned, "block first") {
+ t.Fatalf("cleaned should not contain the block comment")
+ }
}
func TestInstructionFromSelection_SemicolonBeatsCommentIfEarlier(t *testing.T) {
- line := ";do this;// later"
- instr, cleaned := instructionFromSelection(line)
- if instr != "do this" {
- t.Fatalf("want semicolon instr, got %q", instr)
- }
- if strings.Contains(cleaned, ";do this;") {
- t.Fatalf("cleaned should have semicolon tag removed")
- }
+ line := ";do this;// later"
+ instr, cleaned := instructionFromSelection(line)
+ if instr != "do this" {
+ t.Fatalf("want semicolon instr, got %q", instr)
+ }
+ if strings.Contains(cleaned, ";do this;") {
+ t.Fatalf("cleaned should have semicolon tag removed")
+ }
}
func TestInstructionFromSelection_HTMLAndLineComments(t *testing.T) {
- line := "prefix <!-- html note --> suffix"
- instr, cleaned := instructionFromSelection(line)
- if instr != "html note" {
- t.Fatalf("want html note, got %q", instr)
- }
- if strings.Contains(cleaned, "<!--") || strings.Contains(cleaned, "-->") {
- t.Fatalf("cleaned should remove html comment markers")
- }
+ line := "prefix <!-- html note --> suffix"
+ instr, cleaned := instructionFromSelection(line)
+ if instr != "html note" {
+ t.Fatalf("want html note, got %q", instr)
+ }
+ if strings.Contains(cleaned, "<!--") || strings.Contains(cleaned, "-->") {
+ t.Fatalf("cleaned should remove html comment markers")
+ }
}
func TestStripDuplicateAssignmentPrefix(t *testing.T) {
- prefix := "matrix := "
- sug := "matrix := NewMatrix(2,2)"
- got := stripDuplicateAssignmentPrefix(prefix, sug)
- if got != "NewMatrix(2,2)" {
- t.Fatalf("dup strip failed: got %q", got)
- }
- // '=' variant
- prefix2 := "x = "
- sug2 := "x = y + 1"
- got2 := stripDuplicateAssignmentPrefix(prefix2, sug2)
- if got2 != "y + 1" {
- t.Fatalf("dup strip '=' failed: got %q", got2)
- }
+ prefix := "matrix := "
+ sug := "matrix := NewMatrix(2,2)"
+ got := stripDuplicateAssignmentPrefix(prefix, sug)
+ if got != "NewMatrix(2,2)" {
+ t.Fatalf("dup strip failed: got %q", got)
+ }
+ // '=' variant
+ prefix2 := "x = "
+ sug2 := "x = y + 1"
+ got2 := stripDuplicateAssignmentPrefix(prefix2, sug2)
+ if got2 != "y + 1" {
+ t.Fatalf("dup strip '=' failed: got %q", got2)
+ }
}
func TestRangesOverlap(t *testing.T) {
- a := Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 0}}
- b := Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 4, Character: 1}}
- if !rangesOverlap(a, b) { t.Fatalf("expected overlap") }
- c := Range{Start: Position{Line: 4, Character: 1}, End: Position{Line: 5, Character: 0}}
- if rangesOverlap(a, c) { t.Fatalf("expected no overlap") }
+ a := Range{Start: Position{Line: 1, Character: 2}, End: Position{Line: 3, Character: 0}}
+ b := Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 4, Character: 1}}
+ if !rangesOverlap(a, b) {
+ t.Fatalf("expected overlap")
+ }
+ c := Range{Start: Position{Line: 4, Character: 1}, End: Position{Line: 5, Character: 0}}
+ if rangesOverlap(a, c) {
+ t.Fatalf("expected no overlap")
+ }
}
func TestDiagnosticsInRange_Filtering(t *testing.T) {
- s := newTestServer()
- sel := Range{Start: Position{Line: 10, Character: 0}, End: Position{Line: 12, Character: 5}}
- // Build a fake context payload with three diagnostics: one inside, one outside, one touching boundary
- ctx := CodeActionContext{Diagnostics: []Diagnostic{
- {Range: Range{Start: Position{Line: 11, Character: 0}, End: Position{Line: 11, Character: 10}}, Message: "inside"},
- {Range: Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 3, Character: 0}}, Message: "outside"},
- {Range: Range{Start: Position{Line: 12, Character: 5}, End: Position{Line: 12, Character: 8}}, Message: "touch"},
- }}
- data, _ := json.Marshal(ctx)
- got := s.diagnosticsInRange(json.RawMessage(data), sel)
- if len(got) != 2 {
- t.Fatalf("expected 2 diagnostics in range, got %d", len(got))
- }
- msgs := []string{got[0].Message, got[1].Message}
- joined := strings.Join(msgs, ",")
- if !strings.Contains(joined, "inside") || !strings.Contains(joined, "touch") {
- t.Fatalf("unexpected diagnostics: %v", msgs)
- }
+ s := newTestServer()
+ sel := Range{Start: Position{Line: 10, Character: 0}, End: Position{Line: 12, Character: 5}}
+ // Build a fake context payload with three diagnostics: one inside, one outside, one touching boundary
+ ctx := CodeActionContext{Diagnostics: []Diagnostic{
+ {Range: Range{Start: Position{Line: 11, Character: 0}, End: Position{Line: 11, Character: 10}}, Message: "inside"},
+ {Range: Range{Start: Position{Line: 2, Character: 0}, End: Position{Line: 3, Character: 0}}, Message: "outside"},
+ {Range: Range{Start: Position{Line: 12, Character: 5}, End: Position{Line: 12, Character: 8}}, Message: "touch"},
+ }}
+ data, _ := json.Marshal(ctx)
+ got := s.diagnosticsInRange(json.RawMessage(data), sel)
+ if len(got) != 2 {
+ t.Fatalf("expected 2 diagnostics in range, got %d", len(got))
+ }
+ msgs := []string{got[0].Message, got[1].Message}
+ joined := strings.Join(msgs, ",")
+ if !strings.Contains(joined, "inside") || !strings.Contains(joined, "touch") {
+ t.Fatalf("unexpected diagnostics: %v", msgs)
+ }
}
diff --git a/internal/lsp/server.go b/internal/lsp/server.go
index 81cb661..f828ec8 100644
--- a/internal/lsp/server.go
+++ b/internal/lsp/server.go
@@ -27,6 +27,7 @@ type Server struct {
windowLines int
maxContextTokens int
noDiskIO bool
+ triggerChars []string
// LLM request stats
llmReqTotal int64
llmSentBytesTotal int64
@@ -37,33 +38,47 @@ type Server struct {
// ServerOptions collects configuration for NewServer to avoid long parameter lists.
type ServerOptions struct {
- LogContext bool
- MaxTokens int
- ContextMode string
- WindowLines int
- MaxContextTokens int
- NoDiskIO bool
- Client llm.Client
+ LogContext bool
+ MaxTokens int
+ ContextMode string
+ WindowLines int
+ MaxContextTokens int
+ NoDiskIO bool
+ Client llm.Client
+ TriggerCharacters []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 { maxTokens = 500 }
- s.maxTokens = maxTokens
- contextMode := opts.ContextMode
- if contextMode == "" { contextMode = "file-on-new-func" }
- windowLines := opts.WindowLines
- if windowLines <= 0 { windowLines = 120 }
- maxContextTokens := opts.MaxContextTokens
- if maxContextTokens <= 0 { maxContextTokens = 2000 }
- s.contextMode = contextMode
- s.windowLines = windowLines
- s.maxContextTokens = maxContextTokens
- s.noDiskIO = opts.NoDiskIO
- s.startTime = time.Now()
- s.llmClient = opts.Client
- return s
+ s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext}
+ maxTokens := opts.MaxTokens
+ if maxTokens <= 0 {
+ maxTokens = 500
+ }
+ s.maxTokens = maxTokens
+ contextMode := opts.ContextMode
+ if contextMode == "" {
+ contextMode = "file-on-new-func"
+ }
+ windowLines := opts.WindowLines
+ if windowLines <= 0 {
+ windowLines = 120
+ }
+ maxContextTokens := opts.MaxContextTokens
+ if maxContextTokens <= 0 {
+ maxContextTokens = 2000
+ }
+ s.contextMode = contextMode
+ s.windowLines = windowLines
+ s.maxContextTokens = maxContextTokens
+ s.noDiskIO = opts.NoDiskIO
+ s.startTime = time.Now()
+ s.llmClient = opts.Client
+ if len(opts.TriggerCharacters) == 0 {
+ s.triggerChars = []string{".", ":", "/", "_"}
+ } else {
+ s.triggerChars = append([]string{}, opts.TriggerCharacters...)
+ }
+ return s
}
func (s *Server) Run() error {
diff --git a/internal/lsp/transport.go b/internal/lsp/transport.go
index dfdb5fc..4d352f8 100644
--- a/internal/lsp/transport.go
+++ b/internal/lsp/transport.go
@@ -1,13 +1,13 @@
package lsp
import (
- "encoding/json"
- "fmt"
- "hexai/internal/logging"
- "io"
- "net/textproto"
- "strconv"
- "strings"
+ "encoding/json"
+ "fmt"
+ "hexai/internal/logging"
+ "io"
+ "net/textproto"
+ "strconv"
+ "strings"
)
func (s *Server) readMessage() ([]byte, error) {
@@ -48,17 +48,17 @@ func (s *Server) readMessage() ([]byte, error) {
func (s *Server) writeMessage(v any) {
data, err := json.Marshal(v)
- if err != nil {
- logging.Logf("lsp ", "marshal error: %v", err)
- return
- }
+ if err != nil {
+ logging.Logf("lsp ", "marshal error: %v", err)
+ return
+ }
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 {
- logging.Logf("lsp ", "write body error: %v", err)
- return
- }
+ 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 {
+ logging.Logf("lsp ", "write body error: %v", err)
+ return
+ }
}
diff --git a/internal/lsp/types.go b/internal/lsp/types.go
index 00e483e..dbd7331 100644
--- a/internal/lsp/types.go
+++ b/internal/lsp/types.go
@@ -34,9 +34,9 @@ type ServerInfo struct {
}
type ServerCapabilities struct {
- TextDocumentSync any `json:"textDocumentSync,omitempty"`
- CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"`
- CodeActionProvider bool `json:"codeActionProvider,omitempty"`
+ TextDocumentSync any `json:"textDocumentSync,omitempty"`
+ CompletionProvider *CompletionOptions `json:"completionProvider,omitempty"`
+ CodeActionProvider bool `json:"codeActionProvider,omitempty"`
}
type CompletionOptions struct {
@@ -50,16 +50,16 @@ type CompletionList struct {
}
type CompletionItem struct {
- Label string `json:"label"`
- Kind int `json:"kind,omitempty"`
- Detail string `json:"detail,omitempty"`
- InsertText string `json:"insertText,omitempty"`
- InsertTextFormat int `json:"insertTextFormat,omitempty"`
- FilterText string `json:"filterText,omitempty"`
- TextEdit *TextEdit `json:"textEdit,omitempty"`
- AdditionalTextEdits []TextEdit `json:"additionalTextEdits,omitempty"`
- SortText string `json:"sortText,omitempty"`
- Documentation string `json:"documentation,omitempty"`
+ Label string `json:"label"`
+ Kind int `json:"kind,omitempty"`
+ Detail string `json:"detail,omitempty"`
+ InsertText string `json:"insertText,omitempty"`
+ InsertTextFormat int `json:"insertTextFormat,omitempty"`
+ FilterText string `json:"filterText,omitempty"`
+ TextEdit *TextEdit `json:"textEdit,omitempty"`
+ AdditionalTextEdits []TextEdit `json:"additionalTextEdits,omitempty"`
+ SortText string `json:"sortText,omitempty"`
+ Documentation string `json:"documentation,omitempty"`
}
// LSP param types (subset)
@@ -104,39 +104,39 @@ type Position struct {
}
type CompletionParams struct {
- TextDocument TextDocumentIdentifier `json:"textDocument"`
- Position Position `json:"position"`
- Context any `json:"context,omitempty"`
+ TextDocument TextDocumentIdentifier `json:"textDocument"`
+ Position Position `json:"position"`
+ Context any `json:"context,omitempty"`
}
// Code actions
type CodeActionParams struct {
- TextDocument TextDocumentIdentifier `json:"textDocument"`
- Range Range `json:"range"`
- Context json.RawMessage `json:"context,omitempty"`
+ TextDocument TextDocumentIdentifier `json:"textDocument"`
+ Range Range `json:"range"`
+ Context json.RawMessage `json:"context,omitempty"`
}
type WorkspaceEdit struct {
- Changes map[string][]TextEdit `json:"changes,omitempty"`
+ Changes map[string][]TextEdit `json:"changes,omitempty"`
}
type CodeAction struct {
- Title string `json:"title"`
- Kind string `json:"kind,omitempty"`
- Edit *WorkspaceEdit `json:"edit,omitempty"`
+ Title string `json:"title"`
+ Kind string `json:"kind,omitempty"`
+ Edit *WorkspaceEdit `json:"edit,omitempty"`
}
// Diagnostics (subset needed for code action context)
type Diagnostic struct {
- Range Range `json:"range"`
- Message string `json:"message"`
- Severity int `json:"severity,omitempty"`
- Code interface{} `json:"code,omitempty"`
- Source string `json:"source,omitempty"`
+ Range Range `json:"range"`
+ Message string `json:"message"`
+ Severity int `json:"severity,omitempty"`
+ Code interface{} `json:"code,omitempty"`
+ Source string `json:"source,omitempty"`
}
type CodeActionContext struct {
- Diagnostics []Diagnostic `json:"diagnostics"`
+ Diagnostics []Diagnostic `json:"diagnostics"`
}
// Range defines a text range in a document.