all repos

clerk @ ab4f6083f493cb5911c86ae5f723d4519f7d52da

missing tooling for ledger/hledger

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

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat: config (#6), 1 month 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/protocol"
14
	"go.lsp.dev/uri"
15
16
	"olexsmir.xyz/clerk/internal/analyzer"
17
	"olexsmir.xyz/clerk/internal/settings"
18
	"olexsmir.xyz/clerk/journal"
19
)
20
21
type server struct {
22
	protocol.UnimplementedServer
23
24
	client protocol.Client
25
	log    *slog.Logger
26
27
	version, name string
28
29
	settings settings.Settings
30
	loader   *journal.Loader
31
32
	mu            sync.RWMutex
33
	openDocs      map[uri.URI]docState
34
	diagCancel    context.CancelFunc
35
	dynFileWather bool
36
	configPath    string
37
}
38
39
// analysisFor returns the cached analysis for an open doc, rebuilds when the doc or a file it inclues changed.
40
func (s *server) analysisFor(u uri.URI) *analyzer.Analysis {
41
	s.mu.RLock()
42
	state, ok := s.openDocs[u]
43
	if !ok {
44
		s.mu.RUnlock()
45
		return nil
46
	}
47
	if !state.dirty {
48
		an := state.analysis
49
		s.mu.RUnlock()
50
		return an
51
	}
52
	text := state.text
53
	version := state.version
54
	s.mu.RUnlock()
55
56
	an := analyzer.Build(s.loader.ResolveBytes(u.Path(), []byte(text)))
57
58
	s.mu.Lock()
59
	state, ok = s.openDocs[u]
60
	if !ok || state.version != version {
61
		// editot or closed while building. doc stays dirty so te request rebuilds
62
		s.mu.Unlock()
63
		return an
64
	}
65
	state.analysis = an
66
	state.dirty = false
67
	state.paths = make(map[string]bool, len(an.Files))
68
	for _, pf := range an.Files {
69
		state.paths[journal.CanonicalPath(pf.Path)] = true
70
	}
71
	s.openDocs[u] = state
72
	s.mu.Unlock()
73
	return an
74
}
75
76
func (s *server) Initialize(ctx context.Context, params *protocol.InitializeParams) (*protocol.InitializeResult, error) {
77
	if w := params.Capabilities.Workspace; w != nil {
78
		if wf := w.DidChangeWatchedFiles; wf != nil {
79
			s.dynFileWather = wf.DynamicRegistration != nil && *wf.DynamicRegistration
80
		}
81
	}
82
83
	if err := s.applySettings(ctx, params.InitializationOptions); err != nil {
84
		return nil, err
85
	}
86
	full := protocol.SemanticTokensOptionsFull(protocol.Boolean(true))
87
	if td := params.Capabilities.TextDocument; td != nil {
88
		if fd, ok := td.SemanticTokens.Requests.Full.(*protocol.ClientSemanticTokensRequestFullDelta); ok && fd.Delta != nil && *fd.Delta {
89
			full = &protocol.SemanticTokensFullDelta{Delta: new(true)}
90
		}
91
	}
92
93
	return &protocol.InitializeResult{
94
		ServerInfo: protocol.ServerInfo{
95
			Name:    s.name,
96
			Version: protocol.NewOptional(s.version),
97
		},
98
		Capabilities: protocol.ServerCapabilities{
99
			DocumentFormattingProvider: &protocol.DocumentFormattingOptions{},
100
			DefinitionProvider:         protocol.Boolean(true),
101
			HoverProvider:              protocol.Boolean(true),
102
			ReferencesProvider:         protocol.Boolean(true),
103
			WorkspaceSymbolProvider:    protocol.Boolean(true),
104
			DocumentSymbolProvider:     protocol.Boolean(true),
105
			RenameProvider: &protocol.RenameOptions{
106
				PrepareProvider: new(true),
107
			},
108
			CompletionProvider: &protocol.CompletionOptions{
109
				TriggerCharacters: []string{":", "@"},
110
			},
111
			TextDocumentSync: &protocol.TextDocumentSyncOptions{
112
				OpenClose: new(true),
113
				Change:    new(protocol.TextDocumentSyncKindFull),
114
			},
115
			SemanticTokensProvider: &protocol.SemanticTokensOptions{
116
				Legend: getSemanticTokensLegend(),
117
				Range:  protocol.Boolean(true),
118
				Full:   full,
119
			},
120
		},
121
	}, nil
122
}
123
124
func (s *server) Initialized(ctx context.Context, params *protocol.InitializedParams) error {
125
	if s.dynFileWather {
126
		go s.registerFileWatchers(context.Background())
127
	}
128
	s.applyConfigFile(ctx)
129
	s.scheduleDiagnostics(ctx)
130
	return nil
131
}
132
133
func (s *server) DidChangeWatchedFiles(ctx context.Context, params *protocol.DidChangeWatchedFilesParams) error {
134
	for _, change := range params.Changes {
135
		u := change.URI
136
		path := u.Path()
137
		if path == "" {
138
			continue
139
		}
140
		if _, isOpen := s.getDocState(u); isOpen {
141
			continue // editor buffer is authoritative for open docs
142
		}
143
		s.loader.InvalidateFile(path)
144
		s.markDependentsDirty(u)
145
	}
146
	s.scheduleDiagnostics(ctx)
147
	return nil
148
}
149
150
func (s *server) DidChangeConfiguration(ctx context.Context, params *protocol.DidChangeConfigurationParams) error {
151
	return s.applySettings(ctx, params.Settings)
152
}
153
154
func (s *server) Shutdown(ctx context.Context) error {
155
	return nil
156
}
157
158
func (s *server) Exit(ctx context.Context) error {
159
	return nil
160
}
161
162
func (s *server) registerFileWatchers(ctx context.Context) {
163
	if s.client == nil {
164
		return
165
	}
166
	watchers := make([]protocol.FileSystemWatcher, 0, len(journal.SupportedExtensions))
167
	for _, ext := range journal.SupportedExtensions {
168
		watchers = append(watchers, protocol.FileSystemWatcher{GlobPattern: protocol.Pattern("**/*" + ext)})
169
	}
170
	options, err := protocol.Marshal(protocol.DidChangeWatchedFilesRegistrationOptions{Watchers: watchers})
171
	if err != nil {
172
		return
173
	}
174
	if err := s.client.RegisterCapability(ctx, &protocol.RegistrationParams{
175
		Registrations: []protocol.Registration{{
176
			ID:              "clerk.watchedFiles",
177
			Method:          protocol.MethodWorkspaceDidChangeWatchedFiles,
178
			RegisterOptions: protocol.LSPAny(options),
179
		}},
180
	}); err != nil {
181
		s.log.Warn("registering file watchers failed", "err", err)
182
	}
183
}
184
185
func (s *server) applySettings(ctx context.Context, v protocol.LSPAny) error {
186
	if len(v) == 0 {
187
		return nil
188
	}
189
	var raw map[string]any
190
	if err := json.Unmarshal(v, &raw); err != nil {
191
		return fmt.Errorf("invalid settings: %w", err)
192
	}
193
	s.mu.Lock()
194
	warns, err := s.settings.ApplyLSP(raw)
195
	s.mu.Unlock()
196
	for _, w := range warns {
197
		s.reportConfigProblem(ctx, protocol.MessageTypeWarning, w)
198
	}
199
	return err
200
}
201
202
func (s *server) applyConfigFile(ctx context.Context) {
203
	data, err := os.ReadFile(s.configPath)
204
	if err != nil {
205
		if errors.Is(err, os.ErrNotExist) {
206
			return
207
		}
208
		s.reportConfigError(ctx, err)
209
		return
210
	}
211
	var raw map[string]any
212
	if err := toml.Unmarshal(data, &raw); err != nil {
213
		s.reportConfigError(ctx, err)
214
		return
215
	}
216
	s.mu.Lock()
217
	warns, err := s.settings.Apply(raw)
218
	s.mu.Unlock()
219
	if err != nil {
220
		s.reportConfigError(ctx, err)
221
	}
222
	for _, w := range warns {
223
		s.reportConfigProblem(ctx, protocol.MessageTypeWarning, w)
224
	}
225
}
226
227
func (s *server) reportConfigError(ctx context.Context, err error) {
228
	s.reportConfigProblem(ctx, protocol.MessageTypeError, "config "+s.configPath+": "+err.Error())
229
}
230
231
func (s *server) reportConfigProblem(ctx context.Context, typ protocol.MessageType, msg string) {
232
	lvl := slog.LevelWarn
233
	if typ == protocol.MessageTypeError {
234
		lvl = slog.LevelError
235
	}
236
	s.log.Log(ctx, lvl, "config", "message", msg)
237
	if s.client == nil {
238
		return
239
	}
240
	if err := s.client.ShowMessage(ctx, &protocol.ShowMessageParams{Type: typ, Message: msg}); err != nil {
241
		s.log.Warn("window/showMessage failed", "err", err)
242
	}
243
}
244
245
func (s *server) semanticHighlightingEnabled() bool {
246
	s.mu.RLock()
247
	defer s.mu.RUnlock()
248
	return s.settings.SemanticHighlighting
249
}
250
251
func (s *server) latinToCyrillicCompletionEnabled() bool {
252
	s.mu.RLock()
253
	defer s.mu.RUnlock()
254
	return s.settings.LatinToCyrillicCompletion
255
}