Repository: gleam-lang/gleam (rust)

Issue:
Show warning for `let assert` that is guaranteed to fail
It would be nice if the compiler warned us when a `let assert` is guaranteed to fail because of variant inference:
```gleam
let assert Wibble(n) = Wobble(1) 
```

Pull request description:
Warn when asserting on variants which have been inferred
Closes #4389

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b14633afc..f6cf7762e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -65,6 +65,16 @@
 
   ([Surya Rose](https://github.com/GearsDatapacks))
 
+- The compiler now emits a warning when using `let assert` to assert a value
+  whose variant has already been inferred. For example:
+
+  ```gleam
+  // warning: This will always crash
+  let assert Ok(_) = Error("Some error")
+  ```
+
+  ([Surya Rose](https://github.com/GearsDatapacks))
+
 ### Build tool
 
 - Include a type annotation for the `main` function generated by `gleam new`.
@@ -292,6 +302,10 @@
   when it could use the cache from previous compilations.
   ([Louis Pilfold](https://github.com/lpil))
 
+- Fixed a bug where `let assert` would not assert that the given value matched
+  the pattern if it was the only expression inside a block.
+  ([Surya Rose](https://github.com/GearsDatapacks))
+
 ## v1.9.1 - 2025-03-10
 
 ### Formatter
diff --git a/compiler-core/src/javascript/expression.rs b/compiler-core/src/javascript/expression.rs
index 0ab08dca5..8173b49dc 100644
--- a/compiler-core/src/javascript/expression.rs
+++ b/compiler-core/src/javascript/expression.rs
@@ -692,33 +692,38 @@ impl<'module, 'a> Generator<'module, 'a> {
     fn block(&mut self, statements: &'a Vec1<TypedStatement>) -> Output<'a> {
         if statements.len() == 1 {
             match statements.first() {
-                Statement::Expression(expression) => self.child_expression(expression),
+                Statement::Expression(expression) => return self.child_expression(expression),
 
-                Statement::Assignment(assignment) => {
-                    self.child_expression(assignment.value.as_ref())
-                }
+                Statement::Assignment(assignment) => match &assignment.kind {
+                    AssignmentKind::Let | AssignmentKind::Generated => {
+                        return self.child_expression(assignment.value.as_ref());
+                    }
+                    // We can't just return the right-hand side of a `let assert`
+                    // assignment; we still need to check that the pattern matches.
+                    AssignmentKind::Assert { .. } => {}
+                },
 
-                Statement::Use(use_) => self.child_expression(&use_.call),
+                Statement::Use(use_) => return self.child_expression(&use_.call),
             }
-        } else {
-            match &self.scope_position {
-                Position::Tail | Position::Assign(_) => self.block_document(statements),
-                Position::NotTail(Ordering::Strict) => self
-                    .immediately_invoked_function_expression(statements, |this, statements| {
-                        this.statements(statements)
-                    }),
-                Position::NotTail(Ordering::Loose) => self.wrap_block(|this| {
-                    // Save previous scope
-                    let current_scope_vars = this.current_scope_vars.clone();
+        }
 
-                    let document = this.block_document(statements)?;
+        match &self.scope_position {
+            Position::Tail | Position::Assign(_) => self.block_document(statements),
+            Position::NotTail(Ordering::Strict) => self
+                .immediately_invoked_function_expression(statements, |this, statements| {
+                    this.statements(statements)
+                }),
+            Position::NotTail(Ordering::Loose) => self.wrap_block(|this| {
+                // Save previous scope
+                let current_scope_vars = this.current_scope_vars.clone();
 
-                    // Restore previous state
-                    this.current_scope_vars = current_scope_vars;
+                let document = this.block_document(statements)?;
 
-                    Ok(document)
-                }),
-            }
+                // Restore previous state
+                this.current_scope_vars = current_scope_vars;
+
+                Ok(document)
+            }),
         }
     }
 
@@ -769,10 +774,17 @@ impl<'module, 'a> Generator<'module, 'a> {
             self.not_in_tail_position(Some(Ordering::Loose), |this| this.wrap_expression(value))?;
         let js_name = self.next_local_var(name);
         let assignment = docvec!["let ", js_name.clone(), " = ", subject, ";"];
-        let assignment = if self.scope_position.is_tail() {
-            docvec![assignment, line(), "return ", js_name, ";"]
-        } else {
-            assignment
+        let assignment = match &self.scope_position {
+            Position::NotTail(_) => assignment,
+            Position::Tail => docvec![assignment, line(), "return ", js_name, ";"],
+            Position::Assign(block_variable) => docvec![
+                assignment,
+                line(),
+                block_variable.clone(),
+                " = ",
+                js_name,
+                ";"
+            ],
         };
 
         Ok(assignment.force_break())
@@ -803,15 +815,21 @@ impl<'module, 'a> Generator<'module, 'a> {
         let compiled = pattern_generator.take_compiled();
 
         // If we are in tail position we can return value being assigned
-        let afterwards = if self.scope_position.is_tail() {
-            docvec![
+        let afterwards = match &self.scope_position {
+            Position::NotTail(_) => nil(),
+            Position::Tail => docvec![
                 line(),
                 "return ",
                 subject_assignment.clone().unwrap_or_else(|| value.clone()),
                 ";"
-            ]
-        } else {
-            nil()
+            ],
+            Position::Assign(block_variable) => docvec![
+                line(),
+                block_variable.clone(),
+                " = ",
+                subject_assignment.clone().unwrap_or_else(|| value.clone()),
+                ";"
+            ],
         };
 
         let compiled =
@@ -939,7 +957,7 @@ impl<'module, 'a> Generator<'module, 'a> {
     ) -> Output<'a> {
         let message = match message {
             Some(m) => {
-                self.not_in_tail_position(Some(Ordering::Loose), |this| this.expression(m))?
+                self.not_in_tail_position(Some(Ordering::Strict), |this| this.expression(m))?
             }
             None => string("Pattern match failed, no pattern matched the value."),
         };
diff --git a/compiler-core/src/type_/error.rs b/compiler-core/src/type_/error.rs
index d1f6702f8..35aed1385 100644
--- a/compiler-core/src/type_/error.rs
+++ b/compiler-core/src/type_/error.rs
@@ -864,6 +864,10 @@ pub enum Warning {
         location: SrcSpan,
     },
 
+    AssertAssignmentOnInferredVariant {
+        location: SrcSpan,
+    },
+
     /// When a `todo` or `panic` is used as a function instead of providing the
     /// error message with the `as` syntax.
     ///
@@ -1125,6 +1129,7 @@ impl Warning {
             | Warning::OpaqueExternalType { location, .. }
             | Warning::InternalTypeLeak { location, .. }
             | Warning::RedundantAssertAssignment { location, .. }
+            | Warning::AssertAssignmentOnInferredVariant { location, .. }
             | Warning::TodoOrPanicUsedAsFunction { location, .. }
             | Warning::UnreachableCodeAfterPanic { location, .. }
             | Warning::RedundantPipeFunctionCapture { location, .. }
diff --git a/compiler-core/src/type_/expression.rs b/compiler-core/src/type_/expression.rs
index aa28956fb..d59fb38de 100644
--- a/compiler-core/src/type_/expression.rs
+++ b/compiler-core/src/type_/expression.rs
@@ -12,7 +12,7 @@ use crate::{
         UntypedUseAssignment, Use, UseAssignment,
     },
     build::Target,
-    exhaustiveness::{self, Reachability},
+    exhaustiveness::{self, Match, Reachability},
     reference::ReferenceKind,
 };
 use hexpm::version::Version;
@@ -1524,7 +1524,8 @@ impl<'a, 'b> ExprTyper<'a, 'b> {
         }
 
         // Do not perform exhaustiveness checking if user explicitly used `let assert ... = ...`.
-        let exhaustiveness_check = self.check_let_exhaustiveness(location, value.type_(), &pattern);
+        let (output, exhaustiveness_check) =
+            self.check_let_exhaustiveness(location, value.type_(), &pattern);
         match (&kind, exhaustiveness_check) {
             // The pattern is exhaustive in a let assignment, there's no problem here.
             (AssignmentKind::Let | AssignmentKind::Generated, Ok(_)) => {}
@@ -1543,9 +1544,22 @@ impl<'a, 'b> ExprTyper<'a, 'b> {
                 })
             }
 
-            // Otherwise we just don't care, asserting will ignore the any missing
-            // pattern error.
-            (AssignmentKind::Assert { .. }, Err(_)) => {}
+            // Otherwise, if the pattern is never reachable (through variant inference),
+            // we can warn the user about this.
+            (AssignmentKind::Assert { .. }, Err(_)) => {
+                // There is only one pattern to match, so it is index 0
+                match output.is_reachable(0) {
+                    Reachability::Unreachable(UnreachableCaseClauseReason::ImpossibleVariant) => {
+                        self.problems
+                            .warning(Warning::AssertAssignmentOnInferredVariant {
+                                location: pattern.location(),
+                            })
+                    }
+                    // A duplicate pattern warning should not happen, since there is only one pattern.
+                    Reachability::Reachable
+                    | Reachability::Unreachable(UnreachableCaseClauseReason::DuplicatePattern) => {}
+                }
+            }
         }
 
         Assignment {
@@ -4009,20 +4023,21 @@ impl<'a, 'b> ExprTyper<'a, 'b> {
         location: SrcSpan,
         subject: Arc<Type>,
         pattern: &TypedPattern,
-    ) -> Result<(), Error> {
+    ) -> (Match, Result<(), Error>) {
         let mut case = exhaustiveness::CaseToCompile::new(&[subject]);
         case.add_pattern(pattern);
         let output = case.compile(self.environment);
 
         // Error for missing clauses that would cause a crash
-        if output.diagnostics.missing {
+        let result = if output.diagnostics.missing {
             Err(Error::InexhaustiveLetAssignment {
                 location,
                 missing: output.missing_patterns(self.environment),
             })
         } else {
             Ok(())
-        }
+        };
+        (output, result)
     }
 
     fn check_case_exhaustiveness(
diff --git a/compiler-core/src/warning.rs b/compiler-core/src/warning.rs
index d25ccd859..f6a5fa3b1 100644
--- a/compiler-core/src/warning.rs
+++ b/compiler-core/src/warning.rs
@@ -940,6 +940,24 @@ hidden from the package's documentation.",
                     }),
                 },
 
+                type_::Warning::AssertAssignmentOnInferredVariant { location } => Diagnostic {
+                    title: "Assertion on inferred variant".into(),
+                    text: wrap(
+                        "This assertion will always crash since it matches on a variant which is never present.",
+                    ),
+                    hint: None,
+                    level: diagnostic::Level::Warning,
+                    location: Some(Location {
+                        label: diagnostic::Label {
+                            text: None,
+                            span: *location,
+                        },
+                        path: path.clone(),
+                        src: src.clone(),
+                        extra_labels: vec![],
+                    }),
+                },
+
                 type_::Warning::TodoOrPanicUsedAsFunction {
                     kind,
                     location,


Test patch:
diff --git a/compiler-core/src/javascript/tests/blocks.rs b/compiler-core/src/javascript/tests/blocks.rs
index 8a503b3a6..795360297 100644
--- a/compiler-core/src/javascript/tests/blocks.rs
+++ b/compiler-core/src/javascript/tests/blocks.rs
@@ -277,3 +277,70 @@ pub fn main() {
 "
     )
 }
+
+// https://github.com/gleam-lang/gleam/issues/4393
+#[test]
+fn let_assert_only_statement_in_block() {
+    assert_js!(
+        "
+pub fn main() {
+  {
+    let assert Ok(1) = Error(Nil)
+  }
+}
+"
+    )
+}
+
+// https://github.com/gleam-lang/gleam/issues/4394
+#[test]
+fn assignment_last_in_block() {
+    assert_js!(
+        "
+pub fn main() {
+  let a = {
+    let b = 1
+    let c = b + 1
+  }
+  a
+}
+"
+    )
+}
+
+// https://github.com/gleam-lang/gleam/issues/4394
+#[test]
+fn pattern_assignment_last_in_block() {
+    assert_js!(
+        "
+pub fn main() {
+  let a = {
+    let b = #(1, 2)
+    let #(x, y) = b
+  }
+  a
+}
+"
+    )
+}
+
+// https://github.com/gleam-lang/gleam/issues/4395
+#[test]
+fn let_assert_message_no_lifted() {
+    assert_js!(
+        r#"
+fn side_effects(x) {
+  // Some side effects
+  x
+}
+
+pub fn main() {
+  let assert Error(Nil) = side_effects(Ok(10))
+    as {
+    let message = side_effects("some message")
+    message
+  }
+}
+"#
+    )
+}
diff --git a/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__assignment_last_in_block.snap b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__assignment_last_in_block.snap
new file mode 100644
index 000000000..f6337d427
--- /dev/null
+++ b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__assignment_last_in_block.snap
@@ -0,0 +1,26 @@
+---
+source: compiler-core/src/javascript/tests/blocks.rs
+expression: "\npub fn main() {\n  let a = {\n    let b = 1\n    let c = b + 1\n  }\n  a\n}\n"
+---
+----- SOURCE CODE
+
+pub fn main() {
+  let a = {
+    let b = 1
+    let c = b + 1
+  }
+  a
+}
+
+
+----- COMPILED JAVASCRIPT
+export function main() {
+  let _block;
+  {
+    let b = 1;
+    let c = b + 1;
+    _block = c;
+  }
+  let a = _block;
+  return a;
+}
diff --git a/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__let_assert_message_no_lifted.snap b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__let_assert_message_no_lifted.snap
new file mode 100644
index 000000000..8bc3c6892
--- /dev/null
+++ b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__let_assert_message_no_lifted.snap
@@ -0,0 +1,44 @@
+---
+source: compiler-core/src/javascript/tests/blocks.rs
+expression: "\nfn side_effects(x) {\n  // Some side effects\n  x\n}\n\npub fn main() {\n  let assert Error(Nil) = side_effects(Ok(10))\n    as {\n    let message = side_effects(\"some message\")\n    message\n  }\n}\n"
+---
+----- SOURCE CODE
+
+fn side_effects(x) {
+  // Some side effects
+  x
+}
+
+pub fn main() {
+  let assert Error(Nil) = side_effects(Ok(10))
+    as {
+    let message = side_effects("some message")
+    message
+  }
+}
+
+
+----- COMPILED JAVASCRIPT
+import { Ok, makeError } from "../gleam.mjs";
+
+function side_effects(x) {
+  return x;
+}
+
+export function main() {
+  let $ = side_effects(new Ok(10));
+  if ($.isOk() || $[0]) {
+    throw makeError(
+      "let_assert",
+      "my/mod",
+      8,
+      "main",
+      (() => {
+        let message = side_effects("some message");
+        return message;
+      })(),
+      { value: $ }
+    )
+  }
+  return $;
+}
diff --git a/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__let_assert_only_statement_in_block.snap b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__let_assert_only_statement_in_block.snap
new file mode 100644
index 000000000..9932a7290
--- /dev/null
+++ b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__let_assert_only_statement_in_block.snap
@@ -0,0 +1,32 @@
+---
+source: compiler-core/src/javascript/tests/blocks.rs
+expression: "\npub fn main() {\n  {\n    let assert Ok(1) = Error(Nil)\n  }\n}\n"
+---
+----- SOURCE CODE
+
+pub fn main() {
+  {
+    let assert Ok(1) = Error(Nil)
+  }
+}
+
+
+----- COMPILED JAVASCRIPT
+import { Error, makeError } from "../gleam.mjs";
+
+export function main() {
+  {
+    let $ = new Error(undefined);
+    if (!$.isOk() || $[0] !== 1) {
+      throw makeError(
+        "let_assert",
+        "my/mod",
+        4,
+        "main",
+        "Pattern match failed, no pattern matched the value.",
+        { value: $ }
+      )
+    }
+    return $;
+  }
+}
diff --git a/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__pattern_assignment_last_in_block.snap b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__pattern_assignment_last_in_block.snap
new file mode 100644
index 000000000..40c1f8884
--- /dev/null
+++ b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__blocks__pattern_assignment_last_in_block.snap
@@ -0,0 +1,27 @@
+---
+source: compiler-core/src/javascript/tests/blocks.rs
+expression: "\npub fn main() {\n  let a = {\n    let b = #(1, 2)\n    let #(x, y) = b\n  }\n  a\n}\n"
+---
+----- SOURCE CODE
+
+pub fn main() {
+  let a = {
+    let b = #(1, 2)
+    let #(x, y) = b
+  }
+  a
+}
+
+
+----- COMPILED JAVASCRIPT
+export function main() {
+  let _block;
+  {
+    let b = [1, 2];
+    let x = b[0];
+    let y = b[1];
+    _block = b;
+  }
+  let a = _block;
+  return a;
+}
diff --git a/compiler-core/src/type_/tests/snapshots/gleam_core__type___tests__warnings__assert_on_inferred_variant.snap b/compiler-core/src/type_/tests/snapshots/gleam_core__type___tests__warnings__assert_on_inferred_variant.snap
new file mode 100644
index 000000000..51fad4bb8
--- /dev/null
+++ b/compiler-core/src/type_/tests/snapshots/gleam_core__type___tests__warnings__assert_on_inferred_variant.snap
@@ -0,0 +1,26 @@
+---
+source: compiler-core/src/type_/tests/warnings.rs
+expression: "\ntype Wibble {\n  Wibble(w: Int)\n  Wobble(w: String)\n}\n\npub fn main() {\n  let assert Wobble(w) = Wibble(10)\n  w\n}\n"
+---
+----- SOURCE CODE
+
+type Wibble {
+  Wibble(w: Int)
+  Wobble(w: String)
+}
+
+pub fn main() {
+  let assert Wobble(w) = Wibble(10)
+  w
+}
+
+
+----- WARNING
+warning: Assertion on inferred variant
+  ┌─ /src/warning/wrn.gleam:8:14
+  │
+8 │   let assert Wobble(w) = Wibble(10)
+  │              ^^^^^^^^^
+
+This assertion will always crash since it matches on a variant which is
+never present.
diff --git a/compiler-core/src/type_/tests/snapshots/gleam_core__type___tests__warnings__let_assert_with_message_requires_v1_7.snap b/compiler-core/src/type_/tests/snapshots/gleam_core__type___tests__warnings__let_assert_with_message_requires_v1_7.snap
index fff2c6a5e..e6e5706cf 100644
--- a/compiler-core/src/type_/tests/snapshots/gleam_core__type___tests__warnings__let_assert_with_message_requires_v1_7.snap
+++ b/compiler-core/src/type_/tests/snapshots/gleam_core__type___tests__warnings__let_assert_with_message_requires_v1_7.snap
@@ -1,20 +1,20 @@
 ---
 source: compiler-core/src/type_/tests/warnings.rs
-expression: "\npub fn main() {\n  let assert Ok(10) = Error(20) as \"This will crash...\"\n}\n"
+expression: "\npub fn main() {\n  let assert Ok(10) = Ok(20) as \"This will crash...\"\n}\n"
 ---
 ----- SOURCE CODE
 
 pub fn main() {
-  let assert Ok(10) = Error(20) as "This will crash..."
+  let assert Ok(10) = Ok(20) as "This will crash..."
 }
 
 
 ----- WARNING
 warning: Incompatible gleam version range
-  ┌─ /src/warning/wrn.gleam:3:36
+  ┌─ /src/warning/wrn.gleam:3:33
   │
-3 │   let assert Ok(10) = Error(20) as "This will crash..."
-  │                                    ^^^^^^^^^^^^^^^^^^^^ This requires a Gleam version >= 1.7.0
+3 │   let assert Ok(10) = Ok(20) as "This will crash..."
+  │                                 ^^^^^^^^^^^^^^^^^^^^ This requires a Gleam version >= 1.7.0
 
 Specifying a custom panic message when using let assert was introduced in
 version v1.7.0. But the Gleam version range specified in your `gleam.toml`
diff --git a/compiler-core/src/type_/tests/warnings.rs b/compiler-core/src/type_/tests/warnings.rs
index 714fbf410..eb156d4da 100644
--- a/compiler-core/src/type_/tests/warnings.rs
+++ b/compiler-core/src/type_/tests/warnings.rs
@@ -2525,7 +2525,7 @@ fn let_assert_with_message_requires_v1_7() {
         Range::higher_than(Version::new(1, 0, 0)),
         r#"
 pub fn main() {
-  let assert Ok(10) = Error(20) as "This will crash..."
+  let assert Ok(10) = Ok(20) as "This will crash..."
 }
 "#,
     );
@@ -3014,3 +3014,20 @@ pub fn main(a) {
 "
     );
 }
+
+#[test]
+fn assert_on_inferred_variant() {
+    assert_warning!(
+        "
+type Wibble {
+  Wibble(w: Int)
+  Wobble(w: String)
+}
+
+pub fn main() {
+  let assert Wobble(w) = Wibble(10)
+  w
+}
+"
+    );
+}