all repos

clerk @ e0319e5

missing tooling for ledger/hledger

clerk/internal/lsp/server.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
lsp: comply with lsp3.18 lifecycle, 29 days ago
1
package lsp
2
3
import (
4
	"context"
5
	"errors"
6
	"fmt"
7
	"log/slog"
8
	"os"
9
	"sync"
10
11
	"github.com/go-json-experiment/json"
12
	"github.com/pelletier/go-toml/v2"
13
	"go.lsp.dev/jsonrpc2"
14
	"go.lsp.dev/protocol"
15
	"go.lsp.dev/uri"
16
17
	"olexsmir.xyz/clerk/internal/analyzer"
18
	"olexsmir.xyz/clerk/internal/settings"
19
	"olexsmir.xyz/clerk/journal"
20
)
21
22
type server struct {
23
	protocol.UnimplementedServer
24
25
	client protocol.Client
26
	log    *slog.Logger
27
	conn   jsonrpc2.Conn // set in Run; Exit closes it to end the session
28
29
	stateMu       sync.Mutex
30
	state         serverState // [serverState]
31
	version, name string
32
33
	settings settings.Settings
34
	loader   *journal.Loader
35
36
	mu            sync.RWMutex
37
	openDocs      map[uri.URI]docState
38
	diagCancel    context.CancelFunc
39
	dynFileWather bool
40
	configPath    string
41
}
42
43
// parsedFileFor returns the parsed file for path within the analysis, or nil.
44
func parsedFileFor(an *analyzer.Analysis, path string) *journal.ParsedFile {
45
	for _, pf := range an.Files {
46
		if pf.Path == path {
47
			return pf
48
		}
49
	}
50
	return nil
51
}
52
53
// analysisFor returns the cached analysis for an open doc, rebuilding when the doc or a file it includes changed.
54
func (s *server) analysisFor(u uri.URI) *analyzer.Analysis {
55
	s.mu.RLock()
56
	state, ok := s.openDocs[u]
57
	if !ok {
58
		s.mu.RUnlock()
59
		return nil
60
	}
61
	if !state.dirty {
62
		an := state.cache.analysis
63
		s.mu.RUnlock()
64
		return an
65
	}
66
	text := state.text
67
	version := state.version
68
	s.mu.RUnlock()
69
70
	an := analyzer.Build(s.loader.ResolveBytes(u.Path(), []byte(text)))
71
72
	s.mu.Lock()
73
	state, ok = s.openDocs[u]
74
	if !ok || state.version != version {
75
		// editor closed or edited while building; the doc stays dirty so the next request rebuilds
76
		s.mu.Unlock()
77
		return an
78
	}
79
	state.cache = newAnalysisCache(an)
80
	state.dirty = false
81
	s.openDocs[u] = state
82
	s.mu.Unlock()
83
	return an
84
}
85
86
func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) {
87
	if w := params.Capabilities.Workspace; w != nil {
88
		if wf := w.DidChangeWatchedFiles; wf != nil {
89
			s.dynFileWather = wf.DynamicRegistration != nil && *wf.DynamicRegistration
90
		}
91
	}
92
93
	if err := s.applySettings(ctx, params.InitializationOptions); err != nil {
94
		return nil, err
95
	}
96
	td := params.Capabilities.TextDocument
97
	full := protocol.SemanticTokensOptionsFull(protocol.Boolean(true))
98
	if td != nil {
99
		if fd, ok := td.SemanticTokens.Requests.Full.(*protocol.ClientSemanticTokensRequestFullDelta); ok && fd.Delta != nil && *fd.Delta {
100
			full = &protocol.SemanticTokensFullDelta{Delta: new(true)}
101
		}
102
	}
103
104
	// RenameOptions may only be specified when the client states prepare support.
105
	renameProvider := protocol.RenameProvider(&protocol.RenameOptions{PrepareProvider: new(true)})
106
	if td == nil || td.Rename == nil || td.Rename.PrepareSupport == nil || !*td.Rename.PrepareSupport {
107
		renameProvider = protocol.Boolean(true)
108
	}
109
110
	s.stateMu.Lock()
111
	if s.state != stateNew {
112
		s.stateMu.Unlock()
113
		return nil, errInitializeOnce
114
	}
115
	s.state = stateInitialized
116
	s.stateMu.Unlock()
117
	if p := params.ProcessID; p != nil {
118
		s.watchParent(*p)
119
	}
120
121
	return &protocol.InitializeResult{
122
		ServerInfo: protocol.ServerInfo{
123
			Name:    s.name,
124
			Version: protocol.NewOptional(s.version),
125
		},
126
		Capabilities: protocol.ServerCapabilities{
127
			DocumentFormattingProvider: &protocol.DocumentFormattingOptions{},
128
			DefinitionProvider:         protocol.Boolean(true),
129
			HoverProvider:              protocol.Boolean(true),
130
			ReferencesProvider:         protocol.Boolean(true),
131
			WorkspaceSymbolProvider:    protocol.Boolean(true),
132
			DocumentSymbolProvider:     protocol.Boolean(true),
133
			FoldingRangeProvider:       protocol.Boolean(true),
134
			SelectionRangeProvider:     protocol.Boolean(true),
135
			RenameProvider:             renameProvider,
136
			CompletionProvider: &protocol.CompletionOptions{
137
				TriggerCharacters: []string{":", "@"},
138
			},
139
			TextDocumentSync: &protocol.TextDocumentSyncOptions{
140
				OpenClose: new(true),
141
				Change:    new(protocol.TextDocumentSyncKindIncremental),
142
			},
143
			SemanticTokensProvider: &protocol.SemanticTokensOptions{
144
				Legend: getSemanticTokensLegend(),
145
				Range:  protocol.Boolean(true),
146
				Full:   full,
147
			},
148
		},
149
	}, nil
150
}
151
152
func (s *server) Initialized(ctx context.Context, params *protocol.InitializedParams) error {
153
	if s.dynFileWather {
154
		go s.registerFileWatchers(context.Background())
155
	}
156
	s.applyConfigFile(ctx)
157
	s.scheduleDiagnostics(ctx)
158
	return nil
159
}
160
161
func (s *server) DidChangeWatchedFiles(ctx context.Context, params *protocol.DidChangeWatchedFilesParams) error {
162
	for _, change := range params.Changes {
163
		u := change.URI
164
		path := u.Path()
165
		if path == "" {
166
			continue
167
		}
168
		if _, isOpen := s.getDocState(u); isOpen {
169
			continue // editor buffer is authoritative for open docs
170
		}
171
		s.loader.InvalidateFile(path)
172
		s.markDependentsDirty(u)
173
	}
174
	s.scheduleDiagnostics(ctx)
175
	return nil
176
}
177
178
func (s *server) DidChangeConfiguration(ctx context.Context, params *protocol.DidChangeConfigurationParams) error {
179
	return s.applySettings(ctx, params.Settings)
180
}
181
182
func (s *server) Shutdown(ctx context.Context) error {
183
	s.stateMu.Lock()
184
	if s.state == stateInitialized {
185
		s.state = stateShutdownRequested
186
	}
187
	s.stateMu.Unlock()
188
	return nil
189
}
190
191
// Exit records the exit code the LSP spec prescribes — 0 after a shutdown
192
// request, 1 otherwise — and closes the connection to end the session. The
193
// close runs in its own goroutine: called from inside the exit-notification
194
// handler it cannot block on the connection draining (see [jsonrpc2.Conn.Close]).
195
// Exit honors an exit that never saw initialize, and is safe to call
196
// concurrently from the exit notification and watchParent: the stateMu guard
197
// below makes the transition a once-only, and an exit that lost the race to
198
// another exit is a no-op.
199
func (s *server) Exit(context.Context) error {
200
	s.stateMu.Lock()
201
	if s.state < stateExited {
202
		if s.state == stateShutdownRequested {
203
			s.state = stateExitedAfterShutdown
204
		} else {
205
			s.state = stateExited
206
		}
207
	}
208
	s.stateMu.Unlock()
209
	go s.conn.Close()
210
	return nil
211
}
212
213
func (s *server) registerFileWatchers(ctx context.Context) {
214
	if s.client == nil {
215
		return
216
	}
217
	watchers := make([]protocol.FileSystemWatcher, 0, len(journal.SupportedExtensions))
218
	for _, ext := range journal.SupportedExtensions {
219
		watchers = append(watchers, protocol.FileSystemWatcher{GlobPattern: protocol.Pattern("**/*" + ext)})
220
	}
221
	options, err := protocol.Marshal(protocol.DidChangeWatchedFilesRegistrationOptions{Watchers: watchers})
222
	if err != nil {
223
		return
224
	}
225
	if err := s.client.RegisterCapability(ctx, &protocol.RegistrationParams{
226
		Registrations: []protocol.Registration{{
227
			ID:              "clerk.watchedFiles",
228
			Method:          protocol.MethodWorkspaceDidChangeWatchedFiles,
229
			RegisterOptions: protocol.LSPAny(options),
230
		}},
231
	}); err != nil {
232
		s.log.Warn("registering file watchers failed", "err", err)
233
	}
234
}
235
236
func (s *server) applySettings(ctx context.Context, v protocol.LSPAny) error {
237
	if len(v) == 0 {
238
		return nil
239
	}
240
	var raw map[string]any
241
	if err := json.Unmarshal(v, &raw); err != nil {
242
		return fmt.Errorf("invalid settings: %w", err)
243
	}
244
	s.mu.Lock()
245
	warns, err := s.settings.ApplyLSP(raw)
246
	s.mu.Unlock()
247
	for _, w := range warns {
248
		s.reportConfigProblem(ctx, protocol.MessageTypeWarning, w)
249
	}
250
	return err
251
}
252
253
func (s *server) applyConfigFile(ctx context.Context) {
254
	data, err := os.ReadFile(s.configPath)
255
	if err != nil {
256
		if errors.Is(err, os.ErrNotExist) {
257
			return
258
		}
259
		s.reportConfigError(ctx, err)
260
		return
261
	}
262
	var raw map[string]any
263
	if err := toml.Unmarshal(data, &raw); err != nil {
264
		s.reportConfigError(ctx, err)
265
		return
266
	}
267
	s.mu.Lock()
268
	warns, err := s.settings.Apply(raw)
269
	s.mu.Unlock()
270
	if err != nil {
271
		s.reportConfigError(ctx, err)
272
	}
273
	for _, w := range warns {
274
		s.reportConfigProblem(ctx, protocol.MessageTypeWarning, w)
275
	}
276
}
277
278
func (s *server) reportConfigError(ctx context.Context, err error) {
279
	s.reportConfigProblem(ctx, protocol.MessageTypeError, "config "+s.configPath+": "+err.Error())
280
}
281
282
func (s *server) reportConfigProblem(ctx context.Context, typ protocol.MessageType, msg string) {
283
	lvl := slog.LevelWarn
284
	if typ == protocol.MessageTypeError {
285
		lvl = slog.LevelError
286
	}
287
	s.log.Log(ctx, lvl, "config", "message", msg)
288
	if s.client == nil {
289
		return
290
	}
291
	if err := s.client.ShowMessage(ctx, &protocol.ShowMessageParams{Type: typ, Message: msg}); err != nil {
292
		s.log.Warn("window/showMessage failed", "err", err)
293
	}
294
}
295
296
func (s *server) semanticHighlightingEnabled() bool {
297
	s.mu.RLock()
298
	defer s.mu.RUnlock()
299
	return s.settings.SemanticHighlighting
300
}
301
302
func (s *server) latinToCyrillicCompletionEnabled() bool {
303
	s.mu.RLock()
304
	defer s.mu.RUnlock()
305
	return s.settings.LatinToCyrillicCompletion
306
}