Fix project symbol picker UTF-8 highlight panic (#53485) (cherry-pick to preview) (#53563)

zed-zippy[bot] , Anthony Eid , and Lukas Wirth created

Cherry-pick of #53485 to preview

----
This panic was caused because we incorrectly assumed that each character
was one byte when converting character indices to highlight range byte
indices.

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX

checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Closes #53479

Release Notes:

- Fix a panic that could occur in the project symbol search picker

---------

Co-authored-by: Lukas Wirth <lukas@zed.dev>

Co-authored-by: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Co-authored-by: Lukas Wirth <lukas@zed.dev>

Change summary

crates/project_symbols/src/project_symbols.rs | 111 ++++++++++++++++++++
1 file changed, 107 insertions(+), 4 deletions(-)

Detailed changes

crates/project_symbols/src/project_symbols.rs 🔗

@@ -288,7 +288,7 @@ impl PickerDelegate for ProjectSymbolsDelegate {
         let custom_highlights = string_match
             .positions
             .iter()
-            .map(|pos| (*pos..pos + 1, highlight_style));
+            .map(|pos| (*pos..label.ceil_char_boundary(pos + 1), highlight_style));
 
         let highlights = gpui::combine_highlights(custom_highlights, syntax_runs);
 
@@ -299,9 +299,12 @@ impl PickerDelegate for ProjectSymbolsDelegate {
                 .toggle_state(selected)
                 .child(
                     v_flex()
-                        .child(LabelLike::new().child(
-                            StyledText::new(label).with_default_highlights(&text_style, highlights),
-                        ))
+                        .child(
+                            LabelLike::new().child(
+                                StyledText::new(&label)
+                                    .with_default_highlights(&text_style, highlights),
+                            ),
+                        )
                         .child(
                             h_flex()
                                 .child(Label::new(path).size(LabelSize::Small).color(Color::Muted))
@@ -483,6 +486,106 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_project_symbols_renders_utf8_match(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.executor());
+        fs.insert_tree(path!("/dir"), json!({ "test.rs": "" }))
+            .await;
+
+        let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+
+        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+        language_registry.add(Arc::new(Language::new(
+            LanguageConfig {
+                name: "Rust".into(),
+                matcher: LanguageMatcher {
+                    path_suffixes: vec!["rs".to_string()],
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+            None,
+        )));
+        let mut fake_servers = language_registry.register_fake_lsp(
+            "Rust",
+            FakeLspAdapter {
+                capabilities: lsp::ServerCapabilities {
+                    workspace_symbol_provider: Some(OneOf::Left(true)),
+                    ..Default::default()
+                },
+                ..Default::default()
+            },
+        );
+
+        let _buffer = project
+            .update(cx, |project, cx| {
+                project.open_local_buffer_with_lsp(path!("/dir/test.rs"), cx)
+            })
+            .await
+            .unwrap();
+
+        let fake_symbols = [symbol("안녕", path!("/dir/test.rs"))];
+        let fake_server = fake_servers.next().await.unwrap();
+        fake_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
+            move |params: lsp::WorkspaceSymbolParams, cx| {
+                let executor = cx.background_executor().clone();
+                let fake_symbols = fake_symbols.clone();
+                async move {
+                    let candidates = fake_symbols
+                        .iter()
+                        .enumerate()
+                        .map(|(id, symbol)| StringMatchCandidate::new(id, &symbol.name))
+                        .collect::<Vec<_>>();
+                    let matches = fuzzy::match_strings(
+                        &candidates,
+                        &params.query,
+                        true,
+                        true,
+                        100,
+                        &Default::default(),
+                        executor,
+                    )
+                    .await;
+
+                    Ok(Some(lsp::WorkspaceSymbolResponse::Flat(
+                        matches
+                            .into_iter()
+                            .map(|mat| fake_symbols[mat.candidate_id].clone())
+                            .collect(),
+                    )))
+                }
+            },
+        );
+
+        let (multi_workspace, cx) =
+            cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+        let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
+
+        let symbols = cx.new_window_entity(|window, cx| {
+            Picker::uniform_list(
+                ProjectSymbolsDelegate::new(workspace.downgrade(), project.clone()),
+                window,
+                cx,
+            )
+        });
+
+        symbols.update_in(cx, |p, window, cx| {
+            p.update_matches("안".to_string(), window, cx);
+        });
+
+        cx.run_until_parked();
+        symbols.read_with(cx, |symbols, _| {
+            assert_eq!(symbols.delegate.matches.len(), 1);
+            assert_eq!(symbols.delegate.matches[0].string, "안녕");
+        });
+
+        symbols.update_in(cx, |p, window, cx| {
+            assert!(p.delegate.render_match(0, false, window, cx).is_some());
+        });
+    }
+
     fn init_test(cx: &mut TestAppContext) {
         cx.update(|cx| {
             let store = SettingsStore::test(cx);