hx-remote 0.1.2

Open files in new or existing Helix sessions through a tiny LSP bridge
Documentation
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";

    // Put launch options after the first path to ensure Clap does not consume
    // known options as additional positional filenames.
    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(&not_running.stderr).contains("no socket is listening on"),
        "unexpected stopped status output: {}",
        String::from_utf8_lossy(&not_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));
    }
}