Repository: gleam-lang/gleam (2026-03-29T15:44:56Z)

Issue:
"Add missing patterns" code action removes comments in `case` statement
I refactored some code and wanted to generate the correct variants in an outdated case statements.
I commented out the old variant code paths so I could copy paste some of the code accordingly in the new ones, but the code action removed the comments.

For example, if you trigger the code action in the following code, the comment `// wibble wobble` will be removed. I think we should keep comments in those blocks.

```gleam
pub fn main() -> Nil {
  case [] {
    
    // wibble wobble
  }
}
```

Gleam v1.15.0


Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9ac9537098e..16d885dc176 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -49,7 +49,8 @@
 
   ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
 
-- The compiler now emits a helpful error message when source code contains an invalid unicode character that looks similar to a correct character.
+- The compiler now emits a helpful error message when source code contains an
+  invalid unicode character that looks similar to a correct character.
 
   ```
   error: Syntax error
@@ -61,6 +62,7 @@
   This looks like ascii comma, but it is actually the unicode low single
   comma quotation mark.
   ```
+
   ([Louis Pilfold](https://github.com/lpil))
 
 - The compiler now emits more efficient code when matching on single-character
@@ -112,7 +114,7 @@
   dependencies every single time it is run.
   ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
 
-- The build tool will now suggest to create a module in dev` or `test`
+- The build tool will now suggest to create a module in the `dev` or `test`
   directory, if that missing module is a dev module or a test module
   respectively.
   ([Andrey Kozhev](https://github.com/ankddev))
@@ -227,3 +229,7 @@
 - Fixed a bug where the "Add missing type parameter" code action could be
   triggered on types that do not exist instead of type variables.
   ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
+
+- Fixed a bug where the "Add missing patterns" code action could end up deleting
+  comments inside an incomplete case expression.
+  ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
diff --git a/compiler-core/src/parse/extra.rs b/compiler-core/src/parse/extra.rs
index 47e8cf6f09b..10bebc059ec 100644
--- a/compiler-core/src/parse/extra.rs
+++ b/compiler-core/src/parse/extra.rs
@@ -40,7 +40,7 @@ impl ModuleExtra {
         self.first_comment_between(start, end).is_some()
     }
 
-    pub fn first_comment_between(&self, start: u32, end: u32) -> Option<SrcSpan> {
+    fn index_of_first_comment_between(&self, start: u32, end: u32) -> Option<usize> {
         self.comments
             .binary_search_by(|comment| {
                 if comment.end < start {
@@ -52,8 +52,38 @@ impl ModuleExtra {
                 }
             })
             .ok()
+    }
+
+    pub fn first_comment_between(&self, start: u32, end: u32) -> Option<SrcSpan> {
+        self.index_of_first_comment_between(start, end)
             .and_then(|index| self.comments.get(index).copied())
     }
+
+    pub fn last_comment_between(&self, start: u32, end: u32) -> Option<SrcSpan> {
+        // We start from the first comment that we can find in between the given
+        // start and end, this is really fast as we can find such index through
+        // binary search.
+        let mut index_of_last_comment = self.index_of_first_comment_between(start, end)?;
+
+        // Then we go over all the comments we can find from that one that are
+        // still in between the given indices.
+        loop {
+            let next_comment = self.comments.get(index_of_last_comment + 1);
+            if let Some(next_comment) = next_comment
+                && start <= next_comment.start
+                && next_comment.end <= end
+            {
+                // The next comment is still in between the two indices, we keep
+                // going.
+                index_of_last_comment += 1;
+            } else {
+                // The next comment is outside of the given range, that means
+                // the current one is the last comment we were looking for.
+                // We can return it!
+                return self.comments.get(index_of_last_comment).cloned();
+            }
+        }
+    }
 }
 
 #[derive(Debug, PartialEq, Eq)]
diff --git a/language-server/src/code_action.rs b/language-server/src/code_action.rs
index 89819e4ef37..b7a909ab3fb 100644
--- a/language-server/src/code_action.rs
+++ b/language-server/src/code_action.rs
@@ -1298,8 +1298,17 @@ pub fn code_action_add_missing_patterns(
                 }
             }
 
-            // Remove any blank spaces/lines between the start brace and end brace
-            edits.delete(SrcSpan::new(start_brace_location, insert_at));
+            // We remove any blank spaces/lines between the end of the last
+            // comment inside the case expression and the insertion point.
+            // If there's no comments we remove all empty space in between the
+            // two braces
+            let deletion_start = module
+                .extra
+                .last_comment_between(start_brace_location, insert_at)
+                .map(|src_span| src_span.end)
+                .unwrap_or(start_brace_location);
+
+            edits.delete(SrcSpan::new(deletion_start, insert_at));
             edits.insert(insert_at, format!("\n{indent}"));
         }
 


Test patch:
diff --git a/language-server/src/tests/action.rs b/language-server/src/tests/action.rs
index f60bb32bd53..0eabef7d794 100644
--- a/language-server/src/tests/action.rs
+++ b/language-server/src/tests/action.rs
@@ -2758,6 +2758,44 @@ pub fn main(bool: Bool) {
     );
 }
 
+#[test]
+fn add_missing_patterns_does_not_remove_comment() {
+    assert_code_action!(
+        ADD_MISSING_PATTERNS,
+        "
+pub fn main(bool: Bool) {
+  case bool {
+    // Here is some comment that should not be deleted
+  }
+}
+",
+        find_position_of("case").select_until(find_position_of("bool {"))
+    );
+}
+
+#[test]
+fn add_missing_patterns_does_not_remove_comment_2() {
+    assert_code_action!(
+        ADD_MISSING_PATTERNS,
+        "
+pub fn main(bool: Bool) {
+  case bool {
+    // Here is some comment that should not be deleted
+
+    // Here is some other separate comment!
+    // And loads of white space that has to be deleted.
+
+
+
+
+
+  }
+}
+",
+        find_position_of("case").select_until(find_position_of("bool {"))
+    );
+}
+
 #[test]
 fn add_missing_patterns_custom_type() {
     assert_code_action!(
diff --git a/language-server/src/tests/snapshots/gleam_language_server__tests__action__add_missing_patterns_does_not_remove_comment.snap b/language-server/src/tests/snapshots/gleam_language_server__tests__action__add_missing_patterns_does_not_remove_comment.snap
new file mode 100644
index 00000000000..3257ef1325b
--- /dev/null
+++ b/language-server/src/tests/snapshots/gleam_language_server__tests__action__add_missing_patterns_does_not_remove_comment.snap
@@ -0,0 +1,23 @@
+---
+source: language-server/src/tests/action.rs
+expression: "\npub fn main(bool: Bool) {\n  case bool {\n    // Here is some comment that should not be deleted\n  }\n}\n"
+---
+----- BEFORE ACTION
+
+pub fn main(bool: Bool) {
+  case bool {
+  ▔▔▔▔▔↑     
+    // Here is some comment that should not be deleted
+  }
+}
+
+
+----- AFTER ACTION
+
+pub fn main(bool: Bool) {
+  case bool {
+    // Here is some comment that should not be deleted
+    True -> todo
+    False -> todo
+  }
+}
diff --git a/language-server/src/tests/snapshots/gleam_language_server__tests__action__add_missing_patterns_does_not_remove_comment_2.snap b/language-server/src/tests/snapshots/gleam_language_server__tests__action__add_missing_patterns_does_not_remove_comment_2.snap
new file mode 100644
index 00000000000..94781381fc8
--- /dev/null
+++ b/language-server/src/tests/snapshots/gleam_language_server__tests__action__add_missing_patterns_does_not_remove_comment_2.snap
@@ -0,0 +1,34 @@
+---
+source: language-server/src/tests/action.rs
+expression: "\npub fn main(bool: Bool) {\n  case bool {\n    // Here is some comment that should not be deleted\n\n    // Here is some other separate comment!\n    // And loads of white space that has to be deleted.\n\n\n\n\n\n  }\n}\n"
+---
+----- BEFORE ACTION
+
+pub fn main(bool: Bool) {
+  case bool {
+  ▔▔▔▔▔↑     
+    // Here is some comment that should not be deleted
+
+    // Here is some other separate comment!
+    // And loads of white space that has to be deleted.
+
+
+
+
+
+  }
+}
+
+
+----- AFTER ACTION
+
+pub fn main(bool: Bool) {
+  case bool {
+    // Here is some comment that should not be deleted
+
+    // Here is some other separate comment!
+    // And loads of white space that has to be deleted.
+    True -> todo
+    False -> todo
+  }
+}
diff --git a/test-package-compiler/src/generated_tests.rs b/test-package-compiler/src/generated_tests.rs
index f09240c5c36..4bb5c5bbab6 100644
--- a/test-package-compiler/src/generated_tests.rs
+++ b/test-package-compiler/src/generated_tests.rs
@@ -166,6 +166,28 @@ fn erlang_nested_qualified_constant() {
     );
 }
 
+#[rustfmt::skip]
+#[test]
+fn errors_from_related_modules() {
+    let output = crate::prepare("./cases/errors_from_related_modules");
+    insta::assert_snapshot!(
+        "errors_from_related_modules",
+        output,
+        "./cases/errors_from_related_modules",
+    );
+}
+
+#[rustfmt::skip]
+#[test]
+fn errors_from_unrelated_modules() {
+    let output = crate::prepare("./cases/errors_from_unrelated_modules");
+    insta::assert_snapshot!(
+        "errors_from_unrelated_modules",
+        output,
+        "./cases/errors_from_unrelated_modules",
+    );
+}
+
 #[rustfmt::skip]
 #[test]
 fn hello_joe() {