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