Repository: gleam-lang/gleam (rust)

Issue:
Expand function capture code action
An action that converts a function capture syntax sugar into the anonymous function syntax
```gleam
pub fn main() {
  list.map(_, run)
}
```

```gleam
pub fn main() {
  fn(value) { list.map(value, run) }
}
```

Pull request:
Expand function capture code action
This PR closes #3938 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4c2ed9b8a..24aabc181 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -189,6 +189,25 @@
 - The language server now provides an action to extract a value into a variable.
   ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
 
+- The Language Server now suggests a code action to expand a function capture
+  into the equivalent anonymous function. For example, this snippet of code:
+
+  ```gleam
+  pub fn main() {
+    list.map([1, 2, 3], int.add(_, 11))
+  }
+  ```
+
+  Will be turned into:
+
+  ```gleam
+  pub fn main() {
+    list.map([1, 2, 3], fn(value) { int.add(value, 11) })
+  }
+  ```
+
+  ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
+
 ### Formatter
 
 - The formatter now adds a `todo` inside empty blocks.
diff --git a/compiler-core/src/language_server/code_action.rs b/compiler-core/src/language_server/code_action.rs
index 33eaa7439..3e27bb867 100644
--- a/compiler-core/src/language_server/code_action.rs
+++ b/compiler-core/src/language_server/code_action.rs
@@ -1,4 +1,4 @@
-use std::{iter, sync::Arc};
+use std::{collections::HashSet, iter, sync::Arc};
 
 use crate::{
     ast::{
@@ -19,7 +19,7 @@ use crate::{
     },
     Error,
 };
-use ecow::EcoString;
+use ecow::{eco_format, EcoString};
 use im::HashMap;
 use itertools::Itertools;
 use lsp_types::{CodeAction, CodeActionKind, CodeActionParams, Position, Range, TextEdit, Url};
@@ -2729,3 +2729,117 @@ impl<'ast> ast::visit::Visit<'ast> for ExtractVariable<'ast> {
         }
     }
 }
+
+/// Builder for code action to apply the "expand function capture" action.
+///
+pub struct ExpandFunctionCapture<'a> {
+    module: &'a Module,
+    params: &'a CodeActionParams,
+    edits: TextEdits<'a>,
+    location: Option<(SrcSpan, SrcSpan, VariablesNames)>,
+}
+
+impl<'a> ExpandFunctionCapture<'a> {
+    pub fn new(
+        module: &'a Module,
+        line_numbers: &'a LineNumbers,
+        params: &'a CodeActionParams,
+    ) -> Self {
+        Self {
+            module,
+            params,
+            edits: TextEdits::new(line_numbers),
+            location: None,
+        }
+    }
+
+    pub fn code_actions(mut self) -> Vec<CodeAction> {
+        self.visit_typed_module(&self.module.ast);
+
+        let Some((function, hole, names)) = self.location else {
+            return vec![];
+        };
+
+        let name = names.first_available_name("value");
+        self.edits.replace(hole, name.clone().into());
+        self.edits.insert(function.end, " }".into());
+        self.edits.insert(function.start, format!("fn({name}) {{ "));
+
+        let mut action = Vec::with_capacity(1);
+        CodeActionBuilder::new("Expand function capture")
+            .kind(CodeActionKind::REFACTOR_REWRITE)
+            .changes(self.params.text_document.uri.clone(), self.edits.edits)
+            .preferred(false)
+            .push_to(&mut action);
+        action
+    }
+}
+
+impl<'ast> ast::visit::Visit<'ast> for ExpandFunctionCapture<'ast> {
+    fn visit_typed_expr_fn(
+        &mut self,
+        location: &'ast SrcSpan,
+        type_: &'ast Arc<Type>,
+        kind: &'ast FunctionLiteralKind,
+        args: &'ast [ast::TypedArg],
+        body: &'ast [TypedStatement],
+        return_annotation: &'ast Option<ast::TypeAst>,
+    ) {
+        let fn_range = self.edits.src_span_to_lsp_range(*location);
+        if within(self.params.range, fn_range) && kind.is_capture() {
+            if let [arg] = args {
+                self.location = Some((
+                    *location,
+                    arg.location,
+                    VariablesNames::from_statements(body),
+                ));
+            }
+        }
+
+        ast::visit::visit_typed_expr_fn(self, location, type_, kind, args, body, return_annotation)
+    }
+}
+
+struct VariablesNames {
+    names: HashSet<EcoString>,
+}
+
+impl VariablesNames {
+    fn from_statements(statements: &[TypedStatement]) -> Self {
+        let mut variables = Self {
+            names: HashSet::new(),
+        };
+
+        for statement in statements {
+            variables.visit_typed_statement(statement);
+        }
+        variables
+    }
+
+    fn first_available_name(&self, name: &str) -> EcoString {
+        let mut i = 0;
+        loop {
+            let name = if i == 0 {
+                EcoString::from(name)
+            } else {
+                eco_format!("{name}{i}")
+            };
+
+            if !self.names.contains(&name) {
+                return name;
+            }
+            i += 1;
+        }
+    }
+}
+
+impl<'ast> ast::visit::Visit<'ast> for VariablesNames {
+    fn visit_typed_expr_var(
+        &mut self,
+        _location: &'ast SrcSpan,
+        _constructor: &'ast type_::ValueConstructor,
+        name: &'ast EcoString,
+    ) {
+        let _ = self.names.insert(name.clone());
+    }
+}
diff --git a/compiler-core/src/language_server/engine.rs b/compiler-core/src/language_server/engine.rs
index fc6a3f8cf..50a58d318 100644
--- a/compiler-core/src/language_server/engine.rs
+++ b/compiler-core/src/language_server/engine.rs
@@ -33,8 +33,8 @@ use super::{
         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, AddAnnotations, CodeActionBuilder, DesugarUse,
-        ExtractVariable, FillInMissingLabelledArgs, LabelShorthandSyntax, LetAssertToCase,
-        RedundantTupleInCaseSubject, TurnIntoUse,
+        ExpandFunctionCapture, ExtractVariable, FillInMissingLabelledArgs, LabelShorthandSyntax,
+        LetAssertToCase, RedundantTupleInCaseSubject, TurnIntoUse,
     },
     completer::Completer,
     signature_help, src_span_to_lsp_range, DownloadDependencies, MakeLocker,
@@ -332,6 +332,7 @@ where
             actions.extend(FillInMissingLabelledArgs::new(module, &lines, &params).code_actions());
             actions.extend(DesugarUse::new(module, &lines, &params).code_actions());
             actions.extend(TurnIntoUse::new(module, &lines, &params).code_actions());
+            actions.extend(ExpandFunctionCapture::new(module, &lines, &params).code_actions());
             actions.extend(ExtractVariable::new(module, &lines, &params).code_actions());
             AddAnnotations::new(module, &lines, &params).code_action(&mut actions);
             Ok(if actions.is_empty() {


Test patch:
diff --git a/compiler-core/src/language_server/tests/action.rs b/compiler-core/src/language_server/tests/action.rs
index 5e8bd50b6..0410ac352 100644
--- a/compiler-core/src/language_server/tests/action.rs
+++ b/compiler-core/src/language_server/tests/action.rs
@@ -69,6 +69,7 @@ const ADD_ANNOTATIONS: &str = "Add type annotations";
 const DESUGAR_USE_EXPRESSION: &str = "Convert from `use`";
 const CONVERT_TO_USE: &str = "Convert to `use`";
 const EXTRACT_VARIABLE: &str = "Extract variable";
+const EXPAND_FUNCTION_CAPTURE: &str = "Expand function capture";
 
 macro_rules! assert_code_action {
     ($title:expr, $code:literal, $range:expr $(,)?) => {
@@ -3959,3 +3960,38 @@ fn extract_variable_in_block() {
         find_position_of("2").select_until(find_position_of("3"))
     );
 }
+
+#[test]
+fn expand_function_capture() {
+    assert_code_action!(
+        EXPAND_FUNCTION_CAPTURE,
+        r#"pub fn main() {
+  wibble(_, 1)
+}"#,
+        find_position_of("_").to_selection()
+    );
+}
+
+#[test]
+fn expand_function_capture_2() {
+    assert_code_action!(
+        EXPAND_FUNCTION_CAPTURE,
+        r#"pub fn main() {
+  wibble(1, _)
+}"#,
+        find_position_of("wibble").to_selection()
+    );
+}
+
+#[test]
+fn expand_function_capture_does_not_shadow_variables() {
+    assert_code_action!(
+        EXPAND_FUNCTION_CAPTURE,
+        r#"pub fn main() {
+  let value = 1
+  let value1 = 2
+  wibble(value, _, value1)
+}"#,
+        find_position_of("wibble").to_selection()
+    );
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture.snap
new file mode 100644
index 000000000..a89897e44
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture.snap
@@ -0,0 +1,15 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  wibble(_, 1)\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  wibble(_, 1)
+         ↑    
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  fn(value) { wibble(value, 1) }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture_2.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture_2.snap
new file mode 100644
index 000000000..c2ae2bb9b
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture_2.snap
@@ -0,0 +1,15 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  wibble(1, _)\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  wibble(1, _)
+  ↑           
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  fn(value) { wibble(1, value) }
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture_does_not_shadow_variables.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture_does_not_shadow_variables.snap
new file mode 100644
index 000000000..6f44cebde
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__expand_function_capture_does_not_shadow_variables.snap
@@ -0,0 +1,19 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "pub fn main() {\n  let value = 1\n  let value1 = 2\n  wibble(value, _, value1)\n}"
+---
+----- BEFORE ACTION
+pub fn main() {
+  let value = 1
+  let value1 = 2
+  wibble(value, _, value1)
+  ↑                       
+}
+
+
+----- AFTER ACTION
+pub fn main() {
+  let value = 1
+  let value1 = 2
+  fn(value2) { wibble(value, value2, value1) }
+}