Repository: gleam-lang/gleam (rust)

Issue:
LSP: Show module docs when hovering a module
I use the "View on hexdocs" link all the time but it doesn't work on modules 🙃 

```gleam
// show docs on hover here would be nice
import  mymodule



// show docs on hover my module here would be nice
mymodule.do_something()
```

There is bigger fish to fry, but still :)

Pull request description:
Implement LSP Hover for module names
Closes #3451 
This PR implements the ability to hover over a module name and see the documentation for it.
I've had to edit a couple of existing tests to account for the change in behaviour when hovering the module part of a module select.

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f4508094..a0358d5de 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -228,6 +228,10 @@
 
   ([Giacomo Cavalieri](https://github.com/giacomocavalieri))
 
+- The Language Server now shows module documentation when hovering over a module
+  name.
+  ([Surya Rose](https://github.com/GearsDatapacks))
+
 ### Formatter
 
 - Redundant function captures that take no additional arguments are now
@@ -264,6 +268,11 @@
   the package interface file if some modules were cached.
   ([Surya Rose](https://github.com/GearsDatapacks))
 
+- Fixed a bug where pattern matching using a UTF-8 string constant would not
+  work correctly on the JavaScript target when the string contained escape
+  characters.
+  ([Richard Viney](https://github.com/richard-viney))
+
 ## v1.8.1 - 2025-02-11
 
 ### Bug fixes
diff --git a/Cargo.lock b/Cargo.lock
index 5205aaac2..d990b39f9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -358,9 +358,9 @@ checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa"
 
 [[package]]
 name = "bytes"
-version = "1.8.0"
+version = "1.10.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da"
+checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
 
 [[package]]
 name = "camino"
@@ -1247,9 +1247,9 @@ dependencies = [
 
 [[package]]
 name = "http"
-version = "1.1.0"
+version = "1.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258"
+checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea"
 dependencies = [
  "bytes",
  "fnv",
@@ -2208,9 +2208,9 @@ dependencies = [
 
 [[package]]
 name = "pulldown-cmark"
-version = "0.12.2"
+version = "0.13.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0"
 dependencies = [
  "bitflags 2.5.0",
  "memchr",
diff --git a/compiler-core/src/ast.rs b/compiler-core/src/ast.rs
index 1e2015f22..7492ca9b5 100644
--- a/compiler-core/src/ast.rs
+++ b/compiler-core/src/ast.rs
@@ -419,7 +419,9 @@ impl TypeAst {
                     None
                 })
                 .or(Some(Located::Annotation(self.location(), type_))),
-            TypeAst::Constructor(TypeAstConstructor { arguments, .. }) => type_
+            TypeAst::Constructor(TypeAstConstructor {
+                arguments, module, ..
+            }) => type_
                 .constructor_types()
                 .and_then(|arg_types| {
                     if let Some(arg) = arguments
@@ -432,6 +434,17 @@ impl TypeAst {
 
                     None
                 })
+                .or(module.as_ref().and_then(|(name, location)| {
+                    if location.contains(byte_index) {
+                        Some(Located::ModuleName {
+                            location: *location,
+                            name,
+                            layer: Layer::Type,
+                        })
+                    } else {
+                        None
+                    }
+                }))
                 .or(Some(Located::Annotation(self.location(), type_))),
             TypeAst::Tuple(TypeAstTuple { elems, .. }) => type_
                 .tuple_types()
diff --git a/compiler-core/src/ast/typed.rs b/compiler-core/src/ast/typed.rs
index 8a027125a..803f52dff 100644
--- a/compiler-core/src/ast/typed.rs
+++ b/compiler-core/src/ast/typed.rs
@@ -200,6 +200,7 @@ impl TypedExpr {
             Self::ModuleSelect {
                 location,
                 field_start,
+                module_name,
                 ..
             } => {
                 // We want to return the `ModuleSelect` only when we're hovering
@@ -209,8 +210,17 @@ impl TypedExpr {
                     end: location.end,
                 };
 
+                // We subtract 1 so the location doesn't include the `.` character.
+                let module_span = SrcSpan::new(location.start, field_start - 1);
+
                 if field_span.contains(byte_index) {
                     Some(self.into())
+                } else if module_span.contains(byte_index) {
+                    Some(Located::ModuleName {
+                        location: module_span,
+                        name: module_name,
+                        layer: Layer::Value,
+                    })
                 } else {
                     None
                 }
diff --git a/compiler-core/src/build.rs b/compiler-core/src/build.rs
index f3b572cd5..7eb7d37a4 100644
--- a/compiler-core/src/build.rs
+++ b/compiler-core/src/build.rs
@@ -17,7 +17,7 @@ pub use self::project_compiler::{Built, Options, ProjectCompiler};
 pub use self::telemetry::{NullTelemetry, Telemetry};
 
 use crate::ast::{
-    CallArg, CustomType, DefinitionLocation, Pattern, TypeAst, TypedArg, TypedDefinition,
+    self, CallArg, CustomType, DefinitionLocation, Pattern, TypeAst, TypedArg, TypedDefinition,
     TypedExpr, TypedFunction, TypedPattern, TypedStatement,
 };
 use crate::type_::Type;
@@ -344,6 +344,11 @@ pub enum Located<'a> {
     Annotation(SrcSpan, std::sync::Arc<Type>),
     UnqualifiedImport(UnqualifiedImport<'a>),
     Label(SrcSpan, std::sync::Arc<Type>),
+    ModuleName {
+        location: SrcSpan,
+        name: &'a EcoString,
+        layer: ast::Layer,
+    },
 }
 
 impl<'a> Located<'a> {
@@ -398,6 +403,10 @@ impl<'a> Located<'a> {
             Self::Arg(_) => None,
             Self::Annotation(_, type_) => self.type_location(importable_modules, type_.clone()),
             Self::Label(_, _) => None,
+            Self::ModuleName { name, .. } => Some(DefinitionLocation {
+                module: Some((*name).clone()),
+                span: SrcSpan::new(0, 0),
+            }),
         }
     }
 
@@ -413,6 +422,7 @@ impl<'a> Located<'a> {
             Located::ModuleStatement(definition) => None,
             Located::FunctionBody(function) => None,
             Located::UnqualifiedImport(unqualified_import) => None,
+            Located::ModuleName { .. } => None,
         }
     }
 
diff --git a/compiler-core/src/build/package_compiler.rs b/compiler-core/src/build/package_compiler.rs
index cbbe7755f..865bd206d 100644
--- a/compiler-core/src/build/package_compiler.rs
+++ b/compiler-core/src/build/package_compiler.rs
@@ -193,7 +193,7 @@ where
             incomplete_modules,
         );
 
-        let mut modules = match outcome {
+        let modules = match outcome {
             Outcome::Ok(modules) => modules,
             Outcome::PartialFailure(modules, error) => {
                 return Outcome::PartialFailure(
@@ -207,10 +207,6 @@ where
             Outcome::TotalFailure(error) => return Outcome::TotalFailure(error),
         };
 
-        for mut module in modules.iter_mut() {
-            module.attach_doc_and_module_comments();
-        }
-
         tracing::debug!("performing_code_generation");
 
         if let Err(error) = self.perform_codegen(&modules) {
@@ -515,12 +511,8 @@ fn analyse(
             Outcome::Ok(ast) => {
                 // Module has compiled successfully. Make sure it isn't marked as incomplete.
                 let _ = incomplete_modules.remove(&name.clone());
-                // Register the types from this module so they can be imported into
-                // other modules.
-                let _ = module_types.insert(name.clone(), ast.type_info.clone());
-                // Register the successfully type checked module data so that it can be
-                // used for code generation and in the language server.
-                modules.push(Module {
+
+                let mut module = Module {
                     dependencies,
                     origin,
                     extra,
@@ -529,7 +521,15 @@ fn analyse(
                     code,
                     ast,
                     input_path: path,
-                });
+                };
+                module.attach_doc_and_module_comments();
+
+                // Register the types from this module so they can be imported into
+                // other modules.
+                let _ = module_types.insert(module.name.clone(), module.ast.type_info.clone());
+                // Register the successfully type checked module data so that it can be
+                // used for code generation and in the language server.
+                modules.push(module);
             }
 
             Outcome::PartialFailure(ast, errors) => {
diff --git a/compiler-core/src/javascript/pattern.rs b/compiler-core/src/javascript/pattern.rs
index e6a7413bd..15bc28f86 100644
--- a/compiler-core/src/javascript/pattern.rs
+++ b/compiler-core/src/javascript/pattern.rs
@@ -739,7 +739,7 @@ impl<'module_ctx, 'expression_gen, 'a> Generator<'module_ctx, 'expression_gen, '
 
                             [Opt::Utf8 { .. }] => match segment.value.as_ref() {
                                 Pattern::String { value, .. } => {
-                                    for byte in value.as_bytes() {
+                                    for byte in convert_string_escape_chars(value).as_bytes() {
                                         if offset.bits % 8 == 0 {
                                             self.push_byte_at(offset.bits / 8);
                                         } else {
diff --git a/compiler-core/src/language_server/code_action.rs b/compiler-core/src/language_server/code_action.rs
index a12b10e51..9101c5163 100644
--- a/compiler-core/src/language_server/code_action.rs
+++ b/compiler-core/src/language_server/code_action.rs
@@ -1333,19 +1333,15 @@ impl<'ast> ast::visit::Visit<'ast> for QualifiedToUnqualifiedImportFirstPass<'as
     ) {
         let range = src_span_to_lsp_range(*location, &self.line_numbers);
         if overlaps(self.params.range, range) {
-            if let Some((module_alias, module_location)) = module {
-                if let Some(import) =
-                    self.module
-                        .find_node(module_location.start)
-                        .and_then(|node| {
-                            if let Located::Annotation(_, ty) = node {
-                                if let Some((module, _)) = ty.named_type_name() {
-                                    return self.get_module_import(&module, name, ast::Layer::Type);
-                                }
-                            }
-                            None
-                        })
-                {
+            if let Some((module_alias, _)) = module {
+                if let Some(import) = self.module.find_node(location.end).and_then(|node| {
+                    if let Located::Annotation(_, ty) = node {
+                        if let Some((module, _)) = ty.named_type_name() {
+                            return self.get_module_import(&module, name, ast::Layer::Type);
+                        }
+                    }
+                    None
+                }) {
                     self.qualified_constructor = Some(QualifiedConstructor {
                         import,
                         module_aliased: import.as_name.is_some(),
diff --git a/compiler-core/src/language_server/engine.rs b/compiler-core/src/language_server/engine.rs
index fcd867eaa..277089ede 100644
--- a/compiler-core/src/language_server/engine.rs
+++ b/compiler-core/src/language_server/engine.rs
@@ -2,8 +2,8 @@ use crate::{
     Error, Result, Warning,
     analyse::name::correct_name_case,
     ast::{
-        ArgNames, CustomType, Definition, DefinitionLocation, ModuleConstant, Pattern, SrcSpan,
-        TypedArg, TypedExpr, TypedFunction, TypedModule, TypedPattern,
+        self, ArgNames, CustomType, Definition, DefinitionLocation, ModuleConstant, Pattern,
+        SrcSpan, TypedArg, TypedExpr, TypedFunction, TypedModule, TypedPattern,
     },
     build::{Located, Module, UnqualifiedImport, type_constructor_from_modules},
     config::PackageConfig,
@@ -27,7 +27,7 @@ use lsp_types::{
     PrepareRenameResponse, Range, SignatureHelp, SymbolKind, SymbolTag, TextEdit, Url,
     WorkspaceEdit,
 };
-use std::sync::Arc;
+use std::{collections::HashSet, sync::Arc};
 
 use super::{
     DownloadDependencies, MakeLocker,
@@ -81,7 +81,7 @@ pub struct LanguageServerEngine<IO, Reporter> {
 
     /// Used to know if to show the "View on HexDocs" link
     /// when hovering on an imported value
-    hex_deps: std::collections::HashSet<EcoString>,
+    hex_deps: HashSet<EcoString>,
 }
 
 impl<'a, IO, Reporter> LanguageServerEngine<IO, Reporter>
@@ -322,6 +322,15 @@ where
                 Located::Annotation(_, _) => Some(completer.completion_types()),
 
                 Located::Label(_, _) => None,
+
+                Located::ModuleName {
+                    layer: ast::Layer::Type,
+                    ..
+                } => Some(completer.completion_types()),
+                Located::ModuleName {
+                    layer: ast::Layer::Value,
+                    ..
+                } => Some(completer.completion_values()),
             };
 
             Ok(completions)
@@ -729,6 +738,17 @@ where
                 Located::ModuleStatement(Definition::ModuleConstant(constant)) => {
                     Some(hover_for_module_constant(constant, lines, module))
                 }
+                Located::ModuleStatement(Definition::Import(import)) => {
+                    let Some(module) = this.compiler.get_module_interface(&import.module) else {
+                        return Ok(None);
+                    };
+                    Some(hover_for_module(
+                        module,
+                        import.location,
+                        &lines,
+                        &this.hex_deps,
+                    ))
+                }
                 Located::ModuleStatement(_) => None,
                 Located::UnqualifiedImport(UnqualifiedImport {
                     name,
@@ -828,6 +848,12 @@ Unused labelled fields:
                 Located::Label(location, type_) => {
                     Some(hover_for_label(location, type_, lines, module))
                 }
+                Located::ModuleName { location, name, .. } => {
+                    let Some(module) = this.compiler.get_module_interface(name) else {
+                        return Ok(None);
+                    };
+                    Some(hover_for_module(module, location, &lines, &this.hex_deps))
+                }
             })
         })
     }
@@ -1134,7 +1160,7 @@ fn hover_for_expression(
     expression: &TypedExpr,
     line_numbers: LineNumbers,
     module: &Module,
-    hex_deps: &std::collections::HashSet<EcoString>,
+    hex_deps: &HashSet<EcoString>,
 ) -> Hover {
     let documentation = expression.get_documentation().unwrap_or_default();
 
@@ -1169,7 +1195,7 @@ fn hover_for_imported_value(
     let documentation = value.get_documentation().unwrap_or_default();
 
     let link_section = hex_module_imported_from.map_or("".to_string(), |m| {
-        format_hexdocs_link_section(m.package.as_str(), m.name.as_str(), name)
+        format_hexdocs_link_section(m.package.as_str(), m.name.as_str(), Some(name))
     });
 
     // Show the type of the hovered node to the user
@@ -1186,6 +1212,34 @@ fn hover_for_imported_value(
     }
 }
 
+fn hover_for_module(
+    module: &ModuleInterface,
+    location: SrcSpan,
+    line_numbers: &LineNumbers,
+    hex_deps: &HashSet<EcoString>,
+) -> Hover {
+    let documentation = module.documentation.join("\n");
+    let name = &module.name;
+
+    let link_section = if hex_deps.contains(&module.package) {
+        format_hexdocs_link_section(&module.package, name, None)
+    } else {
+        String::new()
+    };
+
+    let contents = format!(
+        "```gleam
+{name}
+```
+{documentation}
+{link_section}",
+    );
+    Hover {
+        contents: HoverContents::Scalar(MarkedString::String(contents)),
+        range: Some(src_span_to_lsp_range(location, line_numbers)),
+    }
+}
+
 // Returns true if any part of either range overlaps with the other.
 pub fn overlaps(a: Range, b: Range) -> bool {
     position_within(a.start, b)
@@ -1417,8 +1471,15 @@ fn get_expr_qualified_name(expression: &TypedExpr) -> Option<(&EcoString, &EcoSt
     }
 }
 
-fn format_hexdocs_link_section(package_name: &str, module_name: &str, name: &str) -> String {
-    let link = format!("https://hexdocs.pm/{package_name}/{module_name}.html#{name}");
+fn format_hexdocs_link_section(
+    package_name: &str,
+    module_name: &str,
+    name: Option<&str>,
+) -> String {
+    let link = match name {
+        Some(name) => format!("https://hexdocs.pm/{package_name}/{module_name}.html#{name}"),
+        None => format!("https://hexdocs.pm/{package_name}/{module_name}.html"),
+    };
     format!("\nView on [HexDocs]({link})")
 }
 
@@ -1426,7 +1487,7 @@ fn get_hexdocs_link_section(
     module_name: &str,
     name: &str,
     ast: &TypedModule,
-    hex_deps: &std::collections::HashSet<EcoString>,
+    hex_deps: &HashSet<EcoString>,
 ) -> Option<String> {
     let package_name = ast.definitions.iter().find_map(|def| match def {
         Definition::Import(p) if p.module == module_name && hex_deps.contains(&p.package) => {
@@ -1435,7 +1496,11 @@ fn get_hexdocs_link_section(
         _ => None,
     })?;
 
-    Some(format_hexdocs_link_section(package_name, module_name, name))
+    Some(format_hexdocs_link_section(
+        package_name,
+        module_name,
+        Some(name),
+    ))
 }
 
 /// Converts the source start position of a documentation comment's contents into
diff --git a/compiler-wasm/Cargo.toml b/compiler-wasm/Cargo.toml
index 6682a959f..0747bdb14 100644
--- a/compiler-wasm/Cargo.toml
+++ b/compiler-wasm/Cargo.toml
@@ -13,7 +13,7 @@ crate-type = ["cdylib", "rlib"]
 gleam-core = { path = "../compiler-core" }
 console_error_panic_hook = "0"
 serde-wasm-bindgen = "0"
-wasm-bindgen = { version = "0", features = ["serde-serialize"] }
+wasm-bindgen = { version = "=0.2.99", features = ["serde-serialize"] }
 tracing-wasm = "*"
 camino.workspace = true
 hexpm.workspace = true


Test patch:
diff --git a/compiler-core/src/ast/tests.rs b/compiler-core/src/ast/tests.rs
index da2af8dba..2f7b07c1c 100644
--- a/compiler-core/src/ast/tests.rs
+++ b/compiler-core/src/ast/tests.rs
@@ -384,7 +384,14 @@ fn find_node_module_select() {
     };
 
     assert_eq!(expr.find_node(0), None);
-    assert_eq!(expr.find_node(1), None);
+    assert_eq!(
+        expr.find_node(1),
+        Some(Located::ModuleName {
+            location: SrcSpan::new(1, 1),
+            name: &"name".into(),
+            layer: super::Layer::Value
+        })
+    );
     assert_eq!(expr.find_node(2), Some(Located::Expression(&expr)));
     assert_eq!(expr.find_node(3), Some(Located::Expression(&expr)));
 }
diff --git a/compiler-core/src/javascript/tests/bit_arrays.rs b/compiler-core/src/javascript/tests/bit_arrays.rs
index 6cff451b8..a6a139782 100644
--- a/compiler-core/src/javascript/tests/bit_arrays.rs
+++ b/compiler-core/src/javascript/tests/bit_arrays.rs
@@ -270,6 +270,17 @@ fn go(x) {
     );
 }
 
+#[test]
+fn match_utf8_with_escape_chars() {
+    assert_js!(
+        r#"
+fn go(x) {
+  let assert <<"\"\\\r\n\t\f\u{1f600}">> = x
+}
+"#,
+    );
+}
+
 #[test]
 fn match_utf8() {
     assert_js!(
diff --git a/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__bit_arrays__match_utf8_with_escape_chars.snap b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__bit_arrays__match_utf8_with_escape_chars.snap
new file mode 100644
index 000000000..05f51fe41
--- /dev/null
+++ b/compiler-core/src/javascript/tests/snapshots/gleam_core__javascript__tests__bit_arrays__match_utf8_with_escape_chars.snap
@@ -0,0 +1,39 @@
+---
+source: compiler-core/src/javascript/tests/bit_arrays.rs
+expression: "\nfn go(x) {\n  let assert <<\"\\\"\\\\\\r\\n\\t\\f\\u{1f600}\">> = x\n}\n"
+---
+----- SOURCE CODE
+
+fn go(x) {
+  let assert <<"\"\\\r\n\t\f\u{1f600}">> = x
+}
+
+
+----- COMPILED JAVASCRIPT
+import { makeError } from "../gleam.mjs";
+
+function go(x) {
+  if (
+    x.byteAt(0) !== 34 ||
+    x.byteAt(1) !== 92 ||
+    x.byteAt(2) !== 13 ||
+    x.byteAt(3) !== 10 ||
+    x.byteAt(4) !== 9 ||
+    x.byteAt(5) !== 12 ||
+    x.byteAt(6) !== 240 ||
+    x.byteAt(7) !== 159 ||
+    x.byteAt(8) !== 152 ||
+    x.byteAt(9) !== 128 ||
+    !(x.bitSize == 80)
+  ) {
+    throw makeError(
+      "let_assert",
+      "my/mod",
+      3,
+      "go",
+      "Pattern match failed, no pattern matched the value.",
+      { value: x }
+    )
+  }
+  return x;
+}
diff --git a/compiler-core/src/language_server/tests/definition.rs b/compiler-core/src/language_server/tests/definition.rs
index 70f2c30b5..488044e85 100644
--- a/compiler-core/src/language_server/tests/definition.rs
+++ b/compiler-core/src/language_server/tests/definition.rs
@@ -791,3 +791,19 @@ pub fn main() {
         find_position_of("w: Wibble").under_char('i')
     );
 }
+
+#[test]
+fn goto_definition_module() {
+    let code = "
+import wibble
+
+pub fn main() {
+  wibble.wibble()
+}
+";
+
+    assert_goto!(
+        TestProject::for_source(code).add_module("wibble", "pub fn wibble() {}"),
+        find_position_of("wibble.").under_char('i')
+    );
+}
diff --git a/compiler-core/src/language_server/tests/hover.rs b/compiler-core/src/language_server/tests/hover.rs
index 68634c2c6..084200d75 100644
--- a/compiler-core/src/language_server/tests/hover.rs
+++ b/compiler-core/src/language_server/tests/hover.rs
@@ -1069,7 +1069,7 @@ fn make_wibble() -> wobble.Wibble { wobble.Wibble }
 
     assert_hover!(
         TestProject::for_source(code).add_hex_module("wibble/wobble", "pub type Wibble { Wibble }"),
-        find_position_of("-> wobble").under_char('o')
+        find_position_of("-> wobble.Wibble").under_char('i')
     );
 }
 
@@ -1131,7 +1131,7 @@ fn main(wibble: wubble.Wibble) {
 
     assert_hover!(
         TestProject::for_source(code).add_hex_module("wibble/wobble", "pub type Wibble { Wibble }"),
-        find_position_of(": wubble").under_char('e')
+        find_position_of(": wubble.Wibble").under_char('W')
     );
 }
 
@@ -1479,3 +1479,113 @@ fn filter(list: List(a), fun: fn(a) -> Bool) -> List(a) { todo }
         find_position_of("fn(value)")
     );
 }
+
+#[test]
+fn hover_over_module_name() {
+    let src = "
+import wibble
+
+pub fn main() {
+  wibble.wibble()
+}
+";
+    assert_hover!(
+        TestProject::for_source(src).add_hex_module(
+            "wibble",
+            "
+//// This is the wibble module.
+//// Here is some documentation about it.
+//// This module does stuff
+
+pub fn wibble() {
+  todo
+}
+"
+        ),
+        find_position_of("wibble.")
+    );
+}
+
+#[test]
+fn hover_over_module_with_path() {
+    let src = "
+import wibble/wobble
+
+pub fn main() {
+  wobble.wibble()
+}
+";
+    assert_hover!(
+        TestProject::for_source(src).add_hex_module(
+            "wibble/wobble",
+            "
+//// The module documentation
+
+pub fn wibble() {
+  todo
+}
+"
+        ),
+        find_position_of("wobble.")
+    );
+}
+
+#[test]
+fn hover_over_module_name_in_annotation() {
+    let src = "
+import wibble
+
+pub fn main(w: wibble.Wibble) {
+  todo
+}
+";
+    assert_hover!(
+        TestProject::for_source(src).add_hex_module(
+            "wibble",
+            "
+//// This is the wibble module.
+//// Here is some documentation about it.
+//// This module does stuff
+
+pub type Wibble
+"
+        ),
+        find_position_of("wibble.")
+    );
+}
+
+#[test]
+fn hover_over_imported_module() {
+    let src = "
+import wibble
+";
+    assert_hover!(
+        TestProject::for_source(src).add_hex_module(
+            "wibble",
+            "
+//// This is the wibble module.
+//// Here is some documentation about it.
+//// This module does stuff
+"
+        ),
+        find_position_of("wibble")
+    );
+}
+
+#[test]
+fn no_hexdocs_link_when_hovering_over_local_module() {
+    let src = "
+import wibble
+";
+    assert_hover!(
+        TestProject::for_source(src).add_module(
+            "wibble",
+            "
+//// This is the wibble module.
+//// Here is some documentation about it.
+//// This module does stuff
+"
+        ),
+        find_position_of("wibble")
+    );
+}
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__definition__goto_definition_module.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__definition__goto_definition_module.snap
new file mode 100644
index 000000000..539ec1311
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__definition__goto_definition_module.snap
@@ -0,0 +1,16 @@
+---
+source: compiler-core/src/language_server/tests/definition.rs
+expression: output
+---
+----- Jumping from `src/app.gleam`
+
+import wibble
+
+pub fn main() {
+  wibble.wibble()
+   ↑             
+}
+
+----- Jumped to `src/wibble.gleam`
+pub fn wibble() {}
+↑
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation.snap
index 4111d75a8..ce9dc468f 100644
--- a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation.snap
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation.snap
@@ -5,7 +5,7 @@ expression: "\nimport wibble/wobble\n\nfn make_wibble() -> wobble.Wibble { wobbl
 import wibble/wobble
 
 fn make_wibble() -> wobble.Wibble { wobble.Wibble }
-                    ▔↑▔▔▔▔▔▔▔▔▔▔▔                  
+                    ▔▔▔▔▔▔▔▔↑▔▔▔▔                  
 
 
 ----- Hover content -----
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation_aliased_module.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation_aliased_module.snap
index 4b8e44174..742050077 100644
--- a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation_aliased_module.snap
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_contextual_type_annotation_aliased_module.snap
@@ -5,7 +5,7 @@ expression: "\nimport wibble/wobble as wubble\n\nfn main(wibble: wubble.Wibble)
 import wibble/wobble as wubble
 
 fn main(wibble: wubble.Wibble) {
-                ▔▔▔▔▔↑▔▔▔▔▔▔▔   
+                ▔▔▔▔▔▔▔↑▔▔▔▔▔   
   wibble
 }
 
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_imported_module.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_imported_module.snap
new file mode 100644
index 000000000..099d9d91a
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_imported_module.snap
@@ -0,0 +1,14 @@
+---
+source: compiler-core/src/language_server/tests/hover.rs
+expression: "\nimport wibble\n"
+---
+import wibble
+▔▔▔▔▔▔▔↑▔▔▔▔▔
+
+
+----- Hover content -----
+Scalar(
+    String(
+        "```gleam\nwibble\n```\n This is the wibble module.\n Here is some documentation about it.\n This module does stuff\n\nView on [HexDocs](https://hexdocs.pm/hex/wibble.html)",
+    ),
+)
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_name.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_name.snap
new file mode 100644
index 000000000..ea91f5451
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_name.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/hover.rs
+expression: "\nimport wibble\n\npub fn main() {\n  wibble.wibble()\n}\n"
+---
+import wibble
+
+pub fn main() {
+  wibble.wibble()
+  ↑▔▔▔▔▔         
+}
+
+
+----- Hover content -----
+Scalar(
+    String(
+        "```gleam\nwibble\n```\n This is the wibble module.\n Here is some documentation about it.\n This module does stuff\n\nView on [HexDocs](https://hexdocs.pm/hex/wibble.html)",
+    ),
+)
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_name_in_annotation.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_name_in_annotation.snap
new file mode 100644
index 000000000..da84b6d16
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_name_in_annotation.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/hover.rs
+expression: "\nimport wibble\n\npub fn main(w: wibble.Wibble) {\n  todo\n}\n"
+---
+import wibble
+
+pub fn main(w: wibble.Wibble) {
+               ↑▔▔▔▔▔          
+  todo
+}
+
+
+----- Hover content -----
+Scalar(
+    String(
+        "```gleam\nwibble\n```\n This is the wibble module.\n Here is some documentation about it.\n This module does stuff\n\nView on [HexDocs](https://hexdocs.pm/hex/wibble.html)",
+    ),
+)
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_with_path.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_with_path.snap
new file mode 100644
index 000000000..98e85d8aa
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__hover_over_module_with_path.snap
@@ -0,0 +1,18 @@
+---
+source: compiler-core/src/language_server/tests/hover.rs
+expression: "\nimport wibble/wobble\n\npub fn main() {\n  wobble.wibble()\n}\n"
+---
+import wibble/wobble
+
+pub fn main() {
+  wobble.wibble()
+  ↑▔▔▔▔▔         
+}
+
+
+----- Hover content -----
+Scalar(
+    String(
+        "```gleam\nwibble/wobble\n```\n The module documentation\n\nView on [HexDocs](https://hexdocs.pm/hex/wibble/wobble.html)",
+    ),
+)
diff --git a/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__no_hexdocs_link_when_hovering_over_local_module.snap b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__no_hexdocs_link_when_hovering_over_local_module.snap
new file mode 100644
index 000000000..b8834ee34
--- /dev/null
+++ b/compiler-core/src/language_server/tests/snapshots/gleam_core__language_server__tests__hover__no_hexdocs_link_when_hovering_over_local_module.snap
@@ -0,0 +1,14 @@
+---
+source: compiler-core/src/language_server/tests/hover.rs
+expression: "\nimport wibble\n"
+---
+import wibble
+▔▔▔▔▔▔▔↑▔▔▔▔▔
+
+
+----- Hover content -----
+Scalar(
+    String(
+        "```gleam\nwibble\n```\n This is the wibble module.\n Here is some documentation about it.\n This module does stuff\n",
+    ),
+)