Repository: gleam-lang/gleam (rust)

Issue:
LSP Code Action: Convert inexhaustive `let` to `case`
For example, you have this code:
```gleam
pub type Wibble {
  Wibble(wibble: Int)
}

pub fn main(wibble: Wibble) {
  let Wibble(inner) = wibble
  do_something_with(inner)
}
```
But then you change the `Wibble` type:
```gleam
pub type Wibble {
  Wibble(wibble: Int)
  Wobble(wobble: Int)
}
```
The `let` expression now fails to compile. It could be useful to have a code action to turn:
```gleam
let Wibble(inner) = wibble
```
into:
```gleam
let inner = case wibble {
  Wibble(inner) -> inner
  _ -> todo
}
```
This would be very similar to the [code action to convert](https://github.com/gleam-lang/gleam/blob/31966a0b3503d9429cc016d29b1ba88569e22fc6/compiler-core/src/language_server/code_action.rs#L299) `let assert` to `case`, except it would only be offered if the `let` expression is not exhaustive.

Pull request description:
Implement code action to convert inexhaustive `let` to `case`
Closes #3868 


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3b2d51c3..5d214068d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -111,6 +111,29 @@
 
 - The language server now provides correct information when hovering over
   patterns in use expressions.
+
+- The language server now suggests a code action to convert an inexhaustive
+  `let` assignment into a `case` expression:
+
+  ```gleam
+  pub fn unwrap_result(result: Result(a, b)) -> a {
+    let Ok(inner) = result
+    inner
+  }
+  ```
+
+  Becomes:
+
+  ```gleam
+  pub fn unwrap_result(result: Result(a, b)) -> a {
+    let inner = case result {
+      Ok(inner) -> inner
+      Error(_) -> todo
+    }
+    inner
+  }
+  ```
+
   ([Surya Rose](https://github.com/GearsDatapacks))
 
 ### Formatter
diff --git a/compiler-core/src/language_server/code_action.rs b/compiler-core/src/language_server/code_action.rs
index c287b9eff..1f17ce8d2 100644
--- a/compiler-core/src/language_server/code_action.rs
+++ b/compiler-core/src/language_server/code_action.rs
@@ -7,7 +7,8 @@ use crate::{
             visit_typed_call_arg, visit_typed_expr_call, visit_typed_pattern_call_arg, Visit as _,
         },
         AssignName, AssignmentKind, CallArg, FunctionLiteralKind, ImplicitCallArgOrigin, Pattern,
-        SrcSpan, TypedExpr, TypedModuleConstant, TypedPattern, TypedUse,
+        SrcSpan, TypedAssignment, TypedExpr, TypedModuleConstant, TypedPattern, TypedStatement,
+        TypedUse,
     },
     build::{Located, Module},
     line_numbers::LineNumbers,
@@ -319,11 +320,10 @@ pub struct LetAssertToCase<'a> {
     params: &'a CodeActionParams,
     actions: Vec<CodeAction>,
     edits: TextEdits<'a>,
-    pattern_variables: Vec<EcoString>,
 }
 
 impl<'ast> ast::visit::Visit<'ast> for LetAssertToCase<'_> {
-    fn visit_typed_assignment(&mut self, assignment: &'ast ast::TypedAssignment) {
+    fn visit_typed_assignment(&mut self, assignment: &'ast TypedAssignment) {
         // To prevent weird behaviour when `let assert` statements are nested,
         // we only check for the code action between the `let` and `=`.
         let code_action_location =
@@ -368,12 +368,9 @@ impl<'ast> ast::visit::Visit<'ast> for LetAssertToCase<'_> {
         });
 
         let range = src_span_to_lsp_range(assignment.location, self.edits.line_numbers);
-        let indent = " ".repeat(range.start.character as usize);
 
         // Figure out which variables are assigned in the pattern
-        self.pattern_variables.clear();
-        self.visit_typed_pattern(&assignment.pattern);
-        let variables = std::mem::take(&mut self.pattern_variables);
+        let variables = PatternVariableFinder::find_variables_in_pattern(&assignment.pattern);
 
         let assigned = match variables.len() {
             0 => "_",
@@ -381,29 +378,76 @@ impl<'ast> ast::visit::Visit<'ast> for LetAssertToCase<'_> {
             _ => &format!("#({})", variables.join(", ")),
         };
 
-        let edit = TextEdit {
-            range,
-            new_text: format!(
-                "let {assigned} = case {expr} {{
-{indent}  {pattern} -> {value}
-{indent}  _ -> panic{message_join}{message}
-{indent}}}",
-                // "_" isn't a valid expression, so we just return Nil from the case expression
-                value = if assigned == "_" { "Nil" } else { assigned },
-                message_join = if message.is_some() { " as " } else { "" },
-                message = message.unwrap_or("")
-            ),
+        let mut new_text = format!("let {assigned} = ");
+        let panic_message = if let Some(message) = message {
+            &format!("panic as {message}")
+        } else {
+            "panic"
         };
+        let clauses = vec![
+            // The existing pattern
+            CaseClause {
+                pattern,
+                // `_` is not a valid expression, so if we are not
+                // binding any variables in the pattern, we simply return Nil.
+                expression: if assigned == "_" { "Nil" } else { assigned },
+            },
+            CaseClause {
+                pattern: "_",
+                expression: panic_message,
+            },
+        ];
+        print_case_expression(range.start.character as usize, expr, clauses, &mut new_text);
 
         let uri = &self.params.text_document.uri;
 
         CodeActionBuilder::new("Convert to case")
             .kind(CodeActionKind::REFACTOR)
-            .changes(uri.clone(), vec![edit])
+            .changes(uri.clone(), vec![TextEdit { range, new_text }])
             .preferred(true)
             .push_to(&mut self.actions);
     }
+}
+
+impl<'a> LetAssertToCase<'a> {
+    pub fn new(
+        module: &'a Module,
+        line_numbers: &'a LineNumbers,
+        params: &'a CodeActionParams,
+    ) -> Self {
+        Self {
+            module,
+            params,
+            actions: Vec::new(),
+            edits: TextEdits::new(line_numbers),
+        }
+    }
+
+    pub fn code_actions(mut self) -> Vec<CodeAction> {
+        self.visit_typed_module(&self.module.ast);
+        self.actions
+    }
+}
+
+struct PatternVariableFinder {
+    pattern_variables: Vec<EcoString>,
+}
 
+impl PatternVariableFinder {
+    fn new() -> Self {
+        Self {
+            pattern_variables: Vec::new(),
+        }
+    }
+
+    fn find_variables_in_pattern(pattern: &TypedPattern) -> Vec<EcoString> {
+        let mut finder = Self::new();
+        finder.visit_typed_pattern(pattern);
+        finder.pattern_variables
+    }
+}
+
+impl<'ast> ast::visit::Visit<'ast> for PatternVariableFinder {
     fn visit_typed_pattern_variable(
         &mut self,
         _location: &'ast SrcSpan,
@@ -441,25 +485,140 @@ impl<'ast> ast::visit::Visit<'ast> for LetAssertToCase<'_> {
     }
 }
 
-impl<'a> LetAssertToCase<'a> {
-    pub fn new(
-        module: &'a Module,
-        line_numbers: &'a LineNumbers,
-        params: &'a CodeActionParams,
-    ) -> Self {
-        Self {
-            module,
-            params,
-            actions: Vec::new(),
-            edits: TextEdits::new(line_numbers),
-            pattern_variables: Vec::new(),
+pub fn code_action_inexhaustive_let_to_case(
+    module: &Module,
+    line_numbers: &LineNumbers,
+    params: &CodeActionParams,
+    error: &Option<Error>,
+    actions: &mut Vec<CodeAction>,
+) {
+    let Some(Error::Type { errors, .. }) = error else {
+        return;
+    };
+    let inexhaustive_assignments = errors
+        .iter()
+        .filter_map(|error| match error {
+            type_::Error::InexhaustiveLetAssignment { location, missing } => {
+                Some((*location, missing))
+            }
+            _ => None,
+        })
+        .collect_vec();
+
+    if inexhaustive_assignments.is_empty() {
+        return;
+    }
+
+    for (location, missing) in inexhaustive_assignments {
+        let mut text_edits = TextEdits::new(line_numbers);
+
+        let range = text_edits.src_span_to_lsp_range(location);
+        if !overlaps(params.range, range) {
+            return;
         }
+
+        let Some(Located::Statement(TypedStatement::Assignment(TypedAssignment {
+            value,
+            pattern,
+            kind: AssignmentKind::Let,
+            location,
+            annotation: _,
+        }))) = module.find_node(location.start)
+        else {
+            continue;
+        };
+
+        // Get the source code for the tested expression
+        let value_location = value.location();
+        let expr = module
+            .code
+            .get(value_location.start as usize..value_location.end as usize)
+            .expect("Location must be valid");
+
+        // Get the source code for the pattern
+        let pattern_location = pattern.location();
+        let pattern_code = module
+            .code
+            .get(pattern_location.start as usize..pattern_location.end as usize)
+            .expect("Location must be valid");
+
+        let range = text_edits.src_span_to_lsp_range(*location);
+
+        // Figure out which variables are assigned in the pattern
+        let variables = PatternVariableFinder::find_variables_in_pattern(pattern);
+
+        let assigned = match variables.len() {
+            0 => "_",
+            1 => variables.first().expect("Variables is length one"),
+            _ => &format!("#({})", variables.join(", ")),
+        };
+
+        let mut new_text = format!("let {assigned} = ");
+        print_case_expression(
+            range.start.character as usize,
+            expr,
+            iter::once(CaseClause {
+                pattern: pattern_code,
+                expression: if assigned == "_" { "Nil" } else { assigned },
+            })
+            .chain(missing.iter().map(|pattern| CaseClause {
+                pattern,
+                expression: "todo",
+            }))
+            .collect(),
+            &mut new_text,
+        );
+
+        let uri = &params.text_document.uri;
+
+        text_edits.replace(*location, new_text);
+
+        CodeActionBuilder::new("Convert to case")
+            .kind(CodeActionKind::QUICKFIX)
+            .changes(uri.clone(), text_edits.edits)
+            .preferred(true)
+            .push_to(actions);
     }
+}
 
-    pub fn code_actions(mut self) -> Vec<CodeAction> {
-        self.visit_typed_module(&self.module.ast);
-        self.actions
+struct CaseClause<'a> {
+    pattern: &'a str,
+    expression: &'a str,
+}
+
+fn print_case_expression(
+    indent_size: usize,
+    subject: &str,
+    clauses: Vec<CaseClause<'_>>,
+    buffer: &mut String,
+) {
+    let indent = " ".repeat(indent_size);
+
+    // Print the beginning of the expression
+    buffer.push_str("case ");
+    buffer.push_str(subject);
+    buffer.push_str(" {");
+
+    for clause in clauses.iter() {
+        // Print the newline and indentation for this clause
+        buffer.push('\n');
+        buffer.push_str(&indent);
+        // Indent this clause one level deeper than the case expression
+        buffer.push_str("  ");
+
+        // Print the clause
+        buffer.push_str(clause.pattern);
+        buffer.push_str(" -> ");
+        buffer.push_str(clause.expression);
+    }
+
+    // If there are no clauses to print, the closing brace should be
+    // on the same line as the opening one, with no space between.
+    if !clauses.is_empty() {
+        buffer.push('\n');
+        buffer.push_str(&indent);
     }
+    buffer.push('}');
 }
 
 /// Builder for code action to apply the label shorthand syntax on arguments
@@ -904,7 +1063,7 @@ pub struct AddAnnotations<'a> {
 }
 
 impl<'ast> ast::visit::Visit<'ast> for AddAnnotations<'_> {
-    fn visit_typed_assignment(&mut self, assignment: &'ast ast::TypedAssignment) {
+    fn visit_typed_assignment(&mut self, assignment: &'ast TypedAssignment) {
         self.visit_typed_expr(&assignment.value);
 
         // We only offer this code action between `let` and `=`, because
@@ -993,7 +1152,7 @@ impl<'ast> ast::visit::Visit<'ast> for AddAnnotations<'_> {
         type_: &'ast Arc<Type>,
         kind: &'ast FunctionLiteralKind,
         args: &'ast [ast::TypedArg],
-        body: &'ast [ast::TypedStatement],
+        body: &'ast [TypedStatement],
         return_annotation: &'ast Option<ast::TypeAst>,
     ) {
         ast::visit::visit_typed_expr_fn(self, location, type_, kind, args, body, return_annotation);
@@ -1146,7 +1305,7 @@ impl<'ast> ast::visit::Visit<'ast> for QualifiedToUnqualifiedImportFirstPass<'as
         type_: &'ast Arc<Type>,
         kind: &'ast FunctionLiteralKind,
         args: &'ast [ast::TypedArg],
-        body: &'ast [ast::TypedStatement],
+        body: &'ast [TypedStatement],
         return_annotation: &'ast Option<ast::TypeAst>,
     ) {
         for arg in args {
@@ -1493,7 +1652,7 @@ impl<'ast> ast::visit::Visit<'ast> for QualifiedToUnqualifiedImportSecondPass<'a
         type_: &'ast Arc<Type>,
         kind: &'ast FunctionLiteralKind,
         args: &'ast [ast::TypedArg],
-        body: &'ast [ast::TypedStatement],
+        body: &'ast [TypedStatement],
         return_annotation: &'ast Option<ast::TypeAst>,
     ) {
         for arg in args {
@@ -1718,7 +1877,7 @@ impl<'ast> ast::visit::Visit<'ast> for UnqualifiedToQualifiedImportFirstPass<'as
         type_: &'ast Arc<Type>,
         kind: &'ast FunctionLiteralKind,
         args: &'ast [ast::TypedArg],
-        body: &'ast [ast::TypedStatement],
+        body: &'ast [TypedStatement],
         return_annotation: &'ast Option<ast::TypeAst>,
     ) {
         for arg in args {
@@ -1917,7 +2076,7 @@ impl<'ast> ast::visit::Visit<'ast> for UnqualifiedToQualifiedImportSecondPass<'a
         type_: &'ast Arc<Type>,
         kind: &'ast FunctionLiteralKind,
         args: &'ast [ast::TypedArg],
-        body: &'ast [ast::TypedStatement],
+        body: &'ast [TypedStatement],
         return_annotation: &'ast Option<ast::TypeAst>,
     ) {
         for arg in args {
@@ -2341,7 +2500,7 @@ impl<'ast> ast::visit::Visit<'ast> for TurnIntoUse<'ast> {
         type_: &'ast Arc<Type>,
         kind: &'ast FunctionLiteralKind,
         args: &'ast [ast::TypedArg],
-        body: &'ast [ast::TypedStatement],
+        body: &'ast [TypedStatement],
         return_annotation: &'ast Option<ast::TypeAst>,
     ) {
         // The cursor has to be inside the last statement of the body to
@@ -2362,7 +2521,7 @@ impl<'ast> ast::visit::Visit<'ast> for TurnIntoUse<'ast> {
     fn visit_typed_expr_block(
         &mut self,
         location: &'ast SrcSpan,
-        statements: &'ast [ast::TypedStatement],
+        statements: &'ast [TypedStatement],
     ) {
         let Some(last_statement) = statements.last() else {
             return;
@@ -2382,7 +2541,7 @@ impl<'ast> ast::visit::Visit<'ast> for TurnIntoUse<'ast> {
     }
 }
 
-fn turn_statement_into_use(statement: &ast::TypedStatement) -> Option<CallLocations> {
+fn turn_statement_into_use(statement: &TypedStatement) -> Option<CallLocations> {
     match statement {
         ast::Statement::Use(_) | ast::Statement::Assignment(_) => None,
         ast::Statement::Expression(expression) => turn_expression_into_use(expression),
diff --git a/compiler-core/src/language_server/engine.rs b/compiler-core/src/language_server/engine.rs
index 2ed36bcd9..3aec1d953 100644
--- a/compiler-core/src/language_server/engine.rs
+++ b/compiler-core/src/language_server/engine.rs
@@ -32,8 +32,9 @@ use super::{
     code_action::{
         code_action_add_missing_patterns, code_action_convert_qualified_constructor_to_unqualified,
         code_action_convert_unqualified_constructor_to_qualified, code_action_import_module,
-        AddAnnotations, CodeActionBuilder, DesugarUse, FillInMissingLabelledArgs,
-        LabelShorthandSyntax, LetAssertToCase, RedundantTupleInCaseSubject, TurnIntoUse,
+        code_action_inexhaustive_let_to_case, AddAnnotations, CodeActionBuilder, DesugarUse,
+        FillInMissingLabelledArgs, LabelShorthandSyntax, LetAssertToCase,
+        RedundantTupleInCaseSubject, TurnIntoUse,
     },
     completer::Completer,
     signature_help, src_span_to_lsp_range, DownloadDependencies, MakeLocker,
@@ -317,6 +318,13 @@ where
             code_action_fix_names(&lines, &params, &this.error, &mut actions);
             code_action_import_module(module, &lines, &params, &this.error, &mut actions);
             code_action_add_missing_patterns(module, &lines, &params, &this.error, &mut actions);
+            code_action_inexhaustive_let_to_case(
+                module,
+                &lines,
+                &params,
+                &this.error,
+                &mut actions,
+            );
             actions.extend(LetAssertToCase::new(module, &lines, &params).code_actions());
             actions
                 .extend(RedundantTupleInCaseSubject::new(module, &lines, &params).code_actions());


Test patch:
diff --git a/compiler-core/src/language_server/tests/action.rs b/compiler-core/src/language_server/tests/action.rs
index 161e87a99..4f83edbaa 100644
--- a/compiler-core/src/language_server/tests/action.rs
+++ b/compiler-core/src/language_server/tests/action.rs
@@ -3633,3 +3633,160 @@ pub fn main() {
         find_position_of("wibble").to_selection(),
     );
 }
+
+#[test]
+fn inexhaustive_let_result_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main(result) {
+  let Ok(value) = result
+}",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_to_case_indented() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main(result) {
+  {
+    let Ok(value) = result
+  }
+}",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_to_case_multi_variables() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main() {
+  let [var1, var2, _var3, var4] = [1, 2, 3, 4]
+}",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_to_case_discard() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main() {
+  let [_elem] = [6]
+}",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_to_case_no_variables() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main() {
+  let [] = []
+}",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_alias_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main() {
+  let 10 as ten = 10
+}",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_tuple_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main() {
+  let #(first, 10, third) = #(5, 10, 15)
+}
+",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_bit_array_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        "pub fn main() {
+  let <<bits1, bits2>> = <<73, 98>>
+}",
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_string_prefix_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        r#"pub fn main() {
+  let "_" <> thing = "_Hello"
+}"#,
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inexhaustive_let_string_prefix_pattern_alias_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        r#"pub fn main() {
+  let "123" as one_two_three <> rest = "123456"
+}"#,
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
+
+#[test]
+fn inner_inexhaustive_let_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        r#"pub fn main(result) {
+  let [wibble] = {
+    let Ok(wobble) = {
+      result
+    }
+    [wobble]
+  }
+}"#,
+        find_position_of("let Ok").select_until(find_position_of(") =")),
+    );
+}
+
+#[test]
+fn outer_inexhaustive_let_to_case() {
+    assert_code_action!(
+        CONVERT_TO_CASE,
+        r#"pub fn main(result) {
+  let [wibble] = {
+    let Ok(wobble) = {
+      result
+    }
+    [wobble]
+  }
+}"#,
+        find_position_of("let [").select_until(find_position_of("] =")),
+    );
+}
+
+#[test]
+fn no_code_action_for_exhaustive_let_to_case() {
+    assert_no_code_actions!(
+        CONVERT_TO_CASE,
+        r#"pub fn first(pair) {
+  let #(first, second) = pair
+  first
+}"#,
+        find_position_of("let").select_until(find_position_of("=")),
+    );
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_alias_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_alias_to_case.snap
new file mode 100644
index 000000000..23a72058c
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_alias_to_case.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let 10 as ten = 10\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let 10 as ten = 10
+  ▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑   
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let ten = case 10 {
+    10 as ten -> ten
+    _ -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_bit_array_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_bit_array_to_case.snap
new file mode 100644
index 000000000..4a6b36e7c
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_bit_array_to_case.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let <<bits1, bits2>> = <<73, 98>>\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let <<bits1, bits2>> = <<73, 98>>
+  ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑           
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let #(bits1, bits2) = case <<73, 98>> {
+    <<bits1, bits2>> -> #(bits1, bits2)
+    _ -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_result_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_result_to_case.snap
new file mode 100644
index 000000000..b5d1615ca
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_result_to_case.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main(result) {\n  let Ok(value) = result\n}"
+---
+----- BEFORE ACTION
+pub fn main(result) {
+  let Ok(value) = result
+  ▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑       
+}
+
+
+----- AFTER ACTION
+pub fn main(result) {
+  let value = case result {
+    Ok(value) -> value
+    Error(_) -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_string_prefix_pattern_alias_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_string_prefix_pattern_alias_to_case.snap
new file mode 100644
index 000000000..7b465b2bd
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_string_prefix_pattern_alias_to_case.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let \"123\" as one_two_three <> rest = \"123456\"\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let "123" as one_two_three <> rest = "123456"
+  ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑         
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let #(one_two_three, rest) = case "123456" {
+    "123" as one_two_three <> rest -> #(one_two_three, rest)
+    _ -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_string_prefix_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_string_prefix_to_case.snap
new file mode 100644
index 000000000..b7f7ff9d1
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_string_prefix_to_case.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let \"_\" <> thing = \"_Hello\"\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let "_" <> thing = "_Hello"
+  ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑         
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let thing = case "_Hello" {
+    "_" <> thing -> thing
+    _ -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_discard.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_discard.snap
new file mode 100644
index 000000000..2052d6d57
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_discard.snap
@@ -0,0 +1,19 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let [_elem] = [6]\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let [_elem] = [6]
+  ▔▔▔▔▔▔▔▔▔▔▔▔↑    
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let _ = case [6] {
+    [_elem] -> Nil
+    [] -> todo
+    [_, _, ..] -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_indented.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_indented.snap
new file mode 100644
index 000000000..eaa2e745a
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_indented.snap
@@ -0,0 +1,22 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main(result) {\n  {\n    let Ok(value) = result\n  }\n}"
+---
+----- BEFORE ACTION
+pub fn main(result) {
+  {
+    let Ok(value) = result
+    ▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑       
+  }
+}
+
+
+----- AFTER ACTION
+pub fn main(result) {
+  {
+    let value = case result {
+      Ok(value) -> value
+      Error(_) -> todo
+    }
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_multi_variables.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_multi_variables.snap
new file mode 100644
index 000000000..81ca19532
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_multi_variables.snap
@@ -0,0 +1,22 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let [var1, var2, _var3, var4] = [1, 2, 3, 4]\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let [var1, var2, _var3, var4] = [1, 2, 3, 4]
+  ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑             
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let #(var1, var2, var4) = case [1, 2, 3, 4] {
+    [var1, var2, _var3, var4] -> #(var1, var2, var4)
+    [] -> todo
+    [_, _, _, _, _, ..] -> todo
+    [_, _, _] -> todo
+    [_, _] -> todo
+    [_] -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_no_variables.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_no_variables.snap
new file mode 100644
index 000000000..9083091ae
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_to_case_no_variables.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let [] = []\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let [] = []
+  ▔▔▔▔▔▔▔↑   
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let _ = case [] {
+    [] -> Nil
+    [_, ..] -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_tuple_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_tuple_to_case.snap
new file mode 100644
index 000000000..9701ed8f1
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inexhaustive_let_tuple_to_case.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let #(first, 10, third) = #(5, 10, 15)\n}\n"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let #(first, 10, third) = #(5, 10, 15)
+  ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔↑             
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let #(first, third) = case #(5, 10, 15) {
+    #(first, 10, third) -> #(first, third)
+    #(_, _, _) -> todo
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inner_inexhaustive_let_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inner_inexhaustive_let_to_case.snap
new file mode 100644
index 000000000..ce51340ab
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__inner_inexhaustive_let_to_case.snap
@@ -0,0 +1,28 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main(result) {\n  let [wibble] = {\n    let Ok(wobble) = {\n      result\n    }\n    [wobble]\n  }\n}"
+---
+----- BEFORE ACTION
+pub fn main(result) {
+  let [wibble] = {
+    let Ok(wobble) = {
+    ▔▔▔▔▔▔▔▔▔▔▔▔▔↑    
+      result
+    }
+    [wobble]
+  }
+}
+
+
+----- AFTER ACTION
+pub fn main(result) {
+  let [wibble] = {
+    let wobble = case {
+      result
+    } {
+      Ok(wobble) -> wobble
+      Error(_) -> todo
+    }
+    [wobble]
+  }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__outer_inexhaustive_let_to_case.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__outer_inexhaustive_let_to_case.snap
new file mode 100644
index 000000000..73145c71e
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__outer_inexhaustive_let_to_case.snap
@@ -0,0 +1,29 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main(result) {\n  let [wibble] = {\n    let Ok(wobble) = {\n      result\n    }\n    [wobble]\n  }\n}"
+---
+----- BEFORE ACTION
+pub fn main(result) {
+  let [wibble] = {
+  ▔▔▔▔▔▔▔▔▔▔▔↑    
+    let Ok(wobble) = {
+      result
+    }
+    [wobble]
+  }
+}
+
+
+----- AFTER ACTION
+pub fn main(result) {
+  let wibble = case {
+    let Ok(wobble) = {
+      result
+    }
+    [wobble]
+  } {
+    [wibble] -> wibble
+    [] -> todo
+    [_, _, ..] -> todo
+  }
+}