Skip to main content

vtcode_commons/
editor.rs

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_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_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
221fn normalize_hash_fragment(fragment: &str) -> Option<String> {
222    let fragment = fragment.strip_prefix('L')?;
223    let mut normalized = String::from(":");
224    for ch in fragment.chars() {
225        match ch {
226            'L' => {}
227            'C' => normalized.push(':'),
228            '0'..='9' | '-' => normalized.push(ch),
229            _ => return None,
230        }
231    }
232    Some(normalized)
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn parses_colon_location_suffix() {
241        let target = parse_editor_target("/tmp/demo.rs:12:4").expect("target");
242        assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
243        assert_eq!(target.location_suffix(), Some(":12:4"));
244        assert_eq!(
245            target.point(),
246            Some(EditorPoint {
247                line: 12,
248                column: Some(4)
249            })
250        );
251    }
252
253    #[test]
254    fn parses_paren_location_suffix() {
255        let target = parse_editor_target("/tmp/demo.rs(12,4)").expect("target");
256        assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
257        assert_eq!(target.location_suffix(), Some(":12:4"));
258    }
259
260    #[test]
261    fn parses_hash_location_suffix() {
262        let target = parse_editor_target("/tmp/demo.rs#L12C4").expect("target");
263        assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
264        assert_eq!(target.location_suffix(), Some(":12:4"));
265    }
266
267    #[test]
268    fn hash_ranges_preserve_suffix_but_not_point() {
269        let target = parse_editor_target("/tmp/demo.rs#L12-L18").expect("target");
270        assert_eq!(target.location_suffix(), Some(":12-18"));
271        assert_eq!(target.point(), None);
272    }
273
274    #[test]
275    fn file_urls_are_supported() {
276        let target = parse_editor_target("file:///tmp/demo.rs#L12").expect("target");
277        assert_eq!(target.path(), Path::new("/tmp/demo.rs"));
278        assert_eq!(target.location_suffix(), Some(":12"));
279    }
280
281    #[test]
282    fn non_file_urls_are_rejected() {
283        assert!(parse_editor_target("https://example.com/file.rs").is_none());
284    }
285
286    #[test]
287    fn resolves_relative_paths_against_base() {
288        let target =
289            resolve_editor_target("src/lib.rs:12", Path::new("/workspace")).expect("target");
290        assert_eq!(target.path(), Path::new("/workspace/src/lib.rs"));
291        assert_eq!(target.location_suffix(), Some(":12"));
292        assert_eq!(target.canonical_string(), "/workspace/src/lib.rs:12");
293    }
294}