1 /*
2  * Copyright (C) 2019 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.stub
18 
19 import com.android.tools.metalava.ApiPredicate
20 import com.android.tools.metalava.FilterPredicate
21 import com.android.tools.metalava.actualItem
22 import com.android.tools.metalava.model.BaseItemVisitor
23 import com.android.tools.metalava.model.ClassItem
24 import com.android.tools.metalava.model.ConstructorItem
25 import com.android.tools.metalava.model.FieldItem
26 import com.android.tools.metalava.model.Item
27 import com.android.tools.metalava.model.Language
28 import com.android.tools.metalava.model.MethodItem
29 import com.android.tools.metalava.model.ModifierListWriter
30 import com.android.tools.metalava.model.PackageItem
31 import com.android.tools.metalava.model.psi.trimDocIndent
32 import com.android.tools.metalava.model.visitors.ApiVisitor
33 import com.android.tools.metalava.reporter.Issues
34 import com.android.tools.metalava.reporter.Reporter
35 import java.io.BufferedWriter
36 import java.io.File
37 import java.io.FileWriter
38 import java.io.IOException
39 import java.io.PrintWriter
40 import java.io.Writer
41 import java.util.regex.Pattern
42 
43 internal class StubWriter(
44     private val stubsDir: File,
45     private val generateAnnotations: Boolean = false,
46     private val preFiltered: Boolean = true,
47     private val docStubs: Boolean,
48     private val reporter: Reporter,
49     private val config: StubWriterConfig,
50 ) :
51     ApiVisitor(
52         visitConstructorsAsMethods = false,
53         nestInnerClasses = true,
54         inlineInheritedFields = true,
55         // Methods are by default sorted in source order in stubs, to encourage methods
56         // that are near each other in the source to show up near each other in the documentation
57         methodComparator = MethodItem.sourceOrderComparator,
58         filterEmit = FilterPredicate(apiPredicate(docStubs, config)),
59         filterReference = apiPredicate(docStubs, config),
60         includeEmptyOuterClasses = true,
61         config = config.apiVisitorConfig,
62     ) {
63 
visitPackagenull64     override fun visitPackage(pkg: PackageItem) {
65         getPackageDir(pkg, create = true)
66 
67         writePackageInfo(pkg)
68 
69         if (docStubs) {
70             pkg.overviewDocumentation?.let { writeDocOverview(pkg, it) }
71         }
72     }
73 
writeDocOverviewnull74     fun writeDocOverview(pkg: PackageItem, content: String) {
75         if (content.isBlank()) {
76             return
77         }
78 
79         val sourceFile = File(getPackageDir(pkg), "overview.html")
80         val overviewWriter =
81             try {
82                 PrintWriter(BufferedWriter(FileWriter(sourceFile)))
83             } catch (e: IOException) {
84                 reporter.report(Issues.IO_ERROR, sourceFile, "Cannot open file for write.")
85                 return
86             }
87 
88         // Should we include this in our stub list?
89         //     startFile(sourceFile)
90 
91         overviewWriter.println(content)
92         overviewWriter.flush()
93         overviewWriter.close()
94     }
95 
writePackageInfonull96     private fun writePackageInfo(pkg: PackageItem) {
97         val annotations = pkg.modifiers.annotations()
98         val writeAnnotations = annotations.isNotEmpty() && generateAnnotations
99         val writeDocumentation =
100             config.includeDocumentationInStubs && pkg.documentation.isNotBlank()
101         if (writeAnnotations || writeDocumentation) {
102             val sourceFile = File(getPackageDir(pkg), "package-info.java")
103             val packageInfoWriter =
104                 try {
105                     PrintWriter(BufferedWriter(FileWriter(sourceFile)))
106                 } catch (e: IOException) {
107                     reporter.report(Issues.IO_ERROR, sourceFile, "Cannot open file for write.")
108                     return
109                 }
110 
111             appendDocumentation(pkg, packageInfoWriter, config)
112 
113             if (annotations.isNotEmpty()) {
114                 // Write the modifier list even though the package info does not actually have
115                 // modifiers as that will write the annotations which it does have and ignore the
116                 // modifiers.
117                 ModifierListWriter.forStubs(
118                         writer = packageInfoWriter,
119                         docStubs = docStubs,
120                     )
121                     .write(pkg)
122             }
123             packageInfoWriter.println("package ${pkg.qualifiedName()};")
124 
125             packageInfoWriter.flush()
126             packageInfoWriter.close()
127         }
128     }
129 
getPackageDirnull130     private fun getPackageDir(packageItem: PackageItem, create: Boolean = true): File {
131         val relative = packageItem.qualifiedName().replace('.', File.separatorChar)
132         val dir = File(stubsDir, relative)
133         if (create && !dir.isDirectory) {
134             val ok = dir.mkdirs()
135             if (!ok) {
136                 throw IOException("Could not create $dir")
137             }
138         }
139 
140         return dir
141     }
142 
getClassFilenull143     private fun getClassFile(classItem: ClassItem): File {
144         assert(classItem.containingClass() == null) { "Should only be called on top level classes" }
145         val packageDir = getPackageDir(classItem.containingPackage())
146 
147         // Kotlin From-text stub generation is not supported.
148         // This method will raise an error if
149         // config.kotlinStubs == true and classItem is TextClassItem.
150         return if (config.kotlinStubs && classItem.isKotlin()) {
151             File(packageDir, "${classItem.simpleName()}.kt")
152         } else {
153             File(packageDir, "${classItem.simpleName()}.java")
154         }
155     }
156 
157     /**
158      * Between top level class files the [textWriter] field doesn't point to a real file; it points
159      * to this writer, which redirects to the error output. Nothing should be written to the writer
160      * at that time.
161      */
162     private var errorTextWriter =
163         PrintWriter(
164             object : Writer() {
closenull165                 override fun close() {
166                     throw IllegalStateException(
167                         "Attempt to close 'textWriter' outside top level class"
168                     )
169                 }
170 
flushnull171                 override fun flush() {
172                     throw IllegalStateException(
173                         "Attempt to flush 'textWriter' outside top level class"
174                     )
175                 }
176 
writenull177                 override fun write(cbuf: CharArray, off: Int, len: Int) {
178                     throw IllegalStateException(
179                         "Attempt to write to 'textWriter' outside top level class\n'${String(cbuf, off, len)}'"
180                     )
181                 }
182             }
183         )
184 
185     /** The writer to write the stubs file to */
186     private var textWriter: PrintWriter = errorTextWriter
187 
188     private var stubWriter: BaseItemVisitor? = null
189 
visitClassnull190     override fun visitClass(cls: ClassItem) {
191         if (cls.isTopLevelClass()) {
192             val sourceFile = getClassFile(cls)
193             textWriter =
194                 try {
195                     PrintWriter(BufferedWriter(FileWriter(sourceFile)))
196                 } catch (e: IOException) {
197                     reporter.report(Issues.IO_ERROR, sourceFile, "Cannot open file for write.")
198                     errorTextWriter
199                 }
200 
201             val kotlin = config.kotlinStubs && cls.isKotlin()
202             val language = if (kotlin) Language.KOTLIN else Language.JAVA
203 
204             val modifierListWriter =
205                 ModifierListWriter.forStubs(
206                     writer = textWriter,
207                     docStubs = docStubs,
208                     runtimeAnnotationsOnly = !generateAnnotations,
209                     language = language,
210                 )
211 
212             stubWriter =
213                 if (kotlin) {
214                     KotlinStubWriter(
215                         textWriter,
216                         modifierListWriter,
217                         filterReference,
218                         preFiltered,
219                         config,
220                     )
221                 } else {
222                     JavaStubWriter(
223                         textWriter,
224                         modifierListWriter,
225                         filterEmit,
226                         filterReference,
227                         preFiltered,
228                         config,
229                     )
230                 }
231 
232             // Copyright statements from the original file?
233             cls.getSourceFile()?.getHeaderComments()?.let { textWriter.println(it) }
234         }
235         stubWriter?.visitClass(cls)
236     }
237 
afterVisitClassnull238     override fun afterVisitClass(cls: ClassItem) {
239         stubWriter?.afterVisitClass(cls)
240 
241         if (cls.isTopLevelClass()) {
242             textWriter.flush()
243             textWriter.close()
244             textWriter = errorTextWriter
245             stubWriter = null
246         }
247     }
248 
visitConstructornull249     override fun visitConstructor(constructor: ConstructorItem) {
250         stubWriter?.visitConstructor(constructor)
251     }
252 
afterVisitConstructornull253     override fun afterVisitConstructor(constructor: ConstructorItem) {
254         stubWriter?.afterVisitConstructor(constructor)
255     }
256 
visitMethodnull257     override fun visitMethod(method: MethodItem) {
258         stubWriter?.visitMethod(method)
259     }
260 
afterVisitMethodnull261     override fun afterVisitMethod(method: MethodItem) {
262         stubWriter?.afterVisitMethod(method)
263     }
264 
visitFieldnull265     override fun visitField(field: FieldItem) {
266         stubWriter?.visitField(field)
267     }
268 
afterVisitFieldnull269     override fun afterVisitField(field: FieldItem) {
270         stubWriter?.afterVisitField(field)
271     }
272 }
273 
apiPredicatenull274 private fun apiPredicate(docStubs: Boolean, config: StubWriterConfig) =
275     ApiPredicate(
276         includeDocOnly = docStubs,
277         config = config.apiVisitorConfig.apiPredicateConfig.copy(ignoreShown = true),
278     )
279 
280 internal fun appendDocumentation(item: Item, writer: PrintWriter, config: StubWriterConfig) {
281     if (config.includeDocumentationInStubs) {
282         val documentation = item.fullyQualifiedDocumentation()
283         if (documentation.isNotBlank()) {
284             val trimmed = trimDocIndent(documentation)
285             val output = revertDocumentationDeprecationChange(item, trimmed)
286             writer.println(output)
287             writer.println()
288         }
289     }
290 }
291 
292 /** Regular expression to match the start of a doc comment. */
293 private const val DOC_COMMENT_START_RE = """\Q/**\E"""
294 /**
295  * Regular expression to match the end of a block comment. If the block comment is at the start of a
296  * line, preceded by some white space then it includes all that white space.
297  */
298 private const val BLOCK_COMMENT_END_RE = """(?m:^\s*)?\Q*/\E"""
299 
300 /**
301  * Regular expression to match the start of a line Javadoc tag, i.e. a Javadoc tag at the beginning
302  * of a line. Optionally, includes the preceding white space and a `*` forming a left hand border.
303  */
304 private const val START_OF_LINE_TAG_RE = """(?m:^\s*)\Q*\E\s*@"""
305 
306 /**
307  * A [Pattern[] for matching an `@deprecated` tag and its associated text. If the tag is at the
308  * start of the line then it includes everything from the start of the line. It includes everything
309  * up to the end of the comment (apart from the line for the end of the comment) or the start of the
310  * next line tag.
311  */
312 private val deprecatedTagPattern =
313     """((?m:^\s*\*\s*)?@deprecated\b(?m:\s*.*?))($START_OF_LINE_TAG_RE|$BLOCK_COMMENT_END_RE)"""
314         .toPattern(Pattern.DOTALL)
315 
316 /** A [Pattern] that matches a blank, i.e. white space only, doc comment. */
317 private val blankDocCommentPattern = """$DOC_COMMENT_START_RE\s*$BLOCK_COMMENT_END_RE""".toPattern()
318 
319 /**
320  * Revert the documentation change that accompanied a deprecation change.
321  *
322  * Deprecating an API requires adding an `@Deprecated` annotation and an `@deprecated` Javadoc tag
323  * with text that explains why it is being deprecated and what will replace it. When the deprecation
324  * change is being reverted then this will remove the `@deprecated` tag and its associated text to
325  * avoid warnings when compiling and misleading information being written into the Javadoc.
326  */
revertDocumentationDeprecationChangenull327 fun revertDocumentationDeprecationChange(currentItem: Item, docs: String): String {
328     val actualItem = currentItem.actualItem
329     // The documentation does not need to be reverted if...
330     if (
331         // the current item is not being reverted
332         currentItem === actualItem
333         // or if the current item and the actual item have the same deprecation setting
334         ||
335             currentItem.effectivelyDeprecated == actualItem.effectivelyDeprecated
336             // or if the actual item is deprecated
337             ||
338             actualItem.effectivelyDeprecated
339     )
340         return docs
341 
342     // Find the `@deprecated` tag.
343     val deprecatedTagMatcher = deprecatedTagPattern.matcher(docs)
344     if (!deprecatedTagMatcher.find()) {
345         // Nothing to do as the documentation does not include @deprecated.
346         return docs
347     }
348 
349     // Remove the @deprecated tag and associated text.
350     val withoutDeprecated =
351         // The part before the `@deprecated` tag.
352         docs.substring(0, deprecatedTagMatcher.start(1)) +
353             // The part after the `@deprecated` tag.
354             docs.substring(deprecatedTagMatcher.end(1))
355 
356     // Check to see if the resulting document comment is empty and if it is then discard it all
357     // together.
358     val emptyDocCommentMatcher = blankDocCommentPattern.matcher(withoutDeprecated)
359     return if (emptyDocCommentMatcher.matches()) {
360         ""
361     } else {
362         withoutDeprecated
363     }
364 }
365