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