summaryrefslogtreecommitdiff
path: root/internal/lsp/server.go
blob: e3728c8d81e850a5b52567026b81db18ec3cf9f3 (plain)
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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
// Summary: Minimal LSP server over stdio; manages documents, dispatches requests, and tracks stats.
package lsp

import (
	"bufio"
	"encoding/json"
	"io"
	"log"
	"strings"
	"sync"
	"time"

	"codeberg.org/snonux/hexai/internal/llm"
	"codeberg.org/snonux/hexai/internal/logging"
)

// Server implements a minimal LSP over stdio.
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)

	// Configurable trigger characters
	inlineOpen   string
	inlineClose  string
	chatSuffix   string
	chatPrefixes []string

	// Prompt templates
	// Completion
	promptCompSysGeneral  string
	promptCompSysParams   string
	promptCompSysInline   string
	promptCompUserGeneral string
	promptCompUserParams  string
	promptCompExtraHeader string
	// Provider-native code completion
	promptNativeCompletion string
	// In-editor chat
	promptChatSystem string
	// Code actions
	promptRewriteSystem     string
	promptDiagnosticsSystem string
	promptDocumentSystem    string
	promptRewriteUser       string
	promptDiagnosticsUser   string
	promptDocumentUser      string
	promptGoTestSystem      string
	promptGoTestUser        string
	promptSimplifySystem    string
	promptSimplifyUser      string

	// Custom actions configured by user
	customActions []CustomAction
}

// ServerOptions collects configuration for NewServer to avoid long parameter lists.
type ServerOptions struct {
	LogContext       bool
	MaxTokens        int
	ContextMode      string
	WindowLines      int
	MaxContextTokens int

	Client                llm.Client
	TriggerCharacters     []string
	CodingTemperature     *float64
	ManualInvokeMinPrefix int
	CompletionDebounceMs  int
	CompletionThrottleMs  int

	// Inline/chat triggers
	InlineOpen   string
	InlineClose  string
	ChatSuffix   string
	ChatPrefixes []string

	// Prompt templates
	PromptCompSysGeneral    string
	PromptCompSysParams     string
	PromptCompSysInline     string
	PromptCompUserGeneral   string
	PromptCompUserParams    string
	PromptCompExtraHeader   string
	PromptNativeCompletion  string
	PromptChatSystem        string
	PromptRewriteSystem     string
	PromptDiagnosticsSystem string
	PromptDocumentSystem    string
	PromptRewriteUser       string
	PromptDiagnosticsUser   string
	PromptDocumentUser      string
	PromptGoTestSystem      string
	PromptGoTestUser        string
	PromptSimplifySystem    string
	PromptSimplifyUser      string

	// Custom actions
	CustomActions []CustomAction
}

// CustomAction mirrors user-defined code actions passed from config.
type CustomAction struct {
	ID          string
	Title       string
	Kind        string
	Scope       string // "selection" | "diagnostics"
	Instruction string // if set, use rewrite templates
	System      string // optional when User is set
	User        string // if set, use this user template
}

func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server {
	s := &Server{in: bufio.NewReader(r), out: w, logger: logger, docs: make(map[string]*document), logContext: opts.LogContext}
	maxTokens := opts.MaxTokens
	if maxTokens <= 0 {
		maxTokens = 500
	}
	s.maxTokens = maxTokens
	contextMode := opts.ContextMode
	if contextMode == "" {
		contextMode = "file-on-new-func"
	}
	windowLines := opts.WindowLines
	if windowLines <= 0 {
		windowLines = 120
	}
	maxContextTokens := opts.MaxContextTokens
	if maxContextTokens <= 0 {
		maxContextTokens = 2000
	}
	s.contextMode = contextMode
	s.windowLines = windowLines
	s.maxContextTokens = maxContextTokens

	s.startTime = time.Now()
	s.llmClient = opts.Client
	if len(opts.TriggerCharacters) == 0 {
		// Defaults (no space to avoid auto-trigger after whitespace)
		s.triggerChars = []string{".", ":", "/", "_", ")", "{"}
	} else {
		s.triggerChars = append([]string{}, opts.TriggerCharacters...)
	}
	s.codingTemperature = opts.CodingTemperature
	s.compCache = make(map[string]string)
	s.manualInvokeMinPrefix = opts.ManualInvokeMinPrefix
	if opts.CompletionDebounceMs > 0 {
		s.completionDebounce = time.Duration(opts.CompletionDebounceMs) * time.Millisecond
	}
	if opts.CompletionThrottleMs > 0 {
		s.throttleInterval = time.Duration(opts.CompletionThrottleMs) * time.Millisecond
	}
	// Trigger character config (with sane defaults if missing)
	if strings.TrimSpace(opts.InlineOpen) == "" {
		s.inlineOpen = ">"
	} else {
		s.inlineOpen = opts.InlineOpen
	}
	if strings.TrimSpace(opts.InlineClose) == "" {
		s.inlineClose = ">"
	} else {
		s.inlineClose = opts.InlineClose
	}
	if strings.TrimSpace(opts.ChatSuffix) == "" {
		s.chatSuffix = ">"
	} else {
		s.chatSuffix = opts.ChatSuffix
	}
	if len(opts.ChatPrefixes) == 0 {
		s.chatPrefixes = []string{"?", "!", ":", ";"}
	} else {
		s.chatPrefixes = append([]string{}, opts.ChatPrefixes...)
	}

	// Prompts
	s.promptCompSysGeneral = opts.PromptCompSysGeneral
	s.promptCompSysParams = opts.PromptCompSysParams
	s.promptCompSysInline = opts.PromptCompSysInline
	s.promptCompUserGeneral = opts.PromptCompUserGeneral
	s.promptCompUserParams = opts.PromptCompUserParams
	s.promptCompExtraHeader = opts.PromptCompExtraHeader
	s.promptNativeCompletion = opts.PromptNativeCompletion
	s.promptChatSystem = opts.PromptChatSystem
	s.promptRewriteSystem = opts.PromptRewriteSystem
	s.promptDiagnosticsSystem = opts.PromptDiagnosticsSystem
	s.promptDocumentSystem = opts.PromptDocumentSystem
	s.promptRewriteUser = opts.PromptRewriteUser
	s.promptDiagnosticsUser = opts.PromptDiagnosticsUser
	s.promptDocumentUser = opts.PromptDocumentUser
	s.promptGoTestSystem = opts.PromptGoTestSystem
	s.promptGoTestUser = opts.PromptGoTestUser
	s.promptSimplifySystem = opts.PromptSimplifySystem
	s.promptSimplifyUser = opts.PromptSimplifyUser

	if len(opts.CustomActions) > 0 {
		s.customActions = append([]CustomAction{}, opts.CustomActions...)
	}

	// Assign package-level inline trigger chars for free helper functions
	if s.inlineOpen != "" {
		inlineOpenChar = s.inlineOpen[0]
	}
	if s.inlineClose != "" {
		inlineCloseChar = s.inlineClose[0]
	}
	if s.chatSuffix != "" {
		chatSuffixChar = s.chatSuffix[0]
	}
	if len(s.chatPrefixes) > 0 {
		chatPrefixSingles = append([]string{}, s.chatPrefixes...)
	}
	// Initialize dispatch table
	s.handlers = map[string]func(Request){
		"initialize":               s.handleInitialize,
		"initialized":              func(_ Request) { s.handleInitialized() },
		"shutdown":                 s.handleShutdown,
		"exit":                     func(_ Request) { s.handleExit() },
		"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,
	}
	return s
}

func (s *Server) Run() error {
	for {
		body, err := s.readMessage()
		if err == io.EOF {
			return nil
		}
		if err != nil {
			return err
		}
		var req Request
		if err := json.Unmarshal(body, &req); err != nil {
			logging.Logf("lsp ", "invalid JSON: %v", err)
			continue
		}
		if req.Method == "" {
			// A response from client; ignore
			continue
		}
		go s.handle(req)
		if s.exited {
			return nil
		}
	}
}