diff options
author | Ignat Beresnev <ignat.beresnev@jetbrains.com> | 2023-07-05 10:04:55 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-07-05 10:04:55 +0200 |
commit | 9559158bfeeb274e9ccf1b4563f1b23b42afc493 (patch) | |
tree | 3ece0887623cfe2b7148af23001867a1dd5e6597 /subprojects/analysis-java-psi/src/main/kotlin | |
parent | cbd9733d3dd2f52992e98e7cebd072091a572529 (diff) | |
download | dokka-9559158bfeeb274e9ccf1b4563f1b23b42afc493.tar.gz dokka-9559158bfeeb274e9ccf1b4563f1b23b42afc493.tar.bz2 dokka-9559158bfeeb274e9ccf1b4563f1b23b42afc493.zip |
Decompose Kotlin/Java analysis (#3034)
* Extract analysis into separate modules
Diffstat (limited to 'subprojects/analysis-java-psi/src/main/kotlin')
31 files changed, 2592 insertions, 0 deletions
diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/DefaultPsiToDocumentableTranslator.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/DefaultPsiToDocumentableTranslator.kt new file mode 100644 index 00000000..72cf45d9 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/DefaultPsiToDocumentableTranslator.kt @@ -0,0 +1,83 @@ +package org.jetbrains.dokka.analysis.java + +import com.intellij.openapi.vfs.VirtualFileManager +import com.intellij.psi.PsiJavaFile +import com.intellij.psi.PsiKeyword +import com.intellij.psi.PsiManager +import com.intellij.psi.PsiModifierListOwner +import kotlinx.coroutines.coroutineScope +import org.jetbrains.dokka.DokkaConfiguration.DokkaSourceSet +import org.jetbrains.dokka.analysis.java.parsers.DokkaPsiParser +import org.jetbrains.dokka.analysis.java.parsers.JavaPsiDocCommentParser +import org.jetbrains.dokka.analysis.java.parsers.JavadocParser +import org.jetbrains.dokka.model.DModule +import org.jetbrains.dokka.model.JavaVisibility +import org.jetbrains.dokka.plugability.DokkaContext +import org.jetbrains.dokka.plugability.plugin +import org.jetbrains.dokka.plugability.query +import org.jetbrains.dokka.plugability.querySingle +import org.jetbrains.dokka.transformers.sources.AsyncSourceToDocumentableTranslator +import org.jetbrains.dokka.utilities.parallelMap +import org.jetbrains.dokka.utilities.parallelMapNotNull + +internal class DefaultPsiToDocumentableTranslator : AsyncSourceToDocumentableTranslator { + + override suspend fun invokeSuspending(sourceSet: DokkaSourceSet, context: DokkaContext): DModule { + return coroutineScope { + val projectProvider = context.plugin<JavaAnalysisPlugin>().querySingle { projectProvider } + val project = projectProvider.getProject(sourceSet, context) + + val sourceRootsExtractor = context.plugin<JavaAnalysisPlugin>().querySingle { sourceRootsExtractor } + val sourceRoots = sourceRootsExtractor.extract(sourceSet, context) + + val localFileSystem = VirtualFileManager.getInstance().getFileSystem("file") + + val psiFiles = sourceRoots.parallelMap { sourceRoot -> + sourceRoot.absoluteFile.walkTopDown().mapNotNull { + localFileSystem.findFileByPath(it.path)?.let { vFile -> + PsiManager.getInstance(project).findFile(vFile) as? PsiJavaFile + } + }.toList() + }.flatten() + + val docParser = createPsiParser(sourceSet, context) + + DModule( + name = context.configuration.moduleName, + packages = psiFiles.parallelMapNotNull { it }.groupBy { it.packageName }.toList() + .parallelMap { (packageName: String, psiFiles: List<PsiJavaFile>) -> + docParser.parsePackage(packageName, psiFiles) + }, + documentation = emptyMap(), + expectPresentInSet = null, + sourceSets = setOf(sourceSet) + ) + } + } + + private fun createPsiParser(sourceSet: DokkaSourceSet, context: DokkaContext): DokkaPsiParser { + val projectProvider = context.plugin<JavaAnalysisPlugin>().querySingle { projectProvider } + val docCommentParsers = context.plugin<JavaAnalysisPlugin>().query { docCommentParsers } + return DokkaPsiParser( + sourceSetData = sourceSet, + project = projectProvider.getProject(sourceSet, context), + logger = context.logger, + javadocParser = JavadocParser( + docCommentParsers = docCommentParsers, + docCommentFinder = context.plugin<JavaAnalysisPlugin>().docCommentFinder + ), + javaPsiDocCommentParser = docCommentParsers.single { it is JavaPsiDocCommentParser } as JavaPsiDocCommentParser, + lightMethodChecker = context.plugin<JavaAnalysisPlugin>().querySingle { kotlinLightMethodChecker } + ) + } +} + +internal fun PsiModifierListOwner.getVisibility() = modifierList?.let { + val ml = it.children.toList() + when { + ml.any { it.text == PsiKeyword.PUBLIC } || it.hasModifierProperty("public") -> JavaVisibility.Public + ml.any { it.text == PsiKeyword.PROTECTED } || it.hasModifierProperty("protected") -> JavaVisibility.Protected + ml.any { it.text == PsiKeyword.PRIVATE } || it.hasModifierProperty("private") -> JavaVisibility.Private + else -> JavaVisibility.Default + } +} ?: JavaVisibility.Default diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/JavaAnalysisPlugin.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/JavaAnalysisPlugin.kt new file mode 100644 index 00000000..8884d444 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/JavaAnalysisPlugin.kt @@ -0,0 +1,106 @@ +package org.jetbrains.dokka.analysis.java + +import com.intellij.lang.jvm.annotation.JvmAnnotationAttribute +import com.intellij.openapi.diagnostic.Logger +import com.intellij.openapi.project.Project +import com.intellij.psi.PsiAnnotation +import org.jetbrains.dokka.CoreExtensions +import org.jetbrains.dokka.DokkaConfiguration.DokkaSourceSet +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.analysis.java.doccomment.DocCommentCreator +import org.jetbrains.dokka.analysis.java.doccomment.DocCommentFactory +import org.jetbrains.dokka.analysis.java.doccomment.DocCommentFinder +import org.jetbrains.dokka.analysis.java.doccomment.JavaDocCommentCreator +import org.jetbrains.dokka.analysis.java.parsers.DocCommentParser +import org.jetbrains.dokka.analysis.java.parsers.doctag.InheritDocTagContentProvider +import org.jetbrains.dokka.analysis.java.parsers.JavaPsiDocCommentParser +import org.jetbrains.dokka.analysis.java.parsers.doctag.InheritDocTagResolver +import org.jetbrains.dokka.analysis.java.parsers.doctag.PsiDocTagParser +import org.jetbrains.dokka.analysis.java.util.NoopIntellijLoggerFactory +import org.jetbrains.dokka.plugability.* +import java.io.File + + +@InternalDokkaApi +interface ProjectProvider { + fun getProject(sourceSet: DokkaSourceSet, context: DokkaContext): Project +} + +@InternalDokkaApi +interface SourceRootsExtractor { + fun extract(sourceSet: DokkaSourceSet, context: DokkaContext): List<File> +} + +@InternalDokkaApi +interface BreakingAbstractionKotlinLightMethodChecker { + // TODO [beresnev] not even sure it's needed, but left for compatibility and to preserve behaviour + fun isLightAnnotation(annotation: PsiAnnotation): Boolean + fun isLightAnnotationAttribute(attribute: JvmAnnotationAttribute): Boolean +} + +@InternalDokkaApi +class JavaAnalysisPlugin : DokkaPlugin() { + + // single + val projectProvider by extensionPoint<ProjectProvider>() + + // single + val sourceRootsExtractor by extensionPoint<SourceRootsExtractor>() + + // multiple + val docCommentCreators by extensionPoint<DocCommentCreator>() + + // multiple + val docCommentParsers by extensionPoint<DocCommentParser>() + + // none or more + val inheritDocTagContentProviders by extensionPoint<InheritDocTagContentProvider>() + + // TODO [beresnev] figure out a better way depending on what it's used for + val kotlinLightMethodChecker by extensionPoint<BreakingAbstractionKotlinLightMethodChecker>() + + private val docCommentFactory by lazy { + DocCommentFactory(query { docCommentCreators }.reversed()) + } + + val docCommentFinder by lazy { + DocCommentFinder(logger, docCommentFactory) + } + + internal val javaDocCommentCreator by extending { + docCommentCreators providing { JavaDocCommentCreator() } + } + + private val psiDocTagParser by lazy { + PsiDocTagParser( + inheritDocTagResolver = InheritDocTagResolver( + docCommentFactory = docCommentFactory, + docCommentFinder = docCommentFinder, + contentProviders = query { inheritDocTagContentProviders } + ) + ) + } + + internal val javaDocCommentParser by extending { + docCommentParsers providing { + JavaPsiDocCommentParser( + psiDocTagParser + ) + } + } + + internal val psiToDocumentableTranslator by extending { + CoreExtensions.sourceToDocumentableTranslator providing { DefaultPsiToDocumentableTranslator() } + } + + @OptIn(DokkaPluginApiPreview::class) + override fun pluginApiPreviewAcknowledgement(): PluginApiPreviewAcknowledgement = PluginApiPreviewAcknowledgement + + private companion object { + init { + // Suppress messages emitted by the IntelliJ logger since + // there's not much the end user can do about it + Logger.setFactory(NoopIntellijLoggerFactory()) + } + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/JavadocTag.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/JavadocTag.kt new file mode 100644 index 00000000..f5cd550f --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/JavadocTag.kt @@ -0,0 +1,48 @@ +package org.jetbrains.dokka.analysis.java + +import com.intellij.psi.PsiMethod +import org.jetbrains.dokka.InternalDokkaApi + +@InternalDokkaApi +sealed class JavadocTag(val name: String) + +object AuthorJavadocTag : JavadocTag("author") +object DeprecatedJavadocTag : JavadocTag("deprecated") +object DescriptionJavadocTag : JavadocTag("description") +object ReturnJavadocTag : JavadocTag("return") +object SinceJavadocTag : JavadocTag("since") + +class ParamJavadocTag( + val method: PsiMethod, + val paramName: String, + val paramIndex: Int +) : JavadocTag(name) { + companion object { + const val name: String = "param" + } +} + +class SeeJavadocTag( + val qualifiedReference: String +) : JavadocTag(name) { + companion object { + const val name: String = "see" + } +} + +sealed class ThrowingExceptionJavadocTag( + name: String, + val exceptionQualifiedName: String? +) : JavadocTag(name) + +class ThrowsJavadocTag(exceptionQualifiedName: String?) : ThrowingExceptionJavadocTag(name, exceptionQualifiedName) { + companion object { + const val name: String = "throws" + } +} + +class ExceptionJavadocTag(exceptionQualifiedName: String?) : ThrowingExceptionJavadocTag(name, exceptionQualifiedName) { + companion object { + const val name: String = "exception" + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/SynheticElementDocumentationProvider.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/SynheticElementDocumentationProvider.kt new file mode 100644 index 00000000..d780bb40 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/SynheticElementDocumentationProvider.kt @@ -0,0 +1,42 @@ +package org.jetbrains.dokka.analysis.java + +import com.intellij.openapi.project.Project +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMethod +import com.intellij.psi.SyntheticElement +import com.intellij.psi.javadoc.PsiDocComment +import org.jetbrains.dokka.analysis.java.parsers.JavaPsiDocCommentParser +import org.jetbrains.dokka.model.doc.DocumentationNode + +private const val ENUM_VALUEOF_TEMPLATE_PATH = "/dokka/docs/javadoc/EnumValueOf.java.template" +private const val ENUM_VALUES_TEMPLATE_PATH = "/dokka/docs/javadoc/EnumValues.java.template" + +internal class SyntheticElementDocumentationProvider( + private val javadocParser: JavaPsiDocCommentParser, + private val project: Project +) { + fun isDocumented(psiElement: PsiElement): Boolean = psiElement is PsiMethod + && (psiElement.isSyntheticEnumValuesMethod() || psiElement.isSyntheticEnumValueOfMethod()) + + fun getDocumentation(psiElement: PsiElement): DocumentationNode? { + val psiMethod = psiElement as? PsiMethod ?: return null + val templatePath = when { + psiMethod.isSyntheticEnumValuesMethod() -> ENUM_VALUES_TEMPLATE_PATH + psiMethod.isSyntheticEnumValueOfMethod() -> ENUM_VALUEOF_TEMPLATE_PATH + else -> return null + } + val docComment = loadSyntheticDoc(templatePath) ?: return null + return javadocParser.parsePsiDocComment(docComment, psiElement) + } + + private fun loadSyntheticDoc(path: String): PsiDocComment? { + val text = javaClass.getResource(path)?.readText() ?: return null + return JavaPsiFacade.getElementFactory(project).createDocCommentFromText(text) + } +} + +private fun PsiMethod.isSyntheticEnumValuesMethod() = this.isSyntheticEnumFunction() && this.name == "values" +private fun PsiMethod.isSyntheticEnumValueOfMethod() = this.isSyntheticEnumFunction() && this.name == "valueOf" +private fun PsiMethod.isSyntheticEnumFunction() = this is SyntheticElement && this.containingClass?.isEnum == true + diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocComment.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocComment.kt new file mode 100644 index 00000000..6cc32233 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocComment.kt @@ -0,0 +1,14 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.analysis.java.JavadocTag + +/** + * MUST override equals and hashcode + */ +@InternalDokkaApi +interface DocComment { + fun hasTag(tag: JavadocTag): Boolean + + fun resolveTag(tag: JavadocTag): List<DocumentationContent> +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentCreator.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentCreator.kt new file mode 100644 index 00000000..3d7d4247 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentCreator.kt @@ -0,0 +1,9 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import com.intellij.psi.PsiNamedElement +import org.jetbrains.dokka.InternalDokkaApi + +@InternalDokkaApi +interface DocCommentCreator { + fun create(element: PsiNamedElement): DocComment? +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentFactory.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentFactory.kt new file mode 100644 index 00000000..96245ac2 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentFactory.kt @@ -0,0 +1,20 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import com.intellij.psi.PsiNamedElement +import org.jetbrains.dokka.InternalDokkaApi + +@InternalDokkaApi +class DocCommentFactory( + private val docCommentCreators: List<DocCommentCreator> +) { + fun fromElement(element: PsiNamedElement): DocComment? { + docCommentCreators.forEach { creator -> + val comment = creator.create(element) + if (comment != null) { + return comment + } + } + return null + } +} + diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentFinder.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentFinder.kt new file mode 100644 index 00000000..32c8dc65 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocCommentFinder.kt @@ -0,0 +1,64 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.javadoc.PsiDocComment +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.analysis.java.util.from +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.utilities.DokkaLogger + +@InternalDokkaApi +class DocCommentFinder( + private val logger: DokkaLogger, + private val docCommentFactory: DocCommentFactory, +) { + fun findClosestToElement(element: PsiNamedElement): DocComment? { + val docComment = docCommentFactory.fromElement(element) + if (docComment != null) { + return docComment + } + + return if (element is PsiMethod) { + findClosestToMethod(element) + } else { + element.children + .filterIsInstance<PsiDocComment>() + .firstOrNull() + ?.let { JavaDocComment(it) } + } + } + + private fun findClosestToMethod(method: PsiMethod): DocComment? { + val superMethods = method.findSuperMethods() + if (superMethods.isEmpty()) return null + + if (superMethods.size == 1) { + return findClosestToElement(superMethods.single()) + } + + val superMethodDocumentation = superMethods.map { superMethod -> findClosestToElement(superMethod) }.distinct() + if (superMethodDocumentation.size == 1) { + return superMethodDocumentation.single() + } + + logger.debug( + "Conflicting documentation for ${DRI.from(method)}" + + "${superMethods.map { DRI.from(it) }}" + ) + + /* Prioritize super class over interface */ + val indexOfSuperClass = superMethods.indexOfFirst { superMethod -> + val parent = superMethod.parent + if (parent is PsiClass) !parent.isInterface + else false + } + + return if (indexOfSuperClass >= 0) { + superMethodDocumentation[indexOfSuperClass] + } else { + superMethodDocumentation.first() + } + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocumentationContent.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocumentationContent.kt new file mode 100644 index 00000000..c06ed496 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/DocumentationContent.kt @@ -0,0 +1,11 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.analysis.java.JavadocTag + +@InternalDokkaApi +interface DocumentationContent { + val tag: JavadocTag + + fun resolveSiblings(): List<DocumentationContent> +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/JavaDocComment.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/JavaDocComment.kt new file mode 100644 index 00000000..5c9be887 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/JavaDocComment.kt @@ -0,0 +1,84 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import com.intellij.psi.PsiElement +import com.intellij.psi.javadoc.PsiDocComment +import com.intellij.psi.javadoc.PsiDocTag +import org.jetbrains.dokka.analysis.java.* +import org.jetbrains.dokka.analysis.java.util.contentElementsWithSiblingIfNeeded +import org.jetbrains.dokka.analysis.java.util.getKotlinFqName +import org.jetbrains.dokka.analysis.java.util.hasTag +import org.jetbrains.dokka.analysis.java.util.resolveToElement +import org.jetbrains.dokka.utilities.firstIsInstanceOrNull + +internal class JavaDocComment(val comment: PsiDocComment) : DocComment { + override fun hasTag(tag: JavadocTag): Boolean { + return when (tag) { + is ThrowingExceptionJavadocTag -> hasTag(tag) + else -> comment.hasTag(tag) + } + } + + private fun hasTag(tag: ThrowingExceptionJavadocTag): Boolean = + comment.hasTag(tag) && comment.resolveTag(tag).firstIsInstanceOrNull<PsiDocTag>() + ?.resolveToElement() + ?.getKotlinFqName() == tag.exceptionQualifiedName + + override fun resolveTag(tag: JavadocTag): List<DocumentationContent> { + return when (tag) { + is ParamJavadocTag -> resolveParamTag(tag) + is ThrowingExceptionJavadocTag -> resolveThrowingTag(tag) + else -> comment.resolveTag(tag).map { PsiDocumentationContent(it, tag) } + } + } + + private fun resolveParamTag(tag: ParamJavadocTag): List<DocumentationContent> { + val resolvedParamElements = comment.resolveTag(tag) + .filterIsInstance<PsiDocTag>() + .map { it.contentElementsWithSiblingIfNeeded() } + .firstOrNull { + it.firstOrNull()?.text == tag.method.parameterList.parameters[tag.paramIndex].name + }.orEmpty() + + return resolvedParamElements + .withoutReferenceLink() + .map { PsiDocumentationContent(it, tag) } + } + + private fun resolveThrowingTag(tag: ThrowingExceptionJavadocTag): List<DocumentationContent> { + val resolvedElements = comment.resolveTag(tag) + .flatMap { + when (it) { + is PsiDocTag -> it.contentElementsWithSiblingIfNeeded() + else -> listOf(it) + } + } + + return resolvedElements + .withoutReferenceLink() + .map { PsiDocumentationContent(it, tag) } + } + + private fun PsiDocComment.resolveTag(tag: JavadocTag): List<PsiElement> { + return when (tag) { + DescriptionJavadocTag -> this.descriptionElements.toList() + else -> this.findTagsByName(tag.name).toList() + } + } + + private fun List<PsiElement>.withoutReferenceLink(): List<PsiElement> = drop(1) + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as JavaDocComment + + if (comment != other.comment) return false + + return true + } + + override fun hashCode(): Int { + return comment.hashCode() + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/JavaDocCommentCreator.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/JavaDocCommentCreator.kt new file mode 100644 index 00000000..00efeb0a --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/JavaDocCommentCreator.kt @@ -0,0 +1,11 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import com.intellij.psi.PsiDocCommentOwner +import com.intellij.psi.PsiNamedElement + +internal class JavaDocCommentCreator : DocCommentCreator { + override fun create(element: PsiNamedElement): DocComment? { + val psiDocComment = (element as? PsiDocCommentOwner)?.docComment ?: return null + return JavaDocComment(psiDocComment) + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/PsiDocumentationContent.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/PsiDocumentationContent.kt new file mode 100644 index 00000000..c36ce50d --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/doccomment/PsiDocumentationContent.kt @@ -0,0 +1,22 @@ +package org.jetbrains.dokka.analysis.java.doccomment + +import com.intellij.psi.PsiElement +import com.intellij.psi.javadoc.PsiDocTag +import org.jetbrains.dokka.analysis.java.JavadocTag +import org.jetbrains.dokka.analysis.java.util.contentElementsWithSiblingIfNeeded + +internal data class PsiDocumentationContent( + val psiElement: PsiElement, + override val tag: JavadocTag +) : DocumentationContent { + + override fun resolveSiblings(): List<DocumentationContent> { + return if (psiElement is PsiDocTag) { + psiElement.contentElementsWithSiblingIfNeeded() + .map { content -> PsiDocumentationContent(content, tag) } + } else { + listOf(this) + } + } + +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/CommentResolutionContext.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/CommentResolutionContext.kt new file mode 100644 index 00000000..1e193648 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/CommentResolutionContext.kt @@ -0,0 +1,9 @@ +package org.jetbrains.dokka.analysis.java.parsers + +import com.intellij.psi.javadoc.PsiDocComment +import org.jetbrains.dokka.analysis.java.JavadocTag + +internal data class CommentResolutionContext( + val comment: PsiDocComment, + val tag: JavadocTag? +) diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/DocCommentParser.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/DocCommentParser.kt new file mode 100644 index 00000000..3f691799 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/DocCommentParser.kt @@ -0,0 +1,12 @@ +package org.jetbrains.dokka.analysis.java.parsers + +import com.intellij.psi.PsiNamedElement +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.analysis.java.doccomment.DocComment +import org.jetbrains.dokka.model.doc.DocumentationNode + +@InternalDokkaApi +interface DocCommentParser { + fun canParse(docComment: DocComment): Boolean + fun parse(docComment: DocComment, context: PsiNamedElement): DocumentationNode +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/DokkaPsiParser.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/DokkaPsiParser.kt new file mode 100644 index 00000000..45f44338 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/DokkaPsiParser.kt @@ -0,0 +1,797 @@ +package org.jetbrains.dokka.analysis.java.parsers + +import com.intellij.lang.jvm.JvmModifier +import com.intellij.lang.jvm.annotation.JvmAnnotationAttribute +import com.intellij.lang.jvm.annotation.JvmAnnotationAttributeValue +import com.intellij.lang.jvm.annotation.JvmAnnotationConstantValue +import com.intellij.lang.jvm.annotation.JvmAnnotationEnumFieldValue +import com.intellij.lang.jvm.types.JvmReferenceType +import com.intellij.openapi.project.Project +import com.intellij.psi.* +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import org.jetbrains.dokka.DokkaConfiguration +import org.jetbrains.dokka.analysis.java.BreakingAbstractionKotlinLightMethodChecker +import org.jetbrains.dokka.analysis.java.SyntheticElementDocumentationProvider +import org.jetbrains.dokka.analysis.java.getVisibility +import org.jetbrains.dokka.analysis.java.util.* +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.links.nextTarget +import org.jetbrains.dokka.links.withClass +import org.jetbrains.dokka.links.withEnumEntryExtra +import org.jetbrains.dokka.model.* +import org.jetbrains.dokka.model.AnnotationTarget +import org.jetbrains.dokka.model.doc.DocumentationNode +import org.jetbrains.dokka.model.doc.Param +import org.jetbrains.dokka.model.properties.PropertyContainer +import org.jetbrains.dokka.utilities.DokkaLogger +import org.jetbrains.dokka.utilities.parallelForEach +import org.jetbrains.dokka.utilities.parallelMap +import org.jetbrains.dokka.utilities.parallelMapNotNull + +internal class DokkaPsiParser( + private val sourceSetData: DokkaConfiguration.DokkaSourceSet, + private val project: Project, + private val logger: DokkaLogger, + private val javadocParser: JavadocParser, + private val javaPsiDocCommentParser: JavaPsiDocCommentParser, + private val lightMethodChecker: BreakingAbstractionKotlinLightMethodChecker, +) { + private val syntheticDocProvider = SyntheticElementDocumentationProvider(javaPsiDocCommentParser, project) + + private val cachedBounds = hashMapOf<String, Bound>() + + private val PsiMethod.hash: Int + get() = "$returnType $name$parameterList".hashCode() + + private val PsiField.hash: Int + get() = "$type $name".hashCode() + + private val PsiClassType.shouldBeIgnored: Boolean + get() = isClass("java.lang.Enum") || isClass("java.lang.Object") + + private fun PsiClassType.isClass(qName: String): Boolean { + val shortName = qName.substringAfterLast('.') + if (className == shortName) { + val psiClass = resolve() + return psiClass?.qualifiedName == qName + } + return false + } + + private fun <T> T.toSourceSetDependent() = mapOf(sourceSetData to this) + + suspend fun parsePackage(packageName: String, psiFiles: List<PsiJavaFile>): DPackage = coroutineScope { + val dri = DRI(packageName = packageName) + val packageInfo = psiFiles.singleOrNull { it.name == "package-info.java" } + val documentation = packageInfo?.let { + javadocParser.parseDocumentation(it).toSourceSetDependent() + }.orEmpty() + val annotations = packageInfo?.packageStatement?.annotationList?.annotations + + DPackage( + dri = dri, + functions = emptyList(), + properties = emptyList(), + classlikes = psiFiles.parallelMap { psiFile -> + coroutineScope { + psiFile.classes.asIterable().parallelMap { parseClasslike(it, dri) } + } + }.flatten(), + typealiases = emptyList(), + documentation = documentation, + expectPresentInSet = null, + sourceSets = setOf(sourceSetData), + extra = PropertyContainer.withAll( + annotations?.toList().orEmpty().toListOfAnnotations().toSourceSetDependent().toAnnotations() + ) + ) + } + + private suspend fun parseClasslike(psi: PsiClass, parent: DRI): DClasslike = coroutineScope { + with(psi) { + val dri = parent.withClass(name.toString()) + val superMethodsKeys = hashSetOf<Int>() + val superMethods = mutableListOf<Pair<PsiMethod, DRI>>() + val superFieldsKeys = hashSetOf<Int>() + val superFields = mutableListOf<Pair<PsiField, DRI>>() + methods.asIterable().parallelForEach { superMethodsKeys.add(it.hash) } + + /** + * Caution! This method mutates + * - superMethodsKeys + * - superMethods + * - superFieldsKeys + * - superKeys + */ + /** + * Caution! This method mutates + * - superMethodsKeys + * - superMethods + * - superFieldsKeys + * - superKeys + */ + fun Array<PsiClassType>.getSuperTypesPsiClasses(): List<Pair<PsiClass, JavaClassKindTypes>> { + forEach { type -> + (type as? PsiClassType)?.resolve()?.let { + val definedAt = DRI.from(it) + it.methods.forEach { method -> + val hash = method.hash + if (!method.isConstructor && !superMethodsKeys.contains(hash) && + method.getVisibility() != JavaVisibility.Private + ) { + superMethodsKeys.add(hash) + superMethods.add(Pair(method, definedAt)) + } + } + it.fields.forEach { field -> + val hash = field.hash + if (!superFieldsKeys.contains(hash)) { + superFieldsKeys.add(hash) + superFields.add(Pair(field, definedAt)) + } + } + } + } + return filter { !it.shouldBeIgnored }.mapNotNull { supertypePsi -> + supertypePsi.resolve()?.let { supertypePsiClass -> + val javaClassKind = when { + supertypePsiClass.isInterface -> JavaClassKindTypes.INTERFACE + else -> JavaClassKindTypes.CLASS + } + supertypePsiClass to javaClassKind + } + } + } + + fun traversePsiClassForAncestorsAndInheritedMembers(psiClass: PsiClass): AncestryNode { + val (classes, interfaces) = psiClass.superTypes.getSuperTypesPsiClasses() + .partition { it.second == JavaClassKindTypes.CLASS } + + return AncestryNode( + typeConstructor = GenericTypeConstructor( + DRI.from(psiClass), + psiClass.typeParameters.map { typeParameter -> + TypeParameter( + dri = DRI.from(typeParameter), + name = typeParameter.name.orEmpty(), + extra = typeParameter.annotations() + ) + } + ), + superclass = classes.singleOrNull()?.first?.let(::traversePsiClassForAncestorsAndInheritedMembers), + interfaces = interfaces.map { traversePsiClassForAncestorsAndInheritedMembers(it.first) } + ) + } + + val ancestry: AncestryNode = traversePsiClassForAncestorsAndInheritedMembers(this) + + val (regularFunctions, accessors) = splitFunctionsAndAccessors(psi.fields, psi.methods) + val (regularSuperFunctions, superAccessors) = splitFunctionsAndAccessors( + fields = superFields.map { it.first }.toTypedArray(), + methods = superMethods.map { it.first }.toTypedArray() + ) + + val regularSuperFunctionsKeys = regularSuperFunctions.map { it.hash }.toSet() + val regularSuperFunctionsWithDRI = superMethods.filter { it.first.hash in regularSuperFunctionsKeys } + + val superAccessorsWithDRI = superAccessors.mapValues { (field, methods) -> + val containsJvmField = field.annotations.mapNotNull { it.toAnnotation() }.any { it.isJvmField() } + if (containsJvmField) { + emptyList() + } else { + methods.mapNotNull { method -> superMethods.find { it.first.hash == method.hash } } + } + } + + val overridden = regularFunctions.flatMap { it.findSuperMethods().toList() } + val documentation = javadocParser.parseDocumentation(this).toSourceSetDependent() + val allFunctions = async { + val parsedRegularFunctions = regularFunctions.parallelMapNotNull { + if (!it.isConstructor) parseFunction( + it, + parentDRI = dri + ) else null + } + val parsedSuperFunctions = regularSuperFunctionsWithDRI + .filter { it.first !in overridden } + .parallelMap { parseFunction(it.first, inheritedFrom = it.second) } + + parsedRegularFunctions + parsedSuperFunctions + } + val allFields = async { + val parsedFields = fields.toList().parallelMapNotNull { + parseField(it, accessors[it].orEmpty()) + } + val parsedSuperFields = superFields.parallelMapNotNull { (field, dri) -> + parseFieldWithInheritingAccessors( + field, + superAccessorsWithDRI[field].orEmpty(), + inheritedFrom = dri + ) + } + parsedFields + parsedSuperFields + } + val source = parseSources() + val classlikes = async { innerClasses.asIterable().parallelMap { parseClasslike(it, dri) } } + val visibility = getVisibility().toSourceSetDependent() + val ancestors = (listOfNotNull(ancestry.superclass?.let { + it.typeConstructor.let { typeConstructor -> + TypeConstructorWithKind( + typeConstructor, + JavaClassKindTypes.CLASS + ) + } + }) + ancestry.interfaces.map { + TypeConstructorWithKind( + it.typeConstructor, + JavaClassKindTypes.INTERFACE + ) + }).toSourceSetDependent() + val modifiers = getModifier().toSourceSetDependent() + val implementedInterfacesExtra = + ImplementedInterfaces(ancestry.allImplementedInterfaces().toSourceSetDependent()) + + when { + isAnnotationType -> + DAnnotation( + name = name.orEmpty(), + dri = dri, + documentation = documentation, + expectPresentInSet = null, + sources = source, + functions = allFunctions.await(), + properties = allFields.await(), + classlikes = classlikes.await(), + visibility = visibility, + companion = null, + constructors = parseConstructors(dri), + generics = mapTypeParameters(dri), + sourceSets = setOf(sourceSetData), + isExpectActual = false, + extra = PropertyContainer.withAll( + implementedInterfacesExtra, + annotations.toList().toListOfAnnotations().toSourceSetDependent() + .toAnnotations() + ) + ) + + isEnum -> DEnum( + dri = dri, + name = name.orEmpty(), + entries = fields.filterIsInstance<PsiEnumConstant>().map { entry -> + DEnumEntry( + dri = dri.withClass(entry.name).withEnumEntryExtra(), + name = entry.name, + documentation = javadocParser.parseDocumentation(entry).toSourceSetDependent(), + expectPresentInSet = null, + functions = emptyList(), + properties = emptyList(), + classlikes = emptyList(), + sourceSets = setOf(sourceSetData), + extra = PropertyContainer.withAll( + implementedInterfacesExtra, + annotations.toList().toListOfAnnotations().toSourceSetDependent() + .toAnnotations() + ) + ) + }, + documentation = documentation, + expectPresentInSet = null, + sources = source, + functions = allFunctions.await(), + properties = fields.filter { it !is PsiEnumConstant } + .map { parseField(it, accessors[it].orEmpty()) }, + classlikes = classlikes.await(), + visibility = visibility, + companion = null, + constructors = parseConstructors(dri), + supertypes = ancestors, + sourceSets = setOf(sourceSetData), + isExpectActual = false, + extra = PropertyContainer.withAll( + implementedInterfacesExtra, + annotations.toList().toListOfAnnotations().toSourceSetDependent() + .toAnnotations() + ) + ) + + isInterface -> DInterface( + dri = dri, + name = name.orEmpty(), + documentation = documentation, + expectPresentInSet = null, + sources = source, + functions = allFunctions.await(), + properties = allFields.await(), + classlikes = classlikes.await(), + visibility = visibility, + companion = null, + generics = mapTypeParameters(dri), + supertypes = ancestors, + sourceSets = setOf(sourceSetData), + isExpectActual = false, + extra = PropertyContainer.withAll( + implementedInterfacesExtra, + annotations.toList().toListOfAnnotations().toSourceSetDependent() + .toAnnotations() + ) + ) + + else -> DClass( + dri = dri, + name = name.orEmpty(), + constructors = parseConstructors(dri), + functions = allFunctions.await(), + properties = allFields.await(), + classlikes = classlikes.await(), + sources = source, + visibility = visibility, + companion = null, + generics = mapTypeParameters(dri), + supertypes = ancestors, + documentation = documentation, + expectPresentInSet = null, + modifier = modifiers, + sourceSets = setOf(sourceSetData), + isExpectActual = false, + extra = PropertyContainer.withAll( + implementedInterfacesExtra, + annotations.toList().toListOfAnnotations().toSourceSetDependent() + .toAnnotations(), + ancestry.exceptionInSupertypesOrNull() + ) + ) + } + } + } + + /* + * Parameter `parentDRI` required for substitute package name: + * in the case of synthetic constructor, it will return empty from [DRI.Companion.from]. + */ + private fun PsiClass.parseConstructors(parentDRI: DRI): List<DFunction> { + val constructors = when { + isAnnotationType || isInterface -> emptyArray() + isEnum -> this.constructors + else -> this.constructors.takeIf { it.isNotEmpty() } ?: arrayOf(createDefaultConstructor()) + } + return constructors.map { parseFunction(psi = it, isConstructor = true, parentDRI = parentDRI) } + } + + /** + * PSI doesn't return a default constructor if class doesn't contain an explicit one. + * This method create synthetic constructor + * Visibility modifier is preserved from the class. + */ + private fun PsiClass.createDefaultConstructor(): PsiMethod { + val psiElementFactory = JavaPsiFacade.getElementFactory(project) + val signature = when (val classVisibility = getVisibility()) { + JavaVisibility.Default -> name.orEmpty() + else -> "${classVisibility.name} $name" + } + return psiElementFactory.createConstructor(signature, this) + } + + private fun AncestryNode.exceptionInSupertypesOrNull(): ExceptionInSupertypes? = + typeConstructorsBeingExceptions().takeIf { it.isNotEmpty() } + ?.let { ExceptionInSupertypes(it.toSourceSetDependent()) } + + private fun parseFunction( + psi: PsiMethod, + isConstructor: Boolean = false, + inheritedFrom: DRI? = null, + parentDRI: DRI? = null, + ): DFunction { + val dri = parentDRI?.let { dri -> + DRI.from(psi).copy(packageName = dri.packageName, classNames = dri.classNames) + } ?: DRI.from(psi) + val docs = psi.getDocumentation() + return DFunction( + dri = dri, + name = psi.name, + isConstructor = isConstructor, + parameters = psi.parameterList.parameters.map { psiParameter -> + DParameter( + dri = dri.copy(target = dri.target.nextTarget()), + name = psiParameter.name, + documentation = DocumentationNode( + listOfNotNull(docs.firstChildOfTypeOrNull<Param> { + it.name == psiParameter.name + }) + ).toSourceSetDependent(), + expectPresentInSet = null, + type = getBound(psiParameter.type), + sourceSets = setOf(sourceSetData), + extra = PropertyContainer.withAll( + psiParameter.annotations.toList().toListOfAnnotations().toSourceSetDependent() + .toAnnotations() + ) + ) + }, + documentation = docs.toSourceSetDependent(), + expectPresentInSet = null, + sources = psi.parseSources(), + visibility = psi.getVisibility().toSourceSetDependent(), + type = psi.returnType?.let { getBound(type = it) } ?: Void, + generics = psi.mapTypeParameters(dri), + receiver = null, + modifier = psi.getModifier().toSourceSetDependent(), + sourceSets = setOf(sourceSetData), + isExpectActual = false, + extra = psi.additionalExtras().let { + PropertyContainer.withAll( + inheritedFrom?.let { InheritedMember(it.toSourceSetDependent()) }, + it.toSourceSetDependent().toAdditionalModifiers(), + (psi.annotations.toList() + .toListOfAnnotations() + it.toListOfAnnotations()).toSourceSetDependent() + .toAnnotations(), + ObviousMember.takeIf { psi.isObvious(inheritedFrom) }, + psi.throwsList.toDriList().takeIf { it.isNotEmpty() } + ?.let { CheckedExceptions(it.toSourceSetDependent()) } + ) + } + ) + } + + private fun PsiNamedElement.parseSources(): SourceSetDependent<DocumentableSource> { + return when { + // `isPhysical` detects the virtual declarations without real sources. + // Otherwise, `PsiDocumentableSource` initialization will fail: non-physical declarations doesn't have `virtualFile`. + // This check protects from accidentally requesting sources for synthetic / virtual declarations. + isPhysical -> PsiDocumentableSource(this).toSourceSetDependent() + else -> emptyMap() + } + } + + private fun PsiMethod.getDocumentation(): DocumentationNode = + this.takeIf { it is SyntheticElement }?.let { syntheticDocProvider.getDocumentation(it) } + ?: javadocParser.parseDocumentation(this) + + private fun PsiMethod.isObvious(inheritedFrom: DRI? = null): Boolean { + return (this is SyntheticElement && !syntheticDocProvider.isDocumented(this)) + || inheritedFrom?.isObvious() == true + } + + private fun DRI.isObvious(): Boolean { + return packageName == "java.lang" && (classNames == "Object" || classNames == "Enum") + } + + private fun PsiReferenceList.toDriList() = referenceElements.mapNotNull { it?.resolve()?.let { DRI.from(it) } } + + private fun PsiModifierListOwner.additionalExtras() = listOfNotNull( + ExtraModifiers.JavaOnlyModifiers.Static.takeIf { hasModifier(JvmModifier.STATIC) }, + ExtraModifiers.JavaOnlyModifiers.Native.takeIf { hasModifier(JvmModifier.NATIVE) }, + ExtraModifiers.JavaOnlyModifiers.Synchronized.takeIf { hasModifier(JvmModifier.SYNCHRONIZED) }, + ExtraModifiers.JavaOnlyModifiers.StrictFP.takeIf { hasModifier(JvmModifier.STRICTFP) }, + ExtraModifiers.JavaOnlyModifiers.Transient.takeIf { hasModifier(JvmModifier.TRANSIENT) }, + ExtraModifiers.JavaOnlyModifiers.Volatile.takeIf { hasModifier(JvmModifier.VOLATILE) }, + ExtraModifiers.JavaOnlyModifiers.Transitive.takeIf { hasModifier(JvmModifier.TRANSITIVE) } + ).toSet() + + private fun Set<ExtraModifiers>.toListOfAnnotations() = map { + if (it !is ExtraModifiers.JavaOnlyModifiers.Static) + Annotations.Annotation(DRI("kotlin.jvm", it.name.toLowerCase().capitalize()), emptyMap()) + else + Annotations.Annotation(DRI("kotlin.jvm", "JvmStatic"), emptyMap()) + } + + /** + * Workaround for getting JvmField Kotlin annotation in PSIs + */ + private fun Collection<PsiAnnotation>.findJvmFieldAnnotation(): Annotations.Annotation? { + val anyJvmFieldAnnotation = this.any { + it.qualifiedName == "$JVM_FIELD_PACKAGE_NAME.$JVM_FIELD_CLASS_NAMES" + } + return if (anyJvmFieldAnnotation) { + Annotations.Annotation(DRI(JVM_FIELD_PACKAGE_NAME, JVM_FIELD_CLASS_NAMES), emptyMap()) + } else { + null + } + } + + private fun <T : AnnotationTarget> PsiTypeParameter.annotations(): PropertyContainer<T> = this.annotations.toList().toListOfAnnotations().annotations() + private fun <T : AnnotationTarget> PsiType.annotations(): PropertyContainer<T> = this.annotations.toList().toListOfAnnotations().annotations() + + private fun <T : AnnotationTarget> List<Annotations.Annotation>.annotations(): PropertyContainer<T> = + this.takeIf { it.isNotEmpty() }?.let { annotations -> + PropertyContainer.withAll(annotations.toSourceSetDependent().toAnnotations()) + } ?: PropertyContainer.empty() + + private fun getBound(type: PsiType): Bound { + //We would like to cache most of the bounds since it is not common to annotate them, + //but if this is the case, we treat them as 'one of' + fun PsiType.cacheBoundIfHasNoAnnotation(f: (List<Annotations.Annotation>) -> Bound): Bound { + val annotations = this.annotations.toList().toListOfAnnotations() + return if (annotations.isNotEmpty()) f(annotations) + else cachedBounds.getOrPut(canonicalText) { + f(annotations) + } + } + + return when (type) { + is PsiClassType -> + type.resolve()?.let { resolved -> + when { + resolved.qualifiedName == "java.lang.Object" -> type.cacheBoundIfHasNoAnnotation { annotations -> JavaObject(annotations.annotations()) } + resolved is PsiTypeParameter -> { + TypeParameter( + dri = DRI.from(resolved), + name = resolved.name.orEmpty(), + extra = type.annotations() + ) + } + + Regex("kotlin\\.jvm\\.functions\\.Function.*").matches(resolved.qualifiedName ?: "") || + Regex("java\\.util\\.function\\.Function.*").matches( + resolved.qualifiedName ?: "" + ) -> FunctionalTypeConstructor( + DRI.from(resolved), + type.parameters.map { getProjection(it) }, + extra = type.annotations() + ) + + else -> { + // cache types that have no annotation and no type parameter + // since we cache only by name and type parameters depend on context + val typeParameters = type.parameters.map { getProjection(it) } + if (typeParameters.isEmpty()) + type.cacheBoundIfHasNoAnnotation { annotations -> + GenericTypeConstructor( + DRI.from(resolved), + typeParameters, + extra = annotations.annotations() + ) + } + else + GenericTypeConstructor( + DRI.from(resolved), + typeParameters, + extra = type.annotations() + ) + } + } + } ?: UnresolvedBound(type.presentableText, type.annotations()) + + is PsiArrayType -> GenericTypeConstructor( + DRI("kotlin", "Array"), + listOf(getProjection(type.componentType)), + extra = type.annotations() + ) + + is PsiPrimitiveType -> if (type.name == "void") Void + else type.cacheBoundIfHasNoAnnotation { annotations -> PrimitiveJavaType(type.name, annotations.annotations()) } + else -> throw IllegalStateException("${type.presentableText} is not supported by PSI parser") + } + } + + + private fun getVariance(type: PsiWildcardType): Projection = when { + type.extendsBound != PsiType.NULL -> Covariance(getBound(type.extendsBound)) + type.superBound != PsiType.NULL -> Contravariance(getBound(type.superBound)) + else -> throw IllegalStateException("${type.presentableText} has incorrect bounds") + } + + private fun getProjection(type: PsiType): Projection = when (type) { + is PsiEllipsisType -> Star + is PsiWildcardType -> getVariance(type) + else -> getBound(type) + } + + private fun PsiModifierListOwner.getModifier() = when { + hasModifier(JvmModifier.ABSTRACT) -> JavaModifier.Abstract + hasModifier(JvmModifier.FINAL) -> JavaModifier.Final + else -> JavaModifier.Empty + } + + private fun PsiTypeParameterListOwner.mapTypeParameters(dri: DRI): List<DTypeParameter> { + fun mapBounds(bounds: Array<JvmReferenceType>): List<Bound> = + if (bounds.isEmpty()) emptyList() else bounds.mapNotNull { + (it as? PsiClassType)?.let { classType -> Nullable(getBound(classType)) } + } + return typeParameters.map { type -> + DTypeParameter( + dri = dri.copy(target = dri.target.nextTarget()), + name = type.name.orEmpty(), + presentableName = null, + documentation = javadocParser.parseDocumentation(type).toSourceSetDependent(), + expectPresentInSet = null, + bounds = mapBounds(type.bounds), + sourceSets = setOf(sourceSetData), + extra = PropertyContainer.withAll( + type.annotations.toList().toListOfAnnotations().toSourceSetDependent() + .toAnnotations() + ) + ) + } + } + + private fun parseFieldWithInheritingAccessors( + psi: PsiField, + accessors: List<Pair<PsiMethod, DRI>>, + inheritedFrom: DRI + ): DProperty { + val getter = accessors + .firstOrNull { (method, _) -> method.isGetterFor(psi) } + ?.let { (method, dri) -> parseFunction(method, inheritedFrom = dri) } + + val setter = accessors + .firstOrNull { (method, _) -> method.isSetterFor(psi) } + ?.let { (method, dri) -> parseFunction(method, inheritedFrom = dri) } + + return parseField( + psi = psi, + getter = getter, + setter = setter, + inheritedFrom = inheritedFrom + ) + } + + private fun parseField(psi: PsiField, accessors: List<PsiMethod>, inheritedFrom: DRI? = null): DProperty { + val getter = accessors.firstOrNull { it.isGetterFor(psi) }?.let { parseFunction(it) } + val setter = accessors.firstOrNull { it.isSetterFor(psi) }?.let { parseFunction(it) } + return parseField( + psi = psi, + getter = getter, + setter = setter, + inheritedFrom = inheritedFrom + ) + } + + private fun parseField(psi: PsiField, getter: DFunction?, setter: DFunction?, inheritedFrom: DRI? = null): DProperty { + val dri = DRI.from(psi) + + // non-final java field without accessors should be a var + // setter should be not null when inheriting kotlin vars + val isMutable = !psi.hasModifierProperty("final") + val isVar = (isMutable && getter == null && setter == null) || (getter != null && setter != null) + + return DProperty( + dri = dri, + name = psi.name, + documentation = javadocParser.parseDocumentation(psi).toSourceSetDependent(), + expectPresentInSet = null, + sources = psi.parseSources(), + visibility = psi.getVisibility(getter).toSourceSetDependent(), + type = getBound(psi.type), + receiver = null, + setter = setter, + getter = getter, + modifier = psi.getModifier().toSourceSetDependent(), + sourceSets = setOf(sourceSetData), + generics = emptyList(), + isExpectActual = false, + extra = psi.additionalExtras().let { + val psiAnnotations = psi.annotations.toList() + val parsedAnnotations = psiAnnotations.toListOfAnnotations() + val extraModifierAnnotations = it.toListOfAnnotations() + val jvmFieldAnnotation = psiAnnotations.findJvmFieldAnnotation() + val annotations = parsedAnnotations + extraModifierAnnotations + listOfNotNull(jvmFieldAnnotation) + + PropertyContainer.withAll( + inheritedFrom?.let { inheritedFrom -> InheritedMember(inheritedFrom.toSourceSetDependent()) }, + it.toSourceSetDependent().toAdditionalModifiers(), + annotations.toSourceSetDependent().toAnnotations(), + psi.getConstantExpression()?.let { DefaultValue(it.toSourceSetDependent()) }, + takeIf { isVar }?.let { IsVar } + ) + } + ) + } + + private fun PsiField.getVisibility(getter: DFunction?): Visibility { + return getter?.visibility?.get(sourceSetData) ?: this.getVisibility() + } + + private fun Collection<PsiAnnotation>.toListOfAnnotations() = + filter { !lightMethodChecker.isLightAnnotation(it) }.mapNotNull { it.toAnnotation() } + + private fun PsiField.getConstantExpression(): Expression? { + val constantValue = this.computeConstantValue() ?: return null + return when (constantValue) { + is Byte -> IntegerConstant(constantValue.toLong()) + is Short -> IntegerConstant(constantValue.toLong()) + is Int -> IntegerConstant(constantValue.toLong()) + is Long -> IntegerConstant(constantValue) + is Char -> StringConstant(constantValue.toString()) + is String -> StringConstant(constantValue) + is Double -> DoubleConstant(constantValue) + is Float -> FloatConstant(constantValue) + is Boolean -> BooleanConstant(constantValue) + else -> ComplexExpression(constantValue.toString()) + } + } + + private fun JvmAnnotationAttribute.toValue(): AnnotationParameterValue = when (this) { + is PsiNameValuePair -> value?.toValue() ?: attributeValue?.toValue() ?: StringValue("") + else -> StringValue(this.attributeName) + }.let { annotationValue -> + if (annotationValue is StringValue) annotationValue.copy(annotationValue.value.removeSurrounding("\"")) + else annotationValue + } + + /** + * This is a workaround for static imports from JDK like RetentionPolicy + * For some reason they are not represented in the same way than using normal import + */ + private fun JvmAnnotationAttributeValue.toValue(): AnnotationParameterValue? { + return when (this) { + is JvmAnnotationEnumFieldValue -> (field as? PsiElement)?.let { EnumValue(fieldName ?: "", DRI.from(it)) } + // static import of a constant is resolved to constant value instead of a field/link + is JvmAnnotationConstantValue -> this.constantValue?.toAnnotationLiteralValue() + else -> null + } + } + + private fun Any.toAnnotationLiteralValue() = when (this) { + is Byte -> IntValue(this.toInt()) + is Short -> IntValue(this.toInt()) + is Char -> StringValue(this.toString()) + is Int -> IntValue(this) + is Long -> LongValue(this) + is Boolean -> BooleanValue(this) + is Float -> FloatValue(this) + is Double -> DoubleValue(this) + else -> StringValue(this.toString()) + } + + private fun PsiAnnotationMemberValue.toValue(): AnnotationParameterValue? = when (this) { + is PsiAnnotation -> toAnnotation()?.let { AnnotationValue(it) } + is PsiArrayInitializerMemberValue -> ArrayValue(initializers.mapNotNull { it.toValue() }) + is PsiReferenceExpression -> psiReference?.let { EnumValue(text ?: "", DRI.from(it)) } + is PsiClassObjectAccessExpression -> { + val parameterType = (type as? PsiClassType)?.parameters?.firstOrNull() + val classType = when (parameterType) { + is PsiClassType -> parameterType.resolve() + // Notice: Array<String>::class will be passed down as String::class + // should probably be Array::class instead but this reflects behaviour for Kotlin sources + is PsiArrayType -> (parameterType.componentType as? PsiClassType)?.resolve() + else -> null + } + classType?.let { ClassValue(it.name ?: "", DRI.from(it)) } + } + is PsiLiteralExpression -> toValue() + else -> StringValue(text ?: "") + } + + private fun PsiLiteralExpression.toValue(): AnnotationParameterValue? = when (type) { + PsiType.INT -> (value as? Int)?.let { IntValue(it) } + PsiType.LONG -> (value as? Long)?.let { LongValue(it) } + PsiType.FLOAT -> (value as? Float)?.let { FloatValue(it) } + PsiType.DOUBLE -> (value as? Double)?.let { DoubleValue(it) } + PsiType.BOOLEAN -> (value as? Boolean)?.let { BooleanValue(it) } + PsiType.NULL -> NullValue + else -> StringValue(text ?: "") + } + + private fun PsiAnnotation.toAnnotation(): Annotations.Annotation? { + // TODO Mitigating workaround for issue https://github.com/Kotlin/dokka/issues/1341 + // Tracking https://youtrack.jetbrains.com/issue/KT-41234 + // Needs to be removed once this issue is fixed in light classes + fun PsiElement.getAnnotationsOrNull(): Array<PsiAnnotation>? { + this as PsiClass + return try { + this.annotations + } catch (e: Exception) { + logger.warn("Failed to get annotations from ${this.qualifiedName}") + null + } + } + + return psiReference?.let { psiElement -> + Annotations.Annotation( + dri = DRI.from(psiElement), + params = attributes + .filter { !lightMethodChecker.isLightAnnotationAttribute(it) } + .mapNotNull { it.attributeName to it.toValue() } + .toMap(), + mustBeDocumented = psiElement.getAnnotationsOrNull().orEmpty().any { annotation -> + annotation.hasQualifiedName("java.lang.annotation.Documented") + } + ) + } + } + + private val PsiElement.psiReference + get() = getChildOfType<PsiJavaCodeReferenceElement>()?.resolve() +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/JavaDocCommentParser.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/JavaDocCommentParser.kt new file mode 100644 index 00000000..9c475054 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/JavaDocCommentParser.kt @@ -0,0 +1,228 @@ +package org.jetbrains.dokka.analysis.java.parsers + +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiNamedElement +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.impl.source.tree.JavaDocElementType +import com.intellij.psi.impl.source.tree.LazyParseablePsiElement +import com.intellij.psi.javadoc.PsiDocComment +import com.intellij.psi.javadoc.PsiDocTag + +import org.jetbrains.dokka.analysis.java.* +import org.jetbrains.dokka.analysis.java.doccomment.DocComment +import org.jetbrains.dokka.analysis.java.doccomment.JavaDocComment +import org.jetbrains.dokka.analysis.java.parsers.doctag.PsiDocTagParser +import org.jetbrains.dokka.analysis.java.util.* +import org.jetbrains.dokka.analysis.markdown.jb.MARKDOWN_ELEMENT_FILE_NAME +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.model.doc.* +import org.jetbrains.dokka.model.doc.Deprecated + +internal class JavaPsiDocCommentParser( + private val psiDocTagParser: PsiDocTagParser, +) : DocCommentParser { + + override fun canParse(docComment: DocComment): Boolean { + return docComment is JavaDocComment + } + + override fun parse(docComment: DocComment, context: PsiNamedElement): DocumentationNode { + val javaDocComment = docComment as JavaDocComment + return parsePsiDocComment(javaDocComment.comment, context) + } + + internal fun parsePsiDocComment(docComment: PsiDocComment, context: PsiNamedElement): DocumentationNode { + val description = listOfNotNull(docComment.getDescription()) + val tags = docComment.tags.mapNotNull { tag -> + parseDocTag(tag, docComment, context) + } + return DocumentationNode(description + tags) + } + + private fun PsiDocComment.getDescription(): Description? { + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = descriptionElements.asIterable(), + commentResolutionContext = CommentResolutionContext(this, DescriptionJavadocTag), + ) + return docTags.takeIf { it.isNotEmpty() }?.let { + Description(wrapTagIfNecessary(it)) + } + } + + private fun parseDocTag(tag: PsiDocTag, docComment: PsiDocComment, analysedElement: PsiNamedElement): TagWrapper? { + return when (tag.name) { + ParamJavadocTag.name -> parseParamTag(tag, docComment, analysedElement) + ThrowsJavadocTag.name, ExceptionJavadocTag.name -> parseThrowsTag(tag, docComment) + ReturnJavadocTag.name -> parseReturnTag(tag, docComment) + SinceJavadocTag.name -> parseSinceTag(tag, docComment) + AuthorJavadocTag.name -> parseAuthorTag(tag, docComment) + SeeJavadocTag.name -> parseSeeTag(tag, docComment) + DeprecatedJavadocTag.name -> parseDeprecatedTag(tag, docComment) + else -> emptyTagWrapper(tag, docComment) + } + } + + private fun parseParamTag( + tag: PsiDocTag, + docComment: PsiDocComment, + analysedElement: PsiNamedElement + ): TagWrapper? { + val paramName = tag.dataElements.firstOrNull()?.text.orEmpty() + + // can be a PsiClass if @param is referencing class generics, like here: + // https://github.com/biojava/biojava/blob/2417c230be36e4ba73c62bb3631b60f876265623/biojava-core/src/main/java/org/biojava/nbio/core/alignment/SimpleProfilePair.java#L43 + // not supported at the moment + val method = analysedElement as? PsiMethod ?: return null + val paramIndex = method.parameterList.parameters.map { it.name }.indexOf(paramName) + + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = tag.contentElementsWithSiblingIfNeeded().drop(1), + commentResolutionContext = CommentResolutionContext( + comment = docComment, + tag = ParamJavadocTag(method, paramName, paramIndex) + ) + ) + return Param(root = wrapTagIfNecessary(docTags), name = paramName) + } + + private fun parseThrowsTag( + tag: PsiDocTag, + docComment: PsiDocComment + ): Throws { + val resolvedElement = tag.resolveToElement() + val exceptionAddress = resolvedElement?.let { DRI.from(it) } + + /* we always would like to have a fully qualified name as name, + * because it will be used as a display name later and we would like to have those unified + * even if documentation states shortened version + * Only if dri search fails we should use the provided phrase (since then we are not able to get a fq name) + */ + val fullyQualifiedExceptionName = + resolvedElement?.getKotlinFqName() ?: tag.dataElements.firstOrNull()?.text.orEmpty() + + val javadocTag = when (tag.name) { + ThrowsJavadocTag.name -> ThrowsJavadocTag(fullyQualifiedExceptionName) + ExceptionJavadocTag.name -> ExceptionJavadocTag(fullyQualifiedExceptionName) + else -> throw IllegalArgumentException("Expected @throws or @exception") + } + + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = tag.dataElements.drop(1), + commentResolutionContext = CommentResolutionContext( + comment = docComment, + tag = javadocTag + ), + ) + return Throws( + root = wrapTagIfNecessary(docTags), + name = fullyQualifiedExceptionName, + exceptionAddress = exceptionAddress + ) + } + + private fun parseReturnTag( + tag: PsiDocTag, + docComment: PsiDocComment + ): Return { + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = tag.contentElementsWithSiblingIfNeeded(), + commentResolutionContext = CommentResolutionContext(comment = docComment, tag = ReturnJavadocTag), + ) + return Return(root = wrapTagIfNecessary(docTags)) + } + + private fun parseSinceTag( + tag: PsiDocTag, + docComment: PsiDocComment + ): Since { + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = tag.contentElementsWithSiblingIfNeeded(), + commentResolutionContext = CommentResolutionContext(comment = docComment, tag = ReturnJavadocTag), + ) + return Since(root = wrapTagIfNecessary(docTags)) + } + + private fun parseAuthorTag( + tag: PsiDocTag, + docComment: PsiDocComment + ): Author { + // TODO [beresnev] see what the hell this is + // Workaround: PSI returns first word after @author tag as a `DOC_TAG_VALUE_ELEMENT`, + // then the rest as a `DOC_COMMENT_DATA`, so for `Name Surname` we get them parted + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = tag.contentElementsWithSiblingIfNeeded(), + commentResolutionContext = CommentResolutionContext(comment = docComment, tag = AuthorJavadocTag), + ) + return Author(root = wrapTagIfNecessary(docTags)) + } + + private fun parseSeeTag( + tag: PsiDocTag, + docComment: PsiDocComment + ): See { + val referenceElement = tag.referenceElement() + val fullyQualifiedSeeReference = tag.resolveToElement()?.getKotlinFqName() + ?: referenceElement?.text.orEmpty().removePrefix("#") + + val context = CommentResolutionContext(comment = docComment, tag = SeeJavadocTag(fullyQualifiedSeeReference)) + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = tag.dataElements.dropWhile { + it is PsiWhiteSpace || it.isDocReferenceHolder() || it == referenceElement + }, + commentResolutionContext = context, + ) + + return See( + root = wrapTagIfNecessary(docTags), + name = fullyQualifiedSeeReference, + address = referenceElement?.toDocumentationLink(context = context)?.dri + ) + } + + private fun PsiElement.isDocReferenceHolder(): Boolean { + return (this as? LazyParseablePsiElement)?.elementType == JavaDocElementType.DOC_REFERENCE_HOLDER + } + + private fun parseDeprecatedTag( + tag: PsiDocTag, + docComment: PsiDocComment + ): Deprecated { + val docTags = psiDocTagParser.parseAsParagraph( + tag.contentElementsWithSiblingIfNeeded(), + CommentResolutionContext(comment = docComment, tag = DeprecatedJavadocTag), + ) + return Deprecated(root = wrapTagIfNecessary(docTags)) + } + + private fun wrapTagIfNecessary(tags: List<DocTag>): CustomDocTag { + val isFile = (tags.singleOrNull() as? CustomDocTag)?.name == MARKDOWN_ELEMENT_FILE_NAME + return if (isFile) { + tags.first() as CustomDocTag + } else { + CustomDocTag(tags, name = MARKDOWN_ELEMENT_FILE_NAME) + } + } + + // Wrapper for unsupported tags https://github.com/Kotlin/dokka/issues/1618 + private fun emptyTagWrapper( + tag: PsiDocTag, + docComment: PsiDocComment, + ): CustomTagWrapper { + val docTags = psiDocTagParser.parseAsParagraph( + psiElements = tag.contentElementsWithSiblingIfNeeded(), + commentResolutionContext = CommentResolutionContext(docComment, null), + ) + return CustomTagWrapper( + root = wrapTagIfNecessary(docTags), + name = tag.name + ) + } + + private fun PsiElement.toDocumentationLink(labelElement: PsiElement? = null, context: CommentResolutionContext): DocumentationLink? { + val resolvedElement = this.resolveToGetDri() ?: return null + val label = labelElement ?: defaultLabel() + val docTags = psiDocTagParser.parse(listOfNotNull(label), context) + return DocumentationLink(dri = DRI.from(resolvedElement), children = docTags) + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/JavadocParser.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/JavadocParser.kt new file mode 100644 index 00000000..b8eba878 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/JavadocParser.kt @@ -0,0 +1,24 @@ +package org.jetbrains.dokka.analysis.java.parsers + +import com.intellij.psi.PsiNamedElement +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.analysis.java.doccomment.DocCommentFinder +import org.jetbrains.dokka.model.doc.DocumentationNode + +internal fun interface JavaDocumentationParser { + fun parseDocumentation(element: PsiNamedElement): DocumentationNode +} + +@InternalDokkaApi +class JavadocParser( + private val docCommentParsers: List<DocCommentParser>, + private val docCommentFinder: DocCommentFinder +) : JavaDocumentationParser { + + override fun parseDocumentation(element: PsiNamedElement): DocumentationNode { + val comment = docCommentFinder.findClosestToElement(element) ?: return DocumentationNode(emptyList()) + return docCommentParsers + .first { it.canParse(comment) } + .parse(comment, element) + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/DocTagParserContext.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/DocTagParserContext.kt new file mode 100644 index 00000000..050736f0 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/DocTagParserContext.kt @@ -0,0 +1,47 @@ +package org.jetbrains.dokka.analysis.java.parsers.doctag + +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.model.doc.DocumentationNode +import java.util.* + +@InternalDokkaApi +class DocTagParserContext { + /** + * exists for resolving `@link element` links, where the referenced + * PSI element is mapped as DRI + * + * only used in the context of parsing to html and then from html to doctag + */ + private val driMap = mutableMapOf<String, DRI>() + + /** + * Cache created to make storing entries from kotlin easier. + * + * It has to be mutable to allow for adding entries when @inheritDoc resolves to kotlin code, + * from which we get a DocTags not descriptors. + */ + private val inheritDocSections = mutableMapOf<String, DocumentationNode>() + + /** + * @return key of the stored DRI + */ + fun store(dri: DRI): String { + val id = dri.toString() + driMap[id] = dri + return id + } + + /** + * @return key of the stored documentation node + */ + fun store(documentationNode: DocumentationNode): String { + val id = UUID.randomUUID().toString() + inheritDocSections[id] = documentationNode + return id + } + + fun getDri(id: String): DRI? = driMap[id] + + fun getDocumentationNode(id: String): DocumentationNode? = inheritDocSections[id] +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/HtmlToDocTagConverter.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/HtmlToDocTagConverter.kt new file mode 100644 index 00000000..ea1997b8 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/HtmlToDocTagConverter.kt @@ -0,0 +1,114 @@ +package org.jetbrains.dokka.analysis.java.parsers.doctag + +import org.jetbrains.dokka.analysis.markdown.jb.parseHtmlEncodedWithNormalisedSpaces +import org.jetbrains.dokka.model.doc.* +import org.jsoup.Jsoup +import org.jsoup.nodes.Comment +import org.jsoup.nodes.Element +import org.jsoup.nodes.Node +import org.jsoup.nodes.TextNode + +internal class HtmlToDocTagConverter( + private val docTagParserContext: DocTagParserContext +) { + fun convertToDocTag(html: String): List<DocTag> { + return Jsoup.parseBodyFragment(html) + .body() + .childNodes() + .flatMap { convertHtmlNode(it) } + } + + private fun convertHtmlNode(node: Node, keepFormatting: Boolean = false): List<DocTag> = when (node) { + is TextNode -> (if (keepFormatting) { + node.wholeText.takeIf { it.isNotBlank() }?.let { listOf(Text(body = it)) } + } else { + node.wholeText.parseHtmlEncodedWithNormalisedSpaces(renderWhiteCharactersAsSpaces = true) + }).orEmpty() + is Comment -> listOf(Text(body = node.outerHtml(), params = DocTag.contentTypeParam("html"))) + is Element -> createBlock(node, keepFormatting) + else -> emptyList() + } + + private fun createBlock(element: Element, keepFormatting: Boolean = false): List<DocTag> { + val tagName = element.tagName() + val children = element.childNodes() + .flatMap { convertHtmlNode(it, keepFormatting = keepFormatting || tagName == "pre" || tagName == "code") } + + fun ifChildrenPresent(operation: () -> DocTag): List<DocTag> { + return if (children.isNotEmpty()) listOf(operation()) else emptyList() + } + return when (tagName) { + "blockquote" -> ifChildrenPresent { BlockQuote(children) } + "p" -> ifChildrenPresent { P(children) } + "b" -> ifChildrenPresent { B(children) } + "strong" -> ifChildrenPresent { Strong(children) } + "index" -> listOf(Index(children)) + "i" -> ifChildrenPresent { I(children) } + "img" -> listOf( + Img( + children, + element.attributes().associate { (if (it.key == "src") "href" else it.key) to it.value }) + ) + "em" -> listOf(Em(children)) + "code" -> ifChildrenPresent { if(keepFormatting) CodeBlock(children) else CodeInline(children) } + "pre" -> if(children.size == 1) { + when(children.first()) { + is CodeInline -> listOf(CodeBlock(children.first().children)) + is CodeBlock -> listOf(children.first()) + else -> listOf(Pre(children)) + } + } else { + listOf(Pre(children)) + } + "ul" -> ifChildrenPresent { Ul(children) } + "ol" -> ifChildrenPresent { Ol(children) } + "li" -> listOf(Li(children)) + "dl" -> ifChildrenPresent { Dl(children) } + "dt" -> listOf(Dt(children)) + "dd" -> listOf(Dd(children)) + "a" -> listOf(createLink(element, children)) + "table" -> ifChildrenPresent { Table(children) } + "tr" -> ifChildrenPresent { Tr(children) } + "td" -> listOf(Td(children)) + "thead" -> listOf(THead(children)) + "tbody" -> listOf(TBody(children)) + "tfoot" -> listOf(TFoot(children)) + "caption" -> ifChildrenPresent { Caption(children) } + "inheritdoc" -> { + // TODO [beresnev] describe how it works + val id = element.attr("id") + val section = docTagParserContext.getDocumentationNode(id) + val parsed = section?.children?.flatMap { it.root.children }.orEmpty() + if(parsed.size == 1 && parsed.first() is P){ + parsed.first().children + } else { + parsed + } + } + "h1" -> ifChildrenPresent { H1(children) } + "h2" -> ifChildrenPresent { H2(children) } + "h3" -> ifChildrenPresent { H3(children) } + "var" -> ifChildrenPresent { Var(children) } + "u" -> ifChildrenPresent { U(children) } + else -> listOf(Text(body = element.ownText())) + } + } + + private fun createLink(element: Element, children: List<DocTag>): DocTag { + return when { + element.hasAttr("docref") -> + A(children, params = mapOf("docref" to element.attr("docref"))) + element.hasAttr("href") -> + A(children, params = mapOf("href" to element.attr("href"))) + element.hasAttr("data-dri") && docTagParserContext.getDri(element.attr("data-dri")) != null -> { + val referencedDriId = element.attr("data-dri") + DocumentationLink( + dri = docTagParserContext.getDri(referencedDriId) + ?: error("docTagParserContext.getDri is null, TODO"), // TODO [beresnev] handle + children = children + ) + } + else -> Text(body = children.filterIsInstance<Text>().joinToString { it.body }) + } + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/InheritDocTagContentProvider.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/InheritDocTagContentProvider.kt new file mode 100644 index 00000000..31149898 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/InheritDocTagContentProvider.kt @@ -0,0 +1,10 @@ +package org.jetbrains.dokka.analysis.java.parsers.doctag + +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.analysis.java.doccomment.DocumentationContent + +@InternalDokkaApi +interface InheritDocTagContentProvider { + fun canConvert(content: DocumentationContent): Boolean + fun convertToHtml(content: DocumentationContent, docTagParserContext: DocTagParserContext): String +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/InheritDocTagResolver.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/InheritDocTagResolver.kt new file mode 100644 index 00000000..031a7b32 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/InheritDocTagResolver.kt @@ -0,0 +1,114 @@ +package org.jetbrains.dokka.analysis.java.parsers.doctag + +import com.intellij.psi.PsiClass +import com.intellij.psi.PsiMethod +import com.intellij.psi.javadoc.PsiDocComment +import org.jetbrains.dokka.analysis.java.* +import org.jetbrains.dokka.analysis.java.doccomment.* +import org.jetbrains.dokka.analysis.java.doccomment.JavaDocComment +import org.jetbrains.dokka.analysis.java.parsers.CommentResolutionContext + +internal class InheritDocTagResolver( + private val docCommentFactory: DocCommentFactory, + private val docCommentFinder: DocCommentFinder, + private val contentProviders: List<InheritDocTagContentProvider> +) { + internal fun convertToHtml(content: DocumentationContent, docTagParserContext: DocTagParserContext): String? { + return contentProviders + .firstOrNull { it.canConvert(content) } + ?.convertToHtml(content, docTagParserContext) + } + + internal fun resolveContent(context: CommentResolutionContext): List<DocumentationContent>? { + val javadocTag = context.tag ?: return null + + return when (javadocTag) { + is ThrowingExceptionJavadocTag -> { + javadocTag.exceptionQualifiedName?.let { _ -> + resolveThrowsTag( + javadocTag, + context.comment, + ) + } ?: return null + } + is ParamJavadocTag -> resolveParamTag(context.comment, javadocTag) + is DeprecatedJavadocTag -> resolveGenericTag(context.comment, DescriptionJavadocTag) + is SeeJavadocTag -> emptyList() + else -> resolveGenericTag(context.comment, javadocTag) + } + } + + private fun resolveGenericTag(currentElement: PsiDocComment, tag: JavadocTag): List<DocumentationContent> { + val docComment = when (val owner = currentElement.owner) { + is PsiClass -> lowestClassWithTag(owner, tag) + is PsiMethod -> lowestMethodWithTag(owner, tag) + else -> null + } + return docComment?.resolveTag(tag)?.flatMap { + it.resolveSiblings() + }.orEmpty() + } + + /** + * Main resolution point for exception like tags + * + * This should be used only with [ThrowsJavadocTag] or [ExceptionJavadocTag] as their resolution path should be the same + */ + private fun resolveThrowsTag( + tag: ThrowingExceptionJavadocTag, + currentElement: PsiDocComment, + ): List<DocumentationContent> { + val closestDocsWithThrows = + (currentElement.owner as? PsiMethod)?.let { method -> lowestMethodsWithTag(method, tag) } + .orEmpty().firstOrNull { + docCommentFinder.findClosestToElement(it)?.hasTag(tag) == true + } ?: return emptyList() + + return docCommentFactory.fromElement(closestDocsWithThrows) + ?.resolveTag(tag) + ?: emptyList() + } + + private fun resolveParamTag( + currentElement: PsiDocComment, + paramTag: ParamJavadocTag, + ): List<DocumentationContent> { + val parameterIndex = paramTag.paramIndex + + val methods = (currentElement.owner as? PsiMethod) + ?.let { method -> lowestMethodsWithTag(method, paramTag) } + .orEmpty() + + return methods.flatMap { + if (parameterIndex >= it.parameterList.parametersCount || parameterIndex < 0) { + return@flatMap emptyList() + } + + val closestTag = docCommentFinder.findClosestToElement(it) + val hasTag = closestTag?.hasTag(paramTag) ?: false + closestTag?.takeIf { hasTag }?.resolveTag(ParamJavadocTag(it, "", parameterIndex)) ?: emptyList() + } + } + + //if we are in psi class javadoc only inherits docs from classes and not from interfaces + private fun lowestClassWithTag(baseClass: PsiClass, javadocTag: JavadocTag): DocComment? = + baseClass.superClass?.let { + docCommentFinder.findClosestToElement(it)?.takeIf { tag -> tag.hasTag(javadocTag) } ?: lowestClassWithTag( + it, + javadocTag + ) + } + + private fun lowestMethodWithTag( + baseMethod: PsiMethod, + javadocTag: JavadocTag, + ): DocComment? { + val methodsWithTag = lowestMethodsWithTag(baseMethod, javadocTag).firstOrNull() + return methodsWithTag?.let { + it.docComment?.let { JavaDocComment(it) } ?: docCommentFinder.findClosestToElement(it) + } + } + + private fun lowestMethodsWithTag(baseMethod: PsiMethod, javadocTag: JavadocTag): List<PsiMethod> = + baseMethod.findSuperMethods().filter { docCommentFinder.findClosestToElement(it)?.hasTag(javadocTag) == true } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/PsiDocTagParser.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/PsiDocTagParser.kt new file mode 100644 index 00000000..803eabcb --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/PsiDocTagParser.kt @@ -0,0 +1,39 @@ +package org.jetbrains.dokka.analysis.java.parsers.doctag + +import com.intellij.psi.PsiElement +import com.intellij.psi.javadoc.PsiDocTag +import org.jetbrains.dokka.analysis.java.parsers.CommentResolutionContext +import org.jetbrains.dokka.model.doc.* + +/** + * Parses [PsiElement] of [PsiDocTag] into Dokka's [DocTag] + */ +internal class PsiDocTagParser( + private val inheritDocTagResolver: InheritDocTagResolver +) { + fun parse( + psiElements: Iterable<PsiElement>, + commentResolutionContext: CommentResolutionContext + ): List<DocTag> = parse(asParagraph = false, psiElements, commentResolutionContext) + + fun parseAsParagraph( + psiElements: Iterable<PsiElement>, + commentResolutionContext: CommentResolutionContext + ): List<DocTag> = parse(asParagraph = true, psiElements, commentResolutionContext) + + private fun parse( + asParagraph: Boolean, + psiElements: Iterable<PsiElement>, + commentResolutionContext: CommentResolutionContext + ): List<DocTag> { + val docTagParserContext = DocTagParserContext() + + val psiToHtmlConverter = PsiElementToHtmlConverter(inheritDocTagResolver) + val elementsHtml = psiToHtmlConverter.convert(psiElements, docTagParserContext, commentResolutionContext) + ?: return emptyList() + + val htmlToDocTagConverter = HtmlToDocTagConverter(docTagParserContext) + val html = if (asParagraph) "<p>$elementsHtml</p>" else elementsHtml + return htmlToDocTagConverter.convertToDocTag(html) + } +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/PsiElementToHtmlConverter.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/PsiElementToHtmlConverter.kt new file mode 100644 index 00000000..0c20a9b7 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/parsers/doctag/PsiElementToHtmlConverter.kt @@ -0,0 +1,214 @@ +package org.jetbrains.dokka.analysis.java.parsers.doctag + +import com.intellij.lexer.JavaDocTokenTypes +import com.intellij.psi.* +import com.intellij.psi.impl.source.javadoc.PsiDocParamRef +import com.intellij.psi.impl.source.tree.LeafPsiElement +import com.intellij.psi.javadoc.PsiDocTagValue +import com.intellij.psi.javadoc.PsiDocToken +import com.intellij.psi.javadoc.PsiInlineDocTag +import org.jetbrains.dokka.analysis.java.doccomment.DocumentationContent +import org.jetbrains.dokka.analysis.java.JavadocTag +import org.jetbrains.dokka.analysis.java.doccomment.PsiDocumentationContent +import org.jetbrains.dokka.analysis.java.parsers.CommentResolutionContext +import org.jetbrains.dokka.analysis.java.util.* +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.utilities.htmlEscape + +private const val UNRESOLVED_PSI_ELEMENT = "UNRESOLVED_PSI_ELEMENT" + +private data class HtmlParserState( + val currentJavadocTag: JavadocTag?, + val previousElement: PsiElement? = null, + val openPreTags: Int = 0, + val closedPreTags: Int = 0 +) + +private data class HtmlParsingResult(val newState: HtmlParserState, val parsedLine: String? = null) { + constructor(tag: JavadocTag?) : this(HtmlParserState(tag)) + + operator fun plus(other: HtmlParsingResult): HtmlParsingResult { + return HtmlParsingResult( + newState = other.newState, + parsedLine = listOfNotNull(parsedLine, other.parsedLine).joinToString(separator = "") + ) + } +} + +internal class PsiElementToHtmlConverter( + private val inheritDocTagResolver: InheritDocTagResolver +) { + private val preOpeningTagRegex = "<pre(\\s+.*)?>".toRegex() + private val preClosingTagRegex = "</pre>".toRegex() + + fun convert( + psiElements: Iterable<PsiElement>, + docTagParserContext: DocTagParserContext, + commentResolutionContext: CommentResolutionContext + ): String? { + return WithContext(docTagParserContext, commentResolutionContext) + .convert(psiElements) + } + + private inner class WithContext( + private val docTagParserContext: DocTagParserContext, + private val commentResolutionContext: CommentResolutionContext + ) { + fun convert(psiElements: Iterable<PsiElement>): String? { + val parsingResult = + psiElements.fold(HtmlParsingResult(commentResolutionContext.tag)) { resultAccumulator, psiElement -> + resultAccumulator + parseHtml(psiElement, resultAccumulator.newState) + } + return parsingResult.parsedLine?.trim() + } + + private fun parseHtml(psiElement: PsiElement, state: HtmlParserState): HtmlParsingResult = + when (psiElement) { + is PsiReference -> psiElement.children.fold(HtmlParsingResult(state)) { acc, e -> + acc + parseHtml(e, acc.newState) + } + else -> parseHtmlOfSimpleElement(psiElement, state) + } + + private fun parseHtmlOfSimpleElement(psiElement: PsiElement, state: HtmlParserState): HtmlParsingResult { + val text = psiElement.text + + val openPre = state.openPreTags + preOpeningTagRegex.findAll(text).count() + val closedPre = state.closedPreTags + preClosingTagRegex.findAll(text).count() + val isInsidePre = openPre > closedPre + + val parsed = when (psiElement) { + is PsiInlineDocTag -> psiElement.toHtml(state.currentJavadocTag) + is PsiDocParamRef -> psiElement.toDocumentationLinkString() + is PsiDocTagValue, is LeafPsiElement -> { + psiElement.stringifyElementAsText(isInsidePre, state.previousElement) + } + else -> null + } + val previousElement = if (text.trim() == "") state.previousElement else psiElement + return HtmlParsingResult( + state.copy( + previousElement = previousElement, + closedPreTags = closedPre, + openPreTags = openPre + ), parsed + ) + } + + /** + * Inline tags can be met in the middle of some text. Example of an inline tag usage: + * + * ```java + * Use the {@link #getComponentAt(int, int) getComponentAt} method. + * ``` + */ + private fun PsiInlineDocTag.toHtml(javadocTag: JavadocTag?): String? = + when (this.name) { + "link", "linkplain" -> this.referenceElement() + ?.toDocumentationLinkString(this.dataElements.filterIsInstance<PsiDocToken>().joinToString(" ") { + it.stringifyElementAsText(keepFormatting = false).orEmpty() + }) + + "code" -> "<code data-inline>${dataElementsAsText(this)}</code>" + "literal" -> "<literal>${dataElementsAsText(this)}</literal>" + "index" -> "<index>${this.children.filterIsInstance<PsiDocTagValue>().joinToString { it.text }}</index>" + "inheritDoc" -> { + val inheritDocContent = inheritDocTagResolver.resolveContent(commentResolutionContext) + val html = inheritDocContent?.fold(HtmlParsingResult(javadocTag)) { result, content -> + result + content.toInheritDocHtml(result.newState, docTagParserContext) + }?.parsedLine.orEmpty() + html + } + + else -> this.text + } + + private fun DocumentationContent.toInheritDocHtml( + parserState: HtmlParserState, + docTagParserContext: DocTagParserContext + ): HtmlParsingResult { + // TODO [beresnev] comment + return if (this is PsiDocumentationContent) { + parseHtml(this.psiElement, parserState) + } else { + HtmlParsingResult(parserState, inheritDocTagResolver.convertToHtml(this, docTagParserContext)) + } + } + + private fun dataElementsAsText(tag: PsiInlineDocTag): String { + return tag.dataElements.joinToString("") { + it.stringifyElementAsText(keepFormatting = true).orEmpty() + }.htmlEscape() + } + + private fun PsiElement.toDocumentationLinkString(label: String = ""): String { + val driId = reference?.resolve()?.takeIf { it !is PsiParameter }?.let { + val dri = DRI.from(it) + val id = docTagParserContext.store(dri) + id + } ?: UNRESOLVED_PSI_ELEMENT // TODO [beresnev] log this somewhere maybe? + + // TODO [beresnev] data-dri into a constant + return """<a data-dri="${driId.htmlEscape()}">${label.ifBlank { defaultLabel().text }}</a>""" + } + } +} + +private fun PsiElement.stringifyElementAsText(keepFormatting: Boolean, previousElement: PsiElement? = null) = + if (keepFormatting) { + /* + For values in the <pre> tag we try to keep formatting, so only the leading space is trimmed, + since it is there because it separates this line from the leading asterisk + */ + text.let { + if (((prevSibling as? PsiDocToken)?.isLeadingAsterisk() == true || (prevSibling as? PsiDocToken)?.isTagName() == true) && it.firstOrNull() == ' ') + it.drop(1) else it + }.let { + if ((nextSibling as? PsiDocToken)?.isLeadingAsterisk() == true) it.dropLastWhile { it == ' ' } else it + } + } else { + /* + Outside of the <pre> we would like to trim everything from the start and end of a line since + javadoc doesn't care about it. + */ + text.let { + if ((prevSibling as? PsiDocToken)?.isLeadingAsterisk() == true && text.isNotBlank() && previousElement !is PsiInlineDocTag) it?.trimStart() else it + }?.let { + if ((nextSibling as? PsiDocToken)?.isLeadingAsterisk() == true && text.isNotBlank()) it.trimEnd() else it + }?.let { + if (shouldHaveSpaceAtTheEnd()) "$it " else it + } + } + +private fun PsiDocToken.isLeadingAsterisk() = tokenType == JavaDocTokenType.DOC_COMMENT_LEADING_ASTERISKS + +private fun PsiDocToken.isTagName() = tokenType == JavaDocTokenType.DOC_TAG_NAME + +/** + * We would like to know if we need to have a space after a this tag + * + * The space is required when: + * - tag spans multiple lines, between every line we would need a space + * + * We wouldn't like to render a space if: + * - tag is followed by an end of comment + * - after a tag there is another tag (eg. multiple @author tags) + * - they end with an html tag like: <a href="...">Something</a> since then the space will be displayed in the following text + * - next line starts with a <p> or <pre> token + */ +private fun PsiElement.shouldHaveSpaceAtTheEnd(): Boolean { + val siblings = siblings(withItself = false).toList().filterNot { it.text.trim() == "" } + val nextNotEmptySibling = (siblings.firstOrNull() as? PsiDocToken) + val furtherNotEmptySibling = + (siblings.drop(1).firstOrNull { it is PsiDocToken && !it.isLeadingAsterisk() } as? PsiDocToken) + val lastHtmlTag = text.trim().substringAfterLast("<") + val endsWithAnUnclosedTag = lastHtmlTag.endsWith(">") && !lastHtmlTag.startsWith("</") + + return (nextSibling as? PsiWhiteSpace)?.text?.startsWith("\n ") == true && + (getNextSiblingIgnoringWhitespace() as? PsiDocToken)?.tokenType != JavaDocTokenTypes.INSTANCE.commentEnd() && + nextNotEmptySibling?.isLeadingAsterisk() == true && + furtherNotEmptySibling?.tokenType == JavaDocTokenTypes.INSTANCE.commentData() && + !endsWithAnUnclosedTag +} + + diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/CoreCopyPaste.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/CoreCopyPaste.kt new file mode 100644 index 00000000..d8702336 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/CoreCopyPaste.kt @@ -0,0 +1,20 @@ +package org.jetbrains.dokka.analysis.java.util + +import org.jetbrains.dokka.links.DRI +import org.jetbrains.dokka.model.AncestryNode +import org.jetbrains.dokka.model.TypeConstructor + +// TODO [beresnev] copy-pasted +internal fun AncestryNode.typeConstructorsBeingExceptions(): List<TypeConstructor> { + fun traverseSupertypes(ancestry: AncestryNode): List<TypeConstructor> = + listOf(ancestry.typeConstructor) + (ancestry.superclass?.let(::traverseSupertypes) ?: emptyList()) + + return superclass?.let(::traverseSupertypes)?.filter { type -> type.dri.isDirectlyAnException() } ?: emptyList() +} + +// TODO [beresnev] copy-pasted +internal fun DRI.isDirectlyAnException(): Boolean = + toString().let { stringed -> + stringed == "kotlin/Exception///PointingToDeclaration/" || + stringed == "java.lang/Exception///PointingToDeclaration/" + } diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/NoopIntellijLogger.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/NoopIntellijLogger.kt new file mode 100644 index 00000000..82482e35 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/NoopIntellijLogger.kt @@ -0,0 +1,43 @@ +package org.jetbrains.dokka.analysis.java.util + +import com.intellij.openapi.diagnostic.Attachment +import com.intellij.openapi.diagnostic.DefaultLogger +import com.intellij.openapi.diagnostic.Logger + +internal class NoopIntellijLoggerFactory : Logger.Factory { + override fun getLoggerInstance(p0: String): Logger = NoopIntellijLogger +} + +/** + * Ignores all messages passed to it + */ +internal object NoopIntellijLogger : DefaultLogger(null) { + override fun isDebugEnabled(): Boolean = false + override fun isTraceEnabled(): Boolean = false + + override fun debug(message: String?) {} + override fun debug(t: Throwable?) {} + override fun debug(message: String?, t: Throwable?) {} + override fun debug(message: String, vararg details: Any?) {} + override fun debugValues(header: String, values: MutableCollection<*>) {} + + override fun trace(message: String?) {} + override fun trace(t: Throwable?) {} + + override fun info(message: String?) {} + override fun info(message: String?, t: Throwable?) {} + override fun info(t: Throwable) {} + + override fun warn(message: String?, t: Throwable?) {} + override fun warn(message: String?) {} + override fun warn(t: Throwable) {} + + override fun error(message: String?, t: Throwable?, vararg details: String?) {} + override fun error(message: String?) {} + override fun error(message: Any?) {} + override fun error(message: String?, vararg attachments: Attachment?) {} + override fun error(message: String?, t: Throwable?, vararg attachments: Attachment?) {} + override fun error(message: String?, vararg details: String?) {} + override fun error(message: String?, t: Throwable?) {} + override fun error(t: Throwable) {} +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PropertiesConventionUtil.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PropertiesConventionUtil.kt new file mode 100644 index 00000000..137f0792 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PropertiesConventionUtil.kt @@ -0,0 +1,101 @@ +package org.jetbrains.dokka.analysis.java.util + +// TODO [beresnev] copy-paste + +internal fun propertyNamesBySetMethodName(methodName: String): List<String> = + listOfNotNull(propertyNameBySetMethodName(methodName, false), propertyNameBySetMethodName(methodName, true)) + +internal fun propertyNameByGetMethodName(methodName: String): String? = + propertyNameFromAccessorMethodName(methodName, "get") ?: propertyNameFromAccessorMethodName(methodName, "is", removePrefix = false) + +private fun propertyNameBySetMethodName(methodName: String, withIsPrefix: Boolean): String? = + propertyNameFromAccessorMethodName(methodName, "set", addPrefix = if (withIsPrefix) "is" else null) + +private fun propertyNameFromAccessorMethodName( + methodName: String, + prefix: String, + removePrefix: Boolean = true, + addPrefix: String? = null +): String? { + val isSpecial = methodName.startsWith("<") // see special in org.jetbrains.kotlin.Name + if (isSpecial) return null + if (!methodName.startsWith(prefix)) return null + if (methodName.length == prefix.length) return null + if (methodName[prefix.length] in 'a'..'z') return null + + if (addPrefix != null) { + assert(removePrefix) + return addPrefix + methodName.removePrefix(prefix) + } + + if (!removePrefix) return methodName + val name = methodName.removePrefix(prefix).decapitalizeSmartForCompiler(asciiOnly = true) + if (!isValidIdentifier(name)) return null + return name +} + +/** + * "FooBar" -> "fooBar" + * "FOOBar" -> "fooBar" + * "FOO" -> "foo" + * "FOO_BAR" -> "foO_BAR" + */ +private fun String.decapitalizeSmartForCompiler(asciiOnly: Boolean = false): String { + if (isEmpty() || !isUpperCaseCharAt(0, asciiOnly)) return this + + if (length == 1 || !isUpperCaseCharAt(1, asciiOnly)) { + return if (asciiOnly) decapitalizeAsciiOnly() else replaceFirstChar(Char::lowercaseChar) + } + + val secondWordStart = (indices.firstOrNull { !isUpperCaseCharAt(it, asciiOnly) } ?: return toLowerCase(this, asciiOnly)) - 1 + + return toLowerCase(substring(0, secondWordStart), asciiOnly) + substring(secondWordStart) +} + +private fun String.isUpperCaseCharAt(index: Int, asciiOnly: Boolean): Boolean { + val c = this[index] + return if (asciiOnly) c in 'A'..'Z' else c.isUpperCase() +} + +private fun toLowerCase(string: String, asciiOnly: Boolean): String { + return if (asciiOnly) string.toLowerCaseAsciiOnly() else string.lowercase() +} + +private fun toUpperCase(string: String, asciiOnly: Boolean): String { + return if (asciiOnly) string.toUpperCaseAsciiOnly() else string.uppercase() +} + +private fun String.decapitalizeAsciiOnly(): String { + if (isEmpty()) return this + val c = this[0] + return if (c in 'A'..'Z') + c.lowercaseChar() + substring(1) + else + this +} + +private fun String.toLowerCaseAsciiOnly(): String { + val builder = StringBuilder(length) + for (c in this) { + builder.append(if (c in 'A'..'Z') c.lowercaseChar() else c) + } + return builder.toString() +} + +private fun String.toUpperCaseAsciiOnly(): String { + val builder = StringBuilder(length) + for (c in this) { + builder.append(if (c in 'a'..'z') c.uppercaseChar() else c) + } + return builder.toString() +} + +private fun isValidIdentifier(name: String): Boolean { + if (name.isEmpty() || name.startsWith("<")) return false + for (element in name) { + if (element == '.' || element == '/' || element == '\\') { + return false + } + } + return true +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiAccessorConventionUtil.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiAccessorConventionUtil.kt new file mode 100644 index 00000000..1424244d --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiAccessorConventionUtil.kt @@ -0,0 +1,98 @@ +package org.jetbrains.dokka.analysis.java.util + +import com.intellij.psi.PsiField +import com.intellij.psi.PsiMethod +import org.jetbrains.dokka.analysis.java.getVisibility +import org.jetbrains.dokka.model.JavaVisibility +import org.jetbrains.dokka.model.KotlinVisibility +import org.jetbrains.dokka.model.Visibility + + +internal data class PsiFunctionsHolder( + val regularFunctions: List<PsiMethod>, + val accessors: Map<PsiField, List<PsiMethod>> +) + +internal fun splitFunctionsAndAccessors(fields: Array<PsiField>, methods: Array<PsiMethod>): PsiFunctionsHolder { + val fieldsByName = fields.associateBy { it.name } + val regularFunctions = mutableListOf<PsiMethod>() + val accessors = mutableMapOf<PsiField, MutableList<PsiMethod>>() + methods.forEach { method -> + val possiblePropertyNamesForFunction = method.getPossiblePropertyNamesForFunction() + val field = possiblePropertyNamesForFunction.firstNotNullOfOrNull { fieldsByName[it] } + if (field != null && method.isAccessorFor(field)) { + accessors.getOrPut(field, ::mutableListOf).add(method) + } else { + regularFunctions.add(method) + } + } + + val accessorLookalikes = removeNonAccessorsReturning(accessors) + regularFunctions.addAll(accessorLookalikes) + + return PsiFunctionsHolder(regularFunctions, accessors) +} + +private fun PsiMethod.getPossiblePropertyNamesForFunction(): List<String> { + val jvmName = getAnnotation("kotlin.jvm.JvmName")?.findAttributeValue("name")?.text + if (jvmName != null) return listOf(jvmName) + + return when { + isGetterName(name) -> listOfNotNull( + propertyNameByGetMethodName(name) + ) + isSetterName(name) -> { + propertyNamesBySetMethodName(name) + } + else -> listOf() + } +} + +private fun isGetterName(name: String): Boolean { + return name.startsWith("get") || name.startsWith("is") +} + +private fun isSetterName(name: String): Boolean { + return name.startsWith("set") +} + +/** + * If a field has no getter, it's not accessible as a property from Kotlin's perspective, + * but it still might have a setter. In this case, this "setter" should be just a regular function + */ +private fun removeNonAccessorsReturning( + fieldAccessors: MutableMap<PsiField, MutableList<PsiMethod>> +): List<PsiMethod> { + val nonAccessors = mutableListOf<PsiMethod>() + fieldAccessors.entries.removeIf { (field, methods) -> + if (methods.size == 1 && methods[0].isSetterFor(field)) { + nonAccessors.add(methods[0]) + true + } else { + false + } + } + return nonAccessors +} + +internal fun PsiMethod.isAccessorFor(field: PsiField): Boolean { + return (this.isGetterFor(field) || this.isSetterFor(field)) + && !field.getVisibility().isPublicAPI() + && this.getVisibility().isPublicAPI() +} + +internal fun PsiMethod.isGetterFor(field: PsiField): Boolean { + return this.returnType == field.type && !this.hasParameters() +} + +internal fun PsiMethod.isSetterFor(field: PsiField): Boolean { + return parameterList.getParameter(0)?.type == field.type && parameterList.getParametersCount() == 1 +} + +private fun Visibility.isPublicAPI() = when(this) { + KotlinVisibility.Public, + KotlinVisibility.Protected, + JavaVisibility.Public, + JavaVisibility.Protected -> true + else -> false +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiCommentsUtils.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiCommentsUtils.kt new file mode 100644 index 00000000..10bb79c7 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiCommentsUtils.kt @@ -0,0 +1,49 @@ +package org.jetbrains.dokka.analysis.java.util + +import com.intellij.psi.JavaDocTokenType +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiJavaCodeReferenceElement +import com.intellij.psi.PsiWhiteSpace +import com.intellij.psi.impl.source.tree.JavaDocElementType +import com.intellij.psi.javadoc.PsiDocComment +import com.intellij.psi.javadoc.PsiDocTag +import com.intellij.psi.javadoc.PsiDocToken +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.dokka.analysis.java.DescriptionJavadocTag +import org.jetbrains.dokka.analysis.java.JavadocTag + +internal fun PsiDocComment.hasTag(tag: JavadocTag): Boolean = + when (tag) { + DescriptionJavadocTag -> descriptionElements.isNotEmpty() + else -> findTagByName(tag.name) != null + } + +internal fun PsiDocTag.contentElementsWithSiblingIfNeeded(): List<PsiElement> = if (dataElements.isNotEmpty()) { + listOfNotNull( + dataElements[0], + dataElements[0].nextSibling?.takeIf { it.text != dataElements.drop(1).firstOrNull()?.text }, + *dataElements.drop(1).toTypedArray() + ) +} else { + emptyList() +} + +internal fun PsiDocTag.resolveToElement(): PsiElement? = + dataElements.firstOrNull()?.firstChild?.referenceElementOrSelf()?.resolveToGetDri() + +internal fun PsiDocTag.referenceElement(): PsiElement? = + linkElement()?.referenceElementOrSelf() + +internal fun PsiElement.referenceElementOrSelf(): PsiElement? = + if (node.elementType == JavaDocElementType.DOC_REFERENCE_HOLDER) { + PsiTreeUtil.findChildOfType(this, PsiJavaCodeReferenceElement::class.java) + } else this + +internal fun PsiDocTag.linkElement(): PsiElement? = + valueElement ?: dataElements.firstOrNull { it !is PsiWhiteSpace } + +internal fun PsiElement.defaultLabel() = children.firstOrNull { + it is PsiDocToken && it.text.isNotBlank() && !it.isSharpToken() +} ?: this + +internal fun PsiDocToken.isSharpToken() = tokenType == JavaDocTokenType.DOC_TAG_VALUE_SHARP_TOKEN diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiUtil.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiUtil.kt new file mode 100644 index 00000000..ed58eb56 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/PsiUtil.kt @@ -0,0 +1,119 @@ +package org.jetbrains.dokka.analysis.java.util + +import com.intellij.psi.* +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.dokka.InternalDokkaApi +import org.jetbrains.dokka.links.* +import org.jetbrains.dokka.model.DocumentableSource +import org.jetbrains.dokka.utilities.firstIsInstanceOrNull + +// TODO [beresnev] copy-paste + +internal val PsiElement.parentsWithSelf: Sequence<PsiElement> + get() = generateSequence(this) { if (it is PsiFile) null else it.parent } + +internal fun DRI.Companion.from(psi: PsiElement) = psi.parentsWithSelf.run { + val psiMethod = firstIsInstanceOrNull<PsiMethod>() + val psiField = firstIsInstanceOrNull<PsiField>() + val classes = filterIsInstance<PsiClass>().filterNot { it is PsiTypeParameter } + .toList() // We only want exact PsiClass types, not PsiTypeParameter subtype + val additionalClasses = if (psi is PsiEnumConstant) listOfNotNull(psiField?.name) else emptyList() + DRI( + packageName = classes.lastOrNull()?.qualifiedName?.substringBeforeLast('.', "") ?: "", + classNames = (additionalClasses + classes.mapNotNull { it.name }).takeIf { it.isNotEmpty() } + ?.asReversed()?.joinToString("."), + // The fallback strategy test whether psi is not `PsiEnumConstant`. The reason behind this is that + // we need unified DRI for both Java and Kotlin enums, so we can link them properly and treat them alike. + // To achieve that, we append enum name to classNames list and leave the callable part set to null. For Kotlin enums + // it is by default, while for Java enums we have to explicitly test for that in this `takeUnless` condition. + callable = psiMethod?.let { Callable.from(it) } ?: psiField?.takeUnless { psi is PsiEnumConstant }?.let { Callable.from(it) }, + target = DriTarget.from(psi), + extra = if (psi is PsiEnumConstant) + DRIExtraContainer().also { it[EnumEntryDRIExtra] = EnumEntryDRIExtra }.encode() + else null + ) +} + +internal fun Callable.Companion.from(psi: PsiMethod) = with(psi) { + Callable( + name, + null, + parameterList.parameters.map { param -> JavaClassReference(param.type.canonicalText) }) +} + +internal fun Callable.Companion.from(psi: PsiField): Callable { + return Callable( + name = psi.name, + receiver = null, + params = emptyList() + ) +} + +internal fun DriTarget.Companion.from(psi: PsiElement): DriTarget = psi.parentsWithSelf.run { + return when (psi) { + is PsiTypeParameter -> PointingToGenericParameters(psi.index) + else -> firstIsInstanceOrNull<PsiParameter>()?.let { + val callable = firstIsInstanceOrNull<PsiMethod>() + val params = (callable?.parameterList?.parameters).orEmpty() + PointingToCallableParameters(params.indexOf(it)) + } ?: PointingToDeclaration + } +} + +// TODO [beresnev] copy-paste +internal fun PsiElement.siblings(forward: Boolean = true, withItself: Boolean = true): Sequence<PsiElement> { + return object : Sequence<PsiElement> { + override fun iterator(): Iterator<PsiElement> { + var next: PsiElement? = this@siblings + return object : Iterator<PsiElement> { + init { + if (!withItself) next() + } + + override fun hasNext(): Boolean = next != null + override fun next(): PsiElement { + val result = next ?: throw NoSuchElementException() + next = if (forward) result.nextSibling else result.prevSibling + return result + } + } + } + } +} + +// TODO [beresnev] copy-paste +internal fun PsiElement.getNextSiblingIgnoringWhitespace(withItself: Boolean = false): PsiElement? { + return siblings(withItself = withItself).filter { it !is PsiWhiteSpace }.firstOrNull() +} + +@InternalDokkaApi +class PsiDocumentableSource(val psi: PsiNamedElement) : DocumentableSource { + override val path = psi.containingFile.virtualFile.path + + override fun computeLineNumber(): Int? { + val range = psi.getChildOfType<PsiIdentifier>()?.textRange ?: psi.textRange + val doc = PsiDocumentManager.getInstance(psi.project).getDocument(psi.containingFile) + // IJ uses 0-based line-numbers; external source browsers use 1-based + return doc?.getLineNumber(range.startOffset)?.plus(1) + } +} + +inline fun <reified T : PsiElement> PsiElement.getChildOfType(): T? { + return PsiTreeUtil.getChildOfType(this, T::class.java) +} + +internal fun PsiElement.getKotlinFqName(): String? = this.kotlinFqNameProp + +//// from import org.jetbrains.kotlin.idea.base.psi.kotlinFqName +internal val PsiElement.kotlinFqNameProp: String? + get() = when (val element = this) { + is PsiPackage -> element.qualifiedName + is PsiClass -> element.qualifiedName + is PsiMember -> element.name?.let { name -> + val prefix = element.containingClass?.qualifiedName + if (prefix != null) "$prefix.$name" else name + } +// is KtNamedDeclaration -> element.fqName TODO [beresnev] decide what to do with it + is PsiQualifiedNamedElement -> element.qualifiedName + else -> null + } diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/StdlibUtil.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/StdlibUtil.kt new file mode 100644 index 00000000..cce76ce6 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/StdlibUtil.kt @@ -0,0 +1,33 @@ +package org.jetbrains.dokka.analysis.java.util + +import java.util.* + +// TODO [beresnev] copy-paste + +// TODO [beresnev] remove this copy-paste and use the same method from stdlib instead after updating to 1.5 +internal fun <T, R : Any> Iterable<T>.firstNotNullOfOrNull(transform: (T) -> R?): R? { + for (element in this) { + val result = transform(element) + if (result != null) { + return result + } + } + return null +} + +// TODO [beresnev] remove this copy-paste and use the same method from stdlib instead after updating to 1.5 +internal fun Char.uppercaseChar(): Char = Character.toUpperCase(this) + +// TODO [beresnev] remove this copy-paste and use the same method from stdlib instead after updating to 1.5 +internal fun Char.lowercaseChar(): Char = Character.toLowerCase(this) + +// TODO [beresnev] remove this copy-paste and use the same method from stdlib instead after updating to 1.5 +internal fun String.lowercase(): String = this.toLowerCase(Locale.ROOT) + +// TODO [beresnev] remove this copy-paste and use the same method from stdlib instead after updating to 1.5 +internal fun String.uppercase(): String = this.toUpperCase(Locale.ROOT) + +// TODO [beresnev] remove this copy-paste and use the same method from stdlib instead after updating to 1.5 +internal fun String.replaceFirstChar(transform: (Char) -> Char): String { + return if (isNotEmpty()) transform(this[0]) + substring(1) else this +} diff --git a/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/resolveToGetDri.kt b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/resolveToGetDri.kt new file mode 100644 index 00000000..2972c009 --- /dev/null +++ b/subprojects/analysis-java-psi/src/main/kotlin/org/jetbrains/dokka/analysis/java/util/resolveToGetDri.kt @@ -0,0 +1,7 @@ +package org.jetbrains.dokka.analysis.java.util + +import com.intellij.psi.PsiElement + +// TODO [beresnev] get rid of +internal fun PsiElement.resolveToGetDri(): PsiElement? = + reference?.resolve() |