Repository: gleam-lang/gleam (rust)

Issue:
LSP fails to read file
**Steps to reproduce**:
- Create a new gleam project, and open it with a code editor (I have tested in vscode and zed)
- Open the `app_name.gleam` file. The LSP works normally
- Run `gleam clean` in that project
- Save the file in the editor. 

The LSP then reports an error:
```txt
An error occurred while trying to read this file:

    project/build/packages/gleam_stdlib/gleam.toml

The error message from the file IO library was:

    No such file or directory (os error 2)
```

This seems to be because it doesn't properly fetch the source files for dependency packages when saving, even if they don't exist. Running `gleam check|build|run`, or restarting the language server fixes this.

Pull request:
LS: Trigger full rebuild if build directory has gone missing
Resolves #3954

I found writing tests for this challenging. If anyone has any pointers, I'd be glad to take another go at it 😅 

You can still mess things up by deleting individual files from within the build directory. However, that seemed very difficult to solve, and also affects the compiler (in addition to LS). Since the full directory can be deleted with `gleam clean`, this seems like it's worth covering as a special case.

I also stumbled upon an invalid modification time optimization while doing it.

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b50ecb574..30b3f516e 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -80,7 +80,7 @@ jobs:
         uses: actions/checkout@v4
 
       - name: Install musl-tools incl. musl-gcc
-        uses: awalsh128/cache-apt-pkgs-action@v1.4.3
+        uses: awalsh128/cache-apt-pkgs-action@v1.5.0
         with:
           # musl-tools provide `musl-gcc` which is required for `ring` which is required for `rustls` et al.
           packages: musl-tools
diff --git a/.github/workflows/release-nightly.yaml b/.github/workflows/release-nightly.yaml
index 51fc70333..1d0034b69 100644
--- a/.github/workflows/release-nightly.yaml
+++ b/.github/workflows/release-nightly.yaml
@@ -114,7 +114,7 @@ jobs:
         id: release
         # https://github.com/softprops/action-gh-release/issues/445
         # uses: softprops/action-gh-release@v2
-        uses: softprops/action-gh-release@0bd7e8b279c9b5b36661d552472fbbfe671fe26e
+        uses: softprops/action-gh-release@ab50eebb6488051c6788d97fa95232267c6a4e23
         with:
           draft: false
           prerelease: true
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index c2a84f2ac..89bb02127 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -69,7 +69,7 @@ jobs:
         id: release
         # https://github.com/softprops/action-gh-release/issues/445
         # uses: softprops/action-gh-release@v2
-        uses: softprops/action-gh-release@0bd7e8b279c9b5b36661d552472fbbfe671fe26e
+        uses: softprops/action-gh-release@ab50eebb6488051c6788d97fa95232267c6a4e23
         with:
           draft: true
           prerelease: ${{ contains(github.ref_name, '-rc.') }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c06389cb9..30ed8df68 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -283,6 +283,33 @@
   encoder if the `gleam_json` package is installed as a dependency.
   ([Surya Rose](https://github.com/GearsDatapacks))
 
+- The language server will offer to wrap assignment or case clause values in
+  blocks. Useful when adding more expressions to an existing case clause or
+  variable assignment.
+
+  ```gleam
+  pub fn f(pokemon_type: PokemonType) {
+    case pokemon_type {
+      Water -> soak()
+      //       ^^^^^^ selecting the right-hand side of the `->` in a clause
+      Fire -> burn()
+    }
+  }
+  ```
+  Becomes
+  ```gleam
+  pub fn f(pokemon_type: PokemonType) {
+    case pokemon_type {
+      Water -> {
+        soak()
+      }
+      Fire -> burn()
+    }
+  }
+  ```
+
+  ([Matias Carlander](https://github.com/matiascr))
+
 ### Formatter
 
 ### Container images
@@ -333,6 +360,11 @@
   variants called `Object` or `Deno` defined in the module.
   ([Louis Pilfold](https://github.com/lpil))
 
+- Fixed a bug where the language server would stop working if the build
+  directory was deleted e.g. as a result of `gleam clean`.
+  ([Sakari Bergen](https://github.com/sbergen))
+
+
 ## v1.9.1 - 2025-03-10
 
 ### Formatter
diff --git a/Cargo.lock b/Cargo.lock
index 0a1887aa7..0fec53b27 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -914,9 +914,9 @@ dependencies = [
 
 [[package]]
 name = "futures"
-version = "0.3.30"
+version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0"
+checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
 dependencies = [
  "futures-channel",
  "futures-core",
@@ -945,9 +945,9 @@ checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
 
 [[package]]
 name = "futures-executor"
-version = "0.3.30"
+version = "0.3.31"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
 dependencies = [
  "futures-core",
  "futures-task",
@@ -1164,9 +1164,9 @@ dependencies = [
 
 [[package]]
 name = "globset"
-version = "0.4.14"
+version = "0.4.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
+checksum = "54a1028dfc5f5df5da8a56a73e6c153c9a9708ec57232470703592a3f18e49f5"
 dependencies = [
  "aho-corasick",
  "bstr",
@@ -1773,9 +1773,9 @@ checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
 
 [[package]]
 name = "lsp-server"
-version = "0.7.6"
+version = "0.7.7"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "248f65b78f6db5d8e1b1604b4098a28b43d21a8eb1deeca22b1c421b276c7095"
+checksum = "550446e84739dcaf6d48a4a093973850669e13e8a34d8f8d64851041be267cd9"
 dependencies = [
  "crossbeam-channel",
  "log",
@@ -2706,9 +2706,9 @@ checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
 
 [[package]]
 name = "serde"
-version = "1.0.204"
+version = "1.0.210"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12"
+checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
 dependencies = [
  "serde_derive",
 ]
@@ -2726,9 +2726,9 @@ dependencies = [
 
 [[package]]
 name = "serde_derive"
-version = "1.0.204"
+version = "1.0.210"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222"
+checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -3157,15 +3157,15 @@ dependencies = [
 
 [[package]]
 name = "toml_datetime"
-version = "0.6.6"
+version = "0.6.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
+checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
 
 [[package]]
 name = "toml_edit"
-version = "0.22.16"
+version = "0.22.20"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788"
+checksum = "583c44c02ad26b0c3f3066fe629275e50627026c51ac2e595cca4c230ce1ce1d"
 dependencies = [
  "indexmap",
  "toml_datetime",
@@ -3775,9 +3775,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
 
 [[package]]
 name = "winnow"
-version = "0.6.13"
+version = "0.6.26"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1"
+checksum = "1e90edd2ac1aa278a5c4599b1d89cf03074b610800f866d4026dc199d7929a28"
 dependencies = [
  "memchr",
 ]
diff --git a/compiler-core/src/language_server/code_action.rs b/compiler-core/src/language_server/code_action.rs
index 99a3801dc..9b3df744c 100644
--- a/compiler-core/src/language_server/code_action.rs
+++ b/compiler-core/src/language_server/code_action.rs
@@ -5977,3 +5977,179 @@ impl<'ast> ast::visit::Visit<'ast> for RemoveEchos<'ast> {
         ast::visit::visit_typed_pipeline_assignment(self, assignment);
     }
 }
+
+/// Code action to wrap assignment and case clause values in a block.
+///
+/// ```gleam
+/// pub type PokemonType {
+///   Fire
+///   Water
+/// }
+///
+/// pub fn main() {
+///   let pokemon_type: PokemonType = todo
+///   case pokemon_type {
+///     Water -> soak()
+///              ^^^^^^ Cursor over the spread
+///     Fire -> burn()
+///   }
+/// }
+/// ```
+/// Becomes
+/// ```gleam
+/// pub type PokemonType {
+///   Fire
+///   Water
+/// }
+///
+/// pub fn main() {
+///   let pokemon_type: PokemonType = todo
+///   case pokemon_type {
+///     Water -> {
+///       soak()
+///     }
+///     Fire -> burn()
+///   }
+/// }
+/// ```
+///
+pub struct WrapInBlock<'a> {
+    module: &'a Module,
+    params: &'a CodeActionParams,
+    edits: TextEdits<'a>,
+    selected_expression: Option<SrcSpan>,
+}
+
+impl<'a> WrapInBlock<'a> {
+    pub fn new(
+        module: &'a Module,
+        line_numbers: &'a LineNumbers,
+        params: &'a CodeActionParams,
+    ) -> Self {
+        Self {
+            module,
+            params,
+            edits: TextEdits::new(line_numbers),
+            selected_expression: None,
+        }
+    }
+
+    pub fn code_actions(mut self) -> Vec<CodeAction> {
+        self.visit_typed_module(&self.module.ast);
+
+        let Some(expr_span) = self.selected_expression else {
+            return vec![];
+        };
+
+        let Some(expr_string) = self
+            .module
+            .code
+            .get(expr_span.start as usize..(expr_span.end as usize + 1))
+        else {
+            return vec![];
+        };
+
+        let range = self
+            .edits
+            .src_span_to_lsp_range(self.selected_expression.expect("Real range value"));
+
+        let line_start = *self
+            .edits
+            .line_numbers
+            .line_starts
+            .get(range.start.line as usize)
+            .expect("Line number should be valid");
+        let chars = self.module.code.chars();
+        let mut chars = chars.skip(line_start as usize);
+
+        // Count indentation
+        let mut indent_size = 0;
+        while chars.next() == Some(' ') {
+            indent_size += 1;
+        }
+        let expr_indent_size = indent_size + 2;
+
+        let indent = " ".repeat(indent_size);
+        let inner_indent = " ".repeat(expr_indent_size);
+
+        self.edits.replace(
+            expr_span,
+            format!("{{\n{inner_indent}{expr_string}{indent}}}"),
+        );
+
+        let mut action = Vec::with_capacity(1);
+        CodeActionBuilder::new("Wrap in block")
+            .kind(CodeActionKind::REFACTOR_EXTRACT)
+            .changes(self.params.text_document.uri.clone(), self.edits.edits)
+            .preferred(false)
+            .push_to(&mut action);
+        action
+    }
+}
+
+impl<'ast> ast::visit::Visit<'ast> for WrapInBlock<'ast> {
+    fn visit_typed_assignment(&mut self, assignment: &'ast TypedAssignment) {
+        ast::visit::visit_typed_expr(self, &assignment.value);
+        if !within(
+            self.params.range,
+            self.edits
+                .src_span_to_lsp_range(assignment.value.location()),
+        ) {
+            return;
+        }
+        match *assignment.to_owned().value {
+            // To avoid wrapping the same expression in multiple, nested blocks.
+            TypedExpr::Block { .. } => {}
+            TypedExpr::RecordAccess {
+                record, location, ..
+            } => {
+                self.selected_expression = Some(SrcSpan {
+                    start: record.location().start,
+                    end: location.end,
+                });
+            }
+            TypedExpr::Int { .. }
+            | TypedExpr::Float { .. }
+            | TypedExpr::String { .. }
+            | TypedExpr::Pipeline { .. }
+            | TypedExpr::Var { .. }
+            | TypedExpr::Fn { .. }
+            | TypedExpr::List { .. }
+            | TypedExpr::Call { .. }
+            | TypedExpr::BinOp { .. }
+            | TypedExpr::Case { .. }
+            | TypedExpr::ModuleSelect { .. }
+            | TypedExpr::Tuple { .. }
+            | TypedExpr::TupleIndex { .. }
+            | TypedExpr::Todo { .. }
+            | TypedExpr::Panic { .. }
+            | TypedExpr::Echo { .. }
+            | TypedExpr::BitArray { .. }
+            | TypedExpr::RecordUpdate { .. }
+            | TypedExpr::NegateBool { .. }
+            | TypedExpr::NegateInt { .. }
+            | TypedExpr::Invalid { .. } => {
+                self.selected_expression = Some(assignment.value.location());
+            }
+        };
+        ast::visit::visit_typed_assignment(self, assignment);
+    }
+
+    fn visit_typed_clause(&mut self, clause: &'ast ast::TypedClause) {
+        ast::visit::visit_typed_clause(self, clause);
+
+        if !within(
+            self.params.range,
+            self.edits.src_span_to_lsp_range(clause.then.location()),
+        ) {
+            return;
+        }
+
+        // To avoid wrapping the same expression in multiple, nested blocks.
+        if !matches!(clause.then, TypedExpr::Block { .. }) {
+            self.selected_expression = Some(clause.then.location());
+        };
+
+        ast::visit::visit_typed_clause(self, clause);
+    }
+}
diff --git a/compiler-core/src/language_server/engine.rs b/compiler-core/src/language_server/engine.rs
index 9077e0dd6..13047f9e3 100644
--- a/compiler-core/src/language_server/engine.rs
+++ b/compiler-core/src/language_server/engine.rs
@@ -39,7 +39,8 @@ use super::{
         FillInMissingLabelledArgs, FillUnusedFields, GenerateDynamicDecoder, GenerateFunction,
         GenerateJsonEncoder, InlineVariable, InterpolateString, LetAssertToCase,
         PatternMatchOnValue, RedundantTupleInCaseSubject, RemoveEchos, UseLabelShorthandSyntax,
-        code_action_add_missing_patterns, code_action_convert_qualified_constructor_to_unqualified,
+        WrapInBlock, code_action_add_missing_patterns,
+        code_action_convert_qualified_constructor_to_unqualified,
         code_action_convert_unqualified_constructor_to_qualified, code_action_import_module,
         code_action_inexhaustive_let_to_case,
     },
@@ -399,6 +400,7 @@ where
                 PatternMatchOnValue::new(module, &lines, &params, &this.compiler).code_actions(),
             );
             actions.extend(InlineVariable::new(module, &lines, &params).code_actions());
+            actions.extend(WrapInBlock::new(module, &lines, &params).code_actions());
             GenerateDynamicDecoder::new(module, &lines, &params, &mut actions).code_actions();
             GenerateJsonEncoder::new(
                 module,
diff --git a/compiler-core/src/language_server/router.rs b/compiler-core/src/language_server/router.rs
index 8cbab2456..6cf632829 100644
--- a/compiler-core/src/language_server/router.rs
+++ b/compiler-core/src/language_server/router.rs
@@ -72,11 +72,16 @@ where
             path
         };
 
-        // If the gleam.toml has changed then discard the project as the target,
+        // If the gleam.toml has changed or the build directory is missing
+        // (e.g. `gleam clean`), then discard the project as the target,
         // deps, etc may have changed and we need to rebuild taking them into
         // account.
         if let Some(project) = self.engines.get(&path) {
-            if Self::gleam_toml_changed(&path, project, &self.io)? {
+            let paths = ProjectPaths::new(path.clone());
+
+            if !self.io.exists(&paths.build_directory())
+                || Self::gleam_toml_changed(&paths, project, &self.io)?
+            {
                 let _ = self.engines.remove(&path);
             }
         }
@@ -94,16 +99,15 @@ where
 
     /// Has gleam.toml changed since the last time we saw this project?
     fn gleam_toml_changed(
-        path: &Utf8PathBuf,
+        paths: &ProjectPaths,
         project: &Project<IO, Reporter>,
         io: &FileSystemProxy<IO>,
     ) -> Result<bool, Error> {
         // Get the location of gleam.toml for this project
-        let paths = ProjectPaths::new(path.clone());
         let config_path = paths.root_config();
 
         // See if the file modification time has changed.
-        if io.modification_time(path)? == project.gleam_toml_modification_time {
+        if io.modification_time(&config_path)? == project.gleam_toml_modification_time {
             return Ok(false); // Not changed
         }
 


Test patch:
diff --git a/compiler-core/src/language_server/tests.rs b/compiler-core/src/language_server/tests.rs
index e8172df15..ef5a9d742 100644
--- a/compiler-core/src/language_server/tests.rs
+++ b/compiler-core/src/language_server/tests.rs
@@ -6,6 +6,7 @@ mod document_symbols;
 mod hover;
 mod reference;
 mod rename;
+mod router;
 mod signature_help;
 
 use std::{
diff --git a/compiler-core/src/language_server/tests/action.rs b/compiler-core/src/language_server/tests/action.rs
index 9eaf5aaa2..48a3f7b0e 100644
--- a/compiler-core/src/language_server/tests/action.rs
+++ b/compiler-core/src/language_server/tests/action.rs
@@ -82,6 +82,7 @@ const CONVERT_TO_PIPE: &str = "Convert to pipe";
 const INTERPOLATE_STRING: &str = "Interpolate string";
 const FILL_UNUSED_FIELDS: &str = "Fill unused fields";
 const REMOVE_ALL_ECHOS_FROM_THIS_MODULE: &str = "Remove all `echo`s from this module";
+const WRAP_IN_BLOCK: &str = "Wrap in block";
 
 macro_rules! assert_code_action {
     ($title:expr, $code:literal, $range:expr $(,)?) => {
@@ -7177,3 +7178,191 @@ pub fn main() {
         find_position_of("c1,").to_selection()
     );
 }
+
+#[test]
+fn wrap_case_clause_in_block() {
+    assert_code_action!(
+        WRAP_IN_BLOCK,
+        "
+pub fn f(option) {
+  case option {
+    Some(content) -> content
+    None -> panic
+  }
+}",
+        find_position_of("content").nth_occurrence(2).to_selection()
+    );
+}
+
+#[test]
+fn wrap_nested_case_clause_in_block() {
+    assert_code_action!(
+        WRAP_IN_BLOCK,
+        "
+pub fn f(result) {
+  case result {
+    Ok(reresult) -> {
+      case reresult {
+        Ok(w) -> w
+        Error(_) -> panic
+      }
+    }
+    Error(_) -> panic
+  }
+}",
+        find_position_of("w").nth_occurrence(2).to_selection()
+    );
+}
+
+#[test]
+fn wrap_case_clause_with_guard_in_block() {
+    assert_code_action!(
+        WRAP_IN_BLOCK,
+        "
+pub fn f(option) {
+  case option {
+    Some(integer) if integer > 0 -> integer
+    Some(integer) -> 0
+    None -> panic
+  }
+}",
+        find_position_of("integer").nth_occurrence(3).to_selection()
+    );
+}
+
+#[test]
+fn wrap_case_clause_with_multiple_patterns_in_block() {
+    assert_code_action!(
+        WRAP_IN_BLOCK,
+        "pub type PokemonType {
+  Air
+  Water
+  Fire
+}
+        
+  pub fn f(pokemon_type: PokemonType) {
+    case pokemon_type {
+      Water | Air -> soak()
+      Fire -> burn()
+    }
+  }",
+        find_position_of("soak").to_selection()
+    );
+}
+
+#[test]
+fn wrap_case_clause_inside_assignment_in_block() {
+    assert_code_action!(
+        WRAP_IN_BLOCK,
+        r#"pub type PokemonType {
+  Air
+  Water
+  Fire
+}
+        
+  pub fn f(pokemon_type: PokemonType) {
+    let damage = case pokemon_type {
+      Water -> soak()
+      Fire -> burn()
+    }
+
+    "Pokemon did " <> damage
+  }"#,
+        find_position_of("burn").to_selection()
+    );
+}
+
+#[test]
+fn wrap_case_assignment_of_record_access_in_block() {
+    assert_code_action!(
+        WRAP_IN_BLOCK,
+        r#"
+pub type Record {
+  R(left: Int, right: Int)
+}
+
+pub fn main() {
+  let r = R(1, 2)
+  let l = r.left
+  l
+}
+"#,
+        find_position_of("left").nth_occurrence(2).to_selection()
+    );
+}
+
+#[test]
+fn do_not_wrap_case_clause_in_block_1() {
+    assert_no_code_actions!(
+        WRAP_IN_BLOCK,
+        "
+pub fn f(option) {
+  case option {
+    Some(content) -> {
+      content
+    }
+    None -> panic
+  }
+}",
+        find_position_of("content").nth_occurrence(2).to_selection()
+    );
+}
+
+#[test]
+fn do_not_wrap_case_clause_in_block_2() {
+    assert_no_code_actions!(
+        WRAP_IN_BLOCK,
+        "
+pub fn f(result) {
+  case result {
+    Ok(reresult) -> {
+      case reresult {
+        Ok(w) -> {
+          w
+        }
+        Error(_) -> panic
+      }
+    }
+    Error(_) -> panic
+  }
+}",
+        find_position_of("w").nth_occurrence(2).to_selection()
+    );
+}
+
+#[test]
+fn do_not_wrap_case_clause_in_block_3() {
+    assert_no_code_actions!(
+        WRAP_IN_BLOCK,
+        "
+pub fn f(option) {
+  case option {
+    Some(content) -> content
+    None -> panic
+  }
+}",
+        find_position_of("Some(content)").to_selection()
+    );
+}
+
+#[test]
+fn wrap_assignment_value_in_block() {
+    assert_code_action!(
+        WRAP_IN_BLOCK,
+        r#"pub fn main() {
+  let var = "value"
+}"#,
+        find_position_of("value").select_until(find_position_of("e").nth_occurrence(2))
+    );
+}
+
+#[test]
+fn do_not_wrap_assignment_value_in_block() {
+    assert_no_code_actions!(
+        WRAP_IN_BLOCK,
+        r#"pub fn main() {
+  let var = "value"
+}"#,
+        find_position_of("var").to_selection()
+    );
+}
diff --git a/compiler-core/src/language_server/tests/router.rs b/compiler-core/src/language_server/tests/router.rs
new file mode 100644
index 000000000..81772833c
--- /dev/null
+++ b/compiler-core/src/language_server/tests/router.rs
@@ -0,0 +1,121 @@
+use std::time::SystemTime;
+
+use crate::{
+    Error,
+    io::FileSystemWriter,
+    language_server::{files::FileSystemProxy, tests::Action},
+    paths::ProjectPaths,
+};
+
+use super::LanguageServerTestIO;
+
+type Router = crate::language_server::router::Router<LanguageServerTestIO, LanguageServerTestIO>;
+
+#[test]
+fn recompile_after_no_changes_does_not_redownload_dependencies() {
+    let paths = ProjectPaths::new("/app".into());
+    let (io, mut router) = set_up_minimal_router(&paths);
+
+    assert_eq!(
+        compile(&mut router, &paths),
+        Ok(()),
+        "Pre-condition: Initial compile should succeed"
+    );
+
+    {
+        let mut actions = io.actions.lock().unwrap();
+        assert!(
+            actions.contains(&Action::DownloadDependencies),
+            "Expectation: Initial compile should download dependencies"
+        );
+        actions.clear();
+    }
+
+    assert_eq!(
+        compile(&mut router, &paths),
+        Ok(()),
+        "Recompile should succeed"
+    );
+
+    {
+        let actions = io.actions.lock().unwrap();
+        assert!(
+            !actions.contains(&Action::DownloadDependencies),
+            "Recompile should not re-download dependencies"
+        );
+    }
+}
+
+#[test]
+fn deleting_build_dir_redownloads_dependencies() {
+    let paths = ProjectPaths::new("/app".into());
+    let (io, mut router) = set_up_minimal_router(&paths);
+
+    _ = compile(&mut router, &paths);
+    io.actions.lock().unwrap().clear();
+
+    io.delete_directory(&paths.build_directory()).unwrap();
+    assert_eq!(
+        compile(&mut router, &paths),
+        Ok(()),
+        "Compile after deleting build directory should succeed"
+    );
+
+    {
+        let actions = io.actions.lock().unwrap();
+        assert!(
+            actions.contains(&Action::DownloadDependencies),
+            "Compile after deleting build directory should re-download dependencies"
+        );
+    }
+}
+
+#[test]
+fn changing_config_redownloads_dependencies() {
+    let paths = ProjectPaths::new("/app".into());
+    let (io, mut router) = set_up_minimal_router(&paths);
+
+    _ = compile(&mut router, &paths);
+    io.actions.lock().unwrap().clear();
+
+    let toml = r#"name = "wobble"
+    version = "1.0.0""#;
+    io.write(&paths.root_config(), toml).unwrap();
+    io.io
+        .set_modification_time(&paths.root_config(), SystemTime::now());
+
+    assert_eq!(
+        compile(&mut router, &paths),
+        Ok(()),
+        "Compile after changing gleam.toml should succeed"
+    );
+
+    {
+        let actions = io.actions.lock().unwrap();
+        assert!(
+            actions.contains(&Action::DownloadDependencies),
+            "Compile after changing gleam.toml should re-download dependencies"
+        );
+    }
+}
+
+fn compile(router: &mut Router, paths: &ProjectPaths) -> Result<(), Error> {
+    router
+        .project_for_path(paths.root().into())
+        .unwrap()
+        .unwrap()
+        .engine
+        .compile_please()
+        .result
+}
+
+fn set_up_minimal_router(paths: &ProjectPaths) -> (LanguageServerTestIO, Router) {
+    let io = LanguageServerTestIO::new();
+    let router = Router::new(io.clone(), FileSystemProxy::new(io.clone()));
+
+    let toml = r#"name = "wibble"
+    version = "1.0.0""#;
+
+    io.write(&paths.root_config(), toml).unwrap();
+    (io, router)
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_assignment_value_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_assignment_value_in_block.snap
new file mode 100644
index 000000000..f77a2d8b3
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_assignment_value_in_block.snap
@@ -0,0 +1,17 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let var = \"value\"\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let var = "value"
+             ▔▔▔▔↑ 
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let var = {
+    "value"
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_assignment_of_record_access_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_assignment_of_record_access_in_block.snap
new file mode 100644
index 000000000..c90895662
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_assignment_of_record_access_in_block.snap
@@ -0,0 +1,31 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\npub type Record {\n  R(left: Int, right: Int)\n}\n\npub fn main() {\n  let r = R(1, 2)\n  let l = r.left\n  l\n}\n"
+---
+----- BEFORE ACTION
+
+pub type Record {
+  R(left: Int, right: Int)
+}
+
+pub fn main() {
+  let r = R(1, 2)
+  let l = r.left
+            ↑   
+  l
+}
+
+
+----- AFTER ACTION
+
+pub type Record {
+  R(left: Int, right: Int)
+}
+
+pub fn main() {
+  let r = R(1, 2)
+  let l = {
+    r.left
+  }
+  l
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_in_block.snap
new file mode 100644
index 000000000..fa68ec2f4
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_in_block.snap
@@ -0,0 +1,25 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\npub fn f(option) {\n  case option {\n    Some(content) -> content\n    None -> panic\n  }\n}"
+---
+----- BEFORE ACTION
+
+pub fn f(option) {
+  case option {
+    Some(content) -> content
+                     ↑      
+    None -> panic
+  }
+}
+
+
+----- AFTER ACTION
+
+pub fn f(option) {
+  case option {
+    Some(content) -> {
+      content
+    }
+    None -> panic
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_inside_assignment_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_inside_assignment_in_block.snap
new file mode 100644
index 000000000..0ad56dbb5
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_inside_assignment_in_block.snap
@@ -0,0 +1,39 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub type PokemonType {\n  Air\n  Water\n  Fire\n}\n        \n  pub fn f(pokemon_type: PokemonType) {\n    let damage = case pokemon_type {\n      Water -> soak()\n      Fire -> burn()\n    }\n\n    \"Pokemon did \" <> damage\n  }"
+---
+----- BEFORE ACTION
+pub type PokemonType {
+  Air
+  Water
+  Fire
+}
+        
+  pub fn f(pokemon_type: PokemonType) {
+    let damage = case pokemon_type {
+      Water -> soak()
+      Fire -> burn()
+              ↑     
+    }
+
+    "Pokemon did " <> damage
+  }
+
+
+----- AFTER ACTION
+pub type PokemonType {
+  Air
+  Water
+  Fire
+}
+        
+  pub fn f(pokemon_type: PokemonType) {
+    let damage = case pokemon_type {
+      Water -> soak()
+      Fire -> {
+        burn()
+      }
+    }
+
+    "Pokemon did " <> damage
+  }
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_with_guard_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_with_guard_in_block.snap
new file mode 100644
index 000000000..7d996ec2c
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_with_guard_in_block.snap
@@ -0,0 +1,27 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\npub fn f(option) {\n  case option {\n    Some(integer) if integer > 0 -> integer\n    Some(integer) -> 0\n    None -> panic\n  }\n}"
+---
+----- BEFORE ACTION
+
+pub fn f(option) {
+  case option {
+    Some(integer) if integer > 0 -> integer
+                                    ↑      
+    Some(integer) -> 0
+    None -> panic
+  }
+}
+
+
+----- AFTER ACTION
+
+pub fn f(option) {
+  case option {
+    Some(integer) if integer > 0 -> {
+      integer
+    }
+    Some(integer) -> 0
+    None -> panic
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_with_multiple_patterns_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_with_multiple_patterns_in_block.snap
new file mode 100644
index 000000000..815f3fbb8
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_case_clause_with_multiple_patterns_in_block.snap
@@ -0,0 +1,35 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub type PokemonType {\n  Air\n  Water\n  Fire\n}\n        \n  pub fn f(pokemon_type: PokemonType) {\n    case pokemon_type {\n      Water | Air -> soak()\n      Fire -> burn()\n    }\n  }"
+---
+----- BEFORE ACTION
+pub type PokemonType {
+  Air
+  Water
+  Fire
+}
+        
+  pub fn f(pokemon_type: PokemonType) {
+    case pokemon_type {
+      Water | Air -> soak()
+                     ↑     
+      Fire -> burn()
+    }
+  }
+
+
+----- AFTER ACTION
+pub type PokemonType {
+  Air
+  Water
+  Fire
+}
+        
+  pub fn f(pokemon_type: PokemonType) {
+    case pokemon_type {
+      Water | Air -> {
+        soak()
+      }
+      Fire -> burn()
+    }
+  }
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_nested_case_clause_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_nested_case_clause_in_block.snap
new file mode 100644
index 000000000..4d09d2676
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__wrap_nested_case_clause_in_block.snap
@@ -0,0 +1,35 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\npub fn f(result) {\n    case result {\n        Ok(reresult) -> {\n            case reresult {\n                Ok(w) -> w\n                Error(_) -> panic\n            }\n        }\n        Error(_) -> panic\n    }\n}"
+---
+----- BEFORE ACTION
+
+pub fn f(result) {
+  case result {
+    Ok(reresult) -> {
+      case reresult {
+        Ok(w) -> w
+                 ↑
+        Error(_) -> panic
+      }
+    }
+    Error(_) -> panic
+  }
+}
+
+
+----- AFTER ACTION
+
+pub fn f(result) {
+  case result {
+    Ok(reresult) -> {
+      case reresult {
+        Ok(w) -> {
+          w
+        }
+        Error(_) -> panic
+      }
+    }
+    Error(_) -> panic
+  }
+}