Make rewrapping take tabs more into account (#20196)

Richard Feldman and Will created

Closes #18686


https://github.com/user-attachments/assets/e87b4508-3570-4395-92b4-c5e0e9e19623


Release Notes:

- The Rewrap command now considers the width of each tab character at
the beginning of the line to be the configured tab size.

---------

Co-authored-by: Will <will@zed.dev>

Change summary

crates/editor/src/editor.rs   | 102 +++++++++++++++++++++++++++---------
crates/language/src/buffer.rs |   8 ++
2 files changed, 85 insertions(+), 25 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -7008,6 +7008,8 @@ impl Editor {
                 }
             }
 
+            let tab_size = buffer.settings_at(selection.head(), cx).tab_size;
+
             // Since not all lines in the selection may be at the same indent
             // level, choose the indent size that is the most common between all
             // of the lines.
@@ -7025,7 +7027,7 @@ impl Editor {
 
                 let indent_size = indent_size_occurrences
                     .into_iter()
-                    .max_by_key(|(indent, count)| (*count, indent.len))
+                    .max_by_key(|(indent, count)| (*count, indent.len_with_expanded_tabs(tab_size)))
                     .map(|(indent, _)| indent)
                     .unwrap_or_default();
                 let row = rows_by_indent_size[&indent_size][0];
@@ -7051,6 +7053,10 @@ impl Editor {
                 should_rewrap = true;
             }
 
+            if !should_rewrap {
+                continue;
+            }
+
             if selection.is_empty() {
                 'expand_upwards: while start_row > 0 {
                     let prev_row = start_row - 1;
@@ -7075,10 +7081,6 @@ impl Editor {
                 }
             }
 
-            if !should_rewrap {
-                continue;
-            }
-
             let start = Point::new(start_row, 0);
             let end = Point::new(end_row, buffer.line_len(MultiBufferRow(end_row)));
             let selection_text = buffer.text_for_range(start..end).collect::<String>();
@@ -7097,29 +7099,15 @@ impl Editor {
                 continue;
             };
 
-            let unwrapped_text = lines_without_prefixes.join(" ");
             let wrap_column = buffer
                 .settings_at(Point::new(start_row, 0), cx)
                 .preferred_line_length as usize;
-            let mut wrapped_text = String::new();
-            let mut current_line = line_prefix.clone();
-            for word in unwrapped_text.split_whitespace() {
-                if current_line.len() + word.len() >= wrap_column {
-                    wrapped_text.push_str(&current_line);
-                    wrapped_text.push('\n');
-                    current_line.truncate(line_prefix.len());
-                }
-
-                if current_line.len() > line_prefix.len() {
-                    current_line.push(' ');
-                }
-
-                current_line.push_str(word);
-            }
-
-            if !current_line.is_empty() {
-                wrapped_text.push_str(&current_line);
-            }
+            let wrapped_text = wrap_with_prefix(
+                line_prefix,
+                lines_without_prefixes.join(" "),
+                wrap_column,
+                tab_size,
+            );
 
             let diff = TextDiff::from_lines(&selection_text, &wrapped_text);
             let mut offset = start.to_offset(&buffer);
@@ -13056,6 +13044,70 @@ impl Editor {
     }
 }
 
+fn len_with_expanded_tabs(offset: usize, comment_prefix: &str, tab_size: NonZeroU32) -> usize {
+    let tab_size = tab_size.get() as usize;
+    let mut width = offset;
+
+    for c in comment_prefix.chars() {
+        width += if c == '\t' {
+            tab_size - (width % tab_size)
+        } else {
+            1
+        };
+    }
+
+    width - offset
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_string_size_with_expanded_tabs() {
+        let nz = |val| NonZeroU32::new(val).unwrap();
+        assert_eq!(len_with_expanded_tabs(0, "", nz(4)), 0);
+        assert_eq!(len_with_expanded_tabs(0, "hello", nz(4)), 5);
+        assert_eq!(len_with_expanded_tabs(0, "\thello", nz(4)), 9);
+        assert_eq!(len_with_expanded_tabs(0, "abc\tab", nz(4)), 6);
+        assert_eq!(len_with_expanded_tabs(0, "hello\t", nz(4)), 8);
+        assert_eq!(len_with_expanded_tabs(0, "\t\t", nz(8)), 16);
+        assert_eq!(len_with_expanded_tabs(0, "x\t", nz(8)), 8);
+        assert_eq!(len_with_expanded_tabs(7, "x\t", nz(8)), 9);
+    }
+}
+
+fn wrap_with_prefix(
+    line_prefix: String,
+    unwrapped_text: String,
+    wrap_column: usize,
+    tab_size: NonZeroU32,
+) -> String {
+    let line_prefix_display_len = len_with_expanded_tabs(0, &line_prefix, tab_size);
+    let mut wrapped_text = String::new();
+    let mut current_line = line_prefix.to_string();
+    let prefix_extra_chars = line_prefix_display_len - line_prefix.len();
+
+    for word in unwrapped_text.split_whitespace() {
+        if current_line.len() + prefix_extra_chars + word.len() >= wrap_column {
+            wrapped_text.push_str(&current_line);
+            wrapped_text.push('\n');
+            current_line.truncate(line_prefix.len());
+        }
+
+        if current_line.len() > line_prefix.len() {
+            current_line.push(' ');
+        }
+
+        current_line.push_str(word);
+    }
+
+    if !current_line.is_empty() {
+        wrapped_text.push_str(&current_line);
+    }
+    wrapped_text
+}
+
 fn hunks_for_selections(
     multi_buffer_snapshot: &MultiBufferSnapshot,
     selections: &[Selection<Anchor>],

crates/language/src/buffer.rs 🔗

@@ -46,6 +46,7 @@ use std::{
     future::Future,
     iter::{self, Iterator, Peekable},
     mem,
+    num::NonZeroU32,
     ops::{Deref, DerefMut, Range},
     path::{Path, PathBuf},
     str,
@@ -4325,6 +4326,13 @@ impl IndentSize {
         }
         self
     }
+
+    pub fn len_with_expanded_tabs(&self, tab_size: NonZeroU32) -> usize {
+        match self.kind {
+            IndentKind::Space => self.len as usize,
+            IndentKind::Tab => self.len as usize * tab_size.get() as usize,
+        }
+    }
 }
 
 #[cfg(any(test, feature = "test-support"))]