diff --git a/compiler/src/dotty/tools/dotc/util/CommentParsing.scala b/compiler/src/dotty/tools/dotc/util/CommentParsing.scala index 2132cdcde73b..61c42ced3d92 100644 --- a/compiler/src/dotty/tools/dotc/util/CommentParsing.scala +++ b/compiler/src/dotty/tools/dotc/util/CommentParsing.scala @@ -5,6 +5,8 @@ */ package dotty.tools.dotc.util +import scala.collection.mutable + /** The comment parsing in `dotc` is used by both the comment cooking and the * dottydoc tool. * @@ -223,6 +225,15 @@ object CommentParsing { result } + /** A map from tag name to all boundaries for this tag */ + def groupedSections(str: String, sections: List[(Int, Int)]): Map[String, List[(Int, Int)]] = { + val map = mutable.Map.empty[String, List[(Int, Int)]].withDefaultValue(Nil) + sections.reverse.foreach { bounds => + val tag = extractSectionTag(str, bounds) + map.update(tag, (skipTag(str, bounds._1), bounds._2) :: map(tag)) + } + map.toMap + } def removeSections(raw: String, xs: String*): String = { val sections = tagIndex(raw) diff --git a/compiler/src/dotty/tools/dotc/util/ParsedComment.scala b/compiler/src/dotty/tools/dotc/util/ParsedComment.scala new file mode 100644 index 000000000000..98cff1a325d1 --- /dev/null +++ b/compiler/src/dotty/tools/dotc/util/ParsedComment.scala @@ -0,0 +1,221 @@ +package dotty.tools.dotc.util + +import dotty.tools.dotc.core.Comments.{Comment, CommentsContext} +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Names.TermName +import dotty.tools.dotc.core.Symbols._ +import dotty.tools.dotc.printing.SyntaxHighlighting + +import scala.Console.{BOLD, RESET, UNDERLINED} +import scala.collection.immutable.ListMap +import scala.util.matching.Regex + +/** + * A parsed doc comment. + * + * @param comment The doc comment to parse + */ +class ParsedComment(val comment: Comment) { + + /** + * The bounds of a section that represents the [start; end[ char offset + * of the section within this comment's `content`. + */ + private type Bounds = (Int, Int) + + /** The content of this comment, after expansion if possible. */ + val content: String = comment.expandedBody.getOrElse(comment.raw) + + /** An index that marks all sections boundaries */ + private lazy val tagIndex: List[Bounds] = CommentParsing.tagIndex(content) + + /** + * Maps a parameter name to the bounds of its doc + * + * @see paramDoc + */ + private lazy val paramDocs: Map[String, Bounds] = CommentParsing.paramDocs(content, "@param", tagIndex) + + /** + * The "main" documentation for this comment. That is, the comment before any section starts. + */ + lazy val mainDoc: String = { + val doc = tagIndex match { + case Nil => content.stripSuffix("*/") + case (start, _) :: _ => content.slice(0, start) + } + clean(doc.stripPrefix("/**")) + } + + /** + * Renders this comment as markdown. + * + * The different sections are formatted according to the mapping in `knownTags`. + */ + def renderAsMarkdown(implicit ctx: Context): String = { + val buf = new StringBuilder + buf.append(mainDoc + System.lineSeparator + System.lineSeparator) + val groupedSections = CommentParsing.groupedSections(content, tagIndex) + + for { + (tag, formatter) <- ParsedComment.knownTags + boundss <- groupedSections.get(tag) + texts = boundss.map { case (start, end) => clean(content.slice(start, end)) } + formatted <- formatter(texts) + } { + buf.append(formatted) + buf.append(System.lineSeparator) + } + + buf.toString + } + + /** + * The `@param` section corresponding to `name`. + * + * @param name The parameter name whose documentation to extract. + * @return The formatted documentation corresponding to `name`. + */ + def paramDoc(name: TermName): Option[String] = paramDocs.get(name.toString).map { case (start, end) => + val rawContent = content.slice(start, end) + val docContent = ParsedComment.prefixRegex.replaceFirstIn(rawContent, "") + clean(docContent) + } + + /** + * Cleans `str`: remove prefixing `*` and trim the string. + * + * @param str The string to clean + * @return The cleaned string. + */ + private def clean(str: String): String = str.stripMargin('*').trim +} + +object ParsedComment { + + /** + * Return the `ParsedComment` associated with `symbol`, if it exists. + * + * @param symbol The symbol for which to retrieve the documentation + * @return If it exists, the `ParsedComment` for `symbol`. + */ + def docOf(symbol: Symbol)(implicit ctx: Context): Option[ParsedComment] = { + val documentedSymbol = if (symbol.isPrimaryConstructor) symbol.owner else symbol + for { docCtx <- ctx.docCtx + comment <- docCtx.docstring(documentedSymbol) + } yield new ParsedComment(comment) + } + + @scala.annotation.internal.sharable + private val prefixRegex = """@param\s+\w+\s+""".r + + /** A mapping from tag name to `TagFormatter` */ + private val knownTags: ListMap[String, TagFormatter] = ListMap( + "@tparam" -> TagFormatter("Type Parameters", toDescriptionList), + "@param" -> TagFormatter("Parameters", toDescriptionList), + "@return" -> TagFormatter("Returns", toMarkdownList), + "@throws" -> TagFormatter("Throws", toDescriptionList), + "@see" -> TagFormatter("See Also", toMarkdownList), + "@example" -> TagFormatter("Examples", toCodeFences("scala")), + "@usecase" -> TagFormatter("Usecases", toCodeFences("scala")), + "@note" -> TagFormatter("Note", toMarkdownList), + "@author" -> TagFormatter("Authors", toMarkdownList), + "@since" -> TagFormatter("Since", toMarkdownList), + "@version" -> TagFormatter("Version", toMarkdownList) + ) + + /** + * Formats a list of items into a list describing them. + * + * Each element is assumed to consist of a first word, which is the item being described. The rest + * is the description of the item. + * + * @param items The items to format into a list. + * @return A markdown list of descriptions. + */ + private def toDescriptionList(ctx: Context, items: List[String]): String = { + val formattedItems = items.map { p => + val name :: rest = p.split(" ", 2).toList + s"${bold(name)(ctx)} ${rest.mkString("").trim}" + } + toMarkdownList(ctx, formattedItems) + } + + /** + * Formats a list of items into a markdown list. + * + * @param items The items to put in a list. + * @return The list of items, in markdown. + */ + private def toMarkdownList(ctx: Context, items: List[String]): String = { + val formattedItems = items.map(_.lines.mkString(System.lineSeparator + " ")) + formattedItems.mkString(" - ", System.lineSeparator + " - ", "") + } + + /** + * If the color is enabled, add syntax highlighting to each of `snippets`, otherwise wrap each + * of them in a markdown code fence. + * The results are put into a markdown list. + * + * @param language The language to use for the code fences + * @param snippets The list of snippets to format. + * @return A markdown list of code fences. + * @see toCodeFence + */ + private def toCodeFences(language: String)(ctx: Context, snippets: List[String]): String = + toMarkdownList(ctx, snippets.map(toCodeFence(language)(ctx, _))) + + /** + * Formats `snippet` for display. If the color is enabled, the syntax is highlighted, + * otherwise the snippet is wrapped in a markdown code fence. + * + * @param language The language to use. + * @param snippet The code snippet + * @return `snippet`, wrapped in a code fence. + */ + private def toCodeFence(language: String)(ctx: Context, snippet: String): String = { + if (colorEnabled(ctx)) { + SyntaxHighlighting.highlight(snippet)(ctx) + } else { + s"""```$language + |$snippet + |```""".stripMargin + } + } + + /** + * Format the elements of documentation associated with a given tag using `fn`, and starts the + * section with `title`. + * + * @param title The title to give to the formatted items. + * @param fn The formatting function to use. + */ + private case class TagFormatter(title: String, fn: (Context, List[String]) => String) { + + /** + * Format `item` using `fn` if `items` is not empty. + * + * @param items The items to format + * @return If items is not empty, the items formatted using `fn`. + */ + def apply(items: List[String])(implicit ctx: Context): Option[String] = items match { + case Nil => + None + case items => + Some(s"""${bold(title)} + |${fn(ctx, items)} + |""".stripMargin) + } + } + + /** Is the color enabled in the context? */ + private def colorEnabled(implicit ctx: Context): Boolean = + ctx.settings.color.value != "never" + + /** Show `str` in bold */ + private def bold(str: String)(implicit ctx: Context): String = { + if (colorEnabled) s"$BOLD$str$RESET" + else s"**$str**" + } + +} diff --git a/compiler/src/dotty/tools/repl/ReplCompiler.scala b/compiler/src/dotty/tools/repl/ReplCompiler.scala index 3f3606ef0906..c030aa349cad 100644 --- a/compiler/src/dotty/tools/repl/ReplCompiler.scala +++ b/compiler/src/dotty/tools/repl/ReplCompiler.scala @@ -15,7 +15,7 @@ import dotty.tools.dotc.reporting.diagnostic.messages import dotty.tools.dotc.transform.PostTyper import dotty.tools.dotc.typer.ImportInfo import dotty.tools.dotc.util.Positions._ -import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.{ParsedComment, SourceFile} import dotty.tools.dotc.{CompilationUnit, Compiler, Run} import dotty.tools.repl.results._ @@ -196,10 +196,8 @@ class ReplCompiler extends Compiler { val symbols = extractSymbols(stat) val doc = for { sym <- symbols - docCtx <- ctx.docCtx - comment <- docCtx.docstring(sym) - body <- comment.expandedBody - } yield body + comment <- ParsedComment.docOf(sym) + } yield comment.renderAsMarkdown if (doc.hasNext) doc.next() else s"// No doc for `$expr`" diff --git a/compiler/src/dotty/tools/repl/ReplDriver.scala b/compiler/src/dotty/tools/repl/ReplDriver.scala index fa1af31c157f..c24aa45f4b15 100644 --- a/compiler/src/dotty/tools/repl/ReplDriver.scala +++ b/compiler/src/dotty/tools/repl/ReplDriver.scala @@ -351,7 +351,7 @@ class ReplDriver(settings: Array[String], case DocOf(expr) => compiler.docOf(expr)(newRun(state)).fold( displayErrors, - res => out.println(SyntaxHighlighting.highlight(res)(state.context)) + res => out.println(res) ) state diff --git a/compiler/test/dotty/tools/repl/DocTests.scala b/compiler/test/dotty/tools/repl/DocTests.scala index b3157966f208..0a34c6a1c498 100644 --- a/compiler/test/dotty/tools/repl/DocTests.scala +++ b/compiler/test/dotty/tools/repl/DocTests.scala @@ -8,52 +8,52 @@ class DocTests extends ReplTest { @Test def docOfDef = eval("/** doc */ def foo = 0").andThen { implicit s => - assertEquals("/** doc */", doc("foo")) + assertEquals("doc", doc("foo")) } @Test def docOfVal = eval("/** doc */ val foo = 0").andThen { implicit s => - assertEquals("/** doc */", doc("foo")) + assertEquals("doc", doc("foo")) } @Test def docOfObject = eval("/** doc */ object Foo").andThen { implicit s => - assertEquals("/** doc */", doc("Foo")) + assertEquals("doc", doc("Foo")) } @Test def docOfClass = eval("/** doc */ class Foo").andThen { implicit s => - assertEquals("/** doc */", doc("new Foo")) + assertEquals("doc", doc("new Foo")) } @Test def docOfTrait = eval("/** doc */ trait Foo").andThen { implicit s => - assertEquals("/** doc */", doc("new Foo")) + assertEquals("doc", doc("new Foo")) } @Test def docOfDefInObject = eval("object O { /** doc */ def foo = 0 }").andThen { implicit s => - assertEquals("/** doc */", doc("O.foo")) + assertEquals("doc", doc("O.foo")) } @Test def docOfValInObject = eval("object O { /** doc */ val foo = 0 }").andThen { implicit s => - assertEquals("/** doc */", doc("O.foo")) + assertEquals("doc", doc("O.foo")) } @Test def docOfObjectInObject = eval("object O { /** doc */ object Foo }").andThen { implicit s => - assertEquals("/** doc */", doc("O.Foo")) + assertEquals("doc", doc("O.Foo")) } @Test def docOfClassInObject = eval("object O { /** doc */ class Foo }").andThen { implicit s => - assertEquals("/** doc */", doc("new O.Foo")) + assertEquals("doc", doc("new O.Foo")) } @Test def docOfTraitInObject = eval("object O { /** doc */ trait Foo }").andThen { implicit s => - assertEquals("/** doc */", doc("new O.Foo")) + assertEquals("doc", doc("new O.Foo")) } @Test def docOfDefInClass = @@ -61,7 +61,7 @@ class DocTests extends ReplTest { """class C { /** doc */ def foo = 0 } |val c = new C """.stripMargin).andThen { implicit s => - assertEquals("/** doc */", doc("c.foo")) + assertEquals("doc", doc("c.foo")) } @Test def docOfValInClass = @@ -69,7 +69,7 @@ class DocTests extends ReplTest { """class C { /** doc */ val foo = 0 } |val c = new C """.stripMargin).andThen { implicit s => - assertEquals("/** doc */", doc("c.foo")) + assertEquals("doc", doc("c.foo")) } @Test def docOfObjectInClass = @@ -77,7 +77,7 @@ class DocTests extends ReplTest { """class C { /** doc */ object Foo } |val c = new C """.stripMargin).andThen { implicit s => - assertEquals("/** doc */", doc("c.Foo")) + assertEquals("doc", doc("c.Foo")) } @Test def docOfClassInClass = @@ -85,7 +85,7 @@ class DocTests extends ReplTest { """class C { /** doc */ class Foo } |val c = new C """.stripMargin).andThen { implicit s => - assertEquals("/** doc */", doc("new c.Foo")) + assertEquals("doc", doc("new c.Foo")) } @Test def docOfTraitInClass = @@ -93,7 +93,7 @@ class DocTests extends ReplTest { """class C { /** doc */ trait Foo } |val c = new C """.stripMargin).andThen { implicit s => - assertEquals("/** doc */", doc("new c.Foo")) + assertEquals("doc", doc("new c.Foo")) } @Test def docOfOverloadedDef = @@ -103,8 +103,8 @@ class DocTests extends ReplTest { | /** doc1 */ def foo(x: String) = x |} """.stripMargin).andThen { implicit s => - assertEquals("/** doc0 */", doc("O.foo(_: Int)")) - assertEquals("/** doc1 */", doc("O.foo(_: String)")) + assertEquals("doc0", doc("O.foo(_: Int)")) + assertEquals("doc1", doc("O.foo(_: String)")) } @Test def docOfInherited = @@ -112,7 +112,7 @@ class DocTests extends ReplTest { """class C { /** doc */ def foo = 0 } |object O extends C """.stripMargin).andThen { implicit s => - assertEquals("/** doc */", doc("O.foo")) + assertEquals("doc", doc("O.foo")) } @Test def docOfOverride = @@ -126,8 +126,8 @@ class DocTests extends ReplTest { | /** overridden doc */ override def foo(x: String): String = x |} """.stripMargin).andThen { implicit s => - assertEquals("/** doc0 */", doc("O.foo(_: Int)")) - assertEquals("/** overridden doc */", doc("O.foo(_: String)")) + assertEquals("doc0", doc("O.foo(_: Int)")) + assertEquals("overridden doc", doc("O.foo(_: String)")) } @Test def docOfOverrideObject = @@ -142,8 +142,8 @@ class DocTests extends ReplTest { | } |} """.stripMargin).andThen { implicit s => - assertEquals("/** companion */", doc("O.foo")) - assertEquals("/** doc0 */", doc("O.foo.bar")) + assertEquals("companion", doc("O.foo")) + assertEquals("doc0", doc("O.foo.bar")) } @Test def docIsCooked = @@ -157,7 +157,7 @@ class DocTests extends ReplTest { | def hello = "world" |} """.stripMargin).andThen { implicit s => - assertEquals("/** Expansion: some-value */", doc("Foo.hello")) + assertEquals("Expansion: some-value", doc("Foo.hello")) } private def eval(code: String): State = diff --git a/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala b/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala index 736d1f335d77..1f82ac9fb8ef 100644 --- a/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala +++ b/language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala @@ -415,8 +415,8 @@ class DottyLanguageServer extends LanguageServer if (tp.isError || tpw == NoType) null // null here indicates that no response should be sent else { val symbol = Interactive.enclosingSourceSymbol(trees, pos) - val docComment = ctx.docCtx.flatMap(_.docstring(symbol)) - val content = hoverContent(tpw.show, docComment) + val docComment = ParsedComment.docOf(symbol) + val content = hoverContent(Some(tpw.show), docComment) new Hover(content, null) } } @@ -655,22 +655,32 @@ object DottyLanguageServer { item } - private def hoverContent(typeInfo: String, comment: Option[Comment]): lsp4j.MarkupContent = { - val markup = new lsp4j.MarkupContent - markup.setKind("markdown") - markup.setValue(( - comment.flatMap(_.expandedBody) match { - case Some(comment) => - s"""```scala - |$typeInfo - |$comment - |```""" - case None => - s"""```scala - |$typeInfo - |```""" - }).stripMargin) - markup + def hoverContent(content: String): lsp4j.MarkupContent = { + if (content.isEmpty) null + else { + val markup = new lsp4j.MarkupContent + markup.setKind("markdown") + markup.setValue(content.trim) + markup + } + } + + private def hoverContent(typeInfo: Option[String], + comment: Option[ParsedComment] + )(implicit ctx: Context): lsp4j.MarkupContent = { + val buf = new StringBuilder + typeInfo.foreach { info => + buf.append(s"""```scala + |$info + |``` + |""".stripMargin) + } + + comment.foreach { comment => + buf.append(comment.renderAsMarkdown) + } + + hoverContent(buf.toString) } /** Create an lsp4j.SymbolInfo from a Symbol and a SourcePosition */ diff --git a/language-server/test/dotty/tools/languageserver/HoverTest.scala b/language-server/test/dotty/tools/languageserver/HoverTest.scala index 45e403102303..acb003a9a872 100644 --- a/language-server/test/dotty/tools/languageserver/HoverTest.scala +++ b/language-server/test/dotty/tools/languageserver/HoverTest.scala @@ -14,8 +14,8 @@ class HoverTest { else s"""```scala |$typeInfo - |$comment - |```""").stripMargin) + |``` + |$comment""").stripMargin) @Test def hoverOnWhiteSpace0: Unit = code"$m1 $m2".withSource.hover(m1 to m2, None) @@ -23,7 +23,7 @@ class HoverTest { @Test def hoverOnClassShowsDoc: Unit = { code"""$m1 /** foo */ ${m2}class Foo $m3 $m4""".withSource .hover(m1 to m2, None) - .hover(m2 to m3, hoverContent("Foo", "/** foo */")) + .hover(m2 to m3, hoverContent("Foo", "foo")) .hover(m3 to m4, None) } @@ -97,8 +97,78 @@ class HoverTest { |/** $$Variable */ |class ${m3}Bar${m4} extends Foo """.withSource - .hover(m1 to m2, hoverContent("Foo", "/** A class: Test\n * */")) - .hover(m3 to m4, hoverContent("Bar", "/** Test */")) + .hover(m1 to m2, hoverContent("Foo", "A class: Test")) + .hover(m3 to m4, hoverContent("Bar", "Test")) } + @Test def documentationIsFormatted: Unit = { + code"""class Foo(val x: Int, val y: Int) { + | /** + | * Does something + | * + | * @tparam T A first type param + | * @tparam U Another type param + | * @param fizz Again another number + | * @param buzz A String + | * @param ev An implicit boolean + | * @return Something + | * @throws java.lang.NullPointerException if you're unlucky + | * @throws java.lang.InvalidArgumentException if the argument is invalid + | * @see java.nio.file.Paths#get() + | * @note A note + | * @example myFoo.bar[Int, String](0, "hello, world") + | * @author John Doe + | * @version 1.0 + | * @since 0.1 + | * @usecase def bar(fizz: Int, buzz: String): Any + | */ + | def ${m1}bar${m2}[T, U](fizz: Int, buzz: String)(implicit ev: Boolean): Any = ??? + |}""".withSource + .hover( + m1 to m2, + hoverContent("[T, U](fizz: Int, buzz: String)(implicit ev: Boolean): Any", + """Does something + | + |**Type Parameters** + | - **T** A first type param + | - **U** Another type param + | + |**Parameters** + | - **fizz** Again another number + | - **buzz** A String + | - **ev** An implicit boolean + | + |**Returns** + | - Something + | + |**Throws** + | - **java.lang.NullPointerException** if you're unlucky + | - **java.lang.InvalidArgumentException** if the argument is invalid + | + |**See Also** + | - java.nio.file.Paths#get() + | + |**Examples** + | - ```scala + | myFoo.bar[Int, String](0, "hello, world") + | ``` + | + |**Usecases** + | - ```scala + | def bar(fizz: Int, buzz: String): Any + | ``` + | + |**Note** + | - A note + | + |**Authors** + | - John Doe + | + |**Since** + | - 0.1 + | + |**Version** + | - 1.0""".stripMargin)) + + } } diff --git a/language-server/test/dotty/tools/languageserver/WorksheetTest.scala b/language-server/test/dotty/tools/languageserver/WorksheetTest.scala index 3893eaa68a0d..907784e5a132 100644 --- a/language-server/test/dotty/tools/languageserver/WorksheetTest.scala +++ b/language-server/test/dotty/tools/languageserver/WorksheetTest.scala @@ -164,16 +164,16 @@ class WorksheetTest { def hoverContent(typeInfo: String, comment: String): Option[String] = Some(s"""```scala |$typeInfo - |$comment - |```""".stripMargin) + |``` + |$comment""".stripMargin) @Test def worksheetHover(): Unit = { ws"""/** A class */ class ${m1}Foo${m2} { /** A method */ def ${m3}bar${m4} = 123 } val x = new ${m5}Foo${m6} x.${m7}bar${m8}""".withSource - .hover(m1 to m2, hoverContent(s"${WorksheetWrapper}.Foo", "/** A class */")) - .hover(m3 to m4, hoverContent("Int", "/** A method */")) - .hover(m5 to m6, hoverContent(s"${WorksheetWrapper}.Foo", "/** A class */")) - .hover(m7 to m8, hoverContent("Int", "/** A method */")) + .hover(m1 to m2, hoverContent(s"${WorksheetWrapper}.Foo", "A class")) + .hover(m3 to m4, hoverContent("Int", "A method")) + .hover(m5 to m6, hoverContent(s"${WorksheetWrapper}.Foo", "A class")) + .hover(m7 to m8, hoverContent("Int", "A method")) } @Test def worksheetDocumentSymbol(): Unit = { diff --git a/project/Build.scala b/project/Build.scala index 670e1983a41b..de79d4d8a79b 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -870,7 +870,7 @@ object Build { fork in run := true, fork in Test := true, libraryDependencies ++= Seq( - "org.eclipse.lsp4j" % "org.eclipse.lsp4j" % "0.5.0.M1", + "org.eclipse.lsp4j" % "org.eclipse.lsp4j" % "0.5.0", Dependencies.`jackson-databind` ), javaOptions := (javaOptions in `dotty-compiler-bootstrapped`).value,