Repository: gleam-lang/gleam (rust)

Issue:
Variable completion goes out of case arm bounds
<!--
Please include the following:
- Steps and code to reproduce the problem
- Versions for Gleam, OS, the runtime and any other software used
- Logs from running Gleam with the `GLEAM_LOG` environment variable set to `trace`
-->
Given this code, with the cursor inside the `False, bar` block:
```gleam
pub fn main() {
  let first = {
    True == True
  }
  let second = 3

  case first, second {
    True, foo -> {
      let x = 1
    }
    False, bar -> {
      |
    }
  }
}
```
The language server completes both `foo` and `x` as valid symbols. However, they're not, since they're in another arm of the case statement. I was able to reproduce this on both 0.10 and 0.11-rc, so it's not been introduced in the last cycle.

Pull request:
Fix local variable completions
This PR fixes #4625.
I've modified the code for local variable completions to take into account several more cases which weren't before.
I wonder if there's a better way to approach this code, as it feels a little hacky at the moment using lexical position for everything. For now though I've implemented the fix using the current method.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7a5ed9c5e..6008906f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -55,6 +55,11 @@
   in the current module, leading to invalid code.
   ([Surya Rose](https://github.com/GearsDatapacks))
 
+- Fixed a bug where the language server would offer completions for local
+  variables where the variables were not in scope, leading to invalid code being
+  produced if the completion was followed.
+  ([Surya Rose](https://github.com/GearsDatapacks))
+
 ## v1.11.0-rc1 - 2025-05-15
 
 ### Compiler
diff --git a/compiler-core/src/language_server/completer.rs b/compiler-core/src/language_server/completer.rs
index 81892a7d2..a4fc8616a 100644
--- a/compiler-core/src/language_server/completer.rs
+++ b/compiler-core/src/language_server/completer.rs
@@ -14,7 +14,7 @@ use crate::{
     Result,
     ast::{
         self, Arg, CallArg, Definition, Function, FunctionLiteralKind, Pattern, Publicity,
-        TypedExpr,
+        TypedExpr, visit::Visit,
     },
     build::{Module, Origin},
     io::{BeamCompiler, CommandExecutor, FileSystemReader, FileSystemWriter},
@@ -1018,14 +1018,8 @@ impl<'a> LocalCompletion<'a> {
 
         // Visit the function body statements
         for statement in &fun.body {
-            // We only want to suggest local variables that are defined before
-            // the cursor
-            if statement.location().start >= self.cursor {
-                continue;
-            }
-
             // Visit the statement to find local variables
-            ast::visit::visit_typed_statement(&mut self, statement);
+            self.visit_typed_statement(statement);
         }
 
         self.completions.into_values().collect_vec()
@@ -1051,7 +1045,16 @@ impl<'a> LocalCompletion<'a> {
     }
 }
 
-impl<'ast> ast::visit::Visit<'ast> for LocalCompletion<'_> {
+impl<'ast> Visit<'ast> for LocalCompletion<'_> {
+    fn visit_typed_statement(&mut self, statement: &'ast ast::TypedStatement) {
+        // We only want to suggest local variables that are defined before
+        // the cursor
+        if statement.location().start >= self.cursor {
+            return;
+        }
+        ast::visit::visit_typed_statement(self, statement);
+    }
+
     /// Visits a typed assignment, selectively processing either the value or the pattern
     /// based on the cursor position.
     /// - If the cursor is within the assignment It visits only the value expression.
@@ -1068,19 +1071,46 @@ impl<'ast> ast::visit::Visit<'ast> for LocalCompletion<'_> {
 
     fn visit_typed_expr_fn(
         &mut self,
-        _: &'ast ast::SrcSpan,
+        location: &'ast ast::SrcSpan,
         _: &'ast Arc<Type>,
         _: &'ast FunctionLiteralKind,
         args: &'ast [ast::TypedArg],
         body: &'ast Vec1<ast::TypedStatement>,
         _: &'ast Option<ast::TypeAst>,
     ) {
+        // If we are completing after the function body, any locally defined
+        // variables are now out of scope so we don't register any.
+        if self.cursor >= location.end {
+            return;
+        }
         self.visit_fn_args(args);
         for statement in body {
             self.visit_typed_statement(statement);
         }
     }
 
+    fn visit_typed_expr_block(
+        &mut self,
+        location: &'ast ast::SrcSpan,
+        statements: &'ast [ast::TypedStatement],
+    ) {
+        // If we are completing after the block, any locally defined variables
+        // are now out of scope so we don't register any.
+        if self.cursor >= location.end {
+            return;
+        }
+        ast::visit::visit_typed_expr_block(self, location, statements);
+    }
+
+    fn visit_typed_clause(&mut self, clause: &'ast ast::TypedClause) {
+        // Any code which comes before or after a case clause cannot access any
+        // of the variables defined within it, so we ignore this clause if so.
+        if self.cursor < clause.location.start || self.cursor > clause.location.end {
+            return;
+        }
+        ast::visit::visit_typed_clause(self, clause);
+    }
+
     fn visit_typed_pattern_variable(
         &mut self,
         _: &'ast ast::SrcSpan,


Test patch:
diff --git a/compiler-core/src/language_server/tests/completion.rs b/compiler-core/src/language_server/tests/completion.rs
index 3475fd013..9d6dcc824 100644
--- a/compiler-core/src/language_server/tests/completion.rs
+++ b/compiler-core/src/language_server/tests/completion.rs
@@ -2131,3 +2131,110 @@ pub fn main() {
 
     assert_completion!(TestProject::for_source(code), Position::new(6, 11));
 }
+
+// https://github.com/gleam-lang/gleam/issues/4625
+#[test]
+fn no_variable_completions_before_declaration_in_block() {
+    let code = "
+pub fn main() {
+  {
+    s
+    let something = 10
+  }
+}
+";
+
+    assert_completion!(TestProject::for_source(code), Position::new(3, 5));
+}
+
+// https://github.com/gleam-lang/gleam/issues/4625
+#[test]
+fn no_variable_completions_before_declaration_in_anonymous_function() {
+    let code = "
+pub fn main() {
+  fn() {
+    s
+    let something = 10
+  }
+}
+";
+
+    assert_completion!(TestProject::for_source(code), Position::new(3, 5));
+}
+
+// https://github.com/gleam-lang/gleam/issues/4625
+#[test]
+fn no_variable_completions_after_block_scope() {
+    let code = "
+pub fn main() {
+  {
+    let something = 10
+  }
+  s
+}
+";
+
+    assert_completion!(TestProject::for_source(code), Position::new(5, 3));
+}
+
+// https://github.com/gleam-lang/gleam/issues/4625
+#[test]
+fn no_variable_completions_after_anonymous_function_scope() {
+    let code = "
+pub fn main() {
+  fn() {
+    let something = 10
+  }
+  s
+}
+";
+
+    assert_completion!(TestProject::for_source(code), Position::new(5, 3));
+}
+
+// https://github.com/gleam-lang/gleam/issues/4625
+#[test]
+fn no_variable_completions_after_case_scope() {
+    let code = "
+pub fn main() {
+  case todo {
+    something -> Nil
+  }
+  s
+}
+";
+
+    assert_completion!(TestProject::for_source(code), Position::new(5, 3));
+}
+
+// https://github.com/gleam-lang/gleam/issues/4625
+#[test]
+fn no_variable_completions_after_case_clause_scope() {
+    let code = "
+pub fn main() {
+  case todo {
+    something -> Nil
+    something_else -> s
+  }
+  s
+}
+";
+
+    assert_completion!(TestProject::for_source(code), Position::new(4, 23));
+}
+
+// https://github.com/gleam-lang/gleam/issues/4625
+#[test]
+fn no_variable_completions_before_case_clause() {
+    let code = "
+pub fn main() {
+  case todo {
+    something -> s
+    something_else -> Nil
+  }
+  s
+}
+";
+
+    assert_completion!(TestProject::for_source(code), Position::new(3, 18));
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_anonymous_function_scope.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_anonymous_function_scope.snap
new file mode 100644
index 000000000..768ce9a02
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_anonymous_function_scope.snap
@@ -0,0 +1,40 @@
+---
+source: compiler-core/src/language_server/tests/completion.rs
+expression: "\npub fn main() {\n  fn() {\n    let something = 10\n  }\n  s\n}\n"
+---
+pub fn main() {
+  fn() {
+    let something = 10
+  }
+  s|
+}
+
+
+----- Completion content -----
+Error
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Error
+False
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_False
+Nil
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_Nil
+Ok
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Ok
+True
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_True
+main
+  kind:   Function
+  detail: fn() -> a
+  sort:   2_main
+  desc:   app
+  edits:
+    [5:2-5:2]: "main"
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_block_scope.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_block_scope.snap
new file mode 100644
index 000000000..5b928a4ce
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_block_scope.snap
@@ -0,0 +1,40 @@
+---
+source: compiler-core/src/language_server/tests/completion.rs
+expression: "\npub fn main() {\n  {\n    let something = 10\n  }\n  s\n}\n"
+---
+pub fn main() {
+  {
+    let something = 10
+  }
+  s|
+}
+
+
+----- Completion content -----
+Error
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Error
+False
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_False
+Nil
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_Nil
+Ok
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Ok
+True
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_True
+main
+  kind:   Function
+  detail: fn() -> a
+  sort:   2_main
+  desc:   app
+  edits:
+    [5:2-5:2]: "main"
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_case_clause_scope.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_case_clause_scope.snap
new file mode 100644
index 000000000..6d54c3817
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_case_clause_scope.snap
@@ -0,0 +1,49 @@
+---
+source: compiler-core/src/language_server/tests/completion.rs
+expression: "\npub fn main() {\n  case todo {\n    something -> Nil\n    something_else -> s\n  }\n  s\n}\n"
+---
+pub fn main() {
+  case todo {
+    something -> Nil
+    something_else -> s|
+  }
+  s
+}
+
+
+----- Completion content -----
+Error
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Error
+False
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_False
+Nil
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_Nil
+Ok
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Ok
+True
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_True
+main
+  kind:   Function
+  detail: fn() -> a
+  sort:   2_main
+  desc:   app
+  edits:
+    [4:22-4:22]: "main"
+something_else
+  kind:   Variable
+  detail: a
+  sort:   2_something_else
+  desc:   app
+  docs:   "A locally defined variable."
+  edits:
+    [4:22-4:22]: "something_else"
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_case_scope.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_case_scope.snap
new file mode 100644
index 000000000..8f6fcfdc1
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_after_case_scope.snap
@@ -0,0 +1,40 @@
+---
+source: compiler-core/src/language_server/tests/completion.rs
+expression: "\npub fn main() {\n  case todo {\n    something -> Nil\n  }\n  s\n}\n"
+---
+pub fn main() {
+  case todo {
+    something -> Nil
+  }
+  s|
+}
+
+
+----- Completion content -----
+Error
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Error
+False
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_False
+Nil
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_Nil
+Ok
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Ok
+True
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_True
+main
+  kind:   Function
+  detail: fn() -> a
+  sort:   2_main
+  desc:   app
+  edits:
+    [5:2-5:2]: "main"
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_case_clause.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_case_clause.snap
new file mode 100644
index 000000000..b643c293b
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_case_clause.snap
@@ -0,0 +1,49 @@
+---
+source: compiler-core/src/language_server/tests/completion.rs
+expression: "\npub fn main() {\n  case todo {\n    something -> s\n    something_else -> Nil\n  }\n  s\n}\n"
+---
+pub fn main() {
+  case todo {
+    something -> s|
+    something_else -> Nil
+  }
+  s
+}
+
+
+----- Completion content -----
+Error
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Error
+False
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_False
+Nil
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_Nil
+Ok
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Ok
+True
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_True
+main
+  kind:   Function
+  detail: fn() -> a
+  sort:   2_main
+  desc:   app
+  edits:
+    [3:17-3:17]: "main"
+something
+  kind:   Variable
+  detail: a
+  sort:   2_something
+  desc:   app
+  docs:   "A locally defined variable."
+  edits:
+    [3:17-3:17]: "something"
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_declaration_in_anonymous_function.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_declaration_in_anonymous_function.snap
new file mode 100644
index 000000000..b85569428
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_declaration_in_anonymous_function.snap
@@ -0,0 +1,40 @@
+---
+source: compiler-core/src/language_server/tests/completion.rs
+expression: "\npub fn main() {\n  fn() {\n    s\n    let something = 10\n  }\n}\n"
+---
+pub fn main() {
+  fn() {
+    s|
+    let something = 10
+  }
+}
+
+
+----- Completion content -----
+Error
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Error
+False
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_False
+Nil
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_Nil
+Ok
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Ok
+True
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_True
+main
+  kind:   Function
+  detail: fn() -> fn() -> Int
+  sort:   2_main
+  desc:   app
+  edits:
+    [3:4-3:4]: "main"
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_declaration_in_block.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_declaration_in_block.snap
new file mode 100644
index 000000000..9573a2e4f
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__completion__no_variable_completions_before_declaration_in_block.snap
@@ -0,0 +1,40 @@
+---
+source: compiler-core/src/language_server/tests/completion.rs
+expression: "\npub fn main() {\n  {\n    s\n    let something = 10\n  }\n}\n"
+---
+pub fn main() {
+  {
+    s|
+    let something = 10
+  }
+}
+
+
+----- Completion content -----
+Error
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Error
+False
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_False
+Nil
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_Nil
+Ok
+  kind:   Constructor
+  detail: gleam
+  sort:   4_Ok
+True
+  kind:   EnumMember
+  detail: gleam
+  sort:   4_True
+main
+  kind:   Function
+  detail: fn() -> Int
+  sort:   2_main
+  desc:   app
+  edits:
+    [3:4-3:4]: "main"