language: Cache buffer syntax highlighting per row chunk (#63145)

Closes https://github.com/zed-industries/zed/issues/63730
Related to #52340 and #43460
Based on https://github.com/zed-industries/zed/pull/63138 and needs it
to be merged first.

`BufferSnapshot::chunks` re-ran the tree-sitter highlight query over the
requested range on every call, so scrolling and cursor movement paid the
full query cost per frame.

Highlight runs are now computed once per 50-row chunk (the same
`RowChunks` that back bracket colorization), resolved against the
current `HighlightMap` at compute time, merged into `(Range<usize>,
HighlightId)` runs, and kept in a cost-budgeted per-buffer LRU (10MB).
Entries reuse the `ResolvedHighlights` representation from #63138: each
stores the grammars and maps it was resolved against and is served only
while every map is pointer-identical (`is_current`), so theme changes
and grammar reloads invalidate structurally, with no invalidation hooks
and no global state.
Edits and reparses drop the cache through the existing `TreeSitterData`
swap.
Chunks spanning more than 64KB bypass the cache and fall back to direct
querying, so files with giant lines behave as before.

## Benchmarks

`editor_render_highlighted` and `editor_render_highlighted_minimap`
benches (`cargo bench -p benchmarks --bench editor_render`) move the
cursor through a generated 10K-line Rust file with real tree-sitter
highlighting, with and without the minimap.
The benches are new in this PR, so the baseline is the same binary with
`ZED_DISABLE_HIGHLIGHT_CACHE=1`, which routes rendering through the
pre-existing direct-query path.

Run 1 — baseline, cache disabled:

```
editor_render_highlighted          time: [1.7164 ms 1.7377 ms 1.7604 ms]
editor_render_highlighted_minimap  time: [12.273 ms 12.407 ms 12.549 ms]
```

Run 2 — cache enabled:

```
editor_render_highlighted          time: [1.4572 ms 1.4597 ms 1.4627 ms]
                                   change: [-16.378% -14.944% -13.526%] (p = 0.00 < 0.05)
editor_render_highlighted_minimap  time: [9.2750 ms 9.3213 ms 9.3744 ms]
                                   change: [-25.800% -24.872% -23.930%] (p = 0.00 < 0.05)
```

Over the same runs, GPUI reports window draw mean 0.81 ms → 0.68 ms and
p99 1.06 ms → 0.76 ms without the minimap, and mean 5.14 ms → 4.12 ms,
p99 6.12 ms → 4.75 ms with it.
The relative deltas held across two independent sessions (-14.1…-14.9%
and -20.7…-24.9%), while absolute times drift a few percent with machine
state.

Release Notes:

- Improved editor rendering performance in syntax-highlighted files,
especially with the minimap enabled
This commit is contained in:
Kirill Bulatov authored and GitHub committed 2026-09-09 14:31:14 +00:00
1 parent ed192bf4f1
commit 7265ff479a
5 files changed
+686 -25

No files matched your search

+84 -4
View File
@@ -1,14 +1,20 @@
use std::{path::PathBuf, sync::Arc};
use benchmarks::bench_utils::random_rust_file;
use editor::{
Editor, EditorMode, MultiBuffer,
actions::{DeleteToPreviousWordStart, SelectAll, SplitSelectionIntoLines},
};
use gpui::{App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _};
use gpui::{
App, AppContext as _, BenchAppContext, BorrowAppContext as _, Focusable as _, UpdateGlobal as _,
};
use indoc::{formatdoc, indoc};
use language::{Buffer, Capability, DiskState, File, LocalFile};
use language::{Buffer, Capability, DiskState, File, LocalFile, Rope};
use rand::{Rng as _, SeedableRng as _, rngs::StdRng};
use settings::{LocalSettingsKind, LocalSettingsPath, SettingsStore, WorktreeId};
use settings::{
DisplayIn, LocalSettingsKind, LocalSettingsPath, SettingsStore, ShowMinimap, WorktreeId,
};
use theme::ActiveTheme as _;
use util::{RandomCharIter, paths::PathStyle, rel_path::RelPath};
use zed_actions::editor::{MoveDown, MoveUp};
@@ -352,6 +358,78 @@ fn jetbrains_editorconfig() -> String {
content
}
#[gpui::bench]
fn editor_render_highlighted(cx: &mut BenchAppContext) {
init_context(cx);
render_highlighted_editor(cx);
}
#[gpui::bench]
fn editor_render_highlighted_minimap(cx: &mut BenchAppContext) {
init_context(cx);
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings(cx, |settings| {
let minimap = settings.editor.minimap.get_or_insert_default();
minimap.show = Some(ShowMinimap::Always);
minimap.display_in = Some(DisplayIn::AllEditors);
});
});
});
render_highlighted_editor(cx);
}
fn render_highlighted_editor(cx: &mut BenchAppContext) {
let mut rng = StdRng::seed_from_u64(1);
let text = random_rust_file(&mut rng, 10_000).join("\n");
let language = language::rust_lang();
let syntax_theme = cx.update(|cx| {
let syntax_theme = cx.theme().syntax().clone();
language.set_theme(&syntax_theme);
syntax_theme
});
let probe = "fn main() {}";
assert!(
!language
.highlight_text(&Rope::from(probe), 0..probe.len())
.is_empty(),
"the benchmark language must resolve syntax highlights against the theme"
);
let buffer = cx.update(|cx| {
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
cx.new(|cx| MultiBuffer::singleton(buffer, cx))
});
cx.run_until_idle();
let mut window = cx.add_empty_window();
let editor = window.update(|window, cx| {
let editor = window.replace_root(cx, |window, cx| {
let mut editor = Editor::new(EditorMode::full(), buffer, None, window, cx);
editor.set_style(
editor::EditorStyle {
syntax: syntax_theme.clone(),
..editor::EditorStyle::default()
},
window,
cx,
);
editor
});
window.focus(&editor.focus_handle(cx), cx);
editor
});
let mut move_down = true;
cx.bench_renderer(editor, move |editor, window, cx| {
if move_down {
editor.move_down(&MoveDown, window, cx);
} else {
editor.move_up(&MoveUp, window, cx);
}
move_down = !move_down;
});
}
fn init_context(cx: &mut BenchAppContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
@@ -375,6 +453,8 @@ gpui::bench_group!(
editor_multi_cursor_input,
open_editor_with_one_long_line,
editor_render,
editor_render_with_editorconfig
editor_render_with_editorconfig,
editor_render_highlighted,
editor_render_highlighted_minimap
);
gpui::bench_main!(benches);
+210 -15
View File
@@ -9,11 +9,12 @@ use crate::{
diagnostic_set::{DiagnosticEntry, DiagnosticEntryRef, DiagnosticGroup},
language_settings::{AutoIndentMode, LanguageSettings},
outline::OutlineItem,
row_chunk::RowChunks,
row_chunk::{RowChunkId, RowChunks},
runnable::{self, RunnableRange},
syntax_map::{
MAX_BYTES_TO_QUERY, SyntaxLayer, SyntaxMap, SyntaxMapCapture, SyntaxMapCaptures,
SyntaxMapMatch, SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint,
flattened_highlight_regions,
},
text_diff::text_diff,
unified_diff_with_offsets,
@@ -35,6 +36,7 @@ use gpui::{
App, AppContext as _, Context, Entity, EventEmitter, HighlightStyle, SharedString, StyledText,
Task, TextStyle,
};
use language_core::highlight_cache::{ChunkHighlightCache, ResolvedHighlights};
use lsp::LanguageServerId;
use parking_lot::Mutex;
@@ -149,21 +151,25 @@ pub struct Buffer {
#[derive(Debug)]
pub struct TreeSitterData {
chunks: RowChunks,
brackets_by_chunks: Mutex<HashMap<usize, Vec<BracketMatch>>>,
brackets_by_chunks: Mutex<HashMap<RowChunkId, Vec<BracketMatch>>>,
highlights_by_chunks: ChunkHighlightCache,
}
const MAX_ROWS_IN_A_CHUNK: u32 = 50;
pub(crate) const MAX_ROWS_IN_A_CHUNK: u32 = 50;
pub(crate) const MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK: usize = 4 * MAX_BYTES_TO_QUERY;
impl TreeSitterData {
fn clear(&mut self, snapshot: &text::BufferSnapshot) {
self.chunks = RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK);
self.brackets_by_chunks.get_mut().clear();
self.highlights_by_chunks.clear();
}
fn new(snapshot: &text::BufferSnapshot) -> Self {
Self {
chunks: RowChunks::new(snapshot, MAX_ROWS_IN_A_CHUNK),
brackets_by_chunks: Mutex::new(HashMap::default()),
highlights_by_chunks: ChunkHighlightCache::default(),
}
}
@@ -519,6 +525,13 @@ struct BufferChunkHighlights<'a> {
highlight_maps: Vec<HighlightMap>,
}
type HighlightRun = (Range<usize>, HighlightId);
struct CachedChunkHighlightsIter {
runs: Vec<HighlightRun>,
ix: usize,
}
/// An iterator that yields chunks of a buffer's text, along with their
/// syntax highlights and diagnostic status.
pub struct BufferChunks<'a> {
@@ -533,6 +546,7 @@ pub struct BufferChunks<'a> {
unnecessary_depth: usize,
underline: bool,
highlights: Option<BufferChunkHighlights<'a>>,
cached_highlights: Option<CachedChunkHighlightsIter>,
}
/// A chunk of a buffer's text, along with its syntax highlight and
@@ -1553,6 +1567,7 @@ impl Buffer {
}
self.non_text_state_update_count += 1;
self.syntax_map.lock().clear(&self.text);
Self::invalidate_tree_sitter_data(&mut self.tree_sitter_data, self.text.snapshot());
let old_language = std::mem::replace(&mut self.language, language);
self.refresh_resolved_settings(cx);
self.was_changed();
@@ -4121,7 +4136,18 @@ impl BufferSnapshot {
let mut syntax = None;
if language_aware.tree_sitter {
syntax = Some(self.get_highlights(range.clone()));
match self.cached_highlight_runs(range.clone()) {
Some(runs) => {
return BufferChunks::with_cached_highlights(
self.text.as_rope(),
range,
runs,
language_aware.diagnostics,
self,
);
}
None => syntax = Some(self.get_highlights(range.clone())),
}
}
BufferChunks::new(
self.text.as_rope(),
@@ -4132,6 +4158,102 @@ impl BufferSnapshot {
)
}
pub(crate) fn cached_highlight_runs(&self, range: Range<usize>) -> Option<Vec<HighlightRun>> {
#[cfg(any(test, feature = "test-support"))]
{
static DISABLE_HIGHLIGHT_CACHE: std::sync::LazyLock<bool> =
std::sync::LazyLock::new(|| {
std::env::var_os("ZED_DISABLE_HIGHLIGHT_CACHE").is_some()
});
if *DISABLE_HIGHLIGHT_CACHE {
return None;
}
}
self.language.as_ref()?.grammar()?;
if range.is_empty() {
return Some(Vec::new());
}
let mut runs = Vec::<HighlightRun>::new();
for chunk in self
.tree_sitter_data
.chunks
.applicable_chunks(&[range.to_point(self)])
{
let chunk_range = chunk.anchor_range().to_offset(self);
if chunk_range.end <= range.start || chunk_range.start >= range.end {
continue;
}
if chunk_range.len() > MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK {
return None;
}
let chunk_highlights = match self.tree_sitter_data.highlights_by_chunks.get(chunk.id) {
Some(chunk_highlights) => chunk_highlights,
None => {
let chunk_highlights = self.compute_chunk_highlights(chunk_range);
self.tree_sitter_data
.highlights_by_chunks
.insert(chunk.id, chunk_highlights.clone());
chunk_highlights
}
};
for (run_range, highlight_id) in chunk_highlights.runs.iter() {
if run_range.end <= range.start {
continue;
}
if run_range.start >= range.end {
break;
}
match runs.last_mut() {
Some((last_range, last_highlight_id))
if last_highlight_id == highlight_id
&& last_range.end == run_range.start =>
{
last_range.end = run_range.end;
}
_ => runs.push((run_range.clone(), *highlight_id)),
}
}
}
Some(runs)
}
fn compute_chunk_highlights(&self, range: Range<usize>) -> ResolvedHighlights {
let captures = self.syntax.captures(range.clone(), &self.text, |grammar| {
grammar
.highlights_config
.as_ref()
.map(|config| &config.query)
});
let sources = captures
.grammars()
.iter()
.map(|&grammar| (Arc::clone(grammar), grammar.highlight_map()))
.collect::<SmallVec<[(Arc<Grammar>, HighlightMap); 2]>>();
let mut runs = Vec::<(Range<usize>, HighlightId)>::new();
for region in flattened_highlight_regions(captures, range) {
let highlight_id = region.stack.iter().rev().find_map(|capture| {
let (_, highlight_map) = sources.get(capture.grammar_index)?;
highlight_map.get(capture.capture_id)
});
let Some(highlight_id) = highlight_id else {
continue;
};
match runs.last_mut() {
Some((last_range, last_highlight_id))
if *last_highlight_id == highlight_id
&& last_range.end == region.range.start =>
{
last_range.end = region.range.end;
}
_ => runs.push((region.range, highlight_id)),
}
}
ResolvedHighlights {
sources,
runs: runs.into(),
}
}
pub fn highlighted_text_for_range<T: ToOffset>(
&self,
range: Range<T>,
@@ -5508,16 +5630,40 @@ impl<'a> BufferChunks<'a> {
diagnostics: bool,
buffer_snapshot: Option<&'a BufferSnapshot>,
) -> Self {
let mut highlights = None;
if let Some((captures, highlight_maps)) = syntax {
highlights = Some(BufferChunkHighlights {
captures,
next_capture: None,
stack: Default::default(),
highlight_maps,
})
}
let highlights = syntax.map(|(captures, highlight_maps)| BufferChunkHighlights {
captures,
next_capture: None,
stack: Vec::new(),
highlight_maps,
});
Self::init(text, range, highlights, None, diagnostics, buffer_snapshot)
}
fn with_cached_highlights(
text: &'a Rope,
range: Range<usize>,
runs: Vec<HighlightRun>,
diagnostics: bool,
buffer_snapshot: &'a BufferSnapshot,
) -> Self {
Self::init(
text,
range,
None,
Some(CachedChunkHighlightsIter { runs, ix: 0 }),
diagnostics,
Some(buffer_snapshot),
)
}
fn init(
text: &'a Rope,
range: Range<usize>,
highlights: Option<BufferChunkHighlights<'a>>,
cached_highlights: Option<CachedChunkHighlightsIter>,
diagnostics: bool,
buffer_snapshot: Option<&'a BufferSnapshot>,
) -> Self {
let diagnostic_endpoints = diagnostics.then(|| Vec::new().into_iter().peekable());
let chunks = text.chunks_in_range(range.clone());
@@ -5533,6 +5679,7 @@ impl<'a> BufferChunks<'a> {
unnecessary_depth: 0,
underline: true,
highlights,
cached_highlights,
};
this.initialize_diagnostic_endpoints();
this
@@ -5542,7 +5689,33 @@ impl<'a> BufferChunks<'a> {
pub fn seek(&mut self, range: Range<usize>) {
let old_range = std::mem::replace(&mut self.range, range.clone());
self.chunks.set_range(self.range.clone());
if let Some(highlights) = self.highlights.as_mut() {
if let Some(cached) = self.cached_highlights.as_mut() {
if old_range.start <= self.range.start && old_range.end >= self.range.end {
cached.ix = cached
.runs
.partition_point(|(run_range, _)| run_range.end <= range.start);
} else if let Some(snapshot) = self.buffer_snapshot {
if let Some(runs) = snapshot.cached_highlight_runs(self.range.clone()) {
cached.runs = runs;
cached.ix = 0;
} else {
let (captures, highlight_maps) = snapshot.get_highlights(self.range.clone());
self.cached_highlights = None;
self.highlights = Some(BufferChunkHighlights {
captures,
next_capture: None,
stack: Vec::new(),
highlight_maps,
});
}
} else {
debug_assert!(
false,
"Attempted to seek on a language-aware buffer iterator without associated buffer snapshot"
);
}
self.initialize_diagnostic_endpoints();
} else if let Some(highlights) = self.highlights.as_mut() {
if old_range.start <= self.range.start && old_range.end >= self.range.end {
// Reuse existing highlights stack, as the new range is a subrange of the old one.
highlights
@@ -5565,7 +5738,7 @@ impl<'a> BufferChunks<'a> {
*highlights = BufferChunkHighlights {
captures,
next_capture: None,
stack: Default::default(),
stack: Vec::new(),
highlight_maps,
};
} else {
@@ -5701,6 +5874,21 @@ impl<'a> Iterator for BufferChunks<'a> {
}
}
if let Some(cached) = self.cached_highlights.as_mut() {
while cached
.runs
.get(cached.ix)
.is_some_and(|(run_range, _)| run_range.end <= self.range.start)
{
cached.ix += 1;
}
if let Some((run_range, _)) = cached.runs.get(cached.ix)
&& self.range.start < run_range.start
{
next_capture_start = run_range.start;
}
}
let mut diagnostic_endpoints = std::mem::take(&mut self.diagnostic_endpoints);
if let Some(diagnostic_endpoints) = diagnostic_endpoints.as_mut() {
while let Some(endpoint) = diagnostic_endpoints.peek().copied() {
@@ -5734,6 +5922,13 @@ impl<'a> Iterator for BufferChunks<'a> {
chunk_end = chunk_end.min(*parent_capture_end);
highlight_id = Some(*parent_highlight_id);
}
if let Some(cached) = self.cached_highlights.as_ref()
&& let Some((run_range, run_highlight_id)) = cached.runs.get(cached.ix)
&& run_range.start <= chunk_start
{
chunk_end = chunk_end.min(run_range.end);
highlight_id = Some(*run_highlight_id);
}
let bit_start = chunk_start - self.chunks.offset();
let bit_end = chunk_end - self.chunks.offset();
+14 -6
View File
@@ -10,6 +10,8 @@ use util::RangeExt;
use crate::BufferRow;
pub type RowChunkId = usize;
/// An range of rows, exclusive as [`lsp::Range`] and
/// <https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#range>
/// denote.
@@ -27,7 +29,7 @@ pub struct RowChunks {
buffer_snapshot: text::BufferSnapshot,
last_row: BufferRow,
max_rows_per_chunk: u32,
computed_chunks: Mutex<HashMap<usize, RowChunk>>,
computed_chunks: Mutex<HashMap<RowChunkId, RowChunk>>,
}
impl std::fmt::Debug for RowChunks {
@@ -86,12 +88,18 @@ impl RowChunks {
}
}
fn chunk_row_range(&self, id: usize) -> Range<BufferRow> {
let start = id as u32 * self.max_rows_per_chunk;
start..(start + self.max_rows_per_chunk).min(self.last_row)
fn chunk_row_range(&self, id: RowChunkId) -> Range<BufferRow> {
let start = u32::try_from(id)
.unwrap_or(u32::MAX)
.saturating_mul(self.max_rows_per_chunk)
.min(self.last_row);
start
..start
.saturating_add(self.max_rows_per_chunk)
.min(self.last_row)
}
fn chunk(&self, id: usize) -> RowChunk {
fn chunk(&self, id: RowChunkId) -> RowChunk {
*self.computed_chunks.lock().entry(id).or_insert_with(|| {
let row_range = self.chunk_row_range(id);
let start = Point::new(row_range.start, 0);
@@ -113,7 +121,7 @@ impl RowChunks {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct RowChunk {
pub id: usize,
pub id: RowChunkId,
pub start: BufferRow,
pub end_exclusive: BufferRow,
pub start_anchor: Anchor,
+323
View File
@@ -5387,6 +5387,269 @@ fn test_settings_changed_event(cx: &mut TestAppContext) {
drop(subscription);
}
#[gpui::test]
fn test_chunk_highlights_follow_edits_and_theme_changes(cx: &mut TestAppContext) {
cx.update(|cx| init_settings(cx, |_| {}));
let language = keyword_and_function_lang();
let theme_with_keyword = keyword_and_function_theme();
let theme_without_keyword =
SyntaxTheme::new([("function".to_string(), gpui::rgba(0x0000ffff).into())]);
language.set_theme(&theme_with_keyword);
let buffer = cx.new(|cx| {
let mut buffer = Buffer::local("fn main() {}", cx);
buffer.set_language(Some(language.clone()), cx);
buffer
});
cx.run_until_parked();
let highlighted_chunks = |snapshot: &BufferSnapshot| {
snapshot
.chunks(
0..snapshot.len(),
LanguageAwareStyling {
tree_sitter: true,
diagnostics: false,
},
)
.filter_map(|chunk| Some((chunk.text.to_string(), chunk.syntax_highlight_id?)))
.collect::<Vec<_>>()
};
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let expected_with_keyword = vec![
(
"fn".to_string(),
theme_highlight_id(&theme_with_keyword, "keyword"),
),
(
"main".to_string(),
theme_highlight_id(&theme_with_keyword, "function"),
),
];
assert_eq!(highlighted_chunks(&snapshot), expected_with_keyword);
assert_eq!(
highlighted_chunks(&snapshot),
expected_with_keyword,
"repeated chunking of the same snapshot must return the same highlights"
);
language.set_theme(&theme_without_keyword);
assert_eq!(
highlighted_chunks(&snapshot),
vec![(
"main".to_string(),
theme_highlight_id(&theme_without_keyword, "function")
)],
"a theme change must invalidate highlights of an existing snapshot"
);
buffer.update(cx, |buffer, cx| {
buffer.edit([(3..7, "launch")], None, cx);
});
cx.run_until_parked();
let edited_snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
assert_eq!(
highlighted_chunks(&edited_snapshot),
vec![(
"launch".to_string(),
theme_highlight_id(&theme_without_keyword, "function")
)],
"an edit must invalidate the cached highlights"
);
}
#[gpui::test]
fn test_chunk_highlights_across_row_chunk_seeks(cx: &mut TestAppContext) {
cx.update(|cx| init_settings(cx, |_| {}));
let language = keyword_and_function_lang();
let theme = keyword_and_function_theme();
language.set_theme(&theme);
let short_row = "fn short() {}\n";
let last_row = "fn omega() {}";
let text = format!(
"{}{last_row}",
short_row.repeat(MAX_ROWS_IN_A_CHUNK as usize)
);
let buffer = cx.new(|cx| {
let mut buffer = Buffer::local(text, cx);
buffer.set_language(Some(language.clone()), cx);
buffer
});
cx.run_until_parked();
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let keyword = theme_highlight_id(&theme, "keyword");
let function = theme_highlight_id(&theme, "function");
assert_eq!(
merged_highlight_runs(&snapshot, 0..short_row.len()),
vec![("fn".to_string(), keyword), ("short".to_string(), function)],
);
let last_row_start = short_row.len() * MAX_ROWS_IN_A_CHUNK as usize;
let expected_last_row_runs = vec![("fn".to_string(), keyword), ("omega".to_string(), function)];
assert_eq!(
merged_highlight_runs(&snapshot, last_row_start..snapshot.len()),
expected_last_row_runs,
);
let mut chunks = snapshot.chunks(
0..short_row.len(),
LanguageAwareStyling {
tree_sitter: true,
diagnostics: false,
},
);
chunks.seek(last_row_start..snapshot.len());
let mut runs_after_seek = Vec::new();
for chunk in chunks {
merge_highlighted_chunk(&mut runs_after_seek, &chunk);
}
assert_eq!(
runs_after_seek, expected_last_row_runs,
"seeking into another row chunk must refetch that chunk's highlights"
);
}
#[gpui::test]
fn test_oversized_chunks_bypass_the_highlight_cache(cx: &mut TestAppContext) {
if std::env::var_os("ZED_DISABLE_HIGHLIGHT_CACHE").is_some() {
return;
}
cx.update(|cx| init_settings(cx, |_| {}));
let language = keyword_and_function_lang();
let theme = keyword_and_function_theme();
language.set_theme(&theme);
let short_row = "fn short() {}\n";
let giant_row = format!(
"fn omega() {{}}{}",
" ".repeat(MAX_BYTES_TO_HIGHLIGHT_IN_A_CHUNK)
);
let text = format!(
"{}{giant_row}",
short_row.repeat(MAX_ROWS_IN_A_CHUNK as usize)
);
let buffer = cx.new(|cx| {
let mut buffer = Buffer::local(text, cx);
buffer.set_language(Some(language.clone()), cx);
buffer
});
cx.run_until_parked();
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
let keyword = theme_highlight_id(&theme, "keyword");
let function = theme_highlight_id(&theme, "function");
let expected_giant_row_runs =
vec![("fn".to_string(), keyword), ("omega".to_string(), function)];
let last_row_start = short_row.len() * MAX_ROWS_IN_A_CHUNK as usize;
assert_eq!(
snapshot.cached_highlight_runs(last_row_start..snapshot.len()),
None,
"chunks larger than the byte cap must not be cached"
);
assert!(
snapshot.cached_highlight_runs(0..short_row.len()).is_some(),
"chunks within the byte cap must still be cached"
);
assert_eq!(
snapshot.cached_highlight_runs(last_row_start + 1..last_row_start + 1),
Some(Vec::new()),
"an empty range must not compute captures for its oversized chunk"
);
assert_eq!(
merged_highlight_runs(&snapshot, last_row_start..snapshot.len()),
expected_giant_row_runs,
"oversized chunks must fall back to direct highlighting"
);
let mut chunks = snapshot.chunks(
0..short_row.len(),
LanguageAwareStyling {
tree_sitter: true,
diagnostics: false,
},
);
chunks.seek(last_row_start..snapshot.len());
let mut runs_after_seek = Vec::new();
for chunk in chunks {
merge_highlighted_chunk(&mut runs_after_seek, &chunk);
}
assert_eq!(
runs_after_seek, expected_giant_row_runs,
"seeking into an oversized chunk must fall back to direct highlighting"
);
}
#[gpui::test]
fn test_language_change_invalidates_cached_chunk_highlights(cx: &mut TestAppContext) {
cx.update(|cx| init_settings(cx, |_| {}));
let rust = keyword_and_function_lang();
let identifiers_only = Arc::new(
Language::new(
LanguageConfig {
name: "Identifiers".into(),
..LanguageConfig::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_highlights_query("(identifier) @variable")
.unwrap(),
);
let theme = SyntaxTheme::new([
("keyword".to_string(), gpui::rgba(0xff0000ff).into()),
("function".to_string(), gpui::rgba(0x00ff00ff).into()),
("variable".to_string(), gpui::rgba(0x0000ffff).into()),
]);
rust.set_theme(&theme);
identifiers_only.set_theme(&theme);
let buffer = cx.new(|cx| {
let mut buffer = Buffer::local("fn main() {}", cx);
buffer.set_language(Some(rust), cx);
buffer
});
cx.run_until_parked();
let keyword = theme_highlight_id(&theme, "keyword");
let function = theme_highlight_id(&theme, "function");
let variable = theme_highlight_id(&theme, "variable");
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
assert_eq!(
merged_highlight_runs(&snapshot, 0..snapshot.len()),
vec![("fn".to_string(), keyword), ("main".to_string(), function)],
);
buffer.update(cx, |buffer, cx| {
buffer.set_sync_parse_timeout(None);
buffer.set_language(Some(identifiers_only), cx);
});
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
assert_eq!(
merged_highlight_runs(&snapshot, 0..snapshot.len()),
Vec::new(),
"a language change must not serve the old language's cached highlights while the new parse is pending"
);
cx.run_until_parked();
let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
assert_eq!(
merged_highlight_runs(&snapshot, 0..snapshot.len()),
vec![("main".to_string(), variable)],
);
}
#[gpui::test(iterations = 100)]
fn test_random_chunk_bitmaps(cx: &mut App, mut rng: StdRng) {
use util::RandomCharIter;
@@ -5514,3 +5777,63 @@ fn test_formatted_chunks(cx: &mut gpui::App) {
}
}
}
fn merged_highlight_runs(
snapshot: &BufferSnapshot,
range: Range<usize>,
) -> Vec<(String, HighlightId)> {
let chunks = snapshot.chunks(
range,
LanguageAwareStyling {
tree_sitter: true,
diagnostics: false,
},
);
let mut runs = Vec::new();
for chunk in chunks {
merge_highlighted_chunk(&mut runs, &chunk);
}
runs
}
fn merge_highlighted_chunk(runs: &mut Vec<(String, HighlightId)>, chunk: &Chunk<'_>) {
let Some(highlight_id) = chunk.syntax_highlight_id else {
return;
};
match runs.last_mut() {
Some((last_text, last_highlight_id)) if *last_highlight_id == highlight_id => {
last_text.push_str(chunk.text);
}
_ => runs.push((chunk.text.to_string(), highlight_id)),
}
}
fn keyword_and_function_lang() -> Arc<Language> {
Arc::new(
Language::new(
LanguageConfig {
name: "Rust".into(),
..LanguageConfig::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_highlights_query(
r#"
"fn" @keyword
(identifier) @function
"#,
)
.unwrap(),
)
}
fn keyword_and_function_theme() -> SyntaxTheme {
SyntaxTheme::new([
("keyword".to_string(), gpui::rgba(0xff0000ff).into()),
("function".to_string(), gpui::rgba(0x00ff00ff).into()),
])
}
fn theme_highlight_id(theme: &SyntaxTheme, capture_name: &str) -> HighlightId {
HighlightId::new(theme.highlight_id(capture_name).unwrap())
}
@@ -14,6 +14,7 @@ use std::{
pub const MAX_TEXT_HIGHLIGHT_ENTRY_BYTES: usize = MAX_TEXT_HIGHLIGHT_CACHE_BYTES / 8;
const MAX_TEXT_HIGHLIGHT_CACHE_BYTES: usize = 4 * 1024 * 1024;
const MAX_CHUNK_HIGHLIGHT_CACHE_BYTES: usize = 10 * 1024 * 1024;
const APPROXIMATE_LRU_NODE_BYTES: usize = 4 * size_of::<usize>();
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -109,6 +110,44 @@ impl TextHighlightCache {
}
}
pub struct ChunkHighlightCache(Mutex<CostBudgetedLru<usize, ResolvedHighlights>>);
impl Default for ChunkHighlightCache {
fn default() -> Self {
Self(Mutex::new(CostBudgetedLru::new(
MAX_CHUNK_HIGHLIGHT_CACHE_BYTES,
MAX_CHUNK_HIGHLIGHT_CACHE_BYTES,
)))
}
}
impl fmt::Debug for ChunkHighlightCache {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChunkHighlightCache")
.finish_non_exhaustive()
}
}
impl ChunkHighlightCache {
pub fn get(&self, chunk_id: usize) -> Option<ResolvedHighlights> {
let mut lru = self.0.lock();
let highlights = lru.get(&chunk_id)?;
if !highlights.is_current() {
return None;
}
Some(highlights.clone())
}
pub fn insert(&self, chunk_id: usize, highlights: ResolvedHighlights) {
let cost = highlights.cost_bytes();
self.0.lock().insert(chunk_id, highlights, cost);
}
pub fn clear(&mut self) {
self.0.get_mut().clear();
}
}
struct CostBudgetedLru<K: Hash + Eq, V> {
entries: LruCache<K, (V, usize)>,
total_cost: usize,
@@ -149,6 +188,11 @@ impl<K: Hash + Eq, V> CostBudgetedLru<K, V> {
self.total_cost -= evicted_cost;
}
}
fn clear(&mut self) {
self.entries.clear();
self.total_cost = 0;
}
}
struct TextHighlightEntry {
@@ -209,6 +253,17 @@ mod tests {
assert_eq!(cache.total_cost, 40 + overhead);
}
#[test]
fn test_clear_resets_the_budget() {
let overhead = CostBudgetedLru::<&str, u32>::ENTRY_OVERHEAD_BYTES;
let mut cache = CostBudgetedLru::<&str, u32>::new(90 + overhead, 100);
cache.insert("a", 1, 90);
cache.clear();
assert_eq!(cache.total_cost, 0);
cache.insert("b", 2, 90);
assert_eq!(cache.get(&"b"), Some(&2));
}
#[test]
fn test_zero_cost_entries_still_consume_budget() {
let overhead = CostBudgetedLru::<usize, u32>::ENTRY_OVERHEAD_BYTES;