Repository: effekt-lang/effekt (scala)

Issue:
Wrong capture hint position on documented definitions
I would assume the capture would be before the `def`, _after_ the `/// ...` comments.
<img width="385" height="97" alt="Image" src="https://github.com/user-attachments/assets/ae94ff41-ab7b-43f9-a065-06426b5a15c5" />

Here's the source:
```scala
/// here is my doc comment
/// why is the capture there though?
def main() = println("hello")
```

Pull request:
Now shows capture inlay hints using the `at` syntax on (function) definitions:

<img width="500" alt="image" src="https://github.com/user-attachments/assets/e6c1c415-49a5-49ae-81be-9de465c4b12b" />

This PR does not change the grammar, meaning it remains invalid syntax to explicitly annotate captures in these positions for now.

Resolves #1077.

Patch:
diff --git a/effekt/js/src/main/scala/effekt/LanguageServer.scala b/effekt/js/src/main/scala/effekt/LanguageServer.scala
index 46c8b6377..c82f6ccf0 100644
--- a/effekt/js/src/main/scala/effekt/LanguageServer.scala
+++ b/effekt/js/src/main/scala/effekt/LanguageServer.scala
@@ -117,7 +117,7 @@ class LanguageServer extends Intelligence {
     val source = VirtualFileSource(path)
     val range = kiama.util.Range(source.offsetToPosition(0), source.offsetToPosition(source.charCount))
     getInferredCaptures(range).map {
-      case CaptureInfo(p, c, _) => new lsp.CaptureInfo(toLSPPosition(p), c.toString)
+      case CaptureInfo(p, c) => new lsp.CaptureInfo(toLSPPosition(p), c.toString)
     }.toJSArray
   }
 
diff --git a/effekt/jvm/src/main/scala/effekt/Server.scala b/effekt/jvm/src/main/scala/effekt/Server.scala
index a40b70870..c27895f5b 100644
--- a/effekt/jvm/src/main/scala/effekt/Server.scala
+++ b/effekt/jvm/src/main/scala/effekt/Server.scala
@@ -374,19 +374,16 @@ class Server(config: EffektConfig, compileOnChange: Boolean=false) extends Langu
       hints = {
         val range = fromLSPRange(params.getRange, source)
         val captures = getInferredCaptures(range)(using context).map {
-          case CaptureInfo(p, c, atSyntax) =>
+          case CaptureInfo(p, c) =>
             val prettyCaptures = TypePrinter.show(c)
-            val codeEdit = if atSyntax then s"at ${prettyCaptures}" else prettyCaptures
+            val codeEdit = s"at ${prettyCaptures}"
             val inlayHint = new InlayHint(convertPosition(p), messages.Either.forLeft(codeEdit))
             inlayHint.setKind(InlayHintKind.Type)
             val markup = new MarkupContent()
             markup.setValue(s"captures: `${prettyCaptures}`")
             markup.setKind("markdown")
             inlayHint.setTooltip(markup)
-            if (atSyntax) then
-              inlayHint.setPaddingLeft(true)
-            else
-              inlayHint.setPaddingRight(true)
+            inlayHint.setPaddingLeft(true)
             inlayHint.setData("capture")
             inlayHint
         }.toVector
diff --git a/effekt/shared/src/main/scala/effekt/Intelligence.scala b/effekt/shared/src/main/scala/effekt/Intelligence.scala
index 52a46f2d5..9627178be 100644
--- a/effekt/shared/src/main/scala/effekt/Intelligence.scala
+++ b/effekt/shared/src/main/scala/effekt/Intelligence.scala
@@ -259,13 +259,13 @@ trait Intelligence {
     }.collect {
       case (t: source.FunDef, c) => for {
         pos <- C.positions.getStart(t)
-      } yield CaptureInfo(pos, c)
+      } yield CaptureInfo(t.ret.span.range.from, c)
       case (t: source.DefDef, c) => for {
         pos <- C.positions.getStart(t)
-      } yield CaptureInfo(pos, c)
+      } yield CaptureInfo(t.annot.span.range.from, c)
       case (source.Box(Maybe(None, span), block, _), _) if C.inferredCaptureOption(block).isDefined => for {
         capt <- C.inferredCaptureOption(block)
-      } yield CaptureInfo(span.range.from, capt, true)
+      } yield CaptureInfo(span.range.from, capt)
     }.flatten
 
   def getInfoOf(sym: Symbol)(using C: Context): Option[SymbolInfo] = PartialFunction.condOpt(resolveCallTarget(sym)) {
@@ -491,9 +491,5 @@ object Intelligence {
   case class CaptureInfo(
     position: Position,
     captures: CaptureSet,
-    /**
-     * Whether this capture set could be written into the source code at `position` using the `at { captures }` syntax
-     */
-    atSyntax: Boolean = false,
   )
 }
diff --git a/effekt/shared/src/main/scala/effekt/Namer.scala b/effekt/shared/src/main/scala/effekt/Namer.scala
index 844f88370..9d94de1e1 100644
--- a/effekt/shared/src/main/scala/effekt/Namer.scala
+++ b/effekt/shared/src/main/scala/effekt/Namer.scala
@@ -320,7 +320,7 @@ object Namer extends Phase[Parsed, NameResolved] {
     case d @ source.DefDef(id, annot, binding, doc, span) =>
       val tpe = annot.map(resolveBlockType)
       resolve(binding)
-      Context.define(id, DefBinder(Context.nameFor(id), tpe, d))
+      Context.define(id, DefBinder(Context.nameFor(id), tpe.unspan, d))
 
     // FunDef and InterfaceDef have already been resolved as part of the module declaration
     case f @ source.FunDef(id, tparams, vparams, bparams, ret, body, doc, span) =>
diff --git a/effekt/shared/src/main/scala/effekt/Parser.scala b/effekt/shared/src/main/scala/effekt/Parser.scala
index 0c625bbe4..166adf378 100644
--- a/effekt/shared/src/main/scala/effekt/Parser.scala
+++ b/effekt/shared/src/main/scala/effekt/Parser.scala
@@ -792,9 +792,9 @@ class Parser(positions: Positions, tokens: Seq[Token], source: Source) {
     nonterminal:
       if peek(`:`) then Some(valueTypeAnnotation()) else None
 
-  def maybeBlockTypeAnnotation(): Option[BlockType] =
+  def maybeBlockTypeAnnotation(): Maybe[BlockType] =
     nonterminal:
-      if peek(`:`) then Some(blockTypeAnnotation()) else None
+      if peek(`:`) then Maybe.Some(blockTypeAnnotation(), span()) else Maybe.None(span())
 
   def maybeReturnAnnotation(): Maybe[Effectful] =
     nonterminal:
diff --git a/effekt/shared/src/main/scala/effekt/source/Tree.scala b/effekt/shared/src/main/scala/effekt/source/Tree.scala
index 92d6e0fee..41d536e5a 100644
--- a/effekt/shared/src/main/scala/effekt/source/Tree.scala
+++ b/effekt/shared/src/main/scala/effekt/source/Tree.scala
@@ -402,7 +402,7 @@ enum Def extends Definition {
   case ValDef(id: IdDef, annot: Option[ValueType], binding: Stmt, info: Info, span: Span)
   case RegDef(id: IdDef, annot: Option[ValueType], region: IdRef, binding: Stmt, info: Info, span: Span)
   case VarDef(id: IdDef, annot: Option[ValueType], binding: Stmt, info: Info, span: Span)
-  case DefDef(id: IdDef, annot: Option[BlockType], block: Term, info: Info, span: Span)
+  case DefDef(id: IdDef, annot: Maybe[BlockType], block: Term, info: Info, span: Span)
 
   case NamespaceDef(id: IdDef, definitions: List[Def], info: Info, span: Span)
 


Test patch:
diff --git a/effekt/jvm/src/test/scala/effekt/LSPTests.scala b/effekt/jvm/src/test/scala/effekt/LSPTests.scala
index 15106427f..e69fd67ae 100644
--- a/effekt/jvm/src/test/scala/effekt/LSPTests.scala
+++ b/effekt/jvm/src/test/scala/effekt/LSPTests.scala
@@ -609,7 +609,7 @@ class LSPTests extends FunSuite {
       val (textDoc, positions) = raw"""
                                 |↑
                                 |def main(): Unit = {
-                                |↑
+                                |          ↑
                                 |  println("Hello, world!")
                                 |}
                                 |↑
@@ -618,12 +618,12 @@ class LSPTests extends FunSuite {
       val inlayHint = new InlayHint()
       inlayHint.setKind(InlayHintKind.Type)
       inlayHint.setPosition(positions(1))
-      inlayHint.setLabel("{io}")
+      inlayHint.setLabel("at {io}")
       val markup = new MarkupContent()
       markup.setKind("markdown")
       markup.setValue("captures: `{io}`")
       inlayHint.setTooltip(markup)
-      inlayHint.setPaddingRight(true)
+      inlayHint.setPaddingLeft(true)
       inlayHint.setData("capture")
 
       val expectedInlayHints = List(inlayHint)
@@ -685,7 +685,7 @@ class LSPTests extends FunSuite {
              |
              |↑
              |def foo() = { do raise(); 5 }
-             |↑        ↑
+             |         ↑
              |
              |↑
              |""".textDocumentAndPositions
@@ -693,19 +693,19 @@ class LSPTests extends FunSuite {
       val captureHint = new InlayHint()
       captureHint.setKind(InlayHintKind.Type)
       captureHint.setPosition(positions(1))
-      captureHint.setLabel("{}")
+      captureHint.setLabel("at {}")
       captureHint.setData("capture")
       captureHint.setTooltip(new MarkupContent("markdown", "captures: `{}`"))
-      captureHint.setPaddingRight(true)
+      captureHint.setPaddingLeft(true)
 
       val omittedHint = new InlayHint()
       omittedHint.setKind(InlayHintKind.Type)
-      omittedHint.setPosition(positions(2))
+      omittedHint.setPosition(positions(1))
       omittedHint.setLabel(": Int / { raise }")
       omittedHint.setData("return-type-annotation")
       omittedHint.setTextEdits(List(
         new TextEdit(
-          new Range(positions(2), positions(2)),
+          new Range(positions(1), positions(1)),
           ": Int / { raise }"
         )
       ).asJava)
@@ -720,7 +720,7 @@ class LSPTests extends FunSuite {
 
       val params = new InlayHintParams()
       params.setTextDocument(textDoc.versionedTextDocumentIdentifier)
-      params.setRange(new Range(positions(0), positions(3)))
+      params.setRange(new Range(positions(0), positions(2)))
 
       val inlayHints = server.getTextDocumentService().inlayHint(params).get()
       assertEquals(expectedInlayHints.asJava, inlayHints)
@@ -733,7 +733,7 @@ class LSPTests extends FunSuite {
         raw"""
              |↑
              |def main(): Unit = {
-             |↑
+             |          ↑
              |  println("Hello, world!")
              |}
              |↑
@@ -742,12 +742,12 @@ class LSPTests extends FunSuite {
       val inlayHint = new InlayHint()
       inlayHint.setKind(InlayHintKind.Type)
       inlayHint.setPosition(positions(1))
-      inlayHint.setLabel("{io}")
+      inlayHint.setLabel("at {io}")
       val markup = new MarkupContent()
       markup.setKind("markdown")
       markup.setValue("captures: `{io}`")
       inlayHint.setTooltip(markup)
-      inlayHint.setPaddingRight(true)
+      inlayHint.setPaddingLeft(true)
       inlayHint.setData("capture")
 
       val expectedInlayHints = List(inlayHint)
@@ -818,7 +818,7 @@ class LSPTests extends FunSuite {
         raw"""
              |↑
              |def main(): Unit = {
-             |↑
+             |          ↑
              |  println("Hello, world!")
              |}
              |↑
@@ -827,12 +827,12 @@ class LSPTests extends FunSuite {
       val inlayHint = new InlayHint()
       inlayHint.setKind(InlayHintKind.Type)
       inlayHint.setPosition(positions(1))
-      inlayHint.setLabel("{io}")
+      inlayHint.setLabel("at {io}")
       val markup = new MarkupContent()
       markup.setKind("markdown")
       markup.setValue("captures: `{io}`")
       inlayHint.setTooltip(markup)
-      inlayHint.setPaddingRight(true)
+      inlayHint.setPaddingLeft(true)
       inlayHint.setData("capture")
 
       val expectedInlayHints = List(inlayHint)
diff --git a/effekt/jvm/src/test/scala/effekt/ParserTests.scala b/effekt/jvm/src/test/scala/effekt/ParserTests.scala
index d79cf558f..80e534ce3 100644
--- a/effekt/jvm/src/test/scala/effekt/ParserTests.scala
+++ b/effekt/jvm/src/test/scala/effekt/ParserTests.scala
@@ -998,7 +998,7 @@ class ParserTests extends munit.FunSuite {
         parseDefinition(source.content),
         DefDef(
           IdDef("foo", Span(source, pos(0), pos(1))),
-          None,
+          Maybe.None(Span(source, pos(1), pos(1))),
           Var(IdRef(Nil, "f", Span(source, pos(2), pos(3))), Span(source, pos(2), pos(3))),
           Info.empty(Span(source, 0, 0)),
           Span(source, 0, pos.last)))