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