diff options
author | Louis Pullen-Freilich <lpf@google.com> | 2019-06-17 15:32:20 +0100 |
---|---|---|
committer | Louis Pullen-Freilich <lpf@google.com> | 2019-06-26 14:13:00 +0100 |
commit | c4169031e74cb57d14da4c38a06f61ff67ad1e60 (patch) | |
tree | 7cc4cc2280335abf684e4e1dc718a9242c90629a | |
parent | da18b8e358a305e4ae90edc548eb48927f037696 (diff) | |
download | support-c4169031e74cb57d14da4c38a06f61ff67ad1e60.tar.gz |
Adds @Sampled annotation and corresponding lint checks
This annotation should be used on sample functions referenced
from within a KDoc @sample tag.
Due to the corresponding lint checks, all functions sampled from
KDoc must have this annotation. Similarly, all functions
annotated with @Sampled must be referenced from KDoc.
This lint check supports two module structures:
1. A module that has a samples folder in its directory tree
2. A module that is the sibling of a folder that has a samples folder in
its directory tree
For example:
1. module 'foo', inside foo/ that contains 'foo:integration-tests:samples', inside foo/integration-tests/samples
2. module 'foo:foo' inside foo/foo, that is the sibling of
foo:integration-tests:samples, inside foo/integration-tests/samples
Bug: b/135445027
Test: RequireSampledAnnotationTest
Change-Id: Ief24f071f55d509a1508ea8709fd69890bc912ab
6 files changed, 1131 insertions, 0 deletions
diff --git a/annotation/annotation-sampled/build.gradle b/annotation/annotation-sampled/build.gradle new file mode 100644 index 00000000000..64d858fd594 --- /dev/null +++ b/annotation/annotation-sampled/build.gradle @@ -0,0 +1,35 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import static androidx.build.dependencies.DependenciesKt.* + +import androidx.build.LibraryGroups +import androidx.build.LibraryVersions +import androidx.build.Publish + +plugins { + id("AndroidXPlugin") + id("kotlin") +} + +dependencies { + implementation(KOTLIN_STDLIB) +} + +androidx { + publish = Publish.NONE + toolingProject = true +} diff --git a/annotation/annotation-sampled/src/main/java/androidx/annotation/Sampled.kt b/annotation/annotation-sampled/src/main/java/androidx/annotation/Sampled.kt new file mode 100644 index 00000000000..f03f55f2ea3 --- /dev/null +++ b/annotation/annotation-sampled/src/main/java/androidx/annotation/Sampled.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.annotation + +/** + * Denotes that the annotated function is considered a sample function, and is linked to from the + * KDoc of a source module. + * + * There are corresponding lint checks ensuring that functions referred to from KDoc with a @sample + * tag are annotated with this annotation, and also to ensure that any functions annotated with this + * annotation are linked to from a @sample tag. + */ +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.SOURCE) +annotation class Sampled diff --git a/buildSrc/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt b/buildSrc/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt index d0d4cb768d5..7170927e8dc 100644 --- a/buildSrc/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt +++ b/buildSrc/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt @@ -24,6 +24,12 @@ class AndroidXIssueRegistry : IssueRegistry() { BanKeepAnnotation.ISSUE, BanTargetApiAnnotation.ISSUE, MissingTestSizeAnnotation.ISSUE, + SampledAnnotationEnforcer.MISSING_SAMPLED_ANNOTATION, + SampledAnnotationEnforcer.OBSOLETE_SAMPLED_ANNOTATION, + SampledAnnotationEnforcer.MISSING_SAMPLES_DIRECTORY, + SampledAnnotationEnforcer.UNRESOLVED_SAMPLE_LINK, + SampledAnnotationEnforcer.MULTIPLE_FUNCTIONS_FOUND, + SampledAnnotationEnforcer.INVALID_SAMPLES_LOCATION, ObsoleteBuildCompatUsageDetector.ISSUE ) } diff --git a/buildSrc/lint-checks/src/main/java/androidx/build/lint/SampledAnnotationEnforcer.kt b/buildSrc/lint-checks/src/main/java/androidx/build/lint/SampledAnnotationEnforcer.kt new file mode 100644 index 00000000000..4df13751790 --- /dev/null +++ b/buildSrc/lint-checks/src/main/java/androidx/build/lint/SampledAnnotationEnforcer.kt @@ -0,0 +1,530 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.build.lint + +import androidx.build.lint.SampledAnnotationEnforcer.Companion.SAMPLED_ANNOTATION +import androidx.build.lint.SampledAnnotationEnforcer.Companion.SAMPLES_DIRECTORY +import androidx.build.lint.SampledAnnotationEnforcer.Companion.SAMPLE_KDOC_ANNOTATION +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category +import com.android.tools.lint.detector.api.Context +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.Severity +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import org.jetbrains.kotlin.asJava.namedUnwrappedElement +import org.jetbrains.kotlin.kdoc.psi.api.KDoc +import org.jetbrains.kotlin.kdoc.psi.impl.KDocSection +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.findDocComment.findDocComment +import org.jetbrains.kotlin.psi.psiUtil.safeNameForLazyResolve +import org.jetbrains.kotlin.utils.addIfNotNull +import org.jetbrains.uast.UDeclaration +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UMethod +import java.io.File + +/** + * Class containing two lint detectors responsible for enforcing @Sampled annotation usage when + * AndroidXExtension.enforceSampledAnnotation == true + * + * 1. KDocSampleLinkDetector, which enforces that any samples referenced from a @sample tag in KDoc + * are correctly annotated with @Sampled + * + * 2. SampledAnnotationDetector, which enforces that any sample functions annotated with @Sampled + * are linked to from KDoc in the parent module + * + * These lint checks make some assumptions about directory / module structure, and supports two + * such setups: + * + * 1. Module foo which has a 'samples' dir/module inside it + * 2. Module foo which has a 'samples' dir/module alongside it + * + * There are also some other tangentially related lint issues that can be reported, to ensure sample + * correctness: + * + * 1. Missing samples directory + * 2. No functions found matching a @sample link + * 3. Multiple functions found matching a @sample link + * 4. Function annotated with @Sampled does not live in a valid samples directory + */ +class SampledAnnotationEnforcer { + + companion object { + // The name of the @sample tag in KDoc + const val SAMPLE_KDOC_ANNOTATION = "sample" + // The name of the @Sampled annotation that samples must be annotated with + const val SAMPLED_ANNOTATION = "Sampled" + // The name of the samples directory inside a project + const val SAMPLES_DIRECTORY = "samples" + + val MISSING_SAMPLED_ANNOTATION = Issue.create( + "EnforceSampledAnnotation", + "Missing @$SAMPLED_ANNOTATION annotation", + "Functions referred to from KDoc with a @$SAMPLE_KDOC_ANNOTATION tag must " + + "be annotated with @$SAMPLED_ANNOTATION, to provide visibility at the sample " + + "site and ensure that it doesn't get changed accidentally.", + Category.CORRECTNESS, 5, Severity.ERROR, + Implementation(KDocSampleLinkDetector::class.java, Scope.JAVA_FILE_SCOPE) + ) + + val OBSOLETE_SAMPLED_ANNOTATION = Issue.create( + "EnforceSampledAnnotation", + "Obsolete @$SAMPLED_ANNOTATION annotation", + "This function is annotated with @$SAMPLED_ANNOTATION, but is not linked to " + + "from a @$SAMPLE_KDOC_ANNOTATION tag. Either remove this annotation, or add " + + "a valid @$SAMPLE_KDOC_ANNOTATION tag linking to it.", + Category.CORRECTNESS, 5, Severity.ERROR, + Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) + ) + + val MISSING_SAMPLES_DIRECTORY = Issue.create( + "EnforceSampledAnnotation", + "Missing $SAMPLES_DIRECTORY directory", + "Couldn't find a valid $SAMPLES_DIRECTORY directory in this project.", + Category.CORRECTNESS, 5, Severity.ERROR, + Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) + ) + + val UNRESOLVED_SAMPLE_LINK = Issue.create( + "EnforceSampledAnnotation", + "Unresolved @$SAMPLE_KDOC_ANNOTATION annotation", + "Couldn't find a valid function matching the function specified in the " + + "$SAMPLE_KDOC_ANNOTATION link.", + Category.CORRECTNESS, 5, Severity.ERROR, + Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) + ) + + val MULTIPLE_FUNCTIONS_FOUND = Issue.create( + "EnforceSampledAnnotation", + "Multiple matching functions found", + "Found multiple functions matching the $SAMPLE_KDOC_ANNOTATION link.", + Category.CORRECTNESS, 5, Severity.ERROR, + Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) + ) + + val INVALID_SAMPLES_LOCATION = Issue.create( + "EnforceSampledAnnotation", + "Invalid samples location", + "This function is annotated with @$SAMPLED_ANNOTATION, but is not inside a" + + "project/directory named $SAMPLES_DIRECTORY.", + Category.CORRECTNESS, 5, Severity.ERROR, + Implementation(SampledAnnotationDetector::class.java, Scope.JAVA_FILE_SCOPE) + ) + } + + /** + * Enforces that any @sample links in KDoc link to a function that is annotated with @Sampled + * + * Checks KDoc in all applicable UDeclarations - this includes classes, functions, fields... + * + * Also reports issues if there is no samples directory found, if there is no function matching + * a given @sample link, and if there are multiple functions matching a given @sample link. + */ + class KDocSampleLinkDetector : Detector(), SourceCodeScanner { + // Cache containing every function inside a project's corresponding samples dir + private var sampleFunctionCache: List<KtNamedFunction>? = null + + /** + * @return a list of all the functions found in the samples directory for this project, or + * `null` if there is no valid samples directory for this project + */ + internal fun getSampleFunctionCache(context: JavaContext): List<KtNamedFunction>? { + if (sampleFunctionCache == null) { + val sampleDirectory = findSampleDirectory(context) + sampleFunctionCache = if (sampleDirectory == null) { + null + } else { + val allKtFiles = sampleDirectory.getAllKtFiles() + // Remove any functions without a valid fully qualified name, this includes + // things such as overridden functions in anonymous classes like a Runnable + allKtFiles.flatMap { it.getAllFunctions() }.filter { + it.fqName != null + } + } + } + return sampleFunctionCache + } + + override fun getApplicableUastTypes(): List<Class<out UElement>>? = + listOf(UDeclaration::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler? = + KDocSampleLinkHandler(context) + + /** + * Clear caches before and after a project run, as they are only relevant per project + * + * Note: this isn't strictly needed, as normally a new detector will be instantiated per + * project, but if lint is set to run on dependencies, the same detector will end up + * being reused, and we can run into some caching issues. Safer just to always clear here as + * we really want to avoid false positives. + */ + override fun beforeCheckEachProject(context: Context) { + sampleFunctionCache = null + } + + override fun afterCheckEachProject(context: Context) { + sampleFunctionCache = null + } + + private inner class KDocSampleLinkHandler( + private val context: JavaContext + ) : UElementHandler() { + + // Cache containing all the KDoc elements we have found inside this Handler (created + // once per JavaContext - Java/Kotlin source file) + private val kdocCache = mutableListOf<KDoc>() + + override fun visitDeclaration(node: UDeclaration) { + node.findKdoc()?.let { kdoc -> + // It's possible for different declarations to point to the same KDoc - for + // example if a class has some KDoc, we will visit it once for the constructor + // function and once for the class itself. If we have seen this KDoc before + // just skip it so we don't report the issue multiple times + if (kdoc !in kdocCache) { + kdocCache.add(kdoc) + handleSampleLink(kdoc, node.namedUnwrappedElement!!.name!!) + } + } + } + + private fun handleSampleLink(kdoc: KDoc, sourceNodeName: String) { + val sections: List<KDocSection> = kdoc.children.mapNotNull { it as? KDocSection } + + // map of a KDocTag (which contains the location used when reporting issues) to the + // method link specified in @sample + val sampleTags = sections.flatMap { section -> + section.findTagsByName(SAMPLE_KDOC_ANNOTATION) + .mapNotNull { sampleTag -> + val linkText = sampleTag.getSubjectLink()?.getLinkText() + if (linkText == null) { + null + } else { + sampleTag to linkText + } + } + }.distinct() + + sampleTags.forEach { (docTag, link) -> + + val allFunctions = getSampleFunctionCache(context) + + if (allFunctions == null) { + context.report( + MISSING_SAMPLES_DIRECTORY, + docTag, + context.getNameLocation(docTag), + "Couldn't find a valid $SAMPLES_DIRECTORY directory in this project" + ) + return@forEach + } + + // We filtered out not-null fqNames when building the cache, so safe to !! + val matchingFunctions = allFunctions.filter { + link == it.fqName!!.asString() + } + + when (matchingFunctions.size) { + 0 -> { + context.report( + UNRESOLVED_SAMPLE_LINK, + docTag, + context.getNameLocation(docTag), + "Couldn't find a valid function matching $link" + ) + } + 1 -> { + val function = matchingFunctions.first() + if (!function.hasSampledAnnotation()) { + context.report( + MISSING_SAMPLED_ANNOTATION, + docTag, + context.getNameLocation(docTag), + "${function.name} is not annotated with @$SAMPLED_ANNOTATION" + + ", but is linked to from the KDoc of $sourceNodeName" + ) + } + } + else -> { + context.report( + MULTIPLE_FUNCTIONS_FOUND, + docTag, + context.getNameLocation(docTag), + "Found multiple functions matching $link" + ) + } + } + } + } + } + } + + /** + * Checks all functions annotated with @Sampled to ensure that they are linked from KDoc + * + * Also reports an issue if a function is annotated with @Sampled, and does not live in a + * valid samples directory/module. + */ + class SampledAnnotationDetector : Detector(), SourceCodeScanner { + // Cache containing every link referenced from a @sample tag inside the parent project + private var sampleLinkCache: List<String>? = null + + internal fun getSampleLinkCache(context: JavaContext): List<String> { + if (sampleLinkCache == null) { + sampleLinkCache = buildSampleLinkCache(context) + } + return sampleLinkCache!! + } + + override fun getApplicableUastTypes(): List<Class<out UElement>>? = + listOf(UMethod::class.java) + + override fun createUastHandler(context: JavaContext): UElementHandler? = + SampledAnnotationHandler(context) + + /** + * Clear caches before and after a project run, as they are only relevant per project + * + * Note: this isn't strictly needed, as normally a new detector will be instantiated per + * project, but if lint is set to run on dependencies, the same detector will end up + * being reused, and we can run into some caching issues. Safer just to always clear here as + * we really want to avoid false positives. + */ + override fun beforeCheckEachProject(context: Context) { + sampleLinkCache = null + } + + override fun afterCheckEachProject(context: Context) { + sampleLinkCache = null + } + + private inner class SampledAnnotationHandler( + private val context: JavaContext + ) : UElementHandler() { + + override fun visitMethod(node: UMethod) { + val element = (node.sourceElement as? KtDeclaration) ?: return + + if (element.annotationEntries.any { + it.shortName.safeNameForLazyResolve().identifier == SAMPLED_ANNOTATION + }) { + handleSampleCode(element, node) + } + } + + private fun handleSampleCode(function: KtDeclaration, node: UMethod) { + val currentPath = context.psiFile!!.virtualFile.path + + if (SAMPLES_DIRECTORY !in currentPath) { + context.report( + INVALID_SAMPLES_LOCATION, + node, + context.getNameLocation(node), + "${function.name} in $currentPath is annotated with @$SAMPLED_ANNOTATION" + + ", but is not inside a project/directory named $SAMPLES_DIRECTORY." + ) + return + } + + // The package name of the file we are in + val parentFqName = function.containingKtFile.packageFqName.asString() + // The full name of the current function that will be referenced in a @sample tag + val fullFqName = "$parentFqName.${function.name}" + + if (getSampleLinkCache(context).none { it == fullFqName }) { + context.report( + OBSOLETE_SAMPLED_ANNOTATION, + node, + context.getNameLocation(node), + "${function.name} is annotated with @$SAMPLED_ANNOTATION, but is not " + + "linked to from a @$SAMPLE_KDOC_ANNOTATION tag." + ) + } + } + } + + /** + * At this point we are inside some sample module, which is depending on a module that + * would end up referencing the sample + * + * For example, we could be in :foo:integration-tests:sample, and we want to find the + * path for module :foo + */ + private fun buildSampleLinkCache(context: JavaContext): List<String> { + val currentProjectPath = context.project.dir.absolutePath + + // The paths of every module the current module depends on + val dependenciesPathList = context.project.directLibraries.map { + it.dir.absolutePath + } + + // Try and find a common path, i.e if we are in a/b/foo/integration-tests/sample, we + // will match a/b/foo for the parent + var parentProjectPath = dependenciesPathList.find { + currentProjectPath.startsWith(it) + } + + // If we haven't found a path, it might be that we are on the same top level, i.e + // we are in a/b/foo/integration-tests/sample, and the module is in a/b/foo/foo-xyz + // Try matching with the parent directory of each module. + parentProjectPath = parentProjectPath ?: dependenciesPathList.find { + currentProjectPath.startsWith(File(it).parent) + } + + // There is no dependent module that exists above us, or alongside us, so throw + if (parentProjectPath == null) { + throw IllegalStateException("Couldn't find a parent project for " + + currentProjectPath + ) + } + + val parentProjectDirectory = navigateToDirectory(context, parentProjectPath) + + return parentProjectDirectory.getAllKtFiles().flatMap { file -> + file.findAllSampleLinks() + } + } + } +} + +/** + * @return the KDoc for the given section, or `null` if there is no corresponding KDoc + * + * This also filters out non-Kotlin declarations, so we don't bother looking for KDoc inside + * Java nodes for example. + */ +internal fun UDeclaration.findKdoc(): KDoc? { + // Unfortunate workaround as the KDoc cannot be returned from the node directly + // https://youtrack.jetbrains.com/issue/KT-22135 + val ktDeclaration = sourceElement as? KtDeclaration ?: return null + return findDocComment(ktDeclaration) +} + +/** + * @return whether this function is annotated with @Sampled + */ +internal fun KtNamedFunction.hasSampledAnnotation(): Boolean { + return modifierList?.annotationEntries?.any { annotation -> + annotation.shortName.safeNameForLazyResolve().identifier == SAMPLED_ANNOTATION + } ?: false +} + +/** + * @return a list of all sample links found recursively inside the element + */ +internal fun PsiElement.findAllSampleLinks(): List<String> { + val sampleLinks = mutableListOf<String>() + if (this is KDoc) { + val sections: List<KDocSection> = this.children.mapNotNull { it as? KDocSection } + + sections.forEach { section -> + section.findTagsByName(SAMPLE_KDOC_ANNOTATION).forEach { sampleTag -> + sampleTag.getSubjectLink()?.getLinkText()?.let { sampleLinks.add(it) } + } + } + } + children.forEach { sampleLinks.addAll(it.findAllSampleLinks()) } + return sampleLinks +} + +/** + * @return a list of all files found recursively inside the directory + */ +internal fun PsiDirectory.getAllKtFiles(): List<PsiFile> { + val psiFiles = mutableListOf<PsiFile>() + accept(object : KtTreeVisitorVoid() { + override fun visitFile(file: PsiFile?) { + psiFiles.addIfNotNull(file as? KtFile) + } + }) + return psiFiles +} + +/** + * @return a list of all functions found recursively inside the element + */ +internal fun PsiElement.getAllFunctions(): List<KtNamedFunction> { + val functions = mutableListOf<KtNamedFunction>() + accept(object : KtTreeVisitorVoid() { + override fun visitDeclaration(dcl: KtDeclaration) { + functions.addIfNotNull(dcl as? KtNamedFunction) + } + }) + return functions +} + +/** + * @return the samples directory if it exists, otherwise returns null + * + * The samples directory could either be a direct child of the current module, or a + * sibling directory + * + * For example, if we are in a/b/foo, the samples directory could either be: + * a/b/foo/.../samples + * a/b/.../samples + * + * For efficiency, first we look inside a/b/foo, and then if that fails we look + * inside a/b + */ +internal fun findSampleDirectory(context: JavaContext): PsiDirectory? { + val currentProjectPath = context.project.dir.absolutePath + val currentProjectDir = navigateToDirectory(context, currentProjectPath) + fun PsiDirectory.searchForSampleDirectory(): PsiDirectory? { + if (name == SAMPLES_DIRECTORY) { + return this + } + subdirectories.forEach { + val dir = it.searchForSampleDirectory() + if (dir != null) { + return dir + } + } + return null + } + + // Look inside a/b/foo + var sampleDir = currentProjectDir.searchForSampleDirectory() + + // Try looking inside /a/b + if (sampleDir == null) { + sampleDir = currentProjectDir.parent!!.searchForSampleDirectory() + } + + return sampleDir +} + +/** + * @return the directory with the given [path], using [context] to get the current filesystem + */ +internal fun navigateToDirectory(context: JavaContext, path: String): PsiDirectory { + val file = context.psiFile + ?: throw IllegalStateException("Not linting a source file") + val filesystem = file.virtualFile.fileSystem + val virtualFile = filesystem.findFileByPath(path) + return file.manager.findDirectory(virtualFile!!) + ?: throw IllegalStateException("Couldn't find directory for $path") +} diff --git a/buildSrc/lint-checks/src/test/java/androidx/build/lint/SampledAnnotationEnforcerTest.kt b/buildSrc/lint-checks/src/test/java/androidx/build/lint/SampledAnnotationEnforcerTest.kt new file mode 100644 index 00000000000..96af3d613eb --- /dev/null +++ b/buildSrc/lint-checks/src/test/java/androidx/build/lint/SampledAnnotationEnforcerTest.kt @@ -0,0 +1,530 @@ +/* + * Copyright 2019 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:Suppress("KDocUnresolvedReference") + +package androidx.build.lint + +import com.android.tools.lint.checks.infrastructure.ProjectDescription +import com.android.tools.lint.checks.infrastructure.TestFile +import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintResult +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import org.junit.runners.Parameterized.Parameter +import org.junit.runners.Parameterized.Parameters + +/** + * Test for [SampledAnnotationEnforcer] + * + * This tests (with Parameterized) the two following module setups: + * + * Module 'foo', which lives in foo + * Module 'foo:integration-tests:samples', which lives in foo/integration-tests/samples, + * and depends on 'foo' + * + * Module 'foo:foo', which lives in foo/foo + * Module 'foo:integration-tests:samples', which lives in foo/integration-tests/samples, + * and depends on 'foo:foo' + */ +@RunWith(Parameterized::class) +class SampledAnnotationEnforcerTest { + + companion object { + @JvmStatic + @Parameters(name = "sourceModule={0}") + fun moduleNames(): Array<String> { + return arrayOf("foo", "foo:foo") + } + } + + // At runtime this contains one of the values listed in moduleNames() + @Parameter lateinit var fooModuleName: String + + private val sampleModuleName = "foo:integration-tests:samples" + + // The path to Bar.kt changes depending on what module we are in + private val barFilePath by lazy { + val prefix = if (fooModuleName == moduleNames()[0]) "" else "foo:foo/" + prefix + "src/foo/Bar.kt" + } + + private val emptySampleFile = kotlin(""" + package foo.samples + """) + + private val unannotatedSampleFile = kotlin(""" + package foo.samples + + fun sampleBar() {} + """) + + private val multipleMatchingSampleFile = kotlin(""" + package foo.samples + + fun sampleBar() {} + + fun sampleBar() {} + """) + + private val correctlyAnnotatedSampleFile = kotlin(""" + package foo.samples + + @Sampled + fun sampleBar() {} + """) + + private fun checkKotlin( + fooFile: TestFile? = null, + sampleFile: TestFile? = null, + sampleModuleNameOverride: String? = null + ): TestLintResult { + val fooProject = ProjectDescription().apply { + name = fooModuleName + fooFile?.let { files = arrayOf(fooFile) } + } + val sampleProject = ProjectDescription().apply { + name = sampleModuleNameOverride ?: sampleModuleName + sampleFile?.let { files = arrayOf(sampleFile) } + dependsOn(fooProject) + } + return lint() + .projects(fooProject, sampleProject) + .allowMissingSdk(true) + .issues( + SampledAnnotationEnforcer.MISSING_SAMPLED_ANNOTATION, + SampledAnnotationEnforcer.OBSOLETE_SAMPLED_ANNOTATION, + SampledAnnotationEnforcer.MISSING_SAMPLES_DIRECTORY, + SampledAnnotationEnforcer.UNRESOLVED_SAMPLE_LINK, + SampledAnnotationEnforcer.MULTIPLE_FUNCTIONS_FOUND, + SampledAnnotationEnforcer.INVALID_SAMPLES_LOCATION + ) + .run() + } + + @Test + fun orphanedSampleFunction() { + val sampleFile = correctlyAnnotatedSampleFile + + val path = if (fooModuleName == moduleNames()[0]) { "" } else { "foo" } + + val expected = +"$path:integration-tests:samples/src/foo/samples/test.kt:5: Error: sampleBar is annotated with" + +""" @Sampled, but is not linked to from a @sample tag. [EnforceSampledAnnotation] + fun sampleBar() {} + ~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun invalidSampleLocation() { + val sampleFile = kotlin(""" + package foo.wrong.location + + @Sampled + fun sampleBar() {} + """) + + val path = if (fooModuleName == moduleNames()[0]) { "" } else { "foo" } + + val expected = +"$path:integration-tests:wrong-location/src/foo/wrong/location/test.kt:5: Error: sampleBar in " + +"/TESTROOT/foo:integration-tests:wrong-location/src/foo/wrong/location/test.kt is annotated " + +"""with @Sampled, but is not inside a project/directory named samples. [EnforceSampledAnnotation] + fun sampleBar() {} + ~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin( + sampleFile = sampleFile, + sampleModuleNameOverride = "foo:integration-tests:wrong-location" + ).expect(expected) + } + + @Test + fun missingSampleDirectory_Function() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + fun bar() {} + } + """) + + val expected = + "$barFilePath:6: Error: Couldn't find a valid samples directory in this project" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile) + .expect(expected) + } + + @Test + fun unresolvedSampleLink_Function() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + fun bar() {} + } + """) + + val sampleFile = emptySampleFile + + val expected = +"$barFilePath:6: Error: Couldn't find a valid function matching foo.samples.sampleBar" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun unannotatedSampleFunction_Function() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + fun bar() {} + } + """) + + val sampleFile = unannotatedSampleFile + + val path = if (fooModuleName == moduleNames()[0]) { "" } else { "foo:foo/" } + + val expected = +"${path}src/foo/Bar.kt:6: Error: sampleBar is not annotated with @Sampled, but is linked to from" + +""" the KDoc of bar [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun multipleMatchingSampleFunctions_Function() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + fun bar() {} + } + """) + + val sampleFile = multipleMatchingSampleFile + + val expected = +"$barFilePath:6: Error: Found multiple functions matching foo.samples.sampleBar" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun correctlyAnnotatedSampleFunction_Function() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + fun bar() {} + } + """) + + val sampleFile = correctlyAnnotatedSampleFile + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expectClean() + } + + @Test + fun missingSampleDirectory_Class() { + val fooFile = kotlin(""" + package foo + + /** + * @sample foo.samples.sampleBar + */ + class Bar {} + """) + + val expected = +"$barFilePath:5: Error: Couldn't find a valid samples directory in this project" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile) + .expect(expected) + } + + @Test + fun unresolvedSampleLink_Class() { + val fooFile = kotlin(""" + package foo + + /** + * @sample foo.samples.sampleBar + */ + class Bar {} + """) + + val sampleFile = emptySampleFile + + val expected = +"$barFilePath:5: Error: Couldn't find a valid function matching foo.samples.sampleBar" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun unannotatedSampleFunction_Class() { + val fooFile = kotlin(""" + package foo + + /** + * @sample foo.samples.sampleBar + */ + class Bar {} + """) + + val sampleFile = unannotatedSampleFile + + val expected = +"$barFilePath:5: Error: sampleBar is not annotated with @Sampled, but is linked to from" + +""" the KDoc of Bar [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun multipleMatchingSampleFunctions_Class() { + val fooFile = kotlin(""" + package foo + + /** + * @sample foo.samples.sampleBar + */ + class Bar {} + """) + + val sampleFile = multipleMatchingSampleFile + + val expected = +"$barFilePath:5: Error: Found multiple functions matching foo.samples.sampleBar" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun correctlyAnnotatedSampleFunction_Class() { + val fooFile = kotlin(""" + package foo + + /** + * @sample foo.samples.sampleBar + */ + class Bar {} + """) + + val sampleFile = correctlyAnnotatedSampleFile + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expectClean() + } + + @Test + fun missingSampleDirectory_Field() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + const val bar = 0 + } + """) + + val expected = +"$barFilePath:6: Error: Couldn't find a valid samples directory in this project" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile) + .expect(expected) + } + + @Test + fun unresolvedSampleLink_Field() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + const val bar = 0 + } + """) + + val sampleFile = emptySampleFile + + val expected = +"$barFilePath:6: Error: Couldn't find a valid function matching foo.samples.sampleBar" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun unannotatedSampleFunction_Field() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + const val bar = 0 + } + """) + + val sampleFile = unannotatedSampleFile + + val expected = +"$barFilePath:6: Error: sampleBar is not annotated with @Sampled, but is linked to from" + +""" the KDoc of bar [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun multipleMatchingSampleFunctions_Field() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + const val bar = 0 + } + """) + + val sampleFile = multipleMatchingSampleFile + + val expected = +"$barFilePath:6: Error: Found multiple functions matching foo.samples.sampleBar" + +""" [EnforceSampledAnnotation] + * @sample foo.samples.sampleBar + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +1 errors, 0 warnings + """ + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expect(expected) + } + + @Test + fun correctlyAnnotatedSampleFunction_Field() { + val fooFile = kotlin(""" + package foo + + class Bar { + /** + * @sample foo.samples.sampleBar + */ + const val bar = 0 + } + """) + + val sampleFile = correctlyAnnotatedSampleFile + + checkKotlin(fooFile = fooFile, sampleFile = sampleFile) + .expectClean() + } +} diff --git a/settings.gradle b/settings.gradle index bee0394f10d..0dbef5cc88d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -40,6 +40,7 @@ includeProject(":activity:activity-ktx", "activity/activity-ktx") includeProject(":activity:integration-tests:testapp", "activity/integration-tests/testapp") includeProject(":ads-identifier", "ads/ads-identifier") includeProject(":annotation", "annotations") +includeProject(":annotation:annotation-sampled", "annotation/annotation-sampled") includeProject(":animation", "animation") includeProject(":animation:testing", "animation/testing") includeProject(":animation:integration-tests:testapp", "animation/integration-tests/testapp") |