1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
|
// Package hexailsp is the 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 (
"fmt"
"io"
"log"
"os"
"strings"
"time"
"codeberg.org/snonux/hexai/internal/appconfig"
"codeberg.org/snonux/hexai/internal/ignore"
"codeberg.org/snonux/hexai/internal/llm"
"codeberg.org/snonux/hexai/internal/logging"
"codeberg.org/snonux/hexai/internal/lsp"
"codeberg.org/snonux/hexai/internal/stats"
tmx "codeberg.org/snonux/hexai/internal/tmux"
)
// ServerRunner is the minimal interface satisfied by lsp.Server.
type ServerRunner interface{ Run() error }
// ConfigurableServerRunner supports runtime option updates.
type ConfigurableServerRunner interface {
ServerRunner
ApplyOptions(lsp.ServerOptions)
}
type tmuxStatusSink struct{}
func (tmuxStatusSink) SetLLMStart(provider, model string) error {
return tmx.SetStatus(tmx.FormatLLMStartStatus(provider, model))
}
func (tmuxStatusSink) SetGlobal(gs lsp.GlobalStatus) error {
status := tmx.FormatGlobalStatusColored(
gs.Reqs, gs.RPM, gs.Sent, gs.Recv,
gs.Provider, gs.Model, gs.ScopeRPM, gs.ScopeReqs, gs.Window,
)
return tmx.SetStatus(status)
}
// 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 {
return RunWithConfig(logPath, "", stdin, stdout, stderr)
}
// RunWithConfig is like Run but accepts an explicit config file path.
func RunWithConfig(logPath string, configPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) error {
llm.RegisterAllProviders()
return runWithConfigDependencies(logPath, configPath, stdin, stdout, stderr, defaultRunDependencies())
}
func runWithConfigDependencies(logPath string, configPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer, deps runDependencies) error {
deps = normalizeRunDependencies(deps)
logger := log.New(stderr, "hexai-lsp-server ", log.LstdFlags|log.Lmsgprefix)
if strings.TrimSpace(logPath) != "" {
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
defer func() {
if err := f.Close(); err != nil {
logger.Printf("failed to close log file: %v", err)
}
}()
logger.SetOutput(f)
}
logging.Bind(logger)
loadOpts := appconfig.LoadOptions{ConfigPath: configPath}
cfg := deps.loadConfig(logger, loadOpts)
if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
if cfg.StatsWindowMinutes > 0 {
stats.SetWindow(time.Duration(cfg.StatsWindowMinutes) * time.Minute)
}
return runWithDependencies(logPath, configPath, stdin, stdout, logger, cfg, nil, nil, deps)
}
// 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, configPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error {
return runWithDependencies(logPath, configPath, stdin, stdout, logger, cfg, client, factory, defaultRunDependencies())
}
func runWithDependencies(logPath string, configPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory, deps runDependencies) error {
deps = normalizeRunDependencies(deps)
normalizeLoggingConfig(&cfg)
if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid config: %w", err)
}
client = deps.buildClient(cfg, client)
factory = ensureFactory(factory)
ignoreChecker := deps.newIgnoreChecker(cfg)
store := deps.newConfigStore(cfg)
logContext := strings.TrimSpace(logPath) != ""
loadOpts := appconfig.LoadOptions{ConfigPath: strings.TrimSpace(configPath)}
opts := makeServerOptions(cfg, logContext, client, loadOpts, ignoreChecker, deps.statusSink)
opts.ConfigLoadOptions = loadOpts
opts.ConfigStore = store
server := factory(stdin, stdout, logger, opts)
if configurable, ok := server.(ConfigurableServerRunner); ok {
store.Subscribe(func(oldCfg, newCfg appconfig.App) {
updated := newCfg
normalizeLoggingConfig(&updated)
if updated.StatsWindowMinutes > 0 {
stats.SetWindow(time.Duration(updated.StatsWindowMinutes) * time.Minute)
}
if newClient := deps.buildClient(updated, nil); newClient != nil {
client = newClient
}
// Update ignore checker patterns on config hot-reload
useGI := updated.IgnoreGitignore == nil || *updated.IgnoreGitignore
ignoreChecker.Update(useGI, updated.IgnoreExtraPatterns)
opts := makeServerOptions(updated, logContext, client, loadOpts, ignoreChecker, deps.statusSink)
opts.ConfigStore = store
configurable.ApplyOptions(opts)
})
}
if err := server.Run(); err != nil {
return fmt.Errorf("server error: %w", err)
}
return nil
}
// --- helpers to keep RunWithFactory small ---
func normalizeLoggingConfig(cfg *appconfig.App) {
cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode))
if cfg.LogPreviewLimit >= 0 {
logging.SetLogPreviewLimit(cfg.LogPreviewLimit)
}
}
func ensureFactory(factory ServerFactory) ServerFactory {
if factory != nil {
return factory
}
return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner {
return lsp.NewServer(r, w, logger, opts)
}
}
func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client, loadOpts appconfig.LoadOptions, ignoreChecker *ignore.Checker, statusSink lsp.StatusSink) lsp.ServerOptions {
return lsp.ServerOptions{
ConfigLoadOptions: loadOpts,
LogContext: logContext,
ConfigStore: nil,
Config: &cfg,
Client: client,
IgnoreChecker: ignoreChecker,
StatusSink: statusSink,
}
}
|