• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.intellij.psi.JavaDocTokenType
20 import com.intellij.psi.JavaPsiFacade
21 import com.intellij.psi.PsiElement
22 import com.intellij.psi.PsiMethod
23 import com.intellij.psi.javadoc.PsiDocComment
24 import com.intellij.psi.javadoc.PsiDocTag
25 import com.intellij.psi.javadoc.PsiDocToken
26 
27 /*
28  * Various utilities for handling javadoc, such as
29  * merging comments into existing javadoc sections,
30  * rewriting javadocs into fully qualified references, etc.
31  *
32  * TODO: Handle KDoc
33  */
34 
35 /**
36  * If the reference is to a class in the same package, include the package prefix? This should not
37  * be necessary, but doclava has problems finding classes without it. Consider turning this off when
38  * we switch to Dokka.
39  */
40 internal const val INCLUDE_SAME_PACKAGE = true
41 
42 /**
43  * Whether we should report unresolved symbols. This is typically a bug in the documentation. It
44  * looks like there are a LOT of mistakes right now, so I'm worried about turning this on since
45  * doclava didn't seem to abort on this.
46  *
47  * Here are some examples I've spot checked: (1) "Unresolved SQLExceptionif": In
48  * java.sql.CallableStatement the getBigDecimal method contains this, presumably missing a space
49  * before the if suffix: "@exception SQLExceptionif parameterName does not..." (2) In
50  * android.nfc.tech.IsoDep there is "@throws TagLostException if ..." but TagLostException is not
51  * imported anywhere and is not in the same package (it's in the parent package).
52  */
53 const val REPORT_UNRESOLVED_SYMBOLS = false
54 
55 /**
56  * Merges the given [newText] into the existing documentation block [existingDoc] (which should be a
57  * full documentation node, including the surrounding comment start and end tokens.)
58  *
59  * If the [tagSection] is null, add the comment to the initial text block of the description.
60  * Otherwise, if it is "@return", add the comment to the return value. Otherwise the [tagSection] is
61  * taken to be the parameter name, and the comment added as parameter documentation for the given
62  * parameter.
63  */
mergeDocumentationnull64 internal fun mergeDocumentation(
65     existingDoc: String,
66     psiElement: PsiElement,
67     newText: String,
68     tagSection: String?,
69     append: Boolean
70 ): String {
71     if (existingDoc.isBlank()) {
72         // There's no existing comment: Create a new one. This is easy.
73         val content =
74             when {
75                 tagSection == "@return" -> "@return $newText"
76                 tagSection?.startsWith("@") ?: false -> "$tagSection $newText"
77                 tagSection != null -> "@param $tagSection $newText"
78                 else -> newText
79             }
80 
81         val inherit =
82             when (psiElement) {
83                 is PsiMethod -> psiElement.findSuperMethods(true).isNotEmpty()
84                 else -> false
85             }
86         val initial = if (inherit) "/**\n* {@inheritDoc}\n */" else "/** */"
87         val new = insertInto(initial, content, initial.indexOf("*/"))
88         if (new.startsWith("/**\n * \n *")) {
89             return "/**\n *" + new.substring(10)
90         }
91         return new
92     }
93 
94     val doc = trimDocIndent(existingDoc)
95 
96     // We'll use the PSI Javadoc support to parse the documentation
97     // to help us scan the tokens in the documentation, such that
98     // we don't have to search for raw substrings like "@return" which
99     // can incorrectly find matches in escaped code snippets etc.
100     val factory =
101         JavaPsiFacade.getElementFactory(psiElement.project)
102             ?: error("Invalid tool configuration; did not find JavaPsiFacade factory")
103     val docComment = factory.createDocCommentFromText(doc)
104 
105     if (tagSection == "@return") {
106         // Add in return value
107         val returnTag = docComment.findTagByName("return")
108         if (returnTag == null) {
109             // Find last tag
110             val lastTag = findLastTag(docComment)
111             val offset =
112                 if (lastTag != null) {
113                     findTagEnd(lastTag)
114                 } else {
115                     doc.length - 2
116                 }
117             return insertInto(doc, "@return $newText", offset)
118         } else {
119             // Add text to the existing @return tag
120             val offset =
121                 if (append) findTagEnd(returnTag)
122                 else returnTag.textRange.startOffset + returnTag.name.length + 1
123             return insertInto(doc, newText, offset)
124         }
125     } else if (tagSection != null) {
126         val parameter =
127             if (tagSection.startsWith("@")) docComment.findTagByName(tagSection.substring(1))
128             else findParamTag(docComment, tagSection)
129         if (parameter == null) {
130             // Add new parameter or tag
131             // TODO: Decide whether to place it alphabetically or place it by parameter order
132             // in the signature. Arguably I should follow the convention already present in the
133             // doc, if any
134             // For now just appending to the last tag before the return tag (if any).
135             // This actually works out well in practice where arguments are generally all documented
136             // or all not documented; when none of the arguments are documented these end up
137             // appending
138             // exactly in the right parameter order!
139             val returnTag = docComment.findTagByName("return")
140             val anchor = returnTag ?: findLastTag(docComment)
141             val offset =
142                 when {
143                     returnTag != null -> returnTag.textRange.startOffset
144                     anchor != null -> findTagEnd(anchor)
145                     else -> doc.length - 2 // "*/
146                 }
147             val tagName = if (tagSection.startsWith("@")) tagSection else "@param $tagSection"
148             return insertInto(doc, "$tagName $newText", offset)
149         } else {
150             // Add to existing tag/parameter
151             val offset =
152                 if (append) findTagEnd(parameter)
153                 else parameter.textRange.startOffset + parameter.name.length + 1
154             return insertInto(doc, newText, offset)
155         }
156     } else {
157         // Add to the main text section of the comment.
158         val firstTag = findFirstTag(docComment)
159         val startOffset =
160             if (!append) {
161                 4 // "/** ".length
162             } else firstTag?.textRange?.startOffset ?: doc.length - 2
163         // Insert a <br> before the appended docs, unless it's the beginning of a doc section
164         return insertInto(doc, if (startOffset > 4) "<br>\n$newText" else newText, startOffset)
165     }
166 }
167 
findParamTagnull168 internal fun findParamTag(docComment: PsiDocComment, paramName: String): PsiDocTag? {
169     return docComment.findTagsByName("param").firstOrNull { it.valueElement?.text == paramName }
170 }
171 
findFirstTagnull172 internal fun findFirstTag(docComment: PsiDocComment): PsiDocTag? {
173     return docComment.tags.asSequence().minByOrNull { it.textRange.startOffset }
174 }
175 
findLastTagnull176 internal fun findLastTag(docComment: PsiDocComment): PsiDocTag? {
177     return docComment.tags.asSequence().maxByOrNull { it.textRange.startOffset }
178 }
179 
findTagEndnull180 internal fun findTagEnd(tag: PsiDocTag): Int {
181     var curr: PsiElement? = tag.nextSibling
182     while (curr != null) {
183         if (curr is PsiDocToken && curr.tokenType == JavaDocTokenType.DOC_COMMENT_END) {
184             return curr.textRange.startOffset
185         } else if (curr is PsiDocTag) {
186             return curr.textRange.startOffset
187         }
188 
189         curr = curr.nextSibling
190     }
191 
192     return tag.textRange.endOffset
193 }
194 
trimDocIndentnull195 fun trimDocIndent(existingDoc: String): String {
196     val index = existingDoc.indexOf('\n')
197     if (index == -1) {
198         return existingDoc
199     }
200 
201     return existingDoc.substring(0, index + 1) +
202         existingDoc.substring(index + 1).trimIndent().split('\n').joinToString(separator = "\n") {
203             if (!it.startsWith(" ")) {
204                 " ${it.trimEnd()}"
205             } else {
206                 it.trimEnd()
207             }
208         }
209 }
210 
insertIntonull211 internal fun insertInto(existingDoc: String, newText: String, initialOffset: Int): String {
212     // TODO: Insert "." between existing documentation and new documentation, if necessary.
213 
214     val offset =
215         if (
216             initialOffset > 4 && existingDoc.regionMatches(initialOffset - 4, "\n * ", 0, 4, false)
217         ) {
218             initialOffset - 4
219         } else {
220             initialOffset
221         }
222     val index = existingDoc.indexOf('\n')
223     val prefixWithStar =
224         index == -1 ||
225             existingDoc[index + 1] == '*' ||
226             existingDoc[index + 1] == ' ' && existingDoc[index + 2] == '*'
227 
228     val prefix = existingDoc.substring(0, offset)
229     val suffix = existingDoc.substring(offset)
230     val startSeparator = "\n"
231     val endSeparator =
232         if (suffix.startsWith("\n") || suffix.startsWith(" \n")) ""
233         else if (suffix == "*/") "\n" else if (prefixWithStar) "\n * " else "\n"
234 
235     val middle =
236         if (prefixWithStar) {
237             startSeparator +
238                 newText.split('\n').joinToString(separator = "\n") { " * $it" } +
239                 endSeparator
240         } else {
241             "$startSeparator$newText$endSeparator"
242         }
243 
244     // Going from single-line to multi-line?
245     return if (existingDoc.indexOf('\n') == -1 && existingDoc.startsWith("/** ")) {
246         prefix.substring(0, 3) +
247             "\n *" +
248             prefix.substring(3) +
249             middle +
250             if (suffix == "*/") " */" else suffix
251     } else {
252         prefix + middle + suffix
253     }
254 }
255 
containsLinkTagsnull256 fun containsLinkTags(documentation: String): Boolean {
257     var index = 0
258     while (true) {
259         index = documentation.indexOf('@', index)
260         if (index == -1) {
261             return false
262         }
263         if (
264             !documentation.startsWith("@code", index) &&
265                 !documentation.startsWith("@literal", index) &&
266                 !documentation.startsWith("@param", index) &&
267                 !documentation.startsWith("@deprecated", index) &&
268                 !documentation.startsWith("@inheritDoc", index) &&
269                 !documentation.startsWith("@return", index)
270         ) {
271             return true
272         }
273 
274         index++
275     }
276 }
277