hx-remote 0.1.0

Open files in an existing Helix session 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::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 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());
}

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));
    }
}