editor: Fix folding for unindented multiline strings and comments (#50049)

Ryan Walker created

Closes #5057

## Summary

The indent-based code fold detection does not account for lines inside
multiline strings or block comments that have less indentation than the
surrounding code. This causes the fold scanner to think the enclosing
block ends prematurely.

For example, folding `fn main()` here would fail because the raw string
content at indent level 0 gets treated as the end of the function body:

``` rust
fn main() {
    let s = r#"
unindented content
"#;
}  
```

This PR checks whether a low-indent line falls inside a string or
comment override region and skips it if so. This works across all
languages that define `@string` or `@comment` overrides in their
`overrides.scm`.

## Before


https://github.com/user-attachments/assets/a08e6bf8-4f25-4211-8a46-8f6da7e49247

## After


https://github.com/user-attachments/assets/cd5b36db-6d4d-420b-9d60-79f9fad8638e

## Test Plan

- Added `test_fold_with_unindented_multiline_raw_string`
- Added `test_fold_with_unindented_multiline_block_comment`
- All existing fold tests pass
- Manually tested both Rust and Python examples

Release Notes:

- Fixed code folding incorrectly collapsing when multiline strings or
block comments contained unindented content

Change summary

crates/editor/src/display_map.rs  |  13 ++++
crates/editor/src/editor_tests.rs | 102 ++++++++++++++++++++++++++++++++
2 files changed, 114 insertions(+), 1 deletion(-)

Detailed changes

crates/editor/src/display_map.rs 🔗

@@ -2320,6 +2320,19 @@ impl DisplaySnapshot {
                 if !line_indent.is_line_blank()
                     && line_indent.raw_len() <= start_line_indent.raw_len()
                 {
+                    if self
+                        .buffer_snapshot()
+                        .language_scope_at(Point::new(row, 0))
+                        .is_some_and(|scope| {
+                            matches!(
+                                scope.override_name(),
+                                Some("string") | Some("comment") | Some("comment.inclusive")
+                            )
+                        })
+                    {
+                        continue;
+                    }
+
                     let prev_row = row - 1;
                     end = Some(Point::new(
                         prev_row,

crates/editor/src/editor_tests.rs 🔗

@@ -26,7 +26,7 @@ use language::{
     BracketPairConfig,
     Capability::ReadWrite,
     DiagnosticSourceKind, FakeLspAdapter, IndentGuideSettings, LanguageConfig,
-    LanguageConfigOverride, LanguageMatcher, LanguageName, Override, Point,
+    LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries, Override, Point,
     language_settings::{
         CompletionSettingsContent, FormatterList, LanguageSettingsContent, LspInsertMode,
     },
@@ -51,6 +51,7 @@ use settings::{
     IndentGuideBackgroundColoring, IndentGuideColoring, InlayHintSettingsContent,
     ProjectSettingsContent, SearchSettingsContent, SettingsContent, SettingsStore,
 };
+use std::borrow::Cow;
 use std::{cell::RefCell, future::Future, rc::Rc, sync::atomic::AtomicBool, time::Instant};
 use std::{
     iter,
@@ -1324,6 +1325,105 @@ fn test_fold_action_multiple_line_breaks(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_fold_with_unindented_multiline_raw_string(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig::default(),
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_queries(LanguageQueries {
+            overrides: Some(Cow::from(indoc! {"
+                [
+                  (string_literal)
+                  (raw_string_literal)
+                ] @string
+                [
+                  (line_comment)
+                  (block_comment)
+                ] @comment.inclusive
+            "})),
+            ..Default::default()
+        })
+        .expect("Could not parse queries"),
+    );
+
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+    cx.set_state(indoc! {"
+        fn main() {
+            let s = r#\"
+        a
+        b
+        c
+        \"#;
+        }ˇ
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.fold_at_level(&FoldAtLevel(1), window, cx);
+        assert_eq!(
+            editor.display_text(cx),
+            indoc! {"
+                fn main() {⋯
+                }
+            "},
+        );
+    });
+}
+
+#[gpui::test]
+async fn test_fold_with_unindented_multiline_block_comment(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let mut cx = EditorTestContext::new(cx).await;
+
+    let language = Arc::new(
+        Language::new(
+            LanguageConfig::default(),
+            Some(tree_sitter_rust::LANGUAGE.into()),
+        )
+        .with_queries(LanguageQueries {
+            overrides: Some(Cow::from(indoc! {"
+                [
+                  (string_literal)
+                  (raw_string_literal)
+                ] @string
+                [
+                  (line_comment)
+                  (block_comment)
+                ] @comment.inclusive
+            "})),
+            ..Default::default()
+        })
+        .expect("Could not parse queries"),
+    );
+
+    cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx));
+    cx.set_state(indoc! {"
+        fn main() {
+            let x = 1;
+            /*
+        unindented comment line
+            */
+        }ˇ
+    "});
+
+    cx.update_editor(|editor, window, cx| {
+        editor.fold_at_level(&FoldAtLevel(1), window, cx);
+        assert_eq!(
+            editor.display_text(cx),
+            indoc! {"
+                fn main() {⋯
+                }
+            "},
+        );
+    });
+}
+
 #[gpui::test]
 fn test_fold_at_level(cx: &mut TestAppContext) {
     init_test(cx, |_| {});