diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-06 11:14:27 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-06 11:14:27 +0300 |
| commit | fb267966f7840df222338f57023273a993a73c9a (patch) | |
| tree | d10066412f08b386a6f9fe9289f27124c6ebe9d6 /docs/coverage.html | |
| parent | 1bf4251a7edbd00902f22db77031d0f998569614 (diff) | |
use TOML not JSON for configuration
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 2838 |
1 files changed, 1508 insertions, 1330 deletions
diff --git a/docs/coverage.html b/docs/coverage.html index d940029..49b89df 100644 --- a/docs/coverage.html +++ b/docs/coverage.html @@ -59,7 +59,7 @@ <option value="file1">codeberg.org/snonux/hexai/cmd/hexai/main.go (0.0%)</option> - <option value="file2">codeberg.org/snonux/hexai/internal/appconfig/config.go (86.9%)</option> + <option value="file2">codeberg.org/snonux/hexai/internal/appconfig/config.go (87.0%)</option> <option value="file3">codeberg.org/snonux/hexai/internal/hexaicli/run.go (91.4%)</option> @@ -83,19 +83,19 @@ <option value="file13">codeberg.org/snonux/hexai/internal/lsp/document.go (90.1%)</option> - <option value="file14">codeberg.org/snonux/hexai/internal/lsp/handlers.go (90.5%)</option> + <option value="file14">codeberg.org/snonux/hexai/internal/lsp/handlers.go (92.1%)</option> <option value="file15">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (81.2%)</option> - <option value="file16">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (86.1%)</option> + <option value="file16">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (87.5%)</option> - <option value="file17">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (87.4%)</option> + <option value="file17">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (88.9%)</option> <option value="file18">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (75.0%)</option> <option value="file19">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (55.6%)</option> - <option value="file20">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (88.2%)</option> + <option value="file20">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (88.5%)</option> <option value="file21">codeberg.org/snonux/hexai/internal/lsp/server.go (77.9%)</option> @@ -178,117 +178,118 @@ func main() <span class="cov0" title="0">{ } </pre> - <pre class="file" id="file2" style="display: none">// Summary: Application configuration model and loader; reads ~/.config/hexai/config.json and merges defaults. + <pre class="file" id="file2" style="display: none">// Summary: Application configuration model and loader; reads ~/.config/hexai/config.toml and merges defaults. package appconfig import ( - "encoding/json" - "fmt" - "log" - "os" - "path/filepath" - "slices" - "strconv" - "strings" + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2" ) -// App holds user-configurable settings read from ~/.config/hexai/config.json. +// App holds user-configurable settings read from ~/.config/hexai/config.toml. type App 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"` + MaxTokens int `json:"max_tokens" toml:"max_tokens"` + ContextMode string `json:"context_mode" toml:"context_mode"` + ContextWindowLines int `json:"context_window_lines" toml:"context_window_lines"` + MaxContextTokens int `json:"max_context_tokens" toml:"max_context_tokens"` + LogPreviewLimit int `json:"log_preview_limit" toml:"log_preview_limit"` // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. - CodingTemperature *float64 `json:"coding_temperature"` - // Minimum identifier characters required for manual (TriggerKind=1) invoke - // to proceed without structural triggers. 0 means always allow. - ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix"` - - // Completion debounce in milliseconds. When > 0, the server waits until - // there has been no text change for at least this duration before sending - // an LLM completion request. - CompletionDebounceMs int `json:"completion_debounce_ms"` - // Completion throttle in milliseconds. When > 0, caps the minimum spacing - // between LLM requests (both chat and code-completer paths). - CompletionThrottleMs int `json:"completion_throttle_ms"` - - TriggerCharacters []string `json:"trigger_characters"` - Provider string `json:"provider"` + CodingTemperature *float64 `json:"coding_temperature" toml:"coding_temperature"` + // Minimum identifier characters required for manual (TriggerKind=1) invoke + // to proceed without structural triggers. 0 means always allow. + ManualInvokeMinPrefix int `json:"manual_invoke_min_prefix" toml:"manual_invoke_min_prefix"` + + // Completion debounce in milliseconds. When > 0, the server waits until + // there has been no text change for at least this duration before sending + // an LLM completion request. + CompletionDebounceMs int `json:"completion_debounce_ms" toml:"completion_debounce_ms"` + // Completion throttle in milliseconds. When > 0, caps the minimum spacing + // between LLM requests (both chat and code-completer paths). + CompletionThrottleMs int `json:"completion_throttle_ms" toml:"completion_throttle_ms"` + + TriggerCharacters []string `json:"trigger_characters" toml:"trigger_characters"` + Provider string `json:"provider" toml:"provider"` // Inline prompt trigger characters (default: >text> and >>text>) - InlineOpen string `json:"inline_open"` - InlineClose string `json:"inline_close"` + InlineOpen string `json:"inline_open" toml:"inline_open"` + InlineClose string `json:"inline_close" toml:"inline_close"` // In-editor chat triggers (default: suffix ">" after one of [?, !, :, ;]) - ChatSuffix string `json:"chat_suffix"` - ChatPrefixes []string `json:"chat_prefixes"` + ChatSuffix string `json:"chat_suffix" toml:"chat_suffix"` + ChatPrefixes []string `json:"chat_prefixes" toml:"chat_prefixes"` // Provider-specific options - OpenAIBaseURL string `json:"openai_base_url"` - OpenAIModel string `json:"openai_model"` + OpenAIBaseURL string `json:"openai_base_url" toml:"openai_base_url"` + OpenAIModel string `json:"openai_model" toml:"openai_model"` // Default temperature for OpenAI requests (nil means use provider default) - OpenAITemperature *float64 `json:"openai_temperature"` - OllamaBaseURL string `json:"ollama_base_url"` - OllamaModel string `json:"ollama_model"` + OpenAITemperature *float64 `json:"openai_temperature" toml:"openai_temperature"` + OllamaBaseURL string `json:"ollama_base_url" toml:"ollama_base_url"` + OllamaModel string `json:"ollama_model" toml:"ollama_model"` // Default temperature for Ollama requests (nil means use provider default) - OllamaTemperature *float64 `json:"ollama_temperature"` - CopilotBaseURL string `json:"copilot_base_url"` - CopilotModel string `json:"copilot_model"` + OllamaTemperature *float64 `json:"ollama_temperature" toml:"ollama_temperature"` + CopilotBaseURL string `json:"copilot_base_url" toml:"copilot_base_url"` + CopilotModel string `json:"copilot_model" toml:"copilot_model"` // Default temperature for Copilot requests (nil means use provider default) - CopilotTemperature *float64 `json:"copilot_temperature"` + CopilotTemperature *float64 `json:"copilot_temperature" toml:"copilot_temperature"` } // Constructor: defaults for App (kept first among functions) func newDefaultConfig() App <span class="cov5" title="9">{ // Coding-friendly default temperature across providers - // Users can override per provider in config.json (including 0.0). + // Users can override per provider in config.toml (including 0.0). t := 0.2 return App{ - MaxTokens: 4000, - ContextMode: "always-full", - ContextWindowLines: 120, - MaxContextTokens: 4000, - LogPreviewLimit: 100, - CodingTemperature: &t, - OpenAITemperature: &t, - OllamaTemperature: &t, - CopilotTemperature: &t, - ManualInvokeMinPrefix: 0, - CompletionDebounceMs: 200, - CompletionThrottleMs: 0, - // Inline/chat trigger defaults - InlineOpen: ">", - InlineClose: ">", - ChatSuffix: ">", - ChatPrefixes: []string{"?", "!", ":", ";"}, - } + MaxTokens: 4000, + ContextMode: "always-full", + ContextWindowLines: 120, + MaxContextTokens: 4000, + LogPreviewLimit: 100, + CodingTemperature: &t, + OpenAITemperature: &t, + OllamaTemperature: &t, + CopilotTemperature: &t, + ManualInvokeMinPrefix: 0, + CompletionDebounceMs: 200, + CompletionThrottleMs: 0, + // Inline/chat trigger defaults + InlineOpen: ">", + InlineClose: ">", + ChatSuffix: ">", + ChatPrefixes: []string{"?", "!", ":", ";"}, + } }</span> // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. func Load(logger *log.Logger) App <span class="cov4" title="8">{ - cfg := newDefaultConfig() - if logger == nil </span><span class="cov3" title="3">{ - return cfg // Return defaults if no logger is provided (e.g. in tests) - }</span> - - <span class="cov4" title="5">configPath, err := getConfigPath() - if err != nil </span><span class="cov0" title="0">{ - logger.Printf("%v", err) - // Even if config path cannot be resolved, still allow env overrides below. - }</span> else<span class="cov4" title="5"> { - if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov3" title="3">{ - cfg.mergeWith(fileCfg) - }</span> - // When the config file is missing or invalid, we keep defaults and still - // apply any environment overrides below. - } - - // Environment overrides (take precedence over file) - <span class="cov4" title="5">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov1" title="1">{ - cfg.mergeWith(envCfg) - }</span> - <span class="cov4" title="5">return cfg</span> + cfg := newDefaultConfig() + if logger == nil </span><span class="cov3" title="3">{ + return cfg // Return defaults if no logger is provided (e.g. in tests) + }</span> + + <span class="cov4" title="5">configPath, err := getConfigPath() + if err != nil </span><span class="cov0" title="0">{ + logger.Printf("%v", err) + // Even if config path cannot be resolved, still allow env overrides below. + }</span> else<span class="cov4" title="5"> { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov3" title="3">{ + cfg.mergeWith(fileCfg) + }</span> + // When the config file is missing or invalid, we keep defaults and still + // apply any environment overrides below. + } + + // Environment overrides (take precedence over file) + <span class="cov4" title="5">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov1" title="1">{ + cfg.mergeWith(envCfg) + }</span> + <span class="cov4" title="5">return cfg</span> } // Private helpers @@ -296,26 +297,29 @@ func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="co f, err := os.Open(path) if err != nil </span><span class="cov2" title="2">{ if !os.IsNotExist(err) && logger != nil </span><span class="cov0" title="0">{ - logger.Printf("cannot open config file %s: %v", path, err) + logger.Printf("cannot open TOML config file %s: %v", path, err) }</span> <span class="cov2" title="2">return nil, err</span> } <span class="cov3" title="4">defer f.Close() - dec := json.NewDecoder(f) + dec := toml.NewDecoder(f) var fileCfg App if err := dec.Decode(&fileCfg); err != nil </span><span class="cov1" title="1">{ if logger != nil </span><span class="cov1" title="1">{ - logger.Printf("invalid config file %s: %v", path, err) + logger.Printf("invalid TOML config file %s: %v", path, err) }</span> <span class="cov1" title="1">return nil, err</span> } + <span class="cov3" title="3">if logger != nil </span><span class="cov3" title="3">{ + logger.Printf("loaded configuration from %s (TOML)", path) + }</span> <span class="cov3" title="3">return &fileCfg, nil</span> } func (a *App) mergeWith(other *App) <span class="cov3" title="4">{ - a.mergeBasics(other) - a.mergeProviderFields(other) + a.mergeBasics(other) + a.mergeProviderFields(other) }</span> // mergeBasics merges general (non-provider) fields. @@ -335,32 +339,36 @@ func (a *App) mergeBasics(other *App) <span class="cov3" title="4">{ <span class="cov3" title="4">if other.LogPreviewLimit >= 0 </span><span class="cov3" title="4">{ a.LogPreviewLimit = other.LogPreviewLimit }</span> - <span class="cov3" title="4">if other.CodingTemperature != nil </span><span class="cov3" title="3">{ // allow explicit 0.0 - a.CodingTemperature = other.CodingTemperature - }</span> - <span class="cov3" title="4">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov3" title="4">{ - a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix - }</span> - <span class="cov3" title="4">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="3">{ a.CompletionDebounceMs = other.CompletionDebounceMs }</span> - <span class="cov3" title="4">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="3">{ a.CompletionThrottleMs = other.CompletionThrottleMs }</span> - <span class="cov3" title="4">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="3">{ - a.TriggerCharacters = slices.Clone(other.TriggerCharacters) - }</span> - <span class="cov3" title="4">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov0" title="0">{ - a.InlineOpen = s - }</span> - <span class="cov3" title="4">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov0" title="0">{ - a.InlineClose = s - }</span> - <span class="cov3" title="4">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov0" title="0">{ - a.ChatSuffix = s - }</span> - <span class="cov3" title="4">if len(other.ChatPrefixes) > 0 </span><span class="cov0" title="0">{ - a.ChatPrefixes = slices.Clone(other.ChatPrefixes) - }</span> - <span class="cov3" title="4">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov3" title="4">{ - a.Provider = s - }</span> + <span class="cov3" title="4">if other.CodingTemperature != nil </span><span class="cov3" title="3">{ // allow explicit 0.0 + a.CodingTemperature = other.CodingTemperature + }</span> + <span class="cov3" title="4">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov3" title="4">{ + a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix + }</span> + <span class="cov3" title="4">if other.CompletionDebounceMs > 0 </span><span class="cov3" title="3">{ + a.CompletionDebounceMs = other.CompletionDebounceMs + }</span> + <span class="cov3" title="4">if other.CompletionThrottleMs > 0 </span><span class="cov3" title="3">{ + a.CompletionThrottleMs = other.CompletionThrottleMs + }</span> + <span class="cov3" title="4">if len(other.TriggerCharacters) > 0 </span><span class="cov3" title="3">{ + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.InlineOpen); s != "" </span><span class="cov0" title="0">{ + a.InlineOpen = s + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.InlineClose); s != "" </span><span class="cov0" title="0">{ + a.InlineClose = s + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.ChatSuffix); s != "" </span><span class="cov0" title="0">{ + a.ChatSuffix = s + }</span> + <span class="cov3" title="4">if len(other.ChatPrefixes) > 0 </span><span class="cov0" title="0">{ + a.ChatPrefixes = slices.Clone(other.ChatPrefixes) + }</span> + <span class="cov3" title="4">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov3" title="4">{ + a.Provider = s + }</span> } // mergeProviderFields merges per-provider configuration. @@ -397,15 +405,15 @@ func (a *App) mergeProviderFields(other *App) <span class="cov3" title="4">{ func getConfigPath() (string, error) <span class="cov4" title="6">{ var configPath string if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov4" title="5">{ - configPath = filepath.Join(xdgConfigHome, "hexai", "config.json") + configPath = filepath.Join(xdgConfigHome, "hexai", "config.toml") }</span> else<span class="cov1" title="1"> { home, err := os.UserHomeDir() if err != nil </span><span class="cov0" title="0">{ return "", fmt.Errorf("cannot find user home directory: %v", err) }</span> - <span class="cov1" title="1">configPath = filepath.Join(home, ".config", "hexai", "config.json")</span> + <span class="cov1" title="1">configPath = filepath.Join(home, ".config", "hexai", "config.toml")</span> } - <span class="cov4" title="6">return configPath, nil</span> + <span class="cov4" title="6">return configPath, nil</span> } // --- Environment overrides --- @@ -413,100 +421,157 @@ func getConfigPath() (string, error) <span class="cov4" title="6">{ // loadFromEnv constructs an App containing only fields set via HEXAI_* env vars. // These values should take precedence over file config when merged. func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="5">{ - var out App - var any bool - - // helpers - getenv := func(k string) string </span><span class="cov10" title="120">{ return strings.TrimSpace(os.Getenv(k)) }</span> - <span class="cov4" title="5">parseInt := func(k string) (int, bool) </span><span class="cov7" title="35">{ - v := getenv(k) - if v == "" </span><span class="cov7" title="28">{ return 0, false }</span> - <span class="cov4" title="7">n, err := strconv.Atoi(v) - if err != nil </span><span class="cov0" title="0">{ if logger != nil </span><span class="cov0" title="0">{ logger.Printf("invalid %s: %v", k, err) }</span> ; <span class="cov0" title="0">return 0, false</span> } - <span class="cov4" title="7">return n, true</span> - } - <span class="cov4" title="5">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov6" title="20">{ - v := getenv(k) - if v == "" </span><span class="cov6" title="16">{ return nil, false }</span> - <span class="cov3" title="4">f, err := strconv.ParseFloat(v, 64) - if err != nil </span><span class="cov0" title="0">{ - if logger != nil </span><span class="cov0" title="0">{ logger.Printf("invalid %s: %v", k, err) }</span> - <span class="cov0" title="0">return nil, false</span> + var out App + var any bool + + // helpers + getenv := func(k string) string </span><span class="cov10" title="120">{ return strings.TrimSpace(os.Getenv(k)) }</span> + <span class="cov4" title="5">parseInt := func(k string) (int, bool) </span><span class="cov7" title="35">{ + v := getenv(k) + if v == "" </span><span class="cov7" title="28">{ + return 0, false + }</span> + <span class="cov4" title="7">n, err := strconv.Atoi(v) + if err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("invalid %s: %v", k, err) + }</span> + <span class="cov0" title="0">return 0, false</span> + } + <span class="cov4" title="7">return n, true</span> } - <span class="cov3" title="4">return &f, true</span> - } - - <span class="cov4" title="5">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov1" title="1">{ - out.MaxTokens = n; any = true - }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ - out.ContextMode = s; any = true - }</span> - <span class="cov4" title="5">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ - out.ContextWindowLines = n; any = true - }</span> - <span class="cov4" title="5">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ - out.MaxContextTokens = n; any = true - }</span> - <span class="cov4" title="5">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ - out.LogPreviewLimit = n; any = true - }</span> - <span class="cov4" title="5">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ - out.ManualInvokeMinPrefix = n; any = true - }</span> - <span class="cov4" title="5">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ - out.CompletionDebounceMs = n; any = true - }</span> - <span class="cov4" title="5">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ - out.CompletionThrottleMs = n; any = true - }</span> - <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ - out.CodingTemperature = f; any = true - }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ - parts := strings.Split(s, ",") - out.TriggerCharacters = nil - for _, p := range parts </span><span class="cov3" title="3">{ - if t := strings.TrimSpace(p); t != "" </span><span class="cov3" title="3">{ - out.TriggerCharacters = append(out.TriggerCharacters, t) - }</span> + <span class="cov4" title="5">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov6" title="20">{ + v := getenv(k) + if v == "" </span><span class="cov6" title="16">{ + return nil, false + }</span> + <span class="cov3" title="4">f, err := strconv.ParseFloat(v, 64) + if err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("invalid %s: %v", k, err) + }</span> + <span class="cov0" title="0">return nil, false</span> + } + <span class="cov3" title="4">return &f, true</span> + } + + <span class="cov4" title="5">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov1" title="1">{ + out.MaxTokens = n + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov1" title="1">{ + out.ContextMode = s + any = true + }</span> + <span class="cov4" title="5">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov1" title="1">{ + out.ContextWindowLines = n + any = true + }</span> + <span class="cov4" title="5">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov1" title="1">{ + out.MaxContextTokens = n + any = true + }</span> + <span class="cov4" title="5">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov1" title="1">{ + out.LogPreviewLimit = n + any = true + }</span> + <span class="cov4" title="5">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov1" title="1">{ + out.ManualInvokeMinPrefix = n + any = true + }</span> + <span class="cov4" title="5">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov1" title="1">{ + out.CompletionDebounceMs = n + any = true + }</span> + <span class="cov4" title="5">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov1" title="1">{ + out.CompletionThrottleMs = n + any = true + }</span> + <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + out.CodingTemperature = f + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov1" title="1">{ + parts := strings.Split(s, ",") + out.TriggerCharacters = nil + for _, p := range parts </span><span class="cov3" title="3">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov3" title="3">{ + out.TriggerCharacters = append(out.TriggerCharacters, t) + }</span> + } + <span class="cov1" title="1">any = true</span> } - <span class="cov1" title="1">any = true</span> - } - <span class="cov4" title="5">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ out.InlineOpen = s; any = true }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ out.InlineClose = s; any = true }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ out.ChatSuffix = s; any = true }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ - parts := strings.Split(s, ",") - out.ChatPrefixes = nil - for _, p := range parts </span><span class="cov0" title="0">{ - if t := strings.TrimSpace(p); t != "" </span><span class="cov0" title="0">{ - out.ChatPrefixes = append(out.ChatPrefixes, t) - }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_INLINE_OPEN"); s != "" </span><span class="cov0" title="0">{ + out.InlineOpen = s + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_INLINE_CLOSE"); s != "" </span><span class="cov0" title="0">{ + out.InlineClose = s + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_CHAT_SUFFIX"); s != "" </span><span class="cov0" title="0">{ + out.ChatSuffix = s + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_CHAT_PREFIXES"); s != "" </span><span class="cov0" title="0">{ + parts := strings.Split(s, ",") + out.ChatPrefixes = nil + for _, p := range parts </span><span class="cov0" title="0">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov0" title="0">{ + out.ChatPrefixes = append(out.ChatPrefixes, t) + }</span> + } + <span class="cov0" title="0">any = true</span> } - <span class="cov0" title="0">any = true</span> - } - <span class="cov4" title="5">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov1" title="1">{ - out.Provider = s; any = true - }</span> - - // Provider-specific - <span class="cov4" title="5">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.OpenAIBaseURL = s; any = true }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov1" title="1">{ out.OpenAIModel = s; any = true }</span> - <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OpenAITemperature = f; any = true }</span> - - <span class="cov4" title="5">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.OllamaBaseURL = s; any = true }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov1" title="1">{ out.OllamaModel = s; any = true }</span> - <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.OllamaTemperature = f; any = true }</span> - - <span class="cov4" title="5">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ out.CopilotBaseURL = s; any = true }</span> - <span class="cov4" title="5">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov1" title="1">{ out.CopilotModel = s; any = true }</span> - <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ out.CopilotTemperature = f; any = true }</span> - - <span class="cov4" title="5">if !any </span><span class="cov3" title="4">{ - return nil - }</span> - <span class="cov1" title="1">return &out</span> + <span class="cov4" title="5">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov1" title="1">{ + out.Provider = s + any = true + }</span> + + // Provider-specific + <span class="cov4" title="5">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + out.OpenAIBaseURL = s + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov1" title="1">{ + out.OpenAIModel = s + any = true + }</span> + <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + out.OpenAITemperature = f + any = true + }</span> + + <span class="cov4" title="5">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + out.OllamaBaseURL = s + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov1" title="1">{ + out.OllamaModel = s + any = true + }</span> + <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + out.OllamaTemperature = f + any = true + }</span> + + <span class="cov4" title="5">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov1" title="1">{ + out.CopilotBaseURL = s + any = true + }</span> + <span class="cov4" title="5">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov1" title="1">{ + out.CopilotModel = s + any = true + }</span> + <span class="cov4" title="5">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov1" title="1">{ + out.CopilotTemperature = f + any = true + }</span> + + <span class="cov4" title="5">if !any </span><span class="cov3" title="4">{ + return nil + }</span> + <span class="cov1" title="1">return &out</span> } </pre> @@ -515,14 +580,14 @@ func loadFromEnv(logger *log.Logger) *App <span class="cov4" title="5">{ package hexaicli import ( - "bufio" - "context" - "fmt" - "io" - "log" - "os" - "strings" - "time" + "bufio" + "context" + "fmt" + "io" + "log" + "os" + "strings" + "time" "codeberg.org/snonux/hexai/internal/appconfig" "codeberg.org/snonux/hexai/internal/llm" @@ -532,14 +597,14 @@ import ( // Run executes the Hexai CLI behavior given arguments and I/O streams. // It assumes flags have already been parsed by the caller. func Run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error <span class="cov1" title="1">{ - // Load configuration with a logger so file-based config is respected. - logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) - cfg := appconfig.Load(logger) - client, err := newClientFromConfig(cfg) - if err != nil </span><span class="cov1" title="1">{ - fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) - return err - }</span> + // Load configuration with a logger so file-based config is respected. + logger := log.New(stderr, "hexai ", log.LstdFlags|log.Lmsgprefix) + cfg := appconfig.Load(logger) + client, err := newClientFromConfig(cfg) + if err != nil </span><span class="cov1" title="1">{ + fmt.Fprintf(stderr, logging.AnsiBase+"hexai: LLM disabled: %v"+logging.AnsiReset+"\n", err) + return err + }</span> <span class="cov0" title="0">return RunWithClient(ctx, args, stdin, stdout, stderr, client)</span> } @@ -583,29 +648,29 @@ func readInput(stdin io.Reader, args []string) (string, error) <span class="cov9 // newClientFromConfig builds an LLM client from the app config and env keys. func newClientFromConfig(cfg appconfig.App) (llm.Client, error) <span class="cov6" title="3">{ - llmCfg := llm.Config{ - Provider: cfg.Provider, - OpenAIBaseURL: cfg.OpenAIBaseURL, - OpenAIModel: cfg.OpenAIModel, - OpenAITemperature: cfg.OpenAITemperature, - OllamaBaseURL: cfg.OllamaBaseURL, - OllamaModel: cfg.OllamaModel, - OllamaTemperature: cfg.OllamaTemperature, - CopilotBaseURL: cfg.CopilotBaseURL, - CopilotModel: cfg.CopilotModel, - CopilotTemperature: cfg.CopilotTemperature, - } - // Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY - oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") - if strings.TrimSpace(oaKey) == "" </span><span class="cov6" title="3">{ - oaKey = os.Getenv("OPENAI_API_KEY") - }</span> - // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY - <span class="cov6" title="3">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" </span><span class="cov6" title="3">{ - cpKey = os.Getenv("COPILOT_API_KEY") - }</span> - <span class="cov6" title="3">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span> + llmCfg := llm.Config{ + Provider: cfg.Provider, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + CopilotBaseURL: cfg.CopilotBaseURL, + CopilotModel: cfg.CopilotModel, + CopilotTemperature: cfg.CopilotTemperature, + } + // Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY + oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") + if strings.TrimSpace(oaKey) == "" </span><span class="cov6" title="3">{ + oaKey = os.Getenv("OPENAI_API_KEY") + }</span> + // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY + <span class="cov6" title="3">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov6" title="3">{ + cpKey = os.Getenv("COPILOT_API_KEY") + }</span> + <span class="cov6" title="3">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span> } // buildMessages creates system and user messages based on input content. @@ -681,7 +746,7 @@ type ServerFactory func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.S func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error <span class="cov1" title="1">{ logger := log.New(stderr, "hexai-lsp ", log.LstdFlags|log.Lmsgprefix) if strings.TrimSpace(logPath) != "" </span><span class="cov1" title="1">{ - f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil </span><span class="cov0" title="0">{ logger.Fatalf("failed to open log file: %v", err) }</span> @@ -733,16 +798,16 @@ func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span cla CopilotModel: cfg.CopilotModel, CopilotTemperature: cfg.CopilotTemperature, } - // Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY - oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") - if strings.TrimSpace(oaKey) == "" </span><span class="cov10" title="6">{ - oaKey = os.Getenv("OPENAI_API_KEY") - }</span> - // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY - <span class="cov10" title="6">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") - if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="6">{ - cpKey = os.Getenv("COPILOT_API_KEY") - }</span> + // Prefer HEXAI_OPENAI_API_KEY; fall back to OPENAI_API_KEY + oaKey := os.Getenv("HEXAI_OPENAI_API_KEY") + if strings.TrimSpace(oaKey) == "" </span><span class="cov10" title="6">{ + oaKey = os.Getenv("OPENAI_API_KEY") + }</span> + // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY + <span class="cov10" title="6">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov10" title="6">{ + cpKey = os.Getenv("COPILOT_API_KEY") + }</span> <span class="cov10" title="6">if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil </span><span class="cov1" title="1">{ logging.Logf("lsp ", "llm disabled: %v", err) return nil @@ -762,23 +827,23 @@ func ensureFactory(factory ServerFactory) ServerFactory <span class="cov10" titl } func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions <span class="cov10" title="6">{ - return lsp.ServerOptions{ - LogContext: logContext, - MaxTokens: cfg.MaxTokens, - ContextMode: cfg.ContextMode, - WindowLines: cfg.ContextWindowLines, - MaxContextTokens: cfg.MaxContextTokens, - CodingTemperature: cfg.CodingTemperature, - Client: client, - TriggerCharacters: cfg.TriggerCharacters, - ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix, - CompletionDebounceMs: cfg.CompletionDebounceMs, - CompletionThrottleMs: cfg.CompletionThrottleMs, - InlineOpen: cfg.InlineOpen, - InlineClose: cfg.InlineClose, - ChatSuffix: cfg.ChatSuffix, - ChatPrefixes: cfg.ChatPrefixes, - } + return lsp.ServerOptions{ + LogContext: logContext, + MaxTokens: cfg.MaxTokens, + ContextMode: cfg.ContextMode, + WindowLines: cfg.ContextWindowLines, + MaxContextTokens: cfg.MaxContextTokens, + CodingTemperature: cfg.CodingTemperature, + Client: client, + TriggerCharacters: cfg.TriggerCharacters, + ManualInvokeMinPrefix: cfg.ManualInvokeMinPrefix, + CompletionDebounceMs: cfg.CompletionDebounceMs, + CompletionThrottleMs: cfg.CompletionThrottleMs, + InlineOpen: cfg.InlineOpen, + InlineClose: cfg.InlineClose, + ChatSuffix: cfg.ChatSuffix, + ChatPrefixes: cfg.ChatPrefixes, + } }</span> </pre> @@ -788,6 +853,7 @@ package llm import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -797,7 +863,6 @@ import ( "strings" "time" - "encoding/base64" appver "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/logging" ) @@ -946,10 +1011,14 @@ func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64 } func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov3" title="8">{ - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) - if err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov3" title="8">for k, v := range headers </span><span class="cov6" title="88">{ req.Header.Set(k, v) }</span> - <span class="cov3" title="8">return c.httpClient.Do(req)</span> + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov3" title="8">for k, v := range headers </span><span class="cov6" title="88">{ + req.Header.Set(k, v) + }</span> + <span class="cov3" title="8">return c.httpClient.Do(req)</span> } func handleCopilotNon2xx(resp *http.Response, start time.Time) error <span class="cov3" title="6">{ @@ -978,55 +1047,73 @@ func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatRespons // --- Copilot session token management --- type ghCopilotTokenResp struct { - Token string `json:"token"` + Token string `json:"token"` } func (c *copilotClient) ensureSession(ctx context.Context) error <span class="cov4" title="16">{ - // If token valid for >60s, reuse - if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) </span><span class="cov3" title="8">{ - return nil - }</span> - <span class="cov3" title="8">if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ - return errors.New("missing Copilot API key") - }</span> - <span class="cov3" title="8">req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) - if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov3" title="8">req.Header.Set("Authorization", "Bearer "+c.apiKey) - req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", "hexai/"+appver.Version) - resp, err := c.httpClient.Do(req) - if err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov3" title="8">defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ - return fmt.Errorf("copilot token http error: %d", resp.StatusCode) - }</span> - <span class="cov3" title="8">var out ghCopilotTokenResp - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ return err }</span> - <span class="cov3" title="8">if strings.TrimSpace(out.Token) == "" </span><span class="cov0" title="0">{ return errors.New("empty copilot session token") }</span> - // Parse JWT exp - <span class="cov3" title="8">exp := parseJWTExp(out.Token) - if exp.IsZero() </span><span class="cov3" title="8">{ exp = time.Now().Add(10 * time.Minute) }</span> - <span class="cov3" title="8">c.sessionToken = out.Token - c.tokenExpiry = exp - return nil</span> + // If token valid for >60s, reuse + if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) </span><span class="cov3" title="8">{ + return nil + }</span> + <span class="cov3" title="8">if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ + return errors.New("missing Copilot API key") + }</span> + <span class="cov3" title="8">req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/copilot_internal/v2/token", nil) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="8">req.Header.Set("Authorization", "Bearer "+c.apiKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "hexai/"+appver.Version) + resp, err := c.httpClient.Do(req) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="8">defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ + return fmt.Errorf("copilot token http error: %d", resp.StatusCode) + }</span> + <span class="cov3" title="8">var out ghCopilotTokenResp + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov3" title="8">if strings.TrimSpace(out.Token) == "" </span><span class="cov0" title="0">{ + return errors.New("empty copilot session token") + }</span> + // Parse JWT exp + <span class="cov3" title="8">exp := parseJWTExp(out.Token) + if exp.IsZero() </span><span class="cov3" title="8">{ + exp = time.Now().Add(10 * time.Minute) + }</span> + <span class="cov3" title="8">c.sessionToken = out.Token + c.tokenExpiry = exp + return nil</span> } var jwtExpRe = regexp.MustCompile(`"exp"\s*:\s*([0-9]+)`) // fallback if we can't base64 decode func parseJWTExp(token string) time.Time <span class="cov3" title="9">{ - parts := strings.Split(token, ".") - if len(parts) < 2 </span><span class="cov3" title="8">{ return time.Time{} }</span> - <span class="cov1" title="1">b, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil </span><span class="cov0" title="0">{ - if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 </span><span class="cov0" title="0">{ - if n, err2 := parseInt64(m[1]); err2 == nil </span><span class="cov0" title="0">{ return time.Unix(n, 0) }</span> + parts := strings.Split(token, ".") + if len(parts) < 2 </span><span class="cov3" title="8">{ + return time.Time{} + }</span> + <span class="cov1" title="1">b, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil </span><span class="cov0" title="0">{ + if m := jwtExpRe.FindStringSubmatch(token); len(m) == 2 </span><span class="cov0" title="0">{ + if n, err2 := parseInt64(m[1]); err2 == nil </span><span class="cov0" title="0">{ + return time.Unix(n, 0) + }</span> + } + <span class="cov0" title="0">return time.Time{}</span> + } + <span class="cov1" title="1">var payload struct { + Exp int64 `json:"exp"` } - <span class="cov0" title="0">return time.Time{}</span> - } - <span class="cov1" title="1">var payload struct{ Exp int64 `json:"exp"` } - _ = json.Unmarshal(b, &payload) - if payload.Exp == 0 </span><span class="cov0" title="0">{ return time.Time{} }</span> - <span class="cov1" title="1">return time.Unix(payload.Exp, 0)</span> + _ = json.Unmarshal(b, &payload) + if payload.Exp == 0 </span><span class="cov0" title="0">{ + return time.Time{} + }</span> + <span class="cov1" title="1">return time.Unix(payload.Exp, 0)</span> } func parseInt64(s string) (int64, error) <span class="cov1" title="1">{ var n int64; _, err := fmt.Sscan(s, &n); return n, err }</span> @@ -1034,99 +1121,120 @@ func parseInt64(s string) (int64, error) <span class="cov1" title="1">{ var n in // --- Copilot headers --- func (c *copilotClient) headersChat() map[string]string <span class="cov3" title="5">{ - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "application/json", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GitHubCopilotChat/0.8.0", - "Editor-Plugin-Version": "copilot-chat/0.8.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "conversation-panel", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h + _ = c.ensureSession(context.Background()) + h := map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "Accept": "application/json", + "Authorization": "Bearer " + c.sessionToken, + "User-Agent": "GitHubCopilotChat/0.8.0", + "Editor-Plugin-Version": "copilot-chat/0.8.0", + "Editor-Version": "vscode/1.85.1", + "Openai-Intent": "conversation-panel", + "Openai-Organization": "github-copilot", + "VScode-MachineId": randHex(64), + "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + } + return h }</span> func (c *copilotClient) headersGhost() map[string]string <span class="cov2" title="3">{ - _ = c.ensureSession(context.Background()) - h := map[string]string{ - "Content-Type": "application/json; charset=utf-8", - "Accept": "*/*", - "Authorization": "Bearer " + c.sessionToken, - "User-Agent": "GithubCopilot/1.155.0", - "Editor-Plugin-Version": "copilot/1.155.0", - "Editor-Version": "vscode/1.85.1", - "Openai-Intent": "copilot-ghost", - "Openai-Organization": "github-copilot", - "VScode-MachineId": randHex(64), - "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), - } - return h + _ = c.ensureSession(context.Background()) + h := map[string]string{ + "Content-Type": "application/json; charset=utf-8", + "Accept": "*/*", + "Authorization": "Bearer " + c.sessionToken, + "User-Agent": "GithubCopilot/1.155.0", + "Editor-Plugin-Version": "copilot/1.155.0", + "Editor-Version": "vscode/1.85.1", + "Openai-Intent": "copilot-ghost", + "Openai-Organization": "github-copilot", + "VScode-MachineId": randHex(64), + "VScode-SessionId": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + "X-Request-Id": randHex(8) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(4) + "-" + randHex(12), + } + return h }</span> func randHex(n int) string <span class="cov6" title="88">{ - const hex = "0123456789abcdef" - b := make([]byte, n) - for i := range b </span><span class="cov10" title="1024">{ - b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] - }</span> - <span class="cov6" title="88">return string(b)</span> + const hex = "0123456789abcdef" + b := make([]byte, n) + for i := range b </span><span class="cov10" title="1024">{ + b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] + }</span> + <span class="cov6" title="88">return string(b)</span> } // --- Codex-style code completion --- // CodeCompletion implements CodeCompleter; returns up to n suggestions. func (c copilotClient) CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) <span class="cov2" title="3">{ - if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ return nil, errors.New("missing Copilot API key") }</span> - <span class="cov2" title="3">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov2" title="3">if n <= 0 </span><span class="cov0" title="0">{ n = 1 }</span> - <span class="cov2" title="3">maxTokens := 500 - body := map[string]any{ - "extra": map[string]any{ - "language": language, - "next_indent": 0, - "prompt_tokens": 500, - "suffix_tokens": 400, - "trim_by_indentation": true, - }, - "max_tokens": maxTokens, - "n": n, - "nwo": "hexai", - "prompt": prompt, - "stop": []string{"\n\n"}, - "stream": true, - "suffix": suffix, - "temperature": temperature, - "top_p": 1, - } - buf, _ := json.Marshal(body) - url := "https://copilot-proxy.githubusercontent.com/v1/engines/copilot-codex/completions" - resp, err := c.postJSON(ctx, url, buf, c.headersGhost()) - if err != nil </span><span class="cov0" title="0">{ return nil, err }</span> - <span class="cov2" title="3">defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ - return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode) - }</span> - // Read all and parse lines that start with "data: " accumulating by index - <span class="cov2" title="3">raw, _ := io.ReadAll(resp.Body) - byIndex := make(map[int]string) - lines := strings.Split(string(raw), "\n") - for _, ln := range lines </span><span class="cov3" title="10">{ - if !strings.HasPrefix(ln, "data: ") </span><span class="cov2" title="3">{ continue</span> } - <span class="cov3" title="7">var evt struct{ Choices []struct{ Index int `json:"index"`; Text string `json:"text"` } `json:"choices"` } - if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil </span><span class="cov2" title="4">{ continue</span> } - <span class="cov2" title="3">for _, ch := range evt.Choices </span><span class="cov2" title="3">{ byIndex[ch.Index] += ch.Text }</span> - } - <span class="cov2" title="3">out := make([]string, 0, len(byIndex)) - for i := 0; i < n; i++ </span><span class="cov2" title="4">{ - if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" </span><span class="cov2" title="3">{ out = append(out, s) }</span> - } - <span class="cov2" title="3">return out, nil</span> + if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ + return nil, errors.New("missing Copilot API key") + }</span> + <span class="cov2" title="3">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov2" title="3">if n <= 0 </span><span class="cov0" title="0">{ + n = 1 + }</span> + <span class="cov2" title="3">maxTokens := 500 + body := map[string]any{ + "extra": map[string]any{ + "language": language, + "next_indent": 0, + "prompt_tokens": 500, + "suffix_tokens": 400, + "trim_by_indentation": true, + }, + "max_tokens": maxTokens, + "n": n, + "nwo": "hexai", + "prompt": prompt, + "stop": []string{"\n\n"}, + "stream": true, + "suffix": suffix, + "temperature": temperature, + "top_p": 1, + } + buf, _ := json.Marshal(body) + url := "https://copilot-proxy.githubusercontent.com/v1/engines/copilot-codex/completions" + resp, err := c.postJSON(ctx, url, buf, c.headersGhost()) + if err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov2" title="3">defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("copilot codex http error: %d", resp.StatusCode) + }</span> + // Read all and parse lines that start with "data: " accumulating by index + <span class="cov2" title="3">raw, _ := io.ReadAll(resp.Body) + byIndex := make(map[int]string) + lines := strings.Split(string(raw), "\n") + for _, ln := range lines </span><span class="cov3" title="10">{ + if !strings.HasPrefix(ln, "data: ") </span><span class="cov2" title="3">{ + continue</span> + } + <span class="cov3" title="7">var evt struct { + Choices []struct { + Index int `json:"index"` + Text string `json:"text"` + } `json:"choices"` + } + if err := json.Unmarshal([]byte(strings.TrimPrefix(ln, "data: ")), &evt); err != nil </span><span class="cov2" title="4">{ + continue</span> + } + <span class="cov2" title="3">for _, ch := range evt.Choices </span><span class="cov2" title="3">{ + byIndex[ch.Index] += ch.Text + }</span> + } + <span class="cov2" title="3">out := make([]string, 0, len(byIndex)) + for i := 0; i < n; i++ </span><span class="cov2" title="4">{ + if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" </span><span class="cov2" title="3">{ + out = append(out, s) + }</span> + } + <span class="cov2" title="3">return out, nil</span> } // newLineDataReader wraps a streaming body and exposes a JSON decoder that @@ -1685,20 +1793,20 @@ type Client interface { // token-by-token streaming responses. Callers can type-assert to Streamer and // fall back to Client.Chat when not implemented. type Streamer interface { - // ChatStream sends chat messages and invokes onDelta with incremental text - // chunks as they are produced by the model. Implementations should call - // onDelta with empty strings sparingly (prefer only non-empty chunks). - ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error + // ChatStream sends chat messages and invokes onDelta with incremental text + // chunks as they are produced by the model. Implementations should call + // onDelta with empty strings sparingly (prefer only non-empty chunks). + ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error } // CodeCompleter is an optional interface for providers that support a // prompt/suffix code-completion API (e.g., Copilot Codex endpoint). Clients // can type-assert to this and prefer it over chat when available. type CodeCompleter interface { - // CodeCompletion requests up to n suggestions given a left-hand prompt and - // right-hand suffix around the cursor. Language is advisory and may be - // ignored. Temperature applies when provider supports it. - CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) + // CodeCompletion requests up to n suggestions given a left-hand prompt and + // right-hand suffix around the cursor. Language is advisory and may be + // ignored. Temperature applies when provider supports it. + CodeCompletion(ctx context.Context, prompt string, suffix string, n int, language string, temperature float64) ([]string, error) } // Options for a request. Providers may ignore unsupported fields. @@ -1714,65 +1822,65 @@ type RequestOption func(*Options) func WithModel(model string) RequestOption <span class="cov1" title="1">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Model = model }</span> } func WithTemperature(t float64) RequestOption <span class="cov1" title="1">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Temperature = t }</span> } -func WithMaxTokens(n int) RequestOption <span class="cov10" title="19">{ return func(o *Options) </span><span class="cov1" title="1">{ o.MaxTokens = n }</span> } +func WithMaxTokens(n int) RequestOption <span class="cov10" title="20">{ return func(o *Options) </span><span class="cov1" title="1">{ o.MaxTokens = n }</span> } func WithStop(stop ...string) RequestOption <span class="cov1" title="1">{ return func(o *Options) </span><span class="cov1" title="1">{ o.Stop = append([]string{}, stop...) }</span> } // Config defines provider configuration read from the Hexai config file. type Config struct { - Provider string - // OpenAI options - OpenAIBaseURL string - OpenAIModel string - OpenAITemperature *float64 - // Ollama options - OllamaBaseURL string - OllamaModel string - OllamaTemperature *float64 - // Copilot options - CopilotBaseURL string - CopilotModel string - CopilotTemperature *float64 + Provider string + // OpenAI options + OpenAIBaseURL string + OpenAIModel string + OpenAITemperature *float64 + // Ollama options + OllamaBaseURL string + OllamaModel string + OllamaTemperature *float64 + // Copilot options + CopilotBaseURL string + CopilotModel string + CopilotTemperature *float64 } // NewFromConfig creates an LLM client using only the supplied configuration. // The OpenAI API key is supplied separately and may be read from the environment // by the caller; other environment-based configuration is not used. -func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) <span class="cov9" title="14">{ - p := strings.ToLower(strings.TrimSpace(cfg.Provider)) - if p == "" </span><span class="cov6" title="6">{ - p = "openai" - }</span> - <span class="cov9" title="14">switch p </span>{ - case "openai":<span class="cov8" title="10"> - if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov5" title="4">{ - return nil, errors.New("missing OPENAI_API_KEY for provider openai") - }</span> - // Set coding-friendly default temperature if none provided - <span class="cov6" title="6">if cfg.OpenAITemperature == nil </span><span class="cov5" title="4">{ - t := 0.2 - cfg.OpenAITemperature = &t - }</span> - <span class="cov6" title="6">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil</span> - case "ollama":<span class="cov1" title="1"> - if cfg.OllamaTemperature == nil </span><span class="cov1" title="1">{ - t := 0.2 - cfg.OllamaTemperature = &t - }</span> - <span class="cov1" title="1">return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil</span> - case "copilot":<span class="cov3" title="2"> - if strings.TrimSpace(copilotAPIKey) == "" </span><span class="cov1" title="1">{ - return nil, errors.New("missing COPILOT_API_KEY for provider copilot") - }</span> - <span class="cov1" title="1">if cfg.CopilotTemperature == nil </span><span class="cov1" title="1">{ - t := 0.2 - cfg.CopilotTemperature = &t - }</span> - <span class="cov1" title="1">return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil</span> - default:<span class="cov1" title="1"> - return nil, errors.New("unknown LLM provider: " + p)</span> - } +func NewFromConfig(cfg Config, openAIAPIKey, copilotAPIKey string) (Client, error) <span class="cov8" title="14">{ + p := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if p == "" </span><span class="cov6" title="6">{ + p = "openai" + }</span> + <span class="cov8" title="14">switch p </span>{ + case "openai":<span class="cov7" title="10"> + if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov5" title="4">{ + return nil, errors.New("missing OPENAI_API_KEY for provider openai") + }</span> + // Set coding-friendly default temperature if none provided + <span class="cov6" title="6">if cfg.OpenAITemperature == nil </span><span class="cov5" title="4">{ + t := 0.2 + cfg.OpenAITemperature = &t + }</span> + <span class="cov6" title="6">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil</span> + case "ollama":<span class="cov1" title="1"> + if cfg.OllamaTemperature == nil </span><span class="cov1" title="1">{ + t := 0.2 + cfg.OllamaTemperature = &t + }</span> + <span class="cov1" title="1">return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil</span> + case "copilot":<span class="cov3" title="2"> + if strings.TrimSpace(copilotAPIKey) == "" </span><span class="cov1" title="1">{ + return nil, errors.New("missing COPILOT_API_KEY for provider copilot") + }</span> + <span class="cov1" title="1">if cfg.CopilotTemperature == nil </span><span class="cov1" title="1">{ + t := 0.2 + cfg.CopilotTemperature = &t + }</span> + <span class="cov1" title="1">return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil</span> + default:<span class="cov1" title="1"> + return nil, errors.New("unknown LLM provider: " + p)</span> + } } </pre> @@ -1800,7 +1908,8 @@ func NewChatLogger(provider string) ChatLogger <span class="cov10" title="36">{ func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens int, stop []string, messages []struct { Role string Content string -}) <span class="cov8" title="24">{ +}, +) <span class="cov8" title="24">{ chatOrStream := "chat" if stream </span><span class="cov6" title="8">{ chatOrStream = "stream" @@ -1824,13 +1933,13 @@ import ( // ANSI color utilities shared across Hexai. const ( - AnsiBgBlack = "\x1b[40m" - AnsiGrey = "\x1b[90m" - AnsiCyan = "\x1b[36m" - AnsiGreen = "\x1b[32m" - AnsiYellow = "\x1b[33m" - AnsiRed = "\x1b[31m" - AnsiReset = "\x1b[0m" + AnsiBgBlack = "\x1b[40m" + AnsiGrey = "\x1b[90m" + AnsiCyan = "\x1b[36m" + AnsiGreen = "\x1b[32m" + AnsiYellow = "\x1b[33m" + AnsiRed = "\x1b[31m" + AnsiReset = "\x1b[0m" ) // AnsiBase is the default style: black background + grey foreground. @@ -1843,11 +1952,11 @@ var std *log.Logger func Bind(l *log.Logger) <span class="cov3" title="4">{ std = l }</span> // Logf prints a formatted message with a module prefix and base ANSI style. -func Logf(prefix, format string, args ...any) <span class="cov10" title="137">{ +func Logf(prefix, format string, args ...any) <span class="cov10" title="141">{ if std == nil </span><span class="cov9" title="101">{ return }</span> - <span class="cov7" title="36">msg := fmt.Sprintf(format, args...) + <span class="cov7" title="40">msg := fmt.Sprintf(format, args...) std.Print(AnsiBase + prefix + msg + AnsiReset)</span> } @@ -1864,7 +1973,7 @@ func PreviewForLog(s string) string <span class="cov7" title="35">{ if len(s) <= logPreviewLimit </span><span class="cov2" title="2">{ return s }</span> - <span class="cov3" title="3">return s[:logPreviewLimit] + "…"</span> + <span class="cov2" title="3">return s[:logPreviewLimit] + "…"</span> } <span class="cov7" title="30">return s</span> } @@ -1874,8 +1983,9 @@ func PreviewForLog(s string) string <span class="cov7" title="35">{ package lsp import ( - "codeberg.org/snonux/hexai/internal/logging" "strings" + + "codeberg.org/snonux/hexai/internal/logging" ) // buildAdditionalContext builds extra context messages based on the configured mode. @@ -1969,7 +2079,7 @@ type document struct { lines []string } -func (s *Server) setDocument(uri, text string) <span class="cov8" title="25">{ +func (s *Server) setDocument(uri, text string) <span class="cov8" title="27">{ s.mu.Lock() defer s.mu.Unlock() s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} @@ -1981,20 +2091,20 @@ func (s *Server) deleteDocument(uri string) <span class="cov1" title="1">{ delete(s.docs, uri) }</span> -func (s *Server) markActivity() <span class="cov4" title="4">{ +func (s *Server) markActivity() <span class="cov3" title="4">{ s.mu.Lock() s.lastInput = time.Now() s.mu.Unlock() }</span> -func (s *Server) getDocument(uri string) *document <span class="cov9" title="40">{ +func (s *Server) getDocument(uri string) *document <span class="cov9" title="46">{ s.mu.RLock() defer s.mu.RUnlock() return s.docs[uri] }</span> // splitLines splits the input string into lines, normalizing line endings to '\n'. -func splitLines(sx string) []string <span class="cov10" title="62">{ +func splitLines(sx string) []string <span class="cov10" title="66">{ sx = strings.ReplaceAll(sx, "\r\n", "\n") return strings.Split(sx, "\n") }</span> @@ -2004,28 +2114,28 @@ func (s *Server) lineContext(uri string, pos Position) (above, current, below, f if d == nil || len(d.lines) == 0 </span><span class="cov1" title="1">{ return "", "", "", "" }</span> - <span class="cov4" title="4">idx := pos.Line + <span class="cov3" title="4">idx := pos.Line if idx < 0 </span><span class="cov0" title="0">{ idx = 0 }</span> - <span class="cov4" title="4">if idx >= len(d.lines) </span><span class="cov0" title="0">{ + <span class="cov3" title="4">if idx >= len(d.lines) </span><span class="cov0" title="0">{ idx = len(d.lines) - 1 }</span> - <span class="cov4" title="4">current = d.lines[idx] - if idx-1 >= 0 </span><span class="cov4" title="4">{ + <span class="cov3" title="4">current = d.lines[idx] + if idx-1 >= 0 </span><span class="cov3" title="4">{ above = d.lines[idx-1] }</span> - <span class="cov4" title="4">if idx+1 < len(d.lines) </span><span class="cov4" title="4">{ + <span class="cov3" title="4">if idx+1 < len(d.lines) </span><span class="cov3" title="4">{ below = d.lines[idx+1] }</span> - <span class="cov4" title="4">for i := idx; i >= 0; i-- </span><span class="cov4" title="6">{ + <span class="cov3" title="4">for i := idx; i >= 0; i-- </span><span class="cov4" title="6">{ line := strings.TrimSpace(d.lines[i]) - if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov4" title="4">{ + if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov3" title="4">{ funcCtx = line break</span> } } - <span class="cov4" title="4">return</span> + <span class="cov3" title="4">return above, current, below, funcCtx</span> } // isDefiningNewFunction returns true when the cursor appears to be within @@ -2060,7 +2170,7 @@ func (s *Server) isDefiningNewFunction(uri string, pos Position) bool <span clas return false }</span> // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body - <span class="cov3" title="3">for i := sigStart; i <= idx; i++ </span><span class="cov4" title="4">{ + <span class="cov3" title="3">for i := sigStart; i <= idx; i++ </span><span class="cov3" title="4">{ line := d.lines[i] brace := strings.Index(line, "{") if brace >= 0 </span><span class="cov2" title="2">{ @@ -2077,28 +2187,28 @@ func (s *Server) isDefiningNewFunction(uri string, pos Position) bool <span clas } func hasAny(s string, needles []string) bool <span class="cov4" title="6">{ - for _, n := range needles </span><span class="cov7" title="16">{ - if strings.Contains(s, n) </span><span class="cov4" title="4">{ + for _, n := range needles </span><span class="cov6" title="16">{ + if strings.Contains(s, n) </span><span class="cov3" title="4">{ return true }</span> } <span class="cov2" title="2">return false</span> } -func trimLen(s string) string <span class="cov8" title="38">{ +func trimLen(s string) string <span class="cov8" title="39">{ s = strings.TrimSpace(s) if len(s) > 200 </span><span class="cov1" title="1">{ return s[:200] + "…" }</span> - <span class="cov8" title="37">return s</span> + <span class="cov8" title="38">return s</span> } -func firstLine(s string) string <span class="cov7" title="20">{ +func firstLine(s string) string <span class="cov7" title="21">{ s = strings.ReplaceAll(s, "\r\n", "\n") - if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov4" title="4">{ + if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov4" title="5">{ return s[:idx] }</span> - <span class="cov7" title="16">return s</span> + <span class="cov6" title="16">return s</span> } </pre> @@ -2155,7 +2265,7 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b text string } cands := []cand{} - if t, l, r, ok := findStrictSemicolonTag(line); ok </span><span class="cov5" title="6">{ + if t, l, r, ok := findStrictInlineTag(line); ok </span><span class="cov5" title="6">{ cands = append(cands, cand{start: l, end: r, text: t}) }</span> <span class="cov8" title="22">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov2" title="2">{ @@ -2187,13 +2297,13 @@ func findFirstInstructionInLine(line string) (instr string, cleaned string, ok b return "", line, false }</span> // pick earliest start index - <span class="cov8" title="16">best := cands[0] + <span class="cov7" title="16">best := cands[0] for _, c := range cands[1:] </span><span class="cov4" title="4">{ if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov1" title="1">{ best = c }</span> } - <span class="cov8" title="16">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + <span class="cov7" title="16">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") return best.text, cleaned, true</span> } @@ -2292,33 +2402,33 @@ func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span cla // --- small completion cache (last ~10 entries) --- -func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov7" title="12">{ +func (s *Server) completionCacheKey(p CompletionParams, above, current, below, funcCtx string, inParams bool, hasExtra bool, extraText string) string <span class="cov7" title="13">{ // Normalize left-of-cursor by trimming trailing spaces/tabs idx := p.Position.Character if idx > len(current) </span><span class="cov0" title="0">{ idx = len(current) }</span> - <span class="cov7" title="12">left := strings.TrimRight(current[:idx], " \t") + <span class="cov7" title="13">left := strings.TrimRight(current[:idx], " \t") right := "" if idx < len(current) </span><span class="cov0" title="0">{ right = current[idx:] }</span> - <span class="cov7" title="12">prov := "" + <span class="cov7" title="13">prov := "" model := "" - if s.llmClient != nil </span><span class="cov7" title="12">{ + if s.llmClient != nil </span><span class="cov7" title="13">{ prov = s.llmClient.Name() model = s.llmClient.DefaultModel() }</span> - <span class="cov7" title="12">temp := "" + <span class="cov7" title="13">temp := "" if s.codingTemperature != nil </span><span class="cov0" title="0">{ temp = fmt.Sprintf("%.3f", *s.codingTemperature) }</span> - <span class="cov7" title="12">extra := "" + <span class="cov7" title="13">extra := "" if hasExtra </span><span class="cov0" title="0">{ extra = strings.TrimSpace(extraText) }</span> // Compose a key from essential context parts - <span class="cov7" title="12">return strings.Join([]string{ + <span class="cov7" title="13">return strings.Join([]string{ "v1", // version for future-proofing prov, model, @@ -2347,13 +2457,13 @@ func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov6 return v, true</span> } -func (s *Server) completionCachePut(key, value string) <span class="cov6" title="9">{ +func (s *Server) completionCachePut(key, value string) <span class="cov6" title="10">{ s.mu.Lock() defer s.mu.Unlock() - if s.compCache == nil </span><span class="cov1" title="1">{ + if s.compCache == nil </span><span class="cov2" title="2">{ s.compCache = make(map[string]string) }</span> - <span class="cov6" title="9">if _, exists := s.compCache[key]; !exists </span><span class="cov6" title="9">{ + <span class="cov6" title="10">if _, exists := s.compCache[key]; !exists </span><span class="cov6" title="10">{ s.compCacheOrder = append(s.compCacheOrder, key) s.compCache[key] = value if len(s.compCacheOrder) > 10 </span><span class="cov0" title="0">{ @@ -2362,7 +2472,7 @@ func (s *Server) completionCachePut(key, value string) <span class="cov6" title= s.compCacheOrder = s.compCacheOrder[1:] delete(s.compCache, old) }</span> - <span class="cov6" title="9">return</span> + <span class="cov6" title="10">return</span> } // update existing and mark most-recent <span class="cov0" title="0">s.compCache[key] = value @@ -2389,30 +2499,30 @@ func (s *Server) compCacheTouchLocked(key string) <span class="cov1" title="1">{ // by typing one of our configured trigger characters. It checks the LSP // CompletionContext if provided and also falls back to inspecting the character // immediately to the left of the cursor. -func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span class="cov8" title="21">{ +func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span class="cov9" title="25">{ // 1) Inspect LSP completion context if present - if p.Context != nil </span><span class="cov6" title="8">{ + if p.Context != nil </span><span class="cov7" title="11">{ var ctx struct { TriggerKind int `json:"triggerKind"` TriggerCharacter string `json:"triggerCharacter,omitempty"` } - if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov5" title="7">{ + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov6" title="10">{ _ = json.Unmarshal(raw, &ctx) }</span> else<span class="cov1" title="1"> { b, _ := json.Marshal(p.Context) _ = json.Unmarshal(b, &ctx) }</span> - // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), - // do not treat as a trigger source. - <span class="cov6" title="8">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) </span><span class="cov0" title="0">{ - return false - }</span> - // TriggerKind 1 = Invoked (manual). Always allow manual invoke. - <span class="cov6" title="8">if ctx.TriggerKind == 1 </span><span class="cov5" title="6">{ - return true - }</span> + // If configured and the line contains a bare double-open marker (e.g., '>>' with no '>>text>'), + // do not treat as a trigger source. + <span class="cov7" title="11">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) </span><span class="cov1" title="1">{ + return false + }</span> + // TriggerKind 1 = Invoked (manual). Always allow manual invoke. + <span class="cov6" title="10">if ctx.TriggerKind == 1 </span><span class="cov5" title="6">{ + return true + }</span> // TriggerKind 2 is TriggerCharacter per LSP spec - <span class="cov2" title="2">if ctx.TriggerKind == 2 </span><span class="cov2" title="2">{ + <span class="cov4" title="4">if ctx.TriggerKind == 2 </span><span class="cov3" title="3">{ if ctx.TriggerCharacter != "" </span><span class="cov2" title="2">{ for _, c := range s.triggerChars </span><span class="cov1" title="1">{ if c == ctx.TriggerCharacter </span><span class="cov1" title="1">{ @@ -2422,37 +2532,37 @@ func (s *Server) isTriggerEvent(p CompletionParams, current string) bool <span c <span class="cov1" title="1">return false</span> } // No character provided but reported as TriggerCharacter; be conservative - <span class="cov0" title="0">return false</span> + <span class="cov1" title="1">return false</span> } // For TriggerForIncomplete (3), require manual char check below } // 2) Fallback: check the character immediately prior to cursor - <span class="cov7" title="13">idx := p.Position.Character + <span class="cov7" title="15">idx := p.Position.Character if idx <= 0 || idx > len(current) </span><span class="cov0" title="0">{ return false }</span> - // Bare double-open should not trigger via fallback char either (only when configured) - <span class="cov7" title="13">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleSemicolonTrigger(current) </span><span class="cov1" title="1">{ - return false - }</span> - <span class="cov7" title="12">ch := string(current[idx-1]) - for _, c := range s.triggerChars </span><span class="cov10" title="34">{ - if c == ch </span><span class="cov5" title="5">{ + // Bare double-open should not trigger via fallback char either (only when configured) + <span class="cov7" title="15">if s.inlineOpen != "" && strings.Contains(current, s.inlineOpen+s.inlineOpen) && !hasDoubleOpenTrigger(current) </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov7" title="14">ch := string(current[idx-1]) + for _, c := range s.triggerChars </span><span class="cov10" title="36">{ + if c == ch </span><span class="cov5" title="6">{ return true }</span> } - <span class="cov5" title="7">return false</span> + <span class="cov6" title="8">return false</span> } -func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov6" title="10">{ +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov7" title="11">{ te, filter := computeTextEditAndFilter(cleaned, inParams, current, p) rm := s.collectPromptRemovalEdits(p.TextDocument.URI) label := labelForCompletion(cleaned, filter) detail := "Hexai LLM completion" - if s.llmClient != nil </span><span class="cov6" title="10">{ + if s.llmClient != nil </span><span class="cov7" title="11">{ detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() }</span> - <span class="cov6" title="10">return []CompletionItem{{ + <span class="cov7" title="11">return []CompletionItem{{ Label: label, Kind: 1, Detail: detail, @@ -2553,15 +2663,16 @@ func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span c package lsp import ( - "context" - "encoding/json" - "fmt" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "strings" - "time" - "os" - "path/filepath" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) func (s *Server) handleCodeAction(req Request) <span class="cov3" title="3">{ @@ -2579,24 +2690,24 @@ func (s *Server) handleCodeAction(req Request) <span class="cov3" title="3">{ }</span> <span class="cov2" title="2">return</span> } - <span class="cov1" title="1">sel := extractRangeText(d, p.Range) - - actions := make([]CodeAction, 0, 4) - if a := s.buildRewriteCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ - actions = append(actions, *a) - }</span> - <span class="cov1" title="1">if a := s.buildDiagnosticsCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ - actions = append(actions, *a) - }</span> - <span class="cov1" title="1">if a := s.buildDocumentCodeAction(p, sel); a != nil </span><span class="cov1" title="1">{ - actions = append(actions, *a) - }</span> - <span class="cov1" title="1">if a := s.buildGoUnitTestCodeAction(p); a != nil </span><span class="cov1" title="1">{ - actions = append(actions, *a) - }</span> - <span class="cov1" title="1">if len(req.ID) != 0 </span><span class="cov1" title="1">{ - s.reply(req.ID, actions, nil) - }</span> + <span class="cov1" title="1">sel := extractRangeText(d, p.Range) + + actions := make([]CodeAction, 0, 4) + if a := s.buildRewriteCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ + actions = append(actions, *a) + }</span> + <span class="cov1" title="1">if a := s.buildDiagnosticsCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ + actions = append(actions, *a) + }</span> + <span class="cov1" title="1">if a := s.buildDocumentCodeAction(p, sel); a != nil </span><span class="cov1" title="1">{ + actions = append(actions, *a) + }</span> + <span class="cov1" title="1">if a := s.buildGoUnitTestCodeAction(p); a != nil </span><span class="cov1" title="1">{ + actions = append(actions, *a) + }</span> + <span class="cov1" title="1">if len(req.ID) != 0 </span><span class="cov1" title="1">{ + s.reply(req.ID, actions, nil) + }</span> } func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov3" title="3">{ @@ -2647,8 +2758,8 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class if err := json.Unmarshal(ca.Data, &payload); err != nil </span><span class="cov0" title="0">{ return ca, false }</span> - <span class="cov6" title="9">switch payload.Type </span>{ - case "rewrite":<span class="cov3" title="3"> + <span class="cov6" title="9">switch payload.Type </span>{ + case "rewrite":<span class="cov3" title="3"> sys := "You are a precise code refactoring engine. Rewrite the given code strictly according to the instruction. Return only the updated code with no prose or backticks. Preserve formatting where reasonable." user := fmt.Sprintf("Instruction: %s\n\nSelected code to transform:\n%s", payload.Instruction, payload.Selection) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -2664,7 +2775,7 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class } else<span class="cov0" title="0"> { logging.Logf("lsp ", "codeAction rewrite llm error: %v", err) }</span> - case "diagnostics":<span class="cov4" title="4"> + case "diagnostics":<span class="cov4" title="4"> sys := "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes." var b strings.Builder b.WriteString("Diagnostics to resolve (selection only):\n") @@ -2690,34 +2801,34 @@ func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class } else<span class="cov0" title="0"> { logging.Logf("lsp ", "codeAction diagnostics llm error: %v", err) }</span> - case "document":<span class="cov2" title="2"> - sys := "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks." - user := "Add documentation comments to this code:\n" + payload.Selection - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov2" title="2">{ - if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov2" title="2">{ - edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} - ca.Edit = &edit - return ca, true - }</span> - } else<span class="cov0" title="0"> { - logging.Logf("lsp ", "codeAction document llm error: %v", err) - }</span> - case "go_test":<span class="cov0" title="0"> - if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok </span><span class="cov0" title="0">{ - ca.Edit = &edit - // After edit is applied, ask client to jump to new test function - ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} - // Also send a server-initiated showDocument shortly after resolve to cover - // clients that do not execute commands from code actions. - s.deferShowDocument(jumpURI, jumpRange) - return ca, true - }</span> - } - <span class="cov0" title="0">return ca, false</span> + case "document":<span class="cov2" title="2"> + sys := "You are a precise code documentation engine. Add idiomatic documentation comments to the given code. Preserve exact behavior and formatting as much as possible. Return only the updated code with comments, no prose or backticks." + user := "Add documentation comments to this code:\n" + payload.Selection + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov2" title="2">{ + if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov2" title="2">{ + edit := WorkspaceEdit{Changes: map[string][]TextEdit{payload.URI: {{Range: payload.Range, NewText: out}}}} + ca.Edit = &edit + return ca, true + }</span> + } else<span class="cov0" title="0"> { + logging.Logf("lsp ", "codeAction document llm error: %v", err) + }</span> + case "go_test":<span class="cov0" title="0"> + if edit, jumpURI, jumpRange, ok := s.resolveGoTest(payload.URI, payload.Range.Start); ok </span><span class="cov0" title="0">{ + ca.Edit = &edit + // After edit is applied, ask client to jump to new test function + ca.Command = &Command{Title: "Jump to generated test", Command: "hexai.showDocument", Arguments: []any{jumpURI, jumpRange}} + // Also send a server-initiated showDocument shortly after resolve to cover + // clients that do not execute commands from code actions. + s.deferShowDocument(jumpURI, jumpRange) + return ca, true + }</span> + } + <span class="cov0" title="0">return ca, false</span> } func (s *Server) handleCodeActionResolve(req Request) <span class="cov2" title="2">{ @@ -2795,256 +2906,284 @@ func greaterPos(p, q Position) bool <span class="cov7" title="17">{ // --- Go unit test code action --- func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span class="cov3" title="3">{ - uri := p.TextDocument.URI - if uri == "" || !strings.HasSuffix(strings.TrimPrefix(uri, "file://"), ".go") </span><span class="cov0" title="0">{ - return nil - }</span> - // Skip if already a _test.go file - <span class="cov3" title="3">if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") </span><span class="cov1" title="1">{ - return nil - }</span> - // Heuristic: only offer when a function context is found above the cursor - <span class="cov2" title="2">_, _, _, funcCtx := s.lineContext(uri, p.Range.Start) - if !strings.Contains(funcCtx, "func ") </span><span class="cov0" title="0">{ - return nil - }</span> - <span class="cov2" title="2">payload := struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - }{Type: "go_test", URI: uri, Range: p.Range} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: implement unit test", Kind: "quickfix", Data: raw} - return &ca</span> + uri := p.TextDocument.URI + if uri == "" || !strings.HasSuffix(strings.TrimPrefix(uri, "file://"), ".go") </span><span class="cov0" title="0">{ + return nil + }</span> + // Skip if already a _test.go file + <span class="cov3" title="3">if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") </span><span class="cov1" title="1">{ + return nil + }</span> + // Heuristic: only offer when a function context is found above the cursor + <span class="cov2" title="2">_, _, _, funcCtx := s.lineContext(uri, p.Range.Start) + if !strings.Contains(funcCtx, "func ") </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov2" title="2">payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + }{Type: "go_test", URI: uri, Range: p.Range} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: implement unit test", Kind: "quickfix", Data: raw} + return &ca</span> } // buildDocumentCodeAction offers to document the selected code by injecting comments. func (s *Server) buildDocumentCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov2" title="2">{ - if s.llmClient == nil </span><span class="cov0" title="0">{ - return nil - }</span> - <span class="cov2" title="2">if strings.TrimSpace(sel) == "" </span><span class="cov0" title="0">{ - return nil - }</span> - <span class="cov2" title="2">payload := struct { - Type string `json:"type"` - URI string `json:"uri"` - Range Range `json:"range"` - Selection string `json:"selection"` - }{Type: "document", URI: p.TextDocument.URI, Range: p.Range, Selection: sel} - raw, _ := json.Marshal(payload) - ca := CodeAction{Title: "Hexai: document code", Kind: "refactor.rewrite", Data: raw} - return &ca</span> + if s.llmClient == nil </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov2" title="2">if strings.TrimSpace(sel) == "" </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov2" title="2">payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + }{Type: "document", URI: p.TextDocument.URI, Range: p.Range, Selection: sel} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: document code", Kind: "refactor.rewrite", Data: raw} + return &ca</span> } func (s *Server) resolveGoTest(uri string, pos Position) (WorkspaceEdit, string, Range, bool) <span class="cov2" title="2">{ - path := strings.TrimPrefix(uri, "file://") - if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") </span><span class="cov0" title="0">{ - return WorkspaceEdit{}, "", Range{}, false - }</span> - // Load source text - <span class="cov2" title="2">_, lines := s.loadFileText(uri) - if len(lines) == 0 </span><span class="cov0" title="0">{ - return WorkspaceEdit{}, "", Range{}, false - }</span> - <span class="cov2" title="2">pkg := parseGoPackageName(lines) - fnStart, fnEnd := findGoFunctionAtLine(lines, pos.Line) - if fnStart < 0 || fnEnd < fnStart </span><span class="cov0" title="0">{ - return WorkspaceEdit{}, "", Range{}, false - }</span> - <span class="cov2" title="2">funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n") - testFunc := s.generateGoTestFunction(funcCode) - if strings.TrimSpace(testFunc) == "" </span><span class="cov0" title="0">{ - return WorkspaceEdit{}, "", Range{}, false - }</span> - // Determine test file target - <span class="cov2" title="2">testPath := strings.TrimSuffix(path, ".go") + "_test.go" - testURI := "file://" + testPath - - // If test file exists, append test at EOF; otherwise, create a new file with package+import - if fileExists(testPath) </span><span class="cov1" title="1">{ - // Build an insertion at end of file - _, tLines := s.loadFileText(testURI) - // Fallback when not open and cannot read: still insert at line 0 - lineIdx := 0 - col := 0 - if len(tLines) > 0 </span><span class="cov1" title="1">{ - lineIdx = len(tLines) - 1 - col = len(tLines[lineIdx]) + path := strings.TrimPrefix(uri, "file://") + if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + // Load source text + <span class="cov2" title="2">_, lines := s.loadFileText(uri) + if len(lines) == 0 </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + <span class="cov2" title="2">pkg := parseGoPackageName(lines) + fnStart, fnEnd := findGoFunctionAtLine(lines, pos.Line) + if fnStart < 0 || fnEnd < fnStart </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + <span class="cov2" title="2">funcCode := strings.Join(lines[fnStart:fnEnd+1], "\n") + testFunc := s.generateGoTestFunction(funcCode) + if strings.TrimSpace(testFunc) == "" </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + // Determine test file target + <span class="cov2" title="2">testPath := strings.TrimSuffix(path, ".go") + "_test.go" + testURI := "file://" + testPath + + // If test file exists, append test at EOF; otherwise, create a new file with package+import + if fileExists(testPath) </span><span class="cov1" title="1">{ + // Build an insertion at end of file + _, tLines := s.loadFileText(testURI) + // Fallback when not open and cannot read: still insert at line 0 + lineIdx := 0 + col := 0 + if len(tLines) > 0 </span><span class="cov1" title="1">{ + lineIdx = len(tLines) - 1 + col = len(tLines[lineIdx]) + }</span> + <span class="cov1" title="1">var b strings.Builder + // Ensure at least two newlines before the new test + if len(tLines) == 0 || (len(tLines) > 0 && !strings.HasSuffix(strings.Join(tLines, "\n"), "\n\n")) </span><span class="cov0" title="0">{ + b.WriteString("\n\n") + }</span> + <span class="cov1" title="1">b.WriteString(testFunc) + insert := b.String() + edit := TextEdit{Range: Range{Start: Position{Line: lineIdx, Character: col}, End: Position{Line: lineIdx, Character: col}}, NewText: insert} + we := WorkspaceEdit{Changes: map[string][]TextEdit{testURI: {edit}}} + // Compute jump range start + // Count how many prefix newlines added before the test function + prefixNL := 0 + if strings.HasPrefix(insert, "\n\n") </span><span class="cov0" title="0">{ + prefixNL = 2 + }</span> + <span class="cov1" title="1">startLine := lineIdx + prefixNL + // If we inserted with two newlines and last line wasn't blank, first newline moves to next line + if prefixNL > 0 </span><span class="cov0" title="0">{ + startLine = lineIdx + prefixNL + }</span> + <span class="cov1" title="1">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} + return we, testURI, jump, true</span> + } + // Create new file content + <span class="cov1" title="1">var content strings.Builder + if pkg == "" </span><span class="cov0" title="0">{ + pkg = filepath.Base(filepath.Dir(path)) + }</span> + <span class="cov1" title="1">content.WriteString("package ") + content.WriteString(pkg) + content.WriteString("\n\n") + content.WriteString("import (\n\t\"testing\"\n)\n\n") + content.WriteString(testFunc) + full := content.String() + // Use documentChanges with create + full content insert + create := CreateFile{Kind: "create", URI: testURI} + tde := TextDocumentEdit{TextDocument: VersionedTextDocumentIdentifier{URI: testURI}, Edits: []TextEdit{{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 0}}, NewText: full}}} + we := WorkspaceEdit{DocumentChanges: []any{create, tde}} + // Find start line of first test function + // Count lines before the substring "func Test" + pre := content.String() + idx := strings.Index(pre, "func Test") + startLine := 0 + if idx > 0 </span><span class="cov1" title="1">{ + before := pre[:idx] + startLine = strings.Count(before, "\n") }</span> - <span class="cov1" title="1">var b strings.Builder - // Ensure at least two newlines before the new test - if len(tLines) == 0 || (len(tLines) > 0 && !strings.HasSuffix(strings.Join(tLines, "\n"), "\n\n")) </span><span class="cov0" title="0">{ - b.WriteString("\n\n") - }</span> - <span class="cov1" title="1">b.WriteString(testFunc) - insert := b.String() - edit := TextEdit{Range: Range{Start: Position{Line: lineIdx, Character: col}, End: Position{Line: lineIdx, Character: col}}, NewText: insert} - we := WorkspaceEdit{Changes: map[string][]TextEdit{testURI: {edit}}} - // Compute jump range start - // Count how many prefix newlines added before the test function - prefixNL := 0 - if strings.HasPrefix(insert, "\n\n") </span><span class="cov0" title="0">{ prefixNL = 2 }</span> - <span class="cov1" title="1">startLine := lineIdx + prefixNL - // If we inserted with two newlines and last line wasn't blank, first newline moves to next line - if prefixNL > 0 </span><span class="cov0" title="0">{ startLine = lineIdx + prefixNL }</span> <span class="cov1" title="1">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} return we, testURI, jump, true</span> - } - // Create new file content - <span class="cov1" title="1">var content strings.Builder - if pkg == "" </span><span class="cov0" title="0">{ pkg = filepath.Base(filepath.Dir(path)) }</span> - <span class="cov1" title="1">content.WriteString("package ") - content.WriteString(pkg) - content.WriteString("\n\n") - content.WriteString("import (\n\t\"testing\"\n)\n\n") - content.WriteString(testFunc) - full := content.String() - // Use documentChanges with create + full content insert - create := CreateFile{Kind: "create", URI: testURI} - tde := TextDocumentEdit{TextDocument: VersionedTextDocumentIdentifier{URI: testURI}, Edits: []TextEdit{{Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: 0, Character: 0}}, NewText: full}}} - we := WorkspaceEdit{DocumentChanges: []any{create, tde}} - // Find start line of first test function - // Count lines before the substring "func Test" - pre := content.String() - idx := strings.Index(pre, "func Test") - startLine := 0 - if idx > 0 </span><span class="cov1" title="1">{ - before := pre[:idx] - startLine = strings.Count(before, "\n") - }</span> - <span class="cov1" title="1">jump := Range{Start: Position{Line: startLine, Character: 0}, End: Position{Line: startLine, Character: 0}} - return we, testURI, jump, true</span> } // loadFileText returns the file content and lines. It prefers the open document; otherwise reads from disk. func (s *Server) loadFileText(uri string) (string, []string) <span class="cov3" title="3">{ - if d := s.getDocument(uri); d != nil </span><span class="cov2" title="2">{ - return d.text, append([]string{}, d.lines...) - }</span> - <span class="cov1" title="1">path := strings.TrimPrefix(uri, "file://") - b, err := os.ReadFile(path) - if err != nil </span><span class="cov0" title="0">{ - return "", nil - }</span> - <span class="cov1" title="1">txt := string(b) - return txt, splitLines(txt)</span> + if d := s.getDocument(uri); d != nil </span><span class="cov2" title="2">{ + return d.text, append([]string{}, d.lines...) + }</span> + <span class="cov1" title="1">path := strings.TrimPrefix(uri, "file://") + b, err := os.ReadFile(path) + if err != nil </span><span class="cov0" title="0">{ + return "", nil + }</span> + <span class="cov1" title="1">txt := string(b) + return txt, splitLines(txt)</span> } func fileExists(path string) bool <span class="cov2" title="2">{ - if _, err := os.Stat(path); err == nil </span><span class="cov1" title="1">{ - return true - }</span> - <span class="cov1" title="1">return false</span> + if _, err := os.Stat(path); err == nil </span><span class="cov1" title="1">{ + return true + }</span> + <span class="cov1" title="1">return false</span> } // parseGoPackageName returns the package name from file lines, or empty if not found. func parseGoPackageName(lines []string) string <span class="cov4" title="4">{ - for _, ln := range lines </span><span class="cov4" title="5">{ - t := strings.TrimSpace(ln) - if strings.HasPrefix(t, "package ") </span><span class="cov3" title="3">{ - name := strings.TrimSpace(strings.TrimPrefix(t, "package ")) - // strip inline comments - if i := strings.Index(name, " "); i >= 0 </span><span class="cov1" title="1">{ name = name[:i] }</span> - <span class="cov3" title="3">if i := strings.Index(name, "\t"); i >= 0 </span><span class="cov0" title="0">{ name = name[:i] }</span> - <span class="cov3" title="3">if i := strings.Index(name, "//"); i >= 0 </span><span class="cov0" title="0">{ name = strings.TrimSpace(name[:i]) }</span> - <span class="cov3" title="3">return name</span> + for _, ln := range lines </span><span class="cov4" title="5">{ + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "package ") </span><span class="cov3" title="3">{ + name := strings.TrimSpace(strings.TrimPrefix(t, "package ")) + // strip inline comments + if i := strings.Index(name, " "); i >= 0 </span><span class="cov1" title="1">{ + name = name[:i] + }</span> + <span class="cov3" title="3">if i := strings.Index(name, "\t"); i >= 0 </span><span class="cov0" title="0">{ + name = name[:i] + }</span> + <span class="cov3" title="3">if i := strings.Index(name, "//"); i >= 0 </span><span class="cov0" title="0">{ + name = strings.TrimSpace(name[:i]) + }</span> + <span class="cov3" title="3">return name</span> + } } - } - <span class="cov1" title="1">return ""</span> + <span class="cov1" title="1">return ""</span> } // findGoFunctionAtLine finds the function enclosing or preceding line idx. Returns start and end line indexes. func findGoFunctionAtLine(lines []string, idx int) (int, int) <span class="cov2" title="2">{ - if idx < 0 </span><span class="cov0" title="0">{ idx = 0 }</span> - <span class="cov2" title="2">if idx >= len(lines) </span><span class="cov0" title="0">{ idx = len(lines)-1 }</span> - // find signature start - <span class="cov2" title="2">start := -1 - for i := idx; i >= 0; i-- </span><span class="cov2" title="2">{ - if strings.Contains(lines[i], "func ") </span><span class="cov2" title="2">{ - start = i - break</span> - } - <span class="cov0" title="0">if strings.Contains(lines[i], "}") </span><span class="cov0" title="0">{ - break</span> + if idx < 0 </span><span class="cov0" title="0">{ + idx = 0 + }</span> + <span class="cov2" title="2">if idx >= len(lines) </span><span class="cov0" title="0">{ + idx = len(lines) - 1 + }</span> + // find signature start + <span class="cov2" title="2">start := -1 + for i := idx; i >= 0; i-- </span><span class="cov2" title="2">{ + if strings.Contains(lines[i], "func ") </span><span class="cov2" title="2">{ + start = i + break</span> + } + <span class="cov0" title="0">if strings.Contains(lines[i], "}") </span><span class="cov0" title="0">{ + break</span> + } } - } - <span class="cov2" title="2">if start == -1 </span><span class="cov0" title="0">{ return -1, -1 }</span> - // find first '{' - <span class="cov2" title="2">depth := 0 - seenOpen := false - for i := start; i < len(lines); i++ </span><span class="cov2" title="2">{ - ln := lines[i] - for j := 0; j < len(ln); j++ </span><span class="cov10" title="47">{ - switch ln[j] </span>{ - case '{':<span class="cov2" title="2"> - depth++ - seenOpen = true</span> - case '}':<span class="cov2" title="2"> - if depth > 0 </span><span class="cov2" title="2">{ depth-- }</span> - <span class="cov2" title="2">if seenOpen && depth == 0 </span><span class="cov2" title="2">{ - return start, i - }</span> - } + <span class="cov2" title="2">if start == -1 </span><span class="cov0" title="0">{ + return -1, -1 + }</span> + // find first '{' + <span class="cov2" title="2">depth := 0 + seenOpen := false + for i := start; i < len(lines); i++ </span><span class="cov2" title="2">{ + ln := lines[i] + for j := 0; j < len(ln); j++ </span><span class="cov10" title="47">{ + switch ln[j] </span>{ + case '{':<span class="cov2" title="2"> + depth++ + seenOpen = true</span> + case '}':<span class="cov2" title="2"> + if depth > 0 </span><span class="cov2" title="2">{ + depth-- + }</span> + <span class="cov2" title="2">if seenOpen && depth == 0 </span><span class="cov2" title="2">{ + return start, i + }</span> + } + } } - } - // if never saw '{', assume single-line prototype; return that line - <span class="cov0" title="0">if !seenOpen </span><span class="cov0" title="0">{ - return start, start - }</span> - <span class="cov0" title="0">return start, -1</span> + // if never saw '{', assume single-line prototype; return that line + <span class="cov0" title="0">if !seenOpen </span><span class="cov0" title="0">{ + return start, start + }</span> + <span class="cov0" title="0">return start, -1</span> } // generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable. func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov2" title="2">{ - if s.llmClient != nil </span><span class="cov1" title="1">{ - sys := "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic." - user := "Function under test:\n" + funcCode - ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel() - messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} - opts := s.llmRequestOpts() - if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov1" title="1">{ - cleaned := strings.TrimSpace(stripCodeFences(out)) - if cleaned != "" </span><span class="cov1" title="1">{ return cleaned }</span> - } else<span class="cov0" title="0"> { - logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + if s.llmClient != nil </span><span class="cov1" title="1">{ + sys := "You are a precise Go unit test generator. Given a Go function, write one or more Test* functions using the testing package. Do NOT include package or imports, only the test function(s). Prefer table-driven tests. Keep it minimal and idiomatic." + user := "Function under test:\n" + funcCode + ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: user}} + opts := s.llmRequestOpts() + if out, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov1" title="1">{ + cleaned := strings.TrimSpace(stripCodeFences(out)) + if cleaned != "" </span><span class="cov1" title="1">{ + return cleaned + }</span> + } else<span class="cov0" title="0"> { + logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + }</span> + } + // Fallback stub + <span class="cov1" title="1">name := deriveGoFuncName(funcCode) + if name == "" </span><span class="cov0" title="0">{ + name = "Function" }</span> - } - // Fallback stub - <span class="cov1" title="1">name := deriveGoFuncName(funcCode) - if name == "" </span><span class="cov0" title="0">{ name = "Function" }</span> - <span class="cov1" title="1">return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name)</span> + <span class="cov1" title="1">return fmt.Sprintf("func Test%s(t *testing.T) {\n\t// TODO: implement tests for %s\n}\n", exportName(name), name)</span> } // deriveGoFuncName extracts function or method name from code. func deriveGoFuncName(code string) string <span class="cov3" title="3">{ - // look for line starting with func - line := firstLine(code) - line = strings.TrimSpace(line) - if !strings.HasPrefix(line, "func ") </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov3" title="3">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) - // method receiver - if strings.HasPrefix(rest, "(") </span><span class="cov1" title="1">{ - // find ")" - if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) </span><span class="cov1" title="1">{ - rest = strings.TrimSpace(rest[i+1:]) - }</span> - } - // now rest should start with Name( - <span class="cov3" title="3">if i := strings.Index(rest, "("); i > 0 </span><span class="cov3" title="3">{ - return strings.TrimSpace(rest[:i]) - }</span> - <span class="cov0" title="0">return ""</span> + // look for line starting with func + line := firstLine(code) + line = strings.TrimSpace(line) + if !strings.HasPrefix(line, "func ") </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov3" title="3">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) + // method receiver + if strings.HasPrefix(rest, "(") </span><span class="cov1" title="1">{ + // find ")" + if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) </span><span class="cov1" title="1">{ + rest = strings.TrimSpace(rest[i+1:]) + }</span> + } + // now rest should start with Name( + <span class="cov3" title="3">if i := strings.Index(rest, "("); i > 0 </span><span class="cov3" title="3">{ + return strings.TrimSpace(rest[:i]) + }</span> + <span class="cov0" title="0">return ""</span> } func exportName(name string) string <span class="cov1" title="1">{ - if name == "" </span><span class="cov0" title="0">{ return name }</span> - <span class="cov1" title="1">r := []rune(name) - if r[0] >= 'a' && r[0] <= 'z' </span><span class="cov0" title="0">{ - r[0] = r[0] - ('a' - 'A') - }</span> - <span class="cov1" title="1">return string(r)</span> + if name == "" </span><span class="cov0" title="0">{ + return name + }</span> + <span class="cov1" title="1">r := []rune(name) + if r[0] >= 'a' && r[0] <= 'z' </span><span class="cov0" title="0">{ + r[0] = r[0] - ('a' - 'A') + }</span> + <span class="cov1" title="1">return string(r)</span> } </pre> @@ -3052,13 +3191,14 @@ func exportName(name string) string <span class="cov1" title="1">{ package lsp import ( - "context" - "encoding/json" - "fmt" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "strings" - "time" + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) func (s *Server) handleCompletion(req Request) <span class="cov1" title="1">{ @@ -3120,8 +3260,8 @@ func (s *Server) logCompletionContext(p CompletionParams, above, current, below, }</span> func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) <span class="cov10" title="18">{ - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) - defer cancel() + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() inlinePrompt := lineHasInlinePrompt(current) if !inlinePrompt && !s.isTriggerEvent(p, current) </span><span class="cov7" title="8">{ @@ -3143,20 +3283,20 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true }</span> - <span class="cov7" title="9">if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) </span><span class="cov1" title="1">{ - logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) - return []CompletionItem{}, true - }</span> + <span class="cov7" title="9">if isBareDoubleOpen(current) || isBareDoubleOpen(below) </span><span class="cov1" title="1">{ + logging.Logf("lsp ", "%scompletion skip=empty-double-semicolon line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) + return []CompletionItem{}, true + }</span> <span class="cov7" title="8">if !inParams && !s.prefixHeuristicAllows(inlinePrompt, current, p, manualInvoke) </span><span class="cov0" title="0">{ logging.Logf("lsp ", "%scompletion skip=short-prefix line=%d char=%d current=%q%s", logging.AnsiYellow, p.Position.Line, p.Position.Character, trimLen(current), logging.AnsiBase) return []CompletionItem{}, true }</span> - // Provider-native path - <span class="cov7" title="8">if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok </span><span class="cov1" title="1">{ - return items, true - }</span> + // Provider-native path + <span class="cov7" title="8">if items, ok := s.tryProviderNativeCompletion(current, p, above, below, funcCtx, docStr, hasExtra, extraText, inParams); ok </span><span class="cov1" title="1">{ + return items, true + }</span> // Chat path <span class="cov7" title="7">messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx) @@ -3170,12 +3310,12 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun if s.codingTemperature != nil </span><span class="cov0" title="0">{ opts = append(opts, llm.WithTemperature(*s.codingTemperature)) }</span> - // Debounce and throttle before making the LLM call - <span class="cov7" title="7">s.waitForDebounce(ctx) - if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ - return nil, false - }</span> - <span class="cov7" title="7">logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) + // Debounce and throttle before making the LLM call + <span class="cov7" title="7">s.waitForDebounce(ctx) + if !s.waitForThrottle(ctx) </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov7" title="7">logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) text, err := s.llmClient.Chat(ctx, messages, opts...) if err != nil </span><span class="cov0" title="0">{ @@ -3212,20 +3352,24 @@ func parseManualInvoke(ctx any) bool <span class="cov8" title="11">{ } // shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. -func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov8" title="12">{ - t := strings.TrimRight(current, " \t") - if s.chatSuffix == "" </span><span class="cov6" title="5">{ return false }</span> - <span class="cov7" title="7">if strings.HasSuffix(t, s.chatSuffix) </span><span class="cov3" title="2">{ - if len(t) < len(s.chatSuffix)+1 </span><span class="cov0" title="0">{ return false }</span> - <span class="cov3" title="2">prev := string(t[len(t)-len(s.chatSuffix)-1]) - for _, pf := range s.chatPrefixes </span><span class="cov7" title="8">{ - if prev == pf </span><span class="cov1" title="1">{ - logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) - return true - }</span> +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov9" title="15">{ + t := strings.TrimRight(current, " \t") + if s.chatSuffix == "" </span><span class="cov6" title="6">{ + return false + }</span> + <span class="cov7" title="9">if strings.HasSuffix(t, s.chatSuffix) </span><span class="cov5" title="4">{ + if len(t) < len(s.chatSuffix)+1 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov5" title="4">prev := string(t[len(t)-len(s.chatSuffix)-1]) + for _, pf := range s.chatPrefixes </span><span class="cov8" title="10">{ + if prev == pf </span><span class="cov3" title="2">{ + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + }</span> + } } - } - <span class="cov6" title="6">return false</span> + <span class="cov7" title="7">return false</span> } // prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. @@ -3265,12 +3409,12 @@ func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p Comp } // tryProviderNativeCompletion attempts provider-native completion and returns items when successful. -func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) <span class="cov7" title="9">{ +func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, above, below, funcCtx, docStr string, hasExtra bool, extraText string, inParams bool) ([]CompletionItem, bool) <span class="cov8" title="10">{ cc, ok := s.llmClient.(llm.CodeCompleter) if !ok </span><span class="cov6" title="6">{ return nil, false }</span> - <span class="cov4" title="3">before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + <span class="cov5" title="4">before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) path := strings.TrimPrefix(p.TextDocument.URI, "file://") prompt := "// Path: " + path + "\n" + before lang := "" @@ -3278,34 +3422,34 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, if s.codingTemperature != nil </span><span class="cov0" title="0">{ temp = *s.codingTemperature }</span> - <span class="cov4" title="3">prov := "" - if s.llmClient != nil </span><span class="cov4" title="3">{ + <span class="cov5" title="4">prov := "" + if s.llmClient != nil </span><span class="cov5" title="4">{ prov = s.llmClient.Name() }</span> - <span class="cov4" title="3">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) - ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) - defer cancel2() - - // Debounce and throttle prior to provider-native call - s.waitForDebounce(ctx2) - if !s.waitForThrottle(ctx2) </span><span class="cov0" title="0">{ - return nil, false - }</span> - <span class="cov4" title="3">suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) - if err == nil && len(suggestions) > 0 </span><span class="cov3" title="2">{ + <span class="cov5" title="4">logging.Logf("lsp ", "completion path=codex provider=%s uri=%s", prov, path) + ctx2, cancel2 := context.WithTimeout(context.Background(), 8*time.Second) + defer cancel2() + + // Debounce and throttle prior to provider-native call + s.waitForDebounce(ctx2) + if !s.waitForThrottle(ctx2) </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov5" title="4">suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) + if err == nil && len(suggestions) > 0 </span><span class="cov4" title="3">{ cleaned := strings.TrimSpace(suggestions[0]) - if cleaned != "" </span><span class="cov3" title="2">{ + if cleaned != "" </span><span class="cov4" title="3">{ cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) - if cleaned != "" </span><span class="cov3" title="2">{ + if cleaned != "" </span><span class="cov4" title="3">{ cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) }</span> - <span class="cov3" title="2">if cleaned != "" && hasDoubleSemicolonTrigger(current) </span><span class="cov0" title="0">{ + <span class="cov4" title="3">if cleaned != "" && hasDoubleOpenTrigger(current) </span><span class="cov1" title="1">{ indent := leadingIndent(current) - if indent != "" </span><span class="cov0" title="0">{ + if indent != "" </span><span class="cov1" title="1">{ cleaned = applyIndent(indent, cleaned) }</span> } - <span class="cov3" title="2">if strings.TrimSpace(cleaned) != "" </span><span class="cov3" title="2">{ + <span class="cov4" title="3">if strings.TrimSpace(cleaned) != "" </span><span class="cov4" title="3">{ key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) s.completionCachePut(key, cleaned) return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true @@ -3319,64 +3463,64 @@ func (s *Server) tryProviderNativeCompletion(current string, p CompletionParams, // waitForDebounce sleeps until there has been no input activity for at least // completionDebounce. If debounce is zero or ctx is done, it returns promptly. -func (s *Server) waitForDebounce(ctx context.Context) <span class="cov8" title="10">{ - d := s.completionDebounce - if d <= 0 </span><span class="cov7" title="9">{ - return - }</span> - <span class="cov1" title="1">for </span><span class="cov3" title="2">{ - s.mu.RLock() - last := s.lastInput - s.mu.RUnlock() - if last.IsZero() </span><span class="cov0" title="0">{ - return - }</span> - <span class="cov3" title="2">since := time.Since(last) - if since >= d </span><span class="cov1" title="1">{ - return - }</span> - <span class="cov1" title="1">rem := d - since - timer := time.NewTimer(rem) - select </span>{ - case <-ctx.Done():<span class="cov0" title="0"> - timer.Stop() - return</span> - case <-timer.C:<span class="cov1" title="1"></span> - // loop and re-evaluate in case input occurred during sleep +func (s *Server) waitForDebounce(ctx context.Context) <span class="cov8" title="12">{ + d := s.completionDebounce + if d <= 0 </span><span class="cov8" title="10">{ + return + }</span> + <span class="cov3" title="2">for </span><span class="cov5" title="4">{ + s.mu.RLock() + last := s.lastInput + s.mu.RUnlock() + if last.IsZero() </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov5" title="4">since := time.Since(last) + if since >= d </span><span class="cov3" title="2">{ + return + }</span> + <span class="cov3" title="2">rem := d - since + timer := time.NewTimer(rem) + select </span>{ + case <-ctx.Done():<span class="cov0" title="0"> + timer.Stop() + return</span> + case <-timer.C:<span class="cov3" title="2"></span> + // loop and re-evaluate in case input occurred during sleep + } } - } } // waitForThrottle enforces a minimum spacing between LLM calls. Returns false // if the context is canceled while waiting. -func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov8" title="10">{ - interval := s.throttleInterval - if interval <= 0 </span><span class="cov7" title="8">{ - return true - }</span> - <span class="cov3" title="2">var wait time.Duration - for </span><span class="cov4" title="3">{ - s.mu.Lock() - next := s.lastLLMCall.Add(interval) - now := time.Now() - if now.Before(next) </span><span class="cov1" title="1">{ - wait = next.Sub(now) - s.mu.Unlock() - timer := time.NewTimer(wait) - select </span>{ - case <-ctx.Done():<span class="cov0" title="0"> - timer.Stop() - return false</span> - case <-timer.C:<span class="cov1" title="1"> - // try again to set the next call time - continue</span> - } +func (s *Server) waitForThrottle(ctx context.Context) bool <span class="cov8" title="12">{ + interval := s.throttleInterval + if interval <= 0 </span><span class="cov7" title="9">{ + return true + }</span> + <span class="cov4" title="3">var wait time.Duration + for </span><span class="cov6" title="5">{ + s.mu.Lock() + next := s.lastLLMCall.Add(interval) + now := time.Now() + if now.Before(next) </span><span class="cov3" title="2">{ + wait = next.Sub(now) + s.mu.Unlock() + timer := time.NewTimer(wait) + select </span>{ + case <-ctx.Done():<span class="cov0" title="0"> + timer.Stop() + return false</span> + case <-timer.C:<span class="cov3" title="2"> + // try again to set the next call time + continue</span> + } + } + // we are allowed to proceed now; record this call as the latest + <span class="cov4" title="3">s.lastLLMCall = now + s.mu.Unlock() + return true</span> } - // we are allowed to proceed now; record this call as the latest - <span class="cov3" title="2">s.lastLLMCall = now - s.mu.Unlock() - return true</span> - } } // buildCompletionMessages constructs the LLM messages for completion. @@ -3409,7 +3553,7 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current <span class="cov8" title="10">if cleaned != "" </span><span class="cov8" title="10">{ cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) }</span> - <span class="cov8" title="10">if cleaned != "" && hasDoubleSemicolonTrigger(currentLine) </span><span class="cov1" title="1">{ + <span class="cov8" title="10">if cleaned != "" && hasDoubleOpenTrigger(currentLine) </span><span class="cov1" title="1">{ if indent := leadingIndent(currentLine); indent != "" </span><span class="cov1" title="1">{ cleaned = applyIndent(indent, cleaned) }</span> @@ -3422,18 +3566,21 @@ func (s *Server) postProcessCompletion(text string, leftOfCursor string, current package lsp import ( - "context" - "encoding/json" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "strings" - "time" + "context" + "encoding/json" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) // Package-level chat trigger vars for helpers without Server receiver. // NewServer assigns these from configuration on startup. -var chatSuffixChar byte = '>' -var chatPrefixSingles = []string{"?", "!", ":", ";"} +var ( + chatSuffixChar byte = '>' + chatPrefixSingles = []string{"?", "!", ":", ";"} +) func (s *Server) handleDidOpen(req Request) <span class="cov1" title="1">{ var p DidOpenTextDocumentParams @@ -3466,33 +3613,33 @@ func (s *Server) handleDidClose(req Request) <span class="cov1" title="1">{ // docBeforeAfter returns the full document text split at the given position. // The returned strings are the text before the cursor (inclusive of anything // left of the position) and the text after the cursor. -func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span class="cov6" title="4">{ +func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span class="cov7" title="6">{ d := s.getDocument(uri) - if d == nil </span><span class="cov5" title="3">{ + if d == nil </span><span class="cov6" title="4">{ return "", "" }</span> // Clamp indices - <span class="cov1" title="1">line := pos.Line + <span class="cov3" title="2">line := pos.Line if line < 0 </span><span class="cov0" title="0">{ line = 0 }</span> - <span class="cov1" title="1">if line >= len(d.lines) </span><span class="cov0" title="0">{ + <span class="cov3" title="2">if line >= len(d.lines) </span><span class="cov1" title="1">{ line = len(d.lines) - 1 }</span> - <span class="cov1" title="1">col := pos.Character + <span class="cov3" title="2">col := pos.Character if col < 0 </span><span class="cov0" title="0">{ col = 0 }</span> - <span class="cov1" title="1">if col > len(d.lines[line]) </span><span class="cov0" title="0">{ + <span class="cov3" title="2">if col > len(d.lines[line]) </span><span class="cov1" title="1">{ col = len(d.lines[line]) }</span> // Build before - <span class="cov1" title="1">var b strings.Builder - for i := 0; i < line; i++ </span><span class="cov1" title="1">{ + <span class="cov3" title="2">var b strings.Builder + for i := 0; i < line; i++ </span><span class="cov3" title="2">{ b.WriteString(d.lines[i]) b.WriteByte('\n') }</span> - <span class="cov1" title="1">b.WriteString(d.lines[line][:col]) + <span class="cov3" title="2">b.WriteString(d.lines[line][:col]) before := b.String() // Build after var a strings.Builder @@ -3501,7 +3648,7 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span a.WriteByte('\n') a.WriteString(d.lines[i]) }</span> - <span class="cov1" title="1">return before, a.String()</span> + <span class="cov3" title="2">return before, a.String()</span> } // --- in-editor chat (";C ...") --- @@ -3509,61 +3656,68 @@ func (s *Server) docBeforeAfter(uri string, pos Position) (string, string) <span // detectAndHandleChat scans the current document for any line that starts with // a new trigger pair (e.g., "?>" ",>" ":>" ";>") at EOL and inserts the LLM // reply below. -func (s *Server) detectAndHandleChat(uri string) <span class="cov5" title="3">{ +func (s *Server) detectAndHandleChat(uri string) <span class="cov6" title="4">{ if s.llmClient == nil </span><span class="cov1" title="1">{ return }</span> - <span class="cov3" title="2">d := s.getDocument(uri) + <span class="cov5" title="3">d := s.getDocument(uri) if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ return }</span> - <span class="cov3" title="2">for i, raw := range d.lines </span><span class="cov6" title="4">{ + <span class="cov5" title="3">for i, raw := range d.lines </span><span class="cov7" title="5">{ // Find last non-space character index j := len(raw) - 1 - for j >= 0 </span><span class="cov5" title="3">{ + for j >= 0 </span><span class="cov6" title="4">{ if raw[j] == ' ' || raw[j] == '\t' </span><span class="cov0" title="0">{ j-- continue</span> } - <span class="cov5" title="3">break</span> + <span class="cov6" title="4">break</span> + } + <span class="cov7" title="5">if j < 0 </span><span class="cov1" title="1">{ + continue</span> + } + // Check suffix/prefix according to configuration + <span class="cov6" title="4">if s.chatSuffix == "" </span><span class="cov3" title="2">{ + continue</span> + } + // Last non-space must equal suffix + <span class="cov3" title="2">if string(raw[j]) != s.chatSuffix </span><span class="cov0" title="0">{ + continue</span> + } + // Require at least one char before suffix and that char must be in chatPrefixes + <span class="cov3" title="2">if j < 1 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov3" title="2">prev := string(raw[j-1]) + isTrigger := false + for _, pfx := range s.chatPrefixes </span><span class="cov3" title="2">{ + if prev == pfx </span><span class="cov3" title="2">{ + isTrigger = true + break</span> + } + } + <span class="cov3" title="2">if !isTrigger </span><span class="cov0" title="0">{ + continue</span> } - <span class="cov6" title="4">if j < 0 </span><span class="cov1" title="1">{ - continue</span> - } - // Check suffix/prefix according to configuration - <span class="cov5" title="3">if s.chatSuffix == "" </span><span class="cov3" title="2">{ - continue</span> - } - // Last non-space must equal suffix - <span class="cov1" title="1">if string(raw[j]) != s.chatSuffix </span><span class="cov0" title="0">{ - continue</span> - } - // Require at least one char before suffix and that char must be in chatPrefixes - <span class="cov1" title="1">if j < 1 </span><span class="cov0" title="0">{ continue</span> } - <span class="cov1" title="1">prev := string(raw[j-1]) - isTrigger := false - for _, pfx := range s.chatPrefixes </span><span class="cov1" title="1">{ - if prev == pfx </span><span class="cov1" title="1">{ isTrigger = true; break</span> } - } - <span class="cov1" title="1">if !isTrigger </span><span class="cov0" title="0">{ continue</span> } // Avoid double-answering: if the next non-empty line starts with '>' we skip. - <span class="cov1" title="1">k := i + 1 - for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov3" title="2">{ + <span class="cov3" title="2">k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov6" title="4">{ k++ }</span> - <span class="cov1" title="1">if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") </span><span class="cov0" title="0">{ + <span class="cov3" title="2">if k < len(d.lines) && strings.HasPrefix(strings.TrimSpace(d.lines[k]), ">") </span><span class="cov0" title="0">{ continue</span> } // Derive prompt by removing only the trailing '>' - <span class="cov1" title="1">removeCount := len(s.chatSuffix) + <span class="cov3" title="2">removeCount := len(s.chatSuffix) base := raw[:j+1-removeCount] - prompt := strings.TrimSpace(base) + prompt := strings.TrimSpace(base) if prompt == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov1" title="1">lineIdx := i + <span class="cov3" title="2">lineIdx := i lastIdx := j - go func(prompt string, remove int) </span><span class="cov1" title="1">{ + go func(prompt string, remove int) </span><span class="cov3" title="2">{ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() sys := "You are a helpful coding assistant. Answer concisely and clearly." @@ -3577,26 +3731,26 @@ func (s *Server) detectAndHandleChat(uri string) <span class="cov5" title="3">{ logging.Logf("lsp ", "chat llm error: %v", err) return }</span> - <span class="cov1" title="1">out := strings.TrimSpace(stripCodeFences(text)) + <span class="cov3" title="2">out := strings.TrimSpace(stripCodeFences(text)) if out == "" </span><span class="cov0" title="0">{ return }</span> - <span class="cov1" title="1">s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out)</span> + <span class="cov3" title="2">s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out)</span> }(prompt, removeCount) // Only handle one per change tick to avoid flooding - <span class="cov1" title="1">break</span> + <span class="cov3" title="2">break</span> } } // applyChatEdits removes the triggering punctuation at end of the line and // inserts two newlines followed by a new line with the response prefixed. -func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) <span class="cov1" title="1">{ +func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, removeCount int, response string) <span class="cov3" title="2">{ d := s.getDocument(uri) if d == nil </span><span class="cov0" title="0">{ return }</span> // 1) Delete the trailing punctuation (1 or 2 chars) - <span class="cov1" title="1">delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} + <span class="cov3" title="2">delStart := Position{Line: lineIdx, Character: lastNonSpace + 1 - removeCount} delEnd := Position{Line: lineIdx, Character: lastNonSpace + 1} // 2) Insert two newlines and the response at end-of-line, then one extra blank line insPos := Position{Line: lineIdx, Character: len(d.lines[lineIdx])} @@ -3612,12 +3766,12 @@ func (s *Server) applyChatEdits(uri string, lineIdx int, lastNonSpace int, remov // buildChatHistory walks upwards from the current line to collect the most recent // Q/A pairs in the in-editor transcript. Returns messages ending with current prompt. -func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message <span class="cov3" title="2">{ +func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) []llm.Message <span class="cov5" title="3">{ d := s.getDocument(uri) if d == nil </span><span class="cov0" title="0">{ return []llm.Message{{Role: "user", Content: currentPrompt}} }</span> - <span class="cov3" title="2">type pair struct{ q, a string } + <span class="cov5" title="3">type pair struct{ q, a string } pairs := []pair{} i := lineIdx - 1 for i >= 0 && len(pairs) < 3 </span><span class="cov3" title="2">{ @@ -3651,7 +3805,7 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) i--</span> } - <span class="cov3" title="2">msgs := make([]llm.Message, 0, len(pairs)*2+1) + <span class="cov5" title="3">msgs := make([]llm.Message, 0, len(pairs)*2+1) for _, p := range pairs </span><span class="cov3" title="2">{ if strings.TrimSpace(p.q) != "" </span><span class="cov3" title="2">{ msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) @@ -3660,51 +3814,47 @@ func (s *Server) buildChatHistory(uri string, lineIdx int, currentPrompt string) msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) }</span> } - <span class="cov3" title="2">msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) + <span class="cov5" title="3">msgs = append(msgs, llm.Message{Role: "user", Content: currentPrompt}) return msgs</span> } // stripTrailingTrigger removes the trailing chat trigger punctuation from a line if present. -func stripTrailingTrigger(sx string) string <span class="cov8" title="7">{ - s := strings.TrimRight(sx, " \t") - if len(s) == 0 </span><span class="cov0" title="0">{ - return sx - }</span> - // Configurable suffix removal when preceded by configured prefixes - <span class="cov8" title="7">if len(s) >= 2 && s[len(s)-1] == chatSuffixChar </span><span class="cov6" title="4">{ - prev := string(s[len(s)-2]) - for _, pf := range chatPrefixSingles </span><span class="cov10" title="10">{ - if prev == pf </span><span class="cov6" title="4">{ - return strings.TrimRight(s[:len(s)-1], " \t") - }</span> +func stripTrailingTrigger(sx string) string <span class="cov8" title="8">{ + s := strings.TrimRight(sx, " \t") + if len(s) == 0 </span><span class="cov0" title="0">{ + return sx + }</span> + // Configurable suffix removal when preceded by configured prefixes + <span class="cov8" title="8">if len(s) >= 2 && s[len(s)-1] == chatSuffixChar </span><span class="cov7" title="5">{ + prev := string(s[len(s)-2]) + for _, pf := range chatPrefixSingles </span><span class="cov10" title="11">{ + if prev == pf </span><span class="cov7" title="5">{ + return strings.TrimRight(s[:len(s)-1], " \t") + }</span> + } + } + // Legacy: remove one trailing punctuation (?, !, :) to build history nicely + <span class="cov5" title="3">last := s[len(s)-1] + switch last </span>{ + case '?', '!', ':':<span class="cov1" title="1"> + return strings.TrimRight(s[:len(s)-1], " \t")</span> + default:<span class="cov3" title="2"> + return sx</span> } - } - // Legacy: inline cleanup for old semicolon form ";;" - <span class="cov5" title="3">if strings.HasSuffix(s, ";;") </span><span class="cov0" title="0">{ - return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") - }</span> - // Legacy: remove one trailing punctuation (?, !, :) to build history nicely - <span class="cov5" title="3">last := s[len(s)-1] - switch last </span>{ - case '?', '!', ':':<span class="cov1" title="1"> - return strings.TrimRight(s[:len(s)-1], " \t")</span> - default:<span class="cov3" title="2"> - return sx</span> - } } // clientApplyEdit sends a workspace/applyEdit request to the client. -func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class="cov1" title="1">{ - params := ApplyWorkspaceEditParams{Label: label, Edit: edit} - id := s.nextReqID() - req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} - b, _ := json.Marshal(params) - req.Params = b - s.writeMessage(req) +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class="cov3" title="2">{ + params := ApplyWorkspaceEditParams{Label: label, Edit: edit} + id := s.nextReqID() + req := Request{JSONRPC: "2.0", ID: id, Method: "workspace/applyEdit"} + b, _ := json.Marshal(params) + req.Params = b + s.writeMessage(req) }</span> // nextReqID returns a unique json.RawMessage id for server-initiated requests. -func (s *Server) nextReqID() json.RawMessage <span class="cov6" title="4">{ +func (s *Server) nextReqID() json.RawMessage <span class="cov7" title="5">{ s.mu.Lock() s.nextID++ idNum := s.nextID @@ -3715,29 +3865,29 @@ func (s *Server) nextReqID() json.RawMessage <span class="cov6" title="4">{ // clientShowDocument asks the client to open/focus a document and select a range. func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov5" title="3">{ - var params struct { - URI string `json:"uri"` - External bool `json:"external,omitempty"` - TakeFocus bool `json:"takeFocus,omitempty"` - Selection *Range `json:"selection,omitempty"` - } - params.URI = uri - params.TakeFocus = true - params.Selection = sel - id := s.nextReqID() - req := Request{JSONRPC: "2.0", ID: id, Method: "window/showDocument"} - b, _ := json.Marshal(params) - req.Params = b - s.writeMessage(req) + var params struct { + URI string `json:"uri"` + External bool `json:"external,omitempty"` + TakeFocus bool `json:"takeFocus,omitempty"` + Selection *Range `json:"selection,omitempty"` + } + params.URI = uri + params.TakeFocus = true + params.Selection = sel + id := s.nextReqID() + req := Request{JSONRPC: "2.0", ID: id, Method: "window/showDocument"} + b, _ := json.Marshal(params) + req.Params = b + s.writeMessage(req) }</span> // deferShowDocument schedules a showDocument after a short delay to allow the client // time to apply any pending edits (e.g., create the file before focusing it). func (s *Server) deferShowDocument(uri string, sel Range) <span class="cov1" title="1">{ - go func() </span><span class="cov1" title="1">{ - time.Sleep(120 * time.Millisecond) - s.clientShowDocument(uri, &sel) - }</span>() + go func() </span><span class="cov1" title="1">{ + time.Sleep(120 * time.Millisecond) + s.clientShowDocument(uri, &sel) + }</span>() } </pre> @@ -3745,46 +3895,46 @@ func (s *Server) deferShowDocument(uri string, sel Range) <span class="cov1" tit package lsp import ( - "encoding/json" + "encoding/json" ) func (s *Server) handleExecuteCommand(req Request) <span class="cov8" title="1">{ - var p ExecuteCommandParams - if err := json.Unmarshal(req.Params, &p); err != nil </span><span class="cov0" title="0">{ - s.reply(req.ID, nil, nil) - return - }</span> - <span class="cov8" title="1">switch p.Command </span>{ - case "hexai.showDocument":<span class="cov8" title="1"> - if len(p.Arguments) >= 2 </span><span class="cov8" title="1">{ - uri, _ := p.Arguments[0].(string) - var r Range - // Convert second arg to Range via re-marshal to be robust across clients - if b, err := json.Marshal(p.Arguments[1]); err == nil </span><span class="cov8" title="1">{ - _ = json.Unmarshal(b, &r) - }</span> - <span class="cov8" title="1">if uri != "" </span><span class="cov8" title="1">{ - s.clientShowDocument(uri, &r) - }</span> + var p ExecuteCommandParams + if err := json.Unmarshal(req.Params, &p); err != nil </span><span class="cov0" title="0">{ + s.reply(req.ID, nil, nil) + return + }</span> + <span class="cov8" title="1">switch p.Command </span>{ + case "hexai.showDocument":<span class="cov8" title="1"> + if len(p.Arguments) >= 2 </span><span class="cov8" title="1">{ + uri, _ := p.Arguments[0].(string) + var r Range + // Convert second arg to Range via re-marshal to be robust across clients + if b, err := json.Marshal(p.Arguments[1]); err == nil </span><span class="cov8" title="1">{ + _ = json.Unmarshal(b, &r) + }</span> + <span class="cov8" title="1">if uri != "" </span><span class="cov8" title="1">{ + s.clientShowDocument(uri, &r) + }</span> + } + <span class="cov8" title="1">s.reply(req.ID, nil, nil) + return</span> + default:<span class="cov0" title="0"> + // Unknown command; no-op + s.reply(req.ID, nil, nil) + return</span> } - <span class="cov8" title="1">s.reply(req.ID, nil, nil) - return</span> - default:<span class="cov0" title="0"> - // Unknown command; no-op - s.reply(req.ID, nil, nil) - return</span> - } } - </pre> <pre class="file" id="file19" style="display: none">// Summary: Initialization and lifecycle handlers split from handlers.go. package lsp import ( + "os" + "codeberg.org/snonux/hexai/internal" "codeberg.org/snonux/hexai/internal/logging" - "os" ) func (s *Server) handleInitialize(req Request) <span class="cov10" title="2">{ @@ -3824,59 +3974,62 @@ func (s *Server) handleExit() <span class="cov0" title="0">{ package lsp import ( - "fmt" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "strings" - "time" + "fmt" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) // Configurable inline trigger characters (default to '>') used by free helpers below. // NewServer assigns these based on ServerOptions. -var inlineOpenChar byte = '>' -var inlineCloseChar byte = '>' +var ( + inlineOpenChar byte = '>' + inlineCloseChar byte = '>' +) // llmRequestOpts builds request options from server settings. -func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov5" title="11">{ +func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov5" title="12">{ opts := []llm.RequestOption{llm.WithMaxTokens(s.maxTokens)} if s.codingTemperature != nil </span><span class="cov0" title="0">{ opts = append(opts, llm.WithTemperature(*s.codingTemperature)) }</span> - <span class="cov5" title="11">return opts</span> + <span class="cov5" title="12">return opts</span> } // small helpers for LLM traffic stats -func (s *Server) incSentCounters(n int) <span class="cov4" title="7">{ +func (s *Server) incSentCounters(n int) <span class="cov5" title="8">{ s.mu.Lock() s.llmReqTotal++ s.llmSentBytesTotal += int64(n) s.mu.Unlock() }</span> -func (s *Server) incRecvCounters(n int) <span class="cov4" title="7">{ +func (s *Server) incRecvCounters(n int) <span class="cov5" title="8">{ s.mu.Lock() s.llmRespTotal++ s.llmRespBytesTotal += int64(n) s.mu.Unlock() }</span> -func (s *Server) logLLMStats() <span class="cov4" title="7">{ +func (s *Server) logLLMStats() <span class="cov5" title="8">{ s.mu.RLock() avgSent := int64(0) - if s.llmReqTotal > 0 </span><span class="cov4" title="7">{ + if s.llmReqTotal > 0 </span><span class="cov5" title="8">{ avgSent = s.llmSentBytesTotal / s.llmReqTotal }</span> - <span class="cov4" title="7">avgRecv := int64(0) - if s.llmRespTotal > 0 </span><span class="cov4" title="7">{ + <span class="cov5" title="8">avgRecv := int64(0) + if s.llmRespTotal > 0 </span><span class="cov5" title="8">{ avgRecv = s.llmRespBytesTotal / s.llmRespTotal }</span> - <span class="cov4" title="7">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal + <span class="cov5" title="8">reqs, sentTot, recvTot := s.llmReqTotal, s.llmSentBytesTotal, s.llmRespBytesTotal s.mu.RUnlock() mins := time.Since(s.startTime).Minutes() if mins <= 0 </span><span class="cov0" title="0">{ mins = 0.001 }</span> - <span class="cov4" title="7">rpm := float64(reqs) / mins + <span class="cov5" title="8">rpm := float64(reqs) / mins 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)</span> @@ -3903,7 +4056,7 @@ func buildPrompts(inParams bool, p CompletionParams, above, current, below, func return sys, user</span> } -func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov6" title="15">{ +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov6" title="16">{ if inParams </span><span class="cov3" title="3">{ open := strings.Index(current, "(") close := strings.Index(current, ")") @@ -3924,25 +4077,25 @@ func computeTextEditAndFilter(cleaned string, inParams bool, current string, p C <span class="cov3" title="3">return te, filter</span> } } - <span class="cov5" title="12">startChar := computeWordStart(current, p.Position.Character) + <span class="cov6" title="13">startChar := computeWordStart(current, p.Position.Character) te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: startChar}, End: Position{Line: p.Position.Line, Character: p.Position.Character}}, NewText: cleaned} filter := strings.TrimLeft(current[startChar:p.Position.Character], " \t") return te, filter</span> } -func computeWordStart(current string, at int) int <span class="cov7" title="21">{ +func computeWordStart(current string, at int) int <span class="cov7" title="22">{ if at > len(current) </span><span class="cov0" title="0">{ at = len(current) }</span> - <span class="cov7" title="21">for at > 0 </span><span class="cov8" title="37">{ + <span class="cov7" title="22">for at > 0 </span><span class="cov8" title="38">{ ch := current[at-1] if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov6" title="20">{ at-- continue</span> } - <span class="cov6" title="17">break</span> + <span class="cov6" title="18">break</span> } - <span class="cov7" title="21">return at</span> + <span class="cov7" title="22">return at</span> } func isIdentChar(ch byte) bool <span class="cov7" title="24">{ @@ -3950,109 +4103,109 @@ func isIdentChar(ch byte) bool <span class="cov7" title="24">{ }</span> // Inline prompt utilities -func lineHasInlinePrompt(line string) bool <span class="cov6" title="18">{ - if _, _, _, ok := findStrictSemicolonTag(line); ok </span><span class="cov1" title="1">{ - return true - }</span> - <span class="cov6" title="17">return hasDoubleSemicolonTrigger(line)</span> +func lineHasInlinePrompt(line string) bool <span class="cov6" title="20">{ + if _, _, _, ok := findStrictInlineTag(line); ok </span><span class="cov3" title="3">{ + return true + }</span> + <span class="cov6" title="17">return hasDoubleOpenTrigger(line)</span> } -func leadingIndent(line string) string <span class="cov2" title="2">{ +func leadingIndent(line string) string <span class="cov3" title="3">{ i := 0 - for i < len(line) </span><span class="cov4" title="7">{ - if line[i] == ' ' || line[i] == '\t' </span><span class="cov4" title="5">{ + for i < len(line) </span><span class="cov5" title="10">{ + if line[i] == ' ' || line[i] == '\t' </span><span class="cov4" title="7">{ i++ continue</span> } - <span class="cov2" title="2">break</span> + <span class="cov3" title="3">break</span> } - <span class="cov2" title="2">if i == 0 </span><span class="cov0" title="0">{ + <span class="cov3" title="3">if i == 0 </span><span class="cov0" title="0">{ return "" }</span> - <span class="cov2" title="2">return line[:i]</span> + <span class="cov3" title="3">return line[:i]</span> } -func applyIndent(indent, suggestion string) string <span class="cov2" title="2">{ +func applyIndent(indent, suggestion string) string <span class="cov3" title="3">{ if indent == "" || suggestion == "" </span><span class="cov0" title="0">{ return suggestion }</span> - <span class="cov2" title="2">lines := splitLines(suggestion) - for i, ln := range lines </span><span class="cov4" title="6">{ + <span class="cov3" title="3">lines := splitLines(suggestion) + for i, ln := range lines </span><span class="cov5" title="8">{ if strings.TrimSpace(ln) == "" </span><span class="cov1" title="1">{ continue</span> } - <span class="cov4" title="5">if strings.HasPrefix(ln, indent) </span><span class="cov0" title="0">{ + <span class="cov4" title="7">if strings.HasPrefix(ln, indent) </span><span class="cov0" title="0">{ continue</span> } - <span class="cov4" title="5">lines[i] = indent + ln</span> + <span class="cov4" title="7">lines[i] = indent + ln</span> } - <span class="cov2" title="2">return strings.Join(lines, "\n")</span> + <span class="cov3" title="3">return strings.Join(lines, "\n")</span> } // --- Inline marker parsing and general string utilities --- -// findStrictSemicolonTag now finds >text> (configurable), with no space after the first +// findStrictInlineTag finds >text> (configurable), with no space after the first // opening marker and no space immediately before the closing marker. Returns the // text between markers, the start index, the end index just after closing, and ok. -func findStrictSemicolonTag(line string) (string, int, int, bool) <span class="cov8" title="46">{ - pos := 0 - for pos < len(line) </span><span class="cov9" title="58">{ - // find opening marker - j := strings.IndexByte(line[pos:], inlineOpenChar) - if j < 0 </span><span class="cov7" title="27">{ - return "", 0, 0, false - }</span> - <span class="cov7" title="31">j += pos - // ensure single open (not double) and non-space after - if j+1 >= len(line) || line[j+1] == inlineOpenChar || line[j+1] == ' ' </span><span class="cov6" title="18">{ - pos = j + 1 - continue</span> - } - // find closing marker - <span class="cov6" title="13">k := strings.IndexByte(line[j+1:], inlineCloseChar) - if k < 0 </span><span class="cov1" title="1">{ - return "", 0, 0, false - }</span> - <span class="cov5" title="12">closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ - pos = closeIdx + 1 - continue</span> - } - <span class="cov5" title="11">inner := strings.TrimSpace(line[j+1 : closeIdx]) - if inner == "" </span><span class="cov0" title="0">{ - pos = closeIdx + 1 - continue</span> +func findStrictInlineTag(line string) (string, int, int, bool) <span class="cov8" title="49">{ + pos := 0 + for pos < len(line) </span><span class="cov9" title="64">{ + // find opening marker + j := strings.IndexByte(line[pos:], inlineOpenChar) + if j < 0 </span><span class="cov7" title="27">{ + return "", 0, 0, false + }</span> + <span class="cov8" title="37">j += pos + // ensure single open (not double) and non-space after + if j+1 >= len(line) || line[j+1] == inlineOpenChar || line[j+1] == ' ' </span><span class="cov6" title="21">{ + pos = j + 1 + continue</span> + } + // find closing marker + <span class="cov6" title="16">k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 </span><span class="cov1" title="1">{ + return "", 0, 0, false + }</span> + <span class="cov6" title="15">closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov6" title="14">inner := strings.TrimSpace(line[j+1 : closeIdx]) + if inner == "" </span><span class="cov0" title="0">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov6" title="14">end := closeIdx + 1 + return inner, j, end, true</span> } - <span class="cov5" title="11">end := closeIdx + 1 - return inner, j, end, true</span> - } - <span class="cov4" title="7">return "", 0, 0, false</span> + <span class="cov4" title="7">return "", 0, 0, false</span> } // isBareDoubleSemicolon reports whether the line contains a standalone // double-semicolon marker with no inline content (";;" possibly with only // whitespace after it). It explicitly excludes the valid form ";;text;". -func isBareDoubleSemicolon(line string) bool <span class="cov6" title="19">{ - t := strings.TrimSpace(line) - // check for double-open pattern - dbl := string([]byte{inlineOpenChar, inlineOpenChar}) - if !strings.Contains(t, dbl) </span><span class="cov6" title="16">{ - return false - }</span> - <span class="cov3" title="3">if hasDoubleSemicolonTrigger(t) </span><span class="cov1" title="1">{ - return false - }</span> - <span class="cov2" title="2">if strings.HasPrefix(t, dbl) </span><span class="cov2" title="2">{ - rest := strings.TrimSpace(t[len(dbl):]) - if rest == "" || rest == ";" </span><span class="cov2" title="2">{ - return true - }</span> - } - <span class="cov0" title="0">return false</span> +func isBareDoubleOpen(line string) bool <span class="cov6" title="19">{ + t := strings.TrimSpace(line) + // check for double-open pattern + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + if !strings.Contains(t, dbl) </span><span class="cov6" title="16">{ + return false + }</span> + <span class="cov3" title="3">if hasDoubleOpenTrigger(t) </span><span class="cov1" title="1">{ + return false + }</span> + <span class="cov2" title="2">if strings.HasPrefix(t, dbl) </span><span class="cov2" title="2">{ + rest := strings.TrimSpace(t[len(dbl):]) + if rest == "" || rest == ";" </span><span class="cov2" title="2">{ + return true + }</span> + } + <span class="cov0" title="0">return false</span> } // stripDuplicateAssignmentPrefix removes a duplicated assignment prefix from the suggestion. -func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="17">{ +func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="18">{ s2 := strings.TrimLeft(suggestion, " \t") // Prefer := if present at end of prefix if idx := strings.LastIndex(prefixBeforeCursor, ":="); idx >= 0 && idx+2 <= len(prefixBeforeCursor) </span><span class="cov3" title="4">{ @@ -4070,7 +4223,7 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } // Fallback to plain '=' if present - <span class="cov6" title="13">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov2" title="2">{ + <span class="cov6" title="14">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov2" title="2">{ if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') </span><span class="cov2" title="2">{ // not := tail := prefixBeforeCursor[idx+1:] if strings.TrimSpace(tail) == "" </span><span class="cov2" title="2">{ @@ -4086,63 +4239,63 @@ func stripDuplicateAssignmentPrefix(prefixBeforeCursor, suggestion string) strin } } } - <span class="cov5" title="11">return suggestion</span> + <span class="cov5" title="12">return suggestion</span> } // stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. -func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="17">{ +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov6" title="18">{ if suggestion == "" </span><span class="cov0" title="0">{ return suggestion }</span> - <span class="cov6" title="17">s := strings.TrimLeft(suggestion, " \t") + <span class="cov6" title="18">s := strings.TrimLeft(suggestion, " \t") p := strings.TrimRight(prefixBeforeCursor, " \t") if p != "" && strings.HasPrefix(s, p) </span><span class="cov4" title="5">{ return strings.TrimLeft(s[len(p):], " \t") }</span> - <span class="cov5" title="12">for k := len(p) - 1; k > 0; k-- </span><span class="cov10" title="94">{ - if !isIdentBoundary(p[k-1]) </span><span class="cov9" title="75">{ + <span class="cov6" title="13">for k := len(p) - 1; k > 0; k-- </span><span class="cov10" title="100">{ + if !isIdentBoundary(p[k-1]) </span><span class="cov9" title="77">{ continue</span> } - <span class="cov6" title="19">suf := strings.TrimLeft(p[k:], " \t") + <span class="cov7" title="23">suf := strings.TrimLeft(p[k:], " \t") if suf == "" </span><span class="cov0" title="0">{ continue</span> } - <span class="cov6" title="19">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ + <span class="cov7" title="23">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ return strings.TrimLeft(s[len(suf):], " \t") }</span> } - <span class="cov5" title="12">return suggestion</span> + <span class="cov6" title="13">return suggestion</span> } -func isIdentBoundary(ch byte) bool <span class="cov10" title="94">{ +func isIdentBoundary(ch byte) bool <span class="cov10" title="100">{ return !((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_') }</span> // stripCodeFences removes surrounding Markdown code fences from a model response. -func stripCodeFences(s string) string <span class="cov7" title="30">{ +func stripCodeFences(s string) string <span class="cov7" title="31">{ t := strings.TrimSpace(s) if t == "" </span><span class="cov0" title="0">{ return t }</span> - <span class="cov7" title="30">lines := splitLines(t) + <span class="cov7" title="31">lines := splitLines(t) start := 0 for start < len(lines) && strings.TrimSpace(lines[start]) == "" </span><span class="cov0" title="0">{ start++ }</span> - <span class="cov7" title="30">end := len(lines) - 1 + <span class="cov7" title="31">end := len(lines) - 1 for end >= 0 && strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{ end-- }</span> - <span class="cov7" title="30">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ + <span class="cov7" title="31">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ return t }</span> - <span class="cov7" title="30">first := strings.TrimSpace(lines[start]) + <span class="cov7" title="31">first := strings.TrimSpace(lines[start]) last := strings.TrimSpace(lines[end]) if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov5" title="8">{ inner := strings.Join(lines[start+1:end], "\n") return inner }</span> - <span class="cov7" title="22">return t</span> + <span class="cov7" title="23">return t</span> } // stripInlineCodeSpan returns the contents of the first inline backtick code span if present. @@ -4164,25 +4317,25 @@ func stripInlineCodeSpan(s string) string <span class="cov5" title="10">{ } // labelForCompletion picks a short, readable label for the completion list. -func labelForCompletion(cleaned, filter string) string <span class="cov6" title="16">{ +func labelForCompletion(cleaned, filter string) string <span class="cov6" title="17">{ label := trimLen(firstLine(cleaned)) if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) </span><span class="cov3" title="3">{ return filter }</span> - <span class="cov6" title="13">return label</span> + <span class="cov6" title="14">return label</span> } // extractRangeText returns the exact text within the given document range. -func extractRangeText(d *document, r Range) string <span class="cov3" title="3">{ - if r.Start.Line == r.End.Line </span><span class="cov2" title="2">{ +func extractRangeText(d *document, r Range) string <span class="cov3" title="4">{ + if r.Start.Line == r.End.Line </span><span class="cov3" title="3">{ line := d.lines[r.Start.Line] if r.Start.Character < 0 </span><span class="cov0" title="0">{ r.Start.Character = 0 }</span> - <span class="cov2" title="2">if r.End.Character > len(line) </span><span class="cov0" title="0">{ + <span class="cov3" title="3">if r.End.Character > len(line) </span><span class="cov0" title="0">{ r.End.Character = len(line) }</span> - <span class="cov2" title="2">if r.Start.Character > r.End.Character </span><span class="cov0" title="0">{ + <span class="cov3" title="3">if r.Start.Character > r.End.Character </span><span class="cov1" title="1">{ return "" }</span> <span class="cov2" title="2">return line[r.Start.Character:r.End.Character]</span> @@ -4218,9 +4371,9 @@ func extractRangeText(d *document, r Range) string <span class="cov3" title="3"> } // collectPromptRemovalEdits returns edits to remove all inline prompt markers. -func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov5" title="11">{ +func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov5" title="12">{ d := s.getDocument(uri) - if d == nil || len(d.lines) == 0 </span><span class="cov5" title="10">{ + if d == nil || len(d.lines) == 0 </span><span class="cov5" title="11">{ return nil }</span> <span class="cov1" title="1">var edits []TextEdit @@ -4231,84 +4384,84 @@ func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="c } func promptRemovalEditsForLine(line string, lineNum int) []TextEdit <span class="cov4" title="7">{ - if hasDoubleSemicolonTrigger(line) </span><span class="cov3" title="3">{ - return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} - }</span> - <span class="cov3" title="4">return collectSemicolonMarkers(line, lineNum)</span> + if hasDoubleOpenTrigger(line) </span><span class="cov3" title="3">{ + return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} + }</span> + <span class="cov3" title="4">return collectSemicolonMarkers(line, lineNum)</span> } -func hasDoubleSemicolonTrigger(line string) bool <span class="cov8" title="49">{ - pos := 0 - for pos < len(line) </span><span class="cov8" title="50">{ - // look for double-open sequence - dbl := string([]byte{inlineOpenChar, inlineOpenChar}) - j := strings.Index(line[pos:], dbl) - if j < 0 </span><span class="cov7" title="32">{ - return false - }</span> - <span class="cov6" title="18">j += pos - contentStart := j + len(dbl) - if contentStart >= len(line) </span><span class="cov4" title="6">{ - return false - }</span> - <span class="cov5" title="12">first := line[contentStart] - if first == ' ' || first == inlineOpenChar </span><span class="cov3" title="3">{ - pos = contentStart + 1 - continue</span> - } - // find closing - <span class="cov5" title="9">k := strings.IndexByte(line[contentStart+1:], inlineCloseChar) - if k < 0 </span><span class="cov0" title="0">{ - return false - }</span> - <span class="cov5" title="9">closeIdx := contentStart + 1 + k - if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ - pos = closeIdx + 1 - continue</span> +func hasDoubleOpenTrigger(line string) bool <span class="cov8" title="51">{ + pos := 0 + for pos < len(line) </span><span class="cov8" title="53">{ + // look for double-open sequence + dbl := string([]byte{inlineOpenChar, inlineOpenChar}) + j := strings.Index(line[pos:], dbl) + if j < 0 </span><span class="cov7" title="32">{ + return false + }</span> + <span class="cov6" title="21">j += pos + contentStart := j + len(dbl) + if contentStart >= len(line) </span><span class="cov4" title="7">{ + return false + }</span> + <span class="cov6" title="14">first := line[contentStart] + if first == ' ' || first == inlineOpenChar </span><span class="cov3" title="4">{ + pos = contentStart + 1 + continue</span> + } + // find closing + <span class="cov5" title="10">k := strings.IndexByte(line[contentStart+1:], inlineCloseChar) + if k < 0 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov5" title="10">closeIdx := contentStart + 1 + k + if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov5" title="9">return true</span> } - <span class="cov5" title="8">return true</span> - } - <span class="cov3" title="3">return false</span> + <span class="cov3" title="3">return false</span> } func collectSemicolonMarkers(line string, lineNum int) []TextEdit <span class="cov4" title="5">{ - var edits []TextEdit - startSemi := 0 - for startSemi < len(line) </span><span class="cov5" title="9">{ - j := strings.IndexByte(line[startSemi:], inlineOpenChar) - if j < 0 </span><span class="cov3" title="4">{ - break</span> - } - <span class="cov4" title="5">j += startSemi - k := strings.IndexByte(line[j+1:], inlineCloseChar) - if k < 0 </span><span class="cov0" title="0">{ - break</span> - } - <span class="cov4" title="5">if j+1 >= len(line) || line[j+1] == ' ' </span><span class="cov0" title="0">{ - startSemi = j + 1 - continue</span> - } - <span class="cov4" title="5">if line[j+1] == inlineOpenChar </span><span class="cov0" title="0">{ // skip double-open start - startSemi = j + 2 - continue</span> - } - <span class="cov4" title="5">closeIdx := j + 1 + k - if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ - startSemi = closeIdx + 1 - continue</span> - } - <span class="cov4" title="5">if closeIdx-(j+1) < 1 </span><span class="cov0" title="0">{ - startSemi = closeIdx + 1 - continue</span> + var edits []TextEdit + startSemi := 0 + for startSemi < len(line) </span><span class="cov5" title="9">{ + j := strings.IndexByte(line[startSemi:], inlineOpenChar) + if j < 0 </span><span class="cov3" title="4">{ + break</span> + } + <span class="cov4" title="5">j += startSemi + k := strings.IndexByte(line[j+1:], inlineCloseChar) + if k < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov4" title="5">if j+1 >= len(line) || line[j+1] == ' ' </span><span class="cov0" title="0">{ + startSemi = j + 1 + continue</span> + } + <span class="cov4" title="5">if line[j+1] == inlineOpenChar </span><span class="cov0" title="0">{ // skip double-open start + startSemi = j + 2 + continue</span> + } + <span class="cov4" title="5">closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ + startSemi = closeIdx + 1 + continue</span> + } + <span class="cov4" title="5">if closeIdx-(j+1) < 1 </span><span class="cov0" title="0">{ + startSemi = closeIdx + 1 + continue</span> + } + <span class="cov4" title="5">endChar := closeIdx + 1 + if endChar < len(line) && line[endChar] == ' ' </span><span class="cov3" title="4">{ + endChar++ + }</span> + <span class="cov4" title="5">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) + startSemi = endChar</span> } - <span class="cov4" title="5">endChar := closeIdx + 1 - if endChar < len(line) && line[endChar] == ' ' </span><span class="cov3" title="4">{ - endChar++ - }</span> - <span class="cov4" title="5">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) - startSemi = endChar</span> - } - <span class="cov4" title="5">return edits</span> + <span class="cov4" title="5">return edits</span> } </pre> @@ -4316,15 +4469,16 @@ func collectSemicolonMarkers(line string, lineNum int) []TextEdit <span class="c package lsp import ( - "bufio" - "encoding/json" - "codeberg.org/snonux/hexai/internal/llm" - "codeberg.org/snonux/hexai/internal/logging" - "io" - "log" - "strings" - "sync" - "time" + "bufio" + "encoding/json" + "io" + "log" + "strings" + "sync" + "time" + + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" ) // Server implements a minimal LSP over stdio. @@ -4341,8 +4495,8 @@ type Server struct { maxTokens int contextMode string windowLines int - maxContextTokens int - triggerChars []string + maxContextTokens int + triggerChars []string // If set, used as the LSP coding temperature for all LLM calls codingTemperature *float64 // LLM request stats @@ -4354,114 +4508,138 @@ type Server struct { // Small LRU cache for recent code completion outputs (keyed by context) compCache map[string]string compCacheOrder []string // most-recent at end; cap ~10 - // Outgoing JSON-RPC id counter for server-initiated requests - nextID int64 + // Outgoing JSON-RPC id counter for server-initiated requests + nextID int64 // Minimum identifier chars required for manual invoke to bypass prefix checks manualInvokeMinPrefix int - // Debounce and throttle settings - completionDebounce time.Duration - throttleInterval time.Duration - lastLLMCall time.Time + // Debounce and throttle settings + completionDebounce time.Duration + throttleInterval time.Duration + lastLLMCall time.Time - // Dispatch table for JSON-RPC methods → handler functions - handlers map[string]func(Request) + // Dispatch table for JSON-RPC methods → handler functions + handlers map[string]func(Request) - // Configurable trigger characters - inlineOpen string - inlineClose string - chatSuffix string - chatPrefixes []string + // Configurable trigger characters + inlineOpen string + inlineClose string + chatSuffix string + chatPrefixes []string } // ServerOptions collects configuration for NewServer to avoid long parameter lists. type ServerOptions struct { - LogContext bool - MaxTokens int - ContextMode string - WindowLines int - MaxContextTokens int - - Client llm.Client - TriggerCharacters []string - CodingTemperature *float64 - ManualInvokeMinPrefix int - CompletionDebounceMs int - CompletionThrottleMs int - - // Inline/chat triggers - InlineOpen string - InlineClose string - ChatSuffix string - ChatPrefixes []string -} - -func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov10" title="4">{ + LogContext bool + MaxTokens int + ContextMode string + WindowLines int + MaxContextTokens int + + Client llm.Client + TriggerCharacters []string + CodingTemperature *float64 + ManualInvokeMinPrefix int + CompletionDebounceMs int + CompletionThrottleMs int + + // Inline/chat triggers + InlineOpen string + InlineClose string + ChatSuffix string + ChatPrefixes []string +} + +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov10" title="6">{ s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext} maxTokens := opts.MaxTokens - if maxTokens <= 0 </span><span class="cov8" title="3">{ + if maxTokens <= 0 </span><span class="cov9" title="5">{ maxTokens = 500 }</span> - <span class="cov10" title="4">s.maxTokens = maxTokens + <span class="cov10" title="6">s.maxTokens = maxTokens contextMode := opts.ContextMode - if contextMode == "" </span><span class="cov8" title="3">{ + if contextMode == "" </span><span class="cov9" title="5">{ contextMode = "file-on-new-func" }</span> - <span class="cov10" title="4">windowLines := opts.WindowLines - if windowLines <= 0 </span><span class="cov8" title="3">{ + <span class="cov10" title="6">windowLines := opts.WindowLines + if windowLines <= 0 </span><span class="cov9" title="5">{ windowLines = 120 }</span> - <span class="cov10" title="4">maxContextTokens := opts.MaxContextTokens - if maxContextTokens <= 0 </span><span class="cov8" title="3">{ + <span class="cov10" title="6">maxContextTokens := opts.MaxContextTokens + if maxContextTokens <= 0 </span><span class="cov9" title="5">{ maxContextTokens = 2000 }</span> - <span class="cov10" title="4">s.contextMode = contextMode + <span class="cov10" title="6">s.contextMode = contextMode s.windowLines = windowLines s.maxContextTokens = maxContextTokens s.startTime = time.Now() s.llmClient = opts.Client - if len(opts.TriggerCharacters) == 0 </span><span class="cov10" title="4">{ + if len(opts.TriggerCharacters) == 0 </span><span class="cov10" title="6">{ // Defaults (no space to avoid auto-trigger after whitespace) s.triggerChars = []string{".", ":", "/", "_", ")", "{"} }</span> else<span class="cov0" title="0"> { s.triggerChars = append([]string{}, opts.TriggerCharacters...) }</span> - <span class="cov10" title="4">s.codingTemperature = opts.CodingTemperature - s.compCache = make(map[string]string) - s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix - if opts.CompletionDebounceMs > 0 </span><span class="cov1" title="1">{ - s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond - }</span> - <span class="cov10" title="4">if opts.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ - s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond - }</span> - // Trigger character config (with sane defaults if missing) - <span class="cov10" title="4">if strings.TrimSpace(opts.InlineOpen) == "" </span><span class="cov8" title="3">{ s.inlineOpen = ">" }</span> else<span class="cov1" title="1"> { s.inlineOpen = opts.InlineOpen }</span> - <span class="cov10" title="4">if strings.TrimSpace(opts.InlineClose) == "" </span><span class="cov8" title="3">{ s.inlineClose = ">" }</span> else<span class="cov1" title="1"> { s.inlineClose = opts.InlineClose }</span> - <span class="cov10" title="4">if strings.TrimSpace(opts.ChatSuffix) == "" </span><span class="cov8" title="3">{ s.chatSuffix = ">" }</span> else<span class="cov1" title="1"> { s.chatSuffix = opts.ChatSuffix }</span> - <span class="cov10" title="4">if len(opts.ChatPrefixes) == 0 </span><span class="cov8" title="3">{ s.chatPrefixes = []string{"?","!",":",";"} }</span> else<span class="cov1" title="1"> { s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) }</span> - - // Assign package-level inline trigger chars for free helper functions - <span class="cov10" title="4">if s.inlineOpen != "" </span><span class="cov10" title="4">{ inlineOpenChar = s.inlineOpen[0] }</span> - <span class="cov10" title="4">if s.inlineClose != "" </span><span class="cov10" title="4">{ inlineCloseChar = s.inlineClose[0] }</span> - <span class="cov10" title="4">if s.chatSuffix != "" </span><span class="cov10" title="4">{ chatSuffixChar = s.chatSuffix[0] }</span> - <span class="cov10" title="4">if len(s.chatPrefixes) > 0 </span><span class="cov10" title="4">{ chatPrefixSingles = append([]string{}, s.chatPrefixes...) }</span> + <span class="cov10" title="6">s.codingTemperature = opts.CodingTemperature + s.compCache = make(map[string]string) + s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix + if opts.CompletionDebounceMs > 0 </span><span class="cov1" title="1">{ + s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond + }</span> + <span class="cov10" title="6">if opts.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ + s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond + }</span> + // Trigger character config (with sane defaults if missing) + <span class="cov10" title="6">if strings.TrimSpace(opts.InlineOpen) == "" </span><span class="cov7" title="4">{ + s.inlineOpen = ">" + }</span> else<span class="cov4" title="2"> { + s.inlineOpen = opts.InlineOpen + }</span> + <span class="cov10" title="6">if strings.TrimSpace(opts.InlineClose) == "" </span><span class="cov7" title="4">{ + s.inlineClose = ">" + }</span> else<span class="cov4" title="2"> { + s.inlineClose = opts.InlineClose + }</span> + <span class="cov10" title="6">if strings.TrimSpace(opts.ChatSuffix) == "" </span><span class="cov6" title="3">{ + s.chatSuffix = ">" + }</span> else<span class="cov6" title="3"> { + s.chatSuffix = opts.ChatSuffix + }</span> + <span class="cov10" title="6">if len(opts.ChatPrefixes) == 0 </span><span class="cov6" title="3">{ + s.chatPrefixes = []string{"?", "!", ":", ";"} + }</span> else<span class="cov6" title="3"> { + s.chatPrefixes = append([]string{}, opts.ChatPrefixes...) + }</span> + + // Assign package-level inline trigger chars for free helper functions + <span class="cov10" title="6">if s.inlineOpen != "" </span><span class="cov10" title="6">{ + inlineOpenChar = s.inlineOpen[0] + }</span> + <span class="cov10" title="6">if s.inlineClose != "" </span><span class="cov10" title="6">{ + inlineCloseChar = s.inlineClose[0] + }</span> + <span class="cov10" title="6">if s.chatSuffix != "" </span><span class="cov10" title="6">{ + chatSuffixChar = s.chatSuffix[0] + }</span> + <span class="cov10" title="6">if len(s.chatPrefixes) > 0 </span><span class="cov10" title="6">{ + chatPrefixSingles = append([]string{}, s.chatPrefixes...) + }</span> // Initialize dispatch table - <span class="cov10" title="4">s.handlers = map[string]func(Request){ - "initialize": s.handleInitialize, - "initialized": func(_ Request) </span><span class="cov0" title="0">{ s.handleInitialized() }</span>, - "shutdown": s.handleShutdown, - "exit": func(_ Request) <span class="cov0" title="0">{ s.handleExit() }</span>, - "textDocument/didOpen": s.handleDidOpen, - "textDocument/didChange": s.handleDidChange, - "textDocument/didClose": s.handleDidClose, - "textDocument/completion": s.handleCompletion, - "textDocument/codeAction": s.handleCodeAction, - "codeAction/resolve": s.handleCodeActionResolve, + <span class="cov10" title="6">s.handlers = map[string]func(Request){ + "initialize": s.handleInitialize, + "initialized": func(_ Request) </span><span class="cov0" title="0">{ s.handleInitialized() }</span>, + "shutdown": s.handleShutdown, + "exit": func(_ Request) <span class="cov0" title="0">{ s.handleExit() }</span>, + "textDocument/didOpen": s.handleDidOpen, + "textDocument/didChange": s.handleDidChange, + "textDocument/didClose": s.handleDidClose, + "textDocument/completion": s.handleCompletion, + "textDocument/codeAction": s.handleCodeAction, + "codeAction/resolve": s.handleCodeActionResolve, "workspace/executeCommand": s.handleExecuteCommand, } - <span class="cov10" title="4">return s</span> + <span class="cov10" title="6">return s</span> } func (s *Server) Run() error <span class="cov1" title="1">{ @@ -4496,11 +4674,12 @@ package lsp import ( "encoding/json" "fmt" - "codeberg.org/snonux/hexai/internal/logging" "io" "net/textproto" "strconv" "strings" + + "codeberg.org/snonux/hexai/internal/logging" ) func (s *Server) readMessage() ([]byte, error) <span class="cov3" title="2">{ @@ -4539,18 +4718,18 @@ func (s *Server) readMessage() ([]byte, error) <span class="cov3" title="2">{ <span class="cov1" title="1">return buf, nil</span> } -func (s *Server) writeMessage(v any) <span class="cov10" title="15">{ +func (s *Server) writeMessage(v any) <span class="cov10" title="17">{ data, err := json.Marshal(v) if err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "marshal error: %v", err) return }</span> - <span class="cov10" title="15">header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + <span class="cov10" title="17">header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) if _, err := io.WriteString(s.out, header); err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "write header error: %v", err) return }</span> - <span class="cov10" title="15">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{ + <span class="cov10" title="17">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{ logging.Logf("lsp ", "write body error: %v", err) return }</span> @@ -4561,29 +4740,28 @@ func (s *Server) writeMessage(v any) <span class="cov10" title="15">{ // MultilineDocBlock returns a realistic multi-line documentation block. func MultilineDocBlock() string <span class="cov8" title="1">{ - return "// add adds two numbers\n// returns their sum" + return "// add adds two numbers\n// returns their sum" }</span> // MultilineChatReply returns a multi-line assistant reply for chat tests. func MultilineChatReply() string <span class="cov8" title="1">{ - return "Hello, world!\nThis is a multi-line reply." + return "Hello, world!\nThis is a multi-line reply." }</span> // MultilineFunctionSuggestion returns a more realistic multi-line function body suggestion. func MultilineFunctionSuggestion() string <span class="cov8" title="1">{ - return "(ctx context.Context, input string) (*CustData, error) {\n // TODO: implement\n return &CustData{}, nil\n}" + return "(ctx context.Context, input string) (*CustData, error) {\n // TODO: implement\n return &CustData{}, nil\n}" }</span> // MarkdownCodeFence returns a fenced markdown snippet used in post-processing tests. func MarkdownCodeFence() string <span class="cov0" title="0">{ - return "```go\nname := value\n```" + return "```go\nname := value\n```" }</span> // MalformedJSON returns a deliberately malformed JSON string. func MalformedJSON() string <span class="cov0" title="0">{ - return "{\"choices\":[{\"delta\":{\"content\":\"oops\"}}]" + return "{\"choices\":[{\"delta\":{\"content\":\"oops\"}}]" }</span> - </pre> </div> |
