hx-remote 0.1.2

Open files in new or existing Helix sessions through a tiny LSP bridge
Documentation
#![cfg_attr(not(unix), allow(dead_code))]

#[cfg(not(unix))]
compile_error!("hx-remote currently requires Unix-domain sockets");

mod client;
mod server;

use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::env;
use std::ffi::OsStr;
use std::io::{self, BufRead, ErrorKind, Write};
use std::path::{Path, PathBuf};
use url::Url;

pub use client::{send_socket_request, socket_is_listening};
pub use server::run_server;

pub const SOCKET_ENV: &str = "HXR_SOCKET";
pub const MAX_LSP_MESSAGE_BYTES: usize = 16 * 1024 * 1024;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SocketRequest {
    Open {
        path: PathBuf,
        line: Option<u32>,
        column: Option<u32>,
    },
    OpenStdin {
        contents: String,
        name: String,
    },
    Stop,
    ForceStop,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SocketResponse {
    pub ok: bool,
    pub message: String,
}

impl SocketResponse {
    pub fn success(message: impl Into<String>) -> Self {
        Self {
            ok: true,
            message: message.into(),
        }
    }

    pub fn error(message: impl Into<String>) -> Self {
        Self {
            ok: false,
            message: message.into(),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedTarget {
    pub path: PathBuf,
    /// One-based line number, matching the command-line syntax.
    pub line: Option<u32>,
    /// One-based column number, matching the command-line syntax.
    pub column: Option<u32>,
}

pub fn parse_target(input: &OsStr) -> Result<ParsedTarget, String> {
    let Some(text) = input.to_str() else {
        return Ok(ParsedTarget {
            path: PathBuf::from(input),
            line: None,
            column: None,
        });
    };

    let Some((before_last, last)) = text.rsplit_once(':') else {
        return Ok(ParsedTarget {
            path: PathBuf::from(input),
            line: None,
            column: None,
        });
    };

    let Some(last_number) = parse_numeric_suffix(last)? else {
        return Ok(ParsedTarget {
            path: PathBuf::from(input),
            line: None,
            column: None,
        });
    };

    let (path, line, column) = match before_last.rsplit_once(':') {
        Some((path, possible_line)) => match parse_numeric_suffix(possible_line)? {
            Some(line) => (path, line, Some(last_number)),
            None => (before_last, last_number, None),
        },
        None => (before_last, last_number, None),
    };

    if path.is_empty() {
        return Err("the file path before :line[:column] cannot be empty".into());
    }

    Ok(ParsedTarget {
        path: PathBuf::from(path),
        line: Some(line),
        column,
    })
}

fn parse_numeric_suffix(value: &str) -> Result<Option<u32>, String> {
    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
        return Ok(None);
    }

    let number = value
        .parse::<u32>()
        .map_err(|_| format!("position component {value:?} is too large"))?;
    if number == 0 {
        return Err("line and column numbers start at 1".into());
    }
    Ok(Some(number))
}

pub fn absolute_path(path: &Path) -> io::Result<PathBuf> {
    if path.is_absolute() {
        Ok(path.to_path_buf())
    } else {
        Ok(env::current_dir()?.join(path))
    }
}

pub fn resolve_socket_path(explicit: Option<PathBuf>) -> PathBuf {
    explicit
        .or_else(|| {
            env::var_os(SOCKET_ENV)
                .filter(|value| !value.is_empty())
                .map(PathBuf::from)
        })
        .unwrap_or_else(default_socket_path)
}

pub fn default_socket_path() -> PathBuf {
    if let Some(runtime_dir) = env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) {
        return PathBuf::from(runtime_dir).join("hx-remote.sock");
    }

    // The uid prevents users from contending for the same name on systems where
    // the temporary directory is shared (most notably /tmp on Linux).
    let uid = unsafe { libc::geteuid() };
    env::temp_dir().join(format!("hx-remote-{uid}.sock"))
}

pub fn default_sentinel_path() -> PathBuf {
    let cache_root = env::var_os("XDG_CACHE_HOME")
        .filter(|value| !value.is_empty())
        .map(PathBuf::from)
        .or_else(|| {
            env::var_os("HOME")
                .filter(|value| !value.is_empty())
                .map(|home| PathBuf::from(home).join(".cache"))
        })
        .unwrap_or_else(env::temp_dir);
    cache_root.join("hx-remote").join("remote.hxremote")
}

pub fn show_document_params(
    path: &Path,
    line: Option<u32>,
    column: Option<u32>,
) -> Result<Value, String> {
    let uri = Url::from_file_path(path)
        .map_err(|()| format!("cannot convert {} to a file URI", path.display()))?;

    let mut params = json!({
        "uri": uri.as_str(),
        "takeFocus": true
    });

    if let Some(line) = line {
        let position = json!({
            "line": line - 1,
            "character": column.unwrap_or(1) - 1
        });
        params["selection"] = json!({
            "start": position,
            "end": position
        });
    }

    Ok(params)
}

pub fn read_lsp_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
    let mut content_length = None;
    let mut saw_header = false;

    loop {
        let mut line = String::new();
        let bytes_read = reader.read_line(&mut line)?;
        if bytes_read == 0 {
            return if saw_header {
                Err(io::Error::new(
                    ErrorKind::UnexpectedEof,
                    "LSP input ended in the middle of its headers",
                ))
            } else {
                Ok(None)
            };
        }

        let line = line.trim_end_matches(['\r', '\n']);
        if line.is_empty() {
            if saw_header {
                break;
            }
            continue;
        }
        saw_header = true;

        let Some((name, value)) = line.split_once(':') else {
            return Err(io::Error::new(
                ErrorKind::InvalidData,
                format!("malformed LSP header: {line}"),
            ));
        };
        if name.eq_ignore_ascii_case("Content-Length") {
            content_length = Some(value.trim().parse::<usize>().map_err(|_| {
                io::Error::new(ErrorKind::InvalidData, "invalid LSP Content-Length")
            })?);
        }
    }

    let content_length = content_length.ok_or_else(|| {
        io::Error::new(ErrorKind::InvalidData, "LSP message has no Content-Length")
    })?;
    if content_length > MAX_LSP_MESSAGE_BYTES {
        return Err(io::Error::new(
            ErrorKind::InvalidData,
            format!("LSP message exceeds {MAX_LSP_MESSAGE_BYTES} bytes"),
        ));
    }

    let mut body = vec![0; content_length];
    reader.read_exact(&mut body)?;
    serde_json::from_slice(&body)
        .map(Some)
        .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))
}

pub fn write_lsp_message(writer: &mut impl Write, message: &Value) -> io::Result<()> {
    let body = serde_json::to_vec(message)
        .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
    write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
    writer.write_all(&body)?;
    writer.flush()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::ffi::OsStr;
    use std::io::{BufReader, Cursor};

    #[test]
    fn parses_path_line_and_column_from_the_right() {
        assert_eq!(
            parse_target(OsStr::new("src/main.rs:50:12")).unwrap(),
            ParsedTarget {
                path: "src/main.rs".into(),
                line: Some(50),
                column: Some(12),
            }
        );
        assert_eq!(
            parse_target(OsStr::new("a:name:7")).unwrap(),
            ParsedTarget {
                path: "a:name".into(),
                line: Some(7),
                column: None,
            }
        );
    }

    #[test]
    fn leaves_non_numeric_colons_in_the_path() {
        assert_eq!(
            parse_target(OsStr::new("notes:today.txt")).unwrap(),
            ParsedTarget {
                path: "notes:today.txt".into(),
                line: None,
                column: None,
            }
        );
    }

    #[test]
    fn rejects_zero_based_cli_positions() {
        assert_eq!(
            parse_target(OsStr::new("main.rs:0")).unwrap_err(),
            "line and column numbers start at 1"
        );
    }

    #[test]
    fn translates_cli_positions_to_zero_based_lsp_positions() {
        let params = show_document_params(Path::new("/tmp/main.rs"), Some(50), Some(12)).unwrap();
        assert_eq!(params["selection"]["start"]["line"], 49);
        assert_eq!(params["selection"]["start"]["character"], 11);
        assert_eq!(params["takeFocus"], true);
    }

    #[test]
    fn lsp_framing_round_trips() {
        let value = json!({"jsonrpc": "2.0", "method": "initialized", "params": {}});
        let mut encoded = Vec::new();
        write_lsp_message(&mut encoded, &value).unwrap();

        let mut reader = BufReader::new(Cursor::new(encoded));
        assert_eq!(read_lsp_message(&mut reader).unwrap(), Some(value));
        assert_eq!(read_lsp_message(&mut reader).unwrap(), None);
    }
}