diff options
| author | Paul Buetow <paul@buetow.org> | 2025-09-03 17:00:06 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-09-03 17:00:06 +0300 |
| commit | 4a2d1c60e6ac518d69bbb490db7f28ca791e49b1 (patch) | |
| tree | a4ce5a53924a1759293ed24a1b92899ee5d65021 /docs/coverage.html | |
| parent | 9ce0a4661d6b5dff060b34d3c566f97b76a52996 (diff) | |
add coverage files
Diffstat (limited to 'docs/coverage.html')
| -rw-r--r-- | docs/coverage.html | 4484 |
1 files changed, 4484 insertions, 0 deletions
diff --git a/docs/coverage.html b/docs/coverage.html new file mode 100644 index 0000000..ac804e2 --- /dev/null +++ b/docs/coverage.html @@ -0,0 +1,4484 @@ + +<!DOCTYPE html> +<html> + <head> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> + <title>hexai-lsp: Go Coverage Report</title> + <style> + body { + background: black; + color: rgb(80, 80, 80); + } + body, pre, #legend span { + font-family: Menlo, monospace; + font-weight: bold; + } + #topbar { + background: black; + position: fixed; + top: 0; left: 0; right: 0; + height: 42px; + border-bottom: 1px solid rgb(80, 80, 80); + } + #content { + margin-top: 50px; + } + #nav, #legend { + float: left; + margin-left: 10px; + } + #legend { + margin-top: 12px; + } + #nav { + margin-top: 10px; + } + #legend span { + margin: 0 5px; + } + .cov0 { color: rgb(192, 0, 0) } +.cov1 { color: rgb(128, 128, 128) } +.cov2 { color: rgb(116, 140, 131) } +.cov3 { color: rgb(104, 152, 134) } +.cov4 { color: rgb(92, 164, 137) } +.cov5 { color: rgb(80, 176, 140) } +.cov6 { color: rgb(68, 188, 143) } +.cov7 { color: rgb(56, 200, 146) } +.cov8 { color: rgb(44, 212, 149) } +.cov9 { color: rgb(32, 224, 152) } +.cov10 { color: rgb(20, 236, 155) } + + </style> + </head> + <body> + <div id="topbar"> + <div id="nav"> + <select id="files"> + + <option value="file0">codeberg.org/snonux/hexai/cmd/hexai-lsp/main.go (0.0%)</option> + + <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 (32.3%)</option> + + <option value="file3">codeberg.org/snonux/hexai/internal/hexaicli/run.go (63.8%)</option> + + <option value="file4">codeberg.org/snonux/hexai/internal/hexailsp/run.go (92.5%)</option> + + <option value="file5">codeberg.org/snonux/hexai/internal/llm/copilot.go (7.9%)</option> + + <option value="file6">codeberg.org/snonux/hexai/internal/llm/ollama.go (13.9%)</option> + + <option value="file7">codeberg.org/snonux/hexai/internal/llm/openai.go (32.6%)</option> + + <option value="file8">codeberg.org/snonux/hexai/internal/llm/provider.go (37.9%)</option> + + <option value="file9">codeberg.org/snonux/hexai/internal/llm/util.go (0.0%)</option> + + <option value="file10">codeberg.org/snonux/hexai/internal/logging/chatlogger.go (14.3%)</option> + + <option value="file11">codeberg.org/snonux/hexai/internal/logging/logging.go (90.9%)</option> + + <option value="file12">codeberg.org/snonux/hexai/internal/lsp/context.go (71.8%)</option> + + <option value="file13">codeberg.org/snonux/hexai/internal/lsp/document.go (56.3%)</option> + + <option value="file14">codeberg.org/snonux/hexai/internal/lsp/handlers.go (77.8%)</option> + + <option value="file15">codeberg.org/snonux/hexai/internal/lsp/handlers_codeaction.go (22.9%)</option> + + <option value="file16">codeberg.org/snonux/hexai/internal/lsp/handlers_completion.go (69.8%)</option> + + <option value="file17">codeberg.org/snonux/hexai/internal/lsp/handlers_document.go (1.8%)</option> + + <option value="file18">codeberg.org/snonux/hexai/internal/lsp/handlers_execute.go (0.0%)</option> + + <option value="file19">codeberg.org/snonux/hexai/internal/lsp/handlers_init.go (0.0%)</option> + + <option value="file20">codeberg.org/snonux/hexai/internal/lsp/handlers_utils.go (65.3%)</option> + + <option value="file21">codeberg.org/snonux/hexai/internal/lsp/server.go (68.8%)</option> + + <option value="file22">codeberg.org/snonux/hexai/internal/lsp/transport.go (17.1%)</option> + + </select> + </div> + <div id="legend"> + <span>not tracked</span> + + <span class="cov0">no coverage</span> + <span class="cov1">low coverage</span> + <span class="cov2">*</span> + <span class="cov3">*</span> + <span class="cov4">*</span> + <span class="cov5">*</span> + <span class="cov6">*</span> + <span class="cov7">*</span> + <span class="cov8">*</span> + <span class="cov9">*</span> + <span class="cov10">high coverage</span> + + </div> + </div> + <div id="content"> + + <pre class="file" id="file0" style="display: none">// Summary: Hexai LSP entrypoint; parses flags and delegates to internal/hexailsp. +package main + +import ( + "flag" + "log" + "os" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/hexailsp" +) + +func main() <span class="cov0" title="0">{ + logPath := flag.String("log", "/tmp/hexai-lsp.log", "path to log file (optional)") + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion </span><span class="cov0" title="0">{ + log.Println(internal.Version) + return + }</span> + + <span class="cov0" title="0">if err := hexailsp.Run(*logPath, os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + log.Fatalf("server error: %v", err) + }</span> +} +</pre> + + <pre class="file" id="file1" style="display: none">// Summary: Hexai CLI entrypoint; parses flags and delegates to internal/hexaicli. +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/hexaicli" +) + +func main() <span class="cov0" title="0">{ + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + if *showVersion </span><span class="cov0" title="0">{ + fmt.Fprintln(os.Stdout, internal.Version) + return + }</span> + + <span class="cov0" title="0">if err := hexaicli.Run(context.Background(), flag.Args(), os.Stdin, os.Stdout, os.Stderr); err != nil </span><span class="cov0" title="0">{ + os.Exit(1) + }</span> +} +</pre> + + <pre class="file" id="file2" style="display: none">// Summary: Application configuration model and loader; reads ~/.config/hexai/config.json and merges defaults. +package appconfig + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "slices" + "strconv" + "strings" +) + +// App holds user-configurable settings read from ~/.config/hexai/config.json. +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"` + // 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"` + + // Provider-specific options + OpenAIBaseURL string `json:"openai_base_url"` + OpenAIModel string `json:"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"` + // 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"` + // Default temperature for Copilot requests (nil means use provider default) + CopilotTemperature *float64 `json:"copilot_temperature"` +} + +// Constructor: defaults for App (kept first among functions) +func newDefaultConfig() App <span class="cov4" title="3">{ + // Coding-friendly default temperature across providers + // Users can override per provider in config.json (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, + } +}</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="3">{ + cfg := newDefaultConfig() + if logger == nil </span><span class="cov3" title="2">{ + return cfg // Return defaults if no logger is provided (e.g. in tests) + }</span> + + <span class="cov1" title="1">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="cov1" title="1"> { + if fileCfg, err := loadFromFile(configPath, logger); err == nil && fileCfg != nil </span><span class="cov0" title="0">{ + 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="cov1" title="1">if envCfg := loadFromEnv(logger); envCfg != nil </span><span class="cov0" title="0">{ + cfg.mergeWith(envCfg) + }</span> + <span class="cov1" title="1">return cfg</span> +} + +// Private helpers +func loadFromFile(path string, logger *log.Logger) (*App, error) <span class="cov1" title="1">{ + f, err := os.Open(path) + if err != nil </span><span class="cov1" title="1">{ + if !os.IsNotExist(err) && logger != nil </span><span class="cov0" title="0">{ + logger.Printf("cannot open config file %s: %v", path, err) + }</span> + <span class="cov1" title="1">return nil, err</span> + } + <span class="cov0" title="0">defer f.Close() + + dec := json.NewDecoder(f) + var fileCfg App + if err := dec.Decode(&fileCfg); err != nil </span><span class="cov0" title="0">{ + if logger != nil </span><span class="cov0" title="0">{ + logger.Printf("invalid config file %s: %v", path, err) + }</span> + <span class="cov0" title="0">return nil, err</span> + } + <span class="cov0" title="0">return &fileCfg, nil</span> +} + +func (a *App) mergeWith(other *App) <span class="cov0" title="0">{ + a.mergeBasics(other) + a.mergeProviderFields(other) +}</span> + +// mergeBasics merges general (non-provider) fields. +func (a *App) mergeBasics(other *App) <span class="cov0" title="0">{ + if other.MaxTokens > 0 </span><span class="cov0" title="0">{ + a.MaxTokens = other.MaxTokens + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.ContextMode); s != "" </span><span class="cov0" title="0">{ + a.ContextMode = s + }</span> + <span class="cov0" title="0">if other.ContextWindowLines > 0 </span><span class="cov0" title="0">{ + a.ContextWindowLines = other.ContextWindowLines + }</span> + <span class="cov0" title="0">if other.MaxContextTokens > 0 </span><span class="cov0" title="0">{ + a.MaxContextTokens = other.MaxContextTokens + }</span> + <span class="cov0" title="0">if other.LogPreviewLimit >= 0 </span><span class="cov0" title="0">{ + a.LogPreviewLimit = other.LogPreviewLimit + }</span> + <span class="cov0" title="0">if other.CodingTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.CodingTemperature = other.CodingTemperature + }</span> + <span class="cov0" title="0">if other.ManualInvokeMinPrefix >= 0 </span><span class="cov0" title="0">{ + a.ManualInvokeMinPrefix = other.ManualInvokeMinPrefix + }</span> + <span class="cov0" title="0">if other.CompletionDebounceMs > 0 </span><span class="cov0" title="0">{ a.CompletionDebounceMs = other.CompletionDebounceMs }</span> + <span class="cov0" title="0">if other.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ a.CompletionThrottleMs = other.CompletionThrottleMs }</span> + <span class="cov0" title="0">if len(other.TriggerCharacters) > 0 </span><span class="cov0" title="0">{ + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.Provider); s != "" </span><span class="cov0" title="0">{ + a.Provider = s + }</span> +} + +// mergeProviderFields merges per-provider configuration. +func (a *App) mergeProviderFields(other *App) <span class="cov0" title="0">{ + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" </span><span class="cov0" title="0">{ + a.OpenAIBaseURL = s + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.OpenAIModel); s != "" </span><span class="cov0" title="0">{ + a.OpenAIModel = s + }</span> + <span class="cov0" title="0">if other.OpenAITemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.OpenAITemperature = other.OpenAITemperature + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.OllamaBaseURL); s != "" </span><span class="cov0" title="0">{ + a.OllamaBaseURL = s + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.OllamaModel); s != "" </span><span class="cov0" title="0">{ + a.OllamaModel = s + }</span> + <span class="cov0" title="0">if other.OllamaTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.OllamaTemperature = other.OllamaTemperature + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.CopilotBaseURL); s != "" </span><span class="cov0" title="0">{ + a.CopilotBaseURL = s + }</span> + <span class="cov0" title="0">if s := strings.TrimSpace(other.CopilotModel); s != "" </span><span class="cov0" title="0">{ + a.CopilotModel = s + }</span> + <span class="cov0" title="0">if other.CopilotTemperature != nil </span><span class="cov0" title="0">{ // allow explicit 0.0 + a.CopilotTemperature = other.CopilotTemperature + }</span> +} + +func getConfigPath() (string, error) <span class="cov1" title="1">{ + var configPath string + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" </span><span class="cov0" title="0">{ + configPath = filepath.Join(xdgConfigHome, "hexai", "config.json") + }</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">return configPath, nil</span> +} + +// --- Environment overrides --- + +// 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="cov1" title="1">{ + var out App + var any bool + + // helpers + getenv := func(k string) string </span><span class="cov10" title="20">{ return strings.TrimSpace(os.Getenv(k)) }</span> + <span class="cov1" title="1">parseInt := func(k string) (int, bool) </span><span class="cov6" title="7">{ + v := getenv(k) + if v == "" </span><span class="cov6" title="7">{ return 0, false }</span> + <span class="cov0" title="0">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="cov0" title="0">return n, true</span> + } + <span class="cov1" title="1">parseFloatPtr := func(k string) (*float64, bool) </span><span class="cov5" title="4">{ + v := getenv(k) + if v == "" </span><span class="cov5" title="4">{ return nil, false }</span> + <span class="cov0" title="0">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="cov0" title="0">return &f, true</span> + } + + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_MAX_TOKENS"); ok </span><span class="cov0" title="0">{ + out.MaxTokens = n; any = true + }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_CONTEXT_MODE"); s != "" </span><span class="cov0" title="0">{ + out.ContextMode = s; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_CONTEXT_WINDOW_LINES"); ok </span><span class="cov0" title="0">{ + out.ContextWindowLines = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_MAX_CONTEXT_TOKENS"); ok </span><span class="cov0" title="0">{ + out.MaxContextTokens = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_LOG_PREVIEW_LIMIT"); ok </span><span class="cov0" title="0">{ + out.LogPreviewLimit = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_MANUAL_INVOKE_MIN_PREFIX"); ok </span><span class="cov0" title="0">{ + out.ManualInvokeMinPrefix = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_COMPLETION_DEBOUNCE_MS"); ok </span><span class="cov0" title="0">{ + out.CompletionDebounceMs = n; any = true + }</span> + <span class="cov1" title="1">if n, ok := parseInt("HEXAI_COMPLETION_THROTTLE_MS"); ok </span><span class="cov0" title="0">{ + out.CompletionThrottleMs = n; any = true + }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_CODING_TEMPERATURE"); ok </span><span class="cov0" title="0">{ + out.CodingTemperature = f; any = true + }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_TRIGGER_CHARACTERS"); s != "" </span><span class="cov0" title="0">{ + parts := strings.Split(s, ",") + out.TriggerCharacters = nil + for _, p := range parts </span><span class="cov0" title="0">{ + if t := strings.TrimSpace(p); t != "" </span><span class="cov0" title="0">{ + out.TriggerCharacters = append(out.TriggerCharacters, t) + }</span> + } + <span class="cov0" title="0">any = true</span> + } + <span class="cov1" title="1">if s := getenv("HEXAI_PROVIDER"); s != "" </span><span class="cov0" title="0">{ + out.Provider = s; any = true + }</span> + + // Provider-specific + <span class="cov1" title="1">if s := getenv("HEXAI_OPENAI_BASE_URL"); s != "" </span><span class="cov0" title="0">{ out.OpenAIBaseURL = s; any = true }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_OPENAI_MODEL"); s != "" </span><span class="cov0" title="0">{ out.OpenAIModel = s; any = true }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_OPENAI_TEMPERATURE"); ok </span><span class="cov0" title="0">{ out.OpenAITemperature = f; any = true }</span> + + <span class="cov1" title="1">if s := getenv("HEXAI_OLLAMA_BASE_URL"); s != "" </span><span class="cov0" title="0">{ out.OllamaBaseURL = s; any = true }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_OLLAMA_MODEL"); s != "" </span><span class="cov0" title="0">{ out.OllamaModel = s; any = true }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_OLLAMA_TEMPERATURE"); ok </span><span class="cov0" title="0">{ out.OllamaTemperature = f; any = true }</span> + + <span class="cov1" title="1">if s := getenv("HEXAI_COPILOT_BASE_URL"); s != "" </span><span class="cov0" title="0">{ out.CopilotBaseURL = s; any = true }</span> + <span class="cov1" title="1">if s := getenv("HEXAI_COPILOT_MODEL"); s != "" </span><span class="cov0" title="0">{ out.CopilotModel = s; any = true }</span> + <span class="cov1" title="1">if f, ok := parseFloatPtr("HEXAI_COPILOT_TEMPERATURE"); ok </span><span class="cov0" title="0">{ out.CopilotTemperature = f; any = true }</span> + + <span class="cov1" title="1">if !any </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov0" title="0">return &out</span> +} +</pre> + + <pre class="file" id="file3" style="display: none">// Summary: Hexai CLI runner; reads input, creates an LLM client, builds messages, +// streams or collects the model output, and prints a short summary to stderr. +package hexaicli + +import ( + "bufio" + "context" + "fmt" + "io" + "log" + "os" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" +) + +// 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="cov0" title="0">{ + // 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="cov0" title="0">{ + 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> +} + +// RunWithClient executes the CLI flow using an already-constructed client. +// Useful for testing and embedding. +func RunWithClient(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer, client llm.Client) error <span class="cov6" title="3">{ + input, err := readInput(stdin, args) + if err != nil </span><span class="cov0" title="0">{ + fmt.Fprintln(stderr, logging.AnsiBase+err.Error()+logging.AnsiReset) + return err + }</span> + <span class="cov6" title="3">printProviderInfo(stderr, client) + msgs := buildMessages(input) + if err := runChat(ctx, client, msgs, input, stdout, stderr); err != nil </span><span class="cov0" title="0">{ + fmt.Fprintf(stderr, logging.AnsiBase+"hexai: error: %v"+logging.AnsiReset+"\n", err) + return err + }</span> + <span class="cov6" title="3">return nil</span> +} + +// readInput reads from stdin and args, then combines them per CLI rules. +func readInput(stdin io.Reader, args []string) (string, error) <span class="cov10" title="7">{ + var stdinData string + if fi, err := os.Stdin.Stat(); err == nil && (fi.Mode()&os.ModeCharDevice) == 0 </span><span class="cov10" title="7">{ + b, _ := io.ReadAll(bufio.NewReader(stdin)) + stdinData = strings.TrimSpace(string(b)) + }</span> + <span class="cov10" title="7">argData := strings.TrimSpace(strings.Join(args, " ")) + switch </span>{ + case stdinData != "" && argData != "":<span class="cov4" title="2"> + return fmt.Sprintf("%s:\n\n%s", argData, stdinData), nil</span> + case stdinData != "":<span class="cov1" title="1"> + return stdinData, nil</span> + case argData != "":<span class="cov6" title="3"> + return argData, nil</span> + default:<span class="cov1" title="1"> + return "", fmt.Errorf("hexai: no input provided; pass text as an argument or via stdin")</span> + } +} + +// newClientFromConfig builds an LLM client from the app config and env keys. +func newClientFromConfig(cfg appconfig.App) (llm.Client, error) <span class="cov0" title="0">{ + 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="cov0" title="0">{ + oaKey = os.Getenv("OPENAI_API_KEY") + }</span> + // Prefer HEXAI_COPILOT_API_KEY; fall back to COPILOT_API_KEY + <span class="cov0" title="0">cpKey := os.Getenv("HEXAI_COPILOT_API_KEY") + if strings.TrimSpace(cpKey) == "" </span><span class="cov0" title="0">{ + cpKey = os.Getenv("COPILOT_API_KEY") + }</span> + <span class="cov0" title="0">return llm.NewFromConfig(llmCfg, oaKey, cpKey)</span> +} + +// buildMessages creates system and user messages based on input content. +func buildMessages(input string) []llm.Message <span class="cov8" title="5">{ + lower := strings.ToLower(input) + system := "You are Hexai CLI. Default to very short, concise answers. If the user asks for commands, output only the commands (one per line) with no commentary or explanation. Only when the word 'explain' appears in the prompt, produce a verbose explanation." + if strings.Contains(lower, "explain") </span><span class="cov1" title="1">{ + system = "You are Hexai CLI. The user requested an explanation. Provide a clear, verbose explanation with reasoning and details. If commands are needed, include them with brief context." + }</span> + <span class="cov8" title="5">return []llm.Message{ + {Role: "system", Content: system}, + {Role: "user", Content: input}, + }</span> +} + +// runChat executes the chat request, handling streaming and summary output. +func runChat(ctx context.Context, client llm.Client, msgs []llm.Message, input string, out io.Writer, errw io.Writer) error <span class="cov8" title="5">{ + start := time.Now() + var output string + if s, ok := client.(llm.Streamer); ok </span><span class="cov4" title="2">{ + var b strings.Builder + if err := s.ChatStream(ctx, msgs, func(chunk string) </span><span class="cov7" title="4">{ + b.WriteString(chunk) + fmt.Fprint(out, chunk) + }</span>); err != nil <span class="cov0" title="0">{ + return err + }</span> + <span class="cov4" title="2">output = b.String()</span> + } else<span class="cov6" title="3"> { + txt, err := client.Chat(ctx, msgs) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov6" title="3">output = txt + fmt.Fprint(out, output)</span> + } + <span class="cov8" title="5">dur := time.Since(start) + fmt.Fprintf(errw, "\n"+logging.AnsiBase+"done provider=%s model=%s time=%s in_bytes=%d out_bytes=%d"+logging.AnsiReset+"\n", + client.Name(), client.DefaultModel(), dur.Round(time.Millisecond), len(input), len(output)) + return nil</span> +} + +// printProviderInfo writes the provider/model line to stderr. +func printProviderInfo(errw io.Writer, client llm.Client) <span class="cov7" title="4">{ + fmt.Fprintf(errw, logging.AnsiBase+"provider=%s model=%s"+logging.AnsiReset+"\n", client.Name(), client.DefaultModel()) +}</span> +</pre> + + <pre class="file" id="file4" style="display: none">// Summary: Hexai LSP runner; configures logging, loads config, builds the LLM client, +// and constructs/runs the LSP server (with injectable factory for tests). +package hexailsp + +import ( + "io" + "log" + "os" + "strings" + + "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "codeberg.org/snonux/hexai/internal/lsp" +) + +// ServerRunner is the minimal interface satisfied by lsp.Server. +type ServerRunner interface{ Run() error } + +// ServerFactory creates a ServerRunner. Default uses lsp.NewServer. +type ServerFactory func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner + +// Run configures logging, loads config, builds the LLM client and runs the LSP server. +// It is thin and delegates to RunWithFactory for testability. +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) + if err != nil </span><span class="cov0" title="0">{ + logger.Fatalf("failed to open log file: %v", err) + }</span> + <span class="cov1" title="1">defer f.Close() + logger.SetOutput(f)</span> + } + <span class="cov1" title="1">logging.Bind(logger) + cfg := appconfig.Load(logger) + return RunWithFactory(logPath, stdin, stdout, logger, cfg, nil, nil)</span> +} + +// RunWithFactory is the testable entrypoint. When client is nil, it is built from cfg+env. +// When factory is nil, lsp.NewServer is used. +func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error <span class="cov10" title="6">{ + normalizeLoggingConfig(&cfg) + client = buildClientIfNil(cfg, client) + factory = ensureFactory(factory) + + opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client) + server := factory(stdin, stdout, logger, opts) + if err := server.Run(); err != nil </span><span class="cov0" title="0">{ + logger.Fatalf("server error: %v", err) + }</span> + <span class="cov10" title="6">return nil</span> +} + +// --- helpers to keep RunWithFactory small --- + +func normalizeLoggingConfig(cfg *appconfig.App) <span class="cov10" title="6">{ + cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode)) + if cfg.LogPreviewLimit >= 0 </span><span class="cov10" title="6">{ + logging.SetLogPreviewLimit(cfg.LogPreviewLimit) + }</span> +} + +func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client <span class="cov10" title="6">{ + if client != nil </span><span class="cov0" title="0">{ + return client + }</span> + <span class="cov10" title="6">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="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 + }</span> else<span class="cov9" title="5"> { + logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) + return c + }</span> +} + +func ensureFactory(factory ServerFactory) ServerFactory <span class="cov10" title="6">{ + if factory != nil </span><span class="cov9" title="5">{ + return factory + }</span> + <span class="cov1" title="1">return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner </span><span class="cov1" title="1">{ + return lsp.NewServer(r, w, logger, opts) + }</span> +} + +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, + } +}</span> +</pre> + + <pre class="file" id="file5" style="display: none">// Summary: GitHub Copilot client for chat and Codex-style code completion. +package llm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" + + "encoding/base64" + appver "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/logging" +) + +// copilotClient implements Client against GitHub Copilot's Chat Completions API. +type copilotClient struct { + httpClient *http.Client + apiKey string + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 + + // cached Copilot session token retrieved from GitHub API using apiKey + sessionToken string + tokenExpiry time.Time +} + +type copilotChatRequest struct { + Model string `json:"model"` + Messages []copilotMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` +} + +type copilotMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type copilotChatResponse struct { + Choices []struct { + Index int `json:"index"` + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + Param any `json:"param"` + Code any `json:"code"` + } `json:"error,omitempty"` +} + +// Constructor (kept among the first functions by convention) +func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov0" title="0">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov0" title="0">{ + baseURL = "https://api.githubcopilot.com" + }</span> + <span class="cov0" title="0">if strings.TrimSpace(model) == "" </span><span class="cov0" title="0">{ + // GitHub Models (Copilot API) commonly supports gpt-4o/gpt-4o-mini. + // Default to a broadly available, cost-effective option. + model = "gpt-4o-mini" + }</span> + <span class="cov0" title="0">return copilotClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + baseURL: strings.TrimRight(baseURL, "/"), + defaultModel: model, + chatLogger: logging.NewChatLogger("copilot"), + defaultTemperature: defaultTemp, + }</span> +} + +func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov0" title="0">{ + if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ + return nilStringErr("missing Copilot API key") + }</span> + // Ensure we have a fresh session token + <span class="cov0" title="0">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov0" title="0">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov0" title="0">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov0" title="0">start := time.Now() + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov0" title="0">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov0" title="0">c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) + + req := buildCopilotChatRequest(o, messages, c.defaultTemperature) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot ", "marshal error: %v", err) + return "", err + }</span> + + <span class="cov0" title="0">endpoint := c.baseURL + "/chat/completions" + logging.Logf("llm/copilot ", "POST %s", endpoint) + resp, err := c.postJSON(ctx, endpoint, body, c.headersChat()) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov0" title="0">defer resp.Body.Close() + if err := handleCopilotNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov0" title="0">out, err := decodeCopilotChat(resp, start) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov0" title="0">if len(out.Choices) == 0 </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("copilot: no choices returned") + }</span> + <span class="cov0" title="0">content := out.Choices[0].Message.Content + logging.Logf("llm/copilot ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) + return content, nil</span> +} + +// Provider metadata +func (c copilotClient) Name() string <span class="cov0" title="0">{ return "copilot" }</span> +func (c copilotClient) DefaultModel() string <span class="cov0" title="0">{ return c.defaultModel }</span> + +// helpers +func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest <span class="cov8" title="1">{ + req := copilotChatRequest{Model: o.Model} + req.Messages = make([]copilotMessage, len(messages)) + for i, m := range messages </span><span class="cov8" title="1">{ + req.Messages[i] = copilotMessage{Role: m.Role, Content: m.Content} + }</span> + <span class="cov8" title="1">if o.Temperature != 0 </span><span class="cov0" title="0">{ + req.Temperature = &o.Temperature + }</span> else<span class="cov8" title="1"> if defaultTemp != nil </span><span class="cov8" title="1">{ + t := *defaultTemp + req.Temperature = &t + }</span> + <span class="cov8" title="1">if o.MaxTokens > 0 </span><span class="cov8" title="1">{ + req.MaxTokens = &o.MaxTokens + }</span> + <span class="cov8" title="1">if len(o.Stop) > 0 </span><span class="cov8" title="1">{ + req.Stop = o.Stop + }</span> + <span class="cov8" title="1">return req</span> +} + +func (c copilotClient) postJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov0" title="0">{ + 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="cov0" title="0">for k, v := range headers </span><span class="cov0" title="0">{ req.Header.Set(k, v) }</span> + <span class="cov0" title="0">return c.httpClient.Do(req)</span> +} + +func handleCopilotNon2xx(resp *http.Response, start time.Time) error <span class="cov0" title="0">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov0" title="0">var apiErr copilotChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) + return fmt.Errorf("copilot error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) + }</span> + <span class="cov0" title="0">logging.Logf("llm/copilot ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return fmt.Errorf("copilot http error: status %d", resp.StatusCode)</span> +} + +func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) <span class="cov0" title="0">{ + var out copilotChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/copilot ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return copilotChatResponse{}, err + }</span> + <span class="cov0" title="0">return out, nil</span> +} + +// --- Copilot session token management --- + +type ghCopilotTokenResp struct { + Token string `json:"token"` +} + +func (c *copilotClient) ensureSession(ctx context.Context) error <span class="cov0" title="0">{ + // If token valid for >60s, reuse + if c.sessionToken != "" && time.Now().Add(60*time.Second).Before(c.tokenExpiry) </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov0" title="0">if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ + return errors.New("missing Copilot API key") + }</span> + <span class="cov0" title="0">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="cov0" title="0">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="cov0" title="0">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="cov0" title="0">var out ghCopilotTokenResp + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ return err }</span> + <span class="cov0" title="0">if strings.TrimSpace(out.Token) == "" </span><span class="cov0" title="0">{ return errors.New("empty copilot session token") }</span> + // Parse JWT exp + <span class="cov0" title="0">exp := parseJWTExp(out.Token) + if exp.IsZero() </span><span class="cov0" title="0">{ exp = time.Now().Add(10 * time.Minute) }</span> + <span class="cov0" title="0">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="cov0" title="0">{ + parts := strings.Split(token, ".") + if len(parts) < 2 </span><span class="cov0" title="0">{ return time.Time{} }</span> + <span class="cov0" title="0">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="cov0" title="0">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="cov0" title="0">return time.Unix(payload.Exp, 0)</span> +} + +func parseInt64(s string) (int64, error) <span class="cov0" title="0">{ var n int64; _, err := fmt.Sscan(s, &n); return n, err }</span> + +// --- Copilot headers --- + +func (c *copilotClient) headersChat() map[string]string <span class="cov0" title="0">{ + _ = 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="cov0" title="0">{ + _ = 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="cov0" title="0">{ + const hex = "0123456789abcdef" + b := make([]byte, n) + for i := range b </span><span class="cov0" title="0">{ + b[i] = hex[int(time.Now().UnixNano()+int64(i))%len(hex)] + }</span> + <span class="cov0" title="0">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="cov0" title="0">{ + if strings.TrimSpace(c.apiKey) == "" </span><span class="cov0" title="0">{ return nil, errors.New("missing Copilot API key") }</span> + <span class="cov0" title="0">if err := c.ensureSession(ctx); err != nil </span><span class="cov0" title="0">{ return nil, err }</span> + <span class="cov0" title="0">if n <= 0 </span><span class="cov0" title="0">{ n = 1 }</span> + <span class="cov0" title="0">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="cov0" title="0">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="cov0" title="0">raw, _ := io.ReadAll(resp.Body) + byIndex := make(map[int]string) + lines := strings.Split(string(raw), "\n") + for _, ln := range lines </span><span class="cov0" title="0">{ + if !strings.HasPrefix(ln, "data: ") </span><span class="cov0" title="0">{ continue</span> } + <span class="cov0" title="0">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="cov0" title="0">{ continue</span> } + <span class="cov0" title="0">for _, ch := range evt.Choices </span><span class="cov0" title="0">{ byIndex[ch.Index] += ch.Text }</span> + } + <span class="cov0" title="0">out := make([]string, 0, len(byIndex)) + for i := 0; i < n; i++ </span><span class="cov0" title="0">{ + if s, ok := byIndex[i]; ok && strings.TrimSpace(s) != "" </span><span class="cov0" title="0">{ out = append(out, s) }</span> + } + <span class="cov0" title="0">return out, nil</span> +} + +// newLineDataReader wraps a streaming body and exposes a JSON decoder that +// decodes successive objects from lines prefixed by "data: ". +// (no streaming decoder needed; we parse whole body lines) +</pre> + + <pre class="file" id="file6" style="display: none">// Summary: Ollama client against a local server; supports chat responses and streaming via /api/chat. +package llm + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/logging" +) + +// ollamaClient implements Client against a local Ollama server. +type ollamaClient struct { + httpClient *http.Client + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 +} + +type ollamaChatRequest struct { + Model string `json:"model"` + Messages []oaMessage `json:"messages"` + Stream bool `json:"stream"` + Options any `json:"options,omitempty"` +} + +type ollamaChatResponse struct { + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + Done bool `json:"done"` + Error string `json:"error,omitempty"` +} + +// Constructor (kept among the first functions by convention) +func newOllama(baseURL, model string, defaultTemp *float64) Client <span class="cov0" title="0">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov0" title="0">{ + baseURL = "http://localhost:11434" + }</span> + <span class="cov0" title="0">if strings.TrimSpace(model) == "" </span><span class="cov0" title="0">{ + model = "qwen3-coder:30b-a3b-q4_K_M`" + }</span> + <span class="cov0" title="0">return ollamaClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(baseURL, "/"), + defaultModel: model, + chatLogger: logging.NewChatLogger("ollama"), + defaultTemperature: defaultTemp, + }</span> +} + +func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov0" title="0">{ + o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov0" title="0">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + + <span class="cov0" title="0">start := time.Now() + c.logStart(false, o, messages) + req := buildOllamaRequest(o, messages, c.defaultTemperature, false) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + + <span class="cov0" title="0">endpoint := c.baseURL + "/api/chat" + logging.Logf("llm/ollama ", "POST %s", endpoint) + resp, err := c.doJSON(ctx, endpoint, body) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov0" title="0">defer resp.Body.Close() + if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + + <span class="cov0" title="0">var out ollamaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov0" title="0">if strings.TrimSpace(out.Message.Content) == "" </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("ollama: empty content") + }</span> + <span class="cov0" title="0">content := out.Message.Content + logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) + return content, nil</span> +} + +// Provider metadata +func (c ollamaClient) Name() string <span class="cov0" title="0">{ return "ollama" }</span> +func (c ollamaClient) DefaultModel() string <span class="cov0" title="0">{ return c.defaultModel }</span> + +// Streaming support (optional) +func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov0" title="0">{ + o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov0" title="0">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + + <span class="cov0" title="0">start := time.Now() + c.logStart(true, o, messages) + req := buildOllamaRequest(o, messages, c.defaultTemperature, true) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + + <span class="cov0" title="0">endpoint := c.baseURL + "/api/chat" + logging.Logf("llm/ollama ", "POST %s (stream)", endpoint) + resp, err := c.doJSON(ctx, endpoint, body) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov0" title="0">defer resp.Body.Close() + if err := handleOllamaNon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + + <span class="cov0" title="0">dec := json.NewDecoder(resp.Body) + for </span><span class="cov0" title="0">{ + var ev ollamaChatResponse + if err := dec.Decode(&ev); err != nil </span><span class="cov0" title="0">{ + if errors.Is(err, io.EOF) </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov0" title="0">logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err</span> + } + <span class="cov0" title="0">if strings.TrimSpace(ev.Error) != "" </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama ", "%sstream event error: %s%s", logging.AnsiRed, ev.Error, logging.AnsiBase) + return fmt.Errorf("ollama stream error: %s", ev.Error) + }</span> + <span class="cov0" title="0">if s := ev.Message.Content; strings.TrimSpace(s) != "" </span><span class="cov0" title="0">{ + onDelta(s) + }</span> + <span class="cov0" title="0">if ev.Done </span><span class="cov0" title="0">{ + break</span> + } + } + <span class="cov0" title="0">logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) + return nil</span> +} + +// helpers to keep methods small +func (c ollamaClient) logStart(stream bool, o Options, messages []Message) <span class="cov0" title="0">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov0" title="0">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov0" title="0">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> +} + +func buildOllamaRequest(o Options, messages []Message, defaultTemp *float64, stream bool) ollamaChatRequest <span class="cov10" title="2">{ + req := ollamaChatRequest{Model: o.Model, Stream: stream} + req.Messages = make([]oaMessage, len(messages)) + for i, m := range messages </span><span class="cov10" title="2">{ + req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} + }</span> + <span class="cov10" title="2">optsMap := map[string]any{} + if o.Temperature != 0 </span><span class="cov0" title="0">{ + optsMap["temperature"] = o.Temperature + }</span> else<span class="cov10" title="2"> if defaultTemp != nil </span><span class="cov10" title="2">{ + optsMap["temperature"] = *defaultTemp + }</span> + <span class="cov10" title="2">if o.MaxTokens > 0 </span><span class="cov10" title="2">{ + optsMap["num_predict"] = o.MaxTokens + }</span> + <span class="cov10" title="2">if len(o.Stop) > 0 </span><span class="cov10" title="2">{ + optsMap["stop"] = o.Stop + }</span> + <span class="cov10" title="2">if len(optsMap) > 0 </span><span class="cov10" title="2">{ + req.Options = optsMap + }</span> + <span class="cov10" title="2">return req</span> +} + +func (c ollamaClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) <span class="cov0" title="0">{ + 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="cov0" title="0">req.Header.Set("Content-Type", "application/json") + return c.httpClient.Do(req)</span> +} + +func handleOllamaNon2xx(resp *http.Response, start time.Time) error <span class="cov0" title="0">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov0" title="0">var apiErr ollamaChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if strings.TrimSpace(apiErr.Error) != "" </span><span class="cov0" title="0">{ + logging.Logf("llm/ollama ", "%sapi error status=%d msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error, time.Since(start), logging.AnsiBase) + return fmt.Errorf("ollama error: %s (status %d)", apiErr.Error, resp.StatusCode) + }</span> + <span class="cov0" title="0">logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return fmt.Errorf("ollama http error: status %d", resp.StatusCode)</span> +} +</pre> + + <pre class="file" id="file7" style="display: none">// Summary: OpenAI client implementation for chat completions with optional streaming and detailed logging. +package llm + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "codeberg.org/snonux/hexai/internal/logging" +) + +// openAIClient implements Client against OpenAI's Chat Completions API. +type openAIClient struct { + httpClient *http.Client + apiKey string + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 +} + +type oaChatRequest struct { + Model string `json:"model"` + Messages []oaMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream,omitempty"` +} + +type oaMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type oaChatResponse struct { + Choices []struct { + Index int `json:"index"` + Message struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + Param any `json:"param"` + Code any `json:"code"` + } `json:"error,omitempty"` +} + +// Streaming response chunk type (SSE) +type oaStreamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + Param any `json:"param"` + Code any `json:"code"` + } `json:"error,omitempty"` +} + +// Constructor (kept among the first functions by convention) +// newOpenAI constructs an OpenAI client using explicit configuration values. +// The apiKey may be empty; calls will fail until a valid key is supplied. +func newOpenAI(baseURL, model, apiKey string, defaultTemp *float64) Client <span class="cov10" title="5">{ + if strings.TrimSpace(baseURL) == "" </span><span class="cov10" title="5">{ + baseURL = "https://api.openai.com/v1" + }</span> + <span class="cov10" title="5">if strings.TrimSpace(model) == "" </span><span class="cov10" title="5">{ + model = "gpt-4.1" + }</span> + <span class="cov10" title="5">return openAIClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + baseURL: baseURL, + defaultModel: model, + chatLogger: logging.NewChatLogger("openai"), + defaultTemperature: defaultTemp, + }</span> +} + +func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) <span class="cov0" title="0">{ + if c.apiKey == "" </span><span class="cov0" title="0">{ + return nilStringErr("missing OpenAI API key") + }</span> + <span class="cov0" title="0">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov0" title="0">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov0" title="0">start := time.Now() + c.logStart(false, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, false) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return "", err + }</span> + <span class="cov0" title="0">endpoint := c.baseURL + "/chat/completions" + logging.Logf("llm/openai ", "POST %s", endpoint) + resp, err := c.doJSON(ctx, endpoint, body, map[string]string{ + "Authorization": "Bearer " + c.apiKey, + }) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return "", err + }</span> + <span class="cov0" title="0">defer resp.Body.Close() + if err := handleOpenAINon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov0" title="0">out, err := decodeOpenAIChat(resp, start) + if err != nil </span><span class="cov0" title="0">{ + return "", err + }</span> + <span class="cov0" title="0">if len(out.Choices) == 0 </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase) + return "", errors.New("openai: no choices returned") + }</span> + <span class="cov0" title="0">content := out.Choices[0].Message.Content + logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start)) + return content, nil</span> +} + +// Provider metadata +func (c openAIClient) Name() string <span class="cov10" title="5">{ return "openai" }</span> +func (c openAIClient) DefaultModel() string <span class="cov10" title="5">{ return c.defaultModel }</span> + +// Streaming support (optional) + +func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error <span class="cov0" title="0">{ + if c.apiKey == "" </span><span class="cov0" title="0">{ + return errors.New("missing OpenAI API key") + }</span> + <span class="cov0" title="0">o := Options{Model: c.defaultModel} + for _, opt := range opts </span><span class="cov0" title="0">{ + opt(&o) + }</span> + <span class="cov0" title="0">if o.Model == "" </span><span class="cov0" title="0">{ + o.Model = c.defaultModel + }</span> + <span class="cov0" title="0">start := time.Now() + c.logStart(true, o, messages) + req := buildOAChatRequest(o, messages, c.defaultTemperature, true) + body, err := json.Marshal(req) + if err != nil </span><span class="cov0" title="0">{ + c.logf("marshal error: %v", err) + return err + }</span> + <span class="cov0" title="0">endpoint := c.baseURL + "/chat/completions" + logging.Logf("llm/openai ", "POST %s (stream)", endpoint) + resp, err := c.doJSONWithAccept(ctx, endpoint, body, map[string]string{ + "Authorization": "Bearer " + c.apiKey, + }, "text/event-stream") + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov0" title="0">defer resp.Body.Close() + if err := handleOpenAINon2xx(resp, start); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + + <span class="cov0" title="0">if err := parseOpenAIStream(resp, start, onDelta); err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) + return nil</span> +} + +// Private helpers +func (c openAIClient) logf(format string, args ...any) <span class="cov0" title="0">{ logging.Logf("llm/openai ", format, args...) }</span> + +// helpers extracted to keep methods small +func (c openAIClient) logStart(stream bool, o Options, messages []Message) <span class="cov0" title="0">{ + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages </span><span class="cov0" title="0">{ + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + }</span> + <span class="cov0" title="0">c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)</span> +} + +func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest <span class="cov4" title="2">{ + req := oaChatRequest{Model: o.Model, Stream: stream} + req.Messages = make([]oaMessage, len(messages)) + for i, m := range messages </span><span class="cov4" title="2">{ + req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} + }</span> + <span class="cov4" title="2">if o.Temperature != 0 </span><span class="cov0" title="0">{ + req.Temperature = &o.Temperature + }</span> else<span class="cov4" title="2"> if defaultTemp != nil </span><span class="cov4" title="2">{ + t := *defaultTemp + req.Temperature = &t + }</span> + <span class="cov4" title="2">if o.MaxTokens > 0 </span><span class="cov4" title="2">{ + req.MaxTokens = &o.MaxTokens + }</span> + <span class="cov4" title="2">if len(o.Stop) > 0 </span><span class="cov4" title="2">{ + req.Stop = o.Stop + }</span> + <span class="cov4" title="2">return req</span> +} + +func (c openAIClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) <span class="cov0" title="0">{ + 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="cov0" title="0">req.Header.Set("Content-Type", "application/json") + for k, v := range headers </span><span class="cov0" title="0">{ + req.Header.Set(k, v) + }</span> + <span class="cov0" title="0">return c.httpClient.Do(req)</span> +} + +func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) <span class="cov0" title="0">{ + 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="cov0" title="0">req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", accept) + for k, v := range headers </span><span class="cov0" title="0">{ + req.Header.Set(k, v) + }</span> + <span class="cov0" title="0">return c.httpClient.Do(req)</span> +} + +func handleOpenAINon2xx(resp *http.Response, start time.Time) error <span class="cov1" title="1">{ + if resp.StatusCode >= 200 && resp.StatusCode < 300 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov1" title="1">var apiErr oaChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if apiErr.Error != nil && apiErr.Error.Message != "" </span><span class="cov1" title="1">{ + logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase) + return fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) + }</span> + <span class="cov0" title="0">logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return fmt.Errorf("openai http error: status %d", resp.StatusCode)</span> +} + +func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) <span class="cov0" title="0">{ + var out oaChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return oaChatResponse{}, err + }</span> + <span class="cov0" title="0">return out, nil</span> +} + +func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error <span class="cov1" title="1">{ + // Parse SSE: lines starting with "data: " containing JSON or [DONE] + scanner := bufio.NewScanner(resp.Body) + const maxBuf = 1024 * 1024 + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, maxBuf) + for scanner.Scan() </span><span class="cov7" title="3">{ + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") </span><span class="cov1" title="1">{ + continue</span> + } + <span class="cov4" title="2">payload := strings.TrimPrefix(line, "data: ") + if strings.TrimSpace(payload) == "[DONE]" </span><span class="cov1" title="1">{ + break</span> + } + <span class="cov1" title="1">var chunk oaStreamChunk + if err := json.Unmarshal([]byte(payload), &chunk); err != nil </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov1" title="1">if chunk.Error != nil && chunk.Error.Message != "" </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase) + return fmt.Errorf("openai stream error: %s", chunk.Error.Message) + }</span> + <span class="cov1" title="1">for _, ch := range chunk.Choices </span><span class="cov1" title="1">{ + if ch.Delta.Content != "" </span><span class="cov1" title="1">{ + onDelta(ch.Delta.Content) + }</span> + } + } + <span class="cov1" title="1">if err := scanner.Err(); err != nil </span><span class="cov0" title="0">{ + logging.Logf("llm/openai ", "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return err + }</span> + <span class="cov1" title="1">return nil</span> +} +</pre> + + <pre class="file" id="file8" style="display: none">// Summary: LLM provider interfaces, request options, configuration, and factory to build a client from config. +package llm + +import ( + "context" + "errors" + "strings" +) + +// Message represents a chat-style prompt message. +type Message struct { + Role string + Content string +} + +// Client is a minimal LLM provider interface. +// Future providers (Ollama, etc.) should implement this. +type Client interface { + // Chat sends chat messages and returns the assistant text. + Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) + // Name returns the provider's short name (e.g., "openai", "ollama"). + Name() string + // DefaultModel returns the configured default model name. + DefaultModel() string +} + +// Streamer is an optional interface that providers may implement to support +// 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 +} + +// 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) +} + +// Options for a request. Providers may ignore unsupported fields. +type Options struct { + Model string + Temperature float64 + MaxTokens int + Stop []string +} + +// RequestOption mutates Options. +type RequestOption func(*Options) + +func WithModel(model string) RequestOption <span class="cov0" title="0">{ return func(o *Options) </span><span class="cov0" title="0">{ o.Model = model }</span> } +func WithTemperature(t float64) RequestOption <span class="cov0" title="0">{ return func(o *Options) </span><span class="cov0" title="0">{ o.Temperature = t }</span> } +func WithMaxTokens(n int) RequestOption <span class="cov10" title="9">{ return func(o *Options) </span><span class="cov0" title="0">{ o.MaxTokens = n }</span> } +func WithStop(stop ...string) RequestOption <span class="cov0" title="0">{ + return func(o *Options) </span><span class="cov0" title="0">{ 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 +} + +// 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="cov8" title="6">{ + p := strings.ToLower(strings.TrimSpace(cfg.Provider)) + if p == "" </span><span class="cov8" title="6">{ + p = "openai" + }</span> + <span class="cov8" title="6">switch p </span>{ + case "openai":<span class="cov8" title="6"> + if strings.TrimSpace(openAIAPIKey) == "" </span><span class="cov1" title="1">{ + return nil, errors.New("missing OPENAI_API_KEY for provider openai") + }</span> + // Set coding-friendly default temperature if none provided + <span class="cov7" title="5">if cfg.OpenAITemperature == nil </span><span class="cov5" title="3">{ + t := 0.2 + cfg.OpenAITemperature = &t + }</span> + <span class="cov7" title="5">return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey, cfg.OpenAITemperature), nil</span> + case "ollama":<span class="cov0" title="0"> + if cfg.OllamaTemperature == nil </span><span class="cov0" title="0">{ + t := 0.2 + cfg.OllamaTemperature = &t + }</span> + <span class="cov0" title="0">return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel, cfg.OllamaTemperature), nil</span> + case "copilot":<span class="cov0" title="0"> + if strings.TrimSpace(copilotAPIKey) == "" </span><span class="cov0" title="0">{ + return nil, errors.New("missing COPILOT_API_KEY for provider copilot") + }</span> + <span class="cov0" title="0">if cfg.CopilotTemperature == nil </span><span class="cov0" title="0">{ + t := 0.2 + cfg.CopilotTemperature = &t + }</span> + <span class="cov0" title="0">return newCopilot(cfg.CopilotBaseURL, cfg.CopilotModel, copilotAPIKey, cfg.CopilotTemperature), nil</span> + default:<span class="cov0" title="0"> + return nil, errors.New("unknown LLM provider: " + p)</span> + } +} +</pre> + + <pre class="file" id="file9" style="display: none">package llm + +import "errors" + +// small helper to keep return type consistent +func nilStringErr(msg string) (string, error) <span class="cov0" title="0">{ return "", errors.New(msg) }</span> +</pre> + + <pre class="file" id="file10" style="display: none">package logging + +// ChatLogger provides a structured way to log chat interactions. +type ChatLogger struct { + Provider string +} + +// NewChatLogger creates a new ChatLogger for a given provider. +func NewChatLogger(provider string) ChatLogger <span class="cov10" title="5">{ + return ChatLogger{Provider: provider} +}</span> + +// LogStart logs the beginning of a chat or stream interaction. +func (cl ChatLogger) LogStart(stream bool, model string, temp float64, maxTokens int, stop []string, messages []struct { + Role string + Content string +}) <span class="cov0" title="0">{ + chatOrStream := "chat" + if stream </span><span class="cov0" title="0">{ + chatOrStream = "stream" + }</span> + <span class="cov0" title="0">Logf("llm/"+cl.Provider+" ", "%s start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", + chatOrStream, model, temp, maxTokens, len(stop), len(messages)) + for i, m := range messages </span><span class="cov0" title="0">{ + Logf("llm/"+cl.Provider+" ", "msg[%d] role=%s size=%d preview=%s%s%s", + i, m.Role, len(m.Content), AnsiCyan, PreviewForLog(m.Content), AnsiBase) + }</span> +} +</pre> + + <pre class="file" id="file11" style="display: none">// Summary: ANSI-styled logging utilities with a bound standard logger and configurable preview truncation. +package logging + +import ( + "fmt" + "log" +) + +// 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" +) + +// AnsiBase is the default style: black background + grey foreground. +const AnsiBase = AnsiBgBlack + AnsiGrey + +// singleton logger used across the codebase +var std *log.Logger + +// Bind sets the underlying standard logger to use for Logf. +func Bind(l *log.Logger) <span class="cov2" title="2">{ 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="33">{ + if std == nil </span><span class="cov6" title="7">{ + return + }</span> + <span class="cov9" title="26">msg := fmt.Sprintf(format, args...) + std.Print(AnsiBase + prefix + msg + AnsiReset)</span> +} + +// Logging configuration for previews (shared) +var logPreviewLimit int // 0 means unlimited + +// SetLogPreviewLimit sets the maximum number of characters to log for +// request/response previews. Set to 0 for unlimited. +func SetLogPreviewLimit(n int) <span class="cov6" title="7">{ logPreviewLimit = n }</span> + +// PreviewForLog returns the string truncated to the configured preview limit. +func PreviewForLog(s string) string <span class="cov2" title="2">{ + if logPreviewLimit > 0 </span><span class="cov1" title="1">{ + if len(s) <= logPreviewLimit </span><span class="cov0" title="0">{ + return s + }</span> + <span class="cov1" title="1">return s[:logPreviewLimit] + "…"</span> + } + <span class="cov1" title="1">return s</span> +} +</pre> + + <pre class="file" id="file12" style="display: none">// Summary: Builds additional context snippets based on configured mode and truncates text by token heuristic. +package lsp + +import ( + "codeberg.org/snonux/hexai/internal/logging" + "strings" +) + +// buildAdditionalContext builds extra context messages based on the configured mode. +// Modes: +// - minimal: no extra context +// - window: include a window of lines around the cursor +// - file-on-new-func: include full file only when defining a new function +// - always-full: always include the full file +func (s *Server) buildAdditionalContext(newFunc bool, uri string, pos Position) (string, bool) <span class="cov10" title="4">{ + mode := s.contextMode + switch mode </span>{ + case "minimal":<span class="cov1" title="1"> + return "", false</span> + case "window":<span class="cov0" title="0"> + return s.windowContext(uri, pos), true</span> + case "file-on-new-func":<span class="cov5" title="2"> + if newFunc </span><span class="cov1" title="1">{ + return s.fullFileContext(uri), true + }</span> + <span class="cov1" title="1">return "", false</span> + case "always-full":<span class="cov1" title="1"> + return s.fullFileContext(uri), true</span> + default:<span class="cov0" title="0"> + // fallback to minimal if unknown + return "", false</span> + } +} + +func (s *Server) windowContext(uri string, pos Position) string <span class="cov1" title="1">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "context: window requested but document not open; skipping uri=%s", uri) + return "" + }</span> + <span class="cov1" title="1">n := len(d.lines) + half := s.windowLines / 2 + start := pos.Line - half + if start < 0 </span><span class="cov0" title="0">{ + start = 0 + }</span> + <span class="cov1" title="1">end := pos.Line + half + 1 + if end > n </span><span class="cov0" title="0">{ + end = n + }</span> + <span class="cov1" title="1">text := strings.Join(d.lines[start:end], "\n") + return truncateToApproxTokens(text, s.maxContextTokens)</span> +} + +func (s *Server) fullFileContext(uri string) string <span class="cov5" title="2">{ + d := s.getDocument(uri) + if d == nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "context: full-file requested but document not open; skipping uri=%s", uri) + return "" + }</span> + <span class="cov5" title="2">return truncateToApproxTokens(d.text, s.maxContextTokens)</span> +} + +// truncateToApproxTokens naively truncates the input to fit approx N tokens. +// Uses 4 chars/token heuristic for speed and determinism. +func truncateToApproxTokens(text string, maxTokens int) string <span class="cov10" title="4">{ + if maxTokens <= 0 </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov10" title="4">maxChars := maxTokens * 4 + if len(text) <= maxChars </span><span class="cov8" title="3">{ + return text + }</span> + // try to cut on a line boundary near maxChars + <span class="cov1" title="1">cut := maxChars + if cut > len(text) </span><span class="cov0" title="0">{ + cut = len(text) + }</span> + <span class="cov1" title="1">if i := strings.LastIndex(text[:cut], "\n"); i > 0 </span><span class="cov0" title="0">{ + cut = i + }</span> + <span class="cov1" title="1">return text[:cut]</span> +} +</pre> + + <pre class="file" id="file13" style="display: none">// Summary: In-memory document model for the LSP; tracks text, lines, and applies edits. +package lsp + +import ( + "strings" + "time" +) + +type document struct { + uri string + text string + lines []string +} + +func (s *Server) setDocument(uri, text string) <span class="cov5" title="4">{ + s.mu.Lock() + defer s.mu.Unlock() + s.docs[uri] = &document{uri: uri, text: text, lines: splitLines(text)} +}</span> + +func (s *Server) deleteDocument(uri string) <span class="cov0" title="0">{ + s.mu.Lock() + defer s.mu.Unlock() + delete(s.docs, uri) +}</span> + +func (s *Server) markActivity() <span class="cov1" title="1">{ + s.mu.Lock() + s.lastInput = time.Now() + s.mu.Unlock() +}</span> + +func (s *Server) getDocument(uri string) *document <span class="cov9" title="16">{ + 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="21">{ + sx = strings.ReplaceAll(sx, "\r\n", "\n") + return strings.Split(sx, "\n") +}</span> + +func (s *Server) lineContext(uri string, pos Position) (above, current, below, funcCtx string) <span class="cov3" title="2">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov1" title="1">{ + return "", "", "", "" + }</span> + <span class="cov1" title="1">idx := pos.Line + if idx < 0 </span><span class="cov0" title="0">{ + idx = 0 + }</span> + <span class="cov1" title="1">if idx >= len(d.lines) </span><span class="cov0" title="0">{ + idx = len(d.lines) - 1 + }</span> + <span class="cov1" title="1">current = d.lines[idx] + if idx-1 >= 0 </span><span class="cov1" title="1">{ + above = d.lines[idx-1] + }</span> + <span class="cov1" title="1">if idx+1 < len(d.lines) </span><span class="cov1" title="1">{ + below = d.lines[idx+1] + }</span> + <span class="cov1" title="1">for i := idx; i >= 0; i-- </span><span class="cov3" title="2">{ + line := strings.TrimSpace(d.lines[i]) + if hasAny(line, []string{"func ", "def ", "class ", "fn ", "procedure ", "sub "}) </span><span class="cov1" title="1">{ + funcCtx = line + break</span> + } + } + <span class="cov1" title="1">return</span> +} + +// isDefiningNewFunction returns true when the cursor appears to be within +// a function declaration/signature and before the opening '{' of the body. +// Heuristic: find nearest preceding line containing "func "; ensure no '{' +// appears before the cursor across those lines. +func (s *Server) isDefiningNewFunction(uri string, pos Position) bool <span class="cov0" title="0">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov0" title="0">idx := pos.Line + if idx < 0 </span><span class="cov0" title="0">{ + idx = 0 + }</span> + <span class="cov0" title="0">if idx >= len(d.lines) </span><span class="cov0" title="0">{ + idx = len(d.lines) - 1 + }</span> + // Find signature start + <span class="cov0" title="0">sigStart := -1 + for i := idx; i >= 0; i-- </span><span class="cov0" title="0">{ + if strings.Contains(d.lines[i], "func ") </span><span class="cov0" title="0">{ + sigStart = i + break</span> + } + // stop if we hit a closing brace which likely ends a previous block + <span class="cov0" title="0">if strings.Contains(d.lines[i], "}") </span><span class="cov0" title="0">{ + break</span> + } + } + <span class="cov0" title="0">if sigStart == -1 </span><span class="cov0" title="0">{ + return false + }</span> + // Scan for '{' from sigStart up to cursor position; if found before or at cursor, we're in body + <span class="cov0" title="0">for i := sigStart; i <= idx; i++ </span><span class="cov0" title="0">{ + line := d.lines[i] + brace := strings.Index(line, "{") + if brace >= 0 </span><span class="cov0" title="0">{ + if i < idx </span><span class="cov0" title="0">{ + return false // body started on a previous line + }</span> + // same line as cursor: if brace position < cursor character, then already in body + <span class="cov0" title="0">if pos.Character > brace </span><span class="cov0" title="0">{ + return false + }</span> + } + } + <span class="cov0" title="0">return true</span> +} + +func hasAny(s string, needles []string) bool <span class="cov3" title="2">{ + for _, n := range needles </span><span class="cov6" title="7">{ + if strings.Contains(s, n) </span><span class="cov1" title="1">{ + return true + }</span> + } + <span class="cov1" title="1">return false</span> +} + +func trimLen(s string) string <span class="cov9" title="18">{ + s = strings.TrimSpace(s) + if len(s) > 200 </span><span class="cov1" title="1">{ + return s[:200] + "…" + }</span> + <span class="cov9" title="17">return s</span> +} + +func firstLine(s string) string <span class="cov7" title="10">{ + s = strings.ReplaceAll(s, "\r\n", "\n") + if idx := strings.IndexByte(s, '\n'); idx >= 0 </span><span class="cov1" title="1">{ + return s[:idx] + }</span> + <span class="cov7" title="9">return s</span> +} +</pre> + + <pre class="file" id="file14" style="display: none">// Summary: LSP JSON-RPC handlers; implements core methods and integrates with the LLM client when enabled. +package lsp + +import ( + "encoding/json" + "fmt" + "strings" +) + +func (s *Server) handle(req Request) <span class="cov0" title="0">{ + if h, ok := s.handlers[req.Method]; ok </span><span class="cov0" title="0">{ + h(req) + return + }</span> + <span class="cov0" title="0">if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, nil, &RespError{Code: -32601, Message: fmt.Sprintf("method not found: %s", req.Method)}) + }</span> +} + +// handleInitialize moved to handlers_init.go + +// llmRequestOpts moved to handlers_utils.go + +// instructionFromSelection extracts the first instruction from selection text. +// Preference order on each line: strict ;text; marker (no inner spaces), then +// a line comment (//, #, --). Returns the instruction string and the selection +// text cleaned of the matched instruction marker or comment. +func instructionFromSelection(sel string) (string, string) <span class="cov2" title="2">{ + lines := splitLines(sel) + for idx, line := range lines </span><span class="cov2" title="2">{ + if instr, cleaned, ok := findFirstInstructionInLine(line); ok && strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{ + lines[idx] = cleaned + return instr, strings.Join(lines, "\n") + }</span> + } + <span class="cov1" title="1">return "", sel</span> +} + +// findFirstInstructionInLine returns the earliest instruction marker on the +// line and the line with that marker removed. Supported markers, ordered by +// earliest byte offset in the line: +// - ;text; (strict, no space after first ';' or before last ';') +// - /* text */ (single-line only) +// - <!-- text --> (single-line only) +// - // text +// - # text +// - -- text +func findFirstInstructionInLine(line string) (instr string, cleaned string, ok bool) <span class="cov8" title="15">{ + type cand struct { + start, end int + text string + } + cands := []cand{} + if t, l, r, ok := findStrictSemicolonTag(line); ok </span><span class="cov5" title="5">{ + cands = append(cands, cand{start: l, end: r, text: t}) + }</span> + <span class="cov8" title="15">if i := strings.Index(line, "/*"); i >= 0 </span><span class="cov1" title="1">{ + if j := strings.Index(line[i+2:], "*/"); j >= 0 </span><span class="cov1" title="1">{ + start := i + end := i + 2 + j + 2 + text := strings.TrimSpace(line[i+2 : i+2+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + }</span> + } + <span class="cov8" title="15">if i := strings.Index(line, "<!--"); i >= 0 </span><span class="cov1" title="1">{ + if j := strings.Index(line[i+4:], "-->"); j >= 0 </span><span class="cov1" title="1">{ + start := i + end := i + 4 + j + 3 + text := strings.TrimSpace(line[i+4 : i+4+j]) + cands = append(cands, cand{start: start, end: end, text: text}) + }</span> + } + <span class="cov8" title="15">if i := strings.Index(line, "//"); i >= 0 </span><span class="cov4" title="3">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + }</span> + <span class="cov8" title="15">if i := strings.Index(line, "#"); i >= 0 </span><span class="cov1" title="1">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+1:])}) + }</span> + <span class="cov8" title="15">if i := strings.Index(line, "--"); i >= 0 </span><span class="cov2" title="2">{ + cands = append(cands, cand{start: i, end: len(line), text: strings.TrimSpace(line[i+2:])}) + }</span> + <span class="cov8" title="15">if len(cands) == 0 </span><span class="cov5" title="5">{ + return "", line, false + }</span> + // pick earliest start index + <span class="cov7" title="10">best := cands[0] + for _, c := range cands[1:] </span><span class="cov4" title="3">{ + if c.start >= 0 && (best.start < 0 || c.start < best.start) </span><span class="cov1" title="1">{ + best = c + }</span> + } + <span class="cov7" title="10">cleaned = strings.TrimRight(line[:best.start]+line[best.end:], " \t") + return best.text, cleaned, true</span> +} + +// diagnosticsInRange parses the CodeAction context and returns diagnostics +// that overlap the given selection range. If the context is missing or does +// not contain diagnostics, returns an empty slice. +// CodeAction-related handlers and helpers moved to handlers_codeaction.go + +// extractRangeText moved to handlers_utils.go + +// handleInitialized moved to handlers_init.go + +// handleShutdown moved to handlers_init.go + +// handleExit moved to handlers_init.go + +// handleDidOpen moved to handlers_document.go + +// handleDidChange moved to handlers_document.go + +// handleDidClose moved to handlers_document.go + +// handleCompletion moved to handlers_completion.go + +func (s *Server) reply(id json.RawMessage, result any, err *RespError) <span class="cov0" title="0">{ + resp := Response{JSONRPC: "2.0", ID: id, Result: result, Error: err} + s.writeMessage(resp) +}</span> + +// 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. +// docBeforeAfter moved to handlers_document.go + +// extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter +// if provided by the client; when absent it returns zeros. +// extractTriggerInfo moved to handlers_completion.go + +// --- in-editor chat (";C ...") --- + +// detectAndHandleChat scans the current document for any line that starts with +// ";C" and appears to be awaiting a response (i.e., followed by a blank line +// and no non-empty answer line yet). If found, it asks the LLM and inserts the +// answer below the blank line, leaving exactly one empty line between prompt +// and response. +// detectAndHandleChat moved to handlers_document.go + +// applyChatEdits removes the triggering punctuation at end of the line and +// inserts two newlines followed by a new line with the response prefixed. +// applyChatEdits moved to handlers_document.go + +// buildChatHistory walks upwards from the current line to collect the most recent +// Q/A pairs in the in-editor transcript. It returns messages in chronological order +// ending with the current user prompt. Limits to a small number of pairs to control tokens. +// buildChatHistory moved to handlers_document.go + +// stripTrailingTrigger removes a single trailing punctuation from the set +// [?,!,:] or both semicolons if present at end, mirroring the inline trigger rules. +// stripTrailingTrigger moved to handlers_document.go + +// clientApplyEdit sends a workspace/applyEdit request to the client. +// clientApplyEdit moved to handlers_document.go + +// nextReqID returns a unique json.RawMessage id for server-initiated requests. +// nextReqID moved to handlers_document.go + +// --- completion helpers --- + +// buildDocString moved to handlers_completion.go + +// logCompletionContext moved to handlers_completion.go + +// tryLLMCompletion moved to handlers_completion.go + +// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. +// parseManualInvoke moved to handlers_completion.go + +// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. +// shouldSuppressForChatTriggerEOL moved to handlers_completion.go + +// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. +// prefixHeuristicAllows moved to handlers_completion.go + +// tryProviderNativeCompletion attempts provider-native completion and returns items when successful. +// tryProviderNativeCompletion moved to handlers_completion.go + +// buildCompletionMessages constructs the LLM messages for completion. +// buildCompletionMessages moved to handlers_completion.go + +// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. +// postProcessCompletion moved to handlers_completion.go + +// busyCompletionItem builds a visible, non-inserting completion item indicating +// that an LLM request is already in flight. +// removed: previous single in-flight LLM busy gate and busy item + +// --- 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="10">{ + // 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="10">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="10">prov := "" + model := "" + if s.llmClient != nil </span><span class="cov7" title="10">{ + prov = s.llmClient.Name() + model = s.llmClient.DefaultModel() + }</span> + <span class="cov7" title="10">temp := "" + if s.codingTemperature != nil </span><span class="cov0" title="0">{ + temp = fmt.Sprintf("%.3f", *s.codingTemperature) + }</span> + <span class="cov7" title="10">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="10">return strings.Join([]string{ + "v1", // version for future-proofing + prov, + model, + temp, + p.TextDocument.URI, + fmt.Sprintf("%d:%d", p.Position.Line, len(left)), + above, + left, + right, + below, + funcCtx, + fmt.Sprintf("params=%t", inParams), + extra, + }, "\x1f")</span> // use unit separator to avoid collisions +} + +func (s *Server) completionCacheGet(key string) (string, bool) <span class="cov7" title="9">{ + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.compCache[key] + if !ok </span><span class="cov6" title="8">{ + return "", false + }</span> + // move to most-recent + <span class="cov1" title="1">s.compCacheTouchLocked(key) + return v, true</span> +} + +func (s *Server) completionCachePut(key, value string) <span class="cov6" title="8">{ + s.mu.Lock() + defer s.mu.Unlock() + if s.compCache == nil </span><span class="cov0" title="0">{ + s.compCache = make(map[string]string) + }</span> + <span class="cov6" title="8">if _, exists := s.compCache[key]; !exists </span><span class="cov6" title="8">{ + s.compCacheOrder = append(s.compCacheOrder, key) + s.compCache[key] = value + if len(s.compCacheOrder) > 10 </span><span class="cov0" title="0">{ + // evict oldest + old := s.compCacheOrder[0] + s.compCacheOrder = s.compCacheOrder[1:] + delete(s.compCache, old) + }</span> + <span class="cov6" title="8">return</span> + } + // update existing and mark most-recent + <span class="cov0" title="0">s.compCache[key] = value + s.compCacheTouchLocked(key)</span> +} + +func (s *Server) compCacheTouchLocked(key string) <span class="cov1" title="1">{ + // assumes s.mu is held + // remove any existing occurrence of key in order slice + idx := -1 + for i, k := range s.compCacheOrder </span><span class="cov1" title="1">{ + if k == key </span><span class="cov1" title="1">{ + idx = i + break</span> + } + } + <span class="cov1" title="1">if idx >= 0 </span><span class="cov1" title="1">{ + s.compCacheOrder = append(append([]string{}, s.compCacheOrder[:idx]...), s.compCacheOrder[idx+1:]...) + }</span> + <span class="cov1" title="1">s.compCacheOrder = append(s.compCacheOrder, key)</span> +} + +// isTriggerEvent returns true when the completion request appears to be caused +// 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="16">{ + // 1) Inspect LSP completion context if present + if p.Context != nil </span><span class="cov5" title="5">{ + 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="5">{ + _ = json.Unmarshal(raw, &ctx) + }</span> else<span class="cov0" title="0"> { + b, _ := json.Marshal(p.Context) + _ = json.Unmarshal(b, &ctx) + }</span> + // If the line contains a bare ';;' (no ';;text;'), do not treat as a trigger source. + <span class="cov5" title="5">if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) </span><span class="cov1" title="1">{ + return false + }</span> + // TriggerKind 1 = Invoked (manual). Always allow manual invoke. + <span class="cov4" title="4">if ctx.TriggerKind == 1 </span><span class="cov4" title="4">{ + return true + }</span> + // TriggerKind 2 is TriggerCharacter per LSP spec + <span class="cov0" title="0">if ctx.TriggerKind == 2 </span><span class="cov0" title="0">{ + if ctx.TriggerCharacter != "" </span><span class="cov0" title="0">{ + for _, c := range s.triggerChars </span><span class="cov0" title="0">{ + if c == ctx.TriggerCharacter </span><span class="cov0" title="0">{ + return true + }</span> + } + <span class="cov0" title="0">return false</span> + } + // No character provided but reported as TriggerCharacter; be conservative + <span class="cov0" title="0">return false</span> + } + // For TriggerForIncomplete (3), require manual char check below + } + // 2) Fallback: check the character immediately prior to cursor + <span class="cov7" title="11">idx := p.Position.Character + if idx <= 0 || idx > len(current) </span><span class="cov0" title="0">{ + return false + }</span> + // Bare ';;' should not trigger via fallback char either + <span class="cov7" title="11">if strings.Contains(current, ";;") && !hasDoubleSemicolonTrigger(current) </span><span class="cov2" title="2">{ + return false + }</span> + <span class="cov7" title="9">ch := string(current[idx-1]) + for _, c := range s.triggerChars </span><span class="cov10" title="24">{ + if c == ch </span><span class="cov4" title="4">{ + return true + }</span> + } + <span class="cov5" title="5">return false</span> +} + +func (s *Server) makeCompletionItems(cleaned string, inParams bool, current string, p CompletionParams, docStr string) []CompletionItem <span class="cov7" title="9">{ + 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="cov7" title="9">{ + detail = "Hexai " + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + }</span> + <span class="cov7" title="9">return []CompletionItem{{ + Label: label, + Kind: 1, + Detail: detail, + InsertTextFormat: 1, + FilterText: strings.TrimLeft(filter, " \t"), + TextEdit: te, + AdditionalTextEdits: rm, + SortText: "0000", + Documentation: docStr, + }}</span> +} + +// small helpers to keep tryLLMCompletion short +// LLM stats helpers moved to handlers_utils.go + +// collectPromptRemovalEdits returns edits to remove all inline prompt markers. +// Supported form (inclusive): +// - ";...;" where there is no space immediately after the first ';' +// and no space immediately before the last ';'. An optional single space +// after the trailing ';' is also removed for cleanliness. +// +// Multiple markers per line are supported. +// Inline prompt removal helpers moved to handlers_utils.go + +// inParamList moved to handlers_utils.go + +// buildPrompts moved to handlers_utils.go + +// computeTextEditAndFilter moved to handlers_utils.go + +// computeWordStart moved to handlers_utils.go + +// isIdentChar moved to handlers_utils.go + +// lineHasInlinePrompt returns true if the line contains an inline strict +// semicolon marker ;text; (no spaces at boundaries) or a double-semicolon +// pattern recognized by hasDoubleSemicolonTrigger. +// lineHasInlinePrompt moved to handlers_utils.go + +// leadingIndent returns the run of leading spaces/tabs from the provided line. +// leadingIndent moved to handlers_utils.go + +// applyIndent prefixes each non-empty line of suggestion with the given indent +// unless it already starts with that indent. +// applyIndent moved to handlers_utils.go + +// 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;". +// isBareDoubleSemicolon moved to handlers_utils.go + +// stripDuplicateAssignmentPrefix removes a duplicated assignment prefix (e.g., +// "name :=") from the beginning of the model suggestion when that same prefix +// already appears immediately to the left of the cursor on the current line. +// Also handles simple '=' assignments. +// stripDuplicateAssignmentPrefix moved to handlers_utils.go + +// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated +// at the beginning of its suggestion. It compares the entire text to the left of the +// cursor (prefixBeforeCursor) against the suggestion, trimming whitespace appropriately, +// and strips the longest sensible overlap. This prevents cases like: +// +// prefix: "func New " +// suggestion:"func New() *Type" +// +// resulting in duplicates like "func New func New() *Type". +// stripDuplicateGeneralPrefix moved to handlers_utils.go + +// isIdentBoundary moved to handlers_utils.go + +// stripCodeFences removes surrounding Markdown code fences from a model +// response when the entire output is wrapped, e.g. starting with "```go" or +// "```" and ending with "```". It returns the inner content unchanged. +// stripCodeFences moved to handlers_utils.go + +// stripInlineCodeSpan returns only the contents of the first inline backtick +// code span if present, e.g., "some text `x := y()` more" -> "x := y()". +// If no matching pair of backticks exists, it returns the input unchanged. +// This is intended for code completion responses where the model may wrap a +// small snippet in single backticks among prose. +// stripInlineCodeSpan moved to handlers_utils.go + +// labelForCompletion moved to handlers_utils.go + +func (s *Server) fallbackCompletionItems(docStr string) []CompletionItem <span class="cov0" title="0">{ + return []CompletionItem{{ + Label: "hexai-complete", + Kind: 1, + Detail: "dummy completion", + InsertText: "hexai", + SortText: "9999", + Documentation: docStr, + }} +}</span> +</pre> + + <pre class="file" id="file15" style="display: none">// Summary: Code Action handlers and helpers split from handlers.go for clarity. +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "strings" + "time" + "os" + "path/filepath" +) + +func (s *Server) handleCodeAction(req Request) <span class="cov0" title="0">{ + var p CodeActionParams + if err := json.Unmarshal(req.Params, &p); err != nil </span><span class="cov0" title="0">{ + if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, []CodeAction{}, nil) + }</span> + <span class="cov0" title="0">return</span> + } + <span class="cov0" title="0">d := s.getDocument(p.TextDocument.URI) + if d == nil || len(d.lines) == 0 || s.llmClient == nil </span><span class="cov0" title="0">{ + if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, []CodeAction{}, nil) + }</span> + <span class="cov0" title="0">return</span> + } + <span class="cov0" title="0">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="cov0" title="0">if a := s.buildDiagnosticsCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ + actions = append(actions, *a) + }</span> + <span class="cov0" title="0">if a := s.buildDocumentCodeAction(p, sel); a != nil </span><span class="cov0" title="0">{ + actions = append(actions, *a) + }</span> + <span class="cov0" title="0">if a := s.buildGoUnitTestCodeAction(p); a != nil </span><span class="cov0" title="0">{ + actions = append(actions, *a) + }</span> + <span class="cov0" title="0">if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, actions, nil) + }</span> +} + +func (s *Server) buildRewriteCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov5" title="2">{ + if instr, cleaned := instructionFromSelection(sel); strings.TrimSpace(instr) != "" </span><span class="cov1" title="1">{ + payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Instruction string `json:"instruction"` + Selection string `json:"selection"` + }{Type: "rewrite", URI: p.TextDocument.URI, Range: p.Range, Instruction: instr, Selection: cleaned} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: rewrite selection", Kind: "refactor.rewrite", Data: raw} + return &ca + }</span> + <span class="cov1" title="1">return nil</span> +} + +func (s *Server) buildDiagnosticsCodeAction(p CodeActionParams, sel string) *CodeAction <span class="cov5" title="2">{ + diags := s.diagnosticsInRange(p.Context, p.Range) + if len(diags) == 0 </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov1" title="1">payload := struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics"` + }{Type: "diagnostics", URI: p.TextDocument.URI, Range: p.Range, Selection: sel, Diagnostics: diags} + raw, _ := json.Marshal(payload) + ca := CodeAction{Title: "Hexai: resolve diagnostics", Kind: "quickfix", Data: raw} + return &ca</span> +} + +func (s *Server) resolveCodeAction(ca CodeAction) (CodeAction, bool) <span class="cov5" title="2">{ + if s.llmClient == nil || len(ca.Data) == 0 </span><span class="cov0" title="0">{ + return ca, false + }</span> + <span class="cov5" title="2">var payload struct { + Type string `json:"type"` + URI string `json:"uri"` + Range Range `json:"range"` + Instruction string `json:"instruction,omitempty"` + Selection string `json:"selection"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + } + if err := json.Unmarshal(ca.Data, &payload); err != nil </span><span class="cov0" title="0">{ + return ca, false + }</span> + <span class="cov5" title="2">switch payload.Type </span>{ + case "rewrite":<span class="cov1" title="1"> + 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) + 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="cov1" title="1">{ + if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov1" title="1">{ + 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 rewrite llm error: %v", err) + }</span> + case "diagnostics":<span class="cov1" title="1"> + sys := "You are a precise code fixer. Resolve the given diagnostics by editing only the selected code. Return only the corrected code with no prose or backticks. Keep behavior and style, and avoid unrelated changes." + var b strings.Builder + b.WriteString("Diagnostics to resolve (selection only):\n") + for i, dgn := range payload.Diagnostics </span><span class="cov1" title="1">{ + if dgn.Source != "" </span><span class="cov0" title="0">{ + fmt.Fprintf(&b, "%d. [%s] %s\n", i+1, dgn.Source, dgn.Message) + }</span> else<span class="cov1" title="1"> { + fmt.Fprintf(&b, "%d. %s\n", i+1, dgn.Message) + }</span> + } + <span class="cov1" title="1">b.WriteString("\nSelected code:\n") + b.WriteString(payload.Selection) + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second) + defer cancel() + messages := []llm.Message{{Role: "system", Content: sys}, {Role: "user", Content: b.String()}} + opts := s.llmRequestOpts() + if text, err := s.llmClient.Chat(ctx, messages, opts...); err == nil </span><span class="cov1" title="1">{ + if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov1" title="1">{ + 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 diagnostics llm error: %v", err) + }</span> + case "document":<span class="cov0" title="0"> + 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="cov0" title="0">{ + if out := stripCodeFences(strings.TrimSpace(text)); out != "" </span><span class="cov0" title="0">{ + 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="cov0" title="0">{ + var ca CodeAction + if err := json.Unmarshal(req.Params, &ca); err != nil </span><span class="cov0" title="0">{ + if len(req.ID) != 0 </span><span class="cov0" title="0">{ + s.reply(req.ID, ca, nil) + }</span> + <span class="cov0" title="0">return</span> + } + <span class="cov0" title="0">if resolved, ok := s.resolveCodeAction(ca); ok </span><span class="cov0" title="0">{ + s.reply(req.ID, resolved, nil) + return + }</span> + <span class="cov0" title="0">s.reply(req.ID, ca, nil)</span> +} + +// diagnosticsInRange parses the CodeAction context and returns diagnostics +// that overlap the given selection range. If the context is missing or does +// not contain diagnostics, returns an empty slice. +func (s *Server) diagnosticsInRange(ctxRaw json.RawMessage, sel Range) []Diagnostic <span class="cov5" title="2">{ + if len(ctxRaw) == 0 </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov1" title="1">var ctx CodeActionContext + if err := json.Unmarshal(ctxRaw, &ctx); err != nil </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov1" title="1">if len(ctx.Diagnostics) == 0 </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov1" title="1">out := make([]Diagnostic, 0, len(ctx.Diagnostics)) + for _, d := range ctx.Diagnostics </span><span class="cov5" title="2">{ + if rangesOverlap(d.Range, sel) </span><span class="cov1" title="1">{ + out = append(out, d) + }</span> + } + <span class="cov1" title="1">return out</span> +} + +// rangesOverlap reports whether two LSP ranges overlap at all. +func rangesOverlap(a, b Range) bool <span class="cov5" title="2">{ + // Normalize ordering + if greaterPos(a.Start, a.End) </span><span class="cov0" title="0">{ + a.Start, a.End = a.End, a.Start + }</span> + <span class="cov5" title="2">if greaterPos(b.Start, b.End) </span><span class="cov0" title="0">{ + b.Start, b.End = b.End, b.Start + }</span> + // a ends before b starts + <span class="cov5" title="2">if lessPos(a.End, b.Start) </span><span class="cov1" title="1">{ + return false + }</span> + // b ends before a starts + <span class="cov1" title="1">if lessPos(b.End, a.Start) </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov1" title="1">return true</span> +} + +func lessPos(p, q Position) bool <span class="cov8" title="3">{ + if p.Line != q.Line </span><span class="cov8" title="3">{ + return p.Line < q.Line + }</span> + <span class="cov0" title="0">return p.Character < q.Character</span> +} + +func greaterPos(p, q Position) bool <span class="cov10" title="4">{ + if p.Line != q.Line </span><span class="cov8" title="3">{ + return p.Line > q.Line + }</span> + <span class="cov1" title="1">return p.Character > q.Character</span> +} + +// --- Go unit test code action --- + +func (s *Server) buildGoUnitTestCodeAction(p CodeActionParams) *CodeAction <span class="cov0" title="0">{ + 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="cov0" title="0">if strings.HasSuffix(strings.TrimPrefix(uri, "file://"), "_test.go") </span><span class="cov0" title="0">{ + return nil + }</span> + // Heuristic: only offer when a function context is found above the cursor + <span class="cov0" title="0">_, _, _, funcCtx := s.lineContext(uri, p.Range.Start) + if !strings.Contains(funcCtx, "func ") </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov0" title="0">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="cov0" title="0">{ + if s.llmClient == nil </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov0" title="0">if strings.TrimSpace(sel) == "" </span><span class="cov0" title="0">{ + return nil + }</span> + <span class="cov0" title="0">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="cov0" title="0">{ + 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="cov0" title="0">_, lines := s.loadFileText(uri) + if len(lines) == 0 </span><span class="cov0" title="0">{ + return WorkspaceEdit{}, "", Range{}, false + }</span> + <span class="cov0" title="0">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="cov0" title="0">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="cov0" title="0">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="cov0" title="0">{ + // 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="cov0" title="0">{ + lineIdx = len(tLines) - 1 + col = len(tLines[lineIdx]) + }</span> + <span class="cov0" title="0">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="cov0" title="0">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="cov0" title="0">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="cov0" title="0">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="cov0" title="0">var content strings.Builder + if pkg == "" </span><span class="cov0" title="0">{ pkg = filepath.Base(filepath.Dir(path)) }</span> + <span class="cov0" title="0">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="cov0" title="0">{ + before := pre[:idx] + startLine = strings.Count(before, "\n") + }</span> + <span class="cov0" title="0">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="cov0" title="0">{ + if d := s.getDocument(uri); d != nil </span><span class="cov0" title="0">{ + return d.text, append([]string{}, d.lines...) + }</span> + <span class="cov0" title="0">path := strings.TrimPrefix(uri, "file://") + b, err := os.ReadFile(path) + if err != nil </span><span class="cov0" title="0">{ + return "", nil + }</span> + <span class="cov0" title="0">txt := string(b) + return txt, splitLines(txt)</span> +} + +func fileExists(path string) bool <span class="cov0" title="0">{ + if _, err := os.Stat(path); err == nil </span><span class="cov0" title="0">{ + return true + }</span> + <span class="cov0" title="0">return false</span> +} + +// parseGoPackageName returns the package name from file lines, or empty if not found. +func parseGoPackageName(lines []string) string <span class="cov0" title="0">{ + for _, ln := range lines </span><span class="cov0" title="0">{ + t := strings.TrimSpace(ln) + if strings.HasPrefix(t, "package ") </span><span class="cov0" title="0">{ + name := strings.TrimSpace(strings.TrimPrefix(t, "package ")) + // strip inline comments + if i := strings.Index(name, " "); i >= 0 </span><span class="cov0" title="0">{ name = name[:i] }</span> + <span class="cov0" title="0">if i := strings.Index(name, "\t"); i >= 0 </span><span class="cov0" title="0">{ name = name[:i] }</span> + <span class="cov0" title="0">if i := strings.Index(name, "//"); i >= 0 </span><span class="cov0" title="0">{ name = strings.TrimSpace(name[:i]) }</span> + <span class="cov0" title="0">return name</span> + } + } + <span class="cov0" title="0">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="cov0" title="0">{ + if idx < 0 </span><span class="cov0" title="0">{ idx = 0 }</span> + <span class="cov0" title="0">if idx >= len(lines) </span><span class="cov0" title="0">{ idx = len(lines)-1 }</span> + // find signature start + <span class="cov0" title="0">start := -1 + for i := idx; i >= 0; i-- </span><span class="cov0" title="0">{ + if strings.Contains(lines[i], "func ") </span><span class="cov0" title="0">{ + start = i + break</span> + } + <span class="cov0" title="0">if strings.Contains(lines[i], "}") </span><span class="cov0" title="0">{ + break</span> + } + } + <span class="cov0" title="0">if start == -1 </span><span class="cov0" title="0">{ return -1, -1 }</span> + // find first '{' + <span class="cov0" title="0">depth := 0 + seenOpen := false + for i := start; i < len(lines); i++ </span><span class="cov0" title="0">{ + ln := lines[i] + for j := 0; j < len(ln); j++ </span><span class="cov0" title="0">{ + switch ln[j] </span>{ + case '{':<span class="cov0" title="0"> + depth++ + seenOpen = true</span> + case '}':<span class="cov0" title="0"> + if depth > 0 </span><span class="cov0" title="0">{ depth-- }</span> + <span class="cov0" title="0">if seenOpen && depth == 0 </span><span class="cov0" title="0">{ + 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> +} + +// generateGoTestFunction uses LLM to produce a test function; falls back to a stub when unavailable. +func (s *Server) generateGoTestFunction(funcCode string) string <span class="cov0" title="0">{ + if s.llmClient != nil </span><span class="cov0" title="0">{ + 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="cov0" title="0">{ + cleaned := strings.TrimSpace(stripCodeFences(out)) + if cleaned != "" </span><span class="cov0" title="0">{ return cleaned }</span> + } else<span class="cov0" title="0"> { + logging.Logf("lsp ", "codeAction go_test llm error: %v", err) + }</span> + } + // Fallback stub + <span class="cov0" title="0">name := deriveGoFuncName(funcCode) + if name == "" </span><span class="cov0" title="0">{ name = "Function" }</span> + <span class="cov0" title="0">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="cov0" title="0">{ + // 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="cov0" title="0">rest := strings.TrimSpace(strings.TrimPrefix(line, "func ")) + // method receiver + if strings.HasPrefix(rest, "(") </span><span class="cov0" title="0">{ + // find ")" + if i := strings.Index(rest, ")"); i >= 0 && i+1 < len(rest) </span><span class="cov0" title="0">{ + rest = strings.TrimSpace(rest[i+1:]) + }</span> + } + // now rest should start with Name( + <span class="cov0" title="0">if i := strings.Index(rest, "("); i > 0 </span><span class="cov0" title="0">{ + return strings.TrimSpace(rest[:i]) + }</span> + <span class="cov0" title="0">return ""</span> +} + +func exportName(name string) string <span class="cov0" title="0">{ + if name == "" </span><span class="cov0" title="0">{ return name }</span> + <span class="cov0" title="0">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="cov0" title="0">return string(r)</span> +} +</pre> + + <pre class="file" id="file16" style="display: none">// Summary: Completion handlers split from handlers.go to reduce file size and isolate feature logic. +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "strings" + "time" +) + +func (s *Server) handleCompletion(req Request) <span class="cov0" title="0">{ + var p CompletionParams + var docStr string + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov0" title="0">{ + // Log trigger information for every completion request from client + tk, tch := extractTriggerInfo(p) + logging.Logf("lsp ", "completion trigger kind=%d char=%q uri=%s line=%d char=%d", + tk, tch, p.TextDocument.URI, p.Position.Line, p.Position.Character) + above, current, below, funcCtx := s.lineContext(p.TextDocument.URI, p.Position) + docStr = s.buildDocString(p, above, current, below, funcCtx) + if s.logContext </span><span class="cov0" title="0">{ + s.logCompletionContext(p, above, current, below, funcCtx) + }</span> + <span class="cov0" title="0">if s.llmClient != nil </span><span class="cov0" title="0">{ + newFunc := s.isDefiningNewFunction(p.TextDocument.URI, p.Position) + extra, has := s.buildAdditionalContext(newFunc, p.TextDocument.URI, p.Position) + items, ok := s.tryLLMCompletion(p, above, current, below, funcCtx, docStr, has, extra) + if ok </span><span class="cov0" title="0">{ + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil) + return + }</span> + } + } + <span class="cov0" title="0">items := s.fallbackCompletionItems(docStr) + s.reply(req.ID, CompletionList{IsIncomplete: false, Items: items}, nil)</span> +} + +// extractTriggerInfo returns the LSP completion TriggerKind and TriggerCharacter +// if provided by the client; when absent it returns zeros. +func extractTriggerInfo(p CompletionParams) (kind int, ch string) <span class="cov0" title="0">{ + if p.Context == nil </span><span class="cov0" title="0">{ + return 0, "" + }</span> + <span class="cov0" title="0">var ctx struct { + TriggerKind int `json:"triggerKind"` + TriggerCharacter string `json:"triggerCharacter,omitempty"` + } + if raw, ok := p.Context.(json.RawMessage); ok </span><span class="cov0" title="0">{ + _ = json.Unmarshal(raw, &ctx) + }</span> else<span class="cov0" title="0"> { + b, _ := json.Marshal(p.Context) + _ = json.Unmarshal(b, &ctx) + }</span> + <span class="cov0" title="0">return ctx.TriggerKind, ctx.TriggerCharacter</span> +} + +// --- completion helpers --- + +func (s *Server) buildDocString(p CompletionParams, above, current, below, funcCtx string) string <span class="cov0" title="0">{ + return fmt.Sprintf("file: %s\nline: %d\nabove: %s\ncurrent: %s\nbelow: %s\nfunction: %s", + p.TextDocument.URI, p.Position.Line, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) +}</span> + +func (s *Server) logCompletionContext(p CompletionParams, above, current, below, funcCtx string) <span class="cov0" title="0">{ + logging.Logf("lsp ", "completion ctx uri=%s line=%d char=%d above=%q current=%q below=%q function=%q", + p.TextDocument.URI, p.Position.Line, p.Position.Character, trimLen(above), trimLen(current), trimLen(below), trimLen(funcCtx)) +}</span> + +func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, funcCtx, docStr string, hasExtra bool, extraText string) ([]CompletionItem, bool) <span class="cov10" title="17">{ + 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">{ + logging.Logf("lsp ", "%scompletion skip=no-trigger 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 s.shouldSuppressForChatTriggerEOL(current, p) </span><span class="cov0" title="0">{ + return []CompletionItem{}, true + }</span> + + <span class="cov7" title="9">inParams := inParamList(current, p.Position.Character) + manualInvoke := parseManualInvoke(p.Context) + + // Cache fast-path + key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) + if cleaned, ok := s.completionCacheGet(key); ok && strings.TrimSpace(cleaned) != "" </span><span class="cov1" title="1">{ + logging.Logf("lsp ", "completion cache hit uri=%s line=%d char=%d preview=%s%s%s", + p.TextDocument.URI, p.Position.Line, p.Position.Character, + logging.AnsiGreen, logging.PreviewForLog(cleaned), logging.AnsiBase) + return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true + }</span> + <span class="cov7" title="8">if (isBareDoubleSemicolon(current) || isBareDoubleSemicolon(below)) && !manualInvoke </span><span class="cov0" title="0">{ + 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> + + // Chat path + <span class="cov7" title="7">messages := s.buildCompletionMessages(inlinePrompt, hasExtra, extraText, inParams, p, above, current, below, funcCtx) + // Counters and options + sentSize := 0 + for _, m := range messages </span><span class="cov9" title="14">{ + sentSize += len(m.Content) + }</span> + <span class="cov7" title="7">s.incSentCounters(sentSize) + 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> + // 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">{ + logging.Logf("lsp ", "llm completion error: %v", err) + s.logLLMStats() + return nil, false + }</span> + <span class="cov7" title="7">s.incRecvCounters(len(text)) + s.logLLMStats() + + cleaned := s.postProcessCompletion(strings.TrimSpace(text), current[:p.Position.Character], current) + if cleaned == "" </span><span class="cov0" title="0">{ + return nil, false + }</span> + <span class="cov7" title="7">s.completionCachePut(key, cleaned) + return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true</span> +} + +// parseManualInvoke inspects the LSP completion context and reports whether the user manually invoked completion. +func parseManualInvoke(ctx any) bool <span class="cov7" title="9">{ + if ctx == nil </span><span class="cov6" title="5">{ + return false + }</span> + <span class="cov5" title="4">var c struct { + TriggerKind int `json:"triggerKind"` + } + if raw, ok := ctx.(json.RawMessage); ok </span><span class="cov5" title="4">{ + _ = json.Unmarshal(raw, &c) + }</span> else<span class="cov0" title="0"> { + b, _ := json.Marshal(ctx) + _ = json.Unmarshal(b, &c) + }</span> + <span class="cov5" title="4">return c.TriggerKind == 1</span> +} + +// shouldSuppressForChatTriggerEOL returns true when a chat trigger like ">" follows ?, !, :, or ; at EOL. +func (s *Server) shouldSuppressForChatTriggerEOL(current string, p CompletionParams) bool <span class="cov7" title="9">{ + if t := strings.TrimRight(current, " \t"); len(t) >= 2 && t[len(t)-1] == '>' </span><span class="cov0" title="0">{ + prev := t[len(t)-2] + if prev == '?' || prev == '!' || prev == ':' || prev == ';' </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "completion skip=chat-trigger-eol uri=%s line=%d", p.TextDocument.URI, p.Position.Line) + return true + }</span> + } + <span class="cov7" title="9">return false</span> +} + +// prefixHeuristicAllows applies minimal prefix rules unless inlinePrompt or structural triggers apply. +func (s *Server) prefixHeuristicAllows(inlinePrompt bool, current string, p CompletionParams, manualInvoke bool) bool <span class="cov7" title="8">{ + // Determine the effective cursor index within current line, clamped, and + // skip over trailing spaces/tabs to support cases like "type Matrix| ". + idx := p.Position.Character + if idx > len(current) </span><span class="cov0" title="0">{ + idx = len(current) + }</span> + <span class="cov7" title="8">allowNoPrefix := inlinePrompt + if idx > 0 </span><span class="cov7" title="8">{ + ch := current[idx-1] + if ch == '.' || ch == ':' || ch == '/' || ch == '_' || ch == ')' </span><span class="cov3" title="2">{ + allowNoPrefix = true + }</span> + } + <span class="cov7" title="8">if allowNoPrefix </span><span class="cov4" title="3">{ + return true + }</span> + // Walk left over whitespace + <span class="cov6" title="5">j := idx + for j > 0 </span><span class="cov8" title="12">{ + c := current[j-1] + if c == ' ' || c == '\t' </span><span class="cov7" title="7">{ + j-- + continue</span> + } + <span class="cov6" title="5">break</span> + } + <span class="cov6" title="5">start := computeWordStart(current, j) + min := 1 + if manualInvoke && s.manualInvokeMinPrefix >= 0 </span><span class="cov5" title="4">{ + min = s.manualInvokeMinPrefix + }</span> + <span class="cov6" title="5">return j-start >= min</span> +} + +// 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="8">{ + cc, ok := s.llmClient.(llm.CodeCompleter) + if !ok </span><span class="cov6" title="6">{ + return nil, false + }</span> + <span class="cov3" title="2">before, after := s.docBeforeAfter(p.TextDocument.URI, p.Position) + path := strings.TrimPrefix(p.TextDocument.URI, "file://") + prompt := "// Path: " + path + "\n" + before + lang := "" + temp := 0.0 + if s.codingTemperature != nil </span><span class="cov0" title="0">{ + temp = *s.codingTemperature + }</span> + <span class="cov3" title="2">prov := "" + if s.llmClient != nil </span><span class="cov3" title="2">{ + prov = s.llmClient.Name() + }</span> + <span class="cov3" title="2">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="cov3" title="2">suggestions, err := cc.CodeCompletion(ctx2, prompt, after, 1, lang, temp) + if err == nil && len(suggestions) > 0 </span><span class="cov1" title="1">{ + cleaned := strings.TrimSpace(suggestions[0]) + if cleaned != "" </span><span class="cov1" title="1">{ + cleaned = stripDuplicateAssignmentPrefix(current[:p.Position.Character], cleaned) + if cleaned != "" </span><span class="cov1" title="1">{ + cleaned = stripDuplicateGeneralPrefix(current[:p.Position.Character], cleaned) + }</span> + <span class="cov1" title="1">if cleaned != "" && hasDoubleSemicolonTrigger(current) </span><span class="cov0" title="0">{ + indent := leadingIndent(current) + if indent != "" </span><span class="cov0" title="0">{ + cleaned = applyIndent(indent, cleaned) + }</span> + } + <span class="cov1" title="1">if strings.TrimSpace(cleaned) != "" </span><span class="cov1" title="1">{ + key := s.completionCacheKey(p, above, current, below, funcCtx, inParams, hasExtra, extraText) + s.completionCachePut(key, cleaned) + return s.makeCompletionItems(cleaned, inParams, current, p, docStr), true + }</span> + } + } else<span class="cov1" title="1"> if err != nil </span><span class="cov1" title="1">{ + logging.Logf("lsp ", "completion path=codex error=%v (falling back to chat)", err) + }</span> + <span class="cov1" title="1">return nil, false</span> +} + +// 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="cov7" title="9">{ + d := s.completionDebounce + if d <= 0 </span><span class="cov7" title="8">{ + 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 + } + } +} + +// 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="cov7" title="9">{ + interval := s.throttleInterval + if interval <= 0 </span><span class="cov7" title="7">{ + 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> + } + } + // 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. +func (s *Server) buildCompletionMessages(inlinePrompt, hasExtra bool, extraText string, inParams bool, p CompletionParams, above, current, below, funcCtx string) []llm.Message <span class="cov7" title="7">{ + sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx) + messages := []llm.Message{ + {Role: "system", Content: sysPrompt}, + {Role: "user", Content: userPrompt}, + } + if hasExtra && extraText != "" </span><span class="cov0" title="0">{ + messages = append(messages, llm.Message{Role: "user", Content: "Additional context:\n" + extraText}) + }</span> + <span class="cov7" title="7">if inlinePrompt </span><span class="cov1" title="1">{ + messages[0].Content = "You are a precise code completion/refactoring engine. Output only the code to insert with no prose, no comments, and no backticks. Return raw code only." + }</span> + <span class="cov7" title="7">return messages</span> +} + +// postProcessCompletion normalizes and deduplicates completion text and applies indentation rules. +func (s *Server) postProcessCompletion(text string, leftOfCursor string, currentLine string) string <span class="cov7" title="7">{ + cleaned := stripCodeFences(text) + if cleaned != "" && strings.ContainsRune(cleaned, '`') </span><span class="cov0" title="0">{ + if inline := stripInlineCodeSpan(cleaned); strings.TrimSpace(inline) != "" </span><span class="cov0" title="0">{ + cleaned = inline + }</span> + } + <span class="cov7" title="7">if cleaned != "" </span><span class="cov7" title="7">{ + cleaned = stripDuplicateAssignmentPrefix(leftOfCursor, cleaned) + }</span> + <span class="cov7" title="7">if cleaned != "" </span><span class="cov7" title="7">{ + cleaned = stripDuplicateGeneralPrefix(leftOfCursor, cleaned) + }</span> + <span class="cov7" title="7">if cleaned != "" && hasDoubleSemicolonTrigger(currentLine) </span><span class="cov0" title="0">{ + if indent := leadingIndent(currentLine); indent != "" </span><span class="cov0" title="0">{ + cleaned = applyIndent(indent, cleaned) + }</span> + } + <span class="cov7" title="7">return cleaned</span> +} +</pre> + + <pre class="file" id="file17" style="display: none">// Summary: Document open/change/close and in-editor chat handlers split out of handlers.go. +package lsp + +import ( + "context" + "encoding/json" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "strings" + "time" +) + +func (s *Server) handleDidOpen(req Request) <span class="cov0" title="0">{ + var p DidOpenTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov0" title="0">{ + s.setDocument(p.TextDocument.URI, p.TextDocument.Text) + s.markActivity() + }</span> +} + +func (s *Server) handleDidChange(req Request) <span class="cov0" title="0">{ + var p DidChangeTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov0" title="0">{ + if len(p.ContentChanges) > 0 </span><span class="cov0" title="0">{ + s.setDocument(p.TextDocument.URI, p.ContentChanges[len(p.ContentChanges)-1].Text) + }</span> + <span class="cov0" title="0">s.markActivity() + // Detect in-editor chat trigger lines and respond inline. + s.detectAndHandleChat(p.TextDocument.URI)</span> + } +} + +func (s *Server) handleDidClose(req Request) <span class="cov0" title="0">{ + var p DidCloseTextDocumentParams + if err := json.Unmarshal(req.Params, &p); err == nil </span><span class="cov0" title="0">{ + s.deleteDocument(p.TextDocument.URI) + s.markActivity() + }</span> +} + +// 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="cov10" title="2">{ + d := s.getDocument(uri) + if d == nil </span><span class="cov10" title="2">{ + return "", "" + }</span> + // Clamp indices + <span class="cov0" title="0">line := pos.Line + if line < 0 </span><span class="cov0" title="0">{ + line = 0 + }</span> + <span class="cov0" title="0">if line >= len(d.lines) </span><span class="cov0" title="0">{ + line = len(d.lines) - 1 + }</span> + <span class="cov0" title="0">col := pos.Character + if col < 0 </span><span class="cov0" title="0">{ + col = 0 + }</span> + <span class="cov0" title="0">if col > len(d.lines[line]) </span><span class="cov0" title="0">{ + col = len(d.lines[line]) + }</span> + // Build before + <span class="cov0" title="0">var b strings.Builder + for i := 0; i < line; i++ </span><span class="cov0" title="0">{ + b.WriteString(d.lines[i]) + b.WriteByte('\n') + }</span> + <span class="cov0" title="0">b.WriteString(d.lines[line][:col]) + before := b.String() + // Build after + var a strings.Builder + a.WriteString(d.lines[line][col:]) + for i := line + 1; i < len(d.lines); i++ </span><span class="cov0" title="0">{ + a.WriteByte('\n') + a.WriteString(d.lines[i]) + }</span> + <span class="cov0" title="0">return before, a.String()</span> +} + +// --- in-editor chat (";C ...") --- + +// 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="cov0" title="0">{ + if s.llmClient == nil </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">for i, raw := range d.lines </span><span class="cov0" title="0">{ + // Find last non-space character index + j := len(raw) - 1 + for j >= 0 </span><span class="cov0" title="0">{ + if raw[j] == ' ' || raw[j] == '\t' </span><span class="cov0" title="0">{ + j-- + continue</span> + } + <span class="cov0" title="0">break</span> + } + <span class="cov0" title="0">if j < 1 </span><span class="cov0" title="0">{ + continue</span> + } // need at least two chars + <span class="cov0" title="0">pair := raw[j-1 : j+1] + isTrigger := pair == "?>" || pair == "!>" || pair == ":>" || pair == ";>" + 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="cov0" title="0">k := i + 1 + for k < len(d.lines) && strings.TrimSpace(d.lines[k]) == "" </span><span class="cov0" title="0">{ + k++ + }</span> + <span class="cov0" title="0">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="cov0" title="0">removeCount := 1 + base := raw[:j+1-removeCount] + prompt := strings.TrimSpace(base) + if prompt == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">lineIdx := i + lastIdx := j + go func(prompt string, remove int) </span><span class="cov0" title="0">{ + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + sys := "You are a helpful coding assistant. Answer concisely and clearly." + // Build short conversation history from the document above this line + history := s.buildChatHistory(uri, lineIdx, prompt) + msgs := append([]llm.Message{{Role: "system", Content: sys}}, history...) + opts := s.llmRequestOpts() + logging.Logf("lsp ", "chat llm=requesting model=%s", s.llmClient.DefaultModel()) + text, err := s.llmClient.Chat(ctx, msgs, opts...) + if err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "chat llm error: %v", err) + return + }</span> + <span class="cov0" title="0">out := strings.TrimSpace(stripCodeFences(text)) + if out == "" </span><span class="cov0" title="0">{ + return + }</span> + <span class="cov0" title="0">s.applyChatEdits(uri, lineIdx, lastIdx, remove, "> "+out)</span> + }(prompt, removeCount) + // Only handle one per change tick to avoid flooding + <span class="cov0" title="0">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="cov0" title="0">{ + 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="cov0" title="0">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])} + resp := strings.TrimRight(response, "\n") + "\n" + insert := "\n\n" + resp + "\n" + edits := []TextEdit{ + {Range: Range{Start: delStart, End: delEnd}, NewText: ""}, + {Range: Range{Start: insPos, End: insPos}, NewText: insert}, + } + we := WorkspaceEdit{Changes: map[string][]TextEdit{uri: edits}} + s.clientApplyEdit("Hexai: insert chat response", we)</span> +} + +// 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="cov0" title="0">{ + d := s.getDocument(uri) + if d == nil </span><span class="cov0" title="0">{ + return []llm.Message{{Role: "user", Content: currentPrompt}} + }</span> + <span class="cov0" title="0">type pair struct{ q, a string } + pairs := []pair{} + i := lineIdx - 1 + for i >= 0 && len(pairs) < 3 </span><span class="cov0" title="0">{ + for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov0" title="0">{ + i-- + }</span> + <span class="cov0" title="0">if i < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov0" title="0">if !strings.HasPrefix(strings.TrimSpace(d.lines[i]), ">") </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov0" title="0">var replyLines []string + for i >= 0 </span><span class="cov0" title="0">{ + line := strings.TrimSpace(d.lines[i]) + if strings.HasPrefix(line, ">") </span><span class="cov0" title="0">{ + replyLines = append([]string{strings.TrimSpace(strings.TrimPrefix(line, ">"))}, replyLines...) + i-- + continue</span> + } + <span class="cov0" title="0">break</span> + } + <span class="cov0" title="0">for i >= 0 && strings.TrimSpace(d.lines[i]) == "" </span><span class="cov0" title="0">{ + i-- + }</span> + <span class="cov0" title="0">if i < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov0" title="0">q := strings.TrimSpace(d.lines[i]) + q = stripTrailingTrigger(q) + pairs = append([]pair{{q: q, a: strings.Join(replyLines, "\n")}}, pairs...) + i--</span> + } + <span class="cov0" title="0">msgs := make([]llm.Message, 0, len(pairs)*2+1) + for _, p := range pairs </span><span class="cov0" title="0">{ + if strings.TrimSpace(p.q) != "" </span><span class="cov0" title="0">{ + msgs = append(msgs, llm.Message{Role: "user", Content: p.q}) + }</span> + <span class="cov0" title="0">if strings.TrimSpace(p.a) != "" </span><span class="cov0" title="0">{ + msgs = append(msgs, llm.Message{Role: "assistant", Content: p.a}) + }</span> + } + <span class="cov0" title="0">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="cov0" title="0">{ + s := strings.TrimRight(sx, " \t") + if len(s) >= 2 && s[len(s)-1] == '>' </span><span class="cov0" title="0">{ // new triggers + prev := s[len(s)-2] + if prev == '?' || prev == '!' || prev == ':' || prev == ';' </span><span class="cov0" title="0">{ + return strings.TrimRight(s[:len(s)-1], " \t") + }</span> + } + <span class="cov0" title="0">if strings.HasSuffix(s, ";;") </span><span class="cov0" title="0">{ // legacy inline cleanup used in history building + return strings.TrimRight(strings.TrimSuffix(s, ";;"), " \t") + }</span> + <span class="cov0" title="0">if len(s) == 0 </span><span class="cov0" title="0">{ + return sx + }</span> + <span class="cov0" title="0">last := s[len(s)-1] + switch last </span>{ // legacy: remove one trailing punctuation + case '?', '!', ':':<span class="cov0" title="0"> + return strings.TrimRight(s[:len(s)-1], " \t")</span> + default:<span class="cov0" title="0"> + return sx</span> + } +} + +// clientApplyEdit sends a workspace/applyEdit request to the client. +func (s *Server) clientApplyEdit(label string, edit WorkspaceEdit) <span class="cov0" title="0">{ + 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="cov0" title="0">{ + s.mu.Lock() + s.nextID++ + idNum := s.nextID + s.mu.Unlock() + b, _ := json.Marshal(idNum) + return b +}</span> + +// clientShowDocument asks the client to open/focus a document and select a range. +func (s *Server) clientShowDocument(uri string, sel *Range) <span class="cov0" title="0">{ + 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="cov0" title="0">{ + go func() </span><span class="cov0" title="0">{ + time.Sleep(120 * time.Millisecond) + s.clientShowDocument(uri, &sel) + }</span>() +} +</pre> + + <pre class="file" id="file18" style="display: none">// Summary: ExecuteCommand handler to support post-edit navigation (jump to generated test). +package lsp + +import ( + "encoding/json" +) + +func (s *Server) handleExecuteCommand(req Request) <span class="cov0" title="0">{ + 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="cov0" title="0">switch p.Command </span>{ + case "hexai.showDocument":<span class="cov0" title="0"> + if len(p.Arguments) >= 2 </span><span class="cov0" title="0">{ + 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="cov0" title="0">{ + _ = json.Unmarshal(b, &r) + }</span> + <span class="cov0" title="0">if uri != "" </span><span class="cov0" title="0">{ + s.clientShowDocument(uri, &r) + }</span> + } + <span class="cov0" title="0">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 ( + "codeberg.org/snonux/hexai/internal" + "codeberg.org/snonux/hexai/internal/logging" + "os" +) + +func (s *Server) handleInitialize(req Request) <span class="cov0" title="0">{ + version := internal.Version + if s.llmClient != nil </span><span class="cov0" title="0">{ + version = version + " [" + s.llmClient.Name() + ":" + s.llmClient.DefaultModel() + "]" + }</span> + <span class="cov0" title="0">res := InitializeResult{ + Capabilities: ServerCapabilities{ + TextDocumentSync: 1, // 1 = TextDocumentSyncKindFull + CompletionProvider: &CompletionOptions{ + ResolveProvider: false, + TriggerCharacters: s.triggerChars, + }, + CodeActionProvider: CodeActionOptions{ResolveProvider: true}, + }, + ServerInfo: &ServerInfo{Name: "hexai", Version: version}, + } + s.reply(req.ID, res, nil)</span> +} + +func (s *Server) handleInitialized() <span class="cov0" title="0">{ + logging.Logf("lsp ", "client initialized") +}</span> + +func (s *Server) handleShutdown(req Request) <span class="cov0" title="0">{ + s.reply(req.ID, nil, nil) +}</span> + +func (s *Server) handleExit() <span class="cov0" title="0">{ + s.exited = true + os.Exit(0) +}</span> +</pre> + + <pre class="file" id="file20" style="display: none">// Summary: Generic LSP helpers shared across handlers (LLM opts, prompts, text utils, counters). +package lsp + +import ( + "fmt" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "strings" + "time" +) + +// llmRequestOpts builds request options from server settings. +func (s *Server) llmRequestOpts() []llm.RequestOption <span class="cov2" title="2">{ + 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="cov2" title="2">return opts</span> +} + +// small helpers for LLM traffic stats +func (s *Server) incSentCounters(n int) <span class="cov4" title="7">{ + s.mu.Lock() + s.llmReqTotal++ + s.llmSentBytesTotal += int64(n) + s.mu.Unlock() +}</span> + +func (s *Server) incRecvCounters(n int) <span class="cov4" title="7">{ + s.mu.Lock() + s.llmRespTotal++ + s.llmRespBytesTotal += int64(n) + s.mu.Unlock() +}</span> + +func (s *Server) logLLMStats() <span class="cov4" title="7">{ + s.mu.RLock() + avgSent := int64(0) + if s.llmReqTotal > 0 </span><span class="cov4" title="7">{ + avgSent = s.llmSentBytesTotal / s.llmReqTotal + }</span> + <span class="cov4" title="7">avgRecv := int64(0) + if s.llmRespTotal > 0 </span><span class="cov4" title="7">{ + avgRecv = s.llmRespBytesTotal / s.llmRespTotal + }</span> + <span class="cov4" title="7">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 + 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> +} + +// Completion prompt builders and filters +func inParamList(current string, cursor int) bool <span class="cov5" title="9">{ + if !strings.Contains(current, "func ") </span><span class="cov4" title="5">{ + return false + }</span> + <span class="cov3" title="4">open := strings.Index(current, "(") + close := strings.Index(current, ")") + return open >= 0 && cursor > open && (close == -1 || cursor <= close)</span> +} + +func buildPrompts(inParams bool, p CompletionParams, above, current, below, funcCtx string) (string, string) <span class="cov4" title="7">{ + if inParams </span><span class="cov0" title="0">{ + sys := "You are a code completion engine for function signatures. Return only the parameter list contents (without parentheses), no braces, no prose. Prefer idiomatic names and types." + user := fmt.Sprintf("Cursor is inside the function parameter list. Suggest only the parameter list (no parentheses).\nFunction line: %s\nCurrent line (cursor at %d): %s", funcCtx, p.Position.Character, current) + return sys, user + }</span> + <span class="cov4" title="7">sys := "You are a terse code completion engine. Return only the code to insert, no surrounding prose or backticks. Only continue from the cursor; never repeat characters already present to the left of the cursor on the current line (e.g., if 'name :=' is already typed, only return the right-hand side expression)." + user := fmt.Sprintf("Provide the next likely code to insert at the cursor.\nFile: %s\nFunction/context: %s\nAbove line: %s\nCurrent line (cursor at character %d): %s\nBelow line: %s\nOnly return the completion snippet.", p.TextDocument.URI, funcCtx, above, p.Position.Character, current, below) + return sys, user</span> +} + +func computeTextEditAndFilter(cleaned string, inParams bool, current string, p CompletionParams) (*TextEdit, string) <span class="cov5" title="9">{ + if inParams </span><span class="cov0" title="0">{ + open := strings.Index(current, "(") + close := strings.Index(current, ")") + if open >= 0 </span><span class="cov0" title="0">{ + left := open + 1 + right := len(current) + if close >= 0 && close >= left </span><span class="cov0" title="0">{ + right = close + }</span> + <span class="cov0" title="0">if p.Position.Character < right </span><span class="cov0" title="0">{ + right = p.Position.Character + }</span> + <span class="cov0" title="0">te := &TextEdit{Range: Range{Start: Position{Line: p.Position.Line, Character: left}, End: Position{Line: p.Position.Line, Character: right}}, NewText: cleaned} + var filter string + if left >= 0 && right >= left && right <= len(current) </span><span class="cov0" title="0">{ + filter = strings.TrimLeft(current[left:right], " \t") + }</span> + <span class="cov0" title="0">return te, filter</span> + } + } + <span class="cov5" title="9">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="cov6" title="14">{ + if at > len(current) </span><span class="cov0" title="0">{ + at = len(current) + }</span> + <span class="cov6" title="14">for at > 0 </span><span class="cov7" title="22">{ + ch := current[at-1] + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' </span><span class="cov5" title="9">{ + at-- + continue</span> + } + <span class="cov6" title="13">break</span> + } + <span class="cov6" title="14">return at</span> +} + +func isIdentChar(ch byte) bool <span class="cov4" title="7">{ + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' +}</span> + +// Inline prompt utilities +func lineHasInlinePrompt(line string) bool <span class="cov6" title="17">{ + if _, _, _, ok := findStrictSemicolonTag(line); ok </span><span class="cov1" title="1">{ + return true + }</span> + <span class="cov6" title="16">return hasDoubleSemicolonTrigger(line)</span> +} + +func leadingIndent(line string) string <span class="cov0" title="0">{ + i := 0 + for i < len(line) </span><span class="cov0" title="0">{ + if line[i] == ' ' || line[i] == '\t' </span><span class="cov0" title="0">{ + i++ + continue</span> + } + <span class="cov0" title="0">break</span> + } + <span class="cov0" title="0">if i == 0 </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov0" title="0">return line[:i]</span> +} + +func applyIndent(indent, suggestion string) string <span class="cov0" title="0">{ + if indent == "" || suggestion == "" </span><span class="cov0" title="0">{ + return suggestion + }</span> + <span class="cov0" title="0">lines := splitLines(suggestion) + for i, ln := range lines </span><span class="cov0" title="0">{ + if strings.TrimSpace(ln) == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">if strings.HasPrefix(ln, indent) </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">lines[i] = indent + ln</span> + } + <span class="cov0" title="0">return strings.Join(lines, "\n")</span> +} + +// --- Inline marker parsing and general string utilities --- + +// findStrictSemicolonTag finds ;text; with no space after first ';' and no space +// before the last ';' on the given line. Returns the text between semicolons, +// the start index of the opening ';', the end index just after the closing ';', +// and whether it was found. +func findStrictSemicolonTag(line string) (string, int, int, bool) <span class="cov8" title="36">{ + pos := 0 + for pos < len(line) </span><span class="cov8" title="47">{ + j := strings.Index(line[pos:], ";") + if j < 0 </span><span class="cov7" title="23">{ + return "", 0, 0, false + }</span> + <span class="cov7" title="24">j += pos + // ensure single ';' (not ';;') and non-space after + if j+1 >= len(line) || line[j+1] == ';' || line[j+1] == ' ' </span><span class="cov6" title="12">{ + pos = j + 1 + continue</span> + } + <span class="cov6" title="12">k := strings.Index(line[j+1:], ";") + if k < 0 </span><span class="cov2" title="2">{ + return "", 0, 0, false + }</span> + <span class="cov5" title="10">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="9">inner := strings.TrimSpace(line[j+1 : closeIdx]) + if inner == "" </span><span class="cov0" title="0">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov5" title="9">end := closeIdx + 1 + return inner, j, end, true</span> + } + <span class="cov2" title="2">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="16">{ + t := strings.TrimSpace(line) + if !strings.Contains(t, ";;") </span><span class="cov6" title="16">{ + return false + }</span> + <span class="cov0" title="0">if hasDoubleSemicolonTrigger(t) </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov0" title="0">if strings.HasPrefix(t, ";;") </span><span class="cov0" title="0">{ + rest := strings.TrimSpace(t[2:]) + if rest == "" || rest == ";" </span><span class="cov0" title="0">{ + 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="cov5" title="10">{ + 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="cov1" title="1">{ + tail := prefixBeforeCursor[idx+2:] + if strings.TrimSpace(tail) == "" </span><span class="cov1" title="1">{ + start := idx - 1 + for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov4" title="5">{ + start-- + }</span> + <span class="cov1" title="1">start++ + seg := strings.TrimRight(prefixBeforeCursor[start:idx+2], " \t") + if strings.HasPrefix(s2, seg) </span><span class="cov1" title="1">{ + return strings.TrimLeft(s2[len(seg):], " \t") + }</span> + } + } + // Fallback to plain '=' if present + <span class="cov5" title="9">if idx := strings.LastIndex(prefixBeforeCursor, "="); idx >= 0 </span><span class="cov1" title="1">{ + if !(idx > 0 && prefixBeforeCursor[idx-1] == ':') </span><span class="cov1" title="1">{ // not := + tail := prefixBeforeCursor[idx+1:] + if strings.TrimSpace(tail) == "" </span><span class="cov1" title="1">{ + start := idx - 1 + for start >= 0 && (isIdentChar(prefixBeforeCursor[start]) || prefixBeforeCursor[start] == ' ' || prefixBeforeCursor[start] == '\t') </span><span class="cov2" title="2">{ + start-- + }</span> + <span class="cov1" title="1">start++ + seg := strings.TrimRight(prefixBeforeCursor[start:idx+1], " \t") + if strings.HasPrefix(s2, seg) </span><span class="cov1" title="1">{ + return strings.TrimLeft(s2[len(seg):], " \t") + }</span> + } + } + } + <span class="cov5" title="8">return suggestion</span> +} + +// stripDuplicateGeneralPrefix removes any already-typed prefix that the model repeated. +func stripDuplicateGeneralPrefix(prefixBeforeCursor, suggestion string) string <span class="cov5" title="10">{ + if suggestion == "" </span><span class="cov0" title="0">{ + return suggestion + }</span> + <span class="cov5" title="10">s := strings.TrimLeft(suggestion, " \t") + p := strings.TrimRight(prefixBeforeCursor, " \t") + if p != "" && strings.HasPrefix(s, p) </span><span class="cov2" title="2">{ + return strings.TrimLeft(s[len(p):], " \t") + }</span> + <span class="cov5" title="8">for k := len(p) - 1; k > 0; k-- </span><span class="cov10" title="85">{ + if !isIdentBoundary(p[k-1]) </span><span class="cov9" title="68">{ + continue</span> + } + <span class="cov6" title="17">suf := strings.TrimLeft(p[k:], " \t") + if suf == "" </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov6" title="17">if strings.HasPrefix(s, suf) </span><span class="cov0" title="0">{ + return strings.TrimLeft(s[len(suf):], " \t") + }</span> + } + <span class="cov5" title="8">return suggestion</span> +} + +func isIdentBoundary(ch byte) bool <span class="cov10" title="85">{ + 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="cov6" title="14">{ + t := strings.TrimSpace(s) + if t == "" </span><span class="cov0" title="0">{ + return t + }</span> + <span class="cov6" title="14">lines := splitLines(t) + start := 0 + for start < len(lines) && strings.TrimSpace(lines[start]) == "" </span><span class="cov0" title="0">{ + start++ + }</span> + <span class="cov6" title="14">end := len(lines) - 1 + for end >= 0 && strings.TrimSpace(lines[end]) == "" </span><span class="cov0" title="0">{ + end-- + }</span> + <span class="cov6" title="14">if start >= len(lines) || end < 0 || start > end </span><span class="cov0" title="0">{ + return t + }</span> + <span class="cov6" title="14">first := strings.TrimSpace(lines[start]) + last := strings.TrimSpace(lines[end]) + if strings.HasPrefix(first, "```") && last == "```" && end > start </span><span class="cov3" title="4">{ + inner := strings.Join(lines[start+1:end], "\n") + return inner + }</span> + <span class="cov5" title="10">return t</span> +} + +// stripInlineCodeSpan returns the contents of the first inline backtick code span if present. +func stripInlineCodeSpan(s string) string <span class="cov4" title="6">{ + t := strings.TrimSpace(s) + if t == "" </span><span class="cov0" title="0">{ + return t + }</span> + <span class="cov4" title="6">i := strings.IndexByte(t, '`') + if i < 0 </span><span class="cov1" title="1">{ + return t + }</span> + <span class="cov4" title="5">jrel := strings.IndexByte(t[i+1:], '`') + if jrel < 0 </span><span class="cov1" title="1">{ + return t + }</span> + <span class="cov3" title="4">j := i + 1 + jrel + return t[i+1 : j]</span> +} + +// labelForCompletion picks a short, readable label for the completion list. +func labelForCompletion(cleaned, filter string) string <span class="cov5" title="9">{ + label := trimLen(firstLine(cleaned)) + if filter != "" && !strings.HasPrefix(strings.ToLower(label), strings.ToLower(filter)) </span><span class="cov1" title="1">{ + return filter + }</span> + <span class="cov5" title="8">return label</span> +} + +// extractRangeText returns the exact text within the given document range. +func extractRangeText(d *document, r Range) string <span class="cov0" title="0">{ + if r.Start.Line == r.End.Line </span><span class="cov0" title="0">{ + line := d.lines[r.Start.Line] + if r.Start.Character < 0 </span><span class="cov0" title="0">{ + r.Start.Character = 0 + }</span> + <span class="cov0" title="0">if r.End.Character > len(line) </span><span class="cov0" title="0">{ + r.End.Character = len(line) + }</span> + <span class="cov0" title="0">if r.Start.Character > r.End.Character </span><span class="cov0" title="0">{ + return "" + }</span> + <span class="cov0" title="0">return line[r.Start.Character:r.End.Character]</span> + } + <span class="cov0" title="0">var b strings.Builder + // first line + first := d.lines[r.Start.Line] + if r.Start.Character < 0 </span><span class="cov0" title="0">{ + r.Start.Character = 0 + }</span> + <span class="cov0" title="0">if r.Start.Character > len(first) </span><span class="cov0" title="0">{ + r.Start.Character = len(first) + }</span> + <span class="cov0" title="0">b.WriteString(first[r.Start.Character:]) + b.WriteString("\n") + // middle lines + for i := r.Start.Line + 1; i < r.End.Line; i++ </span><span class="cov0" title="0">{ + b.WriteString(d.lines[i]) + if i+1 <= r.End.Line </span><span class="cov0" title="0">{ + b.WriteString("\n") + }</span> + } + // last line + <span class="cov0" title="0">last := d.lines[r.End.Line] + if r.End.Character < 0 </span><span class="cov0" title="0">{ + r.End.Character = 0 + }</span> + <span class="cov0" title="0">if r.End.Character > len(last) </span><span class="cov0" title="0">{ + r.End.Character = len(last) + }</span> + <span class="cov0" title="0">b.WriteString(last[:r.End.Character]) + return b.String()</span> +} + +// collectPromptRemovalEdits returns edits to remove all inline prompt markers. +func (s *Server) collectPromptRemovalEdits(uri string) []TextEdit <span class="cov5" title="9">{ + d := s.getDocument(uri) + if d == nil || len(d.lines) == 0 </span><span class="cov5" title="9">{ + return nil + }</span> + <span class="cov0" title="0">var edits []TextEdit + for i, line := range d.lines </span><span class="cov0" title="0">{ + edits = append(edits, promptRemovalEditsForLine(line, i)...) + }</span> + <span class="cov0" title="0">return edits</span> +} + +func promptRemovalEditsForLine(line string, lineNum int) []TextEdit <span class="cov1" title="1">{ + if hasDoubleSemicolonTrigger(line) </span><span class="cov1" title="1">{ + return []TextEdit{{Range: Range{Start: Position{Line: lineNum, Character: 0}, End: Position{Line: lineNum, Character: len(line)}}, NewText: ""}} + }</span> + <span class="cov0" title="0">return collectSemicolonMarkers(line, lineNum)</span> +} + +func hasDoubleSemicolonTrigger(line string) bool <span class="cov8" title="37">{ + pos := 0 + for pos < len(line) </span><span class="cov8" title="42">{ + j := strings.Index(line[pos:], ";;") + if j < 0 </span><span class="cov7" title="27">{ + return false + }</span> + <span class="cov6" title="15">j += pos + contentStart := j + 2 + if contentStart >= len(line) </span><span class="cov4" title="5">{ + return false + }</span> + <span class="cov5" title="10">first := line[contentStart] + if first == ' ' || first == ';' </span><span class="cov4" title="5">{ + pos = contentStart + 1 + continue</span> + } + <span class="cov4" title="5">k := strings.Index(line[contentStart+1:], ";") + if k < 0 </span><span class="cov0" title="0">{ + return false + }</span> + <span class="cov4" title="5">closeIdx := contentStart + 1 + k + if closeIdx-1 >= 0 && line[closeIdx-1] == ' ' </span><span class="cov1" title="1">{ + pos = closeIdx + 1 + continue</span> + } + <span class="cov3" title="4">return true</span> + } + <span class="cov1" title="1">return false</span> +} + +func collectSemicolonMarkers(line string, lineNum int) []TextEdit <span class="cov1" title="1">{ + var edits []TextEdit + startSemi := 0 + for startSemi < len(line) </span><span class="cov3" title="3">{ + j := strings.Index(line[startSemi:], ";") + if j < 0 </span><span class="cov1" title="1">{ + break</span> + } + <span class="cov2" title="2">j += startSemi + k := strings.Index(line[j+1:], ";") + if k < 0 </span><span class="cov0" title="0">{ + break</span> + } + <span class="cov2" title="2">if j+1 >= len(line) || line[j+1] == ' ' </span><span class="cov0" title="0">{ + startSemi = j + 1 + continue</span> + } + <span class="cov2" title="2">if line[j+1] == ';' </span><span class="cov0" title="0">{ + startSemi = j + 2 + continue</span> + } + <span class="cov2" title="2">closeIdx := j + 1 + k + if closeIdx-1 < 0 || line[closeIdx-1] == ' ' </span><span class="cov0" title="0">{ + startSemi = closeIdx + 1 + continue</span> + } + <span class="cov2" title="2">if closeIdx-(j+1) < 1 </span><span class="cov0" title="0">{ + startSemi = closeIdx + 1 + continue</span> + } + <span class="cov2" title="2">endChar := closeIdx + 1 + if endChar < len(line) && line[endChar] == ' ' </span><span class="cov2" title="2">{ + endChar++ + }</span> + <span class="cov2" title="2">edits = append(edits, TextEdit{Range: Range{Start: Position{Line: lineNum, Character: j}, End: Position{Line: lineNum, Character: endChar}}, NewText: ""}) + startSemi = endChar</span> + } + <span class="cov1" title="1">return edits</span> +} +</pre> + + <pre class="file" id="file21" style="display: none">// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats. +package lsp + +import ( + "bufio" + "encoding/json" + "codeberg.org/snonux/hexai/internal/llm" + "codeberg.org/snonux/hexai/internal/logging" + "io" + "log" + "sync" + "time" +) + +// Server implements a minimal LSP over stdio. +type Server struct { + in *bufio.Reader + out io.Writer + logger *log.Logger + exited bool + mu sync.RWMutex + docs map[string]*document + logContext bool + llmClient llm.Client + lastInput time.Time + maxTokens int + contextMode string + windowLines int + maxContextTokens int + triggerChars []string + // If set, used as the LSP coding temperature for all LLM calls + codingTemperature *float64 + // LLM request stats + llmReqTotal int64 + llmSentBytesTotal int64 + llmRespTotal int64 + llmRespBytesTotal int64 + startTime time.Time + // 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 + // 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 + + // Dispatch table for JSON-RPC methods → handler functions + handlers map[string]func(Request) +} + +// 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 +} + +func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server <span class="cov10" title="2">{ + 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="cov1" title="1">{ + maxTokens = 500 + }</span> + <span class="cov10" title="2">s.maxTokens = maxTokens + contextMode := opts.ContextMode + if contextMode == "" </span><span class="cov1" title="1">{ + contextMode = "file-on-new-func" + }</span> + <span class="cov10" title="2">windowLines := opts.WindowLines + if windowLines <= 0 </span><span class="cov1" title="1">{ + windowLines = 120 + }</span> + <span class="cov10" title="2">maxContextTokens := opts.MaxContextTokens + if maxContextTokens <= 0 </span><span class="cov1" title="1">{ + maxContextTokens = 2000 + }</span> + <span class="cov10" title="2">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="2">{ + // 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="2">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="2">if opts.CompletionThrottleMs > 0 </span><span class="cov0" title="0">{ + s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond + }</span> + // Initialize dispatch table + <span class="cov10" title="2">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="2">return s</span> +} + +func (s *Server) Run() error <span class="cov1" title="1">{ + for </span><span class="cov1" title="1">{ + body, err := s.readMessage() + if err == io.EOF </span><span class="cov1" title="1">{ + return nil + }</span> + <span class="cov0" title="0">if err != nil </span><span class="cov0" title="0">{ + return err + }</span> + <span class="cov0" title="0">var req Request + if err := json.Unmarshal(body, &req); err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "invalid JSON: %v", err) + continue</span> + } + <span class="cov0" title="0">if req.Method == "" </span><span class="cov0" title="0">{ + // A response from client; ignore + continue</span> + } + <span class="cov0" title="0">go s.handle(req) + if s.exited </span><span class="cov0" title="0">{ + return nil + }</span> + } +} +</pre> + + <pre class="file" id="file22" style="display: none">// Summary: LSP transport utilities to read and write JSON-RPC messages with Content-Length framing. +package lsp + +import ( + "encoding/json" + "fmt" + "codeberg.org/snonux/hexai/internal/logging" + "io" + "net/textproto" + "strconv" + "strings" +) + +func (s *Server) readMessage() ([]byte, error) <span class="cov8" title="1">{ + tp := textproto.NewReader(s.in) + var contentLength int + for </span><span class="cov8" title="1">{ + line, err := tp.ReadLine() + if err != nil </span><span class="cov8" title="1">{ + return nil, err + }</span> + <span class="cov0" title="0">if line == "" </span><span class="cov0" title="0">{ // end of headers + break</span> + } + <span class="cov0" title="0">parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 </span><span class="cov0" title="0">{ + continue</span> + } + <span class="cov0" title="0">key := strings.TrimSpace(strings.ToLower(parts[0])) + val := strings.TrimSpace(parts[1]) + switch key </span>{ + case "content-length":<span class="cov0" title="0"> + n, err := strconv.Atoi(val) + if err != nil </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("invalid Content-Length: %v", err) + }</span> + <span class="cov0" title="0">contentLength = n</span> + } + } + <span class="cov0" title="0">if contentLength <= 0 </span><span class="cov0" title="0">{ + return nil, fmt.Errorf("missing or invalid Content-Length") + }</span> + <span class="cov0" title="0">buf := make([]byte, contentLength) + if _, err := io.ReadFull(s.in, buf); err != nil </span><span class="cov0" title="0">{ + return nil, err + }</span> + <span class="cov0" title="0">return buf, nil</span> +} + +func (s *Server) writeMessage(v any) <span class="cov0" title="0">{ + 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="cov0" title="0">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="cov0" title="0">if _, err := s.out.Write(data); err != nil </span><span class="cov0" title="0">{ + logging.Logf("lsp ", "write body error: %v", err) + return + }</span> +} +</pre> + + </div> + </body> + <script> + (function() { + var files = document.getElementById('files'); + var visible; + files.addEventListener('change', onChange, false); + function select(part) { + if (visible) + visible.style.display = 'none'; + visible = document.getElementById(part); + if (!visible) + return; + files.value = part; + visible.style.display = 'block'; + location.hash = part; + } + function onChange() { + select(files.value); + window.scrollTo(0, 0); + } + if (location.hash != "") { + select(location.hash.substr(1)); + } + if (!visible) { + select("file0"); + } + })(); + </script> +</html> |
