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 kotlin.properties.ReadWriteProperty
20 import kotlin.reflect.KProperty
21 import com.android.tools.metalava.model.DefaultItem
22 import com.android.tools.metalava.model.MutableModifierList
23 import com.android.tools.metalava.model.ParameterItem
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.uast.UElement
31 import org.jetbrains.uast.sourcePsiElement
32 
33 abstract class PsiItem(
34     override val codebase: PsiBasedCodebase,
35     val element: PsiElement,
36     override val modifiers: PsiModifierItem,
37     override var documentation: String
38 ) : DefaultItem() {
39 
40     @Suppress("LeakingThis")
41     override var deprecated: Boolean = modifiers.isDeprecated()
42 
43     @Suppress("LeakingThis") // Documentation can change, but we don't want to pick up subsequent @docOnly mutations
44     override var docOnly = documentation.contains("@doconly")
45     @Suppress("LeakingThis")
46     override var removed = documentation.contains("@removed")
47 
48     // a property with a lazily calculated default value
49     inner class lazyDelegate<T>(
50         val defaultValueProvider: () -> T
51     ) : ReadWriteProperty<PsiItem, T> {
52         private var currentValue: T? = null
53 
setValuenull54         override operator fun setValue(thisRef: PsiItem, property: KProperty<*>, value: T) {
55             currentValue = value
56         }
getValuenull57         override operator fun getValue(thisRef: PsiItem, property: KProperty<*>): T {
58             if (currentValue == null) {
59                 currentValue = defaultValueProvider()
60             }
61 
62             return currentValue!!
63         }
64     }
65 
<lambda>null66     override var originallyHidden: Boolean by lazyDelegate({
67         documentation.contains('@') &&
68 
69             (documentation.contains("@hide") ||
70                 documentation.contains("@pending") ||
71                 // KDoc:
72                 documentation.contains("@suppress")) ||
73             modifiers.hasHideAnnotations()
74     })
75 
<lambda>null76     override var hidden: Boolean by lazyDelegate({ originallyHidden && !modifiers.hasShowAnnotation() })
77 
psinull78     override fun psi(): PsiElement? = element
79 
80     // TODO: Consider only doing this in tests!
81     override fun isFromClassPath(): Boolean {
82         return if (element is UElement) {
83             (element.sourcePsi ?: element.javaPsi) is PsiCompiledElement
84         } else {
85             element is PsiCompiledElement
86         }
87     }
88 
isClonednull89     override fun isCloned(): Boolean = false
90 
91     /** Get a mutable version of modifiers for this item */
92     override fun mutableModifiers(): MutableModifierList = modifiers
93 
94     override fun findTagDocumentation(tag: String): String? {
95         if (element is PsiCompiledElement) {
96             return null
97         }
98         if (documentation.isBlank()) {
99             return null
100         }
101 
102         // We can't just use element.docComment here because we may have modified
103         // the comment and then the comment snapshot in PSI isn't up to date with our
104         // latest changes
105         val docComment = codebase.getComment(documentation)
106         val docTag = docComment.findTagByName(tag) ?: return null
107         val text = docTag.text
108 
109         // Trim trailing next line (javadoc *)
110         var index = text.length - 1
111         while (index > 0) {
112             val c = text[index]
113             if (!(c == '*' || c.isWhitespace())) {
114                 break
115             }
116             index--
117         }
118         index++
119         return if (index < text.length) {
120             text.substring(0, index)
121         } else {
122             text
123         }
124     }
125 
appendDocumentationnull126     override fun appendDocumentation(comment: String, tagSection: String?, append: Boolean) {
127         if (comment.isBlank()) {
128             return
129         }
130 
131         // TODO: Figure out if an annotation should go on the return value, or on the method.
132         // For example; threading: on the method, range: on the return value.
133         // TODO: Find a good way to add or append to a given tag (@param <something>, @return, etc)
134 
135         if (this is ParameterItem) {
136             // For parameters, the documentation goes into the surrounding method's documentation!
137             // Find the right parameter location!
138             val parameterName = name()
139             val target = containingMethod()
140             target.appendDocumentation(comment, parameterName)
141             return
142         }
143 
144         // Micro-optimization: we're very often going to be merging @apiSince and to a lesser
145         // extend @deprecatedSince into existing comments, since we're flagging every single
146         // public API. Normally merging into documentation has to be done carefully, since
147         // there could be existing versions of the tag we have to append to, and some parts
148         // of the comment needs to be present in certain places. For example, you can't
149         // just append to the description of a method by inserting something right before "*/"
150         // since you could be appending to a javadoc tag like @return.
151         //
152         // However, for @apiSince and @deprecatedSince specifically, in addition to being frequent,
153         // they will (a) never appear in existing docs, and (b) they're separate tags, which means
154         // it's safe to append them at the end. So we'll special case these two tags here, to
155         // help speed up the builds since these tags are inserted 30,000+ times for each framework
156         // API target (there are many), and each time would have involved constructing a full javadoc
157         // AST with lexical tokens using IntelliJ's javadoc parsing APIs. Instead, we'll just
158         // do some simple string heuristics.
159         if (tagSection == "@apiSince" || tagSection == "@deprecatedSince") {
160             documentation = addUniqueTag(documentation, tagSection, comment)
161             return
162         }
163 
164         documentation = mergeDocumentation(documentation, element, comment.trim(), tagSection, append)
165     }
166 
addUniqueTagnull167     private fun addUniqueTag(documentation: String, tagSection: String, commentLine: String): String {
168         assert(commentLine.indexOf('\n') == -1) // Not meant for multi-line comments
169 
170         if (documentation.isBlank()) {
171             return "/** $tagSection $commentLine */"
172         }
173 
174         // Already single line?
175         if (documentation.indexOf('\n') == -1) {
176             var end = documentation.lastIndexOf("*/")
177             val s = "/**\n *" + documentation.substring(3, end) + "\n * $tagSection $commentLine\n */"
178             return s
179         }
180 
181         var end = documentation.lastIndexOf("*/")
182         while (end > 0 && documentation[end - 1].isWhitespace() &&
183             documentation[end - 1] != '\n') {
184             end--
185         }
186         // The comment ends with:
187         // * some comment here */
188         var insertNewLine: Boolean = documentation[end - 1] != '\n'
189 
190         var indent: String
191         var linePrefix = ""
192         val secondLine = documentation.indexOf('\n')
193         if (secondLine == -1) {
194             // Single line comment
195             indent = "\n * "
196         } else {
197             val indentStart = secondLine + 1
198             var indentEnd = indentStart
199             while (indentEnd < documentation.length) {
200                 if (!documentation[indentEnd].isWhitespace()) {
201                     break
202                 }
203                 indentEnd++
204             }
205             indent = documentation.substring(indentStart, indentEnd)
206             // TODO: If it starts with "* " follow that
207             if (documentation.startsWith("* ", indentEnd)) {
208                 linePrefix = "* "
209             }
210         }
211         val s = documentation.substring(0, end) + (if (insertNewLine) "\n" else "") + indent + linePrefix + tagSection + " " + commentLine + "\n" + indent + " */"
212         return s
213     }
214 
fullyQualifiedDocumentationnull215     override fun fullyQualifiedDocumentation(): String {
216         return fullyQualifiedDocumentation(documentation)
217     }
218 
fullyQualifiedDocumentationnull219     override fun fullyQualifiedDocumentation(documentation: String): String {
220         return toFullyQualifiedDocumentation(this, documentation)
221     }
222 
223     /** Finish initialization of the item */
finishInitializationnull224     open fun finishInitialization() {
225         modifiers.setOwner(this)
226     }
227 
isKotlinnull228     override fun isKotlin(): Boolean {
229         return isKotlin(element)
230     }
231 
232     companion object {
javadocnull233         fun javadoc(element: PsiElement): String {
234             if (element is PsiCompiledElement) {
235                 return ""
236             }
237 
238             if (element is UElement) {
239                 val comments = element.comments
240                 if (comments.isNotEmpty()) {
241                     val sb = StringBuilder()
242                     comments.asSequence().joinTo(buffer = sb, separator = "\n")
243                     return sb.toString()
244                 } else {
245                     // Temporary workaround: UAST seems to not return document nodes
246                     // https://youtrack.jetbrains.com/issue/KT-22135
247                     val first = element.sourcePsiElement?.firstChild
248                     if (first is KDoc) {
249                         return first.text
250                     }
251                 }
252             }
253 
254             if (element is PsiDocCommentOwner && element.docComment !is PsiCompiledElement) {
255                 return element.docComment?.text ?: ""
256             }
257 
258             return ""
259         }
260 
modifiersnull261         fun modifiers(
262             codebase: PsiBasedCodebase,
263             element: PsiModifierListOwner,
264             documentation: String
265         ): PsiModifierItem {
266             return PsiModifierItem.create(codebase, element, documentation)
267         }
268 
isKotlinnull269         fun isKotlin(element: PsiElement): Boolean {
270             return element.language === KotlinLanguage.INSTANCE
271         }
272     }
273 }
274