outline.rs

  1use anyhow::Result;
  2use gpui::{AsyncApp, Entity};
  3use language::{Buffer, OutlineItem};
  4use regex::Regex;
  5use std::fmt::Write;
  6use text::Point;
  7
  8/// For files over this size, instead of reading them (or including them in context),
  9/// we automatically provide the file's symbol outline instead, with line numbers.
 10pub const AUTO_OUTLINE_SIZE: usize = 16384;
 11
 12/// Result of getting buffer content, which can be either full content or an outline.
 13pub struct BufferContent {
 14    /// The actual content (either full text or outline)
 15    pub text: String,
 16    /// Whether this is an outline (true) or full content (false)
 17    pub is_outline: bool,
 18}
 19
 20/// Returns either the full content of a buffer or its outline, depending on size.
 21/// For files larger than AUTO_OUTLINE_SIZE, returns an outline with a header.
 22/// For smaller files, returns the full content.
 23pub async fn get_buffer_content_or_outline(
 24    buffer: Entity<Buffer>,
 25    path: Option<&str>,
 26    cx: &AsyncApp,
 27) -> Result<BufferContent> {
 28    let file_size = buffer.read_with(cx, |buffer, _| buffer.text().len())?;
 29
 30    if file_size > AUTO_OUTLINE_SIZE {
 31        // For large files, use outline instead of full content
 32        // Wait until the buffer has been fully parsed, so we can read its outline
 33        buffer
 34            .read_with(cx, |buffer, _| buffer.parsing_idle())?
 35            .await;
 36
 37        let outline_items = buffer.read_with(cx, |buffer, _| {
 38            let snapshot = buffer.snapshot();
 39            snapshot
 40                .outline(None)
 41                .items
 42                .into_iter()
 43                .map(|item| item.to_point(&snapshot))
 44                .collect::<Vec<_>>()
 45        })?;
 46
 47        // If no outline exists, fall back to first 1KB so the agent has some context
 48        if outline_items.is_empty() {
 49            let text = buffer.read_with(cx, |buffer, _| {
 50                let snapshot = buffer.snapshot();
 51                let len = snapshot.len().min(snapshot.as_rope().floor_char_boundary(1024));
 52                let content = snapshot.text_for_range(0..len).collect::<String>();
 53                if let Some(path) = path {
 54                    format!("# First 1KB of {path} (file too large to show full content, and no outline available)\n\n{content}")
 55                } else {
 56                    format!("# First 1KB of file (file too large to show full content, and no outline available)\n\n{content}")
 57                }
 58            })?;
 59
 60            return Ok(BufferContent {
 61                text,
 62                is_outline: false,
 63            });
 64        }
 65
 66        let outline_text = render_outline(outline_items, None, 0, usize::MAX).await?;
 67
 68        let text = if let Some(path) = path {
 69            format!("# File outline for {path}\n\n{outline_text}",)
 70        } else {
 71            format!("# File outline\n\n{outline_text}",)
 72        };
 73        Ok(BufferContent {
 74            text,
 75            is_outline: true,
 76        })
 77    } else {
 78        // File is small enough, return full content
 79        let text = buffer.read_with(cx, |buffer, _| buffer.text())?;
 80        Ok(BufferContent {
 81            text,
 82            is_outline: false,
 83        })
 84    }
 85}
 86
 87async fn render_outline(
 88    items: impl IntoIterator<Item = OutlineItem<Point>>,
 89    regex: Option<Regex>,
 90    offset: usize,
 91    results_per_page: usize,
 92) -> Result<String> {
 93    let mut items = items.into_iter().skip(offset);
 94
 95    let entries = items
 96        .by_ref()
 97        .filter(|item| {
 98            regex
 99                .as_ref()
100                .is_none_or(|regex| regex.is_match(&item.text))
101        })
102        .take(results_per_page)
103        .collect::<Vec<_>>();
104    let has_more = items.next().is_some();
105
106    let mut output = String::new();
107    let entries_rendered = render_entries(&mut output, entries);
108
109    // Calculate pagination information
110    let page_start = offset + 1;
111    let page_end = offset + entries_rendered;
112    let total_symbols = if has_more {
113        format!("more than {}", page_end)
114    } else {
115        page_end.to_string()
116    };
117
118    // Add pagination information
119    if has_more {
120        writeln!(&mut output, "\nShowing symbols {page_start}-{page_end} (there were more symbols found; use offset: {page_end} to see next page)",
121        )
122    } else {
123        writeln!(
124            &mut output,
125            "\nShowing symbols {page_start}-{page_end} (total symbols: {total_symbols})",
126        )
127    }
128    .ok();
129
130    Ok(output)
131}
132
133fn render_entries(
134    output: &mut String,
135    items: impl IntoIterator<Item = OutlineItem<Point>>,
136) -> usize {
137    let mut entries_rendered = 0;
138
139    for item in items {
140        // Indent based on depth ("" for level 0, "  " for level 1, etc.)
141        for _ in 0..item.depth {
142            output.push(' ');
143        }
144        output.push_str(&item.text);
145
146        // Add position information - convert to 1-based line numbers for display
147        let start_line = item.range.start.row + 1;
148        let end_line = item.range.end.row + 1;
149
150        if start_line == end_line {
151            writeln!(output, " [L{}]", start_line).ok();
152        } else {
153            writeln!(output, " [L{}-{}]", start_line, end_line).ok();
154        }
155        entries_rendered += 1;
156    }
157
158    entries_rendered
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use fs::FakeFs;
165    use gpui::TestAppContext;
166    use project::Project;
167    use settings::SettingsStore;
168
169    #[gpui::test]
170    async fn test_large_file_fallback_to_subset(cx: &mut TestAppContext) {
171        cx.update(|cx| {
172            let settings = SettingsStore::test(cx);
173            cx.set_global(settings);
174        });
175
176        let fs = FakeFs::new(cx.executor());
177        let project = Project::test(fs, [], cx).await;
178
179        let content = "".repeat(100 * 1024); // 100KB
180        let content_len = content.len();
181        let buffer = project
182            .update(cx, |project, cx| project.create_buffer(true, cx))
183            .await
184            .expect("failed to create buffer");
185
186        buffer.update(cx, |buffer, cx| buffer.set_text(content, cx));
187
188        let result = cx
189            .spawn(|cx| async move { get_buffer_content_or_outline(buffer, None, &cx).await })
190            .await
191            .unwrap();
192
193        // Should contain some of the actual file content
194        assert!(
195            result.text.contains("⚡⚡⚡⚡⚡⚡⚡"),
196            "Result did not contain content subset"
197        );
198
199        // Should be marked as not an outline (it's truncated content)
200        assert!(
201            !result.is_outline,
202            "Large file without outline should not be marked as outline"
203        );
204
205        // Should be reasonably sized (much smaller than original)
206        assert!(
207            result.text.len() < 50 * 1024,
208            "Result size {} should be smaller than 50KB",
209            result.text.len()
210        );
211
212        // Should be significantly smaller than the original content
213        assert!(
214            result.text.len() < content_len / 10,
215            "Result should be much smaller than original content"
216        );
217    }
218}