use std::io::{BufRead, BufReader, BufWriter, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use serde_json::{Value, json};
struct EmbedSession {
child: Child,
stdin: BufWriter<ChildStdin>,
stdout: BufReader<ChildStdout>,
next_id: u64,
}
impl EmbedSession {
fn spawn() -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_hjkl"))
.arg("--embed")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn hjkl --embed");
let stdin = BufWriter::new(child.stdin.take().expect("child stdin"));
let stdout = BufReader::new(child.stdout.take().expect("child stdout"));
EmbedSession {
child,
stdin,
stdout,
next_id: 1,
}
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": id,
});
let mut line = serde_json::to_string(&req).expect("serialize");
line.push('\n');
self.stdin
.write_all(line.as_bytes())
.expect("write request");
self.stdin.flush().expect("flush");
let mut resp_line = String::new();
self.stdout
.read_line(&mut resp_line)
.expect("read response");
serde_json::from_str(resp_line.trim()).expect("parse response JSON")
}
#[allow(dead_code)]
fn notify(&mut self, method: &str, params: Value) {
let req = json!({
"jsonrpc": "2.0",
"method": method,
"params": params,
});
let mut line = serde_json::to_string(&req).expect("serialize");
line.push('\n');
self.stdin
.write_all(line.as_bytes())
.expect("write notification");
self.stdin.flush().expect("flush");
}
}
impl Drop for EmbedSession {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn embed_input_then_get_buffer() {
let mut s = EmbedSession::spawn();
let r = s.request("hjkl_input", json!(["iHello"]));
assert!(r.get("error").is_none(), "hjkl_input failed: {r}");
let r = s.request("hjkl_get_buffer", json!([]));
let lines = r["result"].as_array().expect("result is array");
assert_eq!(lines.len(), 1, "expected 1 line, got {lines:?}");
assert_eq!(lines[0], "Hello", "buffer mismatch: {lines:?}");
}
#[test]
fn embed_command_substitute() {
let mut f = tempfile::NamedTempFile::new().unwrap();
use std::io::Write as _;
f.write_all(b"foo bar foo\n").unwrap();
let path = f.path().to_path_buf();
let mut child = Command::new(env!("CARGO_BIN_EXE_hjkl"))
.arg("--embed")
.arg(&path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn");
let stdin = BufWriter::new(child.stdin.take().unwrap());
let stdout = BufReader::new(child.stdout.take().unwrap());
let mut s = EmbedSession {
child,
stdin,
stdout,
next_id: 1,
};
let r = s.request("hjkl_command", json!([":%s/foo/baz/g"]));
assert!(r.get("error").is_none(), "hjkl_command failed: {r}");
let r = s.request("hjkl_get_buffer", json!([]));
let lines = r["result"].as_array().expect("result is array");
assert_eq!(lines[0], "baz bar baz", "substitution mismatch: {lines:?}");
}
#[test]
fn embed_get_cursor_after_motion() {
let mut s = EmbedSession::spawn();
s.request("hjkl_input", json!(["ihello world<Esc>"]));
s.request("hjkl_input", json!(["0w"]));
let r = s.request("hjkl_get_cursor", json!([]));
let arr = r["result"].as_array().expect("result is array");
assert_eq!(arr[0], 0, "row should be 0, got {arr:?}");
assert_eq!(arr[1], 6, "col should be 6 (start of 'world'), got {arr:?}");
}
#[test]
fn embed_get_mode_transitions() {
let mut s = EmbedSession::spawn();
let r = s.request("hjkl_get_mode", json!([]));
assert_eq!(r["result"], "normal", "initial mode: {r}");
s.request("hjkl_input", json!(["i"]));
let r = s.request("hjkl_get_mode", json!([]));
assert_eq!(r["result"], "insert", "after 'i': {r}");
s.request("hjkl_input", json!(["<Esc>"]));
let r = s.request("hjkl_get_mode", json!([]));
assert_eq!(r["result"], "normal", "after '<Esc>': {r}");
}
#[test]
fn embed_register_after_yank() {
let mut s = EmbedSession::spawn();
s.request("hjkl_input", json!(["iHello<Esc>"]));
s.request("hjkl_input", json!(["0v$y"]));
let r = s.request("hjkl_get_register", json!(["\""]));
let obj = &r["result"];
assert!(!obj.is_null(), "register should not be null: {r}");
let text = obj["text"].as_str().expect("text field");
assert!(
text.contains("Hello"),
"unnamed register text should contain 'Hello', got: {text:?}"
);
}
#[test]
fn embed_unknown_method_returns_jsonrpc_error() {
let mut s = EmbedSession::spawn();
let r = s.request("hjkl_nonexistent", json!([]));
let code = r["error"]["code"].as_i64().expect("error.code");
assert_eq!(code, -32601, "expected method-not-found: {r}");
}
#[test]
fn embed_malformed_json_keeps_loop_alive() {
let mut s = EmbedSession::spawn();
s.stdin.write_all(b"not json\n").expect("write bad line");
s.stdin.flush().expect("flush");
let mut resp_line = String::new();
s.stdout.read_line(&mut resp_line).expect("read error resp");
let err_resp: Value = serde_json::from_str(resp_line.trim()).expect("parse error resp");
let code = err_resp["error"]["code"].as_i64().expect("error.code");
assert_eq!(code, -32700, "expected parse error: {err_resp}");
let r = s.request("hjkl_get_mode", json!([]));
assert_eq!(r["result"], "normal", "server died after bad JSON: {r}");
}
#[test]
fn embed_eof_exits_clean() {
let mut child = Command::new(env!("CARGO_BIN_EXE_hjkl"))
.arg("--embed")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn");
let mut stdin = BufWriter::new(child.stdin.take().expect("stdin"));
let mut stdout = BufReader::new(child.stdout.take().expect("stdout"));
let req1 = "{\"jsonrpc\":\"2.0\",\"method\":\"hjkl_get_mode\",\"params\":[],\"id\":1}\n";
stdin.write_all(req1.as_bytes()).expect("write req1");
stdin.flush().expect("flush");
let mut _buf = String::new();
stdout.read_line(&mut _buf).expect("read resp1");
let req2 = "{\"jsonrpc\":\"2.0\",\"method\":\"hjkl_input\",\"params\":[\"iHi\"],\"id\":2}\n";
stdin.write_all(req2.as_bytes()).expect("write req2");
stdin.flush().expect("flush");
_buf.clear();
stdout.read_line(&mut _buf).expect("read resp2");
drop(stdin);
let start = std::time::Instant::now();
loop {
match child.try_wait().expect("try_wait") {
Some(status) => {
assert_eq!(status.code(), Some(0), "expected exit 0, got: {status}");
return;
}
None => {
if start.elapsed().as_secs() >= 2 {
let _ = child.kill();
panic!("hjkl --embed did not exit within 2 seconds after EOF");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
}
}