Skip to main content

hx_remote/
lib.rs

1#![cfg_attr(not(unix), allow(dead_code))]
2
3#[cfg(not(unix))]
4compile_error!("hx-remote currently requires Unix-domain sockets");
5
6mod client;
7mod server;
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use std::env;
12use std::ffi::OsStr;
13use std::io::{self, BufRead, ErrorKind, Write};
14use std::path::{Path, PathBuf};
15use url::Url;
16
17pub use client::{send_socket_request, socket_is_listening};
18pub use server::run_server;
19
20pub const SOCKET_ENV: &str = "HXR_SOCKET";
21pub const MAX_LSP_MESSAGE_BYTES: usize = 16 * 1024 * 1024;
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(tag = "type", rename_all = "snake_case")]
25pub enum SocketRequest {
26    Open {
27        path: PathBuf,
28        line: Option<u32>,
29        column: Option<u32>,
30    },
31    OpenStdin {
32        contents: String,
33        name: String,
34    },
35    Stop,
36    ForceStop,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
40pub struct SocketResponse {
41    pub ok: bool,
42    pub message: String,
43}
44
45impl SocketResponse {
46    pub fn success(message: impl Into<String>) -> Self {
47        Self {
48            ok: true,
49            message: message.into(),
50        }
51    }
52
53    pub fn error(message: impl Into<String>) -> Self {
54        Self {
55            ok: false,
56            message: message.into(),
57        }
58    }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ParsedTarget {
63    pub path: PathBuf,
64    /// One-based line number, matching the command-line syntax.
65    pub line: Option<u32>,
66    /// One-based column number, matching the command-line syntax.
67    pub column: Option<u32>,
68}
69
70pub fn parse_target(input: &OsStr) -> Result<ParsedTarget, String> {
71    let Some(text) = input.to_str() else {
72        return Ok(ParsedTarget {
73            path: PathBuf::from(input),
74            line: None,
75            column: None,
76        });
77    };
78
79    let Some((before_last, last)) = text.rsplit_once(':') else {
80        return Ok(ParsedTarget {
81            path: PathBuf::from(input),
82            line: None,
83            column: None,
84        });
85    };
86
87    let Some(last_number) = parse_numeric_suffix(last)? else {
88        return Ok(ParsedTarget {
89            path: PathBuf::from(input),
90            line: None,
91            column: None,
92        });
93    };
94
95    let (path, line, column) = match before_last.rsplit_once(':') {
96        Some((path, possible_line)) => match parse_numeric_suffix(possible_line)? {
97            Some(line) => (path, line, Some(last_number)),
98            None => (before_last, last_number, None),
99        },
100        None => (before_last, last_number, None),
101    };
102
103    if path.is_empty() {
104        return Err("the file path before :line[:column] cannot be empty".into());
105    }
106
107    Ok(ParsedTarget {
108        path: PathBuf::from(path),
109        line: Some(line),
110        column,
111    })
112}
113
114fn parse_numeric_suffix(value: &str) -> Result<Option<u32>, String> {
115    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
116        return Ok(None);
117    }
118
119    let number = value
120        .parse::<u32>()
121        .map_err(|_| format!("position component {value:?} is too large"))?;
122    if number == 0 {
123        return Err("line and column numbers start at 1".into());
124    }
125    Ok(Some(number))
126}
127
128pub fn absolute_path(path: &Path) -> io::Result<PathBuf> {
129    if path.is_absolute() {
130        Ok(path.to_path_buf())
131    } else {
132        Ok(env::current_dir()?.join(path))
133    }
134}
135
136pub fn resolve_socket_path(explicit: Option<PathBuf>) -> PathBuf {
137    explicit
138        .or_else(|| {
139            env::var_os(SOCKET_ENV)
140                .filter(|value| !value.is_empty())
141                .map(PathBuf::from)
142        })
143        .unwrap_or_else(default_socket_path)
144}
145
146pub fn default_socket_path() -> PathBuf {
147    if let Some(runtime_dir) = env::var_os("XDG_RUNTIME_DIR").filter(|value| !value.is_empty()) {
148        return PathBuf::from(runtime_dir).join("hx-remote.sock");
149    }
150
151    // The uid prevents users from contending for the same name on systems where
152    // the temporary directory is shared (most notably /tmp on Linux).
153    let uid = unsafe { libc::geteuid() };
154    env::temp_dir().join(format!("hx-remote-{uid}.sock"))
155}
156
157pub fn default_sentinel_path() -> PathBuf {
158    let cache_root = env::var_os("XDG_CACHE_HOME")
159        .filter(|value| !value.is_empty())
160        .map(PathBuf::from)
161        .or_else(|| {
162            env::var_os("HOME")
163                .filter(|value| !value.is_empty())
164                .map(|home| PathBuf::from(home).join(".cache"))
165        })
166        .unwrap_or_else(env::temp_dir);
167    cache_root.join("hx-remote").join("remote.hxremote")
168}
169
170pub fn show_document_params(
171    path: &Path,
172    line: Option<u32>,
173    column: Option<u32>,
174) -> Result<Value, String> {
175    let uri = Url::from_file_path(path)
176        .map_err(|()| format!("cannot convert {} to a file URI", path.display()))?;
177
178    let mut params = json!({
179        "uri": uri.as_str(),
180        "takeFocus": true
181    });
182
183    if let Some(line) = line {
184        let position = json!({
185            "line": line - 1,
186            "character": column.unwrap_or(1) - 1
187        });
188        params["selection"] = json!({
189            "start": position,
190            "end": position
191        });
192    }
193
194    Ok(params)
195}
196
197pub fn read_lsp_message(reader: &mut impl BufRead) -> io::Result<Option<Value>> {
198    let mut content_length = None;
199    let mut saw_header = false;
200
201    loop {
202        let mut line = String::new();
203        let bytes_read = reader.read_line(&mut line)?;
204        if bytes_read == 0 {
205            return if saw_header {
206                Err(io::Error::new(
207                    ErrorKind::UnexpectedEof,
208                    "LSP input ended in the middle of its headers",
209                ))
210            } else {
211                Ok(None)
212            };
213        }
214
215        let line = line.trim_end_matches(['\r', '\n']);
216        if line.is_empty() {
217            if saw_header {
218                break;
219            }
220            continue;
221        }
222        saw_header = true;
223
224        let Some((name, value)) = line.split_once(':') else {
225            return Err(io::Error::new(
226                ErrorKind::InvalidData,
227                format!("malformed LSP header: {line}"),
228            ));
229        };
230        if name.eq_ignore_ascii_case("Content-Length") {
231            content_length = Some(value.trim().parse::<usize>().map_err(|_| {
232                io::Error::new(ErrorKind::InvalidData, "invalid LSP Content-Length")
233            })?);
234        }
235    }
236
237    let content_length = content_length.ok_or_else(|| {
238        io::Error::new(ErrorKind::InvalidData, "LSP message has no Content-Length")
239    })?;
240    if content_length > MAX_LSP_MESSAGE_BYTES {
241        return Err(io::Error::new(
242            ErrorKind::InvalidData,
243            format!("LSP message exceeds {MAX_LSP_MESSAGE_BYTES} bytes"),
244        ));
245    }
246
247    let mut body = vec![0; content_length];
248    reader.read_exact(&mut body)?;
249    serde_json::from_slice(&body)
250        .map(Some)
251        .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))
252}
253
254pub fn write_lsp_message(writer: &mut impl Write, message: &Value) -> io::Result<()> {
255    let body = serde_json::to_vec(message)
256        .map_err(|error| io::Error::new(ErrorKind::InvalidData, error))?;
257    write!(writer, "Content-Length: {}\r\n\r\n", body.len())?;
258    writer.write_all(&body)?;
259    writer.flush()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::ffi::OsStr;
266    use std::io::{BufReader, Cursor};
267
268    #[test]
269    fn parses_path_line_and_column_from_the_right() {
270        assert_eq!(
271            parse_target(OsStr::new("src/main.rs:50:12")).unwrap(),
272            ParsedTarget {
273                path: "src/main.rs".into(),
274                line: Some(50),
275                column: Some(12),
276            }
277        );
278        assert_eq!(
279            parse_target(OsStr::new("a:name:7")).unwrap(),
280            ParsedTarget {
281                path: "a:name".into(),
282                line: Some(7),
283                column: None,
284            }
285        );
286    }
287
288    #[test]
289    fn leaves_non_numeric_colons_in_the_path() {
290        assert_eq!(
291            parse_target(OsStr::new("notes:today.txt")).unwrap(),
292            ParsedTarget {
293                path: "notes:today.txt".into(),
294                line: None,
295                column: None,
296            }
297        );
298    }
299
300    #[test]
301    fn rejects_zero_based_cli_positions() {
302        assert_eq!(
303            parse_target(OsStr::new("main.rs:0")).unwrap_err(),
304            "line and column numbers start at 1"
305        );
306    }
307
308    #[test]
309    fn translates_cli_positions_to_zero_based_lsp_positions() {
310        let params = show_document_params(Path::new("/tmp/main.rs"), Some(50), Some(12)).unwrap();
311        assert_eq!(params["selection"]["start"]["line"], 49);
312        assert_eq!(params["selection"]["start"]["character"], 11);
313        assert_eq!(params["takeFocus"], true);
314    }
315
316    #[test]
317    fn lsp_framing_round_trips() {
318        let value = json!({"jsonrpc": "2.0", "method": "initialized", "params": {}});
319        let mut encoded = Vec::new();
320        write_lsp_message(&mut encoded, &value).unwrap();
321
322        let mut reader = BufReader::new(Cursor::new(encoded));
323        assert_eq!(read_lsp_message(&mut reader).unwrap(), Some(value));
324        assert_eq!(read_lsp_message(&mut reader).unwrap(), None);
325    }
326}