diff options
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, |
