diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-17 00:06:00 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-17 00:06:00 +0300 |
| commit | dc383b4faef881f3bb22816f42c53a79236a4152 (patch) | |
| tree | 7c6a48487fc1d51fed72ea5d15618d133132cdaa | |
| parent | 6a1d48036105e92193aef11a15a77a569eeb1562 (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.md | 5 | ||||
| -rw-r--r-- | cmd/hexai/main.go | 33 | ||||
| -rw-r--r-- | config.json.example | 2 | ||||
| -rw-r--r-- | internal/llm/ollama.go | 188 | ||||
| -rw-r--r-- | internal/llm/provider.go | 62 | ||||
| -rw-r--r-- | internal/logging/logging.go | 41 | ||||
| -rw-r--r-- | internal/lsp/context.go | 116 | ||||
| -rw-r--r-- | internal/lsp/context_test.go | 100 | ||||
| -rw-r--r-- | internal/lsp/document.go | 86 | ||||
| -rw-r--r-- | internal/lsp/document_test.go | 104 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 740 | ||||
| -rw-r--r-- | internal/lsp/handlers_test.go | 460 | ||||
| -rw-r--r-- | internal/lsp/server.go | 63 | ||||
| -rw-r--r-- | internal/lsp/transport.go | 38 | ||||
| -rw-r--r-- | internal/lsp/types.go | 58 |
15 files changed, 1110 insertions, 986 deletions
@@ -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 |
