Skip to main content

vtcode_commons/
editor.rs

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}