Skip to main content

vtcode_core/
ide_context.rs

1use std::collections::HashSet;
2use std::env;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use anyhow::{Context, Result};
7use serde::{Deserialize, Serialize};
8use vtcode_config::IdeContextProviderFamily;
9
10use crate::utils::common::{display_language_from_editor_language_id, display_language_from_path};
11
12pub const IDE_CONTEXT_ENV_VAR: &str = "VT_IDE_CONTEXT_FILE";
13pub const LEGACY_VSCODE_CONTEXT_ENV_VAR: &str = "VT_VSCODE_CONTEXT_FILE";
14pub const IDE_CONTEXT_SNAPSHOT_VERSION: u32 = 1;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
17pub struct EditorContextSnapshot {
18    #[serde(default = "default_snapshot_version")]
19    pub version: u32,
20    #[serde(default)]
21    pub provider_family: IdeContextProviderFamily,
22    #[serde(default)]
23    pub editor_name: Option<String>,
24    #[serde(default)]
25    pub workspace_root: Option<PathBuf>,
26    #[serde(default)]
27    pub active_file: Option<EditorFileContext>,
28    #[serde(default)]
29    pub visible_editors: Vec<EditorFileContext>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
33pub struct EditorFileContext {
34    pub path: String,
35    #[serde(default)]
36    pub language_id: Option<String>,
37    #[serde(default)]
38    pub line_range: Option<EditorLineRange>,
39    #[serde(default)]
40    pub dirty: bool,
41    #[serde(default)]
42    pub truncated: bool,
43    #[serde(default)]
44    pub selection: Option<EditorSelectionContext>,
45}
46
47#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
48pub struct EditorLineRange {
49    pub start: usize,
50    pub end: usize,
51}
52
53#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
54pub struct EditorSelectionRange {
55    pub start_line: usize,
56    pub start_column: usize,
57    pub end_line: usize,
58    pub end_column: usize,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
62pub struct EditorSelectionContext {
63    pub range: EditorSelectionRange,
64    #[serde(default)]
65    pub text: Option<String>,
66}
67
68impl EditorContextSnapshot {
69    pub fn read_from_env() -> Result<Option<Self>> {
70        if let Some(path) = snapshot_path_from_env(IDE_CONTEXT_ENV_VAR) {
71            return Self::read_json_file(&path);
72        }
73
74        if let Some(path) = snapshot_path_from_env(LEGACY_VSCODE_CONTEXT_ENV_VAR) {
75            return Self::read_legacy_markdown_file(&path);
76        }
77
78        Ok(None)
79    }
80
81    pub fn read_json_file(path: &Path) -> Result<Option<Self>> {
82        let Some(content) = read_snapshot_file(path)? else {
83            return Ok(None);
84        };
85        let snapshot: Self = serde_json::from_str(&content).with_context(|| {
86            format!(
87                "failed to parse IDE context JSON snapshot at {}",
88                path.display()
89            )
90        })?;
91        Ok(Some(snapshot.normalized()))
92    }
93
94    pub fn read_legacy_markdown_file(path: &Path) -> Result<Option<Self>> {
95        let Some(content) = read_snapshot_file(path)? else {
96            return Ok(None);
97        };
98        Ok(parse_legacy_markdown_snapshot(&content))
99    }
100
101    pub fn normalized(mut self) -> Self {
102        self.version = if self.version == 0 {
103            IDE_CONTEXT_SNAPSHOT_VERSION
104        } else {
105            self.version
106        };
107
108        if let Some(editor_name) = self.editor_name.as_mut() {
109            let trimmed = editor_name.trim();
110            if trimmed.is_empty() {
111                self.editor_name = None;
112            } else if trimmed != editor_name {
113                *editor_name = trimmed.to_string();
114            }
115        }
116
117        if let Some(active_file) = self.active_file.as_mut() {
118            active_file.normalize();
119        }
120        for editor in &mut self.visible_editors {
121            editor.normalize();
122        }
123        self
124    }
125
126    pub fn active_display_language(&self) -> Option<String> {
127        self.active_file
128            .as_ref()
129            .and_then(EditorFileContext::display_language)
130    }
131
132    pub fn has_explicit_selection(&self) -> bool {
133        self.active_file
134            .as_ref()
135            .and_then(|file| file.selection.as_ref())
136            .is_some_and(EditorSelectionContext::has_explicit_selection)
137    }
138
139    pub fn header_summary(&self, workspace_root: &Path) -> Option<String> {
140        let file = self.active_file.as_ref()?;
141        let mut parts = Vec::new();
142
143        let path_label = file.display_path(workspace_root, self.workspace_root.as_deref());
144        if !path_label.is_empty() {
145            parts.push(format!("File: {}", path_label));
146        }
147
148        if let Some(language) = file.display_language() {
149            if parts.is_empty() {
150                parts.push(format!("Lang: {}", language));
151            } else {
152                parts.push(language);
153            }
154        }
155
156        if let Some(selection) = file
157            .selection
158            .as_ref()
159            .filter(|selection| selection.has_explicit_selection())
160        {
161            parts.push(format!(
162                "Sel {}-{}",
163                selection.range.start_line, selection.range.end_line
164            ));
165        }
166
167        if parts.is_empty() {
168            None
169        } else {
170            Some(parts.join(" · "))
171        }
172    }
173
174    pub fn prompt_block(
175        &self,
176        workspace_root: &Path,
177        include_selection_text: bool,
178    ) -> Option<String> {
179        let file = self.active_file.as_ref()?;
180        let active_path = file.display_path(workspace_root, self.workspace_root.as_deref());
181        let mut lines = Vec::new();
182        lines.push("## Active Editor Context".to_string());
183        lines.push(format!(
184            "- IDE family: {}",
185            provider_family_label(self.provider_family)
186        ));
187        lines.push(format!("- Active file: {}", active_path));
188
189        if let Some(language) = file.display_language() {
190            lines.push(format!("- Language: {}", language));
191        }
192
193        if let Some(line_range) = file.line_range {
194            lines.push(format!("- Editor lines: {}", format_line_range(line_range)));
195        }
196
197        if file.dirty || file.truncated {
198            let mut states = Vec::new();
199            if file.dirty {
200                states.push("unsaved changes");
201            }
202            if file.truncated {
203                states.push("truncated");
204            }
205            lines.push(format!("- Buffer state: {}", states.join(", ")));
206        }
207
208        if let Some(selection) = file
209            .selection
210            .as_ref()
211            .filter(|selection| selection.has_explicit_selection())
212        {
213            lines.push(format!(
214                "- Selection: {}:{}-{}:{}",
215                selection.range.start_line,
216                selection.range.start_column,
217                selection.range.end_line,
218                selection.range.end_column
219            ));
220
221            if include_selection_text
222                && let Some(text) = selection.text.as_deref().map(str::trim)
223                && !text.is_empty()
224            {
225                let fence_language = file.language_id.as_deref().unwrap_or("text");
226                lines.push("- Selected text:".to_string());
227                lines.push(format!("```{}", fence_language));
228                lines.push(text.to_string());
229                lines.push("```".to_string());
230            }
231        }
232
233        let mut seen_paths = HashSet::new();
234        let open_files = self
235            .visible_editors
236            .iter()
237            .map(|editor| editor.display_path(workspace_root, self.workspace_root.as_deref()))
238            .filter(|path| !path.trim().is_empty())
239            .filter(|path| path != &active_path)
240            .filter(|path| seen_paths.insert(path.clone()))
241            .collect::<Vec<_>>();
242        if !open_files.is_empty() {
243            lines.push("- Open files:".to_string());
244            lines.extend(open_files.into_iter().map(|path| format!("  - {}", path)));
245        }
246
247        Some(lines.join("\n"))
248    }
249}
250
251impl EditorFileContext {
252    fn normalize(&mut self) {
253        self.path = self.path.trim().to_string();
254        if let Some(language_id) = self.language_id.as_mut() {
255            let trimmed = language_id.trim();
256            if trimmed.is_empty() {
257                self.language_id = None;
258            } else if trimmed != language_id {
259                *language_id = trimmed.to_string();
260            }
261        }
262
263        if let Some(selection) = self.selection.as_mut()
264            && let Some(text) = selection.text.as_mut()
265        {
266            let normalized = normalize_snapshot_content(text);
267            if normalized.trim().is_empty() {
268                selection.text = None;
269            } else {
270                *text = normalized;
271            }
272        }
273    }
274
275    pub fn display_language(&self) -> Option<String> {
276        self.language_id
277            .as_deref()
278            .and_then(display_language_from_editor_language_id)
279            .or_else(|| display_language_from_path(Path::new(self.path.as_str())))
280            .map(ToOwned::to_owned)
281    }
282
283    pub fn display_path(
284        &self,
285        workspace_root: &Path,
286        snapshot_workspace_root: Option<&Path>,
287    ) -> String {
288        let raw = self.path.trim();
289        if raw.is_empty() {
290            return String::new();
291        }
292
293        if raw.contains("://") || raw.starts_with("untitled:") {
294            return raw.to_string();
295        }
296
297        let candidate = Path::new(raw);
298        if candidate.is_relative() {
299            return raw.to_string();
300        }
301
302        for root in [Some(workspace_root), snapshot_workspace_root] {
303            if let Some(root) = root
304                && let Ok(relative) = candidate.strip_prefix(root)
305            {
306                return relative.display().to_string();
307            }
308        }
309
310        raw.to_string()
311    }
312
313    pub fn has_explicit_selection(&self) -> bool {
314        self.selection
315            .as_ref()
316            .is_some_and(EditorSelectionContext::has_explicit_selection)
317    }
318}
319
320impl EditorSelectionContext {
321    pub fn has_explicit_selection(&self) -> bool {
322        let range = self.range;
323        range.start_line != range.end_line || range.start_column != range.end_column
324    }
325}
326
327const fn default_snapshot_version() -> u32 {
328    IDE_CONTEXT_SNAPSHOT_VERSION
329}
330
331fn snapshot_path_from_env(env_var: &str) -> Option<PathBuf> {
332    env::var(env_var)
333        .ok()
334        .map(|value| value.trim().to_string())
335        .filter(|value| !value.is_empty())
336        .map(PathBuf::from)
337}
338
339fn read_snapshot_file(path: &Path) -> Result<Option<String>> {
340    let content = match fs::read_to_string(path) {
341        Ok(content) => content,
342        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
343        Err(err) => {
344            return Err(err).with_context(|| {
345                format!("failed to read IDE context snapshot {}", path.display())
346            });
347        }
348    };
349
350    let normalized = normalize_snapshot_content(&content);
351    if normalized.trim().is_empty() {
352        return Ok(None);
353    }
354
355    Ok(Some(normalized))
356}
357
358fn normalize_snapshot_content(content: &str) -> String {
359    content.replace("\r\n", "\n")
360}
361
362fn parse_legacy_markdown_snapshot(markdown: &str) -> Option<EditorContextSnapshot> {
363    let mut active_file = None;
364    let mut visible_editors = Vec::new();
365
366    for section in markdown
367        .split("\n### ")
368        .filter(|section| !section.trim().is_empty())
369    {
370        let normalized = if section.starts_with("### ") {
371            section.to_string()
372        } else {
373            format!("### {}", section)
374        };
375
376        if let Some(file) = parse_legacy_editor_section(&normalized, "### Active Editor:") {
377            active_file = Some(file);
378            continue;
379        }
380
381        if let Some(file) = parse_legacy_editor_section(&normalized, "### Editor:") {
382            visible_editors.push(file);
383        }
384    }
385
386    if active_file.is_none() && visible_editors.is_empty() {
387        return None;
388    }
389
390    Some(EditorContextSnapshot {
391        version: IDE_CONTEXT_SNAPSHOT_VERSION,
392        provider_family: IdeContextProviderFamily::VscodeCompatible,
393        editor_name: Some("VS Code".to_string()),
394        workspace_root: None,
395        active_file,
396        visible_editors,
397    })
398}
399
400fn parse_legacy_editor_section(section: &str, prefix: &str) -> Option<EditorFileContext> {
401    let first_line = section.lines().next()?.trim();
402    let heading = first_line.strip_prefix(prefix)?.trim();
403    let (path, details) = split_heading_label_and_details(heading);
404    let language_id = parse_legacy_fence_language(section);
405
406    Some(EditorFileContext {
407        path: path.to_string(),
408        language_id,
409        line_range: details.and_then(parse_line_range_from_details),
410        dirty: details.is_some_and(|detail| detail.contains("unsaved changes")),
411        truncated: details.is_some_and(|detail| detail.contains("truncated")),
412        selection: None,
413    })
414}
415
416fn split_heading_label_and_details(heading: &str) -> (&str, Option<&str>) {
417    let trimmed = heading.trim();
418    if let Some((path, details)) = trimmed.rsplit_once(" (")
419        && let Some(details) = details.strip_suffix(')')
420    {
421        return (path.trim(), Some(details));
422    }
423
424    (trimmed, None)
425}
426
427fn parse_legacy_fence_language(section: &str) -> Option<String> {
428    section.lines().find_map(|line| {
429        line.trim()
430            .strip_prefix("```")
431            .map(str::trim)
432            .filter(|language| !language.is_empty())
433            .map(ToOwned::to_owned)
434    })
435}
436
437fn parse_line_range_from_details(details: &str) -> Option<EditorLineRange> {
438    let marker = "lines ";
439    let line_token = details
440        .split('•')
441        .map(str::trim)
442        .find_map(|entry| entry.strip_prefix(marker))?;
443
444    parse_line_range(line_token)
445}
446
447fn parse_line_range(text: &str) -> Option<EditorLineRange> {
448    let trimmed = text.trim();
449    let (start, end) = trimmed
450        .split_once('-')
451        .map(|(start, end)| (start.trim(), end.trim()))
452        .unwrap_or((trimmed, trimmed));
453
454    let start = start.parse::<usize>().ok()?;
455    let end = end.parse::<usize>().ok()?;
456    Some(EditorLineRange { start, end })
457}
458
459fn provider_family_label(family: IdeContextProviderFamily) -> &'static str {
460    match family {
461        IdeContextProviderFamily::VscodeCompatible => "vscode_compatible",
462        IdeContextProviderFamily::Zed => "zed",
463        IdeContextProviderFamily::Generic => "generic",
464    }
465}
466
467fn format_line_range(range: EditorLineRange) -> String {
468    if range.start == range.end {
469        range.start.to_string()
470    } else {
471        format!("{}-{}", range.start, range.end)
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::{
478        EditorContextSnapshot, EditorFileContext, EditorLineRange, EditorSelectionContext,
479        EditorSelectionRange, IDE_CONTEXT_SNAPSHOT_VERSION,
480    };
481    use std::fs;
482    use std::path::{Path, PathBuf};
483    use tempfile::TempDir;
484    use vtcode_config::IdeContextProviderFamily;
485
486    #[test]
487    fn parses_json_snapshot_file() {
488        let temp = TempDir::new().expect("temp dir");
489        let path = temp.path().join("snapshot.json");
490        fs::write(
491            &path,
492            r#"{
493                "version": 1,
494                "provider_family": "zed",
495                "editor_name": " VS Code ",
496                "workspace_root": "/workspace",
497                "active_file": {
498                    "path": "/workspace/src/main.rs",
499                    "language_id": "rust",
500                    "line_range": { "start": 10, "end": 24 },
501                    "dirty": true,
502                    "truncated": false,
503                    "selection": {
504                        "range": {
505                            "start_line": 12,
506                            "start_column": 1,
507                            "end_line": 18,
508                            "end_column": 4
509                        },
510                        "text": "fn main() {}\n"
511                    }
512                }
513            }"#,
514        )
515        .expect("write snapshot");
516
517        let snapshot = EditorContextSnapshot::read_json_file(&path)
518            .expect("read snapshot")
519            .expect("snapshot");
520
521        assert_eq!(snapshot.provider_family, IdeContextProviderFamily::Zed);
522        assert_eq!(snapshot.editor_name.as_deref(), Some("VS Code"));
523        assert_eq!(snapshot.active_display_language().as_deref(), Some("Rust"));
524        assert_eq!(
525            snapshot.header_summary(Path::new("/workspace")).as_deref(),
526            Some("File: src/main.rs · Rust · Sel 12-18")
527        );
528    }
529
530    #[test]
531    fn parses_legacy_markdown_snapshot() {
532        let temp = TempDir::new().expect("temp dir");
533        let path = temp.path().join("snapshot.md");
534        fs::write(
535            &path,
536            r#"
537## VS Code Context
538
539### Active Editor: src/app.tsx (lines 12-18 • unsaved changes • truncated)
540
541```typescriptreact
542export function App() {}
543```
544
545### Editor: src/lib.ts (lines 1-4)
546
547```typescript
548export const value = 1;
549```
550"#,
551        )
552        .expect("write snapshot");
553
554        let snapshot = EditorContextSnapshot::read_legacy_markdown_file(&path)
555            .expect("read snapshot")
556            .expect("snapshot");
557
558        let active = snapshot.active_file.expect("active file");
559        assert_eq!(snapshot.editor_name.as_deref(), Some("VS Code"));
560        assert_eq!(active.path, "src/app.tsx");
561        assert_eq!(active.language_id.as_deref(), Some("typescriptreact"));
562        assert_eq!(
563            active.line_range,
564            Some(EditorLineRange { start: 12, end: 18 })
565        );
566        assert!(active.dirty);
567        assert!(active.truncated);
568        assert_eq!(snapshot.visible_editors.len(), 1);
569    }
570
571    #[test]
572    fn prompt_block_includes_selection_text_when_requested() {
573        let snapshot = EditorContextSnapshot {
574            version: IDE_CONTEXT_SNAPSHOT_VERSION,
575            provider_family: IdeContextProviderFamily::Generic,
576            editor_name: None,
577            workspace_root: Some(PathBuf::from("/workspace")),
578            active_file: Some(EditorFileContext {
579                path: "/workspace/src/main.rs".to_string(),
580                language_id: Some("rust".to_string()),
581                line_range: Some(EditorLineRange { start: 1, end: 20 }),
582                dirty: false,
583                truncated: false,
584                selection: Some(EditorSelectionContext {
585                    range: EditorSelectionRange {
586                        start_line: 4,
587                        start_column: 1,
588                        end_line: 6,
589                        end_column: 2,
590                    },
591                    text: Some("fn main() {}\n".to_string()),
592                }),
593            }),
594            visible_editors: vec![
595                EditorFileContext {
596                    path: "/workspace/src/main.rs".to_string(),
597                    language_id: Some("rust".to_string()),
598                    line_range: Some(EditorLineRange { start: 1, end: 20 }),
599                    dirty: false,
600                    truncated: false,
601                    selection: None,
602                },
603                EditorFileContext {
604                    path: "/workspace/src/lib.rs".to_string(),
605                    language_id: Some("rust".to_string()),
606                    line_range: Some(EditorLineRange { start: 1, end: 80 }),
607                    dirty: false,
608                    truncated: false,
609                    selection: None,
610                },
611                EditorFileContext {
612                    path: "/workspace/src/lib.rs".to_string(),
613                    language_id: Some("rust".to_string()),
614                    line_range: Some(EditorLineRange { start: 1, end: 80 }),
615                    dirty: false,
616                    truncated: false,
617                    selection: None,
618                },
619            ],
620        };
621
622        let prompt = snapshot
623            .prompt_block(Path::new("/workspace"), true)
624            .expect("prompt");
625
626        assert!(prompt.contains("## Active Editor Context"));
627        assert!(prompt.contains("- Active file: src/main.rs"));
628        assert!(prompt.contains("- Selection: 4:1-6:2"));
629        assert!(prompt.contains("```rust"));
630        assert!(prompt.contains("- Open files:"));
631        assert!(!prompt.contains("  - src/main.rs"));
632        assert!(prompt.contains("  - src/lib.rs"));
633        assert_eq!(prompt.matches("  - src/lib.rs").count(), 1);
634    }
635
636    #[test]
637    fn collapsed_selection_is_not_rendered_in_header_or_prompt() {
638        let snapshot = EditorContextSnapshot {
639            version: IDE_CONTEXT_SNAPSHOT_VERSION,
640            provider_family: IdeContextProviderFamily::VscodeCompatible,
641            editor_name: None,
642            workspace_root: Some(PathBuf::from("/workspace")),
643            active_file: Some(EditorFileContext {
644                path: "/workspace/src/main.rs".to_string(),
645                language_id: Some("rust".to_string()),
646                line_range: Some(EditorLineRange { start: 12, end: 18 }),
647                dirty: false,
648                truncated: false,
649                selection: Some(EditorSelectionContext {
650                    range: EditorSelectionRange {
651                        start_line: 12,
652                        start_column: 4,
653                        end_line: 12,
654                        end_column: 4,
655                    },
656                    text: Some(String::new()),
657                }),
658            }),
659            visible_editors: Vec::new(),
660        };
661
662        let header = snapshot
663            .header_summary(Path::new("/workspace"))
664            .expect("header summary");
665        let prompt = snapshot
666            .prompt_block(Path::new("/workspace"), true)
667            .expect("prompt block");
668
669        assert!(!header.contains("Sel "));
670        assert!(!prompt.contains("- Selection:"));
671        assert!(!prompt.contains("- Selected text:"));
672    }
673}