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