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