lsp: Sanitize newlines in document and workspace symbol names (#49092)

Shuhei Kadowaki and Claude Opus 4.6 created

LSP servers may return symbol names containing newlines. Since outline
panel, breadcrumbs, and project search modal all expect single-line
items, collapse newlines before display.

- Document symbols: collapse newlines to spaces in name
- Workspace symbols: collapse newlines with `↡ ` separator for name and
container_name, preserving visual hint of original structure

Closes #ISSUE

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>

Change summary

crates/editor/src/folding_ranges.rs              |  4 
crates/project/src/lsp_command.rs                | 15 +---
crates/project/src/lsp_store.rs                  | 12 ++
crates/project/src/lsp_store/document_symbols.rs | 66 ++++++++++++++---
4 files changed, 70 insertions(+), 27 deletions(-)

Detailed changes

crates/editor/src/folding_ranges.rs πŸ”—

@@ -859,7 +859,7 @@ mod tests {
                     "    }\n",
                     "}\n",
                     "\n",
-                    "fn newline() {   … }\n",
+                    "fn newline() { … }\n",
                 ]
                 .concat(),
             );
@@ -996,7 +996,7 @@ mod tests {
                     "\n",
                     "fn outer() { outer… }\n",
                     "\n",
-                    "fn newline() {   … }\n",
+                    "fn newline() { … }\n",
                 ]
                 .concat(),
             );

crates/project/src/lsp_command.rs πŸ”—

@@ -4799,17 +4799,10 @@ impl LspCommand for GetFoldingRanges {
                 );
                 let start = snapshot.anchor_after(start);
                 let end = snapshot.anchor_before(end);
-                let collapsed_text =
-                    folding_range
-                        .collapsed_text
-                        .filter(|t| !t.is_empty())
-                        .map(|t| {
-                            if t.contains('\n') {
-                                SharedString::from(t.replace('\n', " "))
-                            } else {
-                                SharedString::from(t)
-                            }
-                        });
+                let collapsed_text = folding_range
+                    .collapsed_text
+                    .filter(|t| !t.is_empty())
+                    .map(|t| SharedString::from(crate::lsp_store::collapse_newlines(&t, " ")));
                 LspFoldingRange {
                     range: start..end,
                     collapsed_text,

crates/project/src/lsp_store.rs πŸ”—

@@ -7668,9 +7668,10 @@ impl LspStore {
                                         source_worktree_id,
                                         path,
                                         kind: symbol_kind,
-                                        name: symbol_name,
+                                        name: collapse_newlines(&symbol_name, "↡ "),
                                         range: range_from_lsp(symbol_location.range),
-                                        container_name,
+                                        container_name: container_name
+                                            .map(|c| collapse_newlines(&c, "↡ ")),
                                     })
                                 },
                             )
@@ -14275,6 +14276,13 @@ async fn populate_labels_for_symbols(
     }
 }
 
+pub(crate) fn collapse_newlines(text: &str, separator: &str) -> String {
+    text.lines()
+        .map(|line| line.trim())
+        .filter(|line| !line.is_empty())
+        .join(separator)
+}
+
 fn include_text(server: &lsp::LanguageServer) -> Option<bool> {
     match server.capabilities().text_document_sync.as_ref()? {
         lsp::TextDocumentSyncCapability::Options(opts) => match opts.save.as_ref()? {

crates/project/src/lsp_store/document_symbols.rs πŸ”—

@@ -249,6 +249,8 @@ fn flatten_document_symbols(
     output: &mut Vec<OutlineItem<Anchor>>,
 ) {
     for symbol in symbols {
+        let name = super::collapse_newlines(&symbol.name, " ");
+
         let start = snapshot.clip_point_utf16(symbol.range.start, Bias::Right);
         let end = snapshot.clip_point_utf16(symbol.range.end, Bias::Left);
         let selection_start = snapshot.clip_point_utf16(symbol.selection_range.start, Bias::Right);
@@ -258,18 +260,12 @@ fn flatten_document_symbols(
         let selection_range =
             snapshot.anchor_after(selection_start)..snapshot.anchor_before(selection_end);
 
-        let (text, name_ranges, source_range_for_text) = enriched_symbol_text(
-            &symbol.name,
-            start,
-            selection_start,
-            selection_end,
-            snapshot,
-        )
-        .unwrap_or_else(|| {
-            let name = symbol.name.clone();
-            let name_len = name.len();
-            (name, vec![0..name_len], selection_range.clone())
-        });
+        let (text, name_ranges, source_range_for_text) =
+            enriched_symbol_text(&name, start, selection_start, selection_end, snapshot)
+                .unwrap_or_else(|| {
+                    let name_len = name.len();
+                    (name.clone(), vec![0..name_len], selection_range.clone())
+                });
 
         output.push(OutlineItem {
             depth,
@@ -454,4 +450,50 @@ mod tests {
         flatten_document_symbols(&symbols, &snapshot, 0, &mut items);
         assert!(items.is_empty());
     }
+
+    #[gpui::test]
+    async fn test_newlines_collapsed_in_name(cx: &mut TestAppContext) {
+        let buffer = cx.new(|cx| Buffer::local("x = 1\ny = 2\n", cx));
+
+        let symbols = vec![
+            make_symbol(
+                "line1\nline2",
+                lsp::SymbolKind::VARIABLE,
+                (0, 0)..(0, 5),
+                (0, 0)..(0, 1),
+                vec![],
+            ),
+            make_symbol(
+                "  a  \n  b  ",
+                lsp::SymbolKind::VARIABLE,
+                (1, 0)..(1, 5),
+                (1, 0)..(1, 1),
+                vec![],
+            ),
+            make_symbol(
+                "a\r\nb",
+                lsp::SymbolKind::VARIABLE,
+                (0, 0)..(1, 5),
+                (0, 0)..(0, 1),
+                vec![],
+            ),
+            make_symbol(
+                "a\n\nb",
+                lsp::SymbolKind::VARIABLE,
+                (0, 0)..(1, 5),
+                (0, 0)..(0, 1),
+                vec![],
+            ),
+        ];
+
+        let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot());
+        let mut items = Vec::new();
+        flatten_document_symbols(&symbols, &snapshot, 0, &mut items);
+
+        assert_eq!(items.len(), 4);
+        assert_eq!(items[0].text, "line1 line2");
+        assert_eq!(items[1].text, "a b");
+        assert_eq!(items[2].text, "a b");
+        assert_eq!(items[3].text, "a b");
+    }
 }