Skip to content

Commit 02ea0b7

Browse files
committed
Improve display of documentation in IDE
This commit introduces `ParsedComment` which is used to parse the doc comments and make it easier to retrieve parts of the comments (for instance, the documentation associated with a given method parameter). The documentation marked with the tags that scaladoc supports are extracted and their content are formatted into lists, code fences, etc.
1 parent 5a8b5eb commit 02ea0b7

File tree

5 files changed

+320
-29
lines changed

5 files changed

+320
-29
lines changed

compiler/src/dotty/tools/dotc/util/CommentParsing.scala

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*/
66
package dotty.tools.dotc.util
77

8+
import scala.collection.mutable
9+
810
/** The comment parsing in `dotc` is used by both the comment cooking and the
911
* dottydoc tool.
1012
*
@@ -223,6 +225,15 @@ object CommentParsing {
223225
result
224226
}
225227

228+
/** A map from tag name to all boundaries for this tag */
229+
def groupedSections(str: String, sections: List[(Int, Int)]): Map[String, List[(Int, Int)]] = {
230+
val map = mutable.Map.empty[String, List[(Int, Int)]].withDefault(_ => Nil)
231+
sections.reverse.foreach { bounds =>
232+
val tag = extractSectionTag(str, bounds)
233+
map.update(tag, (skipTag(str, bounds._1), bounds._2) :: map(tag))
234+
}
235+
map.toMap
236+
}
226237

227238
def removeSections(raw: String, xs: String*): String = {
228239
val sections = tagIndex(raw)

language-server/src/dotty/tools/languageserver/DottyLanguageServer.scala

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -357,8 +357,8 @@ class DottyLanguageServer extends LanguageServer
357357
if (tp.isError || tpw == NoType) null // null here indicates that no response should be sent
358358
else {
359359
val symbol = Interactive.enclosingSourceSymbol(trees, pos)
360-
val docComment = ctx.docCtx.flatMap(_.docstring(symbol))
361-
val content = hoverContent(tpw.show, docComment)
360+
val docComment = ParsedComment.docOf(symbol)
361+
val content = hoverContent(Some(tpw.show), docComment)
362362
new Hover(content, null)
363363
}
364364
}
@@ -597,22 +597,30 @@ object DottyLanguageServer {
597597
item
598598
}
599599

600-
private def hoverContent(typeInfo: String, comment: Option[Comment]): lsp4j.MarkupContent = {
601-
val markup = new lsp4j.MarkupContent
602-
markup.setKind("markdown")
603-
markup.setValue((
604-
comment.flatMap(_.expandedBody) match {
605-
case Some(comment) =>
606-
s"""```scala
607-
|$typeInfo
608-
|$comment
609-
|```"""
610-
case None =>
611-
s"""```scala
612-
|$typeInfo
613-
|```"""
614-
}).stripMargin)
615-
markup
600+
def hoverContent(content: String): lsp4j.MarkupContent = {
601+
if (content.isEmpty) null
602+
else {
603+
val markup = new lsp4j.MarkupContent
604+
markup.setKind("markdown")
605+
markup.setValue(content.trim)
606+
markup
607+
}
608+
}
609+
610+
private def hoverContent(typeInfo: Option[String], comment: Option[ParsedComment]): lsp4j.MarkupContent = {
611+
val buf = new StringBuilder
612+
typeInfo.foreach { info =>
613+
buf.append(s"""```scala
614+
|$info
615+
|```
616+
|""".stripMargin)
617+
}
618+
619+
comment.foreach { comment =>
620+
buf.append(comment.renderAsMarkdown)
621+
}
622+
623+
hoverContent(buf.toString)
616624
}
617625

618626
/** Create an lsp4j.SymbolInfo from a Symbol and a SourcePosition */
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
package dotty.tools.languageserver
2+
3+
import dotty.tools.dotc.core.Comments.{Comment, CommentsContext}
4+
import dotty.tools.dotc.core.Contexts.Context
5+
import dotty.tools.dotc.core.Names.TermName
6+
import dotty.tools.dotc.core.Symbols._
7+
import dotty.tools.dotc.util.CommentParsing
8+
9+
import scala.collection.immutable.ListMap
10+
import scala.util.matching.Regex
11+
12+
/**
13+
* A parsed doc comment.
14+
*
15+
* @param comment The doc comment to parse
16+
*/
17+
class ParsedComment(val comment: Comment) {
18+
19+
/**
20+
* The bounds of a section that represents the [start; end[ char offset
21+
* of the section within this comment's `content`.
22+
*/
23+
private type Bounds = (Int, Int)
24+
25+
/** The content of this comment, after expansion if possible. */
26+
val content: String = comment.expandedBody.getOrElse(comment.raw)
27+
28+
/** An index that marks all sections boundaries */
29+
private lazy val tagIndex: List[Bounds] = CommentParsing.tagIndex(content)
30+
31+
/**
32+
* Maps a parameter name to the bounds of its doc
33+
*
34+
* @see paramDoc
35+
*/
36+
private lazy val paramDocs: Map[String, Bounds] = CommentParsing.paramDocs(content, "@param", tagIndex)
37+
38+
/**
39+
* The "main" documentation for this comment. That is, the comment before any section starts.
40+
*/
41+
lazy val mainDoc: String = {
42+
val doc = tagIndex match {
43+
case Nil => content.stripSuffix("*/")
44+
case (start, _) :: _ => content.slice(0, start)
45+
}
46+
clean(doc.stripPrefix("/**"))
47+
}
48+
49+
/**
50+
* Renders this comment as markdown.
51+
*
52+
* The different sections are formatted according to the maping in `knownTags`.
53+
*/
54+
def renderAsMarkdown: String = {
55+
val buf = new StringBuilder
56+
buf.append(mainDoc + System.lineSeparator + System.lineSeparator)
57+
val groupedSections = CommentParsing.groupedSections(content, tagIndex)
58+
59+
for {
60+
(tag, formatter) <- ParsedComment.knownTags
61+
boundss <- groupedSections.get(tag)
62+
texts = boundss.map { (start, end) => clean(content.slice(start, end)) }
63+
formatted <- formatter(texts)
64+
} {
65+
buf.append(formatted)
66+
buf.append(System.lineSeparator)
67+
}
68+
69+
buf.toString
70+
}
71+
72+
/**
73+
* The `@param` section corresponding to `name`.
74+
*
75+
* @param name The parameter name whose documentation to extract.
76+
* @return The formatted documentation corresponding to `name`.
77+
*/
78+
def paramDoc(name: TermName): Option[String] = paramDocs.get(name.toString).map { (start, end) =>
79+
val rawContent = content.slice(start, end)
80+
val docContent = ParsedComment.prefixRegex.replaceFirstIn(rawContent, "")
81+
clean(docContent)
82+
}
83+
84+
/**
85+
* Cleans `str`: remove prefixing `*` and trim the string.
86+
*
87+
* @param str The string to clean
88+
* @return The cleaned string.
89+
*/
90+
private def clean(str: String): String = str.stripMargin('*').trim
91+
}
92+
93+
object ParsedComment {
94+
95+
/**
96+
* Return the `ParsedComment` associated with `symbol`, if it exists.
97+
*
98+
* @param symbol The symbol for which to retrieve the documentation
99+
* @return If it exists, the `ParsedComment` for `symbol`.
100+
*/
101+
def docOf(symbol: Symbol)(implicit ctx: Context): Option[ParsedComment] = {
102+
val documentedSymbol = if (symbol.isPrimaryConstructor) symbol.owner else symbol
103+
for { docCtx <- ctx.docCtx
104+
comment <- docCtx.docstring(documentedSymbol)
105+
} yield new ParsedComment(comment)
106+
}
107+
108+
private val prefixRegex = """@param\s+\w+\s+""".r
109+
110+
/** A mapping from tag name to `TagFormatter` */
111+
private val knownTags: ListMap[String, TagFormatter] = ListMap(
112+
"@tparam" -> TagFormatter("Type Parameters", toDescriptionList),
113+
"@param" -> TagFormatter("Parameters", toDescriptionList),
114+
"@return" -> TagFormatter("Returns", toMarkdownList),
115+
"@throws" -> TagFormatter("Throws", toDescriptionList),
116+
"@see" -> TagFormatter("See Also", toMarkdownList),
117+
"@example" -> TagFormatter("Examples", toCodeFences("scala")),
118+
"@usecase" -> TagFormatter("Usecases", toCodeFences("scala")),
119+
"@note" -> TagFormatter("Note", toMarkdownList),
120+
"@author" -> TagFormatter("Authors", toMarkdownList),
121+
"@since" -> TagFormatter("Since", toMarkdownList),
122+
"@version" -> TagFormatter("Version", toMarkdownList)
123+
)
124+
125+
/**
126+
* Formats a list of items into a list describing them.
127+
*
128+
* Each element is assumed to consist of a first word, which is the item being described. The rest
129+
* is the description of the item.
130+
*
131+
* @param items The items to format into a list.
132+
* @return A markdown list of descriptions.
133+
*/
134+
private def toDescriptionList(items: List[String]): String = {
135+
val formattedItems = items.map { p =>
136+
val name :: rest = p.split(" ", 2).toList
137+
s"**$name** ${rest.mkString("").trim}"
138+
}
139+
toMarkdownList(formattedItems)
140+
}
141+
142+
/**
143+
* Formats a list of items into a markdown list.
144+
*
145+
* @param items The items to put in a list.
146+
* @return The list of items, in markdown.
147+
*/
148+
private def toMarkdownList(items: List[String]): String = {
149+
val formattedItems = items.map(_.lines.mkString(System.lineSeparator + " "))
150+
formattedItems.mkString(" - ", System.lineSeparator + " - ", "")
151+
}
152+
153+
/**
154+
* Wrap each of `snippets` into a markdown code fence, using `language`. All the code fences are
155+
* put into a markdown list.
156+
*
157+
* @param language The language to use for the code fences
158+
* @param snippets The list of snippets to format.
159+
* @return A markdown list of code fences.
160+
* @see toCodeFence
161+
*/
162+
private def toCodeFences(language: String)(snippets: List[String]): String =
163+
toMarkdownList(snippets.map(toCodeFence(language)))
164+
165+
/**
166+
* Wraps `snippet` in a markdown code fence, using `language`.
167+
*
168+
* @param language The language to use.
169+
* @param snippet The code snippet
170+
* @return `snippet`, wrapped in a code fence.
171+
*/
172+
private def toCodeFence(language: String)(snippet: String): String =
173+
s"""```$language
174+
|$snippet
175+
|```""".stripMargin
176+
177+
/**
178+
* Format the elements of documentation associated with a given tag using `fn`, and starts the
179+
* section with `title`.
180+
*
181+
* @param title The title to give to the formatted items.
182+
* @param fn The formatting function to use.
183+
*/
184+
private case class TagFormatter(title: String, fn: List[String] => String) {
185+
186+
/**
187+
* Format `item` using `fn` if `items` is not empty.
188+
*
189+
* @param items The items to format
190+
* @return If items is not empty, the items formatted using `fn`.
191+
*/
192+
def apply(items: List[String]): Option[String] = items match {
193+
case Nil =>
194+
None
195+
case items =>
196+
Some(s"""$title:
197+
|${fn(items)}
198+
|""".stripMargin)
199+
}
200+
}
201+
202+
}

language-server/test/dotty/tools/languageserver/HoverTest.scala

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,16 @@ class HoverTest {
1414
else
1515
s"""```scala
1616
|$typeInfo
17-
|$comment
18-
|```""").stripMargin)
17+
|```
18+
|$comment""").stripMargin)
1919

2020
@Test def hoverOnWhiteSpace0: Unit =
2121
code"$m1 $m2".withSource.hover(m1 to m2, None)
2222

2323
@Test def hoverOnClassShowsDoc: Unit = {
2424
code"""$m1 /** foo */ ${m2}class Foo $m3 $m4""".withSource
2525
.hover(m1 to m2, None)
26-
.hover(m2 to m3, hoverContent("Foo", "/** foo */"))
26+
.hover(m2 to m3, hoverContent("Foo", "foo"))
2727
.hover(m3 to m4, None)
2828
}
2929

@@ -97,8 +97,78 @@ class HoverTest {
9797
|/** $$Variable */
9898
|class ${m3}Bar${m4} extends Foo
9999
""".withSource
100-
.hover(m1 to m2, hoverContent("Foo", "/** A class: Test\n * */"))
101-
.hover(m3 to m4, hoverContent("Bar", "/** Test */"))
100+
.hover(m1 to m2, hoverContent("Foo", "A class: Test"))
101+
.hover(m3 to m4, hoverContent("Bar", "Test"))
102102
}
103103

104+
@Test def documentationIsFormatted: Unit = {
105+
code"""class Foo(val x: Int, val y: Int) {
106+
| /**
107+
| * Does something
108+
| *
109+
| * @tparam T A first type param
110+
| * @tparam U Another type param
111+
| * @param fizz Again another number
112+
| * @param buzz A String
113+
| * @param ev An implicit boolean
114+
| * @return Something
115+
| * @throws java.lang.NullPointerException if you're unlucky
116+
| * @throws java.lang.InvalidArgumentException if the argument is invalid
117+
| * @see java.nio.file.Paths#get()
118+
| * @note A note
119+
| * @example myFoo.bar[Int, String](0, "hello, world")
120+
| * @author John Doe
121+
| * @version 1.0
122+
| * @since 0.1
123+
| * @usecase def bar(fizz: Int, buzz: String): Any
124+
| */
125+
| def ${m1}bar${m2}[T, U](fizz: Int, buzz: String)(implicit ev: Boolean): Any = ???
126+
|}""".withSource
127+
.hover(
128+
m1 to m2,
129+
hoverContent("[T, U](fizz: Int, buzz: String)(implicit ev: Boolean): Any",
130+
"""Does something
131+
|
132+
|Type Parameters:
133+
| - **T** A first type param
134+
| - **U** Another type param
135+
|
136+
|Parameters:
137+
| - **fizz** Again another number
138+
| - **buzz** A String
139+
| - **ev** An implicit boolean
140+
|
141+
|Returns:
142+
| - Something
143+
|
144+
|Throws:
145+
| - **java.lang.NullPointerException** if you're unlucky
146+
| - **java.lang.InvalidArgumentException** if the argument is invalid
147+
|
148+
|See Also:
149+
| - java.nio.file.Paths#get()
150+
|
151+
|Examples:
152+
| - ```scala
153+
| myFoo.bar[Int, String](0, "hello, world")
154+
| ```
155+
|
156+
|Usecases:
157+
| - ```scala
158+
| def bar(fizz: Int, buzz: String): Any
159+
| ```
160+
|
161+
|Note:
162+
| - A note
163+
|
164+
|Authors:
165+
| - John Doe
166+
|
167+
|Since:
168+
| - 0.1
169+
|
170+
|Version:
171+
| - 1.0""".stripMargin))
172+
173+
}
104174
}

0 commit comments

Comments
 (0)