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 { 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}