use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, ChildStdout, Command as StdCommand, ExitStatus, Stdio};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
use std::time::{Duration, Instant};
use lsp_server::{Message, Notification, Request, RequestId, Response};
use rmcp::RoleClient;
use rmcp::ServiceExt as _;
use rmcp::model::{CallToolRequestParams, CallToolResult};
use rmcp::service::RunningService;
use rmcp::transport::TokioChildProcess;
use serde_json::json;
use tokio::process::Command;
const TIMEOUT: Duration = Duration::from_secs(20);
const BROKEN_TYPL: &str = "package p\ntype X:";
const THREE_KINDS_TYPL: &str = "package p\ntype Tag : string\ntype Bad : integer [10..5]\ntype X:";
async fn connect() -> RunningService<RoleClient, ()> {
let mut command = Command::new(env!("CARGO_BIN_EXE_ridl"));
command.arg("mcp");
let transport = TokioChildProcess::new(command).expect("spawn ridl mcp");
().serve(transport).await.expect("MCP initialize handshake")
}
fn tool_text(result: &CallToolResult) -> String {
result
.content
.iter()
.filter_map(|content| content.as_text())
.map(|text| text.text.clone())
.collect()
}
async fn call_ridl_check(
client: &RunningService<RoleClient, ()>,
source: &str,
profile: &str,
) -> serde_json::Value {
let arguments = json!({ "source": source, "profile": profile })
.as_object()
.cloned()
.expect("the arguments are a JSON object");
let result = client
.call_tool(CallToolRequestParams::new("ridl_check").with_arguments(arguments))
.await
.expect("tools/call");
assert_ne!(result.is_error, Some(true), "{result:?}");
let text = tool_text(&result);
serde_json::from_str(&text).unwrap_or_else(|err| panic!("the tool returns JSON: {err}: {text}"))
}
#[tokio::test]
async fn ridl_mcp_advertises_ridl_check() {
tokio::time::timeout(TIMEOUT, async {
let client = connect().await;
let tools = client
.list_tools(Default::default())
.await
.expect("tools/list");
let names: Vec<&str> = tools.tools.iter().map(|tool| tool.name.as_ref()).collect();
assert_eq!(names, ["ridl_check"]);
client.cancel().await.expect("shutdown");
})
.await
.expect("ridl_mcp_advertises_ridl_check did not finish within the timeout");
}
#[tokio::test]
async fn ridl_check_returns_the_diagnostic_contract() {
tokio::time::timeout(TIMEOUT, async {
let client = connect().await;
let output = call_ridl_check(&client, BROKEN_TYPL, "typl").await;
client.cancel().await.expect("shutdown");
let diagnostics = output["diagnostics"]
.as_array()
.unwrap_or_else(|| panic!("a diagnostics array: {output}"));
assert_eq!(diagnostics.len(), 1, "{output}");
let diagnostic = &diagnostics[0];
assert_eq!(diagnostic["code"], "FORM-101", "{output}");
assert_eq!(diagnostic["severity"], "error", "{output}");
assert_eq!(diagnostic["span"]["path"], "input.typl", "{output}");
assert_eq!(diagnostic["span"]["start"]["line"], 2, "{output}");
assert_eq!(diagnostic["span"]["start"]["column"], 8, "{output}");
assert!(diagnostic["fixes"].is_array(), "{output}");
})
.await
.expect("ridl_check_returns_the_diagnostic_contract did not finish within the timeout");
}
fn blank_span_paths(diagnostics: &mut serde_json::Value) {
let blank = || serde_json::Value::String(String::new());
for diagnostic in diagnostics.as_array_mut().into_iter().flatten() {
diagnostic["span"]["path"] = blank();
for fix in diagnostic["fixes"].as_array_mut().into_iter().flatten() {
fix["span"]["path"] = blank();
}
}
}
#[tokio::test]
async fn ridl_check_and_check_format_json_agree() {
tokio::time::timeout(TIMEOUT, async {
let dir = TempDir::new("agree");
let path = dir.write("agree.typl", THREE_KINDS_TYPL);
let cli = StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.args(["check", "--format", "json"])
.arg(&path)
.output()
.expect("run ridl check");
assert_eq!(cli.status.code(), Some(1), "{cli:?}");
let mut cli: serde_json::Value =
serde_json::from_slice(&cli.stdout).expect("the CLI prints JSON to stdout");
let client = connect().await;
let mut mcp = call_ridl_check(&client, THREE_KINDS_TYPL, "typl").await;
client.cancel().await.expect("shutdown");
let mut mcp = mcp["diagnostics"].take();
assert!(
cli.as_array().is_some_and(|array| array.len() >= 2),
"the fixture must produce at least two diagnostics, or this proves nothing: {cli}"
);
assert!(
mcp.as_array().is_some_and(|array| array.len() >= 2),
"the tool must report at least two diagnostics, or this proves nothing: {mcp}"
);
blank_span_paths(&mut cli);
blank_span_paths(&mut mcp);
assert_eq!(cli, mcp);
})
.await
.expect("ridl_check_and_check_format_json_agree did not finish within the timeout");
}
fn spawn_mcp() -> Child {
StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ridl mcp")
}
fn read_json_lines(stdout: ChildStdout) -> mpsc::Receiver<serde_json::Value> {
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut stdout = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
match stdout.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let Ok(value) = serde_json::from_str(&line) else {
continue;
};
if sender.send(value).is_err() {
break;
}
}
Err(_) => break,
}
}
});
receiver
}
fn next_json_line(messages: &mpsc::Receiver<serde_json::Value>) -> serde_json::Value {
messages
.recv_timeout(TIMEOUT)
.unwrap_or_else(|err| panic!("no JSON-RPC line within {TIMEOUT:?}: {err}"))
}
fn initialize_request() -> serde_json::Value {
json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "servers-test", "version": "0.0.0" }
}
})
}
#[test]
fn ridl_mcp_serves_the_handshake_and_exits_zero_on_shutdown() {
let mut child = spawn_mcp();
let mut stdin = child.stdin.take().expect("piped stdin");
let messages = read_json_lines(child.stdout.take().expect("piped stdout"));
writeln!(stdin, "{}", initialize_request()).expect("write initialize");
let response = next_json_line(&messages);
assert_eq!(
response["result"]["serverInfo"]["name"], "ridl-mcp",
"{response}"
);
assert_eq!(
response["result"]["serverInfo"]["version"],
env!("RIDL_BUILD_VERSION"),
"{response}"
);
writeln!(
stdin,
"{}",
json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })
)
.expect("write initialized notification");
drop(stdin);
let status = wait_for_exit(&mut child, "ridl mcp");
assert_eq!(status.code(), Some(0), "a clean shutdown exits 0");
}
#[test]
fn ridl_mcp_exits_two_when_stdin_closes_before_initialize() {
let mut child = spawn_mcp();
drop(child.stdin.take().expect("piped stdin"));
let status = wait_for_exit(&mut child, "ridl mcp");
assert_eq!(status.code(), Some(2), "a lost transport exits 2");
}
fn spawn_lsp() -> Child {
StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.arg("lsp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn ridl lsp")
}
fn read_messages(stdout: ChildStdout) -> mpsc::Receiver<Message> {
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let mut stdout = BufReader::new(stdout);
while let Ok(Some(message)) = Message::read(&mut stdout) {
if sender.send(message).is_err() {
break;
}
}
});
receiver
}
fn response_to(messages: &mpsc::Receiver<Message>, id: RequestId) -> Response {
let deadline = Instant::now() + TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
match messages.recv_timeout(remaining) {
Ok(Message::Response(response)) if response.id == id => return response,
Ok(_) => {}
Err(err) => panic!("no response to request {id} within {TIMEOUT:?}: {err}"),
}
}
}
fn wait_for_exit(child: &mut Child, what: &str) -> ExitStatus {
let deadline = Instant::now() + TIMEOUT;
loop {
match child.try_wait().expect("poll the child") {
Some(status) => return status,
None if Instant::now() >= deadline => {
let _ = child.kill();
let _ = child.wait();
panic!("{what} did not exit within {TIMEOUT:?}");
}
None => std::thread::sleep(Duration::from_millis(20)),
}
}
}
#[test]
fn ridl_lsp_serves_the_handshake_and_exits_zero_on_shutdown() {
let mut child = spawn_lsp();
let mut stdin = child.stdin.take().expect("piped stdin");
let messages = read_messages(child.stdout.take().expect("piped stdout"));
let initialize = RequestId::from(1);
Message::Request(Request::new(
initialize.clone(),
"initialize".to_string(),
json!({ "capabilities": {} }),
))
.write(&mut stdin)
.expect("write initialize");
let result = response_to(&messages, initialize)
.response_result
.expect("an initialize result, not an error");
assert!(
result["capabilities"]["textDocumentSync"].is_object(),
"{result}"
);
assert_eq!(
result["serverInfo"]["version"],
env!("RIDL_BUILD_VERSION"),
"{result}"
);
Message::Notification(Notification::new("initialized".to_string(), json!({})))
.write(&mut stdin)
.expect("write initialized");
let shutdown = RequestId::from(2);
Message::Request(Request::new(
shutdown.clone(),
"shutdown".to_string(),
json!(null),
))
.write(&mut stdin)
.expect("write shutdown");
response_to(&messages, shutdown)
.response_result
.expect("a shutdown result, not an error");
Message::Notification(Notification::new("exit".to_string(), json!(null)))
.write(&mut stdin)
.expect("write exit");
drop(stdin);
let status = wait_for_exit(&mut child, "ridl lsp");
assert_eq!(status.code(), Some(0), "a clean shutdown exits 0");
}
#[test]
fn ridl_lsp_exits_two_when_stdin_closes_before_initialize() {
let mut child = spawn_lsp();
drop(child.stdin.take().expect("piped stdin"));
let status = wait_for_exit(&mut child, "ridl lsp");
assert_eq!(status.code(), Some(2), "a lost transport exits 2");
}
struct TempDir(PathBuf);
impl TempDir {
fn new(label: &str) -> Self {
static COUNTER: AtomicUsize = AtomicUsize::new(0);
let mut path = std::env::temp_dir();
path.push(format!(
"ridl-servers-{label}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst),
));
std::fs::create_dir_all(&path).expect("create the temp dir");
Self(path)
}
fn write(&self, relative: &str, text: &str) -> PathBuf {
let path = self.0.join(relative);
std::fs::write(&path, text).expect("write the fixture file");
path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}