use std::io::{BufRead, BufReader, Read, Write};
use std::path::{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 mut names: Vec<&str> = tools.tools.iter().map(|tool| tool.name.as_ref()).collect();
names.sort();
assert_eq!(
names,
[
"ridl_check",
"ridl_dependencies",
"ridl_describe_type",
"ridl_diff",
"ridl_explain",
"ridl_list_interactions",
"ridl_metrics",
"ridl_references",
"ridl_resolve"
]
);
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: Vec<&serde_json::Value> = output["diagnostics"]
.as_array()
.unwrap_or_else(|| panic!("a diagnostics array: {output}"))
.iter()
.filter(|diagnostic| diagnostic["code"] != "TYPL-406")
.collect();
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 label in diagnostic["labels"].as_array_mut().into_iter().flatten() {
label["span"]["path"] = blank();
}
for fix in diagnostic["fixes"].as_array_mut().into_iter().flatten() {
fix["span"]["path"] = blank();
}
}
}
async fn assert_faces_agree(file_name: &str, source: &str, profile: &str, codes: &[&str]) {
let dir = TempDir::new("agree");
let path = dir.write(file_name, source);
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, source, profile).await;
client.cancel().await.expect("shutdown");
let mut mcp = mcp["diagnostics"].take();
let cli_codes: Vec<&str> = cli
.as_array()
.into_iter()
.flatten()
.filter_map(|diagnostic| diagnostic["code"].as_str())
.filter(|code| *code != "TYPL-406")
.collect();
assert_eq!(cli_codes, codes, "{cli}");
blank_span_paths(&mut cli);
blank_span_paths(&mut mcp);
assert_eq!(cli, mcp);
}
#[tokio::test]
async fn ridl_check_and_check_format_json_agree() {
tokio::time::timeout(
TIMEOUT,
assert_faces_agree(
"agree.typl",
THREE_KINDS_TYPL,
"typl",
&["FORM-101", "TYPL-103", "TYPL-104"],
),
)
.await
.expect("ridl_check_and_check_format_json_agree did not finish within the timeout");
}
#[tokio::test]
async fn ridl_check_and_check_format_json_agree_on_the_service_catalog() {
tokio::time::timeout(
TIMEOUT,
assert_faces_agree(
"dup.ridl",
"package p\ninterface I {}\nservice p.s : I\nservice p.s : I\n",
"ridl",
&["RIDL-140"],
),
)
.await
.expect("the service catalog agreement test did not finish within the timeout");
}
#[tokio::test]
async fn ridl_check_and_check_format_json_agree_on_the_rsdl_system() {
tokio::time::timeout(
TIMEOUT,
assert_faces_agree(
"sys.rsdl",
"package p\nsystem S { Missing }\n",
"rsdl",
&["RSDL-602"],
),
)
.await
.expect("the rsdl system agreement test 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 workspace_fixture(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../ridl-mcp/tests/fixtures")
.join(name)
}
async fn call_workspace_tool(
client: &RunningService<RoleClient, ()>,
name: &str,
args: serde_json::Value,
) -> CallToolResult {
client
.call_tool(
CallToolRequestParams::new(name.to_owned())
.with_arguments(args.as_object().unwrap().clone()),
)
.await
.expect("tools/call")
}
#[tokio::test]
async fn path_mode_check_equals_the_cli() {
tokio::time::timeout(TIMEOUT, async {
let path = workspace_fixture("ws-diag");
let cli = StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.args(["check", "--format", "json"])
.arg(&path)
.output()
.unwrap();
assert_eq!(cli.status.code(), Some(1));
let expected: serde_json::Value = serde_json::from_slice(&cli.stdout).unwrap();
let client = connect().await;
let result = call_workspace_tool(&client, "ridl_check", json!({"path":path})).await;
assert_ne!(result.is_error, Some(true));
assert_eq!(result.structured_content.unwrap()["diagnostics"], expected);
client.cancel().await.unwrap();
})
.await
.expect("workspace check timeout");
}
#[tokio::test]
async fn an_unsaved_overlay_reports_diagnostics_without_changing_disk() {
tokio::time::timeout(TIMEOUT, async {
let root = workspace_fixture("ws");
let path = root.join("b/b.ridl");
let disk = std::fs::read_to_string(&path).unwrap();
let source = disk.replace("signal speed: Speed @10ms", "signal speed: Missing @10ms");
assert_ne!(source, disk);
let client = connect().await;
let result = call_workspace_tool(
&client,
"ridl_check",
json!({"path":root,"overlays":[{"path":path,"source":source}]}),
)
.await;
assert_ne!(result.is_error, Some(true));
let output = result.structured_content.unwrap();
assert_eq!(output["workspace"]["errors"], 1);
assert!(
output["diagnostics"]
.as_array()
.unwrap()
.iter()
.any(|d| d["message"].as_str().unwrap().contains("Missing")
&& d["span"]["path"] == path.to_string_lossy().as_ref())
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), disk);
client.cancel().await.unwrap();
})
.await
.expect("unsaved overlay timeout");
}
#[tokio::test]
async fn diff_tool_equals_the_cli() {
tokio::time::timeout(TIMEOUT, async {
let old = workspace_fixture("ws");
let new = workspace_fixture("ws-v2");
let cli = StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.args(["diff", "--format", "json"])
.arg(&old)
.arg(&new)
.output()
.unwrap();
assert_eq!(cli.status.code(), Some(1));
let expected: serde_json::Value = serde_json::from_slice(&cli.stdout).unwrap();
let client = connect().await;
let result = call_workspace_tool(&client, "ridl_diff", json!({"old":old,"new":new})).await;
assert_ne!(result.is_error, Some(true));
assert_eq!(result.structured_content.unwrap(), expected);
client.cancel().await.unwrap();
})
.await
.expect("workspace diff timeout");
}
#[tokio::test]
async fn dirty_workspace_lookups_preserve_valid_declarations() {
tokio::time::timeout(TIMEOUT, async {
let path = workspace_fixture("ws");
let client = connect().await;
let overlays = json!([{"path":path.join("a/a.ridl"), "source":""}]);
for (name, from) in [
("fx.a.sub.Gear", None),
("Gear", None),
("Gear", Some("fx.a.sub")),
] {
for tool in ["ridl_resolve", "ridl_describe_type"] {
let result = call_workspace_tool(
&client,
tool,
json!({"path":path, "name":name, "from":from, "overlays":overlays}),
)
.await;
assert_ne!(result.is_error, Some(true), "{result:?}");
let output = result.structured_content.unwrap();
assert_eq!(output["package"], "fx.a.sub");
assert!(output["workspace"]["errors"].as_u64().unwrap() > 0);
if tool == "ridl_resolve" {
assert_eq!(output["name"], "Gear");
} else {
assert_eq!(output["declaration"]["name"], "Gear");
}
}
}
client.cancel().await.unwrap();
})
.await
.expect("dirty workspace lookup timeout");
}
#[tokio::test]
async fn diff_compile_errors_preserve_structured_diagnostics() {
tokio::time::timeout(TIMEOUT, async {
let path = workspace_fixture("ws-diag");
let cli = StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.args(["check", "--format", "json"])
.arg(&path)
.output()
.unwrap();
assert_eq!(cli.status.code(), Some(1));
let expected: serde_json::Value = serde_json::from_slice(&cli.stdout).unwrap();
let client = connect().await;
let clean = workspace_fixture("ws");
for (old, new, message) in [
(&clean, &path, "the new side does not compile"),
(&path, &clean, "the old side does not compile"),
] {
let result =
call_workspace_tool(&client, "ridl_diff", json!({"old":old, "new":new})).await;
assert_eq!(result.is_error, Some(true));
let text: serde_json::Value = serde_json::from_str(&tool_text(&result)).unwrap();
let output = result.structured_content.unwrap();
assert_eq!(text, output);
assert_eq!(output["message"], message);
assert_eq!(output["diagnostics"], expected);
assert_eq!(
output["diagnostics"]
.as_array()
.unwrap()
.iter()
.map(|d| d["code"].as_str().unwrap())
.filter(|code| *code != "TYPL-406")
.collect::<Vec<_>>(),
["TYPL-103", "TYPL-011"]
);
}
client.cancel().await.unwrap();
})
.await
.expect("structured diff diagnostic timeout");
}
#[tokio::test]
async fn every_tool_leaves_the_tree_unchanged() {
tokio::time::timeout(TIMEOUT, async {
fn copy(from: &std::path::Path, to: &std::path::Path) {
std::fs::create_dir_all(to).unwrap();
for entry in std::fs::read_dir(from).unwrap() {
let entry = entry.unwrap();
let dest = to.join(entry.file_name());
if entry.path().is_dir() {
copy(&entry.path(), &dest);
} else {
std::fs::copy(entry.path(), dest).unwrap();
}
}
}
fn record(root: &std::path::Path) -> Vec<(PathBuf, u64, std::time::SystemTime)> {
fn walk(
root: &std::path::Path,
dir: &std::path::Path,
entries: &mut Vec<(PathBuf, u64, std::time::SystemTime)>,
) {
for entry in std::fs::read_dir(dir).unwrap() {
let entry = entry.unwrap();
let metadata = entry.metadata().unwrap();
if metadata.is_dir() {
walk(root, &entry.path(), entries);
} else {
entries.push((
entry.path().strip_prefix(root).unwrap().into(),
metadata.len(),
metadata.modified().unwrap(),
));
}
}
}
let mut entries = Vec::new();
walk(root, root, &mut entries);
entries.sort();
entries
}
let temp = TempDir::new("read-only");
copy(&workspace_fixture("ws"), &temp.0);
let before = record(&temp.0);
let overlay_path = temp.0.join("a/a.ridl");
let source = std::fs::read_to_string(&overlay_path)
.unwrap()
.replace("250.0", "200.0");
let source = source + "\ntype Probe: integer [0..10]\n";
let interface_path = temp.0.join("b/b.ridl");
let interface_source = std::fs::read_to_string(&interface_path).unwrap()
.replace("import fx.a.Speed", "import fx.a.Speed\nimport fx.a.Probe\nimport fx.a.sub.Gear")
.replace("signal speed: Speed", "signal speed: Gear")
.replace(" fixed softwareVersion: Version", " fixed softwareVersion: Version\n query probe(sample: Probe): Probe @[..100ms]");
let overlays = json!([{"path":overlay_path,"source":source}, {"path":interface_path,"source":interface_source}]);
let client = connect().await;
for (name, arguments) in [
("ridl_check", json!({"path":temp.0,"overlays":overlays})),
("ridl_explain", json!({"code":"TYPL-002"})),
(
"ridl_resolve",
json!({"path":temp.0,"name":"Probe","overlays":overlays}),
),
(
"ridl_describe_type",
json!({"path":temp.0,"name":"Speed","overlays":overlays}),
),
(
"ridl_list_interactions",
json!({"path":temp.0,"interface":"Status","overlays":overlays}),
),
(
"ridl_references",
json!({"path":temp.0,"name":"Speed","overlays":overlays}),
),
(
"ridl_dependencies",
json!({"path":temp.0,"overlays":overlays}),
),
("ridl_metrics", json!({"path":temp.0})),
(
"ridl_diff",
json!({"old":temp.0,"new":temp.0,"overlays":overlays}),
),
] {
let result = call_workspace_tool(&client, name, arguments).await;
assert_ne!(result.is_error, Some(true), "{name}: {result:?}");
let text: serde_json::Value = serde_json::from_str(&tool_text(&result)).unwrap();
let output = result.structured_content.unwrap();
assert_eq!(text, output, "{name}");
match name {
"ridl_check" => {
let diagnostics = output["diagnostics"]
.as_array()
.expect("a diagnostics array")
.iter()
.filter(|diagnostic| diagnostic["lint"] != "missing-docs")
.collect::<Vec<_>>();
assert_eq!(serde_json::to_value(diagnostics).unwrap(), json!([]));
},
"ridl_explain" => {
assert_eq!(output["kind"], "diagnostic");
assert_eq!(output["code"], "TYPL-002");
}
"ridl_resolve" => {
assert_eq!(output["name"], "Probe");
assert_eq!(output["package"], "fx.a");
}
"ridl_describe_type" => {
assert_eq!(output["declaration"]["name"], "Speed");
assert_eq!(output["package"], "fx.a");
assert!(output["declaration"].to_string().contains("200"));
}
"ridl_list_interactions" => {
assert_eq!(output["interface"]["name"], "Status");
assert_eq!(output["interactions"].as_array().unwrap().len(), 6);
assert_eq!(output["interactions"][5]["name"], "probe");
}
"ridl_references" => {
assert_eq!(output["target"], "fx.a.Speed");
assert_eq!(output["references"].as_array().unwrap().len(), 1);
assert_eq!(output["references"][0]["declaration"], "fx.b.diag");
}
"ridl_dependencies" => {
assert_eq!(output["packages"].as_array().unwrap().len(), 3);
let package = output["packages"].as_array().unwrap().iter().find(|p| p["name"] == "fx.b").unwrap();
assert_eq!(package["depends_on"], json!(["fx.a", "fx.a.sub"]));
},
"ridl_metrics" => assert_eq!(output, json!({
"packages": [
{"name":"fx.a", "fanIn":1, "fanOut":0, "instability":0.0, "dependsOn":[]},
{"name":"fx.a.sub", "fanIn":0, "fanOut":0, "instability":null, "dependsOn":[]},
{"name":"fx.b", "fanIn":0, "fanOut":1, "instability":1.0, "dependsOn":["fx.a"]}
],
"interfaces": [{"name":"fx.b.Status", "members":5,
"groups":[["speed"],["reading"],["setLevel"],["outcome"]]}],
"workspace": {"root":temp.0, "errors":0, "warnings":22, "notes":[]}
})),
"ridl_diff" => assert_eq!(output["verdict"], "breaking"),
_ => unreachable!(),
}
}
client.cancel().await.unwrap();
assert_eq!(record(&temp.0), before);
assert!(!temp.0.join("ridl.lock").exists());
assert!(!temp.0.join(".ridl").exists());
})
.await
.expect("read-only tools timeout");
}
#[tokio::test]
async fn a_wrong_request_is_is_error_not_a_protocol_error() {
tokio::time::timeout(TIMEOUT, async {
let client = connect().await;
let result = call_workspace_tool(
&client,
"ridl_resolve",
json!({"path":workspace_fixture("ws"),"name":"Unknown"}),
)
.await;
assert_eq!(result.is_error, Some(true));
client.cancel().await.unwrap();
})
.await
.expect("tool error timeout");
}
fn spawn_lsp(args: &[&str]) -> Child {
StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.arg("lsp")
.args(args)
.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() {
assert_lsp_handshake_and_clean_shutdown(&[]);
}
#[test]
fn ridl_lsp_accepts_the_stdio_flag_an_editor_client_passes() {
assert_lsp_handshake_and_clean_shutdown(&["--stdio"]);
}
#[test]
fn ridl_lsp_accepts_the_client_process_id_the_lsp_specification_recommends() {
assert_lsp_handshake_and_clean_shutdown(&["--stdio", "--clientProcessId=4242"]);
assert_lsp_handshake_and_clean_shutdown(&["--clientProcessId", "4242"]);
}
#[test]
fn ridl_lsp_refuses_a_transport_other_than_stdio() {
for flag in ["--pipe=ridl-test-pipe", "--socket=7777"] {
let mut child = spawn_lsp(&[flag]);
drop(child.stdin.take().expect("piped stdin"));
let status = wait_for_exit(&mut child, "ridl lsp");
let mut stderr = String::new();
child
.stderr
.take()
.expect("piped stderr")
.read_to_string(&mut stderr)
.expect("read stderr");
assert_eq!(
status.code(),
Some(2),
"{flag}: an unsupported transport exits 2"
);
assert!(
stderr.contains("unexpected argument"),
"{flag}: refused as an argument, not served: {stderr}"
);
}
}
fn assert_lsp_handshake_and_clean_shutdown(args: &[&str]) {
let mut child = spawn_lsp(args);
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");
}
const UNTIMED_SIGNAL: &str = "package {name}\n\ntype Speed: integer [0..300]\n\ninterface Sensor {\n \
signal speed: Speed\n}\n";
const SHARED_ERROR: &str = "\
struct FaultPage {
count : integer [0..64]
}
error enum DiagError {
STORAGE_BUSY = 0
ACCESS_DENIED = 1
}
interface Cluster {
query faults(): FaultPage | DiagError @[..50ms]
}
interface Powertrain {
query faults(): FaultPage | DiagError @[..50ms]
}
interface Infotainment {
query faults(): FaultPage | DiagError @[..50ms]
}
";
fn lint_workspace(dir: &TempDir) -> PathBuf {
dir.write(
"ridl.toml",
"[workspace]\nmembers = [\"a\", \"b\"]\n\n[lints]\nmissing-timing = \"deny\"\n\
shared-error-type = \"allow\"\nunknown-lint = \"info\"\nnot-a-lint = \"warn\"\n",
);
std::fs::create_dir_all(dir.0.join("a")).expect("create member a");
std::fs::create_dir_all(dir.0.join("b")).expect("create member b");
dir.write(
"a/ridl.toml",
"[package]\nname = \"a\"\nversion = \"1.0.0\"\n\n[lints]\nmissing-timing = \"info\"\n",
);
dir.write("a/a.ridl", &UNTIMED_SIGNAL.replace("{name}", "a"));
dir.write(
"b/ridl.toml",
"[package]\nname = \"b\"\nversion = \"1.0.0\"\n",
);
dir.write(
"b/b.ridl",
&format!("{}\n{SHARED_ERROR}", UNTIMED_SIGNAL.replace("{name}", "b")),
);
dir.0.clone()
}
fn file_uri(path: &Path) -> String {
let path = path.to_str().expect("a UTF-8 scratch path");
ridl_lsp::convert::path_to_uri(path)
.unwrap_or_else(|| panic!("`{path}` is absolute and converts to a URI"))
.as_str()
.to_string()
}
fn cli_code_severity_pairs(root: &Path) -> Vec<(String, u8)> {
let cli = StdCommand::new(env!("CARGO_BIN_EXE_ridl"))
.args(["check", "--format", "json"])
.arg(root)
.output()
.expect("run ridl check");
assert_eq!(cli.status.code(), Some(1), "{cli:?}");
let diagnostics: serde_json::Value =
serde_json::from_slice(&cli.stdout).expect("the CLI prints JSON to stdout");
let mut pairs: Vec<(String, u8)> = diagnostics
.as_array()
.expect("a JSON array")
.iter()
.filter(|diagnostic| diagnostic["code"] != "TYPL-406")
.map(|diagnostic| {
let code = diagnostic["code"].as_str().expect("a code").to_string();
let severity = match diagnostic["severity"].as_str() {
Some("error") => 1,
Some("warning") => 2,
Some("info") => 3,
other => panic!("an unexpected severity {other:?} in {diagnostic}"),
};
(code, severity)
})
.collect();
pairs.sort();
pairs
}
fn published_code_severity_pairs(params: &serde_json::Value) -> Vec<(String, u8)> {
params["diagnostics"]
.as_array()
.expect("a diagnostics array")
.iter()
.filter(|diagnostic| diagnostic["code"] != "TYPL-406")
.map(|diagnostic| {
let code = diagnostic["code"].as_str().expect("a code").to_string();
let severity = diagnostic["severity"].as_u64().expect("a numeric severity") as u8;
(code, severity)
})
.collect()
}
struct LspSession {
child: Child,
stdin: std::process::ChildStdin,
messages: mpsc::Receiver<Message>,
}
impl LspSession {
fn start(root: &Path) -> LspSession {
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": {},
"workspaceFolders": [{ "uri": file_uri(root), "name": "lints" }],
}),
))
.write(&mut stdin)
.expect("write initialize");
response_to(&messages, initialize)
.response_result
.expect("an initialize result, not an error");
Message::Notification(Notification::new("initialized".to_string(), json!({})))
.write(&mut stdin)
.expect("write initialized");
LspSession {
child,
stdin,
messages,
}
}
fn notify(&mut self, method: &str, params: serde_json::Value) {
Message::Notification(Notification::new(method.to_string(), params))
.write(&mut self.stdin)
.unwrap_or_else(|err| panic!("write {method}: {err}"));
}
fn shutdown_collecting_publishes(mut self) -> Vec<serde_json::Value> {
let shutdown = RequestId::from(2);
Message::Request(Request::new(
shutdown.clone(),
"shutdown".to_string(),
json!(null),
))
.write(&mut self.stdin)
.expect("write shutdown");
let mut publishes = Vec::new();
let deadline = Instant::now() + TIMEOUT;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
match self.messages.recv_timeout(remaining) {
Ok(Message::Response(response)) if response.id == shutdown => {
response
.response_result
.expect("a shutdown result, not an error");
break;
}
Ok(Message::Notification(notification))
if notification.method == "textDocument/publishDiagnostics" =>
{
publishes.push(notification.params);
}
Ok(_) => {}
Err(err) => panic!("no response to shutdown within {TIMEOUT:?}: {err}"),
}
}
self.notify("exit", json!(null));
drop(self.stdin);
let status = wait_for_exit(&mut self.child, "ridl lsp");
assert_eq!(status.code(), Some(0), "a clean shutdown exits 0");
publishes
}
}
#[test]
fn lsp_matches_check_with_lints() {
let dir = TempDir::new("lsp-lints");
let root = lint_workspace(&dir);
let cli = cli_code_severity_pairs(&root);
assert_eq!(
cli,
vec![
("MANI-010".to_string(), 3),
("RIDL-100".to_string(), 1),
("RIDL-100".to_string(), 3),
],
);
let session = LspSession::start(&root);
let publishes = session.shutdown_collecting_publishes();
let mut lsp: Vec<(String, u8)> = publishes
.iter()
.flat_map(published_code_severity_pairs)
.collect();
lsp.sort();
assert_eq!(lsp, cli, "published: {publishes:#?}");
}
#[test]
fn lsp_new_file_uses_member_levels() {
let dir = TempDir::new("lsp-new-file");
let root = lint_workspace(&dir);
let new_file = root.join("a").join("fresh.ridl");
let uri = file_uri(&new_file);
let mut session = LspSession::start(&root);
session.notify(
"textDocument/didOpen",
json!({
"textDocument": {
"uri": uri,
"languageId": "ridl",
"version": 1,
"text": UNTIMED_SIGNAL.replace("{name}", "fresh"),
}
}),
);
let publishes = session.shutdown_collecting_publishes();
let for_new_file = publishes
.iter()
.rev()
.find(|params| params["uri"] == uri)
.unwrap_or_else(|| panic!("a publish for `{uri}` in {publishes:#?}"));
assert_eq!(
published_code_severity_pairs(for_new_file),
vec![("RIDL-100".to_string(), 3)],
"{for_new_file:#?}"
);
}
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);
}
}