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