summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-17 00:06:00 +0300
committerPaul Buetow <paul@buetow.org>2025-08-17 00:06:00 +0300
commitdc383b4faef881f3bb22816f42c53a79236a4152 (patch)
tree7c6a48487fc1d51fed72ea5d15618d133132cdaa
parent6a1d48036105e92193aef11a15a77a569eeb1562 (diff)
lsp/config: make completion trigger characters configurable
- Add trigger_characters to JSON config and ServerOptions - Store on server and advertise in initialize - Update README and example config - Preserve previous defaults when unset
-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 curr