• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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.&nbsp;" + 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