/ tests / e2e / harness / client.rs
client.rs
  1  use serde::{Deserialize, Serialize};
  2  use serde_json::{json, Value};
  3  use std::collections::HashMap;
  4  use std::io::{BufRead, BufReader, Write};
  5  use std::path::PathBuf;
  6  use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
  7  use std::sync::atomic::{AtomicI64, Ordering};
  8  use std::sync::{Arc, Mutex, RwLock};
  9  use std::thread;
 10  use std::time::{Duration, Instant};
 11  
 12  static REQUEST_ID: AtomicI64 = AtomicI64::new(1);
 13  
 14  pub struct LspTestClient {
 15      _child: Child,
 16  
 17      stdin: Arc<Mutex<ChildStdin>>,
 18  
 19      pending_responses: Arc<RwLock<HashMap<i64, Value>>>,
 20  
 21      notifications: Arc<RwLock<Vec<JsonRpcNotification>>>,
 22  
 23      open_documents: Arc<Mutex<HashMap<String, String>>>,
 24  
 25      _reader_handle: thread::JoinHandle<()>,
 26  
 27      pub workspace_root: PathBuf,
 28  }
 29  
 30  #[derive(Debug, Clone, Serialize, Deserialize)]
 31  pub struct JsonRpcRequest {
 32      pub jsonrpc: String,
 33      pub id: i64,
 34      pub method: String,
 35      #[serde(skip_serializing_if = "Option::is_none")]
 36      pub params: Option<Value>,
 37  }
 38  
 39  #[allow(dead_code)]
 40  #[derive(Debug, Clone, Serialize, Deserialize)]
 41  pub struct JsonRpcResponse {
 42      pub jsonrpc: String,
 43      #[serde(skip_serializing_if = "Option::is_none")]
 44      pub id: Option<i64>,
 45      #[serde(skip_serializing_if = "Option::is_none")]
 46      pub result: Option<Value>,
 47      #[serde(skip_serializing_if = "Option::is_none")]
 48      pub error: Option<JsonRpcError>,
 49  }
 50  
 51  #[allow(dead_code)]
 52  #[derive(Debug, Clone, Serialize, Deserialize)]
 53  pub struct JsonRpcError {
 54      pub code: i64,
 55      pub message: String,
 56      #[serde(skip_serializing_if = "Option::is_none")]
 57      pub data: Option<Value>,
 58  }
 59  
 60  #[derive(Debug, Clone, Serialize, Deserialize)]
 61  pub struct JsonRpcNotification {
 62      pub jsonrpc: String,
 63      pub method: String,
 64      #[serde(skip_serializing_if = "Option::is_none")]
 65      pub params: Option<Value>,
 66  }
 67  
 68  impl LspTestClient {
 69      pub fn spawn(workspace_root: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
 70          let lsp_binary = std::env::var("ECOLOG_LSP_BINARY")
 71              .map(PathBuf::from)
 72              .unwrap_or_else(|_| {
 73                  let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
 74  
 75                  let local_binary = manifest_dir.join("target").join("debug").join("ecolog-lsp");
 76                  if local_binary.exists() {
 77                      return local_binary;
 78                  }
 79  
 80                  manifest_dir
 81                      .parent()
 82                      .map(|p| p.join("target").join("debug").join("ecolog-lsp"))
 83                      .unwrap_or(local_binary)
 84              });
 85  
 86          if !lsp_binary.exists() {
 87              return Err(format!(
 88                  "LSP binary not found at {:?}. Run 'cargo build' first.",
 89                  lsp_binary
 90              )
 91              .into());
 92          }
 93  
 94          let mut child = Command::new(&lsp_binary)
 95              .current_dir(&workspace_root)
 96              .stdin(Stdio::piped())
 97              .stdout(Stdio::piped())
 98              .stderr(Stdio::inherit())
 99              .spawn()?;
100  
101          let stdin = child.stdin.take().expect("Failed to capture stdin");
102          let stdout = child.stdout.take().expect("Failed to capture stdout");
103  
104          let stdin = Arc::new(Mutex::new(stdin));
105          let pending_responses = Arc::new(RwLock::new(HashMap::new()));
106          let notifications = Arc::new(RwLock::new(Vec::new()));
107          let open_documents = Arc::new(Mutex::new(HashMap::new()));
108  
109          let pending_clone = Arc::clone(&pending_responses);
110          let notifications_clone = Arc::clone(&notifications);
111          let stdin_clone = Arc::clone(&stdin);
112  
113          let reader_handle = thread::spawn(move || {
114              Self::read_messages(stdout, pending_clone, notifications_clone, stdin_clone);
115          });
116  
117          Ok(Self {
118              _child: child,
119              stdin,
120              pending_responses,
121              notifications,
122              open_documents,
123              _reader_handle: reader_handle,
124              workspace_root,
125          })
126      }
127  
128      fn read_messages(
129          stdout: ChildStdout,
130          pending: Arc<RwLock<HashMap<i64, Value>>>,
131          notifications: Arc<RwLock<Vec<JsonRpcNotification>>>,
132          stdin: Arc<Mutex<ChildStdin>>,
133      ) {
134          let mut reader = BufReader::new(stdout);
135  
136          loop {
137              let mut content_length: Option<usize> = None;
138              loop {
139                  let mut line = String::new();
140                  if reader.read_line(&mut line).unwrap_or(0) == 0 {
141                      return;
142                  }
143                  let line = line.trim();
144                  if line.is_empty() {
145                      break;
146                  }
147                  if let Some(len_str) = line.strip_prefix("Content-Length:") {
148                      content_length = len_str.trim().parse().ok();
149                  }
150              }
151  
152              let Some(len) = content_length else {
153                  continue;
154              };
155  
156              let mut content = vec![0u8; len];
157              if std::io::Read::read_exact(&mut reader, &mut content).is_err() {
158                  return;
159              }
160  
161              let Ok(message): Result<Value, _> = serde_json::from_slice(&content) else {
162                  continue;
163              };
164  
165              if let Some(id) = message.get("id").and_then(|v| v.as_i64()) {
166                  if message.get("result").is_some() || message.get("error").is_some() {
167                      pending.write().unwrap().insert(id, message);
168                  } else if message.get("method").is_some() {
169                      // Server-to-client request: respond with empty success
170                      let response = json!({
171                          "jsonrpc": "2.0",
172                          "id": id,
173                          "result": null
174                      });
175                      let content = serde_json::to_string(&response).unwrap();
176                      let header = format!("Content-Length: {}\r\n\r\n", content.len());
177                      if let Ok(mut stdin_guard) = stdin.lock() {
178                          let _ = stdin_guard.write_all(header.as_bytes());
179                          let _ = stdin_guard.write_all(content.as_bytes());
180                          let _ = stdin_guard.flush();
181                      }
182                  }
183              } else if message.get("method").is_some() {
184                  if let Ok(notif) = serde_json::from_value::<JsonRpcNotification>(message) {
185                      notifications.write().unwrap().push(notif);
186                  }
187              }
188          }
189      }
190  
191      pub fn request(
192          &self,
193          method: &str,
194          params: Option<Value>,
195      ) -> Result<Value, Box<dyn std::error::Error>> {
196          self.request_with_timeout(method, params, Duration::from_secs(30))
197      }
198  
199      pub fn request_with_timeout(
200          &self,
201          method: &str,
202          params: Option<Value>,
203          duration: Duration,
204      ) -> Result<Value, Box<dyn std::error::Error>> {
205          let id = REQUEST_ID.fetch_add(1, Ordering::SeqCst);
206  
207          let request = JsonRpcRequest {
208              jsonrpc: "2.0".to_string(),
209              id,
210              method: method.to_string(),
211              params,
212          };
213  
214          self.send_message(&serde_json::to_value(&request)?)?;
215  
216          let start = Instant::now();
217          loop {
218              {
219                  let mut responses = self.pending_responses.write().unwrap();
220                  if let Some(response) = responses.remove(&id) {
221                      if let Some(error) = response.get("error") {
222                          return Err(format!("LSP Error: {:?}", error).into());
223                      }
224                      return Ok(response.get("result").cloned().unwrap_or(Value::Null));
225                  }
226              }
227  
228              if start.elapsed() > duration {
229                  return Err(format!("Request '{}' timed out after {:?}", method, duration).into());
230              }
231  
232              thread::sleep(Duration::from_millis(10));
233          }
234      }
235  
236      pub fn notify(
237          &self,
238          method: &str,
239          params: Option<Value>,
240      ) -> Result<(), Box<dyn std::error::Error>> {
241          let notification = json!({
242              "jsonrpc": "2.0",
243              "method": method,
244              "params": params
245          });
246          self.send_message(&notification)
247      }
248  
249      fn send_message(&self, message: &Value) -> Result<(), Box<dyn std::error::Error>> {
250          let content = serde_json::to_string(message)?;
251          let header = format!("Content-Length: {}\r\n\r\n", content.len());
252  
253          let mut stdin = self.stdin.lock().unwrap();
254          stdin.write_all(header.as_bytes())?;
255          stdin.write_all(content.as_bytes())?;
256          stdin.flush()?;
257  
258          Ok(())
259      }
260  
261      #[allow(dead_code)]
262      pub fn get_notifications(&self) -> Vec<JsonRpcNotification> {
263          self.notifications.read().unwrap().clone()
264      }
265  
266      pub fn get_notifications_by_method(&self, method: &str) -> Vec<JsonRpcNotification> {
267          self.notifications
268              .read()
269              .unwrap()
270              .iter()
271              .filter(|n| n.method == method)
272              .cloned()
273              .collect()
274      }
275  
276      pub fn clear_notifications(&self) {
277          self.notifications.write().unwrap().clear();
278      }
279  
280      pub fn wait_for_notification(
281          &self,
282          method: &str,
283          timeout_duration: Duration,
284      ) -> Option<JsonRpcNotification> {
285          let start = Instant::now();
286          loop {
287              let notifications = self.get_notifications_by_method(method);
288              if let Some(n) = notifications.last() {
289                  return Some(n.clone());
290              }
291              if start.elapsed() > timeout_duration {
292                  return None;
293              }
294              thread::sleep(Duration::from_millis(50));
295          }
296      }
297  
298      pub fn initialize(&self) -> Result<Value, Box<dyn std::error::Error>> {
299          let init_params = json!({
300              "processId": std::process::id(),
301              "rootUri": format!("file://{}", self.workspace_root.display()),
302              "rootPath": self.workspace_root.display().to_string(),
303              "capabilities": {
304                  "textDocument": {
305                      "hover": { "contentFormat": ["markdown", "plaintext"] },
306                      "completion": {
307                          "completionItem": {
308                              "snippetSupport": true,
309                              "documentationFormat": ["markdown", "plaintext"]
310                          }
311                      },
312                      "definition": {},
313                      "references": {},
314                      "rename": { "prepareSupport": true },
315                      "publishDiagnostics": {}
316                  },
317                  "workspace": {
318                      "didChangeWatchedFiles": { "dynamicRegistration": true }
319                  }
320              }
321          });
322  
323          let result = self.request("initialize", Some(init_params))?;
324  
325          self.notify("initialized", Some(json!({})))?;
326  
327          thread::sleep(Duration::from_millis(500));
328  
329          Ok(result)
330      }
331  
332      pub fn shutdown(&self) -> Result<(), Box<dyn std::error::Error>> {
333          self.request("shutdown", None)?;
334          self.notify("exit", None)?;
335          Ok(())
336      }
337  
338      pub fn open_document(
339          &self,
340          uri: &str,
341          language_id: &str,
342          text: &str,
343      ) -> Result<(), Box<dyn std::error::Error>> {
344          self.notify(
345              "textDocument/didOpen",
346              Some(json!({
347                  "textDocument": {
348                      "uri": uri,
349                      "languageId": language_id,
350                      "version": 1,
351                      "text": text
352                  }
353              })),
354          )?;
355          self.open_documents
356              .lock()
357              .unwrap()
358              .insert(uri.to_string(), text.to_string());
359          Ok(())
360      }
361  
362      pub fn change_document(
363          &self,
364          uri: &str,
365          version: i32,
366          text: &str,
367      ) -> Result<(), Box<dyn std::error::Error>> {
368          let mut docs = self.open_documents.lock().unwrap();
369          let previous = docs.get(uri).cloned().unwrap_or_default();
370          let end = full_document_end_position_utf16(&previous);
371  
372          self.notify(
373              "textDocument/didChange",
374              Some(json!({
375                  "textDocument": {
376                      "uri": uri,
377                      "version": version
378                  },
379                  "contentChanges": [{
380                      "range": {
381                          "start": { "line": 0, "character": 0 },
382                          "end": { "line": end.0, "character": end.1 }
383                      },
384                      "text": text
385                  }]
386              })),
387          )?;
388  
389          docs.insert(uri.to_string(), text.to_string());
390          Ok(())
391      }
392  
393      pub fn close_document(&self, uri: &str) -> Result<(), Box<dyn std::error::Error>> {
394          self.notify(
395              "textDocument/didClose",
396              Some(json!({
397                  "textDocument": { "uri": uri }
398              })),
399          )?;
400          self.open_documents.lock().unwrap().remove(uri);
401          Ok(())
402      }
403  
404      pub fn hover(
405          &self,
406          uri: &str,
407          line: u32,
408          character: u32,
409      ) -> Result<Value, Box<dyn std::error::Error>> {
410          self.request(
411              "textDocument/hover",
412              Some(json!({
413                  "textDocument": { "uri": uri },
414                  "position": { "line": line, "character": character }
415              })),
416          )
417      }
418  
419      pub fn completion(
420          &self,
421          uri: &str,
422          line: u32,
423          character: u32,
424      ) -> Result<Value, Box<dyn std::error::Error>> {
425          self.request(
426              "textDocument/completion",
427              Some(json!({
428                  "textDocument": { "uri": uri },
429                  "position": { "line": line, "character": character }
430              })),
431          )
432      }
433  
434      pub fn definition(
435          &self,
436          uri: &str,
437          line: u32,
438          character: u32,
439      ) -> Result<Value, Box<dyn std::error::Error>> {
440          self.request(
441              "textDocument/definition",
442              Some(json!({
443                  "textDocument": { "uri": uri },
444                  "position": { "line": line, "character": character }
445              })),
446          )
447      }
448  
449      pub fn references(
450          &self,
451          uri: &str,
452          line: u32,
453          character: u32,
454          include_declaration: bool,
455      ) -> Result<Value, Box<dyn std::error::Error>> {
456          self.request(
457              "textDocument/references",
458              Some(json!({
459                  "textDocument": { "uri": uri },
460                  "position": { "line": line, "character": character },
461                  "context": { "includeDeclaration": include_declaration }
462              })),
463          )
464      }
465  
466      pub fn prepare_rename(
467          &self,
468          uri: &str,
469          line: u32,
470          character: u32,
471      ) -> Result<Value, Box<dyn std::error::Error>> {
472          self.request(
473              "textDocument/prepareRename",
474              Some(json!({
475                  "textDocument": { "uri": uri },
476                  "position": { "line": line, "character": character }
477              })),
478          )
479      }
480  
481      pub fn rename(
482          &self,
483          uri: &str,
484          line: u32,
485          character: u32,
486          new_name: &str,
487      ) -> Result<Value, Box<dyn std::error::Error>> {
488          self.request(
489              "textDocument/rename",
490              Some(json!({
491                  "textDocument": { "uri": uri },
492                  "position": { "line": line, "character": character },
493                  "newName": new_name
494              })),
495          )
496      }
497  
498      pub fn execute_command(
499          &self,
500          command: &str,
501          arguments: Vec<Value>,
502      ) -> Result<Value, Box<dyn std::error::Error>> {
503          self.request(
504              "workspace/executeCommand",
505              Some(json!({
506                  "command": command,
507                  "arguments": arguments
508              })),
509          )
510      }
511  
512      pub fn workspace_symbol(&self, query: &str) -> Result<Value, Box<dyn std::error::Error>> {
513          self.request(
514              "workspace/symbol",
515              Some(json!({
516                  "query": query
517              })),
518          )
519      }
520  
521      /// Request inlay hints for a range within a document
522      pub fn inlay_hint(
523          &self,
524          uri: &str,
525          start_line: u32,
526          start_character: u32,
527          end_line: u32,
528          end_character: u32,
529      ) -> Result<Value, Box<dyn std::error::Error>> {
530          self.request(
531              "textDocument/inlayHint",
532              Some(json!({
533                  "textDocument": { "uri": uri },
534                  "range": {
535                      "start": { "line": start_line, "character": start_character },
536                      "end": { "line": end_line, "character": end_character }
537                  }
538              })),
539          )
540      }
541  }
542  
543  fn full_document_end_position_utf16(text: &str) -> (u32, u32) {
544      let mut line = 0u32;
545      let mut col_utf16 = 0u32;
546      for ch in text.chars() {
547          if ch == '\n' {
548              line += 1;
549              col_utf16 = 0;
550          } else {
551              col_utf16 += ch.len_utf16() as u32;
552          }
553      }
554      (line, col_utf16)
555  }