diff --git a/Cargo.lock b/Cargo.lock index cea560d65a501e..48e486af705b1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5549,6 +5549,24 @@ dependencies = [ "ztracing", ] +[[package]] +name = "editor_benchmarks" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor", + "gpui", + "gpui_platform", + "language", + "multi_buffer", + "project", + "release_channel", + "semver", + "settings", + "theme", + "workspace", +] + [[package]] name = "either" version = "1.15.0" diff --git a/Cargo.toml b/Cargo.toml index 72356e7ea35e2a..47b8eddf1486e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = [ "crates/edit_prediction_types", "crates/edit_prediction_ui", "crates/editor", + "crates/editor_benchmarks", "crates/encoding_selector", "crates/env_var", "crates/etw_tracing", diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 125f09c96614e1..c352ec9d03f17e 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -11,7 +11,7 @@ use anyhow::{Context as _, Result, anyhow}; use collections::{HashMap, HashSet}; use file_icons::FileIcons; use fs::MTime; -use futures::future::try_join_all; +use futures::{channel::oneshot, future::try_join_all}; use git::status::GitSummary; use gpui::{ AnyElement, App, AsyncWindowContext, Context, Entity, EntityId, EventEmitter, Font, @@ -22,22 +22,24 @@ use language::{ SelectionGoal, proto::serialize_anchor as serialize_text_anchor, }; use lsp::DiagnosticSeverity; -use multi_buffer::{MultiBufferOffset, PathKey}; +use multi_buffer::{BufferOffset, MultiBufferOffset, PathKey}; use project::{ File, Project, ProjectItem as _, ProjectPath, lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, }; +use rope::TextSummary; use rpc::proto::{self, update_view}; use settings::Settings; use std::{ any::{Any, TypeId}, borrow::Cow, cmp::{self, Ordering}, + num::NonZeroU32, ops::Range, path::{Path, PathBuf}, sync::Arc, }; -use text::{BufferId, BufferSnapshot, Selection}; +use text::{BufferId, BufferSnapshot, OffsetRangeExt, Selection}; use ui::{IconDecorationKind, prelude::*}; use util::{ResultExt, TryFutureExt, paths::PathExt, rel_path::RelPath}; use workspace::item::{Dedup, ItemSettings, SerializableItem, TabContentParams}; @@ -1871,6 +1873,7 @@ impl SearchableItem for Editor { ranges.iter().cloned().collect::>() }); + let executor = cx.background_executor().clone(); cx.background_spawn(async move { let mut ranges = Vec::new(); @@ -1879,38 +1882,70 @@ impl SearchableItem for Editor { } else { search_within_ranges }; - + let num_cpus = executor.num_cpus(); for range in search_within_ranges { for (search_buffer, search_range, deleted_hunk_anchor) in buffer.range_to_buffer_ranges_with_deleted_hunks(range) { - ranges.extend( - query - .search( - search_buffer, - Some(search_range.start.0..search_range.end.0), - ) - .await - .into_iter() - .filter_map(|match_range| { - if let Some(deleted_hunk_anchor) = deleted_hunk_anchor { - let start = search_buffer - .anchor_after(search_range.start + match_range.start); - let end = search_buffer - .anchor_before(search_range.start + match_range.end); - Some( - deleted_hunk_anchor.with_diff_base_anchor(start) - ..deleted_hunk_anchor.with_diff_base_anchor(end), - ) - } else { - let start = search_buffer - .anchor_after(search_range.start + match_range.start); - let end = search_buffer - .anchor_before(search_range.start + match_range.end); - buffer.buffer_anchor_range_to_anchor_range(start..end) - } - }), - ); + let query = query.clone(); + + let mut results = Vec::new(); + executor + .scoped(|scope| { + for search_range in chunk_search_range( + search_buffer.text.clone(), + &query, + num_cpus as u32, + search_range, + ) { + let query = query.clone(); + let buffer = buffer.clone(); + + let (tx, rx) = oneshot::channel(); + results.push(rx); + scope.spawn(async move { + let chunk_result = query + .search( + search_buffer, + Some(search_range.start..search_range.end), + ) + .await + .into_iter() + .filter_map(|match_range| { + if let Some(deleted_hunk_anchor) = deleted_hunk_anchor { + let start = search_buffer.anchor_after( + search_range.start + match_range.start, + ); + let end = search_buffer.anchor_before( + search_range.start + match_range.end, + ); + Some( + deleted_hunk_anchor.with_diff_base_anchor(start) + ..deleted_hunk_anchor + .with_diff_base_anchor(end), + ) + } else { + let start = search_buffer.anchor_after( + search_range.start + match_range.start, + ); + let end = search_buffer.anchor_before( + search_range.start + match_range.end, + ); + buffer.anchor_range_in_buffer(start..end) + } + }) + .collect::>(); + _ = tx.send(chunk_result); + }); + } + }) + .await; + + for rx in results { + if let Ok(results) = rx.await { + ranges.extend(results); + } + } } } @@ -2109,6 +2144,48 @@ fn deserialize_path_key(path_key: proto::PathKey) -> Option { }) } +fn chunk_search_range( + buffer: BufferSnapshot, + query: &SearchQuery, + num_cpus: u32, + initial_range: Range, +) -> Box> + 'static> { + let range = initial_range.to_offset(&buffer); + if range.is_empty() { + return Box::new(std::iter::empty()); + } + + let summary: TextSummary = buffer.text_summary_for_range(initial_range); + let num_chunks = if !query.is_regex() && !query.as_str().contains('\n') { + NonZeroU32::new(summary.lines.row.saturating_add(1).min(num_cpus.max(1))) + } else { + NonZeroU32::new(1) + }; + + let Some(num_chunks) = num_chunks else { + return Box::new(std::iter::empty()); + }; + + let mut chunk_start = range.start; + let rope = buffer.as_rope().clone(); + let range_end = range.end; + let average_chunk_length = summary.len.div_ceil(num_chunks.get() as usize); + Box::new(std::iter::from_fn(move || { + if chunk_start >= range_end { + return None; + } + let candidate_position = chunk_start + average_chunk_length; + let adjusted = rope.ceil_char_boundary(candidate_position); + let mut as_point = rope.offset_to_point(adjusted); + as_point.row += 1; + as_point.column = 0; + let end_offset = buffer.point_to_offset(as_point).min(range_end); + let ret = chunk_start..end_offset; + chunk_start = end_offset; + Some(ret) + })) +} + #[cfg(test)] mod tests { use crate::editor_tests::init_test; @@ -2134,6 +2211,115 @@ mod tests { assert_eq!(path_for_file(&file, 0, false, cx), None); } + #[gpui::test] + fn test_chunk_search_range_multi_line(cx: &mut App) { + let text = "line one\nline two\nline three\nline four\nline five\nline six\n"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let chunks = chunk_search_range_for_test(&snapshot, "line", 4, 0..text.len()); + + assert_chunks_are_contiguous(&chunks, 0..text.len()); + assert!( + chunks.len() <= 4, + "got {} chunks, expected <= num_cpus (4)", + chunks.len() + ); + for chunk in &chunks { + let end = chunk.end; + assert!( + end == text.len() || text.as_bytes()[end - 1] == b'\n', + "chunk ending at {end} is not a line boundary", + ); + } + } + + #[gpui::test] + fn test_chunk_search_range_single_line(cx: &mut App) { + let text = "hello world hello again"; + let buffer = cx.new(|cx| Buffer::local(text, cx)); + let snapshot = buffer.read(cx).snapshot(); + + let chunks = chunk_search_range_for_test(&snapshot, "hello", 4, 0..text.len()); + assert_chunks_are_contiguous(&chunks, 0..text.len()); + } + + #[gpui::test] + fn test_chunk_search_range_empty_range(cx: &mut App) { + let buffer = cx.new(|cx| Buffer::local("hello world", cx)); + let snapshot = buffer.read(cx).snapshot(); + + let chunks = chunk_search_range_for_test(&snapshot, "hello", 4, 5..5); + assert!(chunks.is_empty()); + } + + #[gpui::test] + fn test_chunk_search_range_does_not_start_at_zero(cx: &mut App) { + let line = "abcdefghij\n"; + let text = line.repeat(20); + let buffer = cx.new(|cx| Buffer::local(text.clone(), cx)); + let snapshot = buffer.read(cx).snapshot(); + + let start = line.len() * 7; + let end = line.len() * 14; + let chunks = chunk_search_range_for_test(&snapshot, "abc", 4, start..end); + + assert_chunks_are_contiguous(&chunks, start..end); + } + + fn chunk_search_range_for_test( + snapshot: &language::BufferSnapshot, + query: &str, + num_cpus: u32, + range: Range, + ) -> Vec> { + let query = SearchQuery::text( + query, + false, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .unwrap(); + chunk_search_range( + snapshot.text.clone(), + &query, + num_cpus, + BufferOffset(range.start)..BufferOffset(range.end), + ) + .collect() + } + + #[track_caller] + fn assert_chunks_are_contiguous(chunks: &[Range], expected: Range) { + assert!(!chunks.is_empty(), "expected at least one chunk"); + assert_eq!( + chunks.first().unwrap().start, + expected.start, + "first chunk does not start at {}", + expected.start + ); + assert_eq!( + chunks.last().unwrap().end, + expected.end, + "last chunk does not end at {}", + expected.end + ); + for chunk in chunks { + assert!(chunk.start < chunk.end, "empty chunk: {:?}", chunk); + } + for window in chunks.windows(2) { + assert_eq!( + window[0].end, window[1].start, + "gap or overlap between chunks {:?} and {:?}", + window[0], window[1], + ); + } + } + async fn deserialize_editor( item_id: ItemId, workspace_id: WorkspaceId, diff --git a/crates/editor_benchmarks/Cargo.toml b/crates/editor_benchmarks/Cargo.toml new file mode 100644 index 00000000000000..8db5d4b26aefd8 --- /dev/null +++ b/crates/editor_benchmarks/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "editor_benchmarks" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +editor.workspace = true +gpui.workspace = true +gpui_platform.workspace = true +language.workspace = true +multi_buffer.workspace = true +project.workspace = true +release_channel.workspace = true +semver.workspace = true +settings.workspace = true +theme.workspace = true +workspace.workspace = true + +[lints] +workspace = true diff --git a/crates/editor_benchmarks/LICENSE-GPL b/crates/editor_benchmarks/LICENSE-GPL new file mode 120000 index 00000000000000..89e542f750cd38 --- /dev/null +++ b/crates/editor_benchmarks/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/editor_benchmarks/src/main.rs b/crates/editor_benchmarks/src/main.rs new file mode 100644 index 00000000000000..81df55334014da --- /dev/null +++ b/crates/editor_benchmarks/src/main.rs @@ -0,0 +1,180 @@ +use std::sync::Arc; + +use editor::Editor; +use gpui::{AppContext as _, AsyncWindowContext, WeakEntity, WindowBounds, WindowOptions}; +use language::Buffer; +use multi_buffer::Anchor; +use project::search::SearchQuery; +use workspace::searchable::SearchableItem; + +#[derive(Debug)] +struct Args { + file: String, + query: String, + replace: Option, + regex: bool, + whole_word: bool, + case_sensitive: bool, +} + +fn parse_args() -> Args { + let mut args_iter = std::env::args().skip(1); + let mut parsed = Args { + file: String::new(), + query: String::new(), + replace: None, + regex: false, + whole_word: false, + case_sensitive: false, + }; + + let mut positional = Vec::new(); + while let Some(arg) = args_iter.next() { + match arg.as_str() { + "--regex" => parsed.regex = true, + "--whole-word" => parsed.whole_word = true, + "--case-sensitive" => parsed.case_sensitive = true, + "-r" | "--replace" => { + parsed.replace = args_iter.next(); + } + "--help" | "-h" => { + eprintln!( + "Usage: editor_benchmarks [OPTIONS] \n\n\ + Arguments:\n \ + Path to the file to search in\n \ + The search query string\n\n\ + Options:\n \ + -r, --replace Replacement text (runs replace_all)\n \ + --regex Treat query as regex\n \ + --whole-word Match whole words only\n \ + --case-sensitive Case-sensitive matching\n \ + -h, --help Print help" + ); + std::process::exit(0); + } + other => positional.push(other.to_string()), + } + } + + if positional.len() < 2 { + eprintln!("Usage: editor_benchmarks [OPTIONS] "); + std::process::exit(1); + } + parsed.file = positional.remove(0); + parsed.query = positional.remove(0); + parsed +} + +fn main() { + let args = parse_args(); + + let file_contents = std::fs::read_to_string(&args.file).expect("failed to read input file"); + let file_len = file_contents.len(); + println!("Read {} ({file_len} bytes)", args.file); + + let mut query = if args.regex { + SearchQuery::regex( + &args.query, + args.whole_word, + args.case_sensitive, + false, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("invalid regex query") + } else { + SearchQuery::text( + &args.query, + args.whole_word, + args.case_sensitive, + false, + Default::default(), + Default::default(), + false, + None, + ) + .expect("invalid text query") + }; + + if let Some(replacement) = args.replace.as_deref() { + query = query.with_replacement(replacement.to_string()); + } + + let query = Arc::new(query); + let has_replacement = args.replace.is_some(); + + gpui_platform::headless().run(move |cx| { + release_channel::init_test( + semver::Version::new(0, 0, 0), + release_channel::ReleaseChannel::Dev, + cx, + ); + settings::init(cx); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + + let buffer = cx.new(|cx| Buffer::local(file_contents, cx)); + + let window_handle = cx + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(gpui::Bounds { + origin: Default::default(), + size: gpui::size(gpui::px(800.0), gpui::px(600.0)), + })), + focus: false, + show: false, + ..Default::default() + }, + |window, cx| cx.new(|cx| Editor::for_buffer(buffer, None, window, cx)), + ) + .expect("failed to open window"); + + window_handle + .update(cx, move |_, window, cx| { + cx.spawn_in( + window, + async move |weak: WeakEntity, + cx: &mut AsyncWindowContext| + -> anyhow::Result<()> { + let find_task = weak.update_in(cx, |editor, window, cx| { + editor.find_matches(query.clone(), window, cx) + })?; + + println!("Finding matches..."); + let timer = std::time::Instant::now(); + let matches: Vec> = find_task.await; + let find_elapsed = timer.elapsed(); + println!("Found {} matches in {find_elapsed:?}", matches.len()); + + if has_replacement && !matches.is_empty() { + window_handle.update(cx, |editor: &mut Editor, window, cx| { + let mut match_iter = matches.iter(); + println!("Replacing all matches..."); + let timer = std::time::Instant::now(); + editor.replace_all( + &mut match_iter, + &query, + Default::default(), + window, + cx, + ); + let replace_elapsed = timer.elapsed(); + println!( + "Replaced {} matches in {replace_elapsed:?}", + matches.len() + ); + })?; + } + + std::process::exit(0); + }, + ) + .detach(); + }) + .unwrap(); + }); +} diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index de0a43bac914a8..74eaeef53ebf1a 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -5258,6 +5258,16 @@ impl MultiBufferSnapshot { Some(Anchor::in_buffer(path_key_index, anchor)) } + /// Lifts a buffer anchor range to a multibuffer anchor range without checking against excerpt boundaries. Returns `None` if there are no excerpts for the buffer. + pub fn anchor_range_in_buffer(&self, range: Range) -> Option> { + if range.start.buffer_id != range.end.buffer_id { + return None; + } + + let path_key_index = self.path_key_index_for_buffer(range.start.buffer_id)?; + Some(Anchor::range_in_buffer(path_key_index, range)) + } + /// Creates a multibuffer anchor for the given buffer anchor, if it is contained in any excerpt. pub fn anchor_in_excerpt(&self, text_anchor: text::Anchor) -> Option { let excerpts = { @@ -5295,6 +5305,19 @@ impl MultiBufferSnapshot { &self, text_anchor: Range, ) -> Option> { + if self.is_singleton() { + let excerpt = self.excerpts.first()?; + let buffer_snapshot = excerpt.buffer_snapshot(self); + if excerpt.range.contains(&text_anchor.start, &buffer_snapshot) + && excerpt.range.contains(&text_anchor.end, &buffer_snapshot) + { + return Some(Anchor::range_in_buffer(excerpt.path_key_index, text_anchor)); + } + } + + // for each search match + + let mut buffer_snapshot = None; for excerpt in { let this = &self; let buffer_id = text_anchor.start.buffer_id; @@ -5316,7 +5339,8 @@ impl MultiBufferSnapshot { .into_iter() .flatten() } { - let buffer_snapshot = excerpt.buffer_snapshot(self); + let buffer_snapshot = + buffer_snapshot.get_or_insert_with(|| excerpt.buffer_snapshot(self)); if excerpt.range.contains(&text_anchor.start, &buffer_snapshot) && excerpt.range.contains(&text_anchor.end, &buffer_snapshot) { diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 026f1272790740..4b947234054f10 100644 --- a/crates/text/src/text.rs +++ b/crates/text/src/text.rs @@ -38,7 +38,7 @@ use std::{ }; pub use subscription::*; pub use sum_tree::Bias; -use sum_tree::{Dimensions, FilterCursor, SumTree, TreeMap, TreeSet}; +use sum_tree::{Dimensions, FilterCursor, SumTree, Summary, TreeMap, TreeSet}; use undo_map::UndoMap; use util::debug_panic; @@ -912,7 +912,8 @@ impl Buffer { let mut new_ropes = RopeBuilder::new(self.visible_text.cursor(0), self.deleted_text.cursor(0)); let mut old_fragments = self.fragments.cursor::(&None); - let mut new_fragments = old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right); + let mut new_fragments = + FragmentBuilder::new(old_fragments.slice(&edits.peek().unwrap().0.start, Bias::Right)); new_ropes.append(new_fragments.summary().text); let mut fragment_start = old_fragments.start().visible; @@ -1044,7 +1045,7 @@ impl Buffer { let (visible_text, deleted_text) = new_ropes.finish(); drop(old_fragments); - self.snapshot.fragments = new_fragments; + self.snapshot.fragments = new_fragments.to_sum_tree(&None); self.snapshot.insertions.edit(new_insertions, ()); self.snapshot.visible_text = visible_text; self.snapshot.deleted_text = deleted_text; @@ -1127,8 +1128,9 @@ impl Buffer { let mut old_fragments = self .fragments .cursor::>(&cx); - let mut new_fragments = - old_fragments.slice(&VersionedFullOffset::Offset(ranges[0].start), Bias::Left); + let mut new_fragments = FragmentBuilder::new( + old_fragments.slice(&VersionedFullOffset::Offset(ranges[0].start), Bias::Left), + ); new_ropes.append(new_fragments.summary().text); let mut fragment_start = old_fragments.start().0.full_offset(); @@ -1291,7 +1293,7 @@ impl Buffer { let (visible_text, deleted_text) = new_ropes.finish(); drop(old_fragments); - self.snapshot.fragments = new_fragments; + self.snapshot.fragments = new_fragments.to_sum_tree(&None); self.snapshot.visible_text = visible_text; self.snapshot.deleted_text = deleted_text; self.snapshot.insertions.edit(new_insertions, ()); @@ -1303,7 +1305,7 @@ impl Buffer { new_text: &str, timestamp: clock::Lamport, insertion_offset: &mut u32, - new_fragments: &mut SumTree, + new_fragments: &mut FragmentBuilder, new_insertions: &mut Vec>, insertion_slices: &mut Vec, new_ropes: &mut RopeBuilder, @@ -2836,6 +2838,39 @@ impl BufferSnapshot { } } +struct FragmentBuilder { + fragments: Vec, + summary: FragmentSummary, +} + +impl FragmentBuilder { + fn new(init: SumTree) -> Self { + Self { + summary: init.summary().clone(), + fragments: init.iter().cloned().collect(), + } + } + fn append(&mut self, items: SumTree, cx: &Option) { + if !items.is_empty() { + self.summary.add_summary(items.summary(), cx); + self.fragments.extend(items.iter().cloned()); + } + } + fn push(&mut self, fragment: Fragment, cx: &Option) { + self.append(SumTree::from_item(fragment, cx), cx); + } + fn to_sum_tree(self, cx: &Option) -> SumTree { + if self.fragments.len() > 1024 { + SumTree::from_par_iter(self.fragments, cx) + } else { + SumTree::from_iter(self.fragments, cx) + } + } + fn summary(&self) -> &FragmentSummary { + &self.summary + } +} + struct RopeBuilder<'a> { old_visible_cursor: rope::Cursor<'a>, old_deleted_cursor: rope::Cursor<'a>,