1 /* 2 * Copyright (C) 2018 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 18 19 import com.android.tools.metalava.model.ClassItem 20 import com.android.tools.metalava.model.FieldItem 21 import com.android.tools.metalava.model.Item 22 import com.android.tools.metalava.model.JVM_STATIC 23 import com.android.tools.metalava.model.MethodItem 24 import com.android.tools.metalava.model.ParameterItem 25 import com.android.tools.metalava.model.PropertyItem 26 import com.android.tools.metalava.model.psi.PsiEnvironmentManager 27 import com.android.tools.metalava.reporter.Issues 28 import com.android.tools.metalava.reporter.Reporter 29 import com.intellij.psi.util.PsiUtil 30 31 // Enforces the interoperability guidelines outlined in 32 // https://android.github.io/kotlin-guides/interop.html 33 // 34 // Also potentially makes other API suggestions. 35 class KotlinInteropChecks(val reporter: Reporter) { 36 37 @Suppress("DEPRECATION") 38 private val javaLanguageLevel = 39 PsiEnvironmentManager.javaLanguageLevelFromString(options.javaLanguageLevelAsString) 40 checkFieldnull41 fun checkField(field: FieldItem, isKotlin: Boolean = field.isKotlin()) { 42 ensureFieldNameNotKeyword(field) 43 } 44 checkMethodnull45 fun checkMethod(method: MethodItem, isKotlin: Boolean = method.isKotlin()) { 46 if (isKotlin) { 47 ensureDefaultParamsHaveJvmOverloads(method) 48 ensureCompanionJvmStatic(method) 49 ensureExceptionsDocumented(method) 50 } else { 51 ensureMethodNameNotKeyword(method) 52 ensureParameterNamesNotKeywords(method) 53 ensureLambdaLastParameter(method) 54 } 55 } 56 checkClassnull57 fun checkClass(cls: ClassItem, isKotlin: Boolean = cls.isKotlin()) { 58 if (isKotlin) { 59 disallowValueClasses(cls) 60 } 61 } 62 checkPropertynull63 fun checkProperty(property: PropertyItem) { 64 ensureCompanionJvmField(property) 65 } 66 ensureExceptionsDocumentednull67 private fun ensureExceptionsDocumented(method: MethodItem) { 68 if (!method.isKotlin()) { 69 return 70 } 71 72 val exceptions = method.body.findThrownExceptions() 73 if (exceptions.isEmpty()) { 74 return 75 } 76 val doc = 77 method.documentation.text.ifEmpty { method.property?.documentation?.text.orEmpty() } 78 for (exception in exceptions.sortedBy { it.qualifiedName() }) { 79 val checked = 80 !(exception.extends("java.lang.RuntimeException") || 81 exception.extends("java.lang.Error")) 82 if (checked) { 83 val annotation = method.modifiers.findAnnotation("kotlin.jvm.Throws") 84 if (annotation != null) { 85 // There can be multiple values 86 for (attribute in annotation.attributes) { 87 for (v in attribute.leafValues()) { 88 val source = v.toSource() 89 if (source.endsWith(exception.simpleName() + "::class")) { 90 return 91 } 92 } 93 } 94 } 95 reporter.report( 96 Issues.DOCUMENT_EXCEPTIONS, 97 method, 98 "Method ${method.containingClass().simpleName()}.${method.name()} appears to be throwing ${exception.qualifiedName()}; this should be recorded with a @Throws annotation; see https://android.github.io/kotlin-guides/interop.html#document-exceptions" 99 ) 100 } else { 101 if (!doc.contains(exception.simpleName())) { 102 reporter.report( 103 Issues.DOCUMENT_EXCEPTIONS, 104 method, 105 "Method ${method.containingClass().simpleName()}.${method.name()} appears to be throwing ${exception.qualifiedName()}; this should be listed in the documentation; see https://android.github.io/kotlin-guides/interop.html#document-exceptions" 106 ) 107 } 108 } 109 } 110 } 111 ensureLambdaLastParameternull112 private fun ensureLambdaLastParameter(method: MethodItem) { 113 val parameters = method.parameters() 114 if (parameters.size > 1) { 115 // Make sure that SAM-compatible parameters are last 116 val lastIndex = parameters.size - 1 117 if (!isSamCompatible(parameters[lastIndex])) { 118 for (i in lastIndex - 1 downTo 0) { 119 val parameter = parameters[i] 120 if (isSamCompatible(parameter)) { 121 val message = 122 "SAM-compatible parameters (such as parameter ${i + 1}, " + 123 "\"${parameter.name()}\", in ${ 124 method.containingClass().qualifiedName()}.${method.name() 125 }) should be last to improve Kotlin interoperability; see " + 126 "https://kotlinlang.org/docs/reference/java-interop.html#sam-conversions" 127 reporter.report(Issues.SAM_SHOULD_BE_LAST, method, message) 128 break 129 } 130 } 131 } 132 } 133 } 134 ensureCompanionJvmStaticnull135 private fun ensureCompanionJvmStatic(method: MethodItem) { 136 if ( 137 method.containingClass().simpleName() == "Companion" && 138 // Many properties will be checked through [ensureCompanionJvmField]. If this method 139 // is not a property or its property can't use @JvmField, it should use @JvmStatic. 140 method.property?.canHaveJvmField() != true && 141 method.modifiers.findAnnotation(JVM_STATIC) == null && 142 method.property?.modifiers?.findAnnotation(JVM_STATIC) == null 143 ) { 144 reporter.report( 145 Issues.MISSING_JVMSTATIC, 146 method, 147 "Companion object methods like ${method.name()} should be marked @JvmStatic for Java interoperability; see https://developer.android.com/kotlin/interop#companion_functions" 148 ) 149 } 150 } 151 152 /** 153 * Warn if companion constants are not marked with @JvmField. 154 * 155 * Properties that we can expect to be constant (that is, declared via `val`, so they don't have 156 * a setter) but that aren't declared 'const' in a companion object should have @JvmField, and 157 * not have @JvmStatic. 158 * 159 * See https://developer.android.com/kotlin/interop#companion_constants 160 */ ensureCompanionJvmFieldnull161 private fun ensureCompanionJvmField(property: PropertyItem) { 162 if (property.containingClass().modifiers.isCompanion() && property.canHaveJvmField()) { 163 if (property.modifiers.findAnnotation(JVM_STATIC) != null) { 164 reporter.report( 165 Issues.MISSING_JVMSTATIC, 166 property, 167 "Companion object constants like ${property.name()} should be using @JvmField, not @JvmStatic; see https://developer.android.com/kotlin/interop#companion_constants" 168 ) 169 } else if (property.modifiers.findAnnotation("kotlin.jvm.JvmField") == null) { 170 reporter.report( 171 Issues.MISSING_JVMSTATIC, 172 property, 173 "Companion object constants like ${property.name()} should be marked @JvmField for Java interoperability; see https://developer.android.com/kotlin/interop#companion_constants" 174 ) 175 } 176 } 177 } 178 179 /** 180 * Whether the property (assumed to be a companion property) is allowed to be have @JvmField. 181 * 182 * If it can't be annotated with @JvmField, it should use @JvmStatic for its accessors instead. 183 */ PropertyItemnull184 private fun PropertyItem.canHaveJvmField(): Boolean { 185 val companionContainer = containingClass().containingClass() 186 return !modifiers.isConst() && 187 setter == null && 188 // @JvmField can only be used on interface companion properties in limited situations -- 189 // all the companion properties must be public and constant, so adding more properties 190 // might mean @JvmField would no longer be allowed even if it was originally. Because of 191 // this, don't suggest using @JvmField for interface companion properties. 192 // https://github.com/Kotlin/KEEP/blob/master/proposals/jvm-field-annotation-in-interface-companion.md 193 containingClass().containingClass()?.isInterface() != true && 194 // @JvmField can only be used when the property has a backing field. The backing 195 // field is present on the containing class of the companion. 196 companionContainer?.findField(name()) != null && 197 // The compiler does not allow @JvmField on value class type properties. 198 !type().isValueClassType() 199 } 200 ensureFieldNameNotKeywordnull201 private fun ensureFieldNameNotKeyword(field: FieldItem) { 202 checkKotlinKeyword(field.name(), "field", field) 203 } 204 ensureMethodNameNotKeywordnull205 private fun ensureMethodNameNotKeyword(method: MethodItem) { 206 checkKotlinKeyword(method.name(), "method", method) 207 } 208 ensureDefaultParamsHaveJvmOverloadsnull209 private fun ensureDefaultParamsHaveJvmOverloads(method: MethodItem) { 210 if (!method.isKotlin()) { 211 // Rule does not apply for Java, e.g. if you specify @DefaultValue 212 // in Java you still don't have the option of adding @JvmOverloads 213 return 214 } 215 if (method.containingClass().isInterface()) { 216 // '@JvmOverloads' annotation cannot be used on interface methods 217 // (https://github.com/JetBrains/kotlin/blob/dc7b1fbff946d1476cc9652710df85f65664baee/compiler/frontend.java/src/org/jetbrains/kotlin/resolve/jvm/diagnostics/DefaultErrorMessagesJvm.java#L50) 218 return 219 } 220 val parameters = method.parameters() 221 if (parameters.size <= 1) { 222 // No need for overloads when there is at most one version... 223 return 224 } 225 226 var haveDefault = false 227 for (parameter in parameters) { 228 if (parameter.hasDefaultValue()) { 229 haveDefault = true 230 break 231 } 232 } 233 234 if ( 235 haveDefault && 236 method.modifiers.findAnnotation("kotlin.jvm.JvmOverloads") == null && 237 // Extension methods and inline functions aren't really useful from Java anyway 238 !method.isExtensionMethod() && 239 !method.modifiers.isInline() && 240 // Methods marked @JvmSynthetic are hidden from java, overloads not useful 241 !method.modifiers.hasJvmSyntheticAnnotation() 242 ) { 243 reporter.report( 244 Issues.MISSING_JVMSTATIC, 245 method, 246 "A Kotlin method with default parameter values should be annotated with @JvmOverloads for better Java interoperability; see https://android.github.io/kotlin-guides/interop.html#function-overloads-for-defaults" 247 ) 248 } 249 } 250 ensureParameterNamesNotKeywordsnull251 private fun ensureParameterNamesNotKeywords(method: MethodItem) { 252 val parameters = method.parameters() 253 254 if (parameters.isNotEmpty() && method.isJava()) { 255 // Public java parameter names should also not use Kotlin keywords as names 256 for (parameter in parameters) { 257 val publicName = parameter.publicName() ?: continue 258 checkKotlinKeyword(publicName, "parameter", parameter) 259 } 260 } 261 } 262 263 // Don't use Kotlin hard keywords in Java signatures checkKotlinKeywordnull264 private fun checkKotlinKeyword(name: String, typeLabel: String, item: Item) { 265 if (isKotlinHardKeyword(name)) { 266 reporter.report( 267 Issues.KOTLIN_KEYWORD, 268 item, 269 "Avoid $typeLabel names that are Kotlin hard keywords (\"$name\"); see https://android.github.io/kotlin-guides/interop.html#no-hard-keywords" 270 ) 271 } else if (isJavaKeyword(name)) { 272 reporter.report( 273 Issues.KOTLIN_KEYWORD, 274 item, 275 "Avoid $typeLabel names that are Java keywords (\"$name\"); this makes it harder to use the API from Java" 276 ) 277 } 278 } 279 280 /** 281 * @return whether [parameter] can be invoked by Kotlin callers using SAM conversion. This does 282 * not check TextParameterItem, as there is missing metadata (such as whether the type is 283 * defined in Kotlin source or not, which can affect SAM conversion). 284 */ isSamCompatiblenull285 private fun isSamCompatible(parameter: ParameterItem): Boolean { 286 val cls = parameter.type().asClass() 287 // Some interfaces, while they have a single method are not considered to be SAM that we 288 // want to be the last argument because often it leads to unexpected behavior of the 289 // trailing lambda. 290 when (cls?.qualifiedName()) { 291 "java.util.concurrent.Executor", 292 "java.lang.Iterable" -> return false 293 } 294 295 return parameter.isSamCompatibleOrKotlinLambda() 296 } 297 disallowValueClassesnull298 private fun disallowValueClasses(cls: ClassItem) { 299 if (cls.modifiers.isValue()) { 300 reporter.report( 301 Issues.VALUE_CLASS_DEFINITION, 302 cls, 303 "Value classes should not be public in APIs targeting Java clients." 304 ) 305 } 306 } 307 isKotlinHardKeywordnull308 private fun isKotlinHardKeyword(keyword: String): Boolean { 309 // From 310 // https://github.com/JetBrains/kotlin/blob/master/core/descriptors/src/org/jetbrains/kotlin/renderer/KeywordStringsGenerated.java 311 when (keyword) { 312 "as", 313 "break", 314 "class", 315 "continue", 316 "do", 317 "else", 318 "false", 319 "for", 320 "fun", 321 "if", 322 "in", 323 "interface", 324 "is", 325 "null", 326 "object", 327 "package", 328 "return", 329 "super", 330 "this", 331 "throw", 332 "true", 333 "try", 334 "typealias", 335 "typeof", 336 "val", 337 "var", 338 "when", 339 "while" -> return true 340 } 341 342 return false 343 } 344 345 /** Returns true if the given string is a reserved Java keyword */ isJavaKeywordnull346 private fun isJavaKeyword(keyword: String): Boolean { 347 return PsiUtil.isKeyword(keyword, javaLanguageLevel) 348 } 349 } 350