Skip to main content

vtcode_commons/
editor.rs

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