use hx_remote::{read_lsp_message, write_lsp_message};
use serde_json::{Value, json};
use std::fs;
use std::io::{BufReader, Write};
use std::os::unix::net::UnixListener;
use std::os::unix::process::ExitStatusExt;
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{self, Receiver};
use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;
use url::Url;
struct ChildGuard(Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn bare_paths_launch_helix_with_the_requested_file_first() {
let directory = tempdir().unwrap();
let socket = directory.path().join("hxr.sock");
let sentinel = directory.path().join("cache/hx-remote/remote.hxremote");
let first_file = directory.path().join("first file.rs");
let positioned_file = format!("{}:3:2", directory.path().join("second.rs").display());
let hyphen_file = "-notes.md";
let output = Command::new(env!("CARGO_BIN_EXE_hxr"))
.arg(&first_file)
.arg("--socket")
.arg(&socket)
.arg("--sentinel")
.arg(&sentinel)
.arg("--helix")
.arg("/bin/sh")
.arg("--helix-arg=-c")
.arg("--helix-arg=printf '%s\\n' \"$HXR_SOCKET\" \"$0\" \"$@\"")
.arg(&positioned_file)
.arg("--")
.arg(hyphen_file)
.output()
.unwrap();
assert!(
output.status.success(),
"one-shot hxr failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert!(sentinel.exists(), "one-shot launch did not create sentinel");
let arguments: Vec<_> = String::from_utf8(output.stdout)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
assert_eq!(
arguments,
[
socket.to_string_lossy().into_owned(),
"--".to_owned(),
first_file.to_string_lossy().into_owned(),
positioned_file,
hyphen_file.to_owned(),
sentinel.to_string_lossy().into_owned(),
]
);
}
#[test]
fn split_layout_keeps_a_requested_file_focused() {
let directory = tempdir().unwrap();
let socket = directory.path().join("hxr.sock");
let sentinel = directory.path().join("remote.hxremote");
let requested_file = directory.path().join("requested.rs");
let output = hxr_command(&socket)
.arg("--sentinel")
.arg(&sentinel)
.arg("--helix")
.arg("/bin/sh")
.arg("--helix-arg=-c")
.arg("--helix-arg=printf '%s\\n' \"$@\"")
.arg("--helix-arg=--vsplit")
.arg(&requested_file)
.output()
.unwrap();
assert!(
output.status.success(),
"split-layout hxr failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let arguments: Vec<_> = String::from_utf8(output.stdout)
.unwrap()
.lines()
.map(str::to_owned)
.collect();
assert_eq!(
arguments,
[
"--".to_owned(),
sentinel.to_string_lossy().into_owned(),
requested_file.to_string_lossy().into_owned(),
]
);
}
#[test]
fn implicit_launch_does_not_change_existing_cli_validation() {
let directory = tempdir().unwrap();
let socket = directory.path().join("hxr.sock");
let no_action = hxr_command(&socket).output().unwrap();
assert!(!no_action.status.success());
assert!(
String::from_utf8_lossy(&no_action.stderr).contains(
"choose exactly one of --listen, --open, --status, --stop, or --print-config"
)
);
let listen_with_path = hxr_command(&socket)
.args(["--listen", "README.md"])
.output()
.unwrap();
assert!(!listen_with_path.status.success());
assert!(
String::from_utf8_lossy(&listen_with_path.stderr)
.contains("--listen does not accept paths; use --helix-arg for Helix options")
);
let stdin_without_open = hxr_command(&socket).arg("-").output().unwrap();
assert!(!stdin_without_open.status.success());
assert!(
String::from_utf8_lossy(&stdin_without_open.stderr)
.contains("stdin (-) requires --open and a listening Helix instance")
);
}
#[test]
fn opens_files_with_positions_and_piped_scratch_text() {
let directory = tempdir().unwrap();
let socket = directory.path().join("hxr.sock");
let file = directory.path().join("source file.rs");
fs::write(&file, "one\ntwo\nthree\n").unwrap();
let mut child = Command::new(env!("CARGO_BIN_EXE_hxr"))
.args(["--socket", socket.to_str().unwrap(), "--lsp"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.unwrap();
let mut lsp_input = child.stdin.take().unwrap();
let lsp_output = child.stdout.take().unwrap();
let mut child = ChildGuard(child);
let (message_tx, message_rx) = mpsc::channel();
thread::spawn(move || {
let mut reader = BufReader::new(lsp_output);
loop {
match read_lsp_message(&mut reader) {
Ok(Some(message)) => {
if message_tx.send(message).is_err() {
return;
}
}
_ => return,
}
}
});
send_lsp(
&mut lsp_input,
json!({
"jsonrpc": "2.0",
"id": 100,
"method": "initialize",
"params": {"capabilities": {"window": {"showDocument": {"support": true}}}}
}),
);
let initialize_response = receive(&message_rx);
assert_eq!(initialize_response["id"], 100);
send_lsp(
&mut lsp_input,
json!({"jsonrpc": "2.0", "method": "initialized", "params": {}}),
);
wait_for_path(&socket);
let positioned_path = format!("{}:3:2", file.display());
let output = Command::new(env!("CARGO_BIN_EXE_hxr"))
.args([
"--socket",
socket.to_str().unwrap(),
"--open",
&positioned_path,
])
.output()
.unwrap();
assert!(
output.status.success(),
"hxr failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let show_file = receive(&message_rx);
assert_eq!(show_file["method"], "window/showDocument");
assert_eq!(show_file["params"]["takeFocus"], true);
assert_eq!(show_file["params"]["selection"]["start"]["line"], 2);
assert_eq!(show_file["params"]["selection"]["start"]["character"], 1);
let opened_path = Url::parse(show_file["params"]["uri"].as_str().unwrap())
.unwrap()
.to_file_path()
.unwrap();
assert_eq!(opened_path, file);
reply_to_show_document(&mut lsp_input, &show_file);
let mut stdin_client = Command::new(env!("CARGO_BIN_EXE_hxr"))
.args([
"--socket",
socket.to_str().unwrap(),
"--stdin-name",
"changes.diff",
"--open",
"-",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
stdin_client
.stdin
.take()
.unwrap()
.write_all(b"-before\n+after\n")
.unwrap();
let stdin_output = stdin_client.wait_with_output().unwrap();
assert!(
stdin_output.status.success(),
"stdin hxr failed: {}",
String::from_utf8_lossy(&stdin_output.stderr)
);
let show_scratch = receive(&message_rx);
let scratch_path = Url::parse(show_scratch["params"]["uri"].as_str().unwrap())
.unwrap()
.to_file_path()
.unwrap();
assert!(
scratch_path
.file_name()
.unwrap()
.to_string_lossy()
.ends_with("changes.diff")
);
assert_eq!(
fs::read_to_string(&scratch_path).unwrap(),
"-before\n+after\n"
);
reply_to_show_document(&mut lsp_input, &show_scratch);
send_lsp(
&mut lsp_input,
json!({"jsonrpc": "2.0", "id": 101, "method": "shutdown", "params": null}),
);
assert_eq!(receive(&message_rx)["id"], 101);
send_lsp(
&mut lsp_input,
json!({"jsonrpc": "2.0", "method": "exit", "params": null}),
);
drop(lsp_input);
assert!(child.0.wait().unwrap().success());
}
#[test]
fn status_and_stop_follow_the_server_lifecycle() {
let directory = tempdir().unwrap();
let socket = directory.path().join("hxr.sock");
let mut child = spawn_server(&socket);
wait_for_path(&socket);
let running = hxr_command(&socket).arg("--status").output().unwrap();
assert!(
running.status.success(),
"status failed: {}",
String::from_utf8_lossy(&running.stderr)
);
assert!(
String::from_utf8_lossy(&running.stdout).contains("listening on"),
"unexpected status output: {}",
String::from_utf8_lossy(&running.stdout)
);
let stopped = hxr_command(&socket).arg("--stop").output().unwrap();
assert!(
stopped.status.success(),
"stop failed: {}",
String::from_utf8_lossy(&stopped.stderr)
);
assert_eq!(
String::from_utf8_lossy(&stopped.stdout).trim(),
"server stopping"
);
assert!(child.0.wait().unwrap().success());
assert!(!socket.exists(), "graceful stop left the socket behind");
let not_running = hxr_command(&socket).arg("--status").output().unwrap();
assert!(!not_running.status.success());
assert!(
String::from_utf8_lossy(¬_running.stderr).contains("no socket is listening on"),
"unexpected stopped status output: {}",
String::from_utf8_lossy(¬_running.stderr)
);
drop(UnixListener::bind(&socket).unwrap());
assert!(socket.exists(), "test did not create a stale socket");
let stale = hxr_command(&socket).arg("--status").output().unwrap();
assert!(
!stale.status.success(),
"a stale socket was reported as listening"
);
}
#[test]
fn force_stop_kills_the_server_and_removes_its_socket() {
let directory = tempdir().unwrap();
let socket = directory.path().join("hxr.sock");
let mut child = spawn_server(&socket);
wait_for_path(&socket);
let stopped = hxr_command(&socket)
.args(["--stop", "--force"])
.output()
.unwrap();
assert!(
stopped.status.success(),
"forced stop failed: {}",
String::from_utf8_lossy(&stopped.stderr)
);
assert_eq!(
String::from_utf8_lossy(&stopped.stdout).trim(),
"server force-stopping"
);
let status = child.0.wait().unwrap();
assert_eq!(status.signal(), Some(libc::SIGKILL));
assert!(!socket.exists(), "forced stop left the socket behind");
}
fn spawn_server(socket: &Path) -> ChildGuard {
let child = Command::new(env!("CARGO_BIN_EXE_hxr"))
.args(["--socket", socket.to_str().unwrap(), "--lsp"])
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::inherit())
.spawn()
.unwrap();
ChildGuard(child)
}
fn hxr_command(socket: &Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_hxr"));
command.args(["--socket", socket.to_str().unwrap()]);
command
}
fn send_lsp(input: &mut impl Write, message: Value) {
write_lsp_message(input, &message).unwrap();
}
fn receive(receiver: &Receiver<Value>) -> Value {
receiver
.recv_timeout(Duration::from_secs(5))
.expect("timed out waiting for an LSP message")
}
fn reply_to_show_document(input: &mut impl Write, request: &Value) {
send_lsp(
input,
json!({
"jsonrpc": "2.0",
"id": request["id"],
"result": {"success": true}
}),
);
}
fn wait_for_path(path: &Path) {
let deadline = Instant::now() + Duration::from_secs(5);
while !path.exists() {
assert!(Instant::now() < deadline, "socket was not created");
thread::sleep(Duration::from_millis(10));
}
}