1 /*
2 * Copyright (C) 2024 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
18
19 import java.util.regex.Pattern
20
21 /** A factory that will create an [ItemDocumentation] for a specific [Item]. */
22 typealias ItemDocumentationFactory = (Item) -> ItemDocumentation
23
24 /**
25 * The documentation associated with an [Item].
26 *
27 * This implements [CharSequence] to simplify migration.
28 */
29 interface ItemDocumentation : CharSequence {
30 val text: String
31
32 override val length
33 get() = text.length
34
getnull35 override fun get(index: Int) = text.get(index)
36
37 override fun subSequence(startIndex: Int, endIndex: Int) =
38 text.subSequence(startIndex, endIndex)
39
40 /**
41 * True if the documentation contains one of the following tags that indicates that it should
42 * not be part of an API, unless overridden by a show annotation:
43 * * `@hide`
44 * * `@pending`
45 * * `@suppress`
46 */
47 val isHidden: Boolean
48
49 /**
50 * True if the documentation contains `@doconly` which indicates that it should only be included
51 * in stubs that are generated for documentation purposes.
52 */
53 val isDocOnly: Boolean
54
55 /**
56 * True if the documentation contains `@removed` which indicates that the [Item] must not be
57 * included in stubs or the main signature file but will be included in the `removed` signature
58 * file as it is still considered part of the API available at runtime and so cannot be removed
59 * altogether.
60 */
61 val isRemoved: Boolean
62
63 /**
64 * Return a duplicate of this instance.
65 *
66 * [ItemDocumentation] instances can be mutable, and if they are then they must not be shared.
67 */
68 fun duplicate(item: Item): ItemDocumentation
69
70 /**
71 * Like [duplicate] except that it returns an instance of [ItemDocumentation] suitable for use
72 * in the snapshot.
73 */
74 fun snapshot(item: Item): ItemDocumentation = text.toItemDocumentation()
75
76 /** Work around javadoc cutting off the summary line after the first ". ". */
77 fun workAroundJavaDocSummaryTruncationIssue() {}
78
79 /**
80 * Add the given text to the documentation.
81 *
82 * If the [tagSection] is null, add the comment to the initial text block of the description.
83 * Otherwise, if it is "@return", add the comment to the return value. Otherwise, the
84 * [tagSection] is taken to be the parameter name, and the comment added as parameter
85 * documentation for the given parameter.
86 *
87 * @param tagSection if specified and not a parameter name then it is expected to start with
88 * `@`, e.g. `@deprecated`, not `deprecated`.
89 */
appendDocumentationnull90 fun appendDocumentation(comment: String, tagSection: String?)
91
92 /**
93 * Check to see whether this has the named tag section.
94 *
95 * @param tagSection the name of the tag section, including preceding `@`.
96 */
97 fun hasTagSection(tagSection: String): Boolean {
98 val length = text.length
99 var startIndex = 0
100
101 // Scan through the documentation looking for the tag section.
102 while (startIndex < length) {
103 // Find the position of the tag section starting with the supplied name.
104 val index = text.indexOf(tagSection, startIndex)
105 if (index == -1) return false
106
107 // If the tag section is at the end of the documentation or is followed by a whitespace
108 // then it matches.
109 val nextIndex = index + tagSection.length
110 if (text.length == nextIndex || Character.isWhitespace(text[nextIndex])) return true
111
112 // Else, continue scanning from the end of the tag section.
113 startIndex = nextIndex
114 }
115 return false
116 }
117
118 /**
119 * Looks up docs for the first instance of a specific javadoc tag having the (optionally)
120 * provided value (e.g. parameter name).
121 */
findTagDocumentationnull122 fun findTagDocumentation(tag: String, value: String? = null): String?
123
124 /** Returns the main documentation for the method (the documentation before any tags). */
125 fun findMainDocumentation(): String
126
127 /**
128 * Returns the [text], but with fully qualified links (except for the same package, and when
129 * turning a relative reference into a fully qualified reference, use the javadoc syntax for
130 * continuing to display the relative text, e.g. instead of {@link java.util.List}, use {@link
131 * java.util.List List}.
132 */
133 fun fullyQualifiedDocumentation(): String = fullyQualifiedDocumentation(text)
134
135 /** Expands the given documentation comment in the current name context */
136 fun fullyQualifiedDocumentation(documentation: String): String = documentation
137
138 /** Remove the `@deprecated` section, if any. */
139 fun removeDeprecatedSection()
140
141 companion object {
142 /**
143 * A special [ItemDocumentation] that contains no documentation.
144 *
145 * Used where there is no documentation possible, e.g. text model, type parameters,
146 * parameters.
147 */
148 val NONE: ItemDocumentation = EmptyItemDocumentation()
149
150 /**
151 * A special [ItemDocumentationFactory] that returns [NONE] which contains no documentation.
152 *
153 * Used where there is no documentation possible, e.g. text model, type parameters,
154 * parameters.
155 */
156 val NONE_FACTORY: ItemDocumentationFactory = { NONE }
157
158 /** Wrap a [String] in an [ItemDocumentationFactory]. */
159 fun String.toItemDocumentationFactory(): ItemDocumentationFactory = {
160 toItemDocumentation()
161 }
162
163 /** Wrap a [String] in an [ItemDocumentation] instance. */
164 fun String.toItemDocumentation(): ItemDocumentation = DefaultItemDocumentation(this)
165 }
166
167 /** An empty [ItemDocumentation] that can never contain any text. */
168 private class EmptyItemDocumentation : ItemDocumentation {
169 override val text
170 get() = ""
171
172 override val isHidden
173 get() = false
174
175 override val isDocOnly
176 get() = false
177
178 override val isRemoved
179 get() = false
180
181 // This is ok to share as it is immutable.
duplicatenull182 override fun duplicate(item: Item) = this
183
184 // This is ok to use in a snapshot as it is immutable and model independent.
185 override fun snapshot(item: Item) = this
186
187 override fun findTagDocumentation(tag: String, value: String?): String? = null
188
189 override fun appendDocumentation(comment: String, tagSection: String?) {
190 error("cannot modify documentation on an item that does not support documentation")
191 }
192
findMainDocumentationnull193 override fun findMainDocumentation() = ""
194
195 override fun removeDeprecatedSection() {}
196 }
197 }
198
199 /**
200 * Abstract [ItemDocumentation] into which functionality that is common to all models will be added.
201 */
202 abstract class AbstractItemDocumentation : ItemDocumentation {
203
204 /**
205 * The mutable text contents of the documentation. This is abstract to allow the implementations
206 * of this to optimize how it is accessed, e.g. initialize it lazily.
207 */
208 abstract override var text: String
209
210 override val isHidden
211 get() =
212 text.contains('@') &&
213 (text.contains("@hide") ||
214 text.contains("@pending") ||
215 // KDoc:
216 text.contains("@suppress"))
217
218 override val isDocOnly
219 get() = text.contains("@doconly")
220
221 override val isRemoved
222 get() = text.contains("@removed")
223
workAroundJavaDocSummaryTruncationIssuenull224 override fun workAroundJavaDocSummaryTruncationIssue() {
225 // Work around javadoc cutting off the summary line after the first ". ".
226 val firstDot = text.indexOf(".")
227 if (firstDot > 0 && text.regionMatches(firstDot - 1, "e.g. ", 0, 5, false)) {
228 text = text.substring(0, firstDot) + ".g. " + text.substring(firstDot + 4)
229 }
230 }
231
findTagDocumentationnull232 override fun findTagDocumentation(tag: String, value: String?): String? {
233 TODO("Not yet implemented")
234 }
235
appendDocumentationnull236 override fun appendDocumentation(comment: String, tagSection: String?) {
237 if (comment.isBlank()) {
238 return
239 }
240
241 // Micro-optimization: we're very often going to be merging @apiSince and to a lesser
242 // extent @deprecatedSince into existing comments, since we're flagging every single
243 // public API. Normally merging into documentation has to be done carefully, since
244 // there could be existing versions of the tag we have to append to, and some parts
245 // of the comment needs to be present in certain places. For example, you can't
246 // just append to the description of a method by inserting something right before "*/"
247 // since you could be appending to a javadoc tag like @return.
248 //
249 // However, for @apiSince and @deprecatedSince specifically, in addition to being frequent,
250 // they will (a) never appear in existing docs, and (b) they're separate tags, which means
251 // it's safe to append them at the end. So we'll special case these two tags here, to
252 // help speed up the builds since these tags are inserted 30,000+ times for each framework
253 // API target (there are many), and each time would have involved constructing a full
254 // javadoc
255 // AST with lexical tokens using IntelliJ's javadoc parsing APIs. Instead, we'll just
256 // do some simple string heuristics.
257 if (
258 tagSection == "@apiSince" ||
259 tagSection == "@deprecatedSince" ||
260 tagSection == "@sdkExtSince"
261 ) {
262 text = addUniqueTag(text, tagSection, comment)
263 return
264 }
265
266 mergeDocumentation(comment.trim(), tagSection)
267 }
268
269 /**
270 * Merge the comment into the appropriate [tagSection].
271 *
272 * See [Item.appendDocumentation] for more details.
273 */
mergeDocumentationnull274 protected abstract fun mergeDocumentation(comment: String, tagSection: String?)
275
276 private fun addUniqueTag(text: String, tagSection: String, commentLine: String): String {
277 assert(commentLine.indexOf('\n') == -1) // Not meant for multi-line comments
278
279 if (text.isBlank()) {
280 return "/** $tagSection $commentLine */"
281 }
282
283 // Already single line?
284 if (text.indexOf('\n') == -1) {
285 val end = text.lastIndexOf("*/")
286 return "/**\n *" + text.substring(3, end) + "\n * $tagSection $commentLine\n */"
287 }
288
289 var end = text.lastIndexOf("*/")
290 while (end > 0 && text[end - 1].isWhitespace() && text[end - 1] != '\n') {
291 end--
292 }
293 // The comment ends with:
294 // * some comment here */
295 val insertNewLine: Boolean = text[end - 1] != '\n'
296
297 val indent: String
298 var linePrefix = ""
299 val secondLine = text.indexOf('\n')
300 if (secondLine == -1) {
301 // Single line comment
302 indent = "\n * "
303 } else {
304 val indentStart = secondLine + 1
305 var indentEnd = indentStart
306 while (indentEnd < text.length) {
307 if (!text[indentEnd].isWhitespace()) {
308 break
309 }
310 indentEnd++
311 }
312 indent = text.substring(indentStart, indentEnd)
313 // TODO: If it starts with "* " follow that
314 if (text.startsWith("* ", indentEnd)) {
315 linePrefix = "* "
316 }
317 }
318 return text.substring(0, end) +
319 (if (insertNewLine) "\n" else "") +
320 indent +
321 linePrefix +
322 tagSection +
323 " " +
324 commentLine +
325 "\n" +
326 indent +
327 " */"
328 }
329
removeDeprecatedSectionnull330 override fun removeDeprecatedSection() {
331 text = removeDeprecatedSection(text)
332 }
333 }
334
335 /** A default [ItemDocumentation] containing JavaDoc/KDoc. */
336 internal class DefaultItemDocumentation(override var text: String) : AbstractItemDocumentation() {
337
duplicatenull338 override fun duplicate(item: Item) = DefaultItemDocumentation(text)
339
340 override fun mergeDocumentation(comment: String, tagSection: String?) {
341 TODO("Not yet implemented")
342 }
343
findMainDocumentationnull344 override fun findMainDocumentation(): String {
345 TODO("Not yet implemented")
346 }
347 }
348
349 /** Regular expression to match the start of a doc comment. */
350 private const val DOC_COMMENT_START_RE = """\Q/**\E"""
351
352 /**
353 * Regular expression to match the end of a block comment. If the block comment is at the start of a
354 * line, preceded by some white space then it includes all that white space.
355 */
356 private const val BLOCK_COMMENT_END_RE = """(?m:^\s*)?\Q*/\E"""
357
358 /**
359 * Regular expression to match the start of a line Javadoc tag, i.e. a Javadoc tag at the beginning
360 * of a line. Optionally, includes the preceding white space and a `*` forming a left hand border.
361 */
362 private const val START_OF_LINE_TAG_RE = """(?m:^\s*)\Q*\E\s*@"""
363
364 /**
365 * A [Pattern[] for matching an `@deprecated` tag and its associated text. If the tag is at the
366 * start of the line then it includes everything from the start of the line. It includes everything
367 * up to the end of the comment (apart from the line for the end of the comment) or the start of the
368 * next line tag.
369 */
370 private val deprecatedTagPattern =
371 """((?m:^\s*\*\s*)?@deprecated\b(?m:\s*.*?))($START_OF_LINE_TAG_RE|$BLOCK_COMMENT_END_RE)"""
372 .toPattern(Pattern.DOTALL)
373
374 /** A [Pattern] that matches a blank, i.e. white space only, doc comment. */
375 private val blankDocCommentPattern = """$DOC_COMMENT_START_RE\s*$BLOCK_COMMENT_END_RE""".toPattern()
376
377 /** Remove the `@deprecated` section, if any, from [docs]. */
removeDeprecatedSectionnull378 fun removeDeprecatedSection(docs: String): String {
379 // Find the `@deprecated` tag.
380 val deprecatedTagMatcher = deprecatedTagPattern.matcher(docs)
381 if (!deprecatedTagMatcher.find()) {
382 // Nothing to do as the documentation does not include @deprecated.
383 return docs
384 }
385
386 // Remove the @deprecated tag and associated text.
387 val withoutDeprecated =
388 // The part before the `@deprecated` tag.
389 docs.substring(0, deprecatedTagMatcher.start(1)) +
390 // The part after the `@deprecated` tag.
391 docs.substring(deprecatedTagMatcher.end(1))
392
393 // Check to see if the resulting document comment is empty and if it is then discard it all
394 // together.
395 val emptyDocCommentMatcher = blankDocCommentPattern.matcher(withoutDeprecated)
396 return if (emptyDocCommentMatcher.matches()) {
397 ""
398 } else {
399 withoutDeprecated
400 }
401 }
402