indent guides: Fix tab handling (#12249)

Bennet Bo Fenner created

Fixes indent guides when using tabs, 
Fixes: #12209, fixes #12210

Release Notes:

- N/A

Change summary

crates/editor/src/display_map.rs    |  22 ++--
crates/editor/src/editor_tests.rs   |  57 ++++++++++---
crates/editor/src/element.rs        |  17 ++-
crates/editor/src/indent_guides.rs  |  21 ++--
crates/language/src/buffer.rs       |  91 +++++++++++----------
crates/language/src/buffer_tests.rs |  31 ++++++-
crates/text/src/text.rs             | 130 ++++++++++++++++++++----------
7 files changed, 236 insertions(+), 133 deletions(-)

Detailed changes

crates/editor/src/display_map.rs 🔗

@@ -56,6 +56,7 @@ use std::ops::Add;
 use std::{any::TypeId, borrow::Cow, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc};
 use sum_tree::{Bias, TreeMap};
 use tab_map::{TabMap, TabSnapshot};
+use text::LineIndent;
 use ui::WindowContext;
 use wrap_map::{WrapMap, WrapSnapshot};
 
@@ -843,7 +844,7 @@ impl DisplaySnapshot {
         result
     }
 
-    pub fn line_indent_for_buffer_row(&self, buffer_row: MultiBufferRow) -> (u32, bool) {
+    pub fn line_indent_for_buffer_row(&self, buffer_row: MultiBufferRow) -> LineIndent {
         let (buffer, range) = self
             .buffer_snapshot
             .buffer_line_for_row(buffer_row)
@@ -866,17 +867,16 @@ impl DisplaySnapshot {
             return false;
         }
 
-        let (indent_size, is_blank) = self.line_indent_for_buffer_row(buffer_row);
-        if is_blank {
+        let line_indent = self.line_indent_for_buffer_row(buffer_row);
+        if line_indent.is_line_blank() {
             return false;
         }
 
         for next_row in (buffer_row.0 + 1)..=max_row.0 {
-            let (next_indent_size, next_line_is_blank) =
-                self.line_indent_for_buffer_row(MultiBufferRow(next_row));
-            if next_indent_size > indent_size {
+            let next_line_indent = self.line_indent_for_buffer_row(MultiBufferRow(next_row));
+            if next_line_indent.raw_len() > line_indent.raw_len() {
                 return true;
-            } else if !next_line_is_blank {
+            } else if !next_line_indent.is_line_blank() {
                 break;
             }
         }
@@ -900,13 +900,15 @@ impl DisplaySnapshot {
         } else if self.starts_indent(MultiBufferRow(start.row))
             && !self.is_line_folded(MultiBufferRow(start.row))
         {
-            let (start_indent, _) = self.line_indent_for_buffer_row(buffer_row);
+            let start_line_indent = self.line_indent_for_buffer_row(buffer_row);
             let max_point = self.buffer_snapshot.max_point();
             let mut end = None;
 
             for row in (buffer_row.0 + 1)..=max_point.row {
-                let (indent, is_blank) = self.line_indent_for_buffer_row(MultiBufferRow(row));
-                if !is_blank && indent <= start_indent {
+                let line_indent = self.line_indent_for_buffer_row(MultiBufferRow(row));
+                if !line_indent.is_line_blank()
+                    && line_indent.raw_len() <= start_line_indent.raw_len()
+                {
                     let prev_row = row - 1;
                     end = Some(Point::new(
                         prev_row,

crates/editor/src/editor_tests.rs 🔗

@@ -11523,7 +11523,7 @@ fn assert_indent_guides(
 }
 
 #[gpui::test]
-async fn test_indent_guides_single_line(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_single_line(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     fn main() {
@@ -11543,7 +11543,7 @@ async fn test_indent_guides_single_line(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_simple_block(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_simple_block(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     fn main() {
@@ -11564,7 +11564,7 @@ async fn test_indent_guides_simple_block(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_nested(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_nested(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     fn main() {
@@ -11593,7 +11593,7 @@ async fn test_indent_guides_nested(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_tab(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_tab(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     fn main() {
@@ -11618,7 +11618,7 @@ async fn test_indent_guides_tab(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_continues_on_empty_line(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_continues_on_empty_line(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
         fn main() {
@@ -11640,7 +11640,7 @@ async fn test_indent_guides_continues_on_empty_line(cx: &mut gpui::TestAppContex
 }
 
 #[gpui::test]
-async fn test_indent_guides_complex(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_complex(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
         fn main() {
@@ -11672,7 +11672,7 @@ async fn test_indent_guides_complex(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_starts_off_screen(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_starts_off_screen(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
         fn main() {
@@ -11704,7 +11704,7 @@ async fn test_indent_guides_starts_off_screen(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_ends_off_screen(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_ends_off_screen(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
         fn main() {
@@ -11736,7 +11736,7 @@ async fn test_indent_guides_ends_off_screen(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_without_brackets(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_without_brackets(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
         block1
@@ -11764,7 +11764,7 @@ async fn test_indent_guides_without_brackets(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_indent_guides_ends_before_empty_line(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_ends_before_empty_line(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
         block1
@@ -11790,7 +11790,7 @@ async fn test_indent_guides_ends_before_empty_line(cx: &mut gpui::TestAppContext
 }
 
 #[gpui::test]
-async fn test_indent_guides_continuing_off_screen(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_continuing_off_screen(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
         block1
@@ -11813,7 +11813,34 @@ async fn test_indent_guides_continuing_off_screen(cx: &mut gpui::TestAppContext)
 }
 
 #[gpui::test]
-async fn test_active_indent_guides_single_line(cx: &mut gpui::TestAppContext) {
+async fn test_indent_guide_tabs(cx: &mut gpui::TestAppContext) {
+    let (buffer_id, mut cx) = setup_indent_guides_editor(
+        &"
+        def a:
+        \tb = 3
+        \tif True:
+        \t\tc = 4
+        \t\td = 5
+        \tprint(b)
+        "
+        .unindent(),
+        cx,
+    )
+    .await;
+
+    assert_indent_guides(
+        0..6,
+        vec![
+            IndentGuide::new(buffer_id, 1, 6, 0, 4),
+            IndentGuide::new(buffer_id, 3, 4, 1, 4),
+        ],
+        None,
+        &mut cx,
+    );
+}
+
+#[gpui::test]
+async fn test_active_indent_guide_single_line(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     fn main() {
@@ -11839,7 +11866,7 @@ async fn test_active_indent_guides_single_line(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_active_indent_guides_respect_indented_range(cx: &mut gpui::TestAppContext) {
+async fn test_active_indent_guide_respect_indented_range(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     fn main() {
@@ -11902,7 +11929,7 @@ async fn test_active_indent_guides_respect_indented_range(cx: &mut gpui::TestApp
 }
 
 #[gpui::test]
-async fn test_active_indent_guides_empty_line(cx: &mut gpui::TestAppContext) {
+async fn test_active_indent_guide_empty_line(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     fn main() {
@@ -11930,7 +11957,7 @@ async fn test_active_indent_guides_empty_line(cx: &mut gpui::TestAppContext) {
 }
 
 #[gpui::test]
-async fn test_active_indent_guides_non_matching_indent(cx: &mut gpui::TestAppContext) {
+async fn test_active_indent_guide_non_matching_indent(cx: &mut gpui::TestAppContext) {
     let (buffer_id, mut cx) = setup_indent_guides_editor(
         &"
     def m:

crates/editor/src/element.rs 🔗

@@ -1417,9 +1417,9 @@ impl EditorElement {
                 .into_iter()
                 .enumerate()
                 .filter_map(|(i, indent_guide)| {
-                    let indent_size = self.column_pixels(indent_guide.indent_size as usize, cx);
-                    let total_width = indent_size * px(indent_guide.depth as f32);
-
+                    let single_indent_width =
+                        self.column_pixels(indent_guide.tab_size as usize, cx);
+                    let total_width = single_indent_width * indent_guide.depth as f32;
                     let start_x = content_origin.x + total_width - scroll_pixel_position.x;
                     if start_x >= text_origin.x {
                         let (offset_y, length) = Self::calculate_indent_guide_bounds(
@@ -1433,7 +1433,7 @@ impl EditorElement {
                         Some(IndentGuideLayout {
                             origin: point(start_x, start_y),
                             length,
-                            indent_size,
+                            single_indent_width,
                             depth: indent_guide.depth,
                             active: active_indent_guide_indices.contains(&i),
                         })
@@ -1467,6 +1467,11 @@ impl EditorElement {
         let mut offset_y = row_range.start.0 as f32 * line_height;
         let mut length = (cons_line.0.saturating_sub(row_range.start.0)) as f32 * line_height;
 
+        // If we are at the end of the buffer, ensure that the indent guide extends to the end of the line.
+        if row_range.end == cons_line {
+            length += line_height;
+        }
+
         // If there is a block (e.g. diagnostic) in between the start of the indent guide and the line above,
         // we want to extend the indent guide to the start of the block.
         let mut block_height = 0;
@@ -2637,7 +2642,7 @@ impl EditorElement {
             }
 
             if let Some(color) = background_color {
-                let width = indent_guide.indent_size - px(line_indicator_width);
+                let width = indent_guide.single_indent_width - px(line_indicator_width);
                 cx.paint_quad(fill(
                     Bounds {
                         origin: point(
@@ -5114,7 +5119,7 @@ fn layout_line(
 pub struct IndentGuideLayout {
     origin: gpui::Point<Pixels>,
     length: Pixels,
-    indent_size: Pixels,
+    single_indent_width: Pixels,
     depth: u32,
     active: bool,
 }

crates/editor/src/indent_guides.rs 🔗

@@ -4,7 +4,7 @@ use collections::HashSet;
 use gpui::{AppContext, Task};
 use language::BufferRow;
 use multi_buffer::{MultiBufferIndentGuide, MultiBufferRow};
-use text::{BufferId, Point};
+use text::{BufferId, LineIndent, Point};
 use ui::ViewContext;
 use util::ResultExt;
 
@@ -13,7 +13,7 @@ use crate::{DisplaySnapshot, Editor};
 struct ActiveIndentedRange {
     buffer_id: BufferId,
     row_range: Range<BufferRow>,
-    indent: u32,
+    indent: LineIndent,
 }
 
 #[derive(Default)]
@@ -112,7 +112,8 @@ impl Editor {
             .enumerate()
             .filter(|(_, indent_guide)| {
                 indent_guide.buffer_id == active_indent_range.buffer_id
-                    && indent_guide.indent_width() == active_indent_range.indent
+                    && indent_guide.indent_level()
+                        == active_indent_range.indent.len(indent_guide.tab_size)
             });
 
         let mut matches = HashSet::default();
@@ -189,19 +190,19 @@ fn should_recalculate_indented_range(
             return true;
         }
 
-        let (old_indent, old_is_blank) = snapshot.line_indent_for_row(prev_row.0);
-        let (new_indent, new_is_blank) = snapshot.line_indent_for_row(new_row.0);
+        let old_line_indent = snapshot.line_indent_for_row(prev_row.0);
+        let new_line_indent = snapshot.line_indent_for_row(new_row.0);
 
-        if old_is_blank
-            || new_is_blank
-            || old_indent != new_indent
+        if old_line_indent.is_line_empty()
+            || new_line_indent.is_line_empty()
+            || old_line_indent != new_line_indent
             || snapshot.max_point().row == new_row.0
         {
             return true;
         }
 
-        let (next_line_indent, next_line_is_blank) = snapshot.line_indent_for_row(new_row.0 + 1);
-        next_line_is_blank || next_line_indent != old_indent
+        let next_line_indent = snapshot.line_indent_for_row(new_row.0 + 1);
+        next_line_indent.is_line_empty() || next_line_indent != old_line_indent
     } else {
         true
     }

crates/language/src/buffer.rs 🔗

@@ -541,7 +541,7 @@ pub struct IndentGuide {
     pub start_row: BufferRow,
     pub end_row: BufferRow,
     pub depth: u32,
-    pub indent_size: u32,
+    pub tab_size: u32,
 }
 
 impl IndentGuide {
@@ -550,19 +550,19 @@ impl IndentGuide {
         start_row: BufferRow,
         end_row: BufferRow,
         depth: u32,
-        indent_size: u32,
+        tab_size: u32,
     ) -> Self {
         Self {
             buffer_id,
             start_row,
             end_row,
             depth,
-            indent_size,
+            tab_size,
         }
     }
 
-    pub fn indent_width(&self) -> u32 {
-        self.indent_size * self.depth
+    pub fn indent_level(&self) -> u32 {
+        self.depth * self.tab_size
     }
 }
 
@@ -3118,7 +3118,7 @@ impl BufferSnapshot {
         range: Range<Anchor>,
         cx: &AppContext,
     ) -> Vec<IndentGuide> {
-        fn indent_size_for_row(this: &BufferSnapshot, row: BufferRow, cx: &AppContext) -> u32 {
+        fn tab_size_for_row(this: &BufferSnapshot, row: BufferRow, cx: &AppContext) -> u32 {
             let language = this.language_at(Point::new(row, 0));
             language_settings(language, None, cx).tab_size.get() as u32
         }
@@ -3133,19 +3133,19 @@ impl BufferSnapshot {
         let mut indent_stack = SmallVec::<[IndentGuide; 8]>::new();
 
         // TODO: This should be calculated for every row but it is pretty expensive
-        let indent_size = indent_size_for_row(self, start_row, cx);
+        let tab_size = tab_size_for_row(self, start_row, cx);
 
-        while let Some((first_row, mut line_indent, empty)) = row_indents.next() {
+        while let Some((first_row, mut line_indent)) = row_indents.next() {
             let current_depth = indent_stack.len() as u32;
 
             // When encountering empty, continue until found useful line indent
             // then add to the indent stack with the depth found
             let mut found_indent = false;
             let mut last_row = first_row;
-            if empty {
+            if line_indent.is_line_empty() {
                 let mut trailing_row = end_row;
                 while !found_indent {
-                    let (target_row, new_line_indent, empty) =
+                    let (target_row, new_line_indent) =
                         if let Some(display_row) = row_indents.next() {
                             display_row
                         } else {
@@ -3160,11 +3160,11 @@ impl BufferSnapshot {
                             {
                                 break;
                             }
-                            let (new_line_indent, empty) = self.line_indent_for_row(trailing_row);
-                            (trailing_row, new_line_indent, empty)
+                            let new_line_indent = self.line_indent_for_row(trailing_row);
+                            (trailing_row, new_line_indent)
                         };
 
-                    if empty {
+                    if new_line_indent.is_line_empty() {
                         continue;
                     }
                     last_row = target_row.min(end_row);
@@ -3177,7 +3177,8 @@ impl BufferSnapshot {
             }
 
             let depth = if found_indent {
-                line_indent / indent_size + ((line_indent % indent_size) > 0) as u32
+                line_indent.len(tab_size) / tab_size
+                    + ((line_indent.len(tab_size) % tab_size) > 0) as u32
             } else {
                 current_depth
             };
@@ -3203,7 +3204,7 @@ impl BufferSnapshot {
                         start_row: first_row,
                         end_row: last_row,
                         depth: next_depth,
-                        indent_size,
+                        tab_size,
                     });
                 }
             }
@@ -3221,20 +3222,22 @@ impl BufferSnapshot {
     pub async fn enclosing_indent(
         &self,
         mut buffer_row: BufferRow,
-    ) -> Option<(Range<BufferRow>, u32)> {
+    ) -> Option<(Range<BufferRow>, LineIndent)> {
         let max_row = self.max_point().row;
         if buffer_row >= max_row {
             return None;
         }
 
-        let (mut target_indent_size, is_blank) = self.line_indent_for_row(buffer_row);
+        let mut target_indent = self.line_indent_for_row(buffer_row);
 
         // If the current row is at the start of an indented block, we want to return this
         // block as the enclosing indent.
-        if !is_blank && buffer_row < max_row {
-            let (next_line_indent, is_blank) = self.line_indent_for_row(buffer_row + 1);
-            if !is_blank && target_indent_size < next_line_indent {
-                target_indent_size = next_line_indent;
+        if !target_indent.is_line_empty() && buffer_row < max_row {
+            let next_line_indent = self.line_indent_for_row(buffer_row + 1);
+            if !next_line_indent.is_line_empty()
+                && target_indent.raw_len() < next_line_indent.raw_len()
+            {
+                target_indent = next_line_indent;
                 buffer_row += 1;
             }
         }
@@ -3246,12 +3249,12 @@ impl BufferSnapshot {
         let mut accessed_row_counter = 0;
 
         // If there is a blank line at the current row, search for the next non indented lines
-        if is_blank {
+        if target_indent.is_line_empty() {
             let start = buffer_row.saturating_sub(SEARCH_WHITESPACE_ROW_LIMIT);
             let end = (max_row + 1).min(buffer_row + SEARCH_WHITESPACE_ROW_LIMIT);
 
             let mut non_empty_line_above = None;
-            for (row, indent_size, is_blank) in self
+            for (row, indent) in self
                 .text
                 .reversed_line_indents_in_row_range(start..buffer_row)
             {
@@ -3260,30 +3263,28 @@ impl BufferSnapshot {
                     accessed_row_counter = 0;
                     yield_now().await;
                 }
-                if !is_blank {
-                    non_empty_line_above = Some((row, indent_size));
+                if !indent.is_line_empty() {
+                    non_empty_line_above = Some((row, indent));
                     break;
                 }
             }
 
             let mut non_empty_line_below = None;
-            for (row, indent_size, is_blank) in
-                self.text.line_indents_in_row_range((buffer_row + 1)..end)
-            {
+            for (row, indent) in self.text.line_indents_in_row_range((buffer_row + 1)..end) {
                 accessed_row_counter += 1;
                 if accessed_row_counter == YIELD_INTERVAL {
                     accessed_row_counter = 0;
                     yield_now().await;
                 }
-                if !is_blank {
-                    non_empty_line_below = Some((row, indent_size));
+                if !indent.is_line_empty() {
+                    non_empty_line_below = Some((row, indent));
                     break;
                 }
             }
 
-            let (row, indent_size) = match (non_empty_line_above, non_empty_line_below) {
+            let (row, indent) = match (non_empty_line_above, non_empty_line_below) {
                 (Some((above_row, above_indent)), Some((below_row, below_indent))) => {
-                    if above_indent >= below_indent {
+                    if above_indent.raw_len() >= below_indent.raw_len() {
                         (above_row, above_indent)
                     } else {
                         (below_row, below_indent)
@@ -3294,7 +3295,7 @@ impl BufferSnapshot {
                 _ => return None,
             };
 
-            target_indent_size = indent_size;
+            target_indent = indent;
             buffer_row = row;
         }
 
@@ -3302,7 +3303,7 @@ impl BufferSnapshot {
         let end = (max_row + 1).min(buffer_row + SEARCH_ROW_LIMIT);
 
         let mut start_indent = None;
-        for (row, indent_size, is_blank) in self
+        for (row, indent) in self
             .text
             .reversed_line_indents_in_row_range(start..buffer_row)
         {
@@ -3311,36 +3312,38 @@ impl BufferSnapshot {
                 accessed_row_counter = 0;
                 yield_now().await;
             }
-            if !is_blank && indent_size < target_indent_size {
-                start_indent = Some((row, indent_size));
+            if !indent.is_line_empty() && indent.raw_len() < target_indent.raw_len() {
+                start_indent = Some((row, indent));
                 break;
             }
         }
         let (start_row, start_indent_size) = start_indent?;
 
         let mut end_indent = (end, None);
-        for (row, indent_size, is_blank) in
-            self.text.line_indents_in_row_range((buffer_row + 1)..end)
-        {
+        for (row, indent) in self.text.line_indents_in_row_range((buffer_row + 1)..end) {
             accessed_row_counter += 1;
             if accessed_row_counter == YIELD_INTERVAL {
                 accessed_row_counter = 0;
                 yield_now().await;
             }
-            if !is_blank && indent_size < target_indent_size {
-                end_indent = (row.saturating_sub(1), Some(indent_size));
+            if !indent.is_line_empty() && indent.raw_len() < target_indent.raw_len() {
+                end_indent = (row.saturating_sub(1), Some(indent));
                 break;
             }
         }
         let (end_row, end_indent_size) = end_indent;
 
-        let indent_size = if let Some(end_indent_size) = end_indent_size {
-            start_indent_size.max(end_indent_size)
+        let indent = if let Some(end_indent_size) = end_indent_size {
+            if start_indent_size.raw_len() > end_indent_size.raw_len() {
+                start_indent_size
+            } else {
+                end_indent_size
+            }
         } else {
             start_indent_size
         };
 
-        Some((start_row..end_row, indent_size))
+        Some((start_row..end_row, indent))
     }
 
     /// Returns selections for remote peers intersecting the given range.

crates/language/src/buffer_tests.rs 🔗

@@ -19,7 +19,7 @@ use std::{
     time::{Duration, Instant},
 };
 use text::network::Network;
-use text::{BufferId, LineEnding};
+use text::{BufferId, LineEnding, LineIndent};
 use text::{Point, ToPoint};
 use unindent::Unindent as _;
 use util::{assert_set_eq, post_inc, test::marked_text_ranges, RandomCharIter};
@@ -2060,7 +2060,7 @@ async fn test_find_matching_indent(cx: &mut TestAppContext) {
         text: impl Into<String>,
         buffer_row: u32,
         cx: &mut TestAppContext,
-    ) -> Option<(Range<u32>, u32)> {
+    ) -> Option<(Range<u32>, LineIndent)> {
         let buffer = cx.new_model(|cx| Buffer::local(text, cx));
         let snapshot = cx.read(|cx| buffer.read(cx).snapshot());
         snapshot.enclosing_indent(buffer_row).await
@@ -2079,7 +2079,14 @@ async fn test_find_matching_indent(cx: &mut TestAppContext) {
             cx,
         )
         .await,
-        Some((1..2, 4))
+        Some((
+            1..2,
+            LineIndent {
+                tabs: 0,
+                spaces: 4,
+                line_blank: false,
+            }
+        ))
     );
 
     assert_eq!(
@@ -2095,7 +2102,14 @@ async fn test_find_matching_indent(cx: &mut TestAppContext) {
             cx,
         )
         .await,
-        Some((1..2, 4))
+        Some((
+            1..2,
+            LineIndent {
+                tabs: 0,
+                spaces: 4,
+                line_blank: false,
+            }
+        ))
     );
 
     assert_eq!(
@@ -2113,7 +2127,14 @@ async fn test_find_matching_indent(cx: &mut TestAppContext) {
             cx,
         )
         .await,
-        Some((1..4, 4))
+        Some((
+            1..4,
+            LineIndent {
+                tabs: 0,
+                spaces: 4,
+                line_blank: false,
+            }
+        ))
     );
 }
 

crates/text/src/text.rs 🔗

@@ -516,6 +516,85 @@ pub struct UndoOperation {
     pub counts: HashMap<clock::Lamport, u32>,
 }
 
+/// Stores information about the indentation of a line (tabs and spaces).
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct LineIndent {
+    pub tabs: u32,
+    pub spaces: u32,
+    pub line_blank: bool,
+}
+
+impl LineIndent {
+    /// Constructs a new `LineIndent` which only contains spaces.
+    pub fn spaces(spaces: u32) -> Self {
+        Self {
+            tabs: 0,
+            spaces,
+            line_blank: true,
+        }
+    }
+
+    /// Constructs a new `LineIndent` which only contains tabs.
+    pub fn tabs(tabs: u32) -> Self {
+        Self {
+            tabs,
+            spaces: 0,
+            line_blank: true,
+        }
+    }
+
+    /// Indicates whether the line is empty.
+    pub fn is_line_empty(&self) -> bool {
+        self.tabs == 0 && self.spaces == 0 && self.line_blank
+    }
+
+    /// Indicates whether the line is blank (contains only whitespace).
+    pub fn is_line_blank(&self) -> bool {
+        self.line_blank
+    }
+
+    /// Returns the number of indentation characters (tabs or spaces).
+    pub fn raw_len(&self) -> u32 {
+        self.tabs + self.spaces
+    }
+
+    /// Returns the number of indentation characters (tabs or spaces), taking tab size into account.
+    pub fn len(&self, tab_size: u32) -> u32 {
+        self.tabs * tab_size + self.spaces
+    }
+}
+
+impl From<&str> for LineIndent {
+    fn from(value: &str) -> Self {
+        Self::from_iter(value.chars())
+    }
+}
+
+impl FromIterator<char> for LineIndent {
+    fn from_iter<T: IntoIterator<Item = char>>(chars: T) -> Self {
+        let mut tabs = 0;
+        let mut spaces = 0;
+        let mut line_blank = true;
+        for c in chars {
+            if c == '\t' {
+                tabs += 1;
+            } else if c == ' ' {
+                spaces += 1;
+            } else {
+                if c != '\n' {
+                    line_blank = false;
+                }
+                break;
+            }
+        }
+        Self {
+            tabs,
+            spaces,
+            line_blank,
+        }
+    }
+}
+
 impl Buffer {
     pub fn new(replica_id: u16, remote_id: BufferId, mut base_text: String) -> Buffer {
         let line_ending = LineEnding::detect(&base_text);
@@ -1868,7 +1947,7 @@ impl BufferSnapshot {
     pub fn line_indents_in_row_range(
         &self,
         row_range: Range<u32>,
-    ) -> impl Iterator<Item = (u32, u32, bool)> + '_ {
+    ) -> impl Iterator<Item = (u32, LineIndent)> + '_ {
         let start = Point::new(row_range.start, 0).to_offset(self);
         let end = Point::new(row_range.end, 0).to_offset(self);
 
@@ -1876,20 +1955,9 @@ impl BufferSnapshot {
         let mut row = row_range.start;
         std::iter::from_fn(move || {
             if let Some(line) = lines.next() {
-                let mut indent_size = 0;
-                let mut is_blank = true;
-
-                for c in line.chars() {
-                    is_blank = false;
-                    if c == ' ' || c == '\t' {
-                        indent_size += 1;
-                    } else {
-                        break;
-                    }
-                }
-
+                let indent = LineIndent::from(line);
                 row += 1;
-                Some((row - 1, indent_size, is_blank))
+                Some((row - 1, indent))
             } else {
                 None
             }
@@ -1899,7 +1967,7 @@ impl BufferSnapshot {
     pub fn reversed_line_indents_in_row_range(
         &self,
         row_range: Range<u32>,
-    ) -> impl Iterator<Item = (u32, u32, bool)> + '_ {
+    ) -> impl Iterator<Item = (u32, LineIndent)> + '_ {
         let start = Point::new(row_range.start, 0).to_offset(self);
         let end = Point::new(row_range.end, 0)
             .to_offset(self)
@@ -1909,41 +1977,17 @@ impl BufferSnapshot {
         let mut row = row_range.end;
         std::iter::from_fn(move || {
             if let Some(line) = lines.next() {
-                let mut indent_size = 0;
-                let mut is_blank = true;
-
-                for c in line.chars() {
-                    is_blank = false;
-                    if c == ' ' || c == '\t' {
-                        indent_size += 1;
-                    } else {
-                        break;
-                    }
-                }
-
+                let indent = LineIndent::from(line);
                 row = row.saturating_sub(1);
-                Some((row, indent_size, is_blank))
+                Some((row, indent))
             } else {
                 None
             }
         })
     }
 
-    pub fn line_indent_for_row(&self, row: u32) -> (u32, bool) {
-        let mut indent_size = 0;
-        let mut is_blank = false;
-        for c in self.chars_at(Point::new(row, 0)) {
-            if c == ' ' || c == '\t' {
-                indent_size += 1;
-            } else {
-                if c == '\n' {
-                    is_blank = true;
-                }
-                break;
-            }
-        }
-
-        (indent_size, is_blank)
+    pub fn line_indent_for_row(&self, row: u32) -> LineIndent {
+        LineIndent::from_iter(self.chars_at(Point::new(row, 0)))
     }
 
     pub fn is_line_blank(&self, row: u32) -> bool {