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