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