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"));
Self {
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:?}");
}
fn embed_eol_round_trip(input: &[u8], commands: &[&str]) -> Vec<u8> {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("eol_case.txt");
std::fs::write(&path, input).unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_hjkl"))
.arg("--embed")
.arg("eol_case.txt")
.current_dir(dir.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,
};
for c in commands {
let r = s.request("hjkl_command", json!([c]));
assert!(r.get("error").is_none(), "`{c}` failed: {r}");
}
std::fs::read(&path).unwrap()
}
#[test]
fn embed_write_matches_nvim_with_fixendofline_on() {
for (input, want) in [
("", ""),
("\n", "\n"),
("abc", "abc\n"),
("abc\n", "abc\n"),
("a\n\n", "a\n\n"),
] {
let got = embed_eol_round_trip(input.as_bytes(), &[":w"]);
assert_eq!(
got,
want.as_bytes(),
"fixeol: {input:?} must save as {want:?}, got {:?}",
String::from_utf8_lossy(&got)
);
}
}
#[test]
fn embed_write_matches_nvim_with_nofixendofline() {
for (input, want) in [
("", ""),
("\n", "\n"),
("abc", "abc"),
("abc\n", "abc\n"),
("a\n\n", "a\n\n"),
] {
let got = embed_eol_round_trip(input.as_bytes(), &[":set nofixeol", ":w"]);
assert_eq!(
got,
want.as_bytes(),
"nofixeol: {input:?} must save as {want:?}, got {:?}",
String::from_utf8_lossy(&got)
);
}
}
#[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));
}
}
}
}