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 pub line: Option<u32>,
68 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 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}