- engine::guide: the diagnostics resource is the reader's table (every code with its outcome and meaning) and worked refusals, each held by a test to the code and line the reader gives its example; the language resource carries the reader's feature matrix in place of the lists it kept by hand; the summary for AGENTS.md names what the reader now takes. - engine::editor: a refusal is the reader's own diagnostic, with its code and the very text it names (line, column and length). - wai-quantum-py: EngineError.code is the reader's code. - The service's tool list holds qasm_diagnostics; the command line's agent docs are regenerated. Co-Authored-By: Claude Opus 5.5 <[email protected]>
303 lines
17 KiB
Rust
303 lines
17 KiB
Rust
//! `wai-quantum lsp` driven as an editor drives it: the binary started, a
|
|
//! scripted session of protocol messages written to its standard input, and
|
|
//! every reply read back from its standard output and checked.
|
|
|
|
use serde_json::{Value, json};
|
|
use std::io::{BufRead, BufReader, Write};
|
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
|
use std::sync::mpsc::{Receiver, channel};
|
|
use std::time::Duration;
|
|
|
|
struct Editor {
|
|
child: Child,
|
|
stdin: ChildStdin,
|
|
rx: Receiver<Value>,
|
|
next: i64,
|
|
notes: Vec<Value>,
|
|
home: std::path::PathBuf,
|
|
}
|
|
|
|
fn read_message(r: &mut impl BufRead) -> Option<Value> {
|
|
let mut len = 0usize;
|
|
loop {
|
|
let mut line = String::new();
|
|
if r.read_line(&mut line).ok()? == 0 {
|
|
return None;
|
|
}
|
|
let line = line.trim_end();
|
|
if line.is_empty() {
|
|
break;
|
|
}
|
|
if let Some(v) = line.strip_prefix("Content-Length:") {
|
|
len = v.trim().parse().ok()?;
|
|
}
|
|
}
|
|
let mut buf = vec![0u8; len];
|
|
r.read_exact(&mut buf).ok()?;
|
|
serde_json::from_slice(&buf).ok()
|
|
}
|
|
|
|
impl Editor {
|
|
fn start(name: &str) -> Editor {
|
|
let home = std::env::temp_dir().join(format!("wai-quantum-lsp-{name}-{}", std::process::id()));
|
|
std::fs::create_dir_all(&home).unwrap();
|
|
let mut child = Command::new(env!("CARGO_BIN_EXE_wai-quantum"))
|
|
.args(["lsp", "--demo-key", "--memory-gb", "1"])
|
|
.env("XDG_CONFIG_HOME", &home)
|
|
.env_remove("WAI_QUANTUM_KEY")
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.expect("the binary starts");
|
|
let stdin = child.stdin.take().unwrap();
|
|
let mut stdout = BufReader::new(child.stdout.take().unwrap());
|
|
let (tx, rx) = channel();
|
|
std::thread::spawn(move || {
|
|
while let Some(m) = read_message(&mut stdout) {
|
|
if tx.send(m).is_err() {
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
Editor { child, stdin, rx, next: 1, notes: Vec::new(), home }
|
|
}
|
|
|
|
fn write(&mut self, msg: &Value) {
|
|
let body = msg.to_string();
|
|
write!(self.stdin, "Content-Length: {}\r\n\r\n{body}", body.len()).unwrap();
|
|
self.stdin.flush().unwrap();
|
|
}
|
|
|
|
fn recv(&mut self) -> Value {
|
|
self.rx.recv_timeout(Duration::from_secs(120)).expect("the server answers within two minutes")
|
|
}
|
|
|
|
/// A request's whole reply; notifications that come first are kept.
|
|
fn request(&mut self, method: &str, params: Value) -> Value {
|
|
let id = self.next;
|
|
self.next += 1;
|
|
self.write(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }));
|
|
loop {
|
|
let m = self.recv();
|
|
if m.get("id") == Some(&json!(id)) && (m.get("result").is_some() || m.get("error").is_some()) {
|
|
return m;
|
|
}
|
|
self.notes.push(m);
|
|
}
|
|
}
|
|
|
|
fn result(&mut self, method: &str, params: Value) -> Value {
|
|
let r = self.request(method, params);
|
|
assert!(r.get("error").is_none(), "{method}: {r}");
|
|
r["result"].clone()
|
|
}
|
|
|
|
fn notify(&mut self, method: &str, params: Value) {
|
|
self.write(&json!({ "jsonrpc": "2.0", "method": method, "params": params }));
|
|
}
|
|
|
|
/// The next notification that `pick` takes.
|
|
fn note(&mut self, pick: impl Fn(&Value) -> bool) -> Value {
|
|
if let Some(i) = self.notes.iter().position(&pick) {
|
|
return self.notes.remove(i);
|
|
}
|
|
loop {
|
|
let m = self.recv();
|
|
if pick(&m) {
|
|
return m;
|
|
}
|
|
self.notes.push(m);
|
|
}
|
|
}
|
|
|
|
fn diagnostics(&mut self, uri: &str, version: i64) -> Vec<Value> {
|
|
let n = self.note(|m| m["method"] == "textDocument/publishDiagnostics" && m["params"]["uri"] == uri && m["params"]["version"] == version);
|
|
n["params"]["diagnostics"].as_array().unwrap().clone()
|
|
}
|
|
|
|
fn exit(mut self) -> i32 {
|
|
self.notify("exit", json!(null));
|
|
let status = self.child.wait().unwrap();
|
|
let _ = std::fs::remove_dir_all(&self.home);
|
|
status.code().unwrap_or(-1)
|
|
}
|
|
}
|
|
|
|
fn at(line: u64, character: u64) -> Value {
|
|
json!({ "line": line, "character": character })
|
|
}
|
|
|
|
const BAD: &str = "OPENQASM 3.0;\nqubit[1] q;\nfoo q[0];\n";
|
|
const GOOD: &str = "OPENQASM 3.0;\ninclude \"stdgates.inc\";\nqubit[2] q;\nbit[2] c;\ngate bell a, b {\n h a;\n cx a, b;\n}\nx q[0];\nx q[0];\nbell q[0], q[1];\nc = measure q;\n";
|
|
|
|
#[test]
|
|
fn an_editor_s_session_from_initialize_to_exit() {
|
|
let mut e = Editor::start("session");
|
|
let init = e.result(
|
|
"initialize",
|
|
json!({
|
|
"processId": null,
|
|
"rootUri": null,
|
|
"capabilities": { "general": { "positionEncodings": ["utf-16"] }, "textDocument": { "completion": { "completionItem": { "snippetSupport": true } } } },
|
|
"initializationOptions": { "wai-quantum": { "shots": 200 } },
|
|
}),
|
|
);
|
|
let caps = &init["capabilities"];
|
|
assert_eq!(caps["positionEncoding"], "utf-16");
|
|
assert_eq!(caps["textDocumentSync"]["change"], 1);
|
|
for c in ["hoverProvider", "definitionProvider", "referencesProvider", "documentSymbolProvider", "foldingRangeProvider", "documentFormattingProvider", "documentHighlightProvider"] {
|
|
assert_eq!(caps[c], true, "{c}");
|
|
}
|
|
assert_eq!(caps["renameProvider"]["prepareProvider"], true);
|
|
assert_eq!(caps["executeCommandProvider"]["commands"], json!(["wai-quantum.run", "wai-quantum.plan", "wai-quantum.critique", "wai-quantum.shortcuts", "wai-quantum.receipt"]));
|
|
assert_eq!(init["serverInfo"]["name"], "wai-quantum");
|
|
e.notify("initialized", json!({}));
|
|
|
|
// The reader's refusal, with its code, on the very text it names.
|
|
let uri = "file:///work/bell.qasm";
|
|
e.notify("textDocument/didOpen", json!({ "textDocument": { "uri": uri, "languageId": "openqasm", "version": 1, "text": BAD } }));
|
|
let d = e.diagnostics(uri, 1);
|
|
assert_eq!(d.len(), 1, "{d:?}");
|
|
assert_eq!((d[0]["severity"].clone(), d[0]["message"].clone(), d[0]["source"].clone(), d[0]["code"].clone()), (json!(1), json!("unknown gate foo"), json!("wai-quantum"), json!("Q103")));
|
|
assert_eq!(d[0]["range"], json!({ "start": at(2, 0), "end": at(2, 3) }));
|
|
|
|
// A good program with two gates that cancel: a warning on each line.
|
|
e.notify("textDocument/didChange", json!({ "textDocument": { "uri": uri, "version": 2 }, "contentChanges": [{ "text": GOOD }] }));
|
|
let d = e.diagnostics(uri, 2);
|
|
let cancels: Vec<&Value> = d.iter().filter(|x| x["code"] == "cancels").collect();
|
|
assert_eq!(cancels.len(), 2, "{d:?}");
|
|
assert_eq!((cancels[0]["severity"].clone(), cancels[0]["range"]["start"]["line"].clone(), cancels[1]["range"]["start"]["line"].clone()), (json!(2), json!(8), json!(9)));
|
|
|
|
// Completion: the qubit register after a gate's name; gates, with placeholders, at a statement's start.
|
|
let items = e.result("textDocument/completion", json!({ "textDocument": { "uri": uri }, "position": at(10, 5) }));
|
|
assert_eq!(items["items"][0]["label"], "q");
|
|
let items = e.result("textDocument/completion", json!({ "textDocument": { "uri": uri }, "position": at(12, 0) }));
|
|
let all = items["items"].as_array().unwrap();
|
|
let rz = all.iter().find(|i| i["label"] == "rz").unwrap();
|
|
assert_eq!((rz["insertText"].clone(), rz["insertTextFormat"].clone()), (json!("rz(${1:theta})"), json!(2)));
|
|
assert!(all.iter().any(|i| i["label"] == "bell" && i["kind"] == 3));
|
|
|
|
// Hover: a standard gate's exact matrix; a user gate's, and its cost; a register's size.
|
|
let h = e.result("textDocument/hover", json!({ "textDocument": { "uri": uri }, "position": at(5, 2) }));
|
|
let text = h["contents"]["value"].as_str().unwrap();
|
|
assert!(text.contains("[ 1/√2 1/√2 ]") && text.contains("Exact"), "{text}");
|
|
assert_eq!(h["range"], json!({ "start": at(5, 2), "end": at(5, 3) }));
|
|
let b = e.result("textDocument/hover", json!({ "textDocument": { "uri": uri }, "position": at(10, 1) }));
|
|
let text = b["contents"]["value"].as_str().unwrap();
|
|
assert!(text.contains("a gate this program defines, on 2 qubits") && text.contains("computed in double precision") && text.contains("two-qubit gates 1"), "{text}");
|
|
let q = e.result("textDocument/hover", json!({ "textDocument": { "uri": uri }, "position": at(2, 9) }));
|
|
assert!(q["contents"]["value"].as_str().unwrap().contains("qubit register, 2 qubits"));
|
|
|
|
// Definition, references, highlights.
|
|
let def = e.result("textDocument/definition", json!({ "textDocument": { "uri": uri }, "position": at(10, 1) }));
|
|
assert_eq!(def, json!({ "uri": uri, "range": { "start": at(4, 5), "end": at(4, 9) } }));
|
|
let refs = e.result("textDocument/references", json!({ "textDocument": { "uri": uri }, "position": at(10, 1), "context": { "includeDeclaration": true } }));
|
|
assert_eq!(refs.as_array().unwrap().len(), 2);
|
|
let refs = e.result("textDocument/references", json!({ "textDocument": { "uri": uri }, "position": at(10, 1), "context": { "includeDeclaration": false } }));
|
|
assert_eq!(refs, json!([{ "uri": uri, "range": { "start": at(10, 0), "end": at(10, 4) } }]));
|
|
let hl = e.result("textDocument/documentHighlight", json!({ "textDocument": { "uri": uri }, "position": at(5, 4) }));
|
|
assert_eq!(hl.as_array().unwrap().iter().map(|x| x["kind"].clone()).collect::<Vec<_>>(), [json!(3), json!(2), json!(2)]);
|
|
|
|
// Rename: the declaration and every use; a standard gate refused, with why.
|
|
let p = e.result("textDocument/prepareRename", json!({ "textDocument": { "uri": uri }, "position": at(10, 2) }));
|
|
assert_eq!(p, json!({ "range": { "start": at(10, 0), "end": at(10, 4) }, "placeholder": "bell" }));
|
|
let edit = e.result("textDocument/rename", json!({ "textDocument": { "uri": uri }, "position": at(10, 2), "newName": "pair" }));
|
|
let edits = edit["changes"][uri].as_array().unwrap();
|
|
assert_eq!(edits.len(), 2);
|
|
assert!(edits.iter().all(|x| x["newText"] == "pair"));
|
|
let no = e.request("textDocument/rename", json!({ "textDocument": { "uri": uri }, "position": at(10, 2), "newName": "cx" }));
|
|
assert!(no["error"]["message"].as_str().unwrap().contains("standard gate"), "{no}");
|
|
let no = e.request("textDocument/prepareRename", json!({ "textDocument": { "uri": uri }, "position": at(5, 2) }));
|
|
assert_eq!(no["error"]["code"], -32803);
|
|
|
|
// The outline, the folds and the lenses.
|
|
let syms = e.result("textDocument/documentSymbol", json!({ "textDocument": { "uri": uri } }));
|
|
assert_eq!(syms.as_array().unwrap().iter().map(|s| s["name"].clone()).collect::<Vec<_>>(), [json!("q"), json!("c"), json!("bell")]);
|
|
assert_eq!(syms[2]["children"].as_array().unwrap().len(), 2);
|
|
assert_eq!(syms[2]["selectionRange"], json!({ "start": at(4, 5), "end": at(4, 9) }));
|
|
let folds = e.result("textDocument/foldingRange", json!({ "textDocument": { "uri": uri } }));
|
|
assert!(folds.as_array().unwrap().contains(&json!({ "startLine": 4, "endLine": 6 })), "{folds}");
|
|
let lenses = e.result("textDocument/codeLens", json!({ "textDocument": { "uri": uri } }));
|
|
let lenses = lenses.as_array().unwrap();
|
|
assert_eq!(lenses.iter().map(|l| l["command"]["title"].clone()).collect::<Vec<_>>(), [json!("Run"), json!("Plan"), json!("Critique"), json!("Shortcuts"), json!("Receipt")]);
|
|
assert_eq!(lenses[0]["command"], json!({ "title": "Run", "command": "wai-quantum.run", "arguments": [uri] }));
|
|
assert_eq!(lenses[0]["range"]["start"], at(0, 0));
|
|
|
|
// The commands, run here through the engine; a run's receipt verifies.
|
|
let run = e.result("workspace/executeCommand", json!({ "command": "wai-quantum.run", "arguments": [uri] }));
|
|
assert_eq!((run["ok"].clone(), run["json"]["method"].clone(), run["json"]["request"]["shots"].clone()), (json!(true), json!("stabilizer"), json!(200)));
|
|
assert!(run["json"]["result"]["counts"].as_object().unwrap().keys().all(|k| k == "00" || k == "11"));
|
|
assert!(wai_quantum::engine::job::verify(&run["json"]).unwrap().0);
|
|
assert!(run["text"].as_str().unwrap().contains("receipt verifies"), "{run}");
|
|
let shown = e.note(|m| m["method"] == "window/showMessage");
|
|
assert!(shown["params"]["message"].as_str().unwrap().starts_with("Run: stabilizer"), "{shown}");
|
|
let plan = e.result("workspace/executeCommand", json!({ "command": "wai-quantum.plan", "arguments": [{ "uri": uri }] }));
|
|
assert_eq!(plan["json"]["method"], "stabilizer");
|
|
let crit = e.result("workspace/executeCommand", json!({ "command": "wai-quantum.critique", "arguments": [uri] }));
|
|
assert!(crit["json"]["smells"].as_array().unwrap().iter().any(|s| s["kind"] == "cancels"));
|
|
let short = e.result("workspace/executeCommand", json!({ "command": "wai-quantum.shortcuts", "arguments": [uri] }));
|
|
assert_eq!(short["json"]["ok"], true);
|
|
let receipt = e.result("workspace/executeCommand", json!({ "command": "wai-quantum.receipt", "arguments": [uri] }));
|
|
let text = receipt["text"].as_str().unwrap();
|
|
assert!(text.starts_with("receipt verifies\nsigned by did:key:"), "{text}");
|
|
assert!(wai_quantum::engine::job::verify(&receipt["json"]).unwrap().0);
|
|
let no = e.request("workspace/executeCommand", json!({ "command": "wai-quantum.nothing", "arguments": [uri] }));
|
|
assert!(no["error"]["message"].as_str().unwrap().contains("the commands are"));
|
|
|
|
// Formatting: one edit to the whole document, none for a formatted one, a refusal with its line.
|
|
let messy = "file:///work/messy.qasm";
|
|
e.notify("textDocument/didOpen", json!({ "textDocument": { "uri": messy, "languageId": "openqasm", "version": 1, "text": "OPENQASM 3.0;qubit[2]q;\nh q[0];cx q[0],q[1];\n" } }));
|
|
let f = e.result("textDocument/formatting", json!({ "textDocument": { "uri": messy }, "options": { "tabSize": 4, "insertSpaces": true } }));
|
|
assert_eq!(f, json!([{ "range": { "start": at(0, 0), "end": at(2, 0) }, "newText": "OPENQASM 3.0;\nqubit[2] q;\nh q[0];\ncx q[0], q[1];\n" }]));
|
|
let f = e.result("textDocument/formatting", json!({ "textDocument": { "uri": uri }, "options": { "tabSize": 2, "insertSpaces": true } }));
|
|
assert_eq!(f, json!([]));
|
|
e.notify("textDocument/didChange", json!({ "textDocument": { "uri": messy, "version": 2 }, "contentChanges": [{ "text": "gate g a {\n h a;\n" }] }));
|
|
let no = e.request("textDocument/formatting", json!({ "textDocument": { "uri": messy }, "options": {} }));
|
|
assert!(no["error"]["message"].as_str().unwrap().contains("line 1: `{` is not closed"), "{no}");
|
|
|
|
// A change by range, as an editor that sends increments would.
|
|
e.notify("textDocument/didChange", json!({ "textDocument": { "uri": messy, "version": 3 }, "contentChanges": [{ "range": { "start": at(1, 2), "end": at(1, 3) }, "text": "x" }] }));
|
|
let _ = e.diagnostics(messy, 3);
|
|
let hv = e.result("textDocument/hover", json!({ "textDocument": { "uri": messy }, "position": at(1, 2) }));
|
|
assert!(hv["contents"]["value"].as_str().unwrap().contains("Pauli X"), "{hv}");
|
|
|
|
// Positions count UTF-16 units: after `π`, column 10 is `q`.
|
|
let pi = "file:///work/pi.qasm";
|
|
e.notify("textDocument/didOpen", json!({ "textDocument": { "uri": pi, "languageId": "openqasm", "version": 1, "text": "OPENQASM 3.0;\nqubit[1] q;\nrz(π / 2) q[0];\n" } }));
|
|
let hv = e.result("textDocument/hover", json!({ "textDocument": { "uri": pi }, "position": at(2, 10) }));
|
|
assert!(hv["contents"]["value"].as_str().unwrap().contains("qubit register, 1 qubit"), "{hv}");
|
|
assert_eq!(hv["range"], json!({ "start": at(2, 10), "end": at(2, 11) }));
|
|
|
|
// A generator is read at the editor's inputs: a configuration change reads it again.
|
|
let generator = "file:///work/generator.qasm";
|
|
e.notify("textDocument/didOpen", json!({ "textDocument": { "uri": generator, "languageId": "openqasm", "version": 1, "text": "OPENQASM 3.0;\ninput uint n;\nqubit[n] q;\nh q[3];\n" } }));
|
|
assert!(e.diagnostics(generator, 1).iter().all(|d| d["severity"] != 1));
|
|
e.notify("workspace/didChangeConfiguration", json!({ "settings": { "wai-quantum": { "inputs": { "n": 3 } } } }));
|
|
let d = e.diagnostics(generator, 1);
|
|
assert_eq!(d[0]["message"], "q[3] is outside q, which has 3 (read at n = 3)");
|
|
|
|
// Closing a document clears its diagnostics.
|
|
e.notify("textDocument/didClose", json!({ "textDocument": { "uri": generator } }));
|
|
let n = e.note(|m| m["method"] == "textDocument/publishDiagnostics" && m["params"]["uri"] == generator);
|
|
assert_eq!(n["params"]["diagnostics"], json!([]));
|
|
let no = e.request("textDocument/hover", json!({ "textDocument": { "uri": generator }, "position": at(0, 0) }));
|
|
assert_eq!(no["error"]["code"], -32602);
|
|
|
|
assert_eq!(e.result("shutdown", json!(null)), Value::Null);
|
|
assert_eq!(e.exit(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_request_before_initialize_is_refused_and_an_exit_without_shutdown_fails() {
|
|
let mut e = Editor::start("early");
|
|
let r = e.request("textDocument/hover", json!({ "textDocument": { "uri": "file:///x.qasm" }, "position": at(0, 0) }));
|
|
assert_eq!(r["error"]["code"], -32002);
|
|
let r = e.request("initialize", json!({ "capabilities": { "general": { "positionEncodings": ["utf-8", "utf-16"] } } }));
|
|
assert_eq!(r["result"]["capabilities"]["positionEncoding"], "utf-8");
|
|
let r = e.request("textDocument/nothing", json!({ "textDocument": { "uri": "file:///x.qasm" } }));
|
|
assert_eq!(r["error"]["code"], -32602);
|
|
let r = e.request("workspace/nothing", json!({}));
|
|
assert_eq!(r["error"]["code"], -32601);
|
|
assert_eq!(e.exit(), 1);
|
|
}
|