debugger: Fix correctly determine replace range for debug console completions (#33959)

Remco Smits created

Follow-up #33868

This PR fixes a few issues with determining the completion range for
client‑ and variable‑list completions.

1. Non‑word completions
We previously supported only word characters and _, using their combined
length to compute the start offset. In PHP, however, an expression can
contain `$`, `-`, `>`, `[`, `]`, `(`, and `)`. Because these characters
weren’t treated as word characters, the start offset stopped at them,
even when the preceding character was part of a word.

2. Trailing characters inside the search text
When autocompletion occurred in the middle of the search text, we didn’t
account for trailing characters. As a result, the start offset was off
by the number of characters after the cursor. For example, replacing res
with result in print(res) produced `print(rresult)` because the trailing
`)` wasn’t subtracted from the start offset.

The following completions are correctly covered now:

- **Before** `$aut` -> `$aut$author` **After** `$aut` -> `$author`
- **Before** `$author->na` -> `$author->na$author->name` **After**
`$author->na` -> `$author->name`
- **Before** `$author->books[` -> `$author->books[$author->books[0]`
**After** `$author->books[` -> `$author->books[0]`
- **Before** `print(res)` -> `print(rresult)` **After** `print(res)` ->
`print(result)`

**Before**


https://github.com/user-attachments/assets/b530cf31-8d4d-45e6-9650-18574f14314c


https://github.com/user-attachments/assets/52475b7b-2bf2-4749-98ec-0dc933fcc364

**After**


https://github.com/user-attachments/assets/c065701b-31c9-4e0a-b584-d1daffe3a38c


https://github.com/user-attachments/assets/455ebb3e-632e-4a57-aea8-d214d2992c06

Release Notes:

- Debugger: Fixed autocompletion not always replacing the correct search
text

Change summary

crates/debugger_ui/src/session/running/console.rs | 138 ++++++++++++----
1 file changed, 101 insertions(+), 37 deletions(-)

Detailed changes

crates/debugger_ui/src/session/running/console.rs 🔗

@@ -12,7 +12,7 @@ use gpui::{
     Action as _, AppContext, Context, Corner, Entity, FocusHandle, Focusable, HighlightStyle, Hsla,
     Render, Subscription, Task, TextStyle, WeakEntity, actions,
 };
-use language::{Buffer, CodeLabel, ToOffset};
+use language::{Anchor, Buffer, CodeLabel, TextBufferSnapshot, ToOffset};
 use menu::{Confirm, SelectNext, SelectPrevious};
 use project::{
     Completion, CompletionResponse,
@@ -637,27 +637,13 @@ impl ConsoleQueryBarCompletionProvider {
         });
 
         let snapshot = buffer.read(cx).text_snapshot();
-        let query = snapshot.text();
-        let replace_range = {
-            let buffer_offset = buffer_position.to_offset(&snapshot);
-            let reversed_chars = snapshot.reversed_chars_for_range(0..buffer_offset);
-            let mut word_len = 0;
-            for ch in reversed_chars {
-                if ch.is_alphanumeric() || ch == '_' {
-                    word_len += 1;
-                } else {
-                    break;
-                }
-            }
-            let word_start_offset = buffer_offset - word_len;
-            let start_anchor = snapshot.anchor_at(word_start_offset, Bias::Left);
-            start_anchor..buffer_position
-        };
+        let buffer_text = snapshot.text();
+
         cx.spawn(async move |_, cx| {
             const LIMIT: usize = 10;
             let matches = fuzzy::match_strings(
                 &string_matches,
-                &query,
+                &buffer_text,
                 true,
                 true,
                 LIMIT,
@@ -672,7 +658,12 @@ impl ConsoleQueryBarCompletionProvider {
                     let variable_value = variables.get(&string_match.string)?;
 
                     Some(project::Completion {
-                        replace_range: replace_range.clone(),
+                        replace_range: Self::replace_range_for_completion(
+                            &buffer_text,
+                            buffer_position,
+                            string_match.string.as_bytes(),
+                            &snapshot,
+                        ),
                         new_text: string_match.string.clone(),
                         label: CodeLabel {
                             filter_range: 0..string_match.string.len(),
@@ -697,6 +688,28 @@ impl ConsoleQueryBarCompletionProvider {
         })
     }
 
+    fn replace_range_for_completion(
+        buffer_text: &String,
+        buffer_position: Anchor,
+        new_bytes: &[u8],
+        snapshot: &TextBufferSnapshot,
+    ) -> Range<Anchor> {
+        let buffer_offset = buffer_position.to_offset(&snapshot);
+        let buffer_bytes = &buffer_text.as_bytes()[0..buffer_offset];
+
+        let mut prefix_len = 0;
+        for i in (0..new_bytes.len()).rev() {
+            if buffer_bytes.ends_with(&new_bytes[0..i]) {
+                prefix_len = i;
+                break;
+            }
+        }
+
+        let start = snapshot.clip_offset(buffer_offset - prefix_len, Bias::Left);
+
+        snapshot.anchor_before(start)..buffer_position
+    }
+
     const fn completion_type_score(completion_type: CompletionItemType) -> usize {
         match completion_type {
             CompletionItemType::Field | CompletionItemType::Property => 0,
@@ -744,6 +757,8 @@ impl ConsoleQueryBarCompletionProvider {
         cx.background_executor().spawn(async move {
             let completions = completion_task.await?;
 
+            let buffer_text = snapshot.text();
+
             let completions = completions
                 .into_iter()
                 .map(|completion| {
@@ -753,26 +768,14 @@ impl ConsoleQueryBarCompletionProvider {
                         .as_ref()
                         .unwrap_or(&completion.label)
                         .to_owned();
-                    let buffer_text = snapshot.text();
-                    let buffer_bytes = buffer_text.as_bytes();
-                    let new_bytes = new_text.as_bytes();
-
-                    let mut prefix_len = 0;
-                    for i in (0..new_bytes.len()).rev() {
-                        if buffer_bytes.ends_with(&new_bytes[0..i]) {
-                            prefix_len = i;
-                            break;
-                        }
-                    }
-
-                    let buffer_offset = buffer_position.to_offset(&snapshot);
-                    let start = buffer_offset - prefix_len;
-                    let start = snapshot.clip_offset(start, Bias::Left);
-                    let start = snapshot.anchor_before(start);
-                    let replace_range = start..buffer_position;
 
                     project::Completion {
-                        replace_range,
+                        replace_range: Self::replace_range_for_completion(
+                            &buffer_text,
+                            buffer_position,
+                            new_text.as_bytes(),
+                            &snapshot,
+                        ),
                         new_text,
                         label: CodeLabel {
                             filter_range: 0..completion.label.len(),
@@ -944,3 +947,64 @@ fn color_fetcher(color: ansi::Color) -> fn(&Theme) -> Hsla {
     };
     color_fetcher
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::tests::init_test;
+    use editor::test::editor_test_context::EditorTestContext;
+    use gpui::TestAppContext;
+    use language::Point;
+
+    #[track_caller]
+    fn assert_completion_range(
+        input: &str,
+        expect: &str,
+        replacement: &str,
+        cx: &mut EditorTestContext,
+    ) {
+        cx.set_state(input);
+
+        let buffer_position =
+            cx.editor(|editor, _, cx| editor.selections.newest::<Point>(cx).start);
+
+        let snapshot = &cx.buffer_snapshot();
+
+        let replace_range = ConsoleQueryBarCompletionProvider::replace_range_for_completion(
+            &cx.buffer_text(),
+            snapshot.anchor_before(buffer_position),
+            replacement.as_bytes(),
+            &snapshot,
+        );
+
+        cx.update_editor(|editor, _, cx| {
+            editor.edit(
+                vec![(
+                    snapshot.offset_for_anchor(&replace_range.start)
+                        ..snapshot.offset_for_anchor(&replace_range.end),
+                    replacement,
+                )],
+                cx,
+            );
+        });
+
+        pretty_assertions::assert_eq!(expect, cx.display_text());
+    }
+
+    #[gpui::test]
+    async fn test_determine_completion_replace_range(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let mut cx = EditorTestContext::new(cx).await;
+
+        assert_completion_range("resˇ", "result", "result", &mut cx);
+        assert_completion_range("print(resˇ)", "print(result)", "result", &mut cx);
+        assert_completion_range("$author->nˇ", "$author->name", "$author->name", &mut cx);
+        assert_completion_range(
+            "$author->books[ˇ",
+            "$author->books[0]",
+            "$author->books[0]",
+            &mut cx,
+        );
+    }
+}