diff --git a/compose-lint-checks/src/main/java/slack/lint/compose/ComposeLintsIssueRegistry.kt b/compose-lint-checks/src/main/java/slack/lint/compose/ComposeLintsIssueRegistry.kt index c6bc126a..b95d3769 100644 --- a/compose-lint-checks/src/main/java/slack/lint/compose/ComposeLintsIssueRegistry.kt +++ b/compose-lint-checks/src/main/java/slack/lint/compose/ComposeLintsIssueRegistry.kt @@ -31,6 +31,7 @@ class ComposeLintsIssueRegistry : IssueRegistry() { ModifierMissingDetector.ISSUE, ModifierReusedDetector.ISSUE, ModifierWithoutDefaultDetector.ISSUE, + M2ApiDetector.ISSUE, MultipleContentEmittersDetector.ISSUE, MutableParametersDetector.ISSUE, ParameterOrderDetector.ISSUE, diff --git a/compose-lint-checks/src/main/java/slack/lint/compose/M2ApiDetector.kt b/compose-lint-checks/src/main/java/slack/lint/compose/M2ApiDetector.kt new file mode 100644 index 00000000..326b4747 --- /dev/null +++ b/compose-lint-checks/src/main/java/slack/lint/compose/M2ApiDetector.kt @@ -0,0 +1,83 @@ +// Copyright (C) 2022 Salesforce, Inc. +// SPDX-License-Identifier: Apache-2.0 +package slack.lint.compose + +import com.android.tools.lint.client.api.UElementHandler +import com.android.tools.lint.detector.api.Category.Companion.CORRECTNESS +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Severity.IGNORE +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.StringOption +import com.android.tools.lint.detector.api.TextFormat +import com.intellij.psi.PsiNamedElement +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UQualifiedReferenceExpression +import org.jetbrains.uast.UResolvable +import slack.lint.compose.util.OptionLoadingDetector +import slack.lint.compose.util.Priorities.NORMAL +import slack.lint.compose.util.StringSetLintOption +import slack.lint.compose.util.sourceImplementation + +internal class M2ApiDetector +@JvmOverloads +constructor(private val allowList: StringSetLintOption = StringSetLintOption(ALLOW_LIST)) : + OptionLoadingDetector(allowList), SourceCodeScanner { + + companion object { + private const val M2Package = "androidx.compose.material" + + internal val ALLOW_LIST = + StringOption( + "allowed-m2-apis", + "A comma-separated list of APIs in androidx.compose.material that should be allowed.", + null, + "This property should define a comma-separated list of APIs in androidx.compose.material that should be allowed." + ) + + val ISSUE = + Issue.create( + id = "ComposeM2Api", + briefDescription = "Using a Compose M2 API is not recommended", + explanation = + """ + Compose Material 2 (M2) is succeeded by Material 3 (M3). Please use M3 APIs. + See https://slackhq.github.io/compose-lints/rules/#use-material-3 for more information. + """, + category = CORRECTNESS, + priority = NORMAL, + severity = IGNORE, + implementation = sourceImplementation() + ) + .setOptions(listOf(ALLOW_LIST)) + } + + override fun getApplicableUastTypes() = + listOf>( + UCallExpression::class.java, + UQualifiedReferenceExpression::class.java, + ) + + override fun createUastHandler(context: JavaContext) = + object : UElementHandler() { + override fun visitCallExpression(node: UCallExpression) = checkNode(node) + + override fun visitQualifiedReferenceExpression(node: UQualifiedReferenceExpression) = + checkNode(node) + + private fun checkNode(node: UResolvable) { + val resolved = node.resolve() ?: return + val packageName = context.evaluator.getPackage(resolved)?.qualifiedName ?: return + if (packageName == M2Package) { + // Ignore any in the allow-list. + if (resolved is PsiNamedElement && resolved.name in allowList.value) return + context.report( + issue = ISSUE, + location = context.getLocation(node), + message = ISSUE.getExplanation(TextFormat.TEXT), + ) + } + } + } +} diff --git a/compose-lint-checks/src/test/java/slack/lint/compose/M2ApiDetectorTest.kt b/compose-lint-checks/src/test/java/slack/lint/compose/M2ApiDetectorTest.kt new file mode 100644 index 00000000..2c261929 --- /dev/null +++ b/compose-lint-checks/src/test/java/slack/lint/compose/M2ApiDetectorTest.kt @@ -0,0 +1,121 @@ +// Copyright (C) 2023 Salesforce, Inc. +// SPDX-License-Identifier: Apache-2.0 +package slack.lint.compose + +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Issue +import org.junit.Test + +class M2ApiDetectorTest : BaseSlackLintTest() { + + override fun getDetector(): Detector = M2ApiDetector() + override fun getIssues(): List = listOf(M2ApiDetector.ISSUE) + + private val Stubs = + arrayOf( + kotlin( + """ + package androidx.compose.material + + import androidx.compose.runtime.Composable + + @Composable + fun Text(text: String) { + // no-op + } + + @Composable + fun Surface(content: @Composable () -> Unit) { + // no-op + } + + object BottomNavigationDefaults { + val Elevation = 8.dp + } + + enum class BottomDrawerValue { + Closed, + Open, + Expanded + } + """ + .trimIndent() + ), + kotlin( + """ + package androidx.compose.material.ripple + + import androidx.compose.runtime.Composable + + @Composable + fun rememberRipple() + """ + .trimIndent() + ), + ) + + @Test + fun smokeTest() { + lint() + .configureOption(M2ApiDetector.ALLOW_LIST, "Surface") + .files( + *Stubs, + kotlin( + """ + import androidx.compose.material.BottomDrawerValue + import androidx.compose.material.BottomNavigationDefaults + import androidx.compose.material.Text + import androidx.compose.material.ripple.rememberRipple + import androidx.compose.runtime.Composable + + @Composable + fun example() { + Text("Hello, world!") + } + + @Composable + fun allowedExample() { + Surface { + + } + } + + @Composable + fun composite() { + Surface { + val ripple = rememberRipple() + Text("Hello, world!") + val elevation = BottomNavigationDefaults.Elevation + val drawerValue = BottomDrawerValue.Closed + } + } + """ + ) + .indented() + ) + .allowCompilationErrors() + .run() + .expect( + """ + src/test.kt:9: Warning: Compose Material 2 (M2) is succeeded by Material 3 (M3). Please use M3 APIs. + See https://slackhq.github.io/compose-lints/rules/#use-material-3 for more information. [ComposeM2Api] + Text("Hello, world!") + ~~~~~~~~~~~~~~~~~~~~~ + src/test.kt:23: Warning: Compose Material 2 (M2) is succeeded by Material 3 (M3). Please use M3 APIs. + See https://slackhq.github.io/compose-lints/rules/#use-material-3 for more information. [ComposeM2Api] + Text("Hello, world!") + ~~~~~~~~~~~~~~~~~~~~~ + src/test.kt:24: Warning: Compose Material 2 (M2) is succeeded by Material 3 (M3). Please use M3 APIs. + See https://slackhq.github.io/compose-lints/rules/#use-material-3 for more information. [ComposeM2Api] + val elevation = BottomNavigationDefaults.Elevation + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + src/test.kt:25: Warning: Compose Material 2 (M2) is succeeded by Material 3 (M3). Please use M3 APIs. + See https://slackhq.github.io/compose-lints/rules/#use-material-3 for more information. [ComposeM2Api] + val drawerValue = BottomDrawerValue.Closed + ~~~~~~~~~~~~~~~~~~~~~~~~ + 0 errors, 4 warnings + """ + .trimIndent() + ) + } +} diff --git a/docs/rules.md b/docs/rules.md index 74eb2695..a5bc30b1 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -294,3 +294,39 @@ Composed modifiers may be created outside of composition, shared across elements More info: [Modifier extensions](https://developer.android.com/reference/kotlin/androidx/compose/ui/package-summary#extension-functions), [Composed modifiers in Jetpack Compose by Jorge Castillo](https://jorgecastillo.dev/composed-modifiers-in-jetpack-compose) and [Composed modifiers in API guidelines](https://github.com/androidx/androidx/blob/androidx-main/compose/docs/compose-api-guidelines.md#composed-modifiers) Related rule: [`ComposeComposableModifier`](https://github.com/slackhq/compose-lints/blob/main/compose-lint-checks/src/main/java/slack/lint/compose/ModifierComposableDetector.kt) + +### Use Material 3 + +Rule: [`ComposeM2Api`](https://github.com/slackhq/compose-lints/blob/main/compose-lint-checks/src/main/java/slack/lint/compose/M2ApiDetector.kt) + +Material 3 (M3) reached stable in October 2022. In apps that have migrated to M3, there may be `androidx.compose.material` (M2) APIs still remaining on the classpath from libraries or dependencies that can cause confusing imports due to the many similar or colliding Composable names in the two libraries. The `ComposeM2Api` rule can prevent these from being used. + +!!! warning "Lint Configuration" + This rule is set to `IGNORE` by default and is **opt-in**. You can enable and make it an error via `lint` in Gradle. + ```kotlin + android { + lint { + enable += "ComposeM2Api" + error += "ComposeM2Api" + } + } + ``` + More lint configuration docs can be found [here](https://developer.android.com/studio/write/lint#gradle). + +!!! note "Allow-list Configuration" + To allow certain APIs (i.e. for incremental migration), you can configure a `allowed-m2-apis` option in `lint.xml`. + ```xml + + + ``` + +**Related docs links** + +- Announcement post: https://material.io/blog/material-3-compose-stable +- Docs: https://m3.material.io/develop/android/jetpack-compose +- Migration guide: https://developer.android.com/jetpack/compose/themes/material2-material3 +- Guidance: https://developer.android.com/jetpack/compose/themes/material3 +- Reply (primary sample app): https://github.com/android/compose-samples/tree/main/Reply +- More samples: https://github.com/android/compose-samples +