#![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,
pub line: Option<u32>,
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");
}
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);
}
}