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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
//! Main LSP server implementation.
use crate::handlers::execute_command::COMMANDS;
use crate::handlers::on_type_formatting::{FIRST_TRIGGER_CHARACTER, MORE_TRIGGER_CHARACTERS};
use crate::handlers::semantic_tokens::get_capabilities as get_semantic_tokens_capabilities;
use crate::handlers::signature_help::TRIGGER_CHARACTERS as SIGNATURE_TRIGGER_CHARACTERS;
use crate::ledger_state::{LspConfig, discover_journal_file};
use crate::main_loop::run_main_loop;
use crate::uri_to_path;
use lsp_server::Connection;
use lsp_types::InitializeParams;
/// The LSP server.
pub struct Server {
/// Connection to the LSP client.
connection: Connection,
/// Initialize parameters from client.
init_params: InitializeParams,
/// LSP configuration parsed from init options.
config: LspConfig,
/// Position encoding negotiated with the client during
/// `initialize`. Handler code emitting `Position` values must
/// consult this so output aligns with what the client expects.
position_encoding: crate::handlers::utils::PositionEncoding,
}
impl Server {
/// Create a new LSP server from a connection.
pub fn new(
connection: Connection,
init_params: InitializeParams,
position_encoding: crate::handlers::utils::PositionEncoding,
) -> Self {
// Parse configuration from initialization options
let config = LspConfig::from_init_options(init_params.initialization_options.as_ref());
if let Some(ref journal) = config.journal_file {
tracing::info!("Journal file configured: {}", journal.display());
}
Self {
connection,
init_params,
config,
position_encoding,
}
}
/// Run the server's main loop. Returns the exit code produced by
/// the `exit` notification (or 0 if the channel closed without
/// one). The caller is responsible for draining IO threads
/// (`io_threads.join()`) before terminating the process — without
/// that drain, the writer can lose the shutdown response queued
/// when the loop broke.
#[must_use]
pub fn run(self) -> i32 {
tracing::info!("Starting Beancount Language Server v{}", crate::VERSION);
// Resolve journal file path relative to workspace root if needed
let journal_file = self.resolve_journal_path();
if let Some(ref path) = journal_file {
tracing::info!("Using journal file: {}", path.display());
}
if let Some(folders) = &self.init_params.workspace_folders
&& let Some(folder) = folders.first()
{
tracing::info!("Workspace root: {}", folder.uri.as_str());
}
// Run the main event loop with the journal file configuration
// and the negotiated position encoding (so handlers emit
// positions in the encoding the client expects).
let (sender, receiver) = (self.connection.sender, self.connection.receiver);
let code = run_main_loop(receiver, sender, journal_file, self.position_encoding);
tracing::info!("Server shutdown complete (exit code {code})");
code
}
/// Resolve the journal file path, making it absolute if necessary.
/// If no explicit journal file is configured, attempts auto-discovery.
fn resolve_journal_path(&self) -> Option<std::path::PathBuf> {
// Get workspace root path for resolution and discovery
let workspace_root = self.get_workspace_root();
// If explicit config provided, resolve it
if let Some(journal) = &self.config.journal_file {
return self.resolve_explicit_journal(journal, workspace_root.as_deref());
}
// No explicit config - auto-discover, but only in the right directory:
// a set workspace folder is authoritative; the process cwd is used ONLY
// when there is no workspace folder. Otherwise a stray journal in the
// editor's launch directory silently contaminates an unrelated
// workspace's state.
let discovered = Self::discovery_dir(workspace_root, || std::env::current_dir().ok())
.and_then(|dir| discover_journal_file(&dir));
if discovered.is_none() {
tracing::debug!("No journal file configured or discovered");
}
discovered
}
/// Directory to search for a journal during auto-discovery: the workspace
/// folder when set, otherwise the process cwd. The cwd is deliberately
/// *not* consulted when a workspace folder exists, so a journal that
/// happens to sit in the editor's launch directory cannot leak into an
/// unrelated workspace. `cwd` is computed lazily so the `current_dir`
/// syscall is skipped entirely (and its failure ignored) when a workspace
/// folder is set.
fn discovery_dir(
workspace_root: Option<std::path::PathBuf>,
cwd: impl FnOnce() -> Option<std::path::PathBuf>,
) -> Option<std::path::PathBuf> {
workspace_root.or_else(cwd)
}
/// Get the workspace root path from init params.
fn get_workspace_root(&self) -> Option<std::path::PathBuf> {
self.init_params
.workspace_folders
.as_ref()
.and_then(|folders| folders.first())
.and_then(|folder| uri_to_path(&folder.uri))
}
/// Resolve an explicitly configured journal path.
fn resolve_explicit_journal(
&self,
journal: &std::path::Path,
workspace_root: Option<&std::path::Path>,
) -> Option<std::path::PathBuf> {
// If already absolute, use as-is
if journal.is_absolute() {
return Some(journal.to_path_buf());
}
// Try to resolve relative to workspace root
if let Some(root) = workspace_root {
let resolved = root.join(journal);
if resolved.exists() {
return Some(resolved);
}
}
// Fall back to current directory
let resolved = std::env::current_dir()
.ok()
.map(|cwd| cwd.join(journal))
.filter(|p| p.exists());
if resolved.is_none() {
tracing::warn!(
"Journal file '{}' not found relative to workspace or current directory",
journal.display()
);
}
resolved
}
}
/// Start the LSP server using stdio transport.
///
/// Returns the exit code that the `exit` notification supplied (0 for
/// a clean shutdown, 1 for exit-without-prior-shutdown per LSP spec).
/// If the channel closed without an `exit` notification, returns 0.
///
/// Critically, `io_threads.join()` is called BEFORE returning, so the
/// shutdown response queued in the writer thread's channel is fully
/// flushed to stdout. Previously the production exit path called
/// `process::exit(code)` from inside the main loop, which terminated
/// the process before the writer could flush — losing the shutdown
/// response on slow runners (the `stdio_smoke` CI flake).
pub fn start_stdio() -> Result<i32, Box<dyn std::error::Error + Send + Sync>> {
tracing::info!("Starting LSP server on stdio");
// Create connection using stdio
let (connection, io_threads) = Connection::stdio();
// Wait for initialize request
let (id, params) = connection.initialize_start()?;
let init_params: InitializeParams = serde_json::from_value(params)?;
// Negotiate position encoding. Our handler stack emits LSP
// positions as UTF-8 byte offsets, so prefer UTF-8 if the
// client advertises it (LSP 3.17+; VS Code, neovim, helix, and
// most modern clients do). If the client doesn't advertise
// UTF-8, the LSP spec requires the server to use UTF-16 (the
// default), in which case our byte-based positions are wrong
// for non-ASCII content — a server-wide latent bug tracked
// separately. The negotiation here at least makes us correct
// for modern clients without any handler-side conversion.
let position_encoding = init_params
.capabilities
.general
.as_ref()
.and_then(|g| g.position_encodings.as_ref())
.and_then(|encs| {
encs.contains(&lsp_types::PositionEncodingKind::UTF8)
.then_some(lsp_types::PositionEncodingKind::UTF8)
});
// Derive the handler-facing `PositionEncoding` once, BEFORE
// `position_encoding` moves into the `ServerCapabilities` field
// below. The `Option<PositionEncodingKind>` is non-Copy, so we
// can't borrow it after the move; computing here also makes the
// negotiated-encoding-vs-handler-encoding mapping explicit at
// the negotiation site.
let handler_encoding =
crate::handlers::utils::PositionEncoding::from_negotiated(position_encoding.as_ref());
// Build server capabilities
let capabilities = lsp_types::ServerCapabilities {
position_encoding,
text_document_sync: Some(lsp_types::TextDocumentSyncCapability::Kind(
lsp_types::TextDocumentSyncKind::FULL,
)),
completion_provider: Some(lsp_types::CompletionOptions {
trigger_characters: Some(vec![
":".to_string(), // Account segments
" ".to_string(), // After keywords
"\"".to_string(), // Strings (payees, narrations)
"#".to_string(), // Tags
"^".to_string(), // Links
]),
resolve_provider: Some(true), // Enable completion resolve for detailed info
..Default::default()
}),
definition_provider: Some(lsp_types::OneOf::Left(true)),
references_provider: Some(lsp_types::OneOf::Left(true)),
hover_provider: Some(lsp_types::HoverProviderCapability::Simple(true)),
document_symbol_provider: Some(lsp_types::OneOf::Left(true)),
semantic_tokens_provider: Some(get_semantic_tokens_capabilities()),
code_action_provider: Some(lsp_types::CodeActionProviderCapability::Options(
lsp_types::CodeActionOptions {
code_action_kinds: Some(vec![
lsp_types::CodeActionKind::QUICKFIX,
lsp_types::CodeActionKind::REFACTOR,
]),
resolve_provider: Some(true), // Enable resolve for lazy-loading edits
work_done_progress_options: Default::default(),
},
)),
workspace_symbol_provider: Some(lsp_types::OneOf::Left(true)),
rename_provider: Some(lsp_types::OneOf::Right(lsp_types::RenameOptions {
prepare_provider: Some(true),
work_done_progress_options: Default::default(),
})),
document_formatting_provider: Some(lsp_types::OneOf::Left(true)),
document_range_formatting_provider: Some(lsp_types::OneOf::Left(true)),
document_link_provider: Some(lsp_types::DocumentLinkOptions {
resolve_provider: Some(true), // Enable resolve to verify file existence
work_done_progress_options: Default::default(),
}),
inlay_hint_provider: Some(lsp_types::OneOf::Right(
lsp_types::InlayHintServerCapabilities::Options(lsp_types::InlayHintOptions {
resolve_provider: Some(true), // Enable resolve for rich tooltips
work_done_progress_options: Default::default(),
}),
)),
selection_range_provider: Some(lsp_types::SelectionRangeProviderCapability::Simple(true)),
folding_range_provider: Some(lsp_types::FoldingRangeProviderCapability::Simple(true)),
document_highlight_provider: Some(lsp_types::OneOf::Left(true)),
linked_editing_range_provider: Some(
lsp_types::LinkedEditingRangeServerCapabilities::Simple(true),
),
document_on_type_formatting_provider: Some(lsp_types::DocumentOnTypeFormattingOptions {
first_trigger_character: FIRST_TRIGGER_CHARACTER.to_string(),
more_trigger_character: Some(
MORE_TRIGGER_CHARACTERS
.iter()
.map(|s| s.to_string())
.collect(),
),
}),
code_lens_provider: Some(lsp_types::CodeLensOptions {
resolve_provider: Some(true), // Enable resolve for lazy-loading balance verification
}),
color_provider: Some(lsp_types::ColorProviderCapability::Simple(true)),
declaration_provider: Some(lsp_types::DeclarationCapability::Simple(true)),
call_hierarchy_provider: Some(lsp_types::CallHierarchyServerCapability::Simple(true)),
signature_help_provider: Some(lsp_types::SignatureHelpOptions {
trigger_characters: Some(
SIGNATURE_TRIGGER_CHARACTERS
.iter()
.map(|s| s.to_string())
.collect(),
),
retrigger_characters: None,
work_done_progress_options: Default::default(),
}),
execute_command_provider: Some(lsp_types::ExecuteCommandOptions {
commands: COMMANDS.iter().map(|s| s.to_string()).collect(),
work_done_progress_options: Default::default(),
}),
// Type hierarchy: advertised via experimental until lsp-types adds native support
experimental: Some(serde_json::json!({
"typeHierarchyProvider": true
})),
// Workspace capabilities
workspace: Some(lsp_types::WorkspaceServerCapabilities {
workspace_folders: Some(lsp_types::WorkspaceFoldersServerCapabilities {
supported: Some(true),
change_notifications: Some(lsp_types::OneOf::Left(true)),
}),
file_operations: None, // File operations (create/rename/delete) not needed for Beancount
}),
..Default::default()
};
let server_info = lsp_types::ServerInfo {
name: "rledger-lsp".to_string(),
version: Some(crate::VERSION.to_string()),
};
let init_result = lsp_types::InitializeResult {
capabilities,
server_info: Some(server_info),
};
// Complete initialization handshake
connection.initialize_finish(id, serde_json::to_value(init_result)?)?;
tracing::info!("LSP initialized successfully");
// Create and run server with the handler-facing position encoding
// (derived above at the negotiation site).
let server = Server::new(connection, init_params, handler_encoding);
let exit_code = server.run();
// Drain the writer thread BEFORE returning. The main loop has
// already broken (either via the `exit` notification or because
// the channel closed), but the writer may still be flushing the
// shutdown response queued just before. Without this drain, a
// subsequent `process::exit` in `main()` would kill the writer
// mid-flush; with it, the response reaches stdout before main
// tears down.
io_threads.join()?;
Ok(exit_code)
}
#[cfg(test)]
mod tests {
use super::Server;
use std::path::PathBuf;
#[test]
fn discovery_dir_prefers_workspace_and_ignores_cwd() {
let ws = PathBuf::from("/work/space");
let cwd = PathBuf::from("/tmp/launch");
// Workspace set → search the workspace, never the cwd (no contamination).
// The cwd closure must not even be invoked in this case.
let mut cwd_called = false;
let got = Server::discovery_dir(Some(ws.clone()), || {
cwd_called = true;
Some(cwd.clone())
});
assert_eq!(got, Some(ws));
assert!(
!cwd_called,
"cwd must not be consulted when a workspace is set"
);
// No workspace → fall back to the cwd.
assert_eq!(Server::discovery_dir(None, || Some(cwd.clone())), Some(cwd));
// Neither → nothing to discover.
assert_eq!(Server::discovery_dir(None, || None), None);
}
}