Repository: effekt-lang/effekt (scala)

Issue:
Show documentation on hover
> [!WARNING]
> Blocked on #930

On hover, show the attached documentation to that definition:

1. add a `documentation` to `SymbolInfo`:

https://github.com/effekt-lang/effekt/blob/5ad455b8b9ac2f84e68d7f1481ee9e4a280eb811/effekt/shared/src/main/scala/effekt/Intelligence.scala#L17-L40

2. then populate it here:

https://github.com/effekt-lang/effekt/blob/5ad455b8b9ac2f84e68d7f1481ee9e4a280eb811/effekt/shared/src/main/scala/effekt/Intelligence.scala#L194

Pull request description:
Closes #1006, blocked by #930.

Patch:
diff --git a/effekt/shared/src/main/scala/effekt/Intelligence.scala b/effekt/shared/src/main/scala/effekt/Intelligence.scala
index 235d578fa..c357ecae7 100644
--- a/effekt/shared/src/main/scala/effekt/Intelligence.scala
+++ b/effekt/shared/src/main/scala/effekt/Intelligence.scala
@@ -1,7 +1,7 @@
 package effekt
 
 import effekt.context.{Annotations, Context}
-import effekt.source.{FunDef, Include, Maybe, ModuleDecl, Span, Tree}
+import effekt.source.{FunDef, Include, Maybe, ModuleDecl, Span, Tree, Doc}
 import effekt.symbols.{CaptureSet, Hole}
 import kiama.util.{Position, Source}
 import effekt.symbols.scopes.Scope
@@ -18,27 +18,35 @@ trait Intelligence {
     symbol: Symbol,
     header: String,
     signature: Option[String],
-    description: Option[String]
+    description: Option[String],
+    documentation: Doc
   ) {
     def fullDescription: String = {
       val sig = signature.map(sig => s"```effekt\n$sig\n```").getOrElse { "" }
       val desc = description.getOrElse("")
+      val doc = showDocumentation(documentation)
 
       s"""|#### $header
           |$sig
-          |$desc
+          |$desc$doc
           |""".stripMargin
     }
 
     def shortDescription: String = {
       val sig = signature.map(sig => s"```effekt\n$sig\n```").getOrElse { "" }
+      val doc = showDocumentation(documentation)
 
       s"""|#### $header
-          |$sig
+          |$sig$doc
           |""".stripMargin
     }
   }
 
+  def showDocumentation(documentation: Doc): String =
+    documentation.map('\n' +: _)
+      .getOrElse("")
+      .replace("\\n", "\n")
+
   private def sortByPosition(trees: Vector[Tree])(using C: Context): Vector[Tree] =
     val pos = C.positions
     trees.sortWith {
@@ -110,6 +118,16 @@ trait Intelligence {
     case u => C.definitionTreeOption(u)
   }
 
+  def getDocumentationOf(s: Symbol)(using C: Context): Doc =
+    getDefinitionOf(s).asInstanceOf[Option[Any]] match {
+      case Some(p: Product) =>
+        p.productElementNames.zip(p.productIterator)
+          .collectFirst {
+            case ("doc", Some(s: String)) => s
+          }
+      case _ => None
+  }
+
   // For now, only show the first call target
   def resolveCallTarget(sym: Symbol): Symbol = sym match {
     case t: CallTarget => t.symbols.flatten.headOption.getOrElse(sym)
@@ -204,12 +222,15 @@ trait Intelligence {
   def getInfoOf(sym: Symbol)(using C: Context): Option[SymbolInfo] = PartialFunction.condOpt(resolveCallTarget(sym)) {
 
     case b: ExternFunction =>
-      SymbolInfo(b, "External function definition", Some(DeclPrinter(b)), None)
+      val doc = getDocumentationOf(b)
+      SymbolInfo(b, "External function definition", Some(DeclPrinter(b)), None, doc)
 
     case f: UserFunction if C.functionTypeOption(f).isDefined =>
-      SymbolInfo(f, "Function", Some(DeclPrinter(f)), None)
+      val doc = getDocumentationOf(f)
+      SymbolInfo(f, "Function", Some(DeclPrinter(f)), None, doc)
 
     case f: Operation =>
+      val doc = getDocumentationOf(f)
       val ex =
         pp"""|Effect operations, like `${f.name}` allow to express non-local control flow.
              |
@@ -229,32 +250,39 @@ trait Intelligence {
              |handled by the handler. This is important when considering higher-order functions.
              |""".stripMargin
 
-      SymbolInfo(f, "Effect operation", Some(DeclPrinter(f)), Some(ex))
+      SymbolInfo(f, "Effect operation", Some(DeclPrinter(f)), Some(ex), doc)
 
     case f: EffectAlias =>
-      SymbolInfo(f, "Effect alias", Some(DeclPrinter(f)), None)
+      val doc = getDocumentationOf(f)
+      SymbolInfo(f, "Effect alias", Some(DeclPrinter(f)), None, doc)
 
     case t: TypeAlias =>
-      SymbolInfo(t, "Type alias", Some(DeclPrinter(t)), None)
+      val doc = getDocumentationOf(t)
+      SymbolInfo(t, "Type alias", Some(DeclPrinter(t)), None, doc)
 
     case t: ExternType =>
-      SymbolInfo(t, "External type definition", Some(DeclPrinter(t)), None)
+      val doc = getDocumentationOf(t)
+      SymbolInfo(t, "External type definition", Some(DeclPrinter(t)), None, doc)
 
     case t: ExternInterface =>
-      SymbolInfo(t, "External interface definition", Some(DeclPrinter(t)), None)
+      val doc = getDocumentationOf(t)
+      SymbolInfo(t, "External interface definition", Some(DeclPrinter(t)), None, doc)
 
     case t: ExternResource =>
-      SymbolInfo(t, "External resource definition", Some(DeclPrinter(t)), None)
+      val doc = getDocumentationOf(t)
+      SymbolInfo(t, "External resource definition", Some(DeclPrinter(t)), None, doc)
 
     case c: Constructor =>
+      val doc = getDocumentationOf(c)
       val ex = pp"""|Instances of data types like `${c.tpe}` can only store
                     |_values_, not _blocks_. Hence, constructors like `${c.name}` only have
                     |value parameter lists, not block parameters.
                     |""".stripMargin
 
-      SymbolInfo(c, s"Constructor of data type `${c.tpe}`", Some(DeclPrinter(c)), Some(ex))
+      SymbolInfo(c, s"Constructor of data type `${c.tpe}`", Some(DeclPrinter(c)), Some(ex), doc)
 
     case c: BlockParam =>
+      val doc = getDocumentationOf(c)
       val signature = C.functionTypeOption(c).map { tpe => pp"{ ${c.name}: ${tpe} }" }
 
       val ex =
@@ -267,9 +295,10 @@ trait Intelligence {
             |yielded to.
             |""".stripMargin
 
-      SymbolInfo(c, "Block parameter", signature, Some(ex))
+      SymbolInfo(c, "Block parameter", signature, Some(ex), doc)
 
     case c: ResumeParam =>
+      val doc = getDocumentationOf(c)
       val tpe = C.functionTypeOption(c)
       val signature = tpe.map { tpe => pp"{ ${c.name}: ${tpe} }" }
       val hint = tpe.map { tpe => pp"(i.e., `${tpe.result}`)" }.getOrElse { " " }
@@ -284,9 +313,10 @@ trait Intelligence {
             |- the return type of the resumption.
             |""".stripMargin
 
-      SymbolInfo(c, "Resumption", signature, Some(ex))
+      SymbolInfo(c, "Resumption", signature, Some(ex), doc)
 
     case c: VarBinder =>
+      val doc = getDocumentationOf(c)
       val signature = C.blockTypeOption(c).map(TState.extractType).orElse(c.tpe).map { tpe => pp"${c.name}: ${tpe}" }
 
       val ex =
@@ -299,9 +329,10 @@ trait Intelligence {
             |combination with effect handlers.
          """.stripMargin
 
-      SymbolInfo(c, "Mutable variable binder", signature, Some(ex))
+      SymbolInfo(c, "Mutable variable binder", signature, Some(ex), doc)
 
     case s: RegBinder =>
+      val doc = getDocumentationOf(s)
       val signature = C.blockTypeOption(s).map(TState.extractType).orElse(s.tpe).map { tpe => pp"${s.name}: ${tpe}" }
 
       val ex =
@@ -310,19 +341,22 @@ trait Intelligence {
             |in combination with continuation capture and resumption.
             |""".stripMargin
 
-      SymbolInfo(s, "Variable in region", signature, Some(ex))
+      SymbolInfo(s, "Variable in region", signature, Some(ex), doc)
 
     case c: ValueParam =>
+      val doc = getDocumentationOf(c)
       val signature = C.valueTypeOption(c).orElse(c.tpe).map { tpe => pp"${c.name}: ${tpe}" }
-      SymbolInfo(c, "Value parameter", signature, None)
+      SymbolInfo(c, "Value parameter", signature, None, doc)
 
     case c: ValBinder =>
+      val doc = getDocumentationOf(c)
       val signature = C.valueTypeOption(c).orElse(c.tpe).map { tpe => pp"${c.name}: ${tpe}" }
-      SymbolInfo(c, "Value binder", signature, None)
+      SymbolInfo(c, "Value binder", signature, None, doc)
 
     case c: DefBinder =>
+      val doc = getDocumentationOf(c)
       val signature = C.blockTypeOption(c).orElse(c.tpe).map { tpe => pp"${c.name}: ${tpe}" }
-      SymbolInfo(c, "Block binder", signature, None)
+      SymbolInfo(c, "Block binder", signature, None, doc)
   }
 }
 
diff --git a/effekt/shared/src/main/scala/effekt/RecursiveDescent.scala b/effekt/shared/src/main/scala/effekt/RecursiveDescent.scala
index c2138bc1a..409609e1f 100644
--- a/effekt/shared/src/main/scala/effekt/RecursiveDescent.scala
+++ b/effekt/shared/src/main/scala/effekt/RecursiveDescent.scala
@@ -311,13 +311,19 @@ class RecursiveDescent(positions: Positions, tokens: Seq[Token], source: Source)
      nonterminal:
        // skip spaces at the start
        spaces()
-       val doc = maybeDocumentation()
-       val res = ModuleDecl(moduleDecl(), manyWhile(includeDecl(), `import`), toplevelDefs(), doc, span())
+       val (name, doc) = moduleDecl()
+       val res = ModuleDecl(name, manyWhile(includeDecl(), `import`), toplevelDefs(), doc, span())
        if peek(`EOF`) then res else fail("Unexpected end of input")
        // failure("Required at least one top-level function or effect definition")
 
-  def moduleDecl(): String =
-    when(`module`) { moduleName() } { defaultModulePath }
+  def moduleDecl(): Tuple2[String, Doc] =
+    documentedKind match {
+      case `module` =>
+        val doc = maybeDocumentation()
+        consume(`module`)
+        (moduleName(), doc)
+      case _ => (defaultModulePath, None)
+    }
 
   // we are purposefully not using File here since the parser needs to work both
   // on the JVM and in JavaScript
diff --git a/examples/benchmarks/other/nbe.effekt b/examples/benchmarks/other/nbe.effekt
index 64304ea52..e0791105b 100644
--- a/examples/benchmarks/other/nbe.effekt
+++ b/examples/benchmarks/other/nbe.effekt
@@ -1,5 +1,6 @@
 /// Demo of an effectful Normalization by Evaluation (NbE) implementation for the pure, untyped lambda calculus.
 /// Uses the Call-by-Value reduction order, same as host.
+module examples/benchmarks/nbe
 
 import io
 import map


Test patch:
diff --git a/effekt/jvm/src/test/scala/effekt/LSPTests.scala b/effekt/jvm/src/test/scala/effekt/LSPTests.scala
index c1234abc9..70213abf3 100644
--- a/effekt/jvm/src/test/scala/effekt/LSPTests.scala
+++ b/effekt/jvm/src/test/scala/effekt/LSPTests.scala
@@ -267,6 +267,37 @@ class LSPTests extends FunSuite {
     }
   }
 
+  test("Hovering over documented symbol shows doc comment") {
+    withClientAndServer { (client, server) =>
+      val (textDoc, cursor) = raw"""
+                                |/// Calculate the answer to the ultimate question of life, the universe, and everything
+                                |def calculate() = 42
+                                |
+                                |def main() = println(calculate())
+                                |                     ↑
+                                |""".textDocumentAndPosition
+      val hoverContents =
+        raw"""#### Function
+             |```effekt
+             |def calculate(): Int / {}
+             |```
+             | Calculate the answer to the ultimate question of life, the universe, and everything
+             |""".stripMargin
+
+      val didOpenParams = new DidOpenTextDocumentParams()
+      didOpenParams.setTextDocument(textDoc)
+      server.getTextDocumentService().didOpen(didOpenParams)
+
+      val hoverParams = new HoverParams(textDoc.versionedTextDocumentIdentifier, cursor)
+      val hover = server.getTextDocumentService().hover(hoverParams).get()
+
+      val expectedHover = new Hover()
+      expectedHover.setRange(new Range(cursor, cursor))
+      expectedHover.setContents(new MarkupContent("markdown", hoverContents))
+      assertEquals(hover, expectedHover)
+    }
+  }
+
   test("Hovering over hole shows inside and outside types") {
     withClientAndServer { (client, server) =>
       val (textDoc, cursor) = raw"""