use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStdout, Command, Stdio};
struct Client {
process: Child,
output: BufReader<ChildStdout>,
id: u32,
diagnostics: std::collections::HashMap<String, Value>,
}
impl Client {
fn new() -> Self {
let mut process = Command::new(env!("CARGO_BIN_EXE_gt"))
.arg("lsp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.unwrap();
let output = BufReader::new(process.stdout.take().unwrap());
Self {
process,
output,
id: 0,
diagnostics: Default::default(),
}
}
fn send(&mut self, message: Value) {
let body = serde_json::to_vec(&message).unwrap();
let input = self.process.stdin.as_mut().unwrap();
write!(input, "Content-Length: {}\r\n\r\n", body.len()).unwrap();
input.write_all(&body).unwrap();
input.flush().unwrap();
}
fn notify(&mut self, method: &str, params: Value) {
self.send(json!({ "jsonrpc": "2.0", "method": method, "params": params }));
}
fn request(&mut self, method: &str, params: Value) -> Value {
self.id += 1;
self.send(json!({ "jsonrpc": "2.0", "id": self.id, "method": method, "params": params }));
loop {
let response = self.receive();
if response["method"] == "textDocument/publishDiagnostics" {
let params = response["params"].clone();
self.diagnostics
.insert(params["uri"].as_str().unwrap().into(), params);
continue;
}
assert_eq!(response["id"], self.id);
assert!(response.get("error").is_none(), "{response}");
return response["result"].clone();
}
}
fn receive(&mut self) -> Value {
let mut length = None;
loop {
let mut line = String::new();
assert_ne!(
self.output.read_line(&mut line).unwrap(),
0,
"server exited before responding"
);
if line == "\r\n" {
break;
}
if let Some(value) = line.strip_prefix("Content-Length: ") {
length = Some(value.trim().parse::<usize>().unwrap());
}
}
let mut bytes = vec![0; length.unwrap()];
self.output.read_exact(&mut bytes).unwrap();
serde_json::from_slice(&bytes).unwrap()
}
}
impl Drop for Client {
fn drop(&mut self) {
let _ = self.process.kill();
let _ = self.process.wait();
}
}
#[test]
fn rename_includes_unopened_consuming_projects_but_respects_entry_boundaries() {
let dir = tempfile::tempdir().unwrap();
for project in ["app", "shared", "unrelated"] {
std::fs::create_dir(dir.path().join(project)).unwrap();
std::fs::write(
dir.path().join(project).join("genotype.toml"),
"src = '.'\nentry = 'main.type'",
)
.unwrap();
}
std::fs::write(dir.path().join("shared/main.type"), "User: string").unwrap();
std::fs::write(
dir.path().join("app/main.type"),
"use ../shared/main/User\nValue: User",
)
.unwrap();
std::fs::write(dir.path().join("app/fixture.type"), "Broken: {").unwrap();
std::fs::write(dir.path().join("unrelated/main.type"), "Broken: {").unwrap();
let path = dir.path().to_str().unwrap().replace('\\', "/");
let root = if path.starts_with('/') {
format!("file://{path}")
} else {
format!("file:///{path}")
};
let source = format!("{root}/shared/main.type");
let consumer = format!("{root}/app/main.type");
let mut client = Client::new();
client.request(
"initialize",
json!({ "processId": null, "rootUri": root, "capabilities": {} }),
);
client.notify("initialized", json!({}));
client.notify("textDocument/didOpen", json!({ "textDocument": { "uri": source, "languageId": "genotype", "version": 1, "text": "User: string" } }));
let params = json!({ "textDocument": { "uri": source }, "position": { "line": 0, "character": 4 }, "newName": "Account" });
let result = client.request("textDocument/rename", params.clone());
assert_eq!(result["changes"].as_object().unwrap().len(), 2);
assert_eq!(result["changes"][&consumer].as_array().unwrap().len(), 2);
client.notify("textDocument/didOpen", json!({ "textDocument": { "uri": consumer, "languageId": "genotype", "version": 1, "text": "use ../shared/main/User\nValue: [User]" } }));
let result = client.request("textDocument/rename", params);
assert!(result["changes"][&consumer].is_array(), "{result}");
assert_eq!(result["changes"][&consumer].as_array().unwrap().len(), 2);
client.request("shutdown", Value::Null);
client.notify("exit", Value::Null);
assert!(client.process.wait().unwrap().success());
}
#[test]
fn external_dependencies_refresh_with_and_without_workspace_folders() {
for workspace in [true, false] {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("workspace")).unwrap();
let root = dir.path().to_str().unwrap().replace('\\', "/");
let root = if root.starts_with('/') {
format!("file://{root}")
} else {
format!("file:///{root}")
};
let main = format!("{root}/workspace/main.type");
let external = format!("{root}/external.type");
let text = "Value: ../external/User";
std::fs::write(dir.path().join("workspace/main.type"), text).unwrap();
std::fs::write(dir.path().join("external.type"), "User: string").unwrap();
let mut client = Client::new();
client.request("initialize", json!({ "processId": null, "rootUri": if workspace { Some(format!("{root}/workspace")) } else { None }, "capabilities": {} }));
client.notify("initialized", json!({}));
client.notify("textDocument/didOpen", json!({ "textDocument": { "uri": main, "languageId": "genotype", "version": 1, "text": text } }));
let position =
json!({ "textDocument": { "uri": main }, "position": { "line": 0, "character": 20 } });
assert_eq!(
client.request("textDocument/definition", position.clone())["uri"],
external
);
std::fs::write(dir.path().join("external.type"), "Other: string").unwrap();
assert!(
client
.request("textDocument/definition", position.clone())
.is_null()
);
std::fs::write(dir.path().join("external.type"), "User: string").unwrap();
assert_eq!(
client.request("textDocument/definition", position.clone())["uri"],
external
);
std::fs::remove_file(dir.path().join("external.type")).unwrap();
assert!(
client
.request("textDocument/definition", position)
.is_null()
);
client.request("shutdown", Value::Null);
client.notify("exit", Value::Null);
assert!(client.process.wait().unwrap().success());
}
}
#[test]
fn stdio_language_server_lifecycle_and_editor_features() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_str().unwrap().replace('\\', "/");
let root = if root.starts_with('/') {
format!("file://{root}")
} else {
format!("file:///{root}")
};
let main = format!("{root}/main.type");
let model = format!("{root}/model.type");
let text = "use ./model/User\nRef: User\n";
std::fs::write(dir.path().join("main.type"), text).unwrap();
std::fs::write(
dir.path().join("genotype.toml"),
"src = \".\"\n[ts]\nenabled = true\n",
)
.unwrap();
std::fs::write(
dir.path().join("model.type"),
"/// A **user**.\nUser: string\n",
)
.unwrap();
std::fs::write(
dir.path().join("remote.type"),
"/// Markdown documentation.\nJsDoc: string\nMissing: string\n",
)
.unwrap();
let mut client = Client::new();
let initialized = client.request(
"initialize",
json!({ "processId": null, "rootUri": root, "capabilities": {} }),
);
assert_eq!(
initialized["capabilities"]["renameProvider"]["prepareProvider"],
true
);
assert_eq!(
initialized["capabilities"]["semanticTokensProvider"]["full"],
true
);
assert_eq!(initialized["capabilities"]["hoverProvider"], true);
assert!(initialized["capabilities"]["completionProvider"].is_object());
assert_eq!(
initialized["capabilities"]["codeActionProvider"]["codeActionKinds"],
json!(["quickfix"])
);
client.notify("initialized", json!({}));
client.notify("$/setTrace", json!({ "value": "off" }));
client.notify("textDocument/didOpen", json!({ "textDocument": { "uri": main, "languageId": "genotype", "version": 1, "text": text } }));
let position =
json!({ "textDocument": { "uri": main }, "position": { "line": 1, "character": 5 } });
let generated = client.request(
"genotype/generatedSymbols",
json!({ "uri": main, "position": { "line": 1, "character": 5 } }),
);
assert_eq!(generated[0]["name"], "User");
assert_eq!(generated[0]["source"]["uri"], model);
assert_eq!(
generated[0]["targets"][0]["uri"],
format!("{root}/dist/ts/src/model.ts")
);
assert_eq!(
client.request(
"genotype/generatedSymbols",
json!({ "uri": format!("{root}/dist/ts/src/model.ts") })
),
generated
);
assert_eq!(
client.request("textDocument/definition", position.clone())["uri"],
model
);
assert_eq!(
client.request("textDocument/prepareRename", position.clone())["placeholder"],
"User"
);
let hover = client.request("textDocument/hover", position.clone());
assert_eq!(hover["contents"]["kind"], "markdown");
assert!(
hover["contents"]["value"]
.as_str()
.unwrap()
.contains("A **user**.")
);
let completions = client.request("textDocument/completion", position.clone());
let autoimport = completions
.as_array()
.unwrap()
.iter()
.find(|item| item["label"] == "JsDoc")
.unwrap();
assert_eq!(
autoimport["additionalTextEdits"][0]["newText"],
"use ./remote/JsDoc\n"
);
assert!(
autoimport["documentation"]["value"]
.as_str()
.unwrap()
.contains("Markdown documentation.")
);
let mut rename = position.clone();
rename["newName"] = json!("Account");
let edits = client.request("textDocument/rename", rename);
assert_eq!(edits["changes"].as_object().unwrap().len(), 2);
assert_eq!(edits["changes"][&main].as_array().unwrap().len(), 2);
let tokens = client.request(
"textDocument/semanticTokens/full",
json!({ "textDocument": { "uri": main } }),
);
assert!(!tokens["data"].as_array().unwrap().is_empty());
assert_eq!(client.diagnostics[&main]["diagnostics"], json!([]));
client.notify("textDocument/didChange", json!({ "textDocument": { "uri": main, "version": 2 }, "contentChanges": [{ "text": "Ref: {" }] }));
client.request("textDocument/definition", position.clone());
assert_eq!(client.diagnostics[&main]["diagnostics"][0]["code"], "GT001");
assert_eq!(client.diagnostics[&main]["version"], 2);
client.notify("textDocument/didChange", json!({ "textDocument": { "uri": main, "version": 3 }, "contentChanges": [{ "text": "Ref: Missing" }] }));
client.request("textDocument/definition", position.clone());
assert_eq!(
client.diagnostics[&main]["diagnostics"][0]["code"],
"reference-not-found"
);
let actions = client.request("textDocument/codeAction", json!({
"textDocument": { "uri": main },
"range": { "start": { "line": 0, "character": 5 }, "end": { "line": 0, "character": 12 } },
"context": { "diagnostics": client.diagnostics[&main]["diagnostics"], "only": ["quickfix"] }
}));
assert_eq!(actions[0]["title"], "Import 'Missing' from './remote'");
assert_eq!(
actions[0]["edit"]["changes"][&main][0]["newText"],
"use ./remote/Missing\n"
);
client.notify("textDocument/didChange", json!({ "textDocument": { "uri": main, "version": 4 }, "contentChanges": [{ "text": text }] }));
std::fs::write(dir.path().join("model.type"), "Account: string\n").unwrap();
assert_eq!(
client.request("textDocument/definition", position.clone())["uri"],
model
);
client.notify(
"workspace/didChangeWatchedFiles",
json!({ "changes": [{ "uri": model, "type": 2 }] }),
);
client.request("textDocument/definition", position.clone());
assert!(
!client.diagnostics[&main]["diagnostics"]
.as_array()
.unwrap()
.is_empty()
);
std::fs::write(dir.path().join("model.type"), "User: string\n").unwrap();
client.notify(
"workspace/didChangeWatchedFiles",
json!({ "changes": [{ "uri": model, "type": 2 }] }),
);
client.request("textDocument/definition", position.clone());
assert_eq!(client.diagnostics[&main]["diagnostics"], json!([]));
client.notify("textDocument/didChange", json!({ "textDocument": { "uri": main, "version": 5 }, "contentChanges": [{ "text": "Local: string\nRef: Local\n" }] }));
assert_eq!(
client.request("textDocument/definition", position.clone())["uri"],
main
);
client.notify("textDocument/didChange", json!({ "textDocument": { "uri": main, "version": 6 }, "contentChanges": [{ "text": "Ref: Missing" }] }));
client.request("textDocument/definition", position.clone());
assert!(
!client.diagnostics[&main]["diagnostics"]
.as_array()
.unwrap()
.is_empty()
);
client.notify(
"textDocument/didClose",
json!({ "textDocument": { "uri": main } }),
);
assert_eq!(
client.request("textDocument/definition", position)["uri"],
model
);
assert!(client.request("shutdown", Value::Null).is_null());
assert_eq!(client.diagnostics[&main]["diagnostics"], json!([]));
client.notify("exit", Value::Null);
assert!(client.process.wait().unwrap().success());
}