1use std::{fmt::Write, ops::Range, path::Path, sync::Arc, time::Instant};
2
3use crate::{
4 DebugEvent, EditPredictionFinishedDebugEvent, EditPredictionId, EditPredictionModelInput,
5 EditPredictionStartedDebugEvent, EditPredictionStore, ZedUpdateRequiredError,
6 cursor_excerpt::{editable_and_context_ranges_for_cursor_position, guess_token_count},
7 prediction::EditPredictionResult,
8};
9use anyhow::Result;
10use cloud_llm_client::{
11 PredictEditsBody, PredictEditsGitInfo, PredictEditsRequestTrigger, PredictEditsResponse,
12};
13use edit_prediction_types::PredictedCursorPosition;
14use gpui::{App, AppContext as _, AsyncApp, Context, Entity, SharedString, Task};
15use language::{
16 Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, ToOffset, ToPoint as _, text_diff,
17};
18use project::{Project, ProjectPath};
19use release_channel::AppVersion;
20use text::Bias;
21use workspace::notifications::{ErrorMessagePrompt, NotificationId, show_app_notification};
22use zeta_prompt::{
23 Event, ZetaPromptInput,
24 zeta1::{
25 CURSOR_MARKER, EDITABLE_REGION_END_MARKER, EDITABLE_REGION_START_MARKER,
26 START_OF_FILE_MARKER,
27 },
28};
29
30pub(crate) const MAX_CONTEXT_TOKENS: usize = 150;
31pub(crate) const MAX_REWRITE_TOKENS: usize = 350;
32pub(crate) const MAX_EVENT_TOKENS: usize = 500;
33
34pub(crate) fn request_prediction_with_zeta1(
35 store: &mut EditPredictionStore,
36 EditPredictionModelInput {
37 project,
38 buffer,
39 snapshot,
40 position,
41 events,
42 trigger,
43 debug_tx,
44 ..
45 }: EditPredictionModelInput,
46 cx: &mut Context<EditPredictionStore>,
47) -> Task<Result<Option<EditPredictionResult>>> {
48 let buffer_snapshotted_at = Instant::now();
49 let client = store.client.clone();
50 let llm_token = store.llm_token.clone();
51 let app_version = AppVersion::global(cx);
52
53 let (git_info, can_collect_file) = if let Some(file) = snapshot.file() {
54 let can_collect_file = store.can_collect_file(&project, file, cx);
55 let git_info = if can_collect_file {
56 git_info_for_file(&project, &ProjectPath::from_file(file.as_ref(), cx), cx)
57 } else {
58 None
59 };
60 (git_info, can_collect_file)
61 } else {
62 (None, false)
63 };
64
65 let full_path: Arc<Path> = snapshot
66 .file()
67 .map(|f| Arc::from(f.full_path(cx).as_path()))
68 .unwrap_or_else(|| Arc::from(Path::new("untitled")));
69 let full_path_str = full_path.to_string_lossy().into_owned();
70 let cursor_point = position.to_point(&snapshot);
71 let prompt_for_events = {
72 let events = events.clone();
73 move || prompt_for_events_impl(&events, MAX_EVENT_TOKENS)
74 };
75 let gather_task = gather_context(
76 full_path_str,
77 &snapshot,
78 cursor_point,
79 prompt_for_events,
80 trigger,
81 cx,
82 );
83
84 let uri = match client
85 .http_client()
86 .build_zed_llm_url("/predict_edits/v2", &[])
87 {
88 Ok(url) => Arc::from(url),
89 Err(err) => return Task::ready(Err(err)),
90 };
91
92 cx.spawn(async move |this, cx| {
93 let GatherContextOutput {
94 mut body,
95 context_range,
96 editable_range,
97 included_events_count,
98 } = gather_task.await?;
99 let done_gathering_context_at = Instant::now();
100
101 let included_events = &events[events.len() - included_events_count..events.len()];
102 body.can_collect_data = can_collect_file
103 && this
104 .read_with(cx, |this, cx| this.can_collect_events(included_events, cx))
105 .unwrap_or(false);
106 if body.can_collect_data {
107 body.git_info = git_info;
108 }
109
110 log::debug!(
111 "Events:\n{}\nExcerpt:\n{:?}",
112 body.input_events,
113 body.input_excerpt
114 );
115
116 let response = EditPredictionStore::send_api_request::<PredictEditsResponse>(
117 |request| {
118 Ok(request
119 .uri(uri.as_str())
120 .body(serde_json::to_string(&body)?.into())?)
121 },
122 client,
123 llm_token,
124 app_version,
125 true,
126 )
127 .await;
128
129 let context_start_offset = context_range.start.to_offset(&snapshot);
130 let context_start_row = context_range.start.row;
131 let editable_offset_range = editable_range.to_offset(&snapshot);
132
133 let inputs = ZetaPromptInput {
134 events: included_events.into(),
135 related_files: vec![],
136 cursor_path: full_path,
137 cursor_excerpt: snapshot
138 .text_for_range(context_range)
139 .collect::<String>()
140 .into(),
141 editable_range_in_excerpt: (editable_range.start - context_start_offset)
142 ..(editable_offset_range.end - context_start_offset),
143 cursor_offset_in_excerpt: cursor_point.to_offset(&snapshot) - context_start_offset,
144 excerpt_start_row: Some(context_start_row),
145 };
146
147 if let Some(debug_tx) = &debug_tx {
148 debug_tx
149 .unbounded_send(DebugEvent::EditPredictionStarted(
150 EditPredictionStartedDebugEvent {
151 buffer: buffer.downgrade(),
152 prompt: Some(serde_json::to_string(&inputs).unwrap()),
153 position,
154 },
155 ))
156 .ok();
157 }
158
159 let (response, usage) = match response {
160 Ok(response) => response,
161 Err(err) => {
162 if err.is::<ZedUpdateRequiredError>() {
163 cx.update(|cx| {
164 this.update(cx, |ep_store, _cx| {
165 ep_store.update_required = true;
166 })
167 .ok();
168
169 let error_message: SharedString = err.to_string().into();
170 show_app_notification(
171 NotificationId::unique::<ZedUpdateRequiredError>(),
172 cx,
173 move |cx| {
174 cx.new(|cx| {
175 ErrorMessagePrompt::new(error_message.clone(), cx)
176 .with_link_button("Update Zed", "https://zed.dev/releases")
177 })
178 },
179 );
180 });
181 }
182
183 return Err(err);
184 }
185 };
186
187 let received_response_at = Instant::now();
188 log::debug!("completion response: {}", &response.output_excerpt);
189
190 if let Some(usage) = usage {
191 this.update(cx, |this, cx| {
192 this.user_store.update(cx, |user_store, cx| {
193 user_store.update_edit_prediction_usage(usage, cx);
194 });
195 })
196 .ok();
197 }
198
199 if let Some(debug_tx) = &debug_tx {
200 debug_tx
201 .unbounded_send(DebugEvent::EditPredictionFinished(
202 EditPredictionFinishedDebugEvent {
203 buffer: buffer.downgrade(),
204 model_output: Some(response.output_excerpt.clone()),
205 position,
206 },
207 ))
208 .ok();
209 }
210
211 let edit_prediction = process_completion_response(
212 response,
213 buffer,
214 &snapshot,
215 editable_range,
216 inputs,
217 buffer_snapshotted_at,
218 received_response_at,
219 cx,
220 )
221 .await;
222
223 let finished_at = Instant::now();
224
225 // record latency for ~1% of requests
226 if rand::random::<u8>() <= 2 {
227 telemetry::event!(
228 "Edit Prediction Request",
229 context_latency = done_gathering_context_at
230 .duration_since(buffer_snapshotted_at)
231 .as_millis(),
232 request_latency = received_response_at
233 .duration_since(done_gathering_context_at)
234 .as_millis(),
235 process_latency = finished_at.duration_since(received_response_at).as_millis()
236 );
237 }
238
239 edit_prediction.map(Some)
240 })
241}
242
243fn process_completion_response(
244 prediction_response: PredictEditsResponse,
245 buffer: Entity<Buffer>,
246 snapshot: &BufferSnapshot,
247 editable_range: Range<usize>,
248 inputs: ZetaPromptInput,
249 buffer_snapshotted_at: Instant,
250 received_response_at: Instant,
251 cx: &AsyncApp,
252) -> Task<Result<EditPredictionResult>> {
253 let snapshot = snapshot.clone();
254 let request_id = prediction_response.request_id;
255 let output_excerpt = prediction_response.output_excerpt;
256 cx.spawn(async move |cx| {
257 let output_excerpt: Arc<str> = output_excerpt.into();
258
259 let edits: Arc<[(Range<Anchor>, Arc<str>)]> = cx
260 .background_spawn({
261 let output_excerpt = output_excerpt.clone();
262 let editable_range = editable_range.clone();
263 let snapshot = snapshot.clone();
264 async move { parse_edits(output_excerpt.as_ref(), editable_range, &snapshot) }
265 })
266 .await?
267 .into();
268
269 let id = EditPredictionId(request_id.into());
270 Ok(EditPredictionResult::new(
271 id,
272 &buffer,
273 &snapshot,
274 edits,
275 None,
276 buffer_snapshotted_at,
277 received_response_at,
278 inputs,
279 cx,
280 )
281 .await)
282 })
283}
284
285pub(crate) fn parse_edits(
286 output_excerpt: &str,
287 editable_range: Range<usize>,
288 snapshot: &BufferSnapshot,
289) -> Result<Vec<(Range<Anchor>, Arc<str>)>> {
290 let content = output_excerpt.replace(CURSOR_MARKER, "");
291
292 let start_markers = content
293 .match_indices(EDITABLE_REGION_START_MARKER)
294 .collect::<Vec<_>>();
295 anyhow::ensure!(
296 start_markers.len() <= 1,
297 "expected at most one start marker, found {}",
298 start_markers.len()
299 );
300
301 let end_markers = content
302 .match_indices(EDITABLE_REGION_END_MARKER)
303 .collect::<Vec<_>>();
304 anyhow::ensure!(
305 end_markers.len() <= 1,
306 "expected at most one end marker, found {}",
307 end_markers.len()
308 );
309
310 let sof_markers = content
311 .match_indices(START_OF_FILE_MARKER)
312 .collect::<Vec<_>>();
313 anyhow::ensure!(
314 sof_markers.len() <= 1,
315 "expected at most one start-of-file marker, found {}",
316 sof_markers.len()
317 );
318
319 let content_start = start_markers
320 .first()
321 .map(|e| e.0 + EDITABLE_REGION_START_MARKER.len())
322 .map(|start| {
323 if content.len() > start
324 && content.is_char_boundary(start)
325 && content[start..].starts_with('\n')
326 {
327 start + 1
328 } else {
329 start
330 }
331 })
332 .unwrap_or(0);
333 let content_end = end_markers
334 .first()
335 .map(|e| {
336 if e.0 > 0 && content.is_char_boundary(e.0 - 1) && content[e.0 - 1..].starts_with('\n')
337 {
338 e.0 - 1
339 } else {
340 e.0
341 }
342 })
343 .unwrap_or(content.strip_suffix("\n").unwrap_or(&content).len());
344
345 // min to account for content_end and content_start both accounting for the same newline in the following case:
346 // <|editable_region_start|>\n<|editable_region_end|>
347 let new_text = &content[content_start.min(content_end)..content_end];
348
349 let old_text = snapshot
350 .text_for_range(editable_range.clone())
351 .collect::<String>();
352
353 Ok(compute_edits(
354 old_text,
355 new_text,
356 editable_range.start,
357 snapshot,
358 ))
359}
360
361pub fn compute_edits(
362 old_text: String,
363 new_text: &str,
364 offset: usize,
365 snapshot: &BufferSnapshot,
366) -> Vec<(Range<Anchor>, Arc<str>)> {
367 compute_edits_and_cursor_position(old_text, new_text, offset, None, snapshot).0
368}
369
370pub fn compute_edits_and_cursor_position(
371 old_text: String,
372 new_text: &str,
373 offset: usize,
374 cursor_offset_in_new_text: Option<usize>,
375 snapshot: &BufferSnapshot,
376) -> (
377 Vec<(Range<Anchor>, Arc<str>)>,
378 Option<PredictedCursorPosition>,
379) {
380 let diffs = text_diff(&old_text, new_text);
381
382 // Delta represents the cumulative change in byte count from all preceding edits.
383 // new_offset = old_offset + delta, so old_offset = new_offset - delta
384 let mut delta: isize = 0;
385 let mut cursor_position: Option<PredictedCursorPosition> = None;
386
387 let edits = diffs
388 .iter()
389 .map(|(raw_old_range, new_text)| {
390 // Compute cursor position if it falls within or before this edit.
391 if let (Some(cursor_offset), None) = (cursor_offset_in_new_text, cursor_position) {
392 let edit_start_in_new = (raw_old_range.start as isize + delta) as usize;
393 let edit_end_in_new = edit_start_in_new + new_text.len();
394
395 if cursor_offset < edit_start_in_new {
396 let cursor_in_old = (cursor_offset as isize - delta) as usize;
397 cursor_position = Some(PredictedCursorPosition::at_anchor(
398 snapshot.anchor_after(offset + cursor_in_old),
399 ));
400 } else if cursor_offset < edit_end_in_new {
401 let offset_within_insertion = cursor_offset - edit_start_in_new;
402 cursor_position = Some(PredictedCursorPosition::new(
403 snapshot.anchor_before(offset + raw_old_range.start),
404 offset_within_insertion,
405 ));
406 }
407
408 delta += new_text.len() as isize - raw_old_range.len() as isize;
409 }
410
411 // Compute the edit with prefix/suffix trimming.
412 let mut old_range = raw_old_range.clone();
413 let old_slice = &old_text[old_range.clone()];
414
415 let prefix_len = common_prefix(old_slice.chars(), new_text.chars());
416 let suffix_len = common_prefix(
417 old_slice[prefix_len..].chars().rev(),
418 new_text[prefix_len..].chars().rev(),
419 );
420
421 old_range.start += offset;
422 old_range.end += offset;
423 old_range.start += prefix_len;
424 old_range.end -= suffix_len;
425
426 let new_text = new_text[prefix_len..new_text.len() - suffix_len].into();
427 let range = if old_range.is_empty() {
428 let anchor = snapshot.anchor_after(old_range.start);
429 anchor..anchor
430 } else {
431 snapshot.anchor_after(old_range.start)..snapshot.anchor_before(old_range.end)
432 };
433 (range, new_text)
434 })
435 .collect();
436
437 if let (Some(cursor_offset), None) = (cursor_offset_in_new_text, cursor_position) {
438 let cursor_in_old = (cursor_offset as isize - delta) as usize;
439 let buffer_offset = snapshot.clip_offset(offset + cursor_in_old, Bias::Right);
440 cursor_position = Some(PredictedCursorPosition::at_anchor(
441 snapshot.anchor_after(buffer_offset),
442 ));
443 }
444
445 (edits, cursor_position)
446}
447
448fn common_prefix<T1: Iterator<Item = char>, T2: Iterator<Item = char>>(a: T1, b: T2) -> usize {
449 a.zip(b)
450 .take_while(|(a, b)| a == b)
451 .map(|(a, _)| a.len_utf8())
452 .sum()
453}
454
455fn git_info_for_file(
456 project: &Entity<Project>,
457 project_path: &ProjectPath,
458 cx: &App,
459) -> Option<PredictEditsGitInfo> {
460 let git_store = project.read(cx).git_store().read(cx);
461 if let Some((repository, _repo_path)) =
462 git_store.repository_and_path_for_project_path(project_path, cx)
463 {
464 let repository = repository.read(cx);
465 let head_sha = repository
466 .head_commit
467 .as_ref()
468 .map(|head_commit| head_commit.sha.to_string());
469 let remote_origin_url = repository.remote_origin_url.clone();
470 let remote_upstream_url = repository.remote_upstream_url.clone();
471 if head_sha.is_none() && remote_origin_url.is_none() && remote_upstream_url.is_none() {
472 return None;
473 }
474 Some(PredictEditsGitInfo {
475 head_sha,
476 remote_origin_url,
477 remote_upstream_url,
478 })
479 } else {
480 None
481 }
482}
483
484pub struct GatherContextOutput {
485 pub body: PredictEditsBody,
486 pub context_range: Range<Point>,
487 pub editable_range: Range<usize>,
488 pub included_events_count: usize,
489}
490
491pub fn gather_context(
492 full_path_str: String,
493 snapshot: &BufferSnapshot,
494 cursor_point: language::Point,
495 prompt_for_events: impl FnOnce() -> (String, usize) + Send + 'static,
496 trigger: PredictEditsRequestTrigger,
497 cx: &App,
498) -> Task<Result<GatherContextOutput>> {
499 cx.background_spawn({
500 let snapshot = snapshot.clone();
501 async move {
502 let input_excerpt = excerpt_for_cursor_position(
503 cursor_point,
504 &full_path_str,
505 &snapshot,
506 MAX_REWRITE_TOKENS,
507 MAX_CONTEXT_TOKENS,
508 );
509 let (input_events, included_events_count) = prompt_for_events();
510 let editable_range = input_excerpt.editable_range.to_offset(&snapshot);
511
512 let body = PredictEditsBody {
513 input_events,
514 input_excerpt: input_excerpt.prompt,
515 can_collect_data: false,
516 diagnostic_groups: None,
517 git_info: None,
518 outline: None,
519 speculated_output: None,
520 trigger,
521 };
522
523 Ok(GatherContextOutput {
524 body,
525 context_range: input_excerpt.context_range,
526 editable_range,
527 included_events_count,
528 })
529 }
530 })
531}
532
533pub(crate) fn prompt_for_events(events: &[Arc<Event>], max_tokens: usize) -> String {
534 prompt_for_events_impl(events, max_tokens).0
535}
536
537fn prompt_for_events_impl(events: &[Arc<Event>], mut remaining_tokens: usize) -> (String, usize) {
538 let mut result = String::new();
539 for (ix, event) in events.iter().rev().enumerate() {
540 let event_string = format_event(event.as_ref());
541 let event_tokens = guess_token_count(event_string.len());
542 if event_tokens > remaining_tokens {
543 return (result, ix);
544 }
545
546 if !result.is_empty() {
547 result.insert_str(0, "\n\n");
548 }
549 result.insert_str(0, &event_string);
550 remaining_tokens -= event_tokens;
551 }
552 return (result, events.len());
553}
554
555pub fn format_event(event: &Event) -> String {
556 match event {
557 Event::BufferChange {
558 path,
559 old_path,
560 diff,
561 ..
562 } => {
563 let mut prompt = String::new();
564
565 if old_path != path {
566 writeln!(
567 prompt,
568 "User renamed {} to {}\n",
569 old_path.display(),
570 path.display()
571 )
572 .unwrap();
573 }
574
575 if !diff.is_empty() {
576 write!(
577 prompt,
578 "User edited {}:\n```diff\n{}\n```",
579 path.display(),
580 diff
581 )
582 .unwrap();
583 }
584
585 prompt
586 }
587 }
588}
589
590#[derive(Debug)]
591pub struct InputExcerpt {
592 pub context_range: Range<Point>,
593 pub editable_range: Range<Point>,
594 pub prompt: String,
595}
596
597pub fn excerpt_for_cursor_position(
598 position: Point,
599 path: &str,
600 snapshot: &BufferSnapshot,
601 editable_region_token_limit: usize,
602 context_token_limit: usize,
603) -> InputExcerpt {
604 let (editable_range, context_range) = editable_and_context_ranges_for_cursor_position(
605 position,
606 snapshot,
607 editable_region_token_limit,
608 context_token_limit,
609 );
610
611 let mut prompt = String::new();
612
613 writeln!(&mut prompt, "```{path}").unwrap();
614 if context_range.start == Point::zero() {
615 writeln!(&mut prompt, "{START_OF_FILE_MARKER}").unwrap();
616 }
617
618 for chunk in snapshot.chunks(context_range.start..editable_range.start, false) {
619 prompt.push_str(chunk.text);
620 }
621
622 push_editable_range(position, snapshot, editable_range.clone(), &mut prompt);
623
624 for chunk in snapshot.chunks(editable_range.end..context_range.end, false) {
625 prompt.push_str(chunk.text);
626 }
627 write!(prompt, "\n```").unwrap();
628
629 InputExcerpt {
630 context_range,
631 editable_range,
632 prompt,
633 }
634}
635
636fn push_editable_range(
637 cursor_position: Point,
638 snapshot: &BufferSnapshot,
639 editable_range: Range<Point>,
640 prompt: &mut String,
641) {
642 writeln!(prompt, "{EDITABLE_REGION_START_MARKER}").unwrap();
643 for chunk in snapshot.chunks(editable_range.start..cursor_position, false) {
644 prompt.push_str(chunk.text);
645 }
646 prompt.push_str(CURSOR_MARKER);
647 for chunk in snapshot.chunks(cursor_position..editable_range.end, false) {
648 prompt.push_str(chunk.text);
649 }
650 write!(prompt, "\n{EDITABLE_REGION_END_MARKER}").unwrap();
651}
652
653#[cfg(test)]
654mod tests {
655 use super::*;
656 use gpui::{App, AppContext};
657 use indoc::indoc;
658 use language::Buffer;
659
660 #[gpui::test]
661 fn test_excerpt_for_cursor_position(cx: &mut App) {
662 let text = indoc! {r#"
663 fn foo() {
664 let x = 42;
665 println!("Hello, world!");
666 }
667
668 fn bar() {
669 let x = 42;
670 let mut sum = 0;
671 for i in 0..x {
672 sum += i;
673 }
674 println!("Sum: {}", sum);
675 return sum;
676 }
677
678 fn generate_random_numbers() -> Vec<i32> {
679 let mut rng = rand::thread_rng();
680 let mut numbers = Vec::new();
681 for _ in 0..5 {
682 numbers.push(rng.random_range(1..101));
683 }
684 numbers
685 }
686 "#};
687 let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language::rust_lang(), cx));
688 let snapshot = buffer.read(cx).snapshot();
689
690 // The excerpt expands to syntax boundaries.
691 // With 50 token editable limit, we get a region that expands to syntax nodes.
692 let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 50, 32);
693 assert_eq!(
694 excerpt.prompt,
695 indoc! {r#"
696 ```main.rs
697
698 fn bar() {
699 let x = 42;
700 <|editable_region_start|>
701 let mut sum = 0;
702 for i in 0..x {
703 sum += i;
704 }
705 println!("Sum: {}", sum);
706 r<|user_cursor_is_here|>eturn sum;
707 }
708
709 fn generate_random_numbers() -> Vec<i32> {
710 <|editable_region_end|>
711 let mut rng = rand::thread_rng();
712 let mut numbers = Vec::new();
713 ```"#}
714 );
715
716 // With smaller budget, the region expands to syntax boundaries but is tighter.
717 let excerpt = excerpt_for_cursor_position(Point::new(12, 5), "main.rs", &snapshot, 40, 32);
718 assert_eq!(
719 excerpt.prompt,
720 indoc! {r#"
721 ```main.rs
722 fn bar() {
723 let x = 42;
724 let mut sum = 0;
725 for i in 0..x {
726 <|editable_region_start|>
727 sum += i;
728 }
729 println!("Sum: {}", sum);
730 r<|user_cursor_is_here|>eturn sum;
731 }
732
733 fn generate_random_numbers() -> Vec<i32> {
734 <|editable_region_end|>
735 let mut rng = rand::thread_rng();
736 ```"#}
737 );
738 }
739
740 #[gpui::test]
741 fn test_parse_edits_empty_editable_region(cx: &mut App) {
742 let text = "fn foo() {\n let x = 42;\n}\n";
743 let buffer = cx.new(|cx| Buffer::local(text, cx));
744 let snapshot = buffer.read(cx).snapshot();
745
746 let output = "<|editable_region_start|>\n<|editable_region_end|>";
747 let editable_range = 0..text.len();
748 let edits = parse_edits(output, editable_range, &snapshot).unwrap();
749 assert_eq!(edits.len(), 1);
750 let (range, new_text) = &edits[0];
751 assert_eq!(range.to_offset(&snapshot), 0..text.len(),);
752 assert_eq!(new_text.as_ref(), "");
753 }
754
755 #[gpui::test]
756 fn test_parse_edits_multibyte_char_before_end_marker(cx: &mut App) {
757 let text = "// café";
758 let buffer = cx.new(|cx| Buffer::local(text, cx));
759 let snapshot = buffer.read(cx).snapshot();
760
761 let output = "<|editable_region_start|>\n// café<|editable_region_end|>";
762 let editable_range = 0..text.len();
763
764 let edits = parse_edits(output, editable_range, &snapshot).unwrap();
765 assert_eq!(edits, vec![]);
766 }
767
768 #[gpui::test]
769 fn test_parse_edits_multibyte_char_after_start_marker(cx: &mut App) {
770 let text = "é is great";
771 let buffer = cx.new(|cx| Buffer::local(text, cx));
772 let snapshot = buffer.read(cx).snapshot();
773
774 let output = "<|editable_region_start|>é is great\n<|editable_region_end|>";
775 let editable_range = 0..text.len();
776
777 let edits = parse_edits(output, editable_range, &snapshot).unwrap();
778 assert!(edits.is_empty());
779 }
780}