Handle a file's line endings changing on disk

Max Brunsfeld created

Change summary

crates/language/src/buffer.rs       |  6 +
crates/project/src/project_tests.rs | 78 ++++++++++++++++++++++++++----
2 files changed, 72 insertions(+), 12 deletions(-)

Detailed changes

crates/language/src/buffer.rs 🔗

@@ -98,7 +98,7 @@ pub enum IndentKind {
     Tab,
 }
 
-#[derive(Copy, Clone, PartialEq, Eq)]
+#[derive(Copy, Debug, Clone, PartialEq, Eq)]
 pub enum NewlineStyle {
     Unix,
     Windows,
@@ -283,6 +283,7 @@ pub(crate) struct Diff {
     base_version: clock::Global,
     new_text: Arc<str>,
     changes: Vec<(ChangeTag, usize)>,
+    newline_style: NewlineStyle,
     start_offset: usize,
 }
 
@@ -973,6 +974,7 @@ impl Buffer {
         let base_version = self.version();
         cx.background().spawn(async move {
             let old_text = old_text.to_string();
+            let newline_style = NewlineStyle::detect(&new_text);
             let new_text = new_text.replace("\r\n", "\n").replace('\r', "\n");
             let changes = TextDiff::from_lines(old_text.as_str(), new_text.as_str())
                 .iter_all_changes()
@@ -982,6 +984,7 @@ impl Buffer {
                 base_version,
                 new_text: new_text.into(),
                 changes,
+                newline_style,
                 start_offset: 0,
             }
         })
@@ -995,6 +998,7 @@ impl Buffer {
         if self.version == diff.base_version {
             self.finalize_last_transaction();
             self.start_transaction();
+            self.newline_style = diff.newline_style;
             let mut offset = diff.start_offset;
             for (tag, len) in diff.changes {
                 let range = offset..(offset + len);

crates/project/src/project_tests.rs 🔗

@@ -2363,7 +2363,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
     fs.remove_file("/dir/file2".as_ref(), Default::default())
         .await
         .unwrap();
-    buffer2.condition(&cx, |b, _| b.is_dirty()).await;
+    cx.foreground().run_until_parked();
     assert_eq!(
         *events.borrow(),
         &[
@@ -2393,9 +2393,7 @@ async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) {
     fs.remove_file("/dir/file3".as_ref(), Default::default())
         .await
         .unwrap();
-    buffer3
-        .condition(&cx, |_, _| !events.borrow().is_empty())
-        .await;
+    cx.foreground().run_until_parked();
     assert_eq!(*events.borrow(), &[language::Event::FileHandleChanged]);
     cx.read(|cx| assert!(buffer3.read(cx).is_dirty()));
 }
@@ -2439,10 +2437,7 @@ async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
     // Because the buffer was not modified, it is reloaded from disk. Its
     // contents are edited according to the diff between the old and new
     // file contents.
-    buffer
-        .condition(&cx, |buffer, _| buffer.text() == new_contents)
-        .await;
-
+    cx.foreground().run_until_parked();
     buffer.update(cx, |buffer, _| {
         assert_eq!(buffer.text(), new_contents);
         assert!(!buffer.is_dirty());
@@ -2476,9 +2471,70 @@ async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) {
 
     // Because the buffer is modified, it doesn't reload from disk, but is
     // marked as having a conflict.
-    buffer
-        .condition(&cx, |buffer, _| buffer.has_conflict())
-        .await;
+    cx.foreground().run_until_parked();
+    buffer.read_with(cx, |buffer, _| {
+        assert!(buffer.has_conflict());
+    });
+}
+
+#[gpui::test]
+async fn test_buffer_line_endings(cx: &mut gpui::TestAppContext) {
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            "file1": "a\nb\nc\n",
+            "file2": "one\r\ntwo\r\nthree\r\n",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+    let buffer1 = project
+        .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx))
+        .await
+        .unwrap();
+    let buffer2 = project
+        .update(cx, |p, cx| p.open_local_buffer("/dir/file2", cx))
+        .await
+        .unwrap();
+
+    buffer1.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.text(), "a\nb\nc\n");
+        assert_eq!(buffer.newline_style(), NewlineStyle::Unix);
+    });
+    buffer2.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.text(), "one\ntwo\nthree\n");
+        assert_eq!(buffer.newline_style(), NewlineStyle::Windows);
+    });
+
+    // Change a file's line endings on disk from unix to windows. The buffer's
+    // state updates correctly.
+    fs.save(
+        "/dir/file1".as_ref(),
+        &"aaa\nb\nc\n".into(),
+        NewlineStyle::Windows,
+    )
+    .await
+    .unwrap();
+    cx.foreground().run_until_parked();
+    buffer1.read_with(cx, |buffer, _| {
+        assert_eq!(buffer.text(), "aaa\nb\nc\n");
+        assert_eq!(buffer.newline_style(), NewlineStyle::Windows);
+    });
+
+    // Save a file with windows line endings. The file is written correctly.
+    buffer2
+        .update(cx, |buffer, cx| {
+            buffer.set_text("one\ntwo\nthree\nfour\n", cx);
+            buffer.save(cx)
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        fs.load("/dir/file2".as_ref()).await.unwrap(),
+        "one\r\ntwo\r\nthree\r\nfour\r\n",
+    );
 }
 
 #[gpui::test]