1use crate::project::ProjectRoot;
2use anyhow::{Context, Result, bail};
3use serde_json::{Value, json};
4use std::collections::HashMap;
5use std::io::{BufRead, BufReader};
6use std::path::Path;
7use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
8use std::thread;
9use std::time::{Duration, Instant};
10use url::Url;
11
12use super::commands::is_allowed_lsp_command;
13use super::protocol::{language_id_for_path, poll_readable, read_message, send_message};
14use super::registry::resolve_lsp_binary_with_hint;
15use super::types::{
16 LspCodeActionRefactorPlan, LspCodeActionRefactorResult, LspCodeActionRequest, LspDiagnostic,
17 LspDiagnosticRequest, LspReference, LspRenamePlan, LspRenamePlanRequest, LspRenameRequest,
18 LspRequest, LspResolveTargetRequest, LspResolvedTarget, LspTypeHierarchyRequest,
19 LspWorkspaceEditTransaction, LspWorkspaceSymbol, LspWorkspaceSymbolRequest,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23struct SessionKey {
24 command: String,
25 args: Vec<String>,
26}
27
28impl SessionKey {
29 fn new(command: &str, args: &[String]) -> Self {
30 Self {
31 command: command.to_owned(),
32 args: args.to_owned(),
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
38struct OpenDocumentState {
39 version: i32,
40 text: String,
41}
42
43pub struct LspSessionPool {
44 project: ProjectRoot,
45 sessions: std::sync::Mutex<HashMap<SessionKey, LspSession>>,
46}
47
48pub(super) struct LspSession {
49 pub(super) project: ProjectRoot,
50 child: Child,
51 stdin: ChildStdin,
52 reader: BufReader<ChildStdout>,
53 next_request_id: u64,
54 documents: HashMap<String, OpenDocumentState>,
55 #[allow(dead_code)] stderr_buffer: std::sync::Arc<std::sync::Mutex<String>>,
57 server_quiescent: Option<bool>,
64}
65
66fn ensure_session<'a>(
67 sessions: &'a mut HashMap<SessionKey, LspSession>,
68 project: &ProjectRoot,
69 command: &str,
70 args: &[String],
71) -> Result<&'a mut LspSession> {
72 if !is_allowed_lsp_command(command) {
73 bail!(
74 "Blocked: '{command}' is not a known LSP server. Only whitelisted LSP binaries are allowed."
75 );
76 }
77
78 let key = SessionKey::new(command, args);
79
80 if let Some(session) = sessions.get_mut(&key) {
82 match session.child.try_wait() {
83 Ok(Some(_status)) => {
84 sessions.remove(&key);
86 }
87 Ok(None) => {} Err(_) => {
89 sessions.remove(&key);
90 }
91 }
92 }
93
94 match sessions.entry(key) {
95 std::collections::hash_map::Entry::Occupied(e) => Ok(e.into_mut()),
96 std::collections::hash_map::Entry::Vacant(e) => {
97 let session = LspSession::start(project, command, args)?;
98 Ok(e.insert(session))
99 }
100 }
101}
102
103fn is_retriable_lsp_transport_error(err: &anyhow::Error) -> bool {
104 let text = err.to_string().to_ascii_lowercase();
105 [
106 "unexpected eof",
107 "broken pipe",
108 "connection reset",
109 "connection aborted",
110 "transport endpoint is not connected",
111 "os error 32",
112 "os error 54",
113 ]
114 .iter()
115 .any(|marker| text.contains(marker))
116}
117
118impl LspSessionPool {
119 pub fn new(project: ProjectRoot) -> Self {
120 Self {
121 project,
122 sessions: std::sync::Mutex::new(HashMap::new()),
123 }
124 }
125
126 pub fn reset(&self, project: ProjectRoot) -> Self {
128 self.sessions
130 .lock()
131 .unwrap_or_else(|p| p.into_inner())
132 .clear();
133 Self::new(project)
134 }
135
136 pub fn shutdown(&self) {
138 self.sessions
139 .lock()
140 .unwrap_or_else(|p| p.into_inner())
141 .clear();
142 }
143
144 pub fn session_count(&self) -> usize {
145 self.sessions
146 .lock()
147 .unwrap_or_else(|p| p.into_inner())
148 .len()
149 }
150
151 pub fn has_warm_session(&self, command: &str, args: &[String]) -> bool {
159 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
160 let key = SessionKey::new(command, args);
161 match sessions.get_mut(&key) {
162 Some(session) => match session.child.try_wait() {
163 Ok(None) => true,
164 _ => {
165 sessions.remove(&key);
166 false
167 }
168 },
169 None => false,
170 }
171 }
172
173 pub fn prewarm_session(&self, command: &str, args: &[String]) -> Result<()> {
179 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
180 ensure_session(&mut sessions, &self.project, command, args).map(|_| ())
181 }
182
183 pub fn warm_session_quiescence(&self, command: &str, args: &[String]) -> Option<Option<bool>> {
190 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
191 let key = SessionKey::new(command, args);
192 match sessions.get_mut(&key) {
193 Some(session) => match session.child.try_wait() {
194 Ok(None) => Some(session.server_quiescent()),
195 _ => {
196 sessions.remove(&key);
197 None
198 }
199 },
200 None => None,
201 }
202 }
203
204 pub fn find_referencing_symbols(&self, request: &LspRequest) -> Result<Vec<LspReference>> {
205 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
206 let session = ensure_session(
207 &mut sessions,
208 &self.project,
209 &request.command,
210 &request.args,
211 )?;
212 session.find_references(request)
213 }
214
215 pub fn find_referencing_symbols_tracking_spawn(
224 &self,
225 request: &LspRequest,
226 ) -> Result<(Vec<LspReference>, bool)> {
227 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
228 let key = SessionKey::new(&request.command, &request.args);
229 let was_warm = sessions
232 .get_mut(&key)
233 .map(|session| matches!(session.child.try_wait(), Ok(None)))
234 .unwrap_or(false);
235 let session = ensure_session(
236 &mut sessions,
237 &self.project,
238 &request.command,
239 &request.args,
240 )?;
241 let references = session.find_references(request)?;
242 Ok((references, !was_warm))
243 }
244
245 pub fn get_diagnostics(&self, request: &LspDiagnosticRequest) -> Result<Vec<LspDiagnostic>> {
246 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
247 let result = {
248 let session = ensure_session(
249 &mut sessions,
250 &self.project,
251 &request.command,
252 &request.args,
253 )?;
254 session.get_diagnostics(request)
255 };
256
257 match result {
258 Ok(diagnostics) => Ok(diagnostics),
259 Err(err) if is_retriable_lsp_transport_error(&err) => {
260 let key = SessionKey::new(&request.command, &request.args);
261 sessions.remove(&key);
262 let session = ensure_session(
263 &mut sessions,
264 &self.project,
265 &request.command,
266 &request.args,
267 )?;
268 session
269 .get_diagnostics(request)
270 .with_context(|| "retried diagnostics after stale LSP transport")
271 }
272 Err(err) => Err(err),
273 }
274 }
275
276 pub fn search_workspace_symbols(
277 &self,
278 request: &LspWorkspaceSymbolRequest,
279 ) -> Result<Vec<LspWorkspaceSymbol>> {
280 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
281 let session = ensure_session(
282 &mut sessions,
283 &self.project,
284 &request.command,
285 &request.args,
286 )?;
287 session.search_workspace_symbols(request)
288 }
289
290 pub fn get_type_hierarchy(
291 &self,
292 request: &LspTypeHierarchyRequest,
293 ) -> Result<HashMap<String, Value>> {
294 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
295 let session = ensure_session(
296 &mut sessions,
297 &self.project,
298 &request.command,
299 &request.args,
300 )?;
301 session.get_type_hierarchy(request)
302 }
303
304 pub fn resolve_symbol_target(
305 &self,
306 request: &LspResolveTargetRequest,
307 ) -> Result<Vec<LspResolvedTarget>> {
308 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
309 let session = ensure_session(
310 &mut sessions,
311 &self.project,
312 &request.command,
313 &request.args,
314 )?;
315 session.resolve_symbol_target(request)
316 }
317
318 pub fn get_rename_plan(&self, request: &LspRenamePlanRequest) -> Result<LspRenamePlan> {
319 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
320 let session = ensure_session(
321 &mut sessions,
322 &self.project,
323 &request.command,
324 &request.args,
325 )?;
326 session.get_rename_plan(request)
327 }
328
329 pub fn rename_symbol(&self, request: &LspRenameRequest) -> Result<crate::rename::RenameResult> {
330 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
331 let session = ensure_session(
332 &mut sessions,
333 &self.project,
334 &request.command,
335 &request.args,
336 )?;
337 session.rename_symbol(request)
338 }
339
340 pub fn rename_symbol_transaction(
341 &self,
342 request: &LspRenameRequest,
343 ) -> Result<LspWorkspaceEditTransaction> {
344 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
345 let session = ensure_session(
346 &mut sessions,
347 &self.project,
348 &request.command,
349 &request.args,
350 )?;
351 session.rename_symbol_transaction(request)
352 }
353
354 pub fn code_action_refactor(
355 &self,
356 request: &LspCodeActionRequest,
357 ) -> Result<LspCodeActionRefactorResult> {
358 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
359 let session = ensure_session(
360 &mut sessions,
361 &self.project,
362 &request.command,
363 &request.args,
364 )?;
365 session.code_action_refactor(request)
366 }
367
368 pub fn code_action_refactor_plan(
369 &self,
370 request: &LspCodeActionRequest,
371 ) -> Result<LspCodeActionRefactorPlan> {
372 let mut sessions = self.sessions.lock().unwrap_or_else(|p| p.into_inner());
373 let session = ensure_session(
374 &mut sessions,
375 &self.project,
376 &request.command,
377 &request.args,
378 )?;
379 session.code_action_refactor_plan(request)
380 }
381}
382
383impl LspSession {
384 fn start(project: &ProjectRoot, command: &str, args: &[String]) -> Result<Self> {
385 let command_path = resolve_lsp_binary_with_hint(command, Some(project.as_path()))
386 .unwrap_or_else(|| command.into());
387 let mut child = Command::new(&command_path)
388 .args(args)
389 .stdin(Stdio::piped())
390 .stdout(Stdio::piped())
391 .stderr(Stdio::piped())
392 .spawn()
393 .with_context(|| format!("failed to spawn LSP server {}", command_path.display()))?;
394
395 let stdin = child.stdin.take().context("failed to open LSP stdin")?;
396 let stdout = child.stdout.take().context("failed to open LSP stdout")?;
397
398 let stderr_buffer = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
400 if let Some(stderr) = child.stderr.take() {
401 let buf = std::sync::Arc::clone(&stderr_buffer);
402 thread::spawn(move || {
403 let mut reader = BufReader::new(stderr);
404 let mut line = String::new();
405 while reader.read_line(&mut line).unwrap_or(0) > 0 {
406 if let Ok(mut b) = buf.lock() {
407 if b.len() > 4096 {
408 let drain_to = b.len() - 2048;
409 b.drain(..drain_to);
410 }
411 b.push_str(&line);
412 }
413 line.clear();
414 }
415 });
416 }
417
418 let mut session = Self {
419 project: project.clone(),
420 child,
421 stdin,
422 reader: BufReader::new(stdout),
423 next_request_id: 1,
424 documents: HashMap::new(),
425 stderr_buffer,
426 server_quiescent: None,
427 };
428 session.initialize(command)?;
429 if let Some(grace) = configured_startup_grace() {
430 thread::sleep(grace);
431 }
432 Ok(session)
433 }
434
435 fn initialize(&mut self, command: &str) -> Result<()> {
436 let id = self.next_id();
437 let root_uri = Url::from_directory_path(self.project.as_path())
438 .ok()
439 .map(|url| url.to_string());
440 let workspace_name = self
441 .project
442 .as_path()
443 .file_name()
444 .and_then(|n| n.to_str())
445 .unwrap_or("workspace")
446 .to_owned();
447 self.send_request(
448 id,
449 "initialize",
450 initialize_params(
451 root_uri,
452 &workspace_name,
453 initialization_options_for_command(command),
454 ),
455 )?;
456 let _ = self.read_response_for_id(id)?;
457 self.send_notification("initialized", json!({}))?;
458 Ok(())
459 }
460
461 pub(super) fn prepare_document(&mut self, absolute_path: &Path) -> Result<(String, String)> {
462 let uri = Url::from_file_path(absolute_path).map_err(|_| {
463 anyhow::anyhow!("failed to build file uri for {}", absolute_path.display())
464 })?;
465 let uri_string = uri.to_string();
466 let source = std::fs::read_to_string(absolute_path)
467 .with_context(|| format!("failed to read {}", absolute_path.display()))?;
468 let language_id = language_id_for_path(absolute_path)?;
469 self.sync_document(&uri_string, language_id, &source)?;
470 Ok((uri_string, source))
471 }
472
473 pub(super) fn sync_document(
474 &mut self,
475 uri: &str,
476 language_id: &str,
477 source: &str,
478 ) -> Result<()> {
479 if let Some(state) = self.documents.get(uri)
480 && state.text == source
481 {
482 return Ok(());
483 }
484
485 if let Some(state) = self.documents.get_mut(uri) {
486 state.version += 1;
487 state.text = source.to_owned();
488 let version = state.version;
489 return self.send_notification(
490 "textDocument/didChange",
491 json!({
492 "textDocument":{"uri":uri,"version":version},
493 "contentChanges":[{"text":source}]
494 }),
495 );
496 }
497
498 self.documents.insert(
499 uri.to_owned(),
500 OpenDocumentState {
501 version: 1,
502 text: source.to_owned(),
503 },
504 );
505 self.send_notification(
506 "textDocument/didOpen",
507 json!({
508 "textDocument":{
509 "uri":uri,
510 "languageId":language_id,
511 "version":1,
512 "text":source
513 }
514 }),
515 )
516 }
517
518 pub(super) fn next_id(&mut self) -> u64 {
519 let id = self.next_request_id;
520 self.next_request_id += 1;
521 id
522 }
523
524 pub(super) fn send_request(&mut self, id: u64, method: &str, params: Value) -> Result<()> {
525 send_message(
526 &mut self.stdin,
527 &json!({
528 "jsonrpc":"2.0",
529 "id":id,
530 "method":method,
531 "params":params
532 }),
533 )
534 }
535
536 fn send_notification(&mut self, method: &str, params: Value) -> Result<()> {
537 send_message(
538 &mut self.stdin,
539 &json!({
540 "jsonrpc":"2.0",
541 "method":method,
542 "params":params
543 }),
544 )
545 }
546
547 pub(super) fn read_response_for_id(&mut self, expected_id: u64) -> Result<Value> {
548 let deadline = Instant::now() + Duration::from_secs(30);
549 let mut discarded = 0u32;
550 const MAX_DISCARDED: u32 = 500;
551
552 loop {
553 let remaining = deadline.saturating_duration_since(Instant::now());
554 if remaining.is_zero() {
555 bail!(
556 "LSP response timeout: no response for request id {expected_id} within 30s \
557 ({discarded} unrelated messages discarded)"
558 );
559 }
560 if discarded >= MAX_DISCARDED {
561 bail!(
562 "LSP response loop: discarded {MAX_DISCARDED} messages without finding id {expected_id}"
563 );
564 }
565
566 if !poll_readable(self.reader.get_ref(), remaining.min(Duration::from_secs(5))) {
568 continue; }
570
571 let message = read_message(&mut self.reader)?;
572 let method = message.get("method").and_then(Value::as_str);
573
574 if let Some(method) = method {
580 if let Some(request_id) = message.get("id").filter(|id| !id.is_null()) {
581 let request_id = request_id.clone();
582 let reply = server_request_reply_payload(method, message.get("params"));
583 self.answer_server_request(&request_id, reply)?;
584 } else {
585 self.observe_server_notification(method, message.get("params"));
589 discarded += 1;
590 }
591 continue;
592 }
593
594 let matches_id = message
595 .get("id")
596 .and_then(Value::as_u64)
597 .map(|id| id == expected_id)
598 .unwrap_or(false);
599 if matches_id {
600 if let Some(error) = message.get("error") {
601 let code = error.get("code").and_then(Value::as_i64).unwrap_or(-1);
602 let error_message = error
603 .get("message")
604 .and_then(Value::as_str)
605 .unwrap_or("unknown LSP error");
606 bail!("LSP request failed ({code}): {error_message}");
607 }
608 return Ok(message);
609 }
610 discarded += 1;
611 }
612 }
613
614 fn answer_server_request(
616 &mut self,
617 request_id: &Value,
618 reply: std::result::Result<Value, Value>,
619 ) -> Result<()> {
620 let body = match reply {
621 Ok(result) => json!({"jsonrpc":"2.0","id":request_id,"result":result}),
622 Err(error) => json!({"jsonrpc":"2.0","id":request_id,"error":error}),
623 };
624 send_message(&mut self.stdin, &body)
625 }
626
627 fn observe_server_notification(&mut self, method: &str, params: Option<&Value>) {
629 if method == "experimental/serverStatus"
630 && let Some(quiescent) = params
631 .and_then(|params| params.get("quiescent"))
632 .and_then(Value::as_bool)
633 {
634 self.server_quiescent = Some(quiescent);
635 }
636 }
637
638 pub(super) fn server_quiescent(&self) -> Option<bool> {
642 self.server_quiescent
643 }
644
645 fn shutdown(&mut self) -> Result<()> {
646 let id = self.next_id();
647 self.send_request(id, "shutdown", Value::Null)?;
648 let _ = self.read_response_for_id(id)?;
649 self.send_notification("exit", Value::Null)
650 }
651}
652
653fn server_request_reply_payload(
666 method: &str,
667 params: Option<&Value>,
668) -> std::result::Result<Value, Value> {
669 match method {
670 "workspace/configuration" => {
671 let item_count = params
672 .and_then(|params| params.get("items"))
673 .and_then(Value::as_array)
674 .map(Vec::len)
675 .unwrap_or(0);
676 Ok(Value::Array(vec![Value::Null; item_count]))
677 }
678 "client/registerCapability"
679 | "client/unregisterCapability"
680 | "window/workDoneProgress/create" => Ok(Value::Null),
681 "workspace/applyEdit" => Ok(json!({
682 "applied": false,
683 "failureReason": "codelens read sessions do not accept server-initiated edits"
684 })),
685 _ => Err(json!({
686 "code": -32601,
687 "message": format!("method not supported by codelens LSP client: {method}")
688 })),
689 }
690}
691
692fn initialization_options_for_command(command: &str) -> Option<Value> {
700 let binary = Path::new(command)
701 .file_name()
702 .and_then(|n| n.to_str())
703 .unwrap_or(command);
704 match binary {
705 "rust-analyzer" => Some(json!({"checkOnSave": false})),
709 _ => None,
710 }
711}
712
713fn initialize_params(
718 root_uri: Option<String>,
719 workspace_name: &str,
720 initialization_options: Option<Value>,
721) -> Value {
722 let mut params = json!({
723 "processId":null,
724 "rootUri": root_uri.clone(),
725 "capabilities":{
726 "workspace":{
727 "workspaceEdit":{
728 "documentChanges":true,
729 "resourceOperations":["create","rename","delete"],
730 "failureHandling":"textOnlyTransactional"
731 },
732 "symbol":{"dynamicRegistration":false}
733 },
734 "textDocument":{
735 "declaration":{"dynamicRegistration":false},
736 "definition":{"dynamicRegistration":false},
737 "implementation":{"dynamicRegistration":false},
738 "typeDefinition":{"dynamicRegistration":false},
739 "references":{"dynamicRegistration":false},
740 "rename":{"dynamicRegistration":false,"prepareSupport":true},
741 "diagnostic":{"dynamicRegistration":false},
742 "typeHierarchy":{"dynamicRegistration":false},
743 "codeAction":{
744 "dynamicRegistration":false,
745 "codeActionLiteralSupport":{
746 "codeActionKind":{
747 "valueSet":["quickfix","refactor","refactor.extract","refactor.inline","refactor.rewrite"]
748 }
749 },
750 "resolveSupport":{"properties":["edit","command"]}
751 }
752 },
753 "experimental":{"serverStatusNotification":true}
758 },
759 "workspaceFolders":[
760 {
761 "uri": root_uri,
762 "name": workspace_name
763 }
764 ]
765 });
766 if let Some(options) = initialization_options {
767 params["initializationOptions"] = options;
768 }
769 params
770}
771
772fn configured_startup_grace() -> Option<Duration> {
773 let millis = std::env::var("CODELENS_LSP_STARTUP_GRACE_MS")
774 .ok()
775 .and_then(|value| value.trim().parse::<u64>().ok())
776 .unwrap_or(0)
777 .min(10_000);
778 (millis > 0).then(|| Duration::from_millis(millis))
779}
780
781impl Drop for LspSession {
782 fn drop(&mut self) {
783 let _ = self.shutdown();
784 let deadline = Instant::now() + Duration::from_millis(250);
785 while Instant::now() < deadline {
786 match self.child.try_wait() {
787 Ok(Some(_status)) => return,
788 Ok(None) => thread::sleep(Duration::from_millis(10)),
789 Err(_) => break,
790 }
791 }
792 let _ = self.child.kill();
793 let _ = self.child.wait();
794 }
795}
796
797#[cfg(test)]
798mod warm_probe_tests {
799 use super::*;
800
801 fn temp_project() -> ProjectRoot {
802 let dir = std::env::temp_dir().join(format!(
803 "codelens-warmprobe-{}-{}",
804 std::process::id(),
805 std::time::SystemTime::now()
806 .duration_since(std::time::UNIX_EPOCH)
807 .unwrap()
808 .as_nanos()
809 ));
810 std::fs::create_dir_all(&dir).unwrap();
811 ProjectRoot::new(dir.to_str().unwrap()).unwrap()
812 }
813
814 #[test]
815 fn has_warm_session_is_false_and_non_spawning_when_cold() {
816 let pool = LspSessionPool::new(temp_project());
817 assert!(!pool.has_warm_session("pyright-langserver", &["--stdio".to_owned()]));
819 assert_eq!(pool.session_count(), 0);
822 }
823
824 #[test]
825 fn warm_session_quiescence_is_none_and_non_spawning_when_cold() {
826 let pool = LspSessionPool::new(temp_project());
827 assert_eq!(
828 pool.warm_session_quiescence("pyright-langserver", &["--stdio".to_owned()]),
829 None,
830 "no live session must report outer None (not 'unknown readiness')"
831 );
832 assert_eq!(pool.session_count(), 0, "readiness probe must not spawn");
833 }
834}
835
836#[cfg(test)]
837mod initialization_options_tests {
838 use super::*;
839
840 #[test]
841 fn rust_analyzer_disables_check_on_save() {
842 assert_eq!(
843 initialization_options_for_command("rust-analyzer"),
844 Some(json!({"checkOnSave": false}))
845 );
846 }
847
848 #[test]
849 fn path_qualified_rust_analyzer_hits_the_same_entry() {
850 assert_eq!(
852 initialization_options_for_command("/opt/homebrew/bin/rust-analyzer"),
853 Some(json!({"checkOnSave": false}))
854 );
855 }
856
857 #[test]
858 fn unknown_servers_get_none() {
859 for command in [
860 "pyright-langserver",
861 "typescript-language-server",
862 "gopls",
863 "clangd",
864 "not-an-lsp",
865 ] {
866 assert_eq!(
867 initialization_options_for_command(command),
868 None,
869 "{command} must not receive initializationOptions"
870 );
871 }
872 }
873
874 #[test]
875 fn initialize_params_omits_options_field_when_none() {
876 let params = initialize_params(Some("file:///tmp/proj/".to_owned()), "proj", None);
877 assert!(
878 params.get("initializationOptions").is_none(),
879 "None must omit the field entirely (no null/empty placeholder)"
880 );
881 }
882
883 #[test]
884 fn initialize_params_attaches_options_when_some() {
885 let options = json!({"checkOnSave": false});
886 let params = initialize_params(
887 Some("file:///tmp/proj/".to_owned()),
888 "proj",
889 Some(options.clone()),
890 );
891 assert_eq!(params.get("initializationOptions"), Some(&options));
892 }
893
894 #[test]
895 fn options_do_not_alter_the_rest_of_the_params() {
896 let root_uri = Some("file:///tmp/proj/".to_owned());
899 let without = initialize_params(root_uri.clone(), "proj", None);
900 let mut with = initialize_params(root_uri, "proj", Some(json!({"checkOnSave": false})));
901 assert!(
902 with.as_object_mut()
903 .expect("params is an object")
904 .remove("initializationOptions")
905 .is_some()
906 );
907 assert_eq!(with, without);
908 }
909}
910
911#[cfg(test)]
912mod server_request_reply_tests {
913 use super::*;
914
915 #[test]
916 fn workspace_configuration_returns_one_null_per_item() {
917 let params = json!({"items": [{"section": "rust-analyzer"}, {"section": "python"}]});
920 let reply = server_request_reply_payload("workspace/configuration", Some(¶ms));
921 assert_eq!(reply, Ok(json!([null, null])));
922 }
923
924 #[test]
925 fn workspace_configuration_with_no_items_returns_empty_array() {
926 let reply = server_request_reply_payload("workspace/configuration", None);
927 assert_eq!(reply, Ok(json!([])));
928 }
929
930 #[test]
931 fn capability_registration_and_progress_create_are_acknowledged() {
932 for method in [
933 "client/registerCapability",
934 "client/unregisterCapability",
935 "window/workDoneProgress/create",
936 ] {
937 assert_eq!(
938 server_request_reply_payload(method, None),
939 Ok(Value::Null),
940 "{method} must be acknowledged, not discarded"
941 );
942 }
943 }
944
945 #[test]
946 fn server_initiated_apply_edit_is_refused() {
947 let reply = server_request_reply_payload("workspace/applyEdit", Some(&json!({"edit": {}})))
951 .expect("applyEdit is answered, not errored");
952 assert_eq!(reply.get("applied"), Some(&json!(false)));
953 }
954
955 #[test]
956 fn unknown_server_request_gets_method_not_found() {
957 let err = server_request_reply_payload("window/showMessageRequest", None)
958 .expect_err("unknown requests must be rejected explicitly");
959 assert_eq!(err.get("code"), Some(&json!(-32601)));
960 }
961
962 #[test]
970 #[ignore = "spawns a live rust-analyzer; run manually"]
971 fn quiescence_signal_is_harvested_from_live_rust_analyzer() {
972 use crate::lsp::types::LspRequest;
973
974 let dir = std::env::temp_dir().join(format!(
975 "codelens-quiescence-{}-{}",
976 std::process::id(),
977 std::time::SystemTime::now()
978 .duration_since(std::time::UNIX_EPOCH)
979 .unwrap()
980 .as_nanos()
981 ));
982 std::fs::create_dir_all(dir.join("src")).unwrap();
983 std::fs::write(
984 dir.join("Cargo.toml"),
985 "[package]\nname = \"quiescence_fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
986 )
987 .unwrap();
988 std::fs::write(
989 dir.join("src/lib.rs"),
990 "pub fn target() -> u32 { 41 }\npub fn caller() -> u32 { target() + 1 }\n",
991 )
992 .unwrap();
993 let project = ProjectRoot::new(dir.to_str().unwrap()).unwrap();
994 let pool = LspSessionPool::new(project);
995
996 let request = LspRequest {
997 command: "rust-analyzer".to_owned(),
998 args: Vec::new(),
999 file_path: "src/lib.rs".to_owned(),
1000 line: 1,
1001 column: 8,
1002 max_results: 10,
1003 };
1004 let deadline = std::time::Instant::now() + Duration::from_secs(60);
1005 let mut quiescence = None;
1006 while std::time::Instant::now() < deadline {
1007 let _ = pool.find_referencing_symbols(&request);
1010 quiescence = pool.warm_session_quiescence("rust-analyzer", &[]);
1011 if quiescence == Some(Some(true)) {
1012 break;
1013 }
1014 thread::sleep(Duration::from_millis(500));
1015 }
1016 assert_eq!(
1017 quiescence,
1018 Some(Some(true)),
1019 "rust-analyzer must report quiescent=true within 60s (signal harvested)"
1020 );
1021 }
1022}