1 /*
2  * Copyright (C) 2017 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.tools.metalava.model.psi
18 
19 import com.android.tools.metalava.model.DefaultItem
20 import com.android.tools.metalava.model.DefaultModifierList
21 import com.android.tools.metalava.model.ParameterItem
22 import com.android.tools.metalava.model.source.utils.LazyDelegate
23 import com.android.tools.metalava.reporter.FileLocation
24 import com.intellij.psi.PsiCompiledElement
25 import com.intellij.psi.PsiDocCommentOwner
26 import com.intellij.psi.PsiElement
27 import com.intellij.psi.PsiModifierListOwner
28 import org.jetbrains.kotlin.idea.KotlinLanguage
29 import org.jetbrains.kotlin.kdoc.psi.api.KDoc
30 import org.jetbrains.kotlin.psi.KtDeclaration
31 import org.jetbrains.uast.UElement
32 import org.jetbrains.uast.sourcePsiElement
33 
34 abstract class PsiItem
35 internal constructor(
36     override val codebase: PsiBasedCodebase,
37     element: PsiElement,
38     fileLocation: FileLocation = PsiFileLocation(element),
39     modifiers: DefaultModifierList,
40     override var documentation: String,
41 ) :
42     DefaultItem(
43         fileLocation = fileLocation,
44         modifiers = modifiers,
45     ) {
46 
47     @Suppress(
48         "LeakingThis"
49     ) // Documentation can change, but we don't want to pick up subsequent @docOnly mutations
50     override var docOnly = documentation.contains("@doconly")
51     @Suppress("LeakingThis") override var removed = documentation.contains("@removed")
52 
53     /** The source PSI provided by UAST */
54     internal val sourcePsi: PsiElement? = (element as? UElement)?.sourcePsi
55 
<lambda>null56     override var originallyHidden: Boolean by LazyDelegate {
57         documentation.contains('@') &&
58             (documentation.contains("@hide") ||
59                 documentation.contains("@pending") ||
60                 // KDoc:
61                 documentation.contains("@suppress")) || hasHideAnnotation()
62     }
63 
<lambda>null64     override var hidden: Boolean by LazyDelegate { originallyHidden && !hasShowAnnotation() }
65 
66     /** Returns the PSI element for this item */
psinull67     abstract fun psi(): PsiElement
68 
69     override fun isFromClassPath(): Boolean {
70         return codebase.fromClasspath || containingClass()?.isFromClassPath() ?: false
71     }
72 
findTagDocumentationnull73     override fun findTagDocumentation(tag: String, value: String?): String? {
74         if (psi() is PsiCompiledElement) {
75             return null
76         }
77         if (documentation.isBlank()) {
78             return null
79         }
80 
81         // We can't just use element.docComment here because we may have modified
82         // the comment and then the comment snapshot in PSI isn't up to date with our
83         // latest changes
84         val docComment = codebase.getComment(documentation)
85         val tagComment =
86             if (value == null) {
87                 docComment.findTagByName(tag)
88             } else {
89                 docComment.findTagsByName(tag).firstOrNull { it.valueElement?.text == value }
90             }
91 
92         if (tagComment == null) {
93             return null
94         }
95 
96         val text = tagComment.text
97         // Trim trailing next line (javadoc *)
98         var index = text.length - 1
99         while (index > 0) {
100             val c = text[index]
101             if (!(c == '*' || c.isWhitespace())) {
102                 break
103             }
104             index--
105         }
106         index++
107         if (index < text.length) {
108             return text.substring(0, index)
109         } else {
110             return text
111         }
112     }
113 
appendDocumentationnull114     override fun appendDocumentation(comment: String, tagSection: String?, append: Boolean) {
115         if (comment.isBlank()) {
116             return
117         }
118 
119         // TODO: Figure out if an annotation should go on the return value, or on the method.
120         // For example; threading: on the method, range: on the return value.
121         // TODO: Find a good way to add or append to a given tag (@param <something>, @return, etc)
122 
123         if (this is ParameterItem) {
124             // For parameters, the documentation goes into the surrounding method's documentation!
125             // Find the right parameter location!
126             val parameterName = name()
127             val target = containingMethod()
128             target.appendDocumentation(comment, parameterName)
129             return
130         }
131 
132         // Micro-optimization: we're very often going to be merging @apiSince and to a lesser
133         // extend @deprecatedSince into existing comments, since we're flagging every single
134         // public API. Normally merging into documentation has to be done carefully, since
135         // there could be existing versions of the tag we have to append to, and some parts
136         // of the comment needs to be present in certain places. For example, you can't
137         // just append to the description of a method by inserting something right before "*/"
138         // since you could be appending to a javadoc tag like @return.
139         //
140         // However, for @apiSince and @deprecatedSince specifically, in addition to being frequent,
141         // they will (a) never appear in existing docs, and (b) they're separate tags, which means
142         // it's safe to append them at the end. So we'll special case these two tags here, to
143         // help speed up the builds since these tags are inserted 30,000+ times for each framework
144         // API target (there are many), and each time would have involved constructing a full
145         // javadoc
146         // AST with lexical tokens using IntelliJ's javadoc parsing APIs. Instead, we'll just
147         // do some simple string heuristics.
148         if (
149             tagSection == "@apiSince" ||
150                 tagSection == "@deprecatedSince" ||
151                 tagSection == "@sdkExtSince"
152         ) {
153             documentation = addUniqueTag(documentation, tagSection, comment)
154             return
155         }
156 
157         documentation = mergeDocumentation(documentation, psi(), comment.trim(), tagSection, append)
158     }
159 
addUniqueTagnull160     private fun addUniqueTag(
161         documentation: String,
162         tagSection: String,
163         commentLine: String
164     ): String {
165         assert(commentLine.indexOf('\n') == -1) // Not meant for multi-line comments
166 
167         if (documentation.isBlank()) {
168             return "/** $tagSection $commentLine */"
169         }
170 
171         // Already single line?
172         if (documentation.indexOf('\n') == -1) {
173             val end = documentation.lastIndexOf("*/")
174             return "/**\n *" +
175                 documentation.substring(3, end) +
176                 "\n * $tagSection $commentLine\n */"
177         }
178 
179         var end = documentation.lastIndexOf("*/")
180         while (end > 0 && documentation[end - 1].isWhitespace() && documentation[end - 1] != '\n') {
181             end--
182         }
183         // The comment ends with:
184         // * some comment here */
185         val insertNewLine: Boolean = documentation[end - 1] != '\n'
186 
187         val indent: String
188         var linePrefix = ""
189         val secondLine = documentation.indexOf('\n')
190         if (secondLine == -1) {
191             // Single line comment
192             indent = "\n * "
193         } else {
194             val indentStart = secondLine + 1
195             var indentEnd = indentStart
196             while (indentEnd < documentation.length) {
197                 if (!documentation[indentEnd].isWhitespace()) {
198                     break
199                 }
200                 indentEnd++
201             }
202             indent = documentation.substring(indentStart, indentEnd)
203             // TODO: If it starts with "* " follow that
204             if (documentation.startsWith("* ", indentEnd)) {
205                 linePrefix = "* "
206             }
207         }
208         return documentation.substring(0, end) +
209             (if (insertNewLine) "\n" else "") +
210             indent +
211             linePrefix +
212             tagSection +
213             " " +
214             commentLine +
215             "\n" +
216             indent +
217             " */"
218     }
219 
fullyQualifiedDocumentationnull220     override fun fullyQualifiedDocumentation(): String {
221         return fullyQualifiedDocumentation(documentation)
222     }
223 
fullyQualifiedDocumentationnull224     override fun fullyQualifiedDocumentation(documentation: String): String {
225         return codebase.docQualifier.toFullyQualifiedDocumentation(this, documentation)
226     }
227 
isJavanull228     override fun isJava(): Boolean {
229         return !isKotlin()
230     }
231 
isKotlinnull232     override fun isKotlin(): Boolean {
233         return psi().isKotlin()
234     }
235 
236     companion object {
237 
238         // Gets the javadoc of the current element, unless reading comments is
239         // disabled via allowReadingComments
javadocnull240         internal fun javadoc(element: PsiElement, allowReadingComments: Boolean): String {
241             if (!allowReadingComments) {
242                 return ""
243             }
244             if (element is PsiCompiledElement) {
245                 return ""
246             }
247 
248             if (element is KtDeclaration) {
249                 return element.docComment?.text.orEmpty()
250             }
251 
252             if (element is UElement) {
253                 val comments = element.comments
254                 if (comments.isNotEmpty()) {
255                     val sb = StringBuilder()
256                     comments.asSequence().joinTo(buffer = sb, separator = "\n") { it.text }
257                     return sb.toString()
258                 } else {
259                     // Temporary workaround: UAST seems to not return document nodes
260                     // https://youtrack.jetbrains.com/issue/KT-22135
261                     val first = element.sourcePsiElement?.firstChild
262                     if (first is KDoc) {
263                         return first.text
264                     }
265                 }
266             }
267 
268             if (element is PsiDocCommentOwner && element.docComment !is PsiCompiledElement) {
269                 return element.docComment?.text ?: ""
270             }
271 
272             return ""
273         }
274 
modifiersnull275         internal fun modifiers(
276             codebase: PsiBasedCodebase,
277             element: PsiModifierListOwner,
278             documentation: String? = null,
279         ): DefaultModifierList {
280             return PsiModifierItem.create(codebase, element, documentation)
281         }
282     }
283 }
284 
285 /** Check whether a [PsiElement] is Kotlin or not. */
isKotlinnull286 fun PsiElement.isKotlin(): Boolean {
287     return language === KotlinLanguage.INSTANCE
288 }
289