#[cfg(unix)]
use super::test_support::{expect_method, read_existing_message};
use super::{ClientTransport, LspClient, format_spawn_error};
#[cfg(unix)]
use crate::lsp::transport::{read_message, write_message};
use crate::test_support::{
SUBPROCESS_HELPER_EXIT_CODE_ENV, SUBPROCESS_HELPER_OUTPUT_PATH_ENV,
SUBPROCESS_HELPER_STDERR_ENV, TestDir, env_var, subprocess_helper_command,
subprocess_helper_env, with_env_vars,
};
#[cfg(unix)]
use serde_json::json;
use std::fs;
use std::time::Duration;
#[cfg(unix)]
use std::fs::File;
#[cfg(unix)]
use std::io::BufReader;
#[cfg(unix)]
use std::io::Write;
#[cfg(unix)]
use std::os::fd::AsRawFd;
#[cfg(unix)]
use std::os::unix::net::UnixListener;
#[cfg(unix)]
use std::sync::{Mutex, OnceLock};
#[cfg(unix)]
use std::thread;
#[test]
fn formats_missing_binary_error() {
let error = std::io::Error::new(std::io::ErrorKind::NotFound, "missing");
assert_eq!(
format_spawn_error("ast-grep", &error),
"LSP server executable `ast-grep` is not installed or not in $PATH"
);
}
#[cfg(unix)]
fn write_publish_diagnostics<W: Write>(
writer: &mut W,
uri: &str,
line: u64,
message: &str,
context: &str,
) {
write_message(
writer,
&json!({
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": uri,
"diagnostics": [{
"range": {
"start": {"line": line, "character": 1},
"end": {"line": line, "character": 2}
},
"message": message
}]
}
}),
)
.expect(context);
}
#[cfg(unix)]
#[test]
fn starts_server_in_workspace_root() {
let dir = TestDir::new("client");
let workspace_root = dir.path().join("workspace");
fs::create_dir_all(&workspace_root).expect("workspace should be created");
let cwd_file = dir.path().join("cwd.txt");
let command = subprocess_helper_command();
let mut client = with_env_vars(
&subprocess_helper_env(
"write-cwd",
&[env_var(SUBPROCESS_HELPER_OUTPUT_PATH_ENV, &cwd_file)],
),
|| {
LspClient::new(&command, &workspace_root, false, Duration::from_secs(1))
.expect("helper process should start")
},
);
let status = match &mut client.transport {
ClientTransport::Process { child, .. } => child.wait().expect("helper process should exit"),
ClientTransport::Socket { .. } => panic!("expected process transport"),
};
assert!(status.success());
assert_eq!(
fs::read_to_string(&cwd_file)
.expect("cwd file should be written")
.trim_end(),
workspace_root.display().to_string()
);
}
#[cfg(unix)]
#[test]
fn shutdown_tolerates_truncated_final_message_before_exit() {
use crate::lsp::jsonrpc;
use crate::lsp::transport::frame_message;
let dir = TestDir::new("client-truncated-exit");
let workspace_root = dir.path().join("workspace");
fs::create_dir_all(&workspace_root).expect("workspace should be created");
let shutdown_request = jsonrpc(Some(1u64), "shutdown", &())
.and_then(|message| frame_message(&message))
.expect("shutdown request should frame");
let exit_notification = jsonrpc::<u64, _>(None, "exit", &())
.and_then(|message| frame_message(&message))
.expect("exit notification should frame");
let shutdown_body = r#"{"jsonrpc":"2.0","id":1,"result":null}"#;
let truncated_body = r#"{"jsonrpc":"2.0","method":"window/logMessage","params":{}}"#;
let truncated_half = &truncated_body[..truncated_body.len() / 2];
let script = format!(
"head -c {} >/dev/null; \
printf 'Content-Length: {}\\r\\n\\r\\n{}'; \
head -c {} >/dev/null; \
printf 'Content-Length: {}\\r\\n\\r\\n{}'; \
exit 0",
shutdown_request.len(),
shutdown_body.len(),
shutdown_body,
exit_notification.len(),
truncated_body.len(),
truncated_half,
);
let command = vec!["sh".to_string(), "-c".to_string(), script];
let mut client = LspClient::new(&command, &workspace_root, false, Duration::from_secs(5))
.expect("helper process should start");
client
.shutdown()
.expect("shutdown should tolerate a truncated final message from the server");
}
#[cfg(unix)]
#[test]
fn hides_server_stderr_without_debug() {
assert_eq!(captured_server_stderr(false), "");
}
#[cfg(unix)]
#[test]
fn keeps_server_stderr_visible_with_debug() {
assert!(captured_server_stderr(true).contains("server stderr\n"));
}
#[cfg(unix)]
#[test]
fn collects_latest_publish_diagnostics_notifications() {
let dir = TestDir::new("client-diagnostics");
let socket_path = dir.path().join("server.sock");
let listener = UnixListener::bind(&socket_path).expect("socket should bind");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().expect("client should connect");
let reader_stream = stream.try_clone().expect("stream should clone");
let mut reader = BufReader::new(reader_stream);
let mut writer = stream;
let initialize = read_existing_message(
&mut reader,
"initialize should parse",
"initialize should exist",
);
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": initialize.get("id").cloned().expect("initialize id should exist"),
"result": { "capabilities": {} },
}),
)
.expect("initialize response should write");
expect_method(
&read_existing_message(
&mut reader,
"initialized should parse",
"initialized should exist",
),
"initialized",
);
write_publish_diagnostics(
&mut writer,
"file:///workspace/src/main.rs",
0,
"first",
"first diagnostics should write",
);
write_publish_diagnostics(
&mut writer,
"file:///workspace/src/main.rs",
1,
"second",
"second diagnostics should write",
);
let shutdown = read_existing_message(
&mut reader,
"shutdown should parse",
"shutdown should exist",
);
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": shutdown.get("id").cloned().expect("shutdown id should exist"),
"result": null,
}),
)
.expect("shutdown response should write");
expect_method(
&read_existing_message(&mut reader, "exit should parse", "exit should exist"),
"exit",
);
});
let mut client =
LspClient::connect_unix(&socket_path, false, Duration::from_secs(1)).expect("connect");
client
.initialize("file:///workspace", "workspace", false)
.expect("initialize should succeed");
client
.collect_diagnostics(Duration::from_millis(100))
.expect("collect should succeed");
let diagnostics = client.take_published_diagnostics();
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0]
.get("params")
.and_then(|value| value.get("diagnostics"))
.and_then(|value| value.get(0))
.and_then(|value| value.get("message"))
.and_then(serde_json::Value::as_str),
Some("second")
);
client.shutdown().expect("shutdown should succeed");
server.join().expect("server thread should finish");
}
#[cfg(unix)]
#[test]
fn sends_document_diagnostic_request() {
let dir = TestDir::new("client-document-diagnostic");
let socket_path = dir.path().join("server.sock");
let listener = UnixListener::bind(&socket_path).expect("socket should bind");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().expect("client should connect");
let reader_stream = stream.try_clone().expect("stream should clone");
let mut reader = BufReader::new(reader_stream);
let mut writer = stream;
let initialize = read_message(&mut reader)
.expect("initialize should parse")
.expect("initialize should exist");
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": initialize.get("id").cloned().expect("initialize id should exist"),
"result": {
"capabilities": {
"diagnosticProvider": {"interFileDependencies": false, "workspaceDiagnostics": false}
}
},
}),
)
.expect("initialize response should write");
let initialized = read_message(&mut reader)
.expect("initialized should parse")
.expect("initialized should exist");
assert_eq!(
initialized
.get("method")
.and_then(serde_json::Value::as_str),
Some("initialized")
);
let request = read_message(&mut reader)
.expect("document diagnostic should parse")
.expect("document diagnostic should exist");
assert_eq!(
request.get("method").and_then(serde_json::Value::as_str),
Some("textDocument/diagnostic")
);
assert_eq!(
request
.get("params")
.and_then(|value| value.get("textDocument"))
.and_then(|value| value.get("uri"))
.and_then(serde_json::Value::as_str),
Some("file:///workspace/src/main.rs")
);
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": request.get("id").cloned().expect("request id should exist"),
"result": {"kind": "full", "items": []},
}),
)
.expect("document diagnostic response should write");
let shutdown = read_message(&mut reader)
.expect("shutdown should parse")
.expect("shutdown should exist");
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": shutdown.get("id").cloned().expect("shutdown id should exist"),
"result": null,
}),
)
.expect("shutdown response should write");
let exit = read_message(&mut reader)
.expect("exit should parse")
.expect("exit should exist");
assert_eq!(
exit.get("method").and_then(serde_json::Value::as_str),
Some("exit")
);
});
let mut client =
LspClient::connect_unix(&socket_path, false, Duration::from_secs(1)).expect("connect");
client
.initialize("file:///workspace", "workspace", false)
.expect("initialize should succeed");
client
.document_diagnostic("file:///workspace/src/main.rs")
.expect("document diagnostic should succeed");
client.shutdown().expect("shutdown should succeed");
server.join().expect("server thread should finish");
}
#[cfg(unix)]
#[test]
fn sends_document_formatting_request() {
let dir = TestDir::new("client-document-formatting");
let socket_path = dir.path().join("server.sock");
let listener = UnixListener::bind(&socket_path).expect("socket should bind");
let server = thread::spawn(move || {
let (stream, _) = listener.accept().expect("client should connect");
let reader_stream = stream.try_clone().expect("stream should clone");
let mut reader = BufReader::new(reader_stream);
let mut writer = stream;
let initialize = read_message(&mut reader)
.expect("initialize should parse")
.expect("initialize should exist");
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": initialize.get("id").cloned().expect("initialize id should exist"),
"result": {
"capabilities": {
"documentFormattingProvider": true
}
},
}),
)
.expect("initialize response should write");
let initialized = read_message(&mut reader)
.expect("initialized should parse")
.expect("initialized should exist");
assert_eq!(
initialized
.get("method")
.and_then(serde_json::Value::as_str),
Some("initialized")
);
let request = read_message(&mut reader)
.expect("format request should parse")
.expect("format request should exist");
assert_eq!(
request.get("method").and_then(serde_json::Value::as_str),
Some("textDocument/formatting")
);
assert_eq!(
request
.get("params")
.and_then(|value| value.get("options"))
.and_then(|value| value.get("tabSize"))
.and_then(serde_json::Value::as_u64),
Some(4)
);
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": request.get("id").cloned().expect("request id should exist"),
"result": [],
}),
)
.expect("format response should write");
let shutdown = read_message(&mut reader)
.expect("shutdown should parse")
.expect("shutdown should exist");
write_message(
&mut writer,
&json!({
"jsonrpc": "2.0",
"id": shutdown.get("id").cloned().expect("shutdown id should exist"),
"result": null,
}),
)
.expect("shutdown response should write");
let exit = read_message(&mut reader)
.expect("exit should parse")
.expect("exit should exist");
assert_eq!(
exit.get("method").and_then(serde_json::Value::as_str),
Some("exit")
);
});
let mut client =
LspClient::connect_unix(&socket_path, false, Duration::from_secs(1)).expect("connect");
client
.initialize("file:///workspace", "workspace", false)
.expect("initialize should succeed");
client
.format_document("file:///workspace/src/main.rs")
.expect("format request should succeed");
client.shutdown().expect("shutdown should succeed");
server.join().expect("server thread should finish");
}
#[cfg(unix)]
fn captured_server_stderr(debug: bool) -> String {
let _lock = stderr_lock()
.lock()
.expect("stderr lock should be available");
let dir = TestDir::new("client-stderr");
let workspace_root = dir.path().join("workspace");
fs::create_dir_all(&workspace_root).expect("workspace should be created");
let stderr_file = dir.path().join("stderr.txt");
let command = subprocess_helper_command();
let mut client;
{
let _capture = StderrCapture::new(&stderr_file);
client = with_env_vars(
&subprocess_helper_env(
"stderr-and-exit",
&[
env_var(SUBPROCESS_HELPER_STDERR_ENV, "server stderr\n"),
env_var(SUBPROCESS_HELPER_EXIT_CODE_ENV, "0"),
],
),
|| {
LspClient::new(&command, &workspace_root, debug, Duration::from_secs(1))
.expect("helper process should start")
},
);
let status = match &mut client.transport {
ClientTransport::Process { child, .. } => {
child.wait().expect("helper process should exit")
}
ClientTransport::Socket { .. } => panic!("expected process transport"),
};
assert!(status.success());
}
drop(client);
fs::read_to_string(stderr_file).expect("stderr capture should be readable")
}
#[cfg(unix)]
fn stderr_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
#[cfg(unix)]
struct StderrCapture {
saved_stderr: i32,
}
#[cfg(unix)]
impl StderrCapture {
fn new(path: &std::path::Path) -> Self {
let mut stderr = std::io::stderr().lock();
stderr.flush().expect("stderr should flush before capture");
let file = File::create(path).expect("stderr capture file should be created");
let saved_stderr = unsafe { dup(STDERR_FILENO) };
assert!(saved_stderr >= 0, "stderr should be duplicated");
let redirected = unsafe { dup2(file.as_raw_fd(), STDERR_FILENO) };
assert!(redirected >= 0, "stderr should be redirected");
drop(file);
Self { saved_stderr }
}
}
#[cfg(unix)]
impl Drop for StderrCapture {
fn drop(&mut self) {
let mut stderr = std::io::stderr().lock();
stderr.flush().expect("stderr should flush before restore");
let restored = unsafe { dup2(self.saved_stderr, STDERR_FILENO) };
assert!(restored >= 0, "stderr should be restored");
let closed = unsafe { close(self.saved_stderr) };
assert_eq!(closed, 0, "saved stderr fd should be closed");
}
}
#[cfg(unix)]
const STDERR_FILENO: i32 = 2;
#[cfg(unix)]
unsafe extern "C" {
fn close(fd: i32) -> i32;
fn dup(fd: i32) -> i32;
fn dup2(src: i32, dst: i32) -> i32;
}