aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLouis Pullen-Freilich <lpf@google.com>2019-06-17 15:32:20 +0100
committerLouis Pullen-Freilich <lpf@google.com>2019-06-26 14:13:00 +0100
commitc4169031e74cb57d14da4c38a06f61ff67ad1e60 (patch)
tree7cc4cc2280335abf684e4e1dc718a9242c90629a
parentda18b8e358a305e4ae90edc548eb48927f037696 (diff)
downloadsupport-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
-rw-r--r--annotation/annotation-sampled/build.gradle35
-rw-r--r--annotation/annotation-sampled/src/main/java/androidx/annotation/Sampled.kt29
-rw-r--r--buildSrc/lint-checks/src/main/java/androidx/build/lint/AndroidXIssueRegistry.kt6
-rw-r--r--buildSrc/lint-checks/src/main/java/androidx/build/lint/SampledAnnotationEnforcer.kt530
-rw-r--r--buildSrc/lint-checks/src/test/java/androidx/build/lint/SampledAnnotationEnforcerTest.kt530
-rw-r--r--settings.gradle1
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")