Repository: gleam-lang/gleam (rust)

Issue:
LSP Code Action: Generate JSON Encoder
This would be the reverse to the "Generate Dynamic Decoder" code action, and would generate a function to encode a given custom type as JSON, using the `gleam_json` package.
For example:
```gleam
pub type Wibble {
//       ^ Use code action
  Wibble(int: Int, float: Float, string: String)
}
```
Becomes:
```gleam
import gleam/json

pub type Wibble {
  Wibble(int: Int, float: Float, string: String)
}

pub fn encode_wibble(wibble: Wibble) -> json.Json {
  json.object([
    #("int", json.int(wibble.int)),
    #("float", json.float(wibble.float)),
    #("string", json.string(wibble.string)),
  ])
}
```

Pull request:
Implement Code Action to Generate JSON Encoder
Closes #4227
This is basically a copy-paste-change of the generate dynamic decoder action.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9550c85d1..27c837e58 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -42,6 +42,34 @@
 
   ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
 
+- The Language Server now suggests a code action to generate a function to
+  encode a custom type as JSON using the `gleam_json` package. For example:
+
+  ```gleam
+  pub type Person {
+    Person(name: String, age: Int)
+  }
+  ```
+
+  Will become:
+
+  ```gleam
+  import gleam/json
+
+  pub type Person {
+    Person(name: String, age: Int)
+  }
+
+  fn encode_person(person: Person) -> json.Json {
+    json.object([
+      #("name", json.string(person.name)),
+      #("age", json.int(person.age)),
+    ])
+  }
+  ```
+
+  ([Surya Rose](https://github.com/GearsDatapacks))
+
 ### Formatter
 
 ### Bug fixes
diff --git a/compiler-core/src/language_server/code_action.rs b/compiler-core/src/language_server/code_action.rs
index 7d45fe223..33d5cadb9 100644
--- a/compiler-core/src/language_server/code_action.rs
+++ b/compiler-core/src/language_server/code_action.rs
@@ -3281,6 +3281,277 @@ impl<'a> DecoderPrinter<'a> {
     }
 }
 
+/// Builder for code action to apply the "Generate JSON encoder" action.
+///
+pub struct GenerateJsonEncoder<'a> {
+    module: &'a Module,
+    params: &'a CodeActionParams,
+    edits: TextEdits<'a>,
+    printer: Printer<'a>,
+    actions: &'a mut Vec<CodeAction>,
+}
+
+const JSON_MODULE: &str = "gleam/json";
+const JSON_PACKAGE_NAME: &str = "gleam_json";
+
+impl<'a> GenerateJsonEncoder<'a> {
+    pub fn new(
+        module: &'a Module,
+        line_numbers: &'a LineNumbers,
+        params: &'a CodeActionParams,
+        actions: &'a mut Vec<CodeAction>,
+    ) -> Self {
+        let printer = Printer::new(&module.ast.names);
+        Self {
+            module,
+            params,
+            edits: TextEdits::new(line_numbers),
+            printer,
+            actions,
+        }
+    }
+
+    pub fn code_actions(&mut self) {
+        self.visit_typed_module(&self.module.ast);
+    }
+}
+
+impl<'ast> ast::visit::Visit<'ast> for GenerateJsonEncoder<'ast> {
+    fn visit_typed_custom_type(&mut self, custom_type: &'ast ast::TypedCustomType) {
+        let range = self.edits.src_span_to_lsp_range(custom_type.location);
+        if !overlaps(self.params.range, range) {
+            return;
+        }
+
+        // For now, we only generate json encoders for types with one variant.
+        let constructor = match custom_type.constructors.as_slice() {
+            [constructor] => constructor,
+            _ => return,
+        };
+
+        let record_name = custom_type.name.to_snake_case();
+        let name = eco_format!("encode_{record_name}");
+
+        let Some(fields): Option<Vec<_>> = constructor
+            .arguments
+            .iter()
+            .map(|argument| {
+                Some(RecordField {
+                    label: RecordLabel::Labeled(
+                        argument.label.as_ref().map(|(_, name)| name.as_str())?,
+                    ),
+                    type_: &argument.type_,
+                })
+            })
+            .collect()
+        else {
+            return;
+        };
+
+        let mut encoder_printer = EncoderPrinter::new(
+            &self.module.ast.names,
+            custom_type.name.clone(),
+            self.module.name.clone(),
+        );
+
+        let encoders = fields
+            .iter()
+            .map(|field| encoder_printer.encode_field(&record_name, field, 4))
+            .join(",\n");
+
+        let json_type = self.printer.print_type(&Type::Named {
+            publicity: ast::Publicity::Public,
+            package: JSON_PACKAGE_NAME.into(),
+            module: JSON_MODULE.into(),
+            name: "Json".into(),
+            args: vec![],
+            inferred_variant: None,
+        });
+        let json_module = self.printer.print_module(JSON_MODULE);
+        let parameters = match custom_type.parameters.len() {
+            0 => EcoString::new(),
+            _ => eco_format!(
+                "({})",
+                custom_type
+                    .parameters
+                    .iter()
+                    .map(|(_, name)| name)
+                    .join(", ")
+            ),
+        };
+
+        let function = format!(
+            "
+
+fn {name}({record_name}: {type_name}{parameters}) -> {json_type} {{
+  {json_module}.object([
+{encoders},
+  ])
+}}",
+            type_name = custom_type.name,
+        );
+
+        self.edits.insert(custom_type.end_position, function);
+        maybe_import(&mut self.edits, self.module, JSON_MODULE);
+
+        CodeActionBuilder::new("Generate JSON encoder")
+            .kind(CodeActionKind::REFACTOR)
+            .preferred(false)
+            .changes(
+                self.params.text_document.uri.clone(),
+                std::mem::take(&mut self.edits.edits),
+            )
+            .push_to(self.actions);
+    }
+}
+
+struct EncoderPrinter<'a> {
+    printer: Printer<'a>,
+    /// The name of the root type we are printing an encoder for
+    type_name: EcoString,
+    /// The module name of the root type we are printing an encoder for
+    type_module: EcoString,
+}
+
+impl<'a> EncoderPrinter<'a> {
+    fn new(names: &'a Names, type_name: EcoString, type_module: EcoString) -> Self {
+        Self {
+            type_name,
+            type_module,
+            printer: Printer::new(names),
+        }
+    }
+
+    fn encoder_for(&mut self, encoded_value: &str, type_: &Type, indent: usize) -> EcoString {
+        let module_name = self.printer.print_module(JSON_MODULE);
+        let is_capture = encoded_value == "_";
+        let maybe_capture = |mut function: EcoString| {
+            if is_capture {
+                function
+            } else {
+                function.push('(');
+                function.push_str(encoded_value);
+                function.push(')');
+                function
+            }
+        };
+
+        if type_.is_bool() {
+            maybe_capture(eco_format!("{module_name}.bool"))
+        } else if type_.is_float() {
+            maybe_capture(eco_format!("{module_name}.float"))
+        } else if type_.is_int() {
+            maybe_capture(eco_format!("{module_name}.int"))
+        } else if type_.is_string() {
+            maybe_capture(eco_format!("{module_name}.string"))
+        } else if let Some(types) = type_.tuple_types() {
+            let (tuple, new_indent) = if is_capture {
+                ("value", indent + 4)
+            } else {
+                (encoded_value, indent + 2)
+            };
+
+            let encoders = types
+                .iter()
+                .enumerate()
+                .map(|(index, type_)| {
+                    self.encoder_for(&format!("{tuple}.{index}"), type_, new_indent)
+                })
+                .collect_vec();
+
+            if is_capture {
+                eco_format!(
+                    "fn(value) {{
+{indent}  {module_name}.preprocessed_array([
+{indent}    {encoders},
+{indent}  ])
+{indent}}}",
+                    indent = " ".repeat(indent),
+                    encoders = encoders.join(&format!(",\n{}", " ".repeat(new_indent))),
+                )
+            } else {
+                eco_format!(
+                    "{module_name}.preprocessed_array([
+{indent}  {encoders},
+{indent}])",
+                    indent = " ".repeat(indent),
+                    encoders = encoders.join(&format!(",\n{}", " ".repeat(new_indent))),
+                )
+            }
+        } else {
+            let type_information = type_.named_type_information();
+            let type_information: Option<(&str, &str, &[Arc<Type>])> =
+                type_information.as_ref().map(|(module, name, arguments)| {
+                    (module.as_str(), name.as_str(), arguments.as_slice())
+                });
+
+            match type_information {
+                Some(("gleam", "List", [element])) => {
+                    eco_format!(
+                        "{module_name}.array({encoded_value}, {map_function})",
+                        map_function = self.encoder_for("_", element, indent)
+                    )
+                }
+                Some(("gleam/option", "Option", [some])) => {
+                    eco_format!(
+                        "case {encoded_value} {{
+{indent}  {none} -> {module_name}.null()
+{indent}  {some}(value) -> {encoder}
+{indent}}}",
+                        indent = " ".repeat(indent),
+                        none = self
+                            .printer
+                            .print_constructor(&"gleam/option".into(), &"None".into()),
+                        some = self
+                            .printer
+                            .print_constructor(&"gleam/option".into(), &"Some".into()),
+                        encoder = self.encoder_for("value", some, indent + 2)
+                    )
+                }
+                Some(("gleam/dict", "Dict", [key, value])) => {
+                    let stringify_function = match key.named_type_information().as_ref().map(
+                        |(module, name, arguments)| {
+                            (module.as_str(), name.as_str(), arguments.as_slice())
+                        },
+                    ) {
+                        Some(("gleam", "String", [])) => "fn(string) { string }",
+                        _ => &format!(
+                            r#"todo as "Function to stringify {}""#,
+                            self.printer.print_type(key)
+                        ),
+                    };
+                    eco_format!(
+                        "{module_name}.dict({encoded_value}, {stringify_function}, {})",
+                        self.encoder_for("_", value, indent)
+                    )
+                }
+                Some((module, name, _)) if module == self.type_module && name == self.type_name => {
+                    maybe_capture(eco_format!("encode_{}", name.to_snake_case()))
+                }
+                _ => eco_format!(
+                    r#"todo as "Encoder for {}""#,
+                    self.printer.print_type(type_)
+                ),
+            }
+        }
+    }
+
+    fn encode_field(
+        &mut self,
+        record_name: &str,
+        field: &RecordField<'_>,
+        indent: usize,
+    ) -> EcoString {
+        let field_name = field.label.variable_name();
+        let encoder = self.encoder_for(&format!("{record_name}.{field_name}"), field.type_, indent);
+
+        eco_format!(
+            r#"{indent}#("{field_name}", {encoder})"#,
+            indent = " ".repeat(indent),
+        )
+    }
+}
+
 /// Builder for code action to pattern match on things like (anonymous) function
 /// arguments or variables.
 /// For example:
diff --git a/compiler-core/src/language_server/engine.rs b/compiler-core/src/language_server/engine.rs
index 3facc800c..d2a01ad34 100644
--- a/compiler-core/src/language_server/engine.rs
+++ b/compiler-core/src/language_server/engine.rs
@@ -35,8 +35,9 @@ use super::{
         code_action_convert_unqualified_constructor_to_qualified, code_action_import_module,
         code_action_inexhaustive_let_to_case, AddAnnotations, CodeActionBuilder,
         ConvertToFunctionCall, DesugarUse, ExpandFunctionCapture, ExtractVariable,
-        FillInMissingLabelledArgs, GenerateDynamicDecoder, GenerateFunction, LetAssertToCase,
-        PatternMatchOnValue, RedundantTupleInCaseSubject, TurnIntoUse, UseLabelShorthandSyntax,
+        FillInMissingLabelledArgs, GenerateDynamicDecoder, GenerateFunction, GenerateJsonEncoder,
+        LetAssertToCase, PatternMatchOnValue, RedundantTupleInCaseSubject, TurnIntoUse,
+        UseLabelShorthandSyntax,
     },
     completer::Completer,
     rename::{rename_local_variable, VariableRenameKind},
@@ -343,6 +344,7 @@ where
                 PatternMatchOnValue::new(module, &lines, &params, &this.compiler).code_actions(),
             );
             GenerateDynamicDecoder::new(module, &lines, &params, &mut actions).code_actions();
+            GenerateJsonEncoder::new(module, &lines, &params, &mut actions).code_actions();
             AddAnnotations::new(module, &lines, &params).code_action(&mut actions);
             Ok(if actions.is_empty() {
                 None
diff --git a/compiler-core/src/type_/printer.rs b/compiler-core/src/type_/printer.rs
index 6e8352067..b15a21e4d 100644
--- a/compiler-core/src/type_/printer.rs
+++ b/compiler-core/src/type_/printer.rs
@@ -1,5 +1,5 @@
 use bimap::BiMap;
-use ecow::EcoString;
+use ecow::{eco_format, EcoString};
 use im::HashMap;
 use std::{collections::HashSet, sync::Arc};
 
@@ -384,6 +384,21 @@ impl<'a> Printer<'a> {
         }
     }
 
+    pub fn print_constructor(&mut self, module: &EcoString, name: &EcoString) -> EcoString {
+        let (module, name) = match self.names.named_constructor(module, name) {
+            NameContextInformation::Qualified(module, name) => (Some(module), name),
+            NameContextInformation::Unqualified(name) => (None, name),
+            NameContextInformation::Unimported(name) => {
+                (Some(module.split('/').last().unwrap_or(module)), name)
+            }
+        };
+
+        match module {
+            Some(module) => eco_format!("{module}.{name}"),
+            None => name.into(),
+        }
+    }
+
     fn print_arguments(
         &mut self,
         args: &[Arc<Type>],


Test patch:
diff --git a/compiler-core/src/language_server/tests/action.rs b/compiler-core/src/language_server/tests/action.rs
index ff8c52d5f..48f6d46d5 100644
--- a/compiler-core/src/language_server/tests/action.rs
+++ b/compiler-core/src/language_server/tests/action.rs
@@ -71,6 +71,7 @@ const CONVERT_TO_USE: &str = "Convert to `use`";
 const EXTRACT_VARIABLE: &str = "Extract variable";
 const EXPAND_FUNCTION_CAPTURE: &str = "Expand function capture";
 const GENERATE_DYNAMIC_DECODER: &str = "Generate dynamic decoder";
+const GENERATE_JSON_ENCODER: &str = "Generate JSON encoder";
 const PATTERN_MATCH_ON_ARGUMENT: &str = "Pattern match on argument";
 const PATTERN_MATCH_ON_VARIABLE: &str = "Pattern match on variable";
 const GENERATE_FUNCTION: &str = "Generate function";
@@ -4889,6 +4890,19 @@ fn map(list: List(a), fun: fn(a) -> b) -> List(b) { todo }
     );
 }
 
+#[test]
+fn generate_json_encoder() {
+    assert_code_action!(
+        GENERATE_JSON_ENCODER,
+        "
+pub type Person {
+  Person(name: String, age: Int, height: Float, is_cool: Bool)
+}
+",
+        find_position_of("type").to_selection()
+    );
+}
+
 #[test]
 fn convert_to_function_call_works_with_argument_in_first_position_2() {
     assert_code_action!(
@@ -4904,6 +4918,33 @@ fn wibble(a) { todo }
     );
 }
 
+#[test]
+fn generate_json_encoder_complex_types() {
+    let src = "
+import gleam/option
+import gleam/dict
+
+pub type Something
+
+pub type Wibble(value) {
+  Wibble(
+    maybe: option.Option(Int),
+    something: Something,
+    map: dict.Dict(String, List(Float)),
+    unknown: List(value),
+  )
+}
+";
+
+    assert_code_action!(
+        GENERATE_JSON_ENCODER,
+        TestProject::for_source(src)
+            .add_module("gleam/option", "pub type Option(a)")
+            .add_module("gleam/dict", "pub type Dict(k, v)"),
+        find_position_of("type W").to_selection()
+    );
+}
+
 #[test]
 fn convert_to_function_call_works_with_argument_in_first_position_3() {
     assert_code_action!(
@@ -4919,6 +4960,23 @@ fn wibble(a) { todo }
     );
 }
 
+#[test]
+fn generate_json_encoder_already_imported_module() {
+    let src = "
+import gleam/json as json_encoding
+
+pub type Wibble {
+  Wibble(a: Int, b: Float, c: String)
+}
+";
+
+    assert_code_action!(
+        GENERATE_JSON_ENCODER,
+        TestProject::for_source(src).add_module("gleam/json", "pub type Json"),
+        find_position_of("type W").to_selection()
+    );
+}
+
 #[test]
 fn convert_to_function_call_works_with_argument_in_first_position_4() {
     assert_code_action!(
@@ -4932,6 +4990,19 @@ pub fn main() {
     );
 }
 
+#[test]
+fn generate_json_encoder_tuple() {
+    assert_code_action!(
+        GENERATE_JSON_ENCODER,
+        "
+pub type Wibble {
+  Wibble(tuple: #(Int, Float, #(String, Bool)))
+}
+",
+        find_position_of("type W").to_selection()
+    );
+}
+
 #[test]
 fn convert_to_function_call_works_with_function_producing_another_function() {
     assert_code_action!(
@@ -4949,6 +5020,23 @@ fn wibble(c) -> fn(a) -> Nil {
     );
 }
 
+#[test]
+fn generate_json_encoder_recursive_type() {
+    let src = "
+import gleam/option.{Some}
+
+pub type LinkedList {
+  LinkedList(value: Int, next: option.Option(LinkedList))
+}
+";
+    assert_code_action!(
+        GENERATE_JSON_ENCODER,
+        TestProject::for_source(src)
+            .add_module("gleam/option", "pub type Option(a) { Some(a) None }"),
+        find_position_of("type").to_selection()
+    );
+}
+
 #[test]
 fn convert_to_function_call_works_with_hole_in_first_position() {
     assert_code_action!(
@@ -4965,6 +5053,33 @@ fn map(list: List(a), fun: fn(a) -> b) -> List(b) { todo }
     );
 }
 
+#[test]
+fn generate_json_encoder_list_of_tuples() {
+    assert_code_action!(
+        GENERATE_JSON_ENCODER,
+        "
+pub type Wibble {
+  Wibble(values: List(#(Int, String)))
+}
+",
+        find_position_of("type").to_selection()
+    );
+}
+
+#[test]
+fn no_code_action_to_generate_json_encoder_for_multi_variant_type() {
+    assert_no_code_actions!(
+        GENERATE_JSON_ENCODER,
+        "
+pub type Wibble {
+  Wibble(wibble: Int)
+  Wobble(wobble: Float)
+}
+",
+        find_position_of("type").to_selection()
+    );
+}
+
 #[test]
 fn convert_to_function_call_works_with_hole_not_in_first_position() {
     assert_code_action!(
@@ -4998,3 +5113,16 @@ fn filter(list: List(a), fun: fn(a) -> Bool) -> List(b) { todo }
         find_position_of("[1, 2, 3]").select_until(find_position_of("map"))
     );
 }
+
+#[test]
+fn no_code_action_to_generate_json_encoder_for_type_without_labels() {
+    assert_no_code_actions!(
+        GENERATE_JSON_ENCODER,
+        "
+pub type Wibble {
+  Wibble(Int, Int, String)
+}
+",
+        find_position_of("type").to_selection()
+    );
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder.snap
new file mode 100644
index 000000000..6381a21b8
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder.snap
@@ -0,0 +1,27 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\npub type Person {\n  Person(name: String, age: Int, height: Float, is_cool: Bool)\n}\n"
+---
+----- BEFORE ACTION
+
+pub type Person {
+    ↑            
+  Person(name: String, age: Int, height: Float, is_cool: Bool)
+}
+
+
+----- AFTER ACTION
+import gleam/json
+
+pub type Person {
+  Person(name: String, age: Int, height: Float, is_cool: Bool)
+}
+
+fn encode_person(person: Person) -> json.Json {
+  json.object([
+    #("name", json.string(person.name)),
+    #("age", json.int(person.age)),
+    #("height", json.float(person.height)),
+    #("is_cool", json.bool(person.is_cool)),
+  ])
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_already_imported_module.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_already_imported_module.snap
new file mode 100644
index 000000000..fa0f56312
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_already_imported_module.snap
@@ -0,0 +1,29 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\nimport gleam/json as json_encoding\n\npub type Wibble {\n  Wibble(a: Int, b: Float, c: String)\n}\n"
+---
+----- BEFORE ACTION
+
+import gleam/json as json_encoding
+
+pub type Wibble {
+    ↑            
+  Wibble(a: Int, b: Float, c: String)
+}
+
+
+----- AFTER ACTION
+
+import gleam/json as json_encoding
+
+pub type Wibble {
+  Wibble(a: Int, b: Float, c: String)
+}
+
+fn encode_wibble(wibble: Wibble) -> json_encoding.Json {
+  json_encoding.object([
+    #("a", json_encoding.int(wibble.a)),
+    #("b", json_encoding.float(wibble.b)),
+    #("c", json_encoding.string(wibble.c)),
+  ])
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_complex_types.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_complex_types.snap
new file mode 100644
index 000000000..29ce59a19
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_complex_types.snap
@@ -0,0 +1,50 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\nimport gleam/option\nimport gleam/dict\n\npub type Something\n\npub type Wibble(value) {\n  Wibble(\n    maybe: option.Option(Int),\n    something: Something,\n    map: dict.Dict(String, List(Float)),\n    unknown: List(value),\n  )\n}\n"
+---
+----- BEFORE ACTION
+
+import gleam/option
+import gleam/dict
+
+pub type Something
+
+pub type Wibble(value) {
+    ↑                   
+  Wibble(
+    maybe: option.Option(Int),
+    something: Something,
+    map: dict.Dict(String, List(Float)),
+    unknown: List(value),
+  )
+}
+
+
+----- AFTER ACTION
+
+import gleam/json
+import gleam/option
+import gleam/dict
+
+pub type Something
+
+pub type Wibble(value) {
+  Wibble(
+    maybe: option.Option(Int),
+    something: Something,
+    map: dict.Dict(String, List(Float)),
+    unknown: List(value),
+  )
+}
+
+fn encode_wibble(wibble: Wibble(value)) -> json.Json {
+  json.object([
+    #("maybe", case wibble.maybe {
+      option.None -> json.null()
+      option.Some(value) -> json.int(value)
+    }),
+    #("something", todo as "Encoder for Something"),
+    #("map", json.dict(wibble.map, fn(string) { string }, json.array(_, json.float))),
+    #("unknown", json.array(wibble.unknown, todo as "Encoder for value")),
+  ])
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_list_of_tuples.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_list_of_tuples.snap
new file mode 100644
index 000000000..97c894960
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_list_of_tuples.snap
@@ -0,0 +1,29 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\npub type Wibble {\n  Wibble(values: List(#(Int, String)))\n}\n"
+---
+----- BEFORE ACTION
+
+pub type Wibble {
+    ↑            
+  Wibble(values: List(#(Int, String)))
+}
+
+
+----- AFTER ACTION
+import gleam/json
+
+pub type Wibble {
+  Wibble(values: List(#(Int, String)))
+}
+
+fn encode_wibble(wibble: Wibble) -> json.Json {
+  json.object([
+    #("values", json.array(wibble.values, fn(value) {
+      json.preprocessed_array([
+        json.int(value.0),
+        json.string(value.1),
+      ])
+    })),
+  ])
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_recursive_type.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_recursive_type.snap
new file mode 100644
index 000000000..18383bf43
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_recursive_type.snap
@@ -0,0 +1,32 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\nimport gleam/option.{Some}\n\npub type LinkedList {\n  LinkedList(value: Int, next: option.Option(LinkedList))\n}\n"
+---
+----- BEFORE ACTION
+
+import gleam/option.{Some}
+
+pub type LinkedList {
+    ↑                
+  LinkedList(value: Int, next: option.Option(LinkedList))
+}
+
+
+----- AFTER ACTION
+
+import gleam/json
+import gleam/option.{Some}
+
+pub type LinkedList {
+  LinkedList(value: Int, next: option.Option(LinkedList))
+}
+
+fn encode_linked_list(linked_list: LinkedList) -> json.Json {
+  json.object([
+    #("value", json.int(linked_list.value)),
+    #("next", case linked_list.next {
+      option.None -> json.null()
+      Some(value) -> encode_linked_list(value)
+    }),
+  ])
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_tuple.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_tuple.snap
new file mode 100644
index 000000000..5e4137c1c
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__action__generate_json_encoder_tuple.snap
@@ -0,0 +1,31 @@
+---
+source: compiler-core/src/language_server/tests/action.rs
+expression: "\npub type Wibble {\n  Wibble(tuple: #(Int, Float, #(String, Bool)))\n}\n"
+---
+----- BEFORE ACTION
+
+pub type Wibble {
+    ↑            
+  Wibble(tuple: #(Int, Float, #(String, Bool)))
+}
+
+
+----- AFTER ACTION
+import gleam/json
+
+pub type Wibble {
+  Wibble(tuple: #(Int, Float, #(String, Bool)))
+}
+
+fn encode_wibble(wibble: Wibble) -> json.Json {
+  json.object([
+    #("tuple", json.preprocessed_array([
+      json.int(wibble.tuple.0),
+      json.float(wibble.tuple.1),
+      json.preprocessed_array([
+        json.string(wibble.tuple.2.0),
+        json.bool(wibble.tuple.2.1),
+      ]),
+    ])),
+  ])
+}