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