blob: 6f00597cb09693f9b383e54405a2ac88b8c31be4 [file] [log] [blame]
package org.jetbrains.dokka
import com.intellij.psi.*
import com.intellij.psi.impl.source.javadoc.CorePsiDocTagValueImpl
import com.intellij.psi.impl.source.tree.JavaDocElementType
import com.intellij.psi.javadoc.*
import com.intellij.psi.util.PsiTreeUtil
import com.intellij.util.IncorrectOperationException
import com.intellij.util.containers.isNullOrEmpty
import org.jetbrains.dokka.Model.CodeNode
import org.jetbrains.kotlin.utils.join
import org.jetbrains.kotlin.utils.keysToMap
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import java.io.File
import java.net.URI
import java.util.regex.Pattern
private val NAME_TEXT = Pattern.compile("(\\S+)(.*)", Pattern.DOTALL)
private val TEXT = Pattern.compile("(\\S+)\\s*(.*)", Pattern.DOTALL)
data class JavadocParseResult(
val content: Content,
val deprecatedContent: Content?,
val attributeRefs: List<String>,
val apiLevel: DocumentationNode? = null,
val deprecatedLevel: DocumentationNode? = null,
val artifactId: DocumentationNode? = null,
val attribute: DocumentationNode? = null
) {
companion object {
val Empty = JavadocParseResult(Content.Empty,
null,
emptyList(),
null,
null,
null
)
}
}
interface JavaDocumentationParser {
fun parseDocumentation(element: PsiNamedElement): JavadocParseResult
}
class JavadocParser(
private val refGraph: NodeReferenceGraph,
private val logger: DokkaLogger,
private val signatureProvider: ElementSignatureProvider,
private val externalDocumentationLinkResolver: ExternalDocumentationLinkResolver
) : JavaDocumentationParser {
private fun ContentSection.appendTypeElement(
signature: String,
selector: (DocumentationNode) -> DocumentationNode?
) {
append(LazyContentBlock {
val node = refGraph.lookupOrWarn(signature, logger)?.let(selector)
if (node != null) {
it.append(NodeRenderContent(node, LanguageService.RenderMode.SUMMARY))
it.symbol(":")
it.text(" ")
}
})
}
override fun parseDocumentation(element: PsiNamedElement): JavadocParseResult {
val docComment = (element as? PsiDocCommentOwner)?.docComment
if (docComment == null) return JavadocParseResult.Empty
val result = MutableContent()
var deprecatedContent: Content? = null
val firstParagraph = ContentParagraph()
firstParagraph.convertJavadocElements(
docComment.descriptionElements.dropWhile { it.text.trim().isEmpty() },
element
)
val paragraphs = firstParagraph.children.dropWhile { it !is ContentParagraph }
firstParagraph.children.removeAll(paragraphs)
if (!firstParagraph.isEmpty()) {
result.append(firstParagraph)
}
paragraphs.forEach {
result.append(it)
}
if (element is PsiMethod) {
val tagsByName = element.searchInheritedTags()
for ((tagName, tags) in tagsByName) {
for ((tag, context) in tags) {
val section = result.addSection(javadocSectionDisplayName(tagName), tag.getSubjectName())
val signature = signatureProvider.signature(element)
when (tagName) {
"param" -> {
section.appendTypeElement(signature) {
it.details.find { it.kind == NodeKind.Parameter }?.detailOrNull(NodeKind.Type)
}
}
"return" -> {
section.appendTypeElement(signature) { it.detailOrNull(NodeKind.Type) }
}
}
section.convertJavadocElements(tag.contentElements(), context)
}
}
}
val attrRefSignatures = mutableListOf<String>()
var since: DocumentationNode? = null
var deprecated: DocumentationNode? = null
var artifactId: DocumentationNode? = null
var attrName: String? = null
var attrDesc: Content? = null
var attr: DocumentationNode? = null
docComment.tags.forEach { tag ->
when (tag.name.toLowerCase()) {
"see" -> result.convertSeeTag(tag)
"deprecated" -> {
deprecatedContent = Content().apply {
convertJavadocElements(tag.contentElements(), element)
}
}
"attr" -> {
when (tag.valueElement?.text) {
"ref" ->
tag.getAttrRef(element)?.let {
attrRefSignatures.add(it)
}
"name" -> attrName = tag.getAttrName()
"description" -> attrDesc = tag.getAttrDesc(element)
}
}
"since", "apisince" -> {
since = DocumentationNode(tag.getApiLevel() ?: "", Content.Empty, NodeKind.ApiLevel)
}
"deprecatedsince" -> {
deprecated = DocumentationNode(tag.getApiLevel() ?: "", Content.Empty, NodeKind.DeprecatedLevel)
}
"artifactid" -> {
artifactId = DocumentationNode(tag.artifactId() ?: "", Content.Empty, NodeKind.ArtifactId)
}
in tagsToInherit -> {
}
else -> {
val subjectName = tag.getSubjectName()
val section = result.addSection(javadocSectionDisplayName(tag.name), subjectName)
section.convertJavadocElements(tag.contentElements(), element)
}
}
}
attrName?.let { name ->
attr = DocumentationNode(name, attrDesc ?: Content.Empty, NodeKind.AttributeRef)
}
return JavadocParseResult(result, deprecatedContent, attrRefSignatures, since, deprecated, artifactId, attr)
}
private val tagsToInherit = setOf("param", "return", "throws")
private data class TagWithContext(val tag: PsiDocTag, val context: PsiNamedElement)
fun PsiDocTag.artifactId(): String? {
var artifactName: String? = null
if (dataElements.isNotEmpty()) {
artifactName = join(dataElements.map { it.text }, "")
}
return artifactName
}
fun PsiDocTag.getApiLevel(): String? {
if (dataElements.isNotEmpty()) {
val data = dataElements
if (data[0] is CorePsiDocTagValueImpl) {
val docTagValue = data[0]
if (docTagValue.firstChild != null) {
val apiLevel = docTagValue.firstChild
return apiLevel.text
}
}
}
return null
}
private fun PsiDocTag.getAttrRef(element: PsiNamedElement): String? {
if (dataElements.size > 1) {
val elementText = dataElements[1].text
try {
val linkComment = JavaPsiFacade.getInstance(project).elementFactory
.createDocCommentFromText("/** {@link $elementText} */", element)
val linkElement = PsiTreeUtil.getChildOfType(linkComment, PsiInlineDocTag::class.java)?.linkElement()
val signature = resolveInternalLink(linkElement)
val attrSignature = "AttrMain:$signature"
return attrSignature
} catch (e: IncorrectOperationException) {
return null
}
} else return null
}
private fun PsiDocTag.getAttrName(): String? {
if (dataElements.size > 1) {
val nameMatcher = NAME_TEXT.matcher(dataElements[1].text)
if (nameMatcher.matches()) {
return nameMatcher.group(1)
} else {
return null
}
} else return null
}
private fun PsiDocTag.getAttrDesc(element: PsiNamedElement): Content? {
return Content().apply {
convertJavadocElementsToAttrDesc(contentElements(), element)
}
}
private fun PsiMethod.searchInheritedTags(): Map<String, Collection<TagWithContext>> {
val output = tagsToInherit.keysToMap { mutableMapOf<String?, TagWithContext>() }
fun recursiveSearch(methods: Array<PsiMethod>) {
for (method in methods) {
recursiveSearch(method.findSuperMethods())
}
for (method in methods) {
for (tag in method.docComment?.tags.orEmpty()) {
if (tag.name in tagsToInherit) {
output[tag.name]!![tag.getSubjectName()] = TagWithContext(tag, method)
}
}
}
}
recursiveSearch(arrayOf(this))
return output.mapValues { it.value.values }
}
private fun PsiDocTag.contentElements(): Iterable<PsiElement> {
val tagValueElements = children
.dropWhile { it.node?.elementType == JavaDocTokenType.DOC_TAG_NAME }
.dropWhile { it is PsiWhiteSpace }
.filterNot { it.node?.elementType == JavaDocTokenType.DOC_COMMENT_LEADING_ASTERISKS }
return if (getSubjectName() != null) tagValueElements.dropWhile { it is PsiDocTagValue } else tagValueElements
}
private fun ContentBlock.convertJavadocElements(elements: Iterable<PsiElement>, element: PsiNamedElement) {
val doc = Jsoup.parse(expandAllForElements(elements, element))
doc.body().childNodes().forEach {
convertHtmlNode(it)?.let { append(it) }
}
doc.head().childNodes().forEach {
convertHtmlNode(it)?.let { append(it) }
}
}
private fun ContentBlock.convertJavadocElementsToAttrDesc(elements: Iterable<PsiElement>, element: PsiNamedElement) {
val doc = Jsoup.parse(expandAllForElements(elements, element))
doc.body().childNodes().forEach {
convertHtmlNode(it)?.let {
var content = it
if (content is ContentText) {
var description = content.text
val matcher = TEXT.matcher(content.text)
if (matcher.matches()) {
val command = matcher.group(1)
if (command == "description") {
description = matcher.group(2)
content = ContentText(description)
}
}
}
append(content)
}
}
}
private fun expandAllForElements(elements: Iterable<PsiElement>, element: PsiNamedElement): String {
val htmlBuilder = StringBuilder()
elements.forEach {
if (it is PsiInlineDocTag) {
htmlBuilder.append(convertInlineDocTag(it, element))
} else {
htmlBuilder.append(it.text)
}
}
return htmlBuilder.toString().trim()
}
private fun convertHtmlNode(node: Node, isBlockCode: Boolean = false): ContentNode? {
if (isBlockCode) {
return if (node is TextNode) { // Fixes b/129762453
val codeNode = CodeNode(node.wholeText, "")
ContentText(codeNode.text().removePrefix("#"))
} else { // Fixes b/129857975
ContentText(node.toString())
}
}
if (node is TextNode) {
return ContentText(node.text().removePrefix("#"))
} else if (node is Element) {
val childBlock = createBlock(node)
node.childNodes().forEach {
val child = convertHtmlNode(it, isBlockCode = childBlock is ContentBlockCode)
if (child != null) {
childBlock.append(child)
}
}
return (childBlock)
}
return null
}
private fun createBlock(element: Element): ContentBlock = when (element.tagName()) {
"p" -> ContentParagraph()
"b", "strong" -> ContentStrong()
"i", "em" -> ContentEmphasis()
"s", "del" -> ContentStrikethrough()
"code" -> ContentCode()
"pre" -> ContentBlockCode()
"ul" -> ContentUnorderedList()
"ol" -> ContentOrderedList()
"li" -> ContentListItem()
"a" -> createLink(element)
"br" -> ContentBlock().apply { hardLineBreak() }
"dl" -> ContentDescriptionList()
"dt" -> ContentDescriptionTerm()
"dd" -> ContentDescriptionDefinition()
"table" -> ContentTable()
"tbody" -> ContentTableBody()
"tr" -> ContentTableRow()
"th" -> {
val colspan = element.attr("colspan")
val rowspan = element.attr("rowspan")
ContentTableHeader(colspan, rowspan)
}
"td" -> {
val colspan = element.attr("colspan")
val rowspan = element.attr("rowspan")
ContentTableCell(colspan, rowspan)
}
"h1" -> ContentHeading(1)
"h2" -> ContentHeading(2)
"h3" -> ContentHeading(3)
"h4" -> ContentHeading(4)
"h5" -> ContentHeading(5)
"h6" -> ContentHeading(6)
"div" -> {
val divClass = element.attr("class")
if (divClass == "special reference" || divClass == "note") ContentSpecialReference()
else ContentParagraph()
}
"script" -> ScriptBlock(element.attr("type"), element.attr("src"))
else -> ContentBlock()
}
private fun createLink(element: Element): ContentBlock {
return when {
element.hasAttr("docref") -> {
val docref = element.attr("docref")
ContentNodeLazyLink(docref, { -> refGraph.lookupOrWarn(docref, logger) })
}
element.hasAttr("href") -> {
val href = element.attr("href")
val uri = try {
URI(href)
} catch (_: Exception) {
null
}
if (uri?.isAbsolute == false) {
ContentLocalLink(href)
} else {
ContentExternalLink(href)
}
}
element.hasAttr("name") -> {
ContentBookmark(element.attr("name"))
}
else -> ContentBlock()
}
}
private fun MutableContent.convertSeeTag(tag: PsiDocTag) {
val linkElement = tag.linkElement() ?: return
val seeSection = findSectionByTag(ContentTags.SeeAlso) ?: addSection(ContentTags.SeeAlso, null)
val valueElement = tag.referenceElement()
val externalLink = resolveExternalLink(valueElement)
val text = ContentText(linkElement.text)
val linkSignature by lazy { resolveInternalLink(valueElement) }
val node = when {
externalLink != null -> {
val linkNode = ContentExternalLink(externalLink)
linkNode.append(text)
linkNode
}
linkSignature != null -> {
val linkNode =
ContentNodeLazyLink(
(tag.valueElement ?: linkElement).text,
{ -> refGraph.lookupOrWarn(linkSignature, logger) }
)
linkNode.append(text)
linkNode
}
else -> text
}
seeSection.append(node)
}
private fun convertInlineDocTag(tag: PsiInlineDocTag, element: PsiNamedElement) = when (tag.name) {
"link", "linkplain" -> {
val valueElement = tag.referenceElement()
val externalLink = resolveExternalLink(valueElement)
val linkSignature by lazy { resolveInternalLink(valueElement) }
if (externalLink != null || linkSignature != null) {
val labelText = tag.dataElements.firstOrNull { it is PsiDocToken }?.text ?: valueElement!!.text
val linkTarget = if (externalLink != null) "href=\"$externalLink\"" else "docref=\"$linkSignature\""
val link = "<a $linkTarget>${labelText.htmlEscape()}</a>"
if (tag.name == "link") "<code>$link</code>" else link
} else if (valueElement != null) {
valueElement.text
} else {
""
}
}
"code", "literal" -> {
val text = StringBuilder()
tag.dataElements.forEach { text.append(it.text) }
val escaped = text.toString().trimStart().htmlEscape()
if (tag.name == "code") "<code>$escaped</code>" else escaped
}
"inheritDoc" -> {
val result = (element as? PsiMethod)?.let {
// @{inheritDoc} is only allowed on functions
val parent = tag.parent
when (parent) {
is PsiDocComment -> element.findSuperDocCommentOrWarn()
is PsiDocTag -> element.findSuperDocTagOrWarn(parent)
else -> null
}
}
result ?: tag.text
}
"docRoot" -> {
// TODO: fix that
"https://developer.android.com/"
}
"sample" -> {
tag.text?.let { tagText ->
val (absolutePath, delimiter) = getSampleAnnotationInformation(tagText)
val code = retrieveCodeInFile(absolutePath, delimiter)
return if (code != null && code.isNotEmpty()) {
"<pre is-upgraded>$code</pre>"
} else {
""
}
}
}
// Loads script from CDN, ScriptBlock objects constructs HTML object
"usesMathJax" -> {
"<script type=\"text/javascript\" async src=\"https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/" +
"latest.js?config=TeX-AMS_SVG\"></script>"
}
else -> tag.text
}
private fun PsiDocTag.referenceElement(): PsiElement? =
linkElement()?.let {
if (it.node.elementType == JavaDocElementType.DOC_REFERENCE_HOLDER) {
PsiTreeUtil.findChildOfType(it, PsiJavaCodeReferenceElement::class.java)
} else {
it
}
}
private fun PsiDocTag.linkElement(): PsiElement? =
valueElement ?: dataElements.firstOrNull { it !is PsiWhiteSpace }
private fun resolveExternalLink(valueElement: PsiElement?): String? {
val target = valueElement?.reference?.resolve()
if (target != null) {
return externalDocumentationLinkResolver.buildExternalDocumentationLink(target)
}
return null
}
private fun resolveInternalLink(valueElement: PsiElement?): String? {
val target = valueElement?.reference?.resolve()
if (target != null) {
return signatureProvider.signature(target)
}
return null
}
fun PsiDocTag.getSubjectName(): String? {
if (name == "param" || name == "throws" || name == "exception") {
return valueElement?.text
}
return null
}
private fun PsiMethod.findSuperDocCommentOrWarn(): String {
val method = findFirstSuperMethodWithDocumentation(this)
if (method != null) {
val descriptionElements = method.docComment?.descriptionElements?.dropWhile {
it.text.trim().isEmpty()
} ?: return ""
return expandAllForElements(descriptionElements, method)
}
logger.warn("No docs found on supertype with {@inheritDoc} method ${this.name} in ${this.containingFile.name}:${this.lineNumber()}")
return ""
}
private fun PsiMethod.findSuperDocTagOrWarn(elementToExpand: PsiDocTag): String {
val result = findFirstSuperMethodWithDocumentationforTag(elementToExpand, this)
if (result != null) {
val (method, tag) = result
val contentElements = tag.contentElements().dropWhile { it.text.trim().isEmpty() }
val expandedString = expandAllForElements(contentElements, method)
return expandedString
}
logger.warn("No docs found on supertype for @${elementToExpand.name} ${elementToExpand.getSubjectName()} with {@inheritDoc} method ${this.name} in ${this.containingFile.name}:${this.lineNumber()}")
return ""
}
private fun findFirstSuperMethodWithDocumentation(current: PsiMethod): PsiMethod? {
val superMethods = current.findSuperMethods()
for (method in superMethods) {
val docs = method.docComment?.descriptionElements?.dropWhile { it.text.trim().isEmpty() }
if (!docs.isNullOrEmpty()) {
return method
}
}
for (method in superMethods) {
val result = findFirstSuperMethodWithDocumentation(method)
if (result != null) {
return result
}
}
return null
}
private fun findFirstSuperMethodWithDocumentationforTag(
elementToExpand: PsiDocTag,
current: PsiMethod
): Pair<PsiMethod, PsiDocTag>? {
val superMethods = current.findSuperMethods()
val mappedFilteredTags = superMethods.map {
it to it.docComment?.tags?.filter { it.name == elementToExpand.name }
}
for ((method, tags) in mappedFilteredTags) {
tags ?: continue
for (tag in tags) {
val (tagSubject, elementSubject) = when (tag.name) {
"throws" -> {
// match class names only for throws, ignore possibly fully qualified path
// TODO: Always match exactly here
tag.getSubjectName()?.split(".")?.last() to elementToExpand.getSubjectName()?.split(".")?.last()
}
else -> {
tag.getSubjectName() to elementToExpand.getSubjectName()
}
}
if (tagSubject == elementSubject) {
return method to tag
}
}
}
for (method in superMethods) {
val result = findFirstSuperMethodWithDocumentationforTag(elementToExpand, method)
if (result != null) {
return result
}
}
return null
}
/**
* Returns information inside @sample
*
* Component1 is the absolute path to the file
* Component2 is the delimiter if exists in the file
*/
private fun getSampleAnnotationInformation(tagText: String): Pair<String, String> {
val pathContent = tagText
.trim { it == '{' || it == '}' }
.removePrefix("@sample ")
val formattedPath = pathContent.substringBefore(" ").trim()
val potentialDelimiter = pathContent.substringAfterLast(" ").trim()
val delimiter = if (potentialDelimiter == formattedPath) "" else potentialDelimiter
val path = "samples/$formattedPath"
return Pair(path, delimiter)
}
/**
* Retrieves the code inside a file.
*
* If betweenTag is not empty, it retrieves the code between
* BEGIN_INCLUDE($betweenTag) and END_INCLUDE($betweenTag) comments.
*
* Also, the method will trim every line with the number of spaces in the first line
*/
private fun retrieveCodeInFile(path: String, betweenTag: String = "") = StringBuilder().apply {
try {
if (betweenTag.isEmpty()) {
appendContent(path)
} else {
appendContentBetweenIncludes(path, betweenTag)
}
} catch (e: java.lang.Exception) {
logger.error("No file found when processing Java @sample. Path to sample: $path")
}
}
private fun StringBuilder.appendContent(path: String) {
val spaces = InitialSpaceIndent()
File(path).forEachLine {
appendWithoutInitialIndent(it, spaces)
}
}
private fun StringBuilder.appendContentBetweenIncludes(path: String, includeTag: String) {
var shouldAppend = false
val beginning = "BEGIN_INCLUDE($includeTag)"
val end = "END_INCLUDE($includeTag)"
val spaces = InitialSpaceIndent()
File(path).forEachLine {
if (shouldAppend) {
if (it.contains(end)) {
shouldAppend = false
} else {
appendWithoutInitialIndent(it, spaces)
}
} else {
if (it.contains(beginning)) shouldAppend = true
}
}
}
private fun StringBuilder.appendWithoutInitialIndent(it: String, spaces: InitialSpaceIndent) {
if (spaces.value == -1) {
spaces.value = (it.length - it.trimStart().length).coerceAtLeast(0)
appendln(it)
} else {
appendln(if (it.isBlank()) it else it.substring(spaces.value, it.length))
}
}
private data class InitialSpaceIndent(var value: Int = -1)
}