1#![expect(
2 clippy::indexing_slicing,
3 clippy::string_slice,
4 unused_results,
5 reason = "Editor URI parsing uses validated delimiters and intentionally ignores String mutation results."
6)]
7
8use std::borrow::Cow;
9use std::env;
10use std::path::{Path, PathBuf};
11
12use percent_encoding::percent_decode_str;
13use url::Url;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub struct EditorPoint {
17 pub line: usize,
18 pub column: Option<usize>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct EditorTarget {
23 path: PathBuf,
24 location_suffix: Option<String>,
25}
26
27impl EditorTarget {
28 #[must_use]
29 pub fn new(path: PathBuf, location_suffix: Option<String>) -> Self {
30 Self { path, location_suffix }
31 }
32
33 #[must_use]
34 pub fn path(&self) -> &Path {
35 &self.path
36 }
37
38 #[must_use]
39 pub fn location_suffix(&self) -> Option<&str> {
40 self.location_suffix.as_deref()
41 }
42
43 #[must_use]
44 pub fn with_resolved_path(mut self, base: &Path) -> Self {
45 self.path = resolve_editor_path(&self.path, base);
46 self
47 }
48
49 #[must_use]
50 pub fn canonical_string(&self) -> String {
51 let mut target = self.path.display().to_string();
52 if let Some(location) = self.location_suffix() {
53 target.push_str(location);
54 }
55 target
56 }
57
58 #[must_use]
59 pub fn point(&self) -> Option<EditorPoint> {
60 let suffix = self.location_suffix()?.strip_prefix(':')?;
61 if suffix.contains('-') {
62 return None;
63 }
64
65 let mut parts = suffix.split(':');
66 let line = parts.next()?.parse().ok()?;
67 let column = parts.next().map(str::parse).transpose().ok().flatten();
68 if parts.next().is_some() {
69 return None;
70 }
71
72 Some(EditorPoint { line, column })
73 }
74}
75
76#[must_use]
77pub fn parse_editor_target(raw: &str) -> Option<EditorTarget> {
78 let raw = raw.trim();
79 if raw.is_empty() {
80 return None;
81 }
82
83 if raw.starts_with("http://") || raw.starts_with("https://") {
84 return None;
85 }
86 if raw.contains("://") && !raw.starts_with("file://") {
87 return None;
88 }
89
90 if raw.starts_with("file://") {
91 let url = Url::parse(raw).ok()?;
92 let location_suffix = url
93 .fragment()
94 .and_then(normalize_editor_hash_fragment)
95 .or_else(|| extract_trailing_location(url.path()));
96 let path = url.to_file_path().ok()?;
97 return Some(EditorTarget::new(path, location_suffix));
98 }
99
100 if let Some((path_str, fragment)) = raw.split_once('#')
101 && let Some(location_suffix) = normalize_editor_hash_fragment(fragment)
102 {
103 if path_str.is_empty() {
104 return None;
105 }
106 let decoded_path = decode_bare_local_path(path_str);
107 return Some(EditorTarget::new(
108 expand_home_relative_path(decoded_path.as_ref()).unwrap_or_else(|| PathBuf::from(decoded_path.as_ref())),
109 Some(location_suffix),
110 ));
111 }
112
113 if let Some(paren_start) = location_paren_suffix_start(raw) {
114 let location_suffix = parse_paren_location_suffix(&raw[paren_start..])?;
115 let path_str = &raw[..paren_start];
116 if path_str.is_empty() {
117 return None;
118 }
119 let decoded_path = decode_bare_local_path(path_str);
120
121 return Some(EditorTarget::new(
122 expand_home_relative_path(decoded_path.as_ref()).unwrap_or_else(|| PathBuf::from(decoded_path.as_ref())),
123 Some(location_suffix),
124 ));
125 }
126
127 let location_suffix = extract_trailing_location(raw);
128 let path_str = match location_suffix.as_deref() {
129 Some(suffix) => &raw[..raw.len().saturating_sub(suffix.len())],
130 None => raw,
131 };
132 if path_str.is_empty() {
133 return None;
134 }
135 let decoded_path = decode_bare_local_path(path_str);
136
137 Some(EditorTarget::new(
138 expand_home_relative_path(decoded_path.as_ref()).unwrap_or_else(|| PathBuf::from(decoded_path.as_ref())),
139 location_suffix,
140 ))
141}
142
143#[must_use]
144pub fn resolve_editor_target(raw: &str, base: &Path) -> Option<EditorTarget> {
145 parse_editor_target(raw).map(|target| target.with_resolved_path(base))
146}
147
148#[must_use]
149pub fn resolve_editor_path(path: &Path, base: &Path) -> PathBuf {
150 if path.is_absolute() {
151 return path.to_path_buf();
152 }
153
154 let mut joined = PathBuf::from(base);
155 for component in path.components() {
156 match component {
157 std::path::Component::CurDir => {}
158 std::path::Component::ParentDir => {
159 joined.pop();
160 }
161 other => joined.push(other.as_os_str()),
162 }
163 }
164 joined
165}
166
167fn expand_home_relative_path(path: &str) -> Option<PathBuf> {
168 let remainder = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\"))?;
169 let home = env::var_os("HOME").or_else(|| env::var_os("USERPROFILE"))?;
170 Some(PathBuf::from(home).join(remainder))
171}
172
173fn decode_bare_local_path(path: &str) -> Cow<'_, str> {
174 percent_decode_str(path).decode_utf8().unwrap_or(Cow::Borrowed(path))
175}
176
177fn extract_trailing_location(raw: &str) -> Option<String> {
178 let bytes = raw.as_bytes();
179 let mut idx = bytes.len();
180 while idx > 0 && (bytes[idx - 1].is_ascii_digit() || matches!(bytes[idx - 1], b':' | b'-')) {
181 idx -= 1;
182 }
183 if idx >= bytes.len() || bytes.get(idx).copied() != Some(b':') {
184 return None;
185 }
186
187 let suffix = &raw[idx..];
188 let digits = suffix.chars().filter(|ch| ch.is_ascii_digit()).count();
189 (digits > 0).then(|| suffix.to_string())
190}
191
192fn location_paren_suffix_start(token: &str) -> Option<usize> {
193 let paren_start = token.rfind('(')?;
194 let inner = token[paren_start + 1..].strip_suffix(')')?;
195 let valid = !inner.is_empty()
196 && !inner.starts_with(',')
197 && !inner.ends_with(',')
198 && !inner.contains(",,")
199 && inner.chars().all(|c| c.is_ascii_digit() || c == ',');
200 valid.then_some(paren_start)
201}
202
203fn parse_paren_location_suffix(suffix: &str) -> Option<String> {
204 let inner = suffix.strip_prefix('(')?.strip_suffix(')')?;
205 if inner.is_empty() {
206 return None;
207 }
208
209 let mut parts = inner.split(',');
210 let line = parts.next()?;
211 let column = parts.next();
212 if parts.next().is_some() {
213 return None;
214 }
215
216 if line.is_empty() || !line.chars().all(|ch| ch.is_ascii_digit()) {
217 return None;
218 }
219
220 let mut normalized = format!(":{line}");
221 if let Some(column) = column {
222 if column.is_empty() || !column.chars().all(|ch| ch.is_ascii_digit()) {
223 return None;
224 }
225 normalized.push(':');
226 normalized.push_str(column);
227 }
228
229 Some(normalized)
230}
231
232#[must_use]
233pub fn normalize_editor_hash_fragment(fragment: &str) -> Option<String> {
234 let (start, end) = match fragment.split_once('-') {
235 Some((start, end)) => (start, Some(end)),
236 None => (fragment, None),
237 };
238
239 let (start_line, start_col) = parse_hash_point(start)?;
240 let mut normalized = format!(":{start_line}");
241 if let Some(col) = start_col {
242 normalized.push(':');
243 normalized.push_str(col);
244 }
245
246 if let Some(end) = end {
247 let (end_line, end_col) = parse_hash_point(end)?;
248 normalized.push('-');
249 normalized.push_str(end_line);
250 if let Some(col) = end_col {
251 normalized.push(':');
252 normalized.push_str(col);
253 }
254 }
255
256 Some(normalized)
257}
258
259fn parse_hash_point(point: &str) -> Option<(&str, Option<&str>)> {
260 let point = point.strip_prefix('L')?;
261 let (line, column) = match point.split_once('C') {
262 Some((line, column)) => (line, Some(column)),
263 None => (point, None),
264 };
265 if line.is_empty() || !line.chars().all(|ch| ch.is_ascii_digit()) {
266 return None;
267 }
268 if let Some(column) = column
269 && (column.is_empty() || !column.chars().all(|ch| ch.is_ascii_digit()))
270 {
271 return None;
272 }
273 Some((line, column))
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn parses_colon_location_suffix() {
282 let target = parse_editor_target("/tmp/demo.rs:12:4").expect("target");
283 assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
284 assert_eq!(target.location_suffix(), Some(":12:4"));
285 assert_eq!(target.point(), Some(EditorPoint { line: 12, column: Some(4) }));
286 }
287
288 #[test]
289 fn parses_paren_location_suffix() {
290 let target = parse_editor_target("/tmp/demo.rs(12,4)").expect("target");
291 assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
292 assert_eq!(target.location_suffix(), Some(":12:4"));
293 }
294
295 #[test]
296 fn parses_hash_location_suffix() {
297 let target = parse_editor_target("/tmp/demo.rs#L12C4").expect("target");
298 assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
299 assert_eq!(target.location_suffix(), Some(":12:4"));
300 }
301
302 #[test]
303 fn normalizes_hash_location_ranges() {
304 assert_eq!(normalize_editor_hash_fragment("L74C3-L76C9"), Some(":74:3-76:9".to_string()));
305 assert_eq!(normalize_editor_hash_fragment("L74-L76"), Some(":74-76".to_string()));
306 assert_eq!(normalize_editor_hash_fragment("L"), None);
307 assert_eq!(normalize_editor_hash_fragment("L74-"), None);
308 assert_eq!(normalize_editor_hash_fragment("L74C"), None);
309 }
310
311 #[test]
312 fn hash_ranges_preserve_suffix_but_not_point() {
313 let target = parse_editor_target("/tmp/demo.rs#L12-L18").expect("target");
314 assert_eq!(target.location_suffix(), Some(":12-18"));
315 assert_eq!(target.point(), None);
316 }
317
318 #[test]
319 fn file_urls_are_supported() {
320 let target = parse_editor_target("file:///tmp/demo.rs#L12").expect("target");
321 assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
322 assert_eq!(target.location_suffix(), Some(":12"));
323 }
324
325 #[test]
326 fn bare_percent_encoded_paths_are_decoded() {
327 let target = parse_editor_target("/tmp/Example%20Folder/R%C3%A9sum%C3%A9.md:12").expect("target");
328 assert_eq!(target.path(), Path::new("/tmp/Example Folder/Résumé.md"));
329 assert_eq!(target.location_suffix(), Some(":12"));
330 }
331
332 #[test]
333 fn non_file_urls_are_rejected() {
334 assert!(parse_editor_target("https://example.com/file.rs").is_none());
335 }
336
337 #[test]
338 fn resolves_relative_paths_against_base() {
339 let target = resolve_editor_target("src/lib.rs:12", Path::new("/workspace")).expect("target");
340 assert_eq!(target.path(), Path::new("/workspace/src/lib.rs"));
341 assert_eq!(target.location_suffix(), Some(":12"));
342 assert_eq!(target.canonical_string(), "/workspace/src/lib.rs:12");
343 }
344}