├── renovate.json ├── spotless ├── spotless.kt └── spotless-external.kt ├── docs ├── images │ ├── slack_logo.png │ └── slack_logo_small.png └── index.md ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── RELEASING.md ├── CODEOWNERS ├── .github ├── workflows │ ├── scriptUtil.sh │ ├── renovate.json │ ├── mkdocs-requirements.txt │ ├── renovate.yml │ ├── increment_version.sh │ ├── publish-docs.yml │ └── ci.yml ├── PULL_REQUEST_TEMPLATE.md ├── CODE_OF_CONDUCT.md └── CONTRIBUTING.md ├── compose-lint-checks ├── gradle.properties ├── src │ ├── main │ │ └── java │ │ │ └── slack │ │ │ └── lint │ │ │ └── compose │ │ │ ├── util │ │ │ ├── KtAnnotateds.kt │ │ │ ├── Priorities.kt │ │ │ ├── LintOption.kt │ │ │ ├── StringSetLintOption.kt │ │ │ ├── Types.kt │ │ │ ├── OptionLoadingDetector.kt │ │ │ ├── KotlinUtils.kt │ │ │ ├── ASTNodes.kt │ │ │ ├── KtFunctions.kt │ │ │ ├── Previews.kt │ │ │ ├── LintUtils.kt │ │ │ ├── PsiElements.kt │ │ │ ├── KtCallableDeclarations.kt │ │ │ ├── Stability.kt │ │ │ └── Composables.kt │ │ │ ├── ContentEmitterLintOption.kt │ │ │ ├── ComposeLintsIssueRegistry.kt │ │ │ ├── ComposableFunctionDetector.kt │ │ │ ├── MutableParametersDetector.kt │ │ │ ├── ModifierComposedDetector.kt │ │ │ ├── UnstableCollectionsDetector.kt │ │ │ ├── PreviewNamingDetector.kt │ │ │ ├── ModifierWithoutDefaultDetector.kt │ │ │ ├── PreviewPublicDetector.kt │ │ │ ├── RememberMissingDetector.kt │ │ │ ├── ViewModelInjectionDetector.kt │ │ │ ├── ViewModelForwardingDetector.kt │ │ │ ├── UnstableReceiverDetector.kt │ │ │ ├── ModifierMissingDetector.kt │ │ │ ├── SlotReusedDetector.kt │ │ │ ├── ComposableFunctionNamingDetector.kt │ │ │ ├── M2ApiDetector.kt │ │ │ ├── CompositionLocalUsageDetector.kt │ │ │ ├── ParameterOrderDetector.kt │ │ │ └── ContentEmitterReturningValuesDetector.kt │ └── test │ │ └── java │ │ └── slack │ │ └── lint │ │ └── compose │ │ ├── ComposeLintsIssueRegistryTest.kt │ │ ├── ModifierComposedDetectorTest.kt │ │ ├── ViewModelForwardingDetectorTest.kt │ │ ├── BaseComposeLintTest.kt │ │ ├── MutableParametersDetectorTest.kt │ │ ├── PreviewNamingDetectorTest.kt │ │ ├── UnstableCollectionsDetectorTest.kt │ │ ├── CompositionLocalUsageDetectorTest.kt │ │ ├── M2ApiDetectorTest.kt │ │ ├── ModifierWithoutDefaultDetectorTest.kt │ │ ├── UnstableReceiverDetectorTest.kt │ │ ├── ViewModelInjectionDetectorTest.kt │ │ ├── ContentEmitterReturningValuesDetectorTest.kt │ │ ├── RememberMissingDetectorTest.kt │ │ └── PreviewPublicDetectorTest.kt └── build.gradle.kts ├── .gitignore ├── README.md ├── release.sh ├── settings.gradle.kts ├── gradle.properties ├── deploy_website.sh ├── mkdocs.yml ├── gradlew.bat └── CHANGELOG.md /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json" 3 | } 4 | -------------------------------------------------------------------------------- /spotless/spotless.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) $YEAR Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 -------------------------------------------------------------------------------- /docs/images/slack_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slackhq/compose-lints/HEAD/docs/images/slack_logo.png -------------------------------------------------------------------------------- /docs/images/slack_logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slackhq/compose-lints/HEAD/docs/images/slack_logo_small.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/slackhq/compose-lints/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /spotless/spotless-external.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) $YEAR Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | Releasing 2 | ========= 3 | 4 | 1. Update the `CHANGELOG.md` for the impending release. 5 | 2. Run `./release.sh `. 6 | 3. Publish the release on the repo's releases tab. 7 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Comment line immediately above ownership line is reserved for related other information. Please be careful while editing. 2 | #ECCN:Open Source 3 | #GUSINFO:Open Source,Open Source Workflow 4 | -------------------------------------------------------------------------------- /.github/workflows/scriptUtil.sh: -------------------------------------------------------------------------------- 1 | # Source this file to use its functions 2 | 3 | # Gets a property out of a .properties file 4 | # usage: getProperty $key $filename 5 | function getProperty() { 6 | grep "${1}" "$2" | cut -d'=' -f2 7 | } 8 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /compose-lint-checks/gradle.properties: -------------------------------------------------------------------------------- 1 | POM_ARTIFACT_ID=compose-lint-checks 2 | POM_NAME=Compose Lint Checks 3 | POM_DESCRIPTION=Compose Lint Checks 4 | 5 | # Opt-out flag for bundling Kotlin standard library because Lint forces its own version 6 | # See https://plugins.jetbrains.com/docs/intellij/using-kotlin.html#kotlin-standard-library 7 | kotlin.stdlib.default.dependency=false 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/KtAnnotateds.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import org.jetbrains.uast.UAnnotated 7 | 8 | val UAnnotated.isComposable: Boolean 9 | get() = findAnnotation("androidx.compose.runtime.Composable") != null 10 | -------------------------------------------------------------------------------- /.github/workflows/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ], 5 | "branchPrefix": "renovate/", 6 | "gitAuthor": "OSS-Bot ", 7 | "repositories": [ 8 | "slackhq/compose-lints" 9 | ], 10 | "prHourlyLimit": 20, 11 | "packageRules": [ 12 | { 13 | "matchPackageNames": ["renovatebot/github-action"], 14 | "extends": ["schedule:monthly"] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /.github/workflows/mkdocs-requirements.txt: -------------------------------------------------------------------------------- 1 | click==8.2.1 2 | future==1.0.0 3 | Jinja2==3.1.6 4 | livereload==2.7.1 5 | lunr==0.8.0 6 | Markdown==3.7 7 | MarkupSafe==3.0.2 8 | mkdocs==1.6.1 9 | mkdocs-macros-plugin==1.3.7 10 | mkdocs-material==9.6.12 11 | mkdocs-material-extensions==1.3.1 12 | Pygments==2.18.0 13 | pymdown-extensions==10.12 14 | python-dateutil==2.9.0.post0 15 | PyYAML==6.0.2 16 | repackage==0.7.3 17 | six==1.17.0 18 | termcolor==2.5.0 19 | tornado==6.4.2 -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/Priorities.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose.util 4 | 5 | /** 6 | * Priorities with semantic names. Partially for readability, partially so detekt stops nagging 7 | * about MagicNumber. 8 | */ 9 | object Priorities { 10 | const val HIGH = 10 11 | const val NORMAL = 5 12 | const val LOW = 3 13 | const val NONE = 1 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yml: -------------------------------------------------------------------------------- 1 | name: Renovate 2 | 3 | on: 4 | schedule: 5 | - cron: "0 8 * * *" # 8am daily 6 | workflow_dispatch: 7 | 8 | jobs: 9 | renovate: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: Self-hosted Renovate 16 | uses: renovatebot/github-action@v40.2.6 17 | with: 18 | configurationFile: .github/workflows/renovate.json 19 | token: ${{ secrets.SLACKHQ_MBR_GITHUB_TOKEN }} -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/LintOption.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose.util 4 | 5 | import com.android.tools.lint.client.api.Configuration 6 | import com.android.tools.lint.detector.api.Issue 7 | 8 | /** 9 | * A layer of indirection for implementations of option loaders without needing to extend from 10 | * Detector. This goes along with [OptionLoadingDetector]. 11 | */ 12 | interface LintOption { 13 | fun load(configuration: Configuration, issue: Issue) 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gradle 2 | .kotlin/ 3 | local.properties 4 | .fleet 5 | .idea 6 | !/.idea/codeStyles 7 | !/.idea/inspectionProfiles 8 | !/.idea/icon.png 9 | *.iml 10 | *.so 11 | .DS_Store 12 | build 13 | captures 14 | version.properties 15 | env 16 | node_modules 17 | reports 18 | .cxx 19 | 20 | # IDE-generated dir 21 | out/ 22 | 23 | # We have one gitignore but project generation often generates extra ones. Ignore those 24 | */**/.gitignore 25 | 26 | # Docs-related hings 27 | docs/code-of-conduct.md 28 | docs/contributing.md 29 | docs/changelog.md 30 | docs/api/ 31 | site/ 32 | temp-clone/ -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/StringSetLintOption.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose.util 4 | 5 | import com.android.tools.lint.client.api.Configuration 6 | import com.android.tools.lint.detector.api.Issue 7 | import com.android.tools.lint.detector.api.StringOption 8 | 9 | open class StringSetLintOption(private val option: StringOption) : LintOption { 10 | var value: Set = emptySet() 11 | private set 12 | 13 | override fun load(configuration: Configuration, issue: Issue) { 14 | value = option.loadAsSet(configuration, issue) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | compose-lints 2 | =========== 3 | 4 | ### [slackhq.github.io/compose-lints](https://slackhq.github.io/compose-lints) 5 | 6 | License 7 | -------- 8 | 9 | Copyright 2023 Salesforce, Inc. 10 | Copyright 2022 Twitter, Inc. 11 | 12 | Licensed under the Apache License, Version 2.0 (the "License"); 13 | you may not use this file except in compliance with the License. 14 | You may obtain a copy of the License at 15 | 16 | http://www.apache.org/licenses/LICENSE-2.0 17 | 18 | Unless required by applicable law or agreed to in writing, software 19 | distributed under the License is distributed on an "AS IS" BASIS, 20 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 21 | See the License for the specific language governing permissions and 22 | limitations under the License. 23 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/Types.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2024 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose.util 4 | 5 | import com.intellij.psi.PsiClass 6 | 7 | val PsiClass.isFunctionalInterface: Boolean 8 | get() { 9 | return hasAnnotation("java.lang.FunctionalInterface") || 10 | qualifiedName == "kotlin.Function" || 11 | qualifiedName?.startsWith("kotlin.jvm.functions.") == true 12 | } 13 | 14 | val PsiClass.allSupertypes: Sequence 15 | get() { 16 | return sequenceOf(this) + 17 | superTypes 18 | .asSequence() 19 | .distinct() 20 | .mapNotNull { it.resolve() } 21 | .filterNot { it.qualifiedName == "java.lang.Object" } 22 | .flatMap { resolved -> sequenceOf(resolved).plus(resolved.allSupertypes) } 23 | .distinct() 24 | } 25 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/OptionLoadingDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose.util 4 | 5 | import com.android.tools.lint.detector.api.Context 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Issue 8 | 9 | /** A [Detector] that supports reading the given [options]. */ 10 | abstract class OptionLoadingDetector(private val options: List>) : 11 | Detector() { 12 | 13 | constructor(vararg options: Pair) : this(options.toList()) 14 | 15 | override fun beforeCheckRootProject(context: Context) { 16 | super.beforeCheckRootProject(context) 17 | val config = context.findConfiguration(context.file) 18 | options.forEach { (option, issue) -> option.load(config, issue) } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -exo pipefail 4 | 5 | # Gets a property out of a .properties file 6 | # usage: getProperty $key $filename 7 | function getProperty() { 8 | grep "${1}" "$2" | cut -d'=' -f2 9 | } 10 | 11 | NEW_VERSION=$1 12 | SNAPSHOT_VERSION=$(getProperty 'VERSION_NAME' gradle.properties) 13 | 14 | echo "Publishing $NEW_VERSION" 15 | 16 | # Prepare release 17 | sed -i '' "s/${SNAPSHOT_VERSION}/${NEW_VERSION}/g" gradle.properties 18 | git commit -am "Prepare for release $NEW_VERSION." 19 | git tag -a "$NEW_VERSION" -m "Version $NEW_VERSION" 20 | 21 | # Publish 22 | ./gradlew publish --no-configuration-cache 23 | 24 | # Prepare next snapshot 25 | echo "Restoring snapshot version $SNAPSHOT_VERSION" 26 | sed -i '' "s/${NEW_VERSION}/${SNAPSHOT_VERSION}/g" gradle.properties 27 | git commit -am "Prepare next development version." 28 | 29 | # Push it all up 30 | git push && git push --tags -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import java.util.Locale 4 | 5 | pluginManagement { 6 | repositories { 7 | mavenCentral() 8 | google() 9 | // Last because this proxies jcenter! 10 | gradlePluginPortal() 11 | } 12 | } 13 | 14 | dependencyResolutionManagement { 15 | versionCatalogs { 16 | if (System.getenv("DEP_OVERRIDES") == "true") { 17 | val overrides = System.getenv().filterKeys { it.startsWith("DEP_OVERRIDE_") } 18 | maybeCreate("libs").apply { 19 | for ((key, value) in overrides) { 20 | val catalogKey = key.removePrefix("DEP_OVERRIDE_").lowercase(Locale.US) 21 | println("Overriding $catalogKey with $value") 22 | version(catalogKey, value) 23 | } 24 | } 25 | } 26 | } 27 | repositories { 28 | google() 29 | mavenCentral() 30 | } 31 | } 32 | 33 | rootProject.name = "compose-lints" 34 | 35 | include(":compose-lint-checks") 36 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Introduction 4 | 5 | Diversity and inclusion make our community strong. We encourage participation from the most varied and diverse backgrounds possible and want to be very clear about where we stand. 6 | 7 | Our goal is to maintain a safe, helpful and friendly community for everyone, regardless of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other defining characteristic. 8 | 9 | This code and related procedures also apply to unacceptable behavior occurring outside the scope of community activities, in all community venues (online and in-person) as well as in all one-on-one communications, and anywhere such behavior has the potential to adversely affect the safety and well-being of community members. 10 | 11 | For more information on our code of conduct, please visit [https://slackhq.github.io/code-of-conduct](https://slackhq.github.io/code-of-conduct) 12 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/ComposeLintsIssueRegistryTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.detector.api.Detector 6 | import com.android.tools.lint.detector.api.Issue 7 | import org.junit.Test 8 | 9 | class ComposeLintsIssueRegistryTest : BaseComposeLintTest() { 10 | @Test 11 | fun ensureUniqueIds() { 12 | val issues = ComposeLintsIssueRegistry().issues 13 | if (issues.distinctBy { it.id }.size != issues.size) { 14 | fail( 15 | "Duplicate issue IDs found!\n${issues.groupBy { it.id }.filter { it.value.size > 1 }.entries.joinToString("\n") { (key, value) -> "${key}=${value.map { it.implementation.detectorClass.simpleName }}" } }" 16 | ) 17 | } 18 | } 19 | 20 | override fun getDetector(): Detector { 21 | throw NotImplementedError() 22 | } 23 | 24 | override fun getIssues(): List { 25 | throw NotImplementedError() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/KotlinUtils.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import java.util.Locale 7 | 8 | fun T.runIf(value: Boolean, block: T.() -> T): T = if (value) block() else this 9 | 10 | fun String?.matchesAnyOf(patterns: Sequence): Boolean { 11 | if (isNullOrEmpty()) return false 12 | for (regex in patterns) { 13 | if (matches(regex)) return true 14 | } 15 | return false 16 | } 17 | 18 | fun String.toCamelCase() = 19 | split('_') 20 | .joinToString( 21 | separator = "", 22 | transform = { original -> 23 | original.replaceFirstChar { 24 | if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() 25 | } 26 | }, 27 | ) 28 | 29 | fun String.toSnakeCase() = replace(humps, "_").lowercase(Locale.getDefault()) 30 | 31 | private val humps by lazy(LazyThreadSafetyMode.NONE) { "(?<=.)(?=\\p{Upper})".toRegex() } 32 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ContentEmitterLintOption.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.detector.api.StringOption 6 | import slack.lint.compose.util.StringSetLintOption 7 | 8 | class ContentEmitterLintOption(option: StringOption) : StringSetLintOption(option) { 9 | companion object { 10 | /** 11 | * We reuse the content-emitters option in lint but it has this annoying behavior where options 12 | * can _not_ be reused across lints. This includes not only declarations, but also even `Option` 13 | * instances themselves because they're mutable and associated with specific `Issue` instances! 14 | */ 15 | fun newOption(): StringOption { 16 | return StringOption( 17 | "content-emitters", 18 | "A comma-separated list of known content-emitting composables", 19 | null, 20 | "This property should define a comma-separated list of known content-emitting composables.", 21 | ) 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/increment_version.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Target project to update, required 4 | TARGET=$1 5 | # Version to increment to, optional. If blank or not specified, will auto-increment. 6 | REQUESTED_VERSION=$2 7 | source .github/workflows/scriptUtil.sh 8 | # Parse the current version, strip leading zeros, increment 9 | CURRENT_VERSION=$(getProperty 'VERSION_NAME' "$TARGET"/gradle.properties) 10 | 11 | # Export the coordinates while we're at it for later use in publish.yml 12 | # Group is always in our root dir 13 | GROUP=$(getProperty 'GROUP' gradle.properties) 14 | ARTIFACT=$(getProperty 'POM_ARTIFACT_ID' "$TARGET"/gradle.properties) 15 | 16 | STRIPPED_CURRENT=$(echo "$CURRENT_VERSION" | sed 's/^0*//') 17 | if [[ "$REQUESTED_VERSION" != "" ]]; then 18 | NEW_VERSION=$REQUESTED_VERSION 19 | else 20 | ((STRIPPED_CURRENT++)) 21 | NEW_VERSION=$(printf "%05d\n" $STRIPPED_CURRENT) 22 | fi 23 | sed -i -e "s/${CURRENT_VERSION}/${NEW_VERSION}/g" "$TARGET"/gradle.properties 24 | echo "current: $CURRENT_VERSION" 25 | echo "CURRENT_VERSION=$CURRENT_VERSION" >> $GITHUB_ENV 26 | echo "new: $NEW_VERSION" 27 | echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_ENV 28 | # We just use the artifact ID in the android repo for the coordinate 29 | echo "COORDINATES=$ARTIFACT" >> $GITHUB_ENV 30 | -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish docs 2 | 3 | on: 4 | # Run on new version tags... 5 | push: 6 | tags: 7 | - v* 8 | # or manually from workflow dispatch (from GitHub UI) 9 | workflow_dispatch: 10 | 11 | jobs: 12 | deploy_docs: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install JDK 19 | uses: actions/setup-java@v4 20 | with: 21 | distribution: 'zulu' 22 | java-version: '21' 23 | 24 | - name: Setup Gradle 25 | uses: gradle/actions/setup-gradle@v4 26 | 27 | - name: Build Dokka API docs 28 | id: gradle 29 | run: ./gradlew dokkaHtml --no-configuration-cache 30 | 31 | - name: Setup Python 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: '3.x' 35 | 36 | - name: Install python dependencies 37 | run: | 38 | python3 -m pip install --upgrade pip 39 | python3 -m pip install -r .github/workflows/mkdocs-requirements.txt 40 | 41 | - name: Build site 42 | run: ./deploy_website.sh --ci 43 | 44 | - name: Deploy site 45 | uses: peaceiris/actions-gh-pages@v4 46 | with: 47 | github_token: ${{ secrets.GITHUB_TOKEN }} 48 | publish_dir: ./site 49 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | kotlin = "2.0.21" 3 | ktfmt = "0.56" 4 | jdk = "21" 5 | jvmTarget = "17" 6 | lint = "31.9.2" 7 | lint-latest = "31.8.0-alpha07" 8 | 9 | [plugins] 10 | detekt = { id = "io.gitlab.arturbosch.detekt", version = "1.23.8" } 11 | dokka = { id = "org.jetbrains.dokka", version = "2.0.0-Beta" } 12 | lint = { id = "com.android.lint", version = "8.11.1" } 13 | kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 14 | ksp = { id = "com.google.devtools.ksp", version = "2.0.21-1.0.27" } 15 | mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.30.0" } 16 | spotless = { id = "com.diffplug.spotless", version = "7.1.0" } 17 | 18 | [libraries] 19 | autoService-annotations = "com.google.auto.service:auto-service-annotations:1.1.1" 20 | autoService-ksp = "dev.zacsweers.autoservice:auto-service-ksp:1.2.0" 21 | junit = "junit:junit:4.13.2" 22 | ktfmt = { module = "com.facebook:ktfmt", version.ref = "ktfmt" } 23 | lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lint" } 24 | lint = { module = "com.android.tools.lint:lint", version.ref = "lint-latest" } 25 | lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint-latest" } 26 | lint-testUtils = { module = "com.android.tools:testutils", version.ref = "lint-latest" } 27 | 28 | [bundles] 29 | lintTest = ["lint", "lint-tests", "lint-testUtils"] 30 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xms1g -Xmx4g -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1g 2 | 3 | org.gradle.parallel=true 4 | org.gradle.caching=true 5 | org.gradle.configureondemand=true 6 | org.gradle.configuration-cache=true 7 | 8 | # Suppress warnings about experimental AGP properties we're using 9 | # Ironically, this property itself is also experimental, so we have to suppress it too. 10 | android.suppressUnsupportedOptionWarnings=android.suppressUnsupportedOptionWarnings,\ 11 | android.experimental.lint.missingBaselineIsEmptyBaseline,\ 12 | android.lint.useK2Uast 13 | 14 | android.experimental.lint.missingBaselineIsEmptyBaseline=true 15 | 16 | android.lint.useK2Uast=true 17 | 18 | # https://github.com/google/ksp/issues/1839 19 | ksp.useKSP2=false 20 | 21 | # Dokka flags 22 | org.jetbrains.dokka.experimental.gradle.pluginMode=V2Enabled 23 | org.jetbrains.dokka.experimental.gradle.pluginMode.noWarn=true 24 | 25 | # Versioning bits 26 | GROUP=com.slack.lint.compose 27 | POM_URL=https://github.com/slackhq/compose-lints/ 28 | POM_SCM_URL=https://github.com/slackhq/compose-lints/ 29 | POM_SCM_CONNECTION=scm:git:git://github.com/slackhq/compose-lints.git 30 | POM_SCM_DEV_CONNECTION=scm:git:ssh://git@github.com/slackhq/compose-lints.git 31 | POM_LICENCE_DIST=repo 32 | POM_DEVELOPER_ID=slackhq 33 | POM_DEVELOPER_NAME=Salesforce, Inc. 34 | POM_DEVELOPER_URL=https://github.com/slackhq 35 | POM_INCEPTION_YEAR=2023 36 | VERSION_NAME=1.5.0-SNAPSHOT 37 | -------------------------------------------------------------------------------- /deploy_website.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # The website is built using MkDocs with the Material theme. 4 | # https://squidfunk.github.io/mkdocs-material/ 5 | # It requires Python to run. 6 | # Install the packages with the following command: 7 | # python3 -m pip install -r .github/workflows/mkdocs-requirements.txt 8 | 9 | if [[ "$1" = "--local" ]]; then 10 | local=true 11 | elif [[ "$1" = "--ci" ]]; then 12 | ci=true 13 | fi 14 | 15 | if ! [[ ${local} || ${ci} ]]; then 16 | set -ex 17 | REPO="git@github.com:slackhq/compose-lints.git" 18 | DIR=temp-clone 19 | # Delete any existing temporary website clone 20 | rm -rf ${DIR} 21 | # Clone the current repo into temp folder 22 | git clone ${REPO} ${DIR} 23 | # Move working directory into temp folder 24 | cd ${DIR} 25 | # Generate the API docs 26 | ./gradlew :dokkaGenerate 27 | fi 28 | 29 | # Copy in special files that GitHub wants in the project root. 30 | cp CHANGELOG.md docs/changelog.md 31 | cp .github/CONTRIBUTING.md docs/contributing.md 32 | cp .github/CODE_OF_CONDUCT.md docs/code-of-conduct.md 33 | 34 | # Build the site and push the new files up to GitHub 35 | if [[ ${local} ]]; then 36 | # For local dev, just serve to localhost 37 | mkdocs serve 38 | elif [[ ${ci} ]]; then 39 | # For CI we just build the site. It deploys using a GitHub Action 40 | mkdocs build -d site 41 | else 42 | # Otherwise we deploy using mkdocs 43 | mkdocs gh-deploy 44 | fi 45 | 46 | # Delete our temp folder 47 | if ! [[ ${local} || ${ci} ]]; then 48 | cd .. 49 | rm -rf ${DIR} 50 | fi -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/ASTNodes.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import com.intellij.lang.ASTNode 7 | import com.intellij.psi.PsiComment 8 | import org.jetbrains.kotlin.lexer.KtTokens 9 | 10 | fun ASTNode.lastChildLeafOrSelf(): ASTNode { 11 | var node = this 12 | if (node.lastChildNode != null) { 13 | do { 14 | node = node.lastChildNode 15 | } while (node.lastChildNode != null) 16 | return node 17 | } 18 | return node 19 | } 20 | 21 | fun ASTNode.firstChildLeafOrSelf(): ASTNode { 22 | var node = this 23 | if (node.firstChildNode != null) { 24 | do { 25 | node = node.firstChildNode 26 | } while (node.firstChildNode != null) 27 | return node 28 | } 29 | return node 30 | } 31 | 32 | fun ASTNode.parent(p: (ASTNode) -> Boolean, strict: Boolean = true): ASTNode? { 33 | var n: ASTNode? = if (strict) this.treeParent else this 34 | while (n != null) { 35 | if (p(n)) { 36 | return n 37 | } 38 | n = n.treeParent 39 | } 40 | return null 41 | } 42 | 43 | fun ASTNode.isPartOfComment(): Boolean = parent({ it.psi is PsiComment }, strict = false) != null 44 | 45 | fun ASTNode.nextCodeSibling(): ASTNode? = nextSibling { 46 | it.elementType != KtTokens.WHITE_SPACE && !it.isPartOfComment() 47 | } 48 | 49 | inline fun ASTNode.nextSibling(p: (ASTNode) -> Boolean): ASTNode? { 50 | var node = treeNext 51 | while (node != null) { 52 | if (p(node)) { 53 | return node 54 | } 55 | node = node.treeNext 56 | } 57 | return null 58 | } 59 | -------------------------------------------------------------------------------- /compose-lint-checks/build.gradle.kts: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | import org.jetbrains.kotlin.gradle.dsl.KotlinVersion 4 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile 5 | 6 | plugins { 7 | alias(libs.plugins.kotlin.jvm) 8 | // Run lint on the lints! https://groups.google.com/g/lint-dev/c/q_TVEe85dgc 9 | alias(libs.plugins.lint) 10 | alias(libs.plugins.ksp) 11 | alias(libs.plugins.mavenPublish) 12 | } 13 | 14 | lint { 15 | htmlReport = true 16 | xmlReport = true 17 | textReport = true 18 | absolutePaths = false 19 | checkTestSources = true 20 | baseline = file("lint-baseline.xml") 21 | disable += setOf("GradleDependency") 22 | fatal += setOf("LintDocExample", "LintImplPsiEquals", "UastImplementation") 23 | } 24 | 25 | tasks.test { 26 | // Disable noisy java applications launching during tests 27 | jvmArgs("-Djava.awt.headless=true") 28 | maxParallelForks = Runtime.getRuntime().availableProcessors() * 2 29 | } 30 | 31 | dependencies { 32 | compileOnly(libs.lint.api) 33 | ksp(libs.autoService.ksp) 34 | implementation(libs.autoService.annotations) 35 | testImplementation(libs.bundles.lintTest) 36 | testImplementation(libs.junit) 37 | } 38 | 39 | val kgpKotlinVersion = KotlinVersion.KOTLIN_1_9 40 | 41 | tasks.withType().configureEach { 42 | compilerOptions { 43 | // Lint forces Kotlin (regardless of what version the project uses), so this 44 | // forces a matching language level for now. Similar to `targetCompatibility` for Java. 45 | // This should match the value in LintKotlinVersionCheckTest.kt 46 | apiVersion.set(kgpKotlinVersion) 47 | languageVersion.set(kgpKotlinVersion) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributors Guide 2 | 3 | ## Development 4 | 5 | Check out this repo with Android Studio or IntelliJ. It's a standard gradle project and 6 | conventional to check out. 7 | 8 | The primary project is `compose-lint-checks`. 9 | 10 | Kotlin should be used for more idiomatic use with lint APIs. 11 | 12 | ## Lint Documentation 13 | 14 | [The Android Lint API Guide](https://googlesamples.github.io/android-custom-lint-rules/book.html) provides an excellent overview of lint's purpose, how it works, and how to author custom checks. 15 | 16 | ## Lint Guidelines 17 | - Limited scopes. Remember this will run in a slow build step or during the IDE, performance matters! 18 | - If your check only matters for java or kotlin, only run on appropriate files 19 | - Use the smallest necessary scope. Avoid tree walking through the AST if it can be avoided, there 20 | are usually more appropriate hooks. 21 | - Use `UElementHandler` (via overriding `createUastHandler()`) rather than overriding `Detector` 22 | callback methods. `Detector` callback methods tend only to be useful for tricky scenarios, like 23 | annotated elements. For basic `UElement` types it's best to just use `UElementHandler` as it affords 24 | a standard API and is easy to conditionally avoid nested parsing. 25 | - For testing, prefer writing source stubs directly in the test rather than extract individual files 26 | in `resources` for stubs. Stubs in resources add friction for source glancing and tedious to 27 | maintain, and should only be used for extremely complex source files. 28 | - Use our `implementation<*Detector>()` helper functions for wiring your `Issue` information. This 29 | is important because it will help ensure your check works in both command line and in the IDE. 30 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | # pip install mkdocs mkdocs-material 2 | # mkdocs serve 3 | # mkdocs gh-deploy 4 | 5 | site_name: compose-lints 6 | repo_name: compose-lints 7 | repo_url: https://github.com/slackhq/compose-lints 8 | site_description: "Lint checks to aid with a healthy adoption of Compose" 9 | site_author: Slack 10 | remote_branch: gh-pages 11 | 12 | copyright: 'Copyright © 2023 Salesforce, Inc.' 13 | 14 | theme: 15 | name: 'material' 16 | favicon: images/slack_logo_small.png 17 | logo: images/slack_logo.png 18 | palette: 19 | - media: '(prefers-color-scheme: light)' 20 | scheme: default 21 | primary: 'white' 22 | accent: 'green' 23 | toggle: 24 | icon: material/brightness-7 25 | name: Switch to dark mode 26 | - media: '(prefers-color-scheme: dark)' 27 | scheme: slate 28 | primary: 'black' 29 | accent: 'green' 30 | toggle: 31 | icon: material/brightness-4 32 | name: Switch to light mode 33 | font: 34 | text: 'Lato' 35 | code: 'Fira Code' 36 | 37 | extra_css: 38 | - 'css/app.css' 39 | 40 | markdown_extensions: 41 | - smarty 42 | - codehilite: 43 | guess_lang: false 44 | - footnotes 45 | - meta 46 | - toc: 47 | permalink: true 48 | - pymdownx.betterem: 49 | smart_enable: all 50 | - pymdownx.caret 51 | - pymdownx.inlinehilite 52 | - pymdownx.magiclink 53 | - pymdownx.smartsymbols 54 | - pymdownx.superfences 55 | - pymdownx.emoji 56 | - tables 57 | - admonition 58 | 59 | nav: 60 | - 'Overview': index.md 61 | - 'Rules': rules.md 62 | - 'Discussions ⏏': https://github.com/slackhq/compose-lints/discussions 63 | - 'Change Log': changelog.md 64 | - 'API': api/0.x/ 65 | - 'Contributing': contributing.md 66 | - 'CoC': code-of-conduct.md -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/KtFunctions.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import com.android.tools.lint.client.api.JavaEvaluator 7 | import com.intellij.psi.PsiTypes 8 | import org.jetbrains.kotlin.lexer.KtTokens 9 | import org.jetbrains.kotlin.psi.KtClass 10 | import org.jetbrains.kotlin.psi.KtClassBody 11 | import org.jetbrains.kotlin.psi.KtFunction 12 | import org.jetbrains.kotlin.psi.psiUtil.visibilityModifierType 13 | import org.jetbrains.uast.UMethod 14 | 15 | fun UMethod.returnsUnitOrVoid(evaluator: JavaEvaluator): Boolean { 16 | return returnType?.let { 17 | it == PsiTypes.voidType() || evaluator.getTypeClass(it)?.qualifiedName == "kotlin.Unit" 18 | } ?: false 19 | } 20 | 21 | val KtFunction.hasReceiverType: Boolean 22 | get() = receiverTypeReference != null 23 | 24 | val KtFunction.isPrivate: Boolean 25 | get() = visibilityModifierType() == KtTokens.PRIVATE_KEYWORD 26 | 27 | val KtFunction.isProtected: Boolean 28 | get() = visibilityModifierType() == KtTokens.PROTECTED_KEYWORD 29 | 30 | val KtFunction.isInternal: Boolean 31 | get() = visibilityModifierType() == KtTokens.INTERNAL_KEYWORD 32 | 33 | val KtFunction.isOverride: Boolean 34 | get() = hasModifier(KtTokens.OVERRIDE_KEYWORD) 35 | 36 | val KtFunction.isActual: Boolean 37 | get() = hasModifier(KtTokens.ACTUAL_KEYWORD) 38 | 39 | val KtFunction.isExpect: Boolean 40 | get() = hasModifier(KtTokens.EXPECT_KEYWORD) 41 | 42 | val KtFunction.isAbstract: Boolean 43 | get() = hasModifier(KtTokens.ABSTRACT_KEYWORD) 44 | 45 | val KtFunction.definedInInterface: Boolean 46 | get() = ((parent as? KtClassBody)?.parent as? KtClass)?.isInterface() ?: false 47 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ComposeLintsIssueRegistry.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.client.api.IssueRegistry 6 | import com.android.tools.lint.client.api.Vendor 7 | import com.android.tools.lint.detector.api.CURRENT_API 8 | import com.android.tools.lint.detector.api.Issue 9 | import com.google.auto.service.AutoService 10 | 11 | @AutoService(IssueRegistry::class) 12 | class ComposeLintsIssueRegistry : IssueRegistry() { 13 | 14 | override val vendor: Vendor = 15 | Vendor( 16 | vendorName = "slack", 17 | identifier = "com.slack.lint.compose:compose-lints", 18 | feedbackUrl = "https://github.com/slackhq/compose-lints/issues", 19 | ) 20 | 21 | override val api: Int = CURRENT_API 22 | override val minApi: Int = CURRENT_API 23 | 24 | @Suppress("SpreadOperator") 25 | override val issues: List = 26 | listOf( 27 | *ComposableFunctionNamingDetector.ISSUES, 28 | *CompositionLocalUsageDetector.ISSUES, 29 | ContentEmitterReturningValuesDetector.ISSUE, 30 | ModifierMissingDetector.ISSUE, 31 | ModifierReusedDetector.ISSUE, 32 | ModifierWithoutDefaultDetector.ISSUE, 33 | M2ApiDetector.ISSUE, 34 | MultipleContentEmittersDetector.ISSUE, 35 | MutableParametersDetector.ISSUE, 36 | ParameterOrderDetector.ISSUE, 37 | PreviewNamingDetector.ISSUE, 38 | PreviewPublicDetector.ISSUE, 39 | RememberMissingDetector.ISSUE, 40 | SlotReusedDetector.ISSUE, 41 | UnstableCollectionsDetector.ISSUE, 42 | ViewModelForwardingDetector.ISSUE, 43 | ViewModelInjectionDetector.ISSUE, 44 | ModifierComposedDetector.ISSUE, 45 | UnstableReceiverDetector.ISSUE, 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/Previews.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import org.jetbrains.uast.UAnnotated 7 | import org.jetbrains.uast.UParameter 8 | import org.jetbrains.uast.toUElementOfType 9 | 10 | private const val COMPOSE_PREVIEW = "androidx.compose.ui.tooling.preview.Preview" 11 | private const val COMPOSE_DESKTOP_PREVIEW = "androidx.compose.desktop.ui.tooling.preview.Preview" 12 | 13 | val PREVIEW_ANNOTATIONS = setOf(COMPOSE_PREVIEW, COMPOSE_DESKTOP_PREVIEW) 14 | val TEST_ANNOTATIONS = 15 | setOf( 16 | "org.jetbrains.annotations.TestOnly", 17 | "com.google.common.annotations.VisibleForTesting", 18 | "androidx.annotation.VisibleForTesting", 19 | ) 20 | 21 | val UAnnotated.isPreview: Boolean 22 | get() = checkIsPreview(0, maxDepth = 4) 23 | 24 | /** 25 | * Previews can go multiple layers so we can recurse up to check. In [UAnnotated.isPreview] we cap 26 | * it at 4 to be reasonable. 27 | */ 28 | private fun UAnnotated.checkIsPreview(depth: Int, maxDepth: Int): Boolean { 29 | if (depth >= maxDepth) return false 30 | return uAnnotations.any { 31 | it.resolve()?.let { cls -> 32 | cls.qualifiedName in PREVIEW_ANNOTATIONS || 33 | // Is the annotation itself a preview-annotated annotation? 34 | cls.toUElementOfType()?.checkIsPreview(depth + 1, maxDepth) == true 35 | } ?: false 36 | } 37 | } 38 | 39 | val UAnnotated.isVisibleForTesting: Boolean 40 | get() = 41 | uAnnotations.any { 42 | // Is it itself a preview annotation? 43 | it.resolve()?.let { cls -> cls.qualifiedName in TEST_ANNOTATIONS } ?: false 44 | } 45 | 46 | val UParameter.isPreviewParameter: Boolean 47 | get() = findAnnotation("androidx.compose.ui.tooling.preview.PreviewParameter") != null 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | # Only run push on main 5 | push: 6 | branches: 7 | - main 8 | paths-ignore: 9 | - '*.md' 10 | # Always run on PRs 11 | pull_request: 12 | merge_group: 13 | 14 | concurrency: 15 | group: '${{ github.event.merge_group.head_ref || github.head_ref }}-${{ github.workflow }}' 16 | cancel-in-progress: true 17 | 18 | jobs: 19 | build: 20 | name: "K2 UAST = ${{ matrix.useK2Uast }}" 21 | runs-on: ubuntu-latest 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | useK2Uast: [ true, false ] 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Install JDK 31 | uses: actions/setup-java@v4 32 | with: 33 | distribution: 'zulu' 34 | java-version: '21' 35 | 36 | - name: Setup Gradle 37 | uses: gradle/actions/setup-gradle@v4 38 | 39 | - name: Build 40 | run: ./gradlew check -Dlint.use.fir.uast=${{ matrix.useK2Uast }} 41 | 42 | - name: (Fail-only) Upload build reports 43 | if: failure() 44 | uses: actions/upload-artifact@v4 45 | with: 46 | name: reports-useK2Uast=${{ matrix.useK2Uast }} 47 | path: | 48 | **/build/reports/* 49 | 50 | publish-snapshots: 51 | name: "Publish snapshots" 52 | if: github.repository == 'slackhq/compose-lints' && github.ref == 'refs/heads/main' 53 | needs: 'build' 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Checkout 57 | uses: actions/checkout@v4 58 | 59 | - name: Install JDK 60 | uses: actions/setup-java@v4 61 | with: 62 | distribution: 'zulu' 63 | java-version: '21' 64 | 65 | - name: Setup Gradle 66 | uses: gradle/actions/setup-gradle@v4 67 | 68 | - name: Publish 69 | run: ./gradlew publish -PmavenCentralUsername=${{ secrets.SONATYPEUSERNAME }} -PmavenCentralPassword=${{ secrets.SONATYPEPASSWORD }} --no-configuration-cache 70 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ComposableFunctionDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.client.api.UElementHandler 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.SourceCodeScanner 10 | import org.jetbrains.kotlin.psi.KtFunction 11 | import org.jetbrains.kotlin.psi.KtPropertyAccessor 12 | import org.jetbrains.uast.UMethod 13 | import org.jetbrains.uast.kotlin.isKotlin 14 | import slack.lint.compose.util.LintOption 15 | import slack.lint.compose.util.OptionLoadingDetector 16 | import slack.lint.compose.util.isComposable 17 | 18 | abstract class ComposableFunctionDetector(options: List>) : 19 | OptionLoadingDetector(options), SourceCodeScanner { 20 | 21 | constructor(vararg options: Pair) : this(options.toList()) 22 | 23 | final override fun getApplicableUastTypes() = listOf(UMethod::class.java) 24 | 25 | final override fun createUastHandler(context: JavaContext): UElementHandler? { 26 | if (!isKotlin(context.uastFile?.lang)) return null 27 | return object : UElementHandler() { 28 | override fun visitMethod(node: UMethod) { 29 | if (node.isComposable) { 30 | visitComposable(context, node) 31 | when (val sourcePsi = node.sourcePsi ?: return) { 32 | is KtPropertyAccessor -> { 33 | visitComposable(context, node, sourcePsi) 34 | } 35 | is KtFunction -> { 36 | visitComposable(context, node, sourcePsi) 37 | } 38 | } 39 | } 40 | } 41 | } 42 | } 43 | 44 | open fun visitComposable(context: JavaContext, method: UMethod) {} 45 | 46 | open fun visitComposable(context: JavaContext, method: UMethod, property: KtPropertyAccessor) {} 47 | 48 | open fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) {} 49 | } 50 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/LintUtils.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose.util 4 | 5 | import com.android.tools.lint.client.api.Configuration 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Implementation 8 | import com.android.tools.lint.detector.api.Issue 9 | import com.android.tools.lint.detector.api.Scope 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.android.tools.lint.detector.api.StringOption 12 | import java.util.EnumSet 13 | 14 | @Suppress("SpreadOperator") 15 | internal inline fun sourceImplementation( 16 | shouldRunOnTestSources: Boolean = true 17 | ): Implementation where T : Detector, T : SourceCodeScanner { 18 | // We use the overloaded constructor that takes a varargs of `Scope` as the last param. 19 | // This is to enable on-the-fly IDE checks. We are telling lint to run on both 20 | // JAVA and TEST_SOURCES in the `scope` parameter but by providing the `analysisScopes` 21 | // params, we're indicating that this check can run on either JAVA or TEST_SOURCES and 22 | // doesn't require both of them together. 23 | // From discussion on lint-dev https://groups.google.com/d/msg/lint-dev/ULQMzW1ZlP0/1dG4Vj3-AQAJ 24 | // This was supposed to be fixed in AS 3.4 but still required as recently as 3.6-alpha10. 25 | return if (shouldRunOnTestSources) { 26 | Implementation( 27 | T::class.java, 28 | EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES), 29 | EnumSet.of(Scope.JAVA_FILE), 30 | EnumSet.of(Scope.TEST_SOURCES), 31 | ) 32 | } else { 33 | Implementation(T::class.java, EnumSet.of(Scope.JAVA_FILE)) 34 | } 35 | } 36 | 37 | /** Loads a [StringOption] as a [delimiter]-delimited [Set] of strings. */ 38 | internal fun StringOption.loadAsSet( 39 | configuration: Configuration, 40 | issue: Issue, 41 | delimiter: String = ",", 42 | ): Set { 43 | return configuration 44 | .getOption(issue, name) 45 | ?.splitToSequence(delimiter) 46 | .orEmpty() 47 | .map(String::trim) 48 | .filter(String::isNotBlank) 49 | .toSet() 50 | } 51 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/PsiElements.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import com.intellij.psi.PsiElement 7 | import org.jetbrains.kotlin.psi.KtBlockExpression 8 | import org.jetbrains.kotlin.psi.KtExpression 9 | import org.jetbrains.kotlin.psi.KtParenthesizedExpression 10 | import org.jetbrains.kotlin.psi.KtReturnExpression 11 | 12 | inline fun PsiElement.findChildrenByClass(): Sequence { 13 | val expr = unwrapParenthesis() ?: return emptySequence() 14 | return sequence { 15 | val queue = ArrayDeque() 16 | queue.add(expr) 17 | while (queue.isNotEmpty()) { 18 | val current = queue.removeFirst().unwrapParenthesis() ?: continue 19 | if (current is T) { 20 | yield(current) 21 | } 22 | queue.addAll(current.children) 23 | } 24 | } 25 | } 26 | 27 | inline fun PsiElement.findDirectChildrenByClass(): Sequence { 28 | val expr = unwrapParenthesis() ?: return emptySequence() 29 | return sequence { 30 | var current = expr.firstChild?.unwrapParenthesis() 31 | while (current != null) { 32 | if (current is T) { 33 | yield(current) 34 | } 35 | current = current.nextSibling?.unwrapParenthesis() 36 | } 37 | } 38 | } 39 | 40 | @PublishedApi 41 | internal fun PsiElement?.unwrapParenthesis(): PsiElement? { 42 | return when (this) { 43 | null -> null 44 | is KtExpression -> unwrapParenthesis() 45 | else -> this 46 | } 47 | } 48 | 49 | @PublishedApi 50 | internal fun KtExpression.unwrapParenthesis(): KtExpression? { 51 | return when (this) { 52 | is KtParenthesizedExpression -> expression 53 | else -> this 54 | } 55 | } 56 | 57 | @PublishedApi 58 | internal fun KtExpression.unwrapBlock(): KtExpression? { 59 | return when (this) { 60 | is KtBlockExpression -> firstStatement 61 | else -> this 62 | } 63 | } 64 | 65 | @PublishedApi 66 | internal fun KtExpression.unwrapReturnExpression(): KtExpression? { 67 | return when (this) { 68 | is KtReturnExpression -> returnedExpression 69 | else -> this 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/MutableParametersDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.android.tools.lint.detector.api.TextFormat 12 | import org.jetbrains.kotlin.psi.KtFunction 13 | import org.jetbrains.uast.UMethod 14 | import slack.lint.compose.util.Priorities 15 | import slack.lint.compose.util.isTypeMutable 16 | import slack.lint.compose.util.sourceImplementation 17 | 18 | class MutableParametersDetector : ComposableFunctionDetector(), SourceCodeScanner { 19 | companion object { 20 | val ISSUE = 21 | Issue.create( 22 | id = "ComposeMutableParameters", 23 | briefDescription = "Mutable objects in Compose will break state", 24 | explanation = 25 | """ 26 | Using mutable objects as state in Compose will cause your users to see incorrect or stale data in your app.\ 27 | Mutable objects that are not observable, such as `ArrayList` or a mutable data class, cannot be observed by Compose to trigger recomposition when they change.\ 28 | See https://slackhq.github.io/compose-lints/rules/#do-not-use-inherently-mutable-types-as-parameters for more information. 29 | """, 30 | category = Category.PRODUCTIVITY, 31 | priority = Priorities.NORMAL, 32 | severity = Severity.ERROR, 33 | implementation = sourceImplementation(), 34 | ) 35 | } 36 | 37 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 38 | method.uastParameters 39 | .filter { it.isTypeMutable(context.evaluator) } 40 | .forEach { parameter -> 41 | context.report( 42 | ISSUE, 43 | parameter.typeReference, 44 | context.getLocation(parameter.typeReference), 45 | ISSUE.getExplanation(TextFormat.TEXT), 46 | ) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | Compose Lints 2 | ============= 3 | 4 | This repository contains a collection of custom lint checks for Jetpack Compose, mostly ported from the original [twitter/compose-rules](https://github.com/twitter/compose-rules) project. 5 | 6 | These checks are to ensure that your composables don't fall into common pitfalls that may be easy to miss in code reviews. 7 | 8 | ## Why 9 | 10 | > _Originally from twitter/compose-rules._ 11 | 12 | It can be challenging for big teams to start adopting Compose, particularly because not everyone will start at same time or with the same patterns. Twitter tried to ease the pain by creating a set of Compose static checks. 13 | 14 | Compose has lots of superpowers but also has a bunch of footguns to be aware of [as seen in this Twitter Thread](https://twitter.com/mrmans0n/status/1507390768796909571). 15 | 16 | This is where our static checks come in. We want to detect as many potential issues as we can, as quickly as we can. In this case we want an error to show prior to engineers having to review code. Similar to other static check libraries we hope this leads to a "don't shoot the messengers" philosophy which will foster healthy Compose adoption. 17 | 18 | ## Installation 19 | 20 | Just add the dependency to the `lintChecks` configuration. Note for non-android projects, you must apply the `com.android.lint` Gradle plugin to use this. 21 | 22 | [![Maven Central](https://img.shields.io/maven-central/v/com.slack.lint.compose/compose-lint-checks.svg)](https://mvnrepository.com/artifact/com.slack.lint.compose/compose-lint-checks) 23 | 24 | ```kotlin 25 | dependencies { 26 | lintChecks("com.slack.lint.compose:compose-lint-checks:") 27 | } 28 | ``` 29 | 30 | License 31 | -------- 32 | 33 | Copyright 2023 Salesforce, Inc. 34 | Copyright 2022 Twitter, Inc. 35 | 36 | Licensed under the Apache License, Version 2.0 (the "License"); 37 | you may not use this file except in compliance with the License. 38 | You may obtain a copy of the License at 39 | 40 | http://www.apache.org/licenses/LICENSE-2.0 41 | 42 | Unless required by applicable law or agreed to in writing, software 43 | distributed under the License is distributed on an "AS IS" BASIS, 44 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 45 | See the License for the specific language governing permissions and 46 | limitations under the License. 47 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ModifierComposedDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.client.api.UElementHandler 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Detector 8 | import com.android.tools.lint.detector.api.Issue 9 | import com.android.tools.lint.detector.api.JavaContext 10 | import com.android.tools.lint.detector.api.Severity 11 | import com.android.tools.lint.detector.api.SourceCodeScanner 12 | import com.android.tools.lint.detector.api.TextFormat 13 | import org.jetbrains.uast.UCallExpression 14 | import org.jetbrains.uast.UElement 15 | import org.jetbrains.uast.kotlin.isKotlin 16 | import slack.lint.compose.util.* 17 | import slack.lint.compose.util.sourceImplementation 18 | 19 | class ModifierComposedDetector : Detector(), SourceCodeScanner { 20 | 21 | companion object { 22 | val ISSUE = 23 | Issue.create( 24 | id = "ComposeModifierComposed", 25 | briefDescription = "Don't use Modifier.composed {}", 26 | explanation = 27 | """ 28 | Modifier.composed { ... } is no longer recommended due to performance issues. 29 | 30 | You should use the Modifier.Node API instead, as it was designed from the ground up to be far more performant than composed modifiers. 31 | 32 | See https://slackhq.github.io/compose-lints/rules/#migrate-to-modifiernode for more information. 33 | """, 34 | category = Category.CORRECTNESS, 35 | priority = Priorities.NORMAL, 36 | severity = Severity.ERROR, 37 | implementation = sourceImplementation(), 38 | ) 39 | } 40 | 41 | override fun getApplicableUastTypes() = listOf>(UCallExpression::class.java) 42 | 43 | override fun createUastHandler(context: JavaContext): UElementHandler? { 44 | if (!isKotlin(context.uastFile?.lang)) return null 45 | return object : UElementHandler() { 46 | override fun visitCallExpression(node: UCallExpression) { 47 | if (node.methodName != "composed") return 48 | context.evaluator.getTypeClass(node.receiverType)?.let { receiver -> 49 | if (context.evaluator.implementsInterface(receiver, "androidx.compose.ui.Modifier")) { 50 | context.report( 51 | ISSUE, 52 | node, 53 | context.getLocation(node), 54 | ISSUE.getExplanation(TextFormat.TEXT), 55 | ) 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/KtCallableDeclarations.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import com.android.tools.lint.client.api.JavaEvaluator 7 | import org.jetbrains.kotlin.psi.KtParameter 8 | import org.jetbrains.uast.UClass 9 | import org.jetbrains.uast.UParameter 10 | import org.jetbrains.uast.toUElementOfType 11 | 12 | fun UParameter.isTypeMutable(evaluator: JavaEvaluator): Boolean { 13 | // Trivial check for Kotlin mutable collections. See its doc for details. 14 | // Note this doesn't work on typealiases, which unfortunately we can't really 15 | // do anything about 16 | if ( 17 | (sourcePsi as? KtParameter)?.typeReference?.text?.matchesAnyOf(KnownMutableKotlinCollections) == 18 | true 19 | ) { 20 | return true 21 | } 22 | 23 | val uParamClass = type.let(evaluator::getTypeClass)?.toUElementOfType() ?: return false 24 | 25 | if (uParamClass.hasAnnotation("androidx.compose.runtime.Immutable")) { 26 | return false 27 | } 28 | 29 | return uParamClass.name in KnownMutableCommonTypesSimpleNames 30 | } 31 | 32 | /** Lint can't read "Mutable*" Kotlin collections that are compiler intrinsics. */ 33 | val KnownMutableKotlinCollections = 34 | sequenceOf( 35 | "MutableMap(\\s)?<.*,(\\s)?.*>\\??", 36 | "MutableSet(\\s)?<.*>\\??", 37 | "MutableList(\\s)?<.*>\\??", 38 | "MutableCollection(\\s)?<.*>\\??", 39 | ) 40 | .map(::Regex) 41 | 42 | val KnownMutableCommonTypesSimpleNames = 43 | setOf( 44 | // Set 45 | "MutableSet", 46 | "ArraySet", 47 | "HashSet", 48 | // List 49 | "MutableList", 50 | "ArrayList", 51 | // Array 52 | "SparseArray", 53 | "SparseArrayCompat", 54 | "LongSparseArray", 55 | "SparseBooleanArray", 56 | "SparseIntArray", 57 | // Map 58 | "MutableMap", 59 | "HashMap", 60 | "Hashtable", 61 | // Compose 62 | "MutableState", 63 | // Flow 64 | "MutableStateFlow", 65 | "MutableSharedFlow", 66 | // RxJava & RxRelay 67 | "PublishSubject", 68 | "BehaviorSubject", 69 | "ReplaySubject", 70 | "PublishRelay", 71 | "BehaviorRelay", 72 | "ReplayRelay", 73 | ) 74 | 75 | fun UParameter.isTypeUnstableCollection(evaluator: JavaEvaluator): Boolean { 76 | val uParamClass = type.let(evaluator::getTypeClass)?.toUElementOfType() ?: return false 77 | 78 | if (uParamClass.hasAnnotation("androidx.compose.runtime.Immutable")) { 79 | return false 80 | } 81 | 82 | return uParamClass.qualifiedName in KnownUnstableCollectionTypes 83 | } 84 | 85 | val KnownUnstableCollectionTypes = 86 | sequenceOf("java.util.Collection", "java.util.Set", "java.util.List", "java.util.Map") 87 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/UnstableCollectionsDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import java.util.Locale 12 | import org.jetbrains.kotlin.psi.KtFunction 13 | import org.jetbrains.kotlin.psi.KtParameter 14 | import org.jetbrains.uast.UMethod 15 | import slack.lint.compose.util.Priorities 16 | import slack.lint.compose.util.isTypeUnstableCollection 17 | import slack.lint.compose.util.sourceImplementation 18 | 19 | class UnstableCollectionsDetector : ComposableFunctionDetector(), SourceCodeScanner { 20 | 21 | companion object { 22 | private val DiamondRegex by lazy(LazyThreadSafetyMode.NONE) { Regex("<.*>\\??") } 23 | private val String.capitalized: String 24 | get() = replaceFirstChar { 25 | if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() 26 | } 27 | 28 | fun createErrorMessage(type: String, rawType: String, variable: String) = 29 | """ 30 | The Compose Compiler cannot infer the stability of a parameter if a $type is used in it, even if the item type is stable. 31 | You should use Kotlinx Immutable Collections instead: `$variable: Immutable$type` or create an `@Immutable` wrapper for this class: `@Immutable data class ${variable.capitalized}$rawType(val items: $type)` 32 | See https://slackhq.github.io/compose-lints/rules/#avoid-using-unstable-collections for more information. 33 | """ 34 | .trimIndent() 35 | 36 | val ISSUE = 37 | Issue.create( 38 | id = "ComposeUnstableCollections", 39 | briefDescription = "Immutable collections should ideally be used in Composables", 40 | explanation = "This is replaced when reported", 41 | category = Category.PRODUCTIVITY, 42 | priority = Priorities.NORMAL, 43 | severity = Severity.WARNING, 44 | implementation = sourceImplementation(), 45 | ) 46 | } 47 | 48 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 49 | for (param in method.uastParameters.filter { it.isTypeUnstableCollection(context.evaluator) }) { 50 | val variableName = param.name 51 | val type = (param.sourcePsi as? KtParameter)?.typeReference?.text ?: "List/Set/Map" 52 | val message = 53 | createErrorMessage( 54 | type = type, 55 | rawType = type.replace(DiamondRegex, ""), 56 | variable = variableName, 57 | ) 58 | val targetToReport = param.typeReference ?: param 59 | context.report(ISSUE, targetToReport, context.getLocation(targetToReport), message) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/PreviewNamingDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.client.api.UElementHandler 7 | import com.android.tools.lint.detector.api.Category 8 | import com.android.tools.lint.detector.api.Detector 9 | import com.android.tools.lint.detector.api.Issue 10 | import com.android.tools.lint.detector.api.JavaContext 11 | import com.android.tools.lint.detector.api.Severity 12 | import com.android.tools.lint.detector.api.SourceCodeScanner 13 | import org.jetbrains.kotlin.psi.KtClass 14 | import org.jetbrains.uast.UClass 15 | import org.jetbrains.uast.kotlin.isKotlin 16 | import org.jetbrains.uast.toUElementOfType 17 | import slack.lint.compose.util.PREVIEW_ANNOTATIONS 18 | import slack.lint.compose.util.Priorities 19 | import slack.lint.compose.util.isPreview 20 | import slack.lint.compose.util.sourceImplementation 21 | 22 | class PreviewNamingDetector : Detector(), SourceCodeScanner { 23 | 24 | companion object { 25 | fun createMessage(count: Int, suggestedSuffix: String): String = 26 | """ 27 | Preview annotations with $count preview annotations should end with the `$suggestedSuffix` suffix. 28 | See https://slackhq.github.io/compose-lints/rules/#naming-multipreview-annotations-properly for more information. 29 | """ 30 | .trimIndent() 31 | 32 | val ISSUE = 33 | Issue.create( 34 | id = "ComposePreviewNaming", 35 | briefDescription = "Preview annotations require certain suffixes", 36 | explanation = "This is replaced when reporting", 37 | category = Category.PRODUCTIVITY, 38 | priority = Priorities.NORMAL, 39 | severity = Severity.ERROR, 40 | implementation = sourceImplementation(), 41 | ) 42 | } 43 | 44 | override fun getApplicableUastTypes() = listOf(UClass::class.java) 45 | 46 | override fun createUastHandler(context: JavaContext): UElementHandler? { 47 | if (!isKotlin(context.uastFile?.lang)) return null 48 | return object : UElementHandler() { 49 | override fun visitClass(node: UClass) { 50 | val clazz = node.sourcePsi as? KtClass ?: return 51 | if (!clazz.isAnnotation()) return 52 | if (!node.isPreview) return 53 | 54 | // We know here that we are in an annotation that either has a @Preview or other preview 55 | // annotations 56 | val count = 57 | node.uAnnotations.count { 58 | it.resolve().toUElementOfType()?.let { annotation -> 59 | annotation.qualifiedName in PREVIEW_ANNOTATIONS || 60 | annotation.uAnnotations.any { it.resolve()?.qualifiedName in PREVIEW_ANNOTATIONS } 61 | } ?: false 62 | } 63 | val name = clazz.nameAsSafeName.asString() 64 | val message = 65 | if (count == 1 && !name.endsWith("Preview")) { 66 | createMessage(count, "Preview") 67 | } else if (count > 1 && !name.endsWith("Previews")) { 68 | createMessage(count, "Previews") 69 | } else { 70 | null 71 | } 72 | message?.let { context.report(ISSUE, clazz, context.getLocation(clazz), it) } 73 | } 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ModifierWithoutDefaultDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.android.tools.lint.detector.api.TextFormat 12 | import com.intellij.psi.impl.source.tree.LeafPsiElement 13 | import org.jetbrains.kotlin.psi.KtFunction 14 | import org.jetbrains.kotlin.psi.KtParameter 15 | import org.jetbrains.uast.UMethod 16 | import slack.lint.compose.util.Priorities 17 | import slack.lint.compose.util.definedInInterface 18 | import slack.lint.compose.util.isAbstract 19 | import slack.lint.compose.util.isActual 20 | import slack.lint.compose.util.isModifier 21 | import slack.lint.compose.util.isModifierReceiver 22 | import slack.lint.compose.util.isOverride 23 | import slack.lint.compose.util.lastChildLeafOrSelf 24 | import slack.lint.compose.util.sourceImplementation 25 | 26 | class ModifierWithoutDefaultDetector : ComposableFunctionDetector(), SourceCodeScanner { 27 | 28 | companion object { 29 | val ISSUE = 30 | Issue.create( 31 | id = "ComposeModifierWithoutDefault", 32 | briefDescription = "Missing Modifier default value", 33 | explanation = 34 | """ 35 | This @Composable function has a modifier parameter but it doesn't have a default value.\ 36 | See https://slackhq.github.io/compose-lints/rules/#modifiers-should-have-default-parameters for more information. 37 | """, 38 | category = Category.PRODUCTIVITY, 39 | priority = Priorities.NORMAL, 40 | severity = Severity.ERROR, 41 | implementation = sourceImplementation(), 42 | ) 43 | } 44 | 45 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 46 | if ( 47 | function.definedInInterface || 48 | function.isActual || 49 | function.isOverride || 50 | function.isAbstract || 51 | function.isModifierReceiver 52 | ) { 53 | return 54 | } 55 | 56 | // Look for modifier params in the composable signature, and if any without a default value is 57 | // found, error out. 58 | method.uastParameters 59 | .filter { param -> param.isModifier(context.evaluator) } 60 | .filterNot { param -> 61 | param.sourcePsi is KtParameter && (param.sourcePsi as KtParameter).hasDefaultValue() 62 | } 63 | .forEach { param -> 64 | val modifierParameter = param.sourcePsi as KtParameter 65 | 66 | // This error is easily auto fixable, we just inject ` = Modifier` to the param 67 | val lastToken = modifierParameter.node.lastChildLeafOrSelf() as LeafPsiElement 68 | val currentText = lastToken.text 69 | context.report( 70 | ISSUE, 71 | modifierParameter, 72 | context.getLocation(modifierParameter), 73 | ISSUE.getExplanation(TextFormat.TEXT), 74 | fix() 75 | .replace() 76 | .name("Add '= Modifier' default value.") 77 | .range(context.getLocation(modifierParameter)) 78 | .shortenNames() 79 | .text(currentText) 80 | .with("$currentText = Modifier") 81 | .autoFix() 82 | .build(), 83 | ) 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/ModifierComposedDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.detector.api.Detector 6 | import com.android.tools.lint.detector.api.Issue 7 | import org.intellij.lang.annotations.Language 8 | import org.junit.Test 9 | 10 | class ModifierComposedDetectorTest : BaseComposeLintTest() { 11 | 12 | private val modifierStub = 13 | kotlin( 14 | """ 15 | package androidx.compose.ui 16 | 17 | class InspectorInfo { 18 | companion object { 19 | val NoInspectorInfo = InspectorInfo() 20 | } 21 | } 22 | 23 | interface Modifier { 24 | companion object : Modifier 25 | } 26 | """ 27 | .trimIndent() 28 | ) 29 | private val composed = 30 | kotlin( 31 | "test/androidx/compose/ui/ComposedModifier.kt", 32 | """ 33 | package androidx.compose.ui 34 | 35 | fun Modifier.composed( 36 | inspectorInfo: InspectorInfo.() -> Unit = NoInspectorInfo, 37 | factory: Modifier.() -> Modifier 38 | ): Modifier { 39 | TODO() 40 | } 41 | """ 42 | .trimIndent(), 43 | ) 44 | 45 | override fun getDetector(): Detector = ModifierComposedDetector() 46 | 47 | override fun getIssues(): List = listOf(ModifierComposedDetector.ISSUE) 48 | 49 | @Test 50 | fun `errors when a composable Modifier extension is detected`() { 51 | @Language("kotlin") 52 | val code = 53 | """ 54 | package test 55 | 56 | import androidx.compose.ui.composed 57 | import androidx.compose.ui.Modifier 58 | 59 | fun Modifier.something1() = Modifier.composed { } 60 | fun Modifier.something2() = composed { } 61 | fun Modifier.something3() = somethingElse() 62 | """ 63 | .trimIndent() 64 | 65 | lint() 66 | .files(modifierStub, composed, kotlin(code)) 67 | .run() 68 | .expect( 69 | """ 70 | src/test/test.kt:6: Error: Modifier.composed { ... } is no longer recommended due to performance issues. 71 | 72 | You should use the Modifier.Node API instead, as it was designed from the ground up to be far more performant than composed modifiers. 73 | 74 | See https://slackhq.github.io/compose-lints/rules/#migrate-to-modifiernode for more information. [ComposeModifierComposed] 75 | fun Modifier.something1() = Modifier.composed { } 76 | ~~~~~~~~~~~~~~~~~~~~~ 77 | src/test/test.kt:7: Error: Modifier.composed { ... } is no longer recommended due to performance issues. 78 | 79 | You should use the Modifier.Node API instead, as it was designed from the ground up to be far more performant than composed modifiers. 80 | 81 | See https://slackhq.github.io/compose-lints/rules/#migrate-to-modifiernode for more information. [ComposeModifierComposed] 82 | fun Modifier.something2() = composed { } 83 | ~~~~~~~~~~~~ 84 | 2 errors, 0 warnings 85 | """ 86 | .trimIndent() 87 | ) 88 | } 89 | 90 | @Test 91 | fun `do not error on a regular composable`() { 92 | @Language("kotlin") 93 | val code = 94 | """ 95 | @Composable 96 | fun TextHolder(text: String) {} 97 | """ 98 | .trimIndent() 99 | 100 | lint().files(kotlin(code)).allowCompilationErrors().run().expectClean() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/Stability.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2024 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose.util 4 | 5 | import com.android.tools.lint.client.api.JavaEvaluator 6 | import com.intellij.psi.PsiType 7 | import com.intellij.psi.PsiTypes 8 | import org.jetbrains.uast.UAnnotation 9 | import org.jetbrains.uast.UClass 10 | import org.jetbrains.uast.toUElementOfType 11 | 12 | private const val COMPOSE_STABLE = "androidx.compose.runtime.Stable" 13 | private const val COMPOSE_IMMUTABLE = "androidx.compose.runtime.Immutable" 14 | private const val COMPOSE_STABLE_MARKER = "androidx.compose.runtime.StableMarker" 15 | 16 | val STABILITY_ANNOTATIONS = setOf(COMPOSE_STABLE, COMPOSE_IMMUTABLE) 17 | 18 | /** 19 | * Sets of known external stable constructs to the compose-compiler. 20 | * 21 | * @see KnownStableConstructs 23 | */ 24 | object KnownStableConstructs { 25 | 26 | val stableTypes = 27 | setOf( 28 | Pair::class.qualifiedName!!, 29 | Triple::class.qualifiedName!!, 30 | Comparator::class.qualifiedName!!, 31 | Result::class.qualifiedName!!, 32 | ClosedRange::class.qualifiedName!!, 33 | ClosedFloatingPointRange::class.qualifiedName!!, 34 | // Guava 35 | "com.google.common.collect.ImmutableList", 36 | "com.google.common.collect.ImmutableEnumMap", 37 | "com.google.common.collect.ImmutableMap", 38 | "com.google.common.collect.ImmutableEnumSet", 39 | "com.google.common.collect.ImmutableSet", 40 | // Kotlinx immutable 41 | "kotlinx.collections.immutable.ImmutableCollection", 42 | "kotlinx.collections.immutable.ImmutableList", 43 | "kotlinx.collections.immutable.ImmutableSet", 44 | "kotlinx.collections.immutable.ImmutableMap", 45 | "kotlinx.collections.immutable.PersistentCollection", 46 | "kotlinx.collections.immutable.PersistentList", 47 | "kotlinx.collections.immutable.PersistentSet", 48 | "kotlinx.collections.immutable.PersistentMap", 49 | // Dagger 50 | "dagger.Lazy", 51 | // Coroutines 52 | "kotlin.coroutines.EmptyCoroutineContext", 53 | ) 54 | } 55 | 56 | fun PsiType.isStable( 57 | evaluator: JavaEvaluator, 58 | resolveUClass: () -> UClass? = { evaluator.getTypeClass(this)?.toUElementOfType() }, 59 | ): Boolean { 60 | // Primitive types 61 | when (this) { 62 | PsiTypes.byteType(), 63 | PsiTypes.charType(), 64 | PsiTypes.doubleType(), 65 | PsiTypes.floatType(), 66 | PsiTypes.intType(), 67 | PsiTypes.longType(), 68 | PsiTypes.shortType(), 69 | PsiTypes.booleanType(), 70 | PsiTypes.voidType() -> return true 71 | } 72 | val root = resolveUClass() ?: return false 73 | 74 | for (resolved in root.allSupertypes) { 75 | // Enums are stable 76 | if (resolved.isEnum) return true 77 | resolved.qualifiedName?.let { qualifiedName -> 78 | if (qualifiedName == "java.lang.String") return true 79 | if (qualifiedName in KnownStableConstructs.stableTypes) return true 80 | } 81 | if (resolved.isFunctionalInterface) return true 82 | val isStableAnnotated = 83 | resolved.annotations.any { 84 | // Is it itself a preview annotation? 85 | it.qualifiedName in STABILITY_ANNOTATIONS || 86 | it.toUElementOfType()?.resolve()?.hasAnnotation(COMPOSE_STABLE_MARKER) == 87 | true 88 | } 89 | if (isStableAnnotated) return true 90 | } 91 | return false 92 | } 93 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/PreviewPublicDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.android.tools.lint.detector.api.TextFormat 12 | import org.jetbrains.kotlin.lexer.KtTokens 13 | import org.jetbrains.kotlin.psi.KtFunction 14 | import org.jetbrains.kotlin.psi.KtNamedFunction 15 | import org.jetbrains.kotlin.psi.psiUtil.isPublic 16 | import org.jetbrains.kotlin.psi.psiUtil.visibilityModifier 17 | import org.jetbrains.kotlin.psi.psiUtil.visibilityModifierTypeOrDefault 18 | import org.jetbrains.uast.UMethod 19 | import slack.lint.compose.util.Priorities 20 | import slack.lint.compose.util.isPreview 21 | import slack.lint.compose.util.isVisibleForTesting 22 | import slack.lint.compose.util.sourceImplementation 23 | 24 | class PreviewPublicDetector : ComposableFunctionDetector(), SourceCodeScanner { 25 | 26 | companion object { 27 | val ISSUE = 28 | Issue.create( 29 | id = "ComposePreviewPublic", 30 | briefDescription = "Preview composables should be private", 31 | explanation = 32 | """ 33 | Composables annotated with `@Preview` that are used only for previewing the UI should not be public.\ 34 | See https://slackhq.github.io/compose-lints/rules/#preview-composables-should-not-be-public for more information. 35 | """, 36 | category = Category.PRODUCTIVITY, 37 | priority = Priorities.NORMAL, 38 | severity = Severity.ERROR, 39 | implementation = sourceImplementation(), 40 | ) 41 | } 42 | 43 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 44 | // We only want previews 45 | if (!method.isPreview) return 46 | // We only care about public methods 47 | if (!function.isPublic) return 48 | // If it's used for tests, allow it 49 | if (method.isVisibleForTesting) return 50 | 51 | // If we got here, it's a public method in a @Preview composable with a @PreviewParameter 52 | // parameter 53 | val visibility = function.visibilityModifierTypeOrDefault() 54 | val visibilityModifier = function.visibilityModifier() 55 | 56 | // If it has a visibility modifier, replace it 57 | // If it doesn't have one (i.e. implicit), put it before the "fun" keyword 58 | val fix = 59 | if (visibilityModifier != null) { 60 | val location = context.getLocation(visibilityModifier) 61 | fix() 62 | .replace() 63 | .name("Make 'private'") 64 | .range(location) 65 | .shortenNames() 66 | .text(visibility.value) 67 | .with(KtTokens.PRIVATE_KEYWORD.value) 68 | .autoFix() 69 | .build() 70 | } else if (function is KtNamedFunction) { 71 | val location = context.getLocation(function.funKeyword) 72 | fix() 73 | .replace() 74 | .name("Make 'private'") 75 | .range(location) 76 | .shortenNames() 77 | .text("fun") 78 | .with("${KtTokens.PRIVATE_KEYWORD.value} fun") 79 | .autoFix() 80 | .build() 81 | } else { 82 | null 83 | } 84 | 85 | context.report( 86 | ISSUE, 87 | function, 88 | context.getLocation(function), 89 | ISSUE.getExplanation(TextFormat.TEXT), 90 | fix, 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/RememberMissingDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.intellij.psi.PsiElement 12 | import org.jetbrains.kotlin.psi.KtCallExpression 13 | import org.jetbrains.kotlin.psi.KtFunction 14 | import org.jetbrains.uast.UMethod 15 | import slack.lint.compose.util.Priorities 16 | import slack.lint.compose.util.findChildrenByClass 17 | import slack.lint.compose.util.sourceImplementation 18 | 19 | class RememberMissingDetector : ComposableFunctionDetector(), SourceCodeScanner { 20 | 21 | companion object { 22 | private fun errorMessage(name: String): String = 23 | """ 24 | Using `$name` in a @Composable function without it being inside of a remember function. 25 | If you don't remember the state instance, a new state instance will be created when the function is recomposed. 26 | See https://slackhq.github.io/compose-lints/rules/#state-should-be-remembered-in-composables for more information. 27 | """ 28 | .trimIndent() 29 | 30 | private val MethodsThatNeedRemembering = setOf("derivedStateOf", "mutableStateOf") 31 | val DerivedStateOfNotRemembered = errorMessage("derivedStateOf") 32 | val MutableStateOfNotRemembered = errorMessage("mutableStateOf") 33 | 34 | val ISSUE = 35 | Issue.create( 36 | id = "ComposeRememberMissing", 37 | briefDescription = "State values should be remembered", 38 | explanation = "This is replaced when reported", 39 | category = Category.PRODUCTIVITY, 40 | priority = Priorities.NORMAL, 41 | severity = Severity.ERROR, 42 | implementation = sourceImplementation(), 43 | ) 44 | } 45 | 46 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 47 | // To keep memory consumption in check, we first traverse down until we see one of our known 48 | // functions 49 | // that need remembering 50 | function 51 | .findChildrenByClass() 52 | .filter { MethodsThatNeedRemembering.contains(it.calleeExpression?.text) } 53 | // Only for those, we traverse up to [function], to see if it was actually remembered 54 | .filterNot { it.isRemembered(function) } 55 | // If it wasn't, we show the error 56 | .forEach { callExpression -> 57 | when (callExpression.calleeExpression!!.text) { 58 | "mutableStateOf" -> { 59 | context.report( 60 | ISSUE, 61 | callExpression, 62 | context.getLocation(callExpression), 63 | MutableStateOfNotRemembered, 64 | ) 65 | } 66 | "derivedStateOf" -> { 67 | context.report( 68 | ISSUE, 69 | callExpression, 70 | context.getLocation(callExpression), 71 | DerivedStateOfNotRemembered, 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | 78 | private fun KtCallExpression.isRemembered(stopAt: PsiElement): Boolean { 79 | var current: PsiElement = parent 80 | while (!current.isEquivalentTo(stopAt)) { 81 | (current as? KtCallExpression)?.let { callExpression -> 82 | if (callExpression.calleeExpression?.text?.startsWith("remember") == true) return true 83 | } 84 | current = current.parent 85 | } 86 | return false 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/ViewModelForwardingDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Issue 8 | import org.intellij.lang.annotations.Language 9 | import org.junit.Test 10 | 11 | class ViewModelForwardingDetectorTest : BaseComposeLintTest() { 12 | 13 | override fun getDetector(): Detector = ViewModelForwardingDetector() 14 | 15 | override fun getIssues(): List = listOf(ViewModelForwardingDetector.ISSUE) 16 | 17 | @Test 18 | fun `allows the forwarding of ViewModels in overridden Composable functions`() { 19 | @Language("kotlin") 20 | val code = 21 | """ 22 | import androidx.compose.runtime.Composable 23 | 24 | @Composable 25 | override fun Content() { 26 | val viewModel = weaverViewModel() 27 | AnotherComposable(viewModel) 28 | } 29 | """ 30 | .trimIndent() 31 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 32 | } 33 | 34 | @Test 35 | fun `allows the forwarding of ViewModels in interface Composable functions`() { 36 | @Language("kotlin") 37 | val code = 38 | """ 39 | import androidx.compose.runtime.Composable 40 | 41 | interface MyInterface { 42 | @Composable 43 | fun Content() { 44 | val viewModel = weaverViewModel() 45 | AnotherComposable(viewModel) 46 | } 47 | } 48 | """ 49 | .trimIndent() 50 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 51 | } 52 | 53 | @Test 54 | fun `using state hoisting properly shouldn't be flagged`() { 55 | @Language("kotlin") 56 | val code = 57 | """ 58 | import androidx.compose.runtime.Composable 59 | 60 | @Composable 61 | fun MyComposable(viewModel: MyViewModel = weaverViewModel()) { 62 | val state by viewModel.watchAsState() 63 | AnotherComposable(state, onAvatarClicked = { viewModel(AvatarClickedIntent) }) 64 | } 65 | """ 66 | .trimIndent() 67 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 68 | } 69 | 70 | @Test 71 | fun `errors when a ViewModel is forwarded to another Composable`() { 72 | @Language("kotlin") 73 | val code = 74 | """ 75 | import androidx.compose.runtime.Composable 76 | 77 | class MyViewModel 78 | 79 | @Composable 80 | fun MyComposable(viewModel: MyViewModel) { 81 | AnotherComposable(viewModel) 82 | } 83 | """ 84 | .trimIndent() 85 | lint() 86 | .files(*commonStubs, kotlin(code)) 87 | .run() 88 | .expect( 89 | """ 90 | src/MyViewModel.kt:7: Error: Forwarding a ViewModel through multiple @Composable functions should be avoided. Consider using state hoisting.See https://slackhq.github.io/compose-lints/rules/#hoist-all-the-things for more information. [ComposeViewModelForwarding] 91 | AnotherComposable(viewModel) 92 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 93 | 1 errors, 0 warnings 94 | """ 95 | .trimIndent() 96 | ) 97 | } 98 | 99 | @Test 100 | fun `allows the forwarding of ViewModels that are used as keys`() { 101 | @Language("kotlin") 102 | val code = 103 | """ 104 | import androidx.compose.runtime.Composable 105 | 106 | @Composable 107 | fun Content() { 108 | val viewModel = weaverViewModel() 109 | key(viewModel) { } 110 | val x = remember(viewModel) { "ABC" } 111 | LaunchedEffect(viewModel) { } 112 | } 113 | """ 114 | .trimIndent() 115 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ViewModelInjectionDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.android.tools.lint.detector.api.StringOption 12 | import org.jetbrains.kotlin.psi.KtCallExpression 13 | import org.jetbrains.kotlin.psi.KtFunction 14 | import org.jetbrains.kotlin.psi.KtProperty 15 | import org.jetbrains.uast.UMethod 16 | import slack.lint.compose.util.Priorities 17 | import slack.lint.compose.util.StringSetLintOption 18 | import slack.lint.compose.util.definedInInterface 19 | import slack.lint.compose.util.findChildrenByClass 20 | import slack.lint.compose.util.findDirectChildrenByClass 21 | import slack.lint.compose.util.isOverride 22 | import slack.lint.compose.util.sourceImplementation 23 | import slack.lint.compose.util.unwrapParenthesis 24 | 25 | class ViewModelInjectionDetector 26 | @JvmOverloads 27 | constructor(private val userFactories: StringSetLintOption = StringSetLintOption(USER_FACTORIES)) : 28 | ComposableFunctionDetector(userFactories to ISSUE), SourceCodeScanner { 29 | 30 | companion object { 31 | 32 | internal val USER_FACTORIES = 33 | StringOption( 34 | "viewmodel-factories", 35 | "A comma-separated list of viewModel factories.", 36 | null, 37 | "This property should define comma-separated list of allowed viewModel factory function names.", 38 | ) 39 | 40 | private fun errorMessage(factoryName: String): String = 41 | """ 42 | Implicit dependencies of composables should be made explicit. 43 | Usages of $factoryName to acquire a ViewModel should be done in composable default parameters, so that it is more testable and flexible. 44 | See https://slackhq.github.io/compose-lints/rules/#viewmodels for more information. 45 | """ 46 | .trimIndent() 47 | 48 | val ISSUE = 49 | Issue.create( 50 | id = "ComposeViewModelInjection", 51 | briefDescription = "Implicit dependencies of composables should be made explicit", 52 | explanation = "Replaced when reporting", 53 | category = Category.CORRECTNESS, 54 | priority = Priorities.NORMAL, 55 | severity = Severity.ERROR, 56 | implementation = sourceImplementation(), 57 | ) 58 | .setOptions(listOf(USER_FACTORIES)) 59 | 60 | private val KnownViewModelFactories by lazy { 61 | setOf( 62 | "viewModel", // AAC VM 63 | "weaverViewModel", // Weaver 64 | "hiltViewModel", // Hilt 65 | "injectedViewModel", // Whetstone 66 | "mavericksViewModel", // Mavericks 67 | ) 68 | } 69 | } 70 | 71 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 72 | if (function.isOverride || function.definedInInterface) return 73 | 74 | val bodyBlock = function.bodyBlockExpression ?: return 75 | val allFactoryNames = KnownViewModelFactories + userFactories.value 76 | 77 | bodyBlock 78 | .findChildrenByClass() 79 | .flatMap { property -> 80 | property.findDirectChildrenByClass().mapNotNull { 81 | it.calleeExpression!! 82 | .unwrapParenthesis() 83 | ?.text 84 | ?.takeIf(allFactoryNames::contains) 85 | ?.let(property::to) 86 | } 87 | } 88 | .forEach { (property, viewModelFactoryName) -> 89 | context.report( 90 | ISSUE, 91 | property, 92 | context.getLocation(property), 93 | errorMessage(viewModelFactoryName), 94 | // TODO would be cool if we could auto-apply a fix like the detekt/ktlint version, but 95 | // requires a rewrite. 96 | ) 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/BaseComposeLintTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.checks.infrastructure.LintDetectorTest 6 | import com.android.tools.lint.checks.infrastructure.TestLintTask 7 | import com.android.tools.lint.checks.infrastructure.TestMode 8 | import com.android.tools.lint.detector.api.Detector 9 | import com.android.tools.lint.detector.api.Issue 10 | import org.junit.runner.RunWith 11 | import org.junit.runners.JUnit4 12 | 13 | @RunWith(JUnit4::class) 14 | abstract class BaseComposeLintTest : LintDetectorTest() { 15 | 16 | protected val commonStubs = 17 | arrayOf( 18 | kotlin( 19 | """ 20 | package androidx.compose.ui 21 | 22 | import androidx.compose.runtime.Composable 23 | 24 | @Composable 25 | interface Modifier { 26 | companion object : Modifier 27 | } 28 | """ 29 | .trimIndent() 30 | ), 31 | kotlin( 32 | """ 33 | package androidx.compose.runtime 34 | 35 | annotation class Composable 36 | @Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.CLASS) 37 | annotation class StableMarker 38 | @StableMarker 39 | annotation class Stable 40 | @StableMarker 41 | annotation class Immutable 42 | 43 | interface State { 44 | val value: T 45 | } 46 | 47 | interface MutableState : State { 48 | override var value: T 49 | operator fun component1(): T 50 | operator fun component2(): (T) -> Unit 51 | } 52 | 53 | fun mutableStateOf(value: T): MutableState = TODO() 54 | 55 | fun derivedStateOf( 56 | calculation: () -> T, 57 | ): State = TODO() 58 | 59 | inline fun remember(crossinline calculation: () -> T): T = TODO() 60 | 61 | fun movableContentOf(content: @Composable () -> Unit): @Composable () -> Unit = TODO() 62 | """ 63 | .trimIndent() 64 | ), 65 | kotlin( 66 | """ 67 | package androidx.compose.ui.tooling.preview 68 | 69 | @Repeatable 70 | annotation class Preview 71 | 72 | interface PreviewParameterProvider { 73 | val values: Sequence 74 | val count get() = values.count() 75 | } 76 | 77 | annotation class PreviewParameter( 78 | val provider: KClass>, 79 | val limit: Int = Int.MAX_VALUE 80 | ) 81 | """ 82 | .trimIndent() 83 | ), 84 | kotlin( 85 | """ 86 | package androidx.compose.ui 87 | 88 | import androidx.compose.runtime.Composable 89 | import androidx.compose.ui.Modifier 90 | 91 | @Composable 92 | fun Text(text: String, modifier: Modifier = Modifier) { 93 | 94 | } 95 | """ 96 | .trimIndent() 97 | ), 98 | ) 99 | 100 | /** 101 | * Lint periodically adds new "TestModes" to LintDetectorTest. These modes act as a sort of chaos 102 | * testing mechanism, adding different common variations of code (extra spaces, extra parens, etc) 103 | * to the test files to ensure that lints are robust against them. They also make it quite 104 | * difficult to test against and need extra work sometimes to properly support, so we expose this 105 | * property to allow tests to skip certain modes that need more work in subsequent PRs. 106 | */ 107 | open val skipTestModes: Array? = null 108 | 109 | abstract override fun getDetector(): Detector 110 | 111 | abstract override fun getIssues(): List 112 | 113 | override fun lint(): TestLintTask { 114 | val lintTask = super.lint() 115 | lintTask.allowCompilationErrors(false).allowMissingSdk(true) 116 | 117 | skipTestModes?.let { testModesToSkip -> lintTask.skipTestModes(*testModesToSkip) } 118 | return lintTask 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/MutableParametersDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.checks.infrastructure.TestMode 7 | import com.android.tools.lint.detector.api.Detector 8 | import com.android.tools.lint.detector.api.Issue 9 | import org.intellij.lang.annotations.Language 10 | import org.junit.Test 11 | 12 | class MutableParametersDetectorTest : BaseComposeLintTest() { 13 | 14 | override fun getDetector(): Detector = MutableParametersDetector() 15 | 16 | override fun getIssues(): List = listOf(MutableParametersDetector.ISSUE) 17 | 18 | // Can't get typealias working correctly in this case as the combination of an 19 | // alias + lint's inability to reach kotlin intrinsic collections defeats it 20 | override val skipTestModes: Array = arrayOf(TestMode.TYPE_ALIAS) 21 | 22 | @Test 23 | fun `errors when a Composable has a mutable parameter`() { 24 | @Language("kotlin") 25 | val code = 26 | """ 27 | import androidx.compose.runtime.MutableState 28 | import androidx.compose.runtime.Composable 29 | 30 | @Composable 31 | fun Something(a: MutableState) {} 32 | @Composable 33 | fun Something(a: ArrayList) {} 34 | @Composable 35 | fun Something(a: HashSet) {} 36 | @Composable 37 | fun Something(a: MutableMap) {} 38 | """ 39 | .trimIndent() 40 | lint() 41 | .files(*commonStubs, kotlin(code)) 42 | .run() 43 | .expect( 44 | """ 45 | src/test.kt:5: Error: Using mutable objects as state in Compose will cause your users to see incorrect or stale data in your app.Mutable objects that are not observable, such as ArrayList or a mutable data class, cannot be observed by Compose to trigger recomposition when they change.See https://slackhq.github.io/compose-lints/rules/#do-not-use-inherently-mutable-types-as-parameters for more information. [ComposeMutableParameters] 46 | fun Something(a: MutableState) {} 47 | ~~~~~~~~~~~~~~~~~~~~ 48 | src/test.kt:7: Error: Using mutable objects as state in Compose will cause your users to see incorrect or stale data in your app.Mutable objects that are not observable, such as ArrayList or a mutable data class, cannot be observed by Compose to trigger recomposition when they change.See https://slackhq.github.io/compose-lints/rules/#do-not-use-inherently-mutable-types-as-parameters for more information. [ComposeMutableParameters] 49 | fun Something(a: ArrayList) {} 50 | ~~~~~~~~~~~~~~~~~ 51 | src/test.kt:9: Error: Using mutable objects as state in Compose will cause your users to see incorrect or stale data in your app.Mutable objects that are not observable, such as ArrayList or a mutable data class, cannot be observed by Compose to trigger recomposition when they change.See https://slackhq.github.io/compose-lints/rules/#do-not-use-inherently-mutable-types-as-parameters for more information. [ComposeMutableParameters] 52 | fun Something(a: HashSet) {} 53 | ~~~~~~~~~~~~~~~ 54 | src/test.kt:11: Error: Using mutable objects as state in Compose will cause your users to see incorrect or stale data in your app.Mutable objects that are not observable, such as ArrayList or a mutable data class, cannot be observed by Compose to trigger recomposition when they change.See https://slackhq.github.io/compose-lints/rules/#do-not-use-inherently-mutable-types-as-parameters for more information. [ComposeMutableParameters] 55 | fun Something(a: MutableMap) {} 56 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 57 | 4 errors, 0 warnings 58 | """ 59 | .trimIndent() 60 | ) 61 | } 62 | 63 | @Test 64 | fun `no errors when a Composable has valid parameters`() { 65 | @Language("kotlin") 66 | val code = 67 | """ 68 | import androidx.compose.runtime.State 69 | 70 | @Composable 71 | fun Something(a: String, b: (Int) -> Unit) {} 72 | @Composable 73 | fun Something(a: State) {} 74 | """ 75 | .trimIndent() 76 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ViewModelForwardingDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.android.tools.lint.detector.api.TextFormat 12 | import org.jetbrains.kotlin.psi.KtCallExpression 13 | import org.jetbrains.kotlin.psi.KtFunction 14 | import org.jetbrains.kotlin.psi.KtReferenceExpression 15 | import org.jetbrains.uast.UMethod 16 | import org.jetbrains.uast.UParameter 17 | import org.jetbrains.uast.toUElementOfType 18 | import slack.lint.compose.util.Priorities 19 | import slack.lint.compose.util.definedInInterface 20 | import slack.lint.compose.util.findDirectChildrenByClass 21 | import slack.lint.compose.util.isActual 22 | import slack.lint.compose.util.isOverride 23 | import slack.lint.compose.util.isRestartableEffect 24 | import slack.lint.compose.util.sourceImplementation 25 | import slack.lint.compose.util.unwrapParenthesis 26 | 27 | class ViewModelForwardingDetector : ComposableFunctionDetector(), SourceCodeScanner { 28 | 29 | companion object { 30 | val ISSUE = 31 | Issue.create( 32 | id = "ComposeViewModelForwarding", 33 | briefDescription = "Don't forward ViewModels through composables", 34 | explanation = 35 | """ 36 | Forwarding a `ViewModel` through multiple `@Composable` functions should be avoided. Consider using state hoisting.\ 37 | See https://slackhq.github.io/compose-lints/rules/#hoist-all-the-things for more information. 38 | """, 39 | category = Category.CORRECTNESS, 40 | priority = Priorities.NORMAL, 41 | severity = Severity.ERROR, 42 | implementation = sourceImplementation(), 43 | ) 44 | } 45 | 46 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 47 | if (function.isOverride || function.definedInInterface || function.isActual) return 48 | val bodyBlock = function.bodyBlockExpression ?: return 49 | 50 | // We get here a list of variable names that tentatively contain ViewModels 51 | val parameters = function.valueParameterList?.parameters ?: emptyList() 52 | val viewModelParameterNames = 53 | parameters 54 | .filter { parameter -> 55 | // We can't do much better than this. We could look for viewModel() / weaverViewModel() 56 | // but that would give us way less (and less useful) hits. 57 | context.evaluator 58 | .getTypeClass(parameter.toUElementOfType()?.type) 59 | ?.name 60 | ?.endsWith("ViewModel") ?: false 61 | } 62 | .mapNotNull { it.name } 63 | .toSet() 64 | 65 | // We want now to see if these parameter names are used in any other calls to functions that 66 | // start with a capital letter (so, most likely, composables). 67 | val forwardingCallExpressions = 68 | bodyBlock 69 | .findDirectChildrenByClass() 70 | .filter { callExpression -> 71 | callExpression.calleeExpression?.unwrapParenthesis()?.text?.first()?.isUpperCase() 72 | ?: false 73 | } 74 | // Avoid LaunchedEffect/DisposableEffect/etc that can use the VM as a key 75 | .filterNot { callExpression -> callExpression.isRestartableEffect } 76 | .flatMap { callExpression -> 77 | // Get VALUE_ARGUMENT that has a REFERENCE_EXPRESSION. This would map to `viewModel` in 78 | // this example: 79 | // MyComposable(viewModel, ...) 80 | callExpression.valueArguments 81 | .mapNotNull { valueArgument -> 82 | valueArgument.getArgumentExpression() as? KtReferenceExpression 83 | } 84 | .filter { reference -> reference.text in viewModelParameterNames } 85 | .map { callExpression } 86 | } 87 | for (callExpression in forwardingCallExpressions) { 88 | context.report( 89 | ISSUE, 90 | callExpression, 91 | context.getLocation(callExpression), 92 | ISSUE.getExplanation(TextFormat.TEXT), 93 | ) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/UnstableReceiverDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.detector.api.Category 6 | import com.android.tools.lint.detector.api.Issue 7 | import com.android.tools.lint.detector.api.JavaContext 8 | import com.android.tools.lint.detector.api.Severity 9 | import com.android.tools.lint.detector.api.SourceCodeScanner 10 | import com.android.tools.lint.detector.api.TextFormat 11 | import com.intellij.psi.PsiType 12 | import org.jetbrains.kotlin.psi.KtFunction 13 | import org.jetbrains.kotlin.psi.KtObjectDeclaration 14 | import org.jetbrains.kotlin.psi.KtPropertyAccessor 15 | import org.jetbrains.kotlin.psi.KtTypeReference 16 | import org.jetbrains.kotlin.psi.psiUtil.isTopLevelKtOrJavaMember 17 | import org.jetbrains.uast.UMethod 18 | import org.jetbrains.uast.getContainingUClass 19 | import slack.lint.compose.util.Priorities 20 | import slack.lint.compose.util.isStable 21 | import slack.lint.compose.util.returnsUnitOrVoid 22 | import slack.lint.compose.util.sourceImplementation 23 | 24 | class UnstableReceiverDetector : ComposableFunctionDetector(), SourceCodeScanner { 25 | companion object { 26 | val ISSUE = 27 | Issue.create( 28 | id = "ComposeUnstableReceiver", 29 | briefDescription = "Unstable receivers will always be recomposed", 30 | explanation = 31 | """ 32 | Instance composable functions on non-stable classes will always be recomposed. \ 33 | If possible, make the receiver type stable or refactor this function if that isn't possible. \ 34 | See https://slackhq.github.io/compose-lints/rules/#unstable-receivers for more information. 35 | """, 36 | category = Category.PRODUCTIVITY, 37 | priority = Priorities.NORMAL, 38 | severity = Severity.WARNING, 39 | implementation = sourceImplementation(), 40 | ) 41 | } 42 | 43 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 44 | // Not implemented as we want properties too 45 | } 46 | 47 | override fun visitComposable(context: JavaContext, method: UMethod) { 48 | lateinit var nodeToReport: KtTypeReference 49 | val receiverParam = method.uastParameters.firstOrNull() 50 | val receiverType: PsiType? = 51 | when (val source = method.sourcePsi) { 52 | is KtFunction -> { 53 | source.receiverTypeReference?.let { 54 | nodeToReport = it 55 | // Receiver is the first uastParameter 56 | receiverParam?.type 57 | } 58 | } 59 | is KtPropertyAccessor -> { 60 | source.property.receiverTypeReference?.let { 61 | nodeToReport = it 62 | // Receiver is the first uastParameter 63 | receiverParam?.type 64 | } 65 | } 66 | else -> null 67 | } 68 | 69 | // Only non-Unit returning functions can be skippable. 70 | // Non-skippable functions will always be recomposed regardless of the receiver type. 71 | if (method.returnsUnitOrVoid(context.evaluator)) { 72 | if (receiverType?.isStable(context.evaluator) == false) { 73 | context.report( 74 | ISSUE, 75 | nodeToReport, 76 | context.getLocation(nodeToReport), 77 | ISSUE.getExplanation(TextFormat.TEXT), 78 | ) 79 | } else if (!method.isTopLevelKtOrJavaMember() && !method.isStatic) { 80 | // We check both the receiver and the containing class, as classes could have 81 | // extension functions in their declarations too. 82 | val containingClass = method.getContainingUClass() 83 | val containingClassType = 84 | containingClass 85 | // If the containing class is an object, it will never be passed as a receiver arg 86 | ?.takeUnless { it.sourcePsi is KtObjectDeclaration } 87 | ?.let(context.evaluator::getClassType) ?: return 88 | 89 | if (!containingClassType.isStable(context.evaluator, resolveUClass = { containingClass })) { 90 | context.report( 91 | ISSUE, 92 | method, 93 | context.getNameLocation(method), 94 | ISSUE.getExplanation(TextFormat.TEXT), 95 | ) 96 | } 97 | } 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ModifierMissingDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.SourceCodeScanner 11 | import com.android.tools.lint.detector.api.StringOption 12 | import com.android.tools.lint.detector.api.TextFormat 13 | import org.jetbrains.kotlin.psi.KtFunction 14 | import org.jetbrains.kotlin.psi.psiUtil.isPublic 15 | import org.jetbrains.uast.UMethod 16 | import slack.lint.compose.util.Priorities 17 | import slack.lint.compose.util.definedInInterface 18 | import slack.lint.compose.util.emitsContent 19 | import slack.lint.compose.util.isInternal 20 | import slack.lint.compose.util.isOverride 21 | import slack.lint.compose.util.isPreview 22 | import slack.lint.compose.util.modifierParameter 23 | import slack.lint.compose.util.returnsUnitOrVoid 24 | import slack.lint.compose.util.sourceImplementation 25 | 26 | class ModifierMissingDetector 27 | @JvmOverloads 28 | constructor( 29 | private val contentEmitterOption: ContentEmitterLintOption = 30 | ContentEmitterLintOption(CONTENT_EMITTER_OPTION) 31 | ) : ComposableFunctionDetector(contentEmitterOption to ISSUE), SourceCodeScanner { 32 | 33 | companion object { 34 | 35 | val CONTENT_EMITTER_OPTION = ContentEmitterLintOption.newOption() 36 | internal val VISIBILITY_THRESHOLD = 37 | StringOption( 38 | name = "visibility-threshold", 39 | description = "Visibility threshold to check for Modifiers", 40 | defaultValue = "only_public", 41 | explanation = 42 | "You can control the visibility of which composables to check for Modifiers. Possible values are: `only_public` (default), `public_and_internal` and `all`", 43 | ) 44 | 45 | val ISSUE = 46 | Issue.create( 47 | id = "ComposeModifierMissing", 48 | briefDescription = "Missing modifier parameter", 49 | explanation = 50 | """ 51 | This @Composable function emits content but doesn't have a modifier parameter.\ 52 | See https://slackhq.github.io/compose-lints/rules/#when-should-i-expose-modifier-parameters for more information. 53 | """, 54 | category = Category.PRODUCTIVITY, 55 | priority = Priorities.NORMAL, 56 | severity = Severity.ERROR, 57 | implementation = sourceImplementation(), 58 | ) 59 | .setOptions(listOf(CONTENT_EMITTER_OPTION, VISIBILITY_THRESHOLD)) 60 | } 61 | 62 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 63 | // We want to find all composable functions that: 64 | // - emit content 65 | // - are not overridden or part of an interface 66 | // - are not a @Preview composable 67 | if ( 68 | function.isOverride || 69 | function.definedInInterface || 70 | method.isPreview || 71 | !method.returnsUnitOrVoid(context.evaluator) 72 | ) { 73 | return 74 | } 75 | 76 | // We want to check now the visibility to see whether it's allowed by the configuration 77 | // Possible values: 78 | // - only_public: will check for modifiers only on public composables 79 | // - public_and_internal: will check for public and internal composables 80 | // - all: will check all composables (public, internal, protected, private 81 | val shouldCheck = 82 | when (VISIBILITY_THRESHOLD.getValue(context.configuration)) { 83 | "only_public" -> function.isPublic 84 | "public_and_internal" -> function.isPublic || function.isInternal 85 | "all" -> true 86 | else -> function.isPublic 87 | } 88 | if (!shouldCheck) return 89 | 90 | // If there is a modifier param, we bail 91 | if (method.modifierParameter(context.evaluator) != null) return 92 | 93 | // In case we didn't find any `modifier` parameters, we check if it emits content and report the 94 | // error if so. 95 | if (function.emitsContent(contentEmitterOption.value)) { 96 | context.report( 97 | ISSUE, 98 | function, 99 | context.getNameLocation(function), 100 | ISSUE.getExplanation(TextFormat.TEXT), 101 | ) 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/PreviewNamingDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Issue 8 | import org.intellij.lang.annotations.Language 9 | import org.junit.Test 10 | 11 | class PreviewNamingDetectorTest : BaseComposeLintTest() { 12 | 13 | override fun getDetector(): Detector = PreviewNamingDetector() 14 | 15 | override fun getIssues(): List = listOf(PreviewNamingDetector.ISSUE) 16 | 17 | @Test 18 | fun `passes for non-preview annotations`() { 19 | @Language("kotlin") 20 | val code = 21 | """ 22 | annotation class Banana 23 | """ 24 | .trimIndent() 25 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 26 | } 27 | 28 | @Test 29 | fun `passes for preview annotations with the proper names`() { 30 | @Language("kotlin") 31 | val code = 32 | """ 33 | import androidx.compose.ui.tooling.preview.Preview 34 | 35 | @Preview 36 | annotation class BananaPreview 37 | @BananaPreview 38 | annotation class DoubleBananaPreview 39 | @Preview 40 | @Preview 41 | annotation class ApplePreviews 42 | @Preview 43 | @ApplePreviews 44 | annotation class CombinedApplePreviews 45 | @BananaPreview 46 | @ApplePreviews 47 | annotation class FruitBasketPreviews 48 | """ 49 | .trimIndent() 50 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 51 | } 52 | 53 | @Test 54 | fun `errors when a multipreview annotation is not correctly named for 1 preview`() { 55 | @Language("kotlin") 56 | val code = 57 | """ 58 | import androidx.compose.ui.tooling.preview.Preview 59 | 60 | @Preview 61 | annotation class Banana 62 | @Preview 63 | annotation class BananaPreviews 64 | @BananaPreviews 65 | annotation class WithBananaPreviews 66 | """ 67 | .trimIndent() 68 | lint() 69 | .files(*commonStubs, kotlin(code)) 70 | .run() 71 | .expect( 72 | """ 73 | src/Banana.kt:3: Error: Preview annotations with 1 preview annotations should end with the Preview suffix. 74 | See https://slackhq.github.io/compose-lints/rules/#naming-multipreview-annotations-properly for more information. [ComposePreviewNaming] 75 | @Preview 76 | ^ 77 | src/Banana.kt:5: Error: Preview annotations with 1 preview annotations should end with the Preview suffix. 78 | See https://slackhq.github.io/compose-lints/rules/#naming-multipreview-annotations-properly for more information. [ComposePreviewNaming] 79 | @Preview 80 | ^ 81 | src/Banana.kt:7: Error: Preview annotations with 1 preview annotations should end with the Preview suffix. 82 | See https://slackhq.github.io/compose-lints/rules/#naming-multipreview-annotations-properly for more information. [ComposePreviewNaming] 83 | @BananaPreviews 84 | ^ 85 | 3 errors, 0 warnings 86 | """ 87 | .trimIndent() 88 | ) 89 | } 90 | 91 | @Test 92 | fun `errors when a multipreview annotation is not correctly named for multi previews`() { 93 | @Language("kotlin") 94 | val code = 95 | """ 96 | import androidx.compose.ui.tooling.preview.Preview 97 | 98 | @Preview 99 | @Preview 100 | @Repeatable 101 | annotation class BananaPreview 102 | @BananaPreview 103 | @BananaPreview 104 | annotation class BananaPreview 105 | """ 106 | .trimIndent() 107 | lint() 108 | .files(*commonStubs, kotlin(code)) 109 | .allowDuplicates() 110 | .run() 111 | .expect( 112 | """ 113 | src/BananaPreview.kt:3: Error: Preview annotations with 2 preview annotations should end with the Previews suffix. 114 | See https://slackhq.github.io/compose-lints/rules/#naming-multipreview-annotations-properly for more information. [ComposePreviewNaming] 115 | @Preview 116 | ^ 117 | src/BananaPreview.kt:7: Error: Preview annotations with 2 preview annotations should end with the Previews suffix. 118 | See https://slackhq.github.io/compose-lints/rules/#naming-multipreview-annotations-properly for more information. [ComposePreviewNaming] 119 | @BananaPreview 120 | ^ 121 | 2 errors, 0 warnings 122 | """ 123 | .trimIndent() 124 | ) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/UnstableCollectionsDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.checks.infrastructure.TestMode 7 | import com.android.tools.lint.detector.api.Detector 8 | import com.android.tools.lint.detector.api.Issue 9 | import org.intellij.lang.annotations.Language 10 | import org.junit.Test 11 | 12 | class UnstableCollectionsDetectorTest : BaseComposeLintTest() { 13 | 14 | override fun getDetector(): Detector = UnstableCollectionsDetector() 15 | 16 | override fun getIssues(): List = listOf(UnstableCollectionsDetector.ISSUE) 17 | 18 | // Can't get typealias working correctly in this case as the combination of an 19 | // alias + lint's inability to reach kotlin intrinsic collections defeats it 20 | override val skipTestModes: Array = arrayOf(TestMode.TYPE_ALIAS) 21 | 22 | @Test 23 | fun `warnings when a Composable has a Collection List Set Map parameter`() { 24 | @Language("kotlin") 25 | val code = 26 | """ 27 | import androidx.compose.runtime.Composable 28 | 29 | @Composable 30 | fun Something(a: Collection) {} 31 | @Composable 32 | fun Something(a: List) {} 33 | @Composable 34 | fun Something(a: Set) {} 35 | @Composable 36 | fun Something(a: Map) {} 37 | """ 38 | .trimIndent() 39 | 40 | lint() 41 | .files(*commonStubs, kotlin(code)) 42 | .run() 43 | .expect( 44 | """ 45 | src/test.kt:4: Warning: The Compose Compiler cannot infer the stability of a parameter if a Collection is used in it, even if the item type is stable. 46 | You should use Kotlinx Immutable Collections instead: a: ImmutableCollection or create an @Immutable wrapper for this class: @Immutable data class ACollection(val items: Collection) 47 | See https://slackhq.github.io/compose-lints/rules/#avoid-using-unstable-collections for more information. [ComposeUnstableCollections] 48 | fun Something(a: Collection) {} 49 | ~~~~~~~~~~~~~~~~~~ 50 | src/test.kt:6: Warning: The Compose Compiler cannot infer the stability of a parameter if a List is used in it, even if the item type is stable. 51 | You should use Kotlinx Immutable Collections instead: a: ImmutableList or create an @Immutable wrapper for this class: @Immutable data class AList(val items: List) 52 | See https://slackhq.github.io/compose-lints/rules/#avoid-using-unstable-collections for more information. [ComposeUnstableCollections] 53 | fun Something(a: List) {} 54 | ~~~~~~~~~~~~ 55 | src/test.kt:8: Warning: The Compose Compiler cannot infer the stability of a parameter if a Set is used in it, even if the item type is stable. 56 | You should use Kotlinx Immutable Collections instead: a: ImmutableSet or create an @Immutable wrapper for this class: @Immutable data class ASet(val items: Set) 57 | See https://slackhq.github.io/compose-lints/rules/#avoid-using-unstable-collections for more information. [ComposeUnstableCollections] 58 | fun Something(a: Set) {} 59 | ~~~~~~~~~~~ 60 | src/test.kt:10: Warning: The Compose Compiler cannot infer the stability of a parameter if a Map is used in it, even if the item type is stable. 61 | You should use Kotlinx Immutable Collections instead: a: ImmutableMap or create an @Immutable wrapper for this class: @Immutable data class AMap(val items: Map) 62 | See https://slackhq.github.io/compose-lints/rules/#avoid-using-unstable-collections for more information. [ComposeUnstableCollections] 63 | fun Something(a: Map) {} 64 | ~~~~~~~~~~~~~~~~ 65 | 0 errors, 4 warnings 66 | """ 67 | .trimIndent() 68 | ) 69 | } 70 | 71 | @Test 72 | fun `no errors when a Composable has valid parameters`() { 73 | @Language("kotlin") 74 | val code = 75 | """ 76 | import androidx.compose.runtime.MutableState 77 | import androidx.compose.runtime.Composable 78 | 79 | interface ImmutableList 80 | interface ImmutableSet 81 | interface ImmutableMap 82 | class StringList 83 | class StringSet 84 | class StringToIntMap 85 | 86 | @Composable 87 | fun Something(a: ImmutableList, b: ImmutableSet, c: ImmutableMap) {} 88 | @Composable 89 | fun Something(a: StringList, b: StringSet, c: StringToIntMap) {} 90 | """ 91 | .trimIndent() 92 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/CompositionLocalUsageDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.checks.infrastructure.TestLintTask 7 | import com.android.tools.lint.detector.api.Detector 8 | import com.android.tools.lint.detector.api.Issue 9 | import org.junit.Test 10 | 11 | class CompositionLocalUsageDetectorTest : BaseComposeLintTest() { 12 | 13 | override fun getDetector(): Detector = CompositionLocalUsageDetector() 14 | 15 | override fun getIssues(): List = CompositionLocalUsageDetector.ISSUES.toList() 16 | 17 | override fun lint(): TestLintTask { 18 | return super.lint() 19 | .configureOption(CompositionLocalUsageDetector.ALLOW_LIST, "LocalBanana,LocalPotato") 20 | } 21 | 22 | @Test 23 | fun `warning when a CompositionLocal is defined`() { 24 | lint() 25 | .files( 26 | kotlin( 27 | """ 28 | private val LocalApple = staticCompositionLocalOf { "Apple" } 29 | internal val LocalPlum: String = staticCompositionLocalOf { "Plum" } 30 | val LocalPrune = compositionLocalOf { "Prune" } 31 | private val LocalKiwi: String = compositionLocalOf { "Kiwi" } 32 | """ 33 | ) 34 | ) 35 | .allowCompilationErrors() 36 | .run() 37 | .expectWarningCount(4) 38 | .expect( 39 | """ 40 | src/test.kt:2: Warning: `CompositionLocal`s are implicit dependencies and creating new ones should be avoided. See https://slackhq.github.io/compose-lints/rules/#compositionlocals for more information. [ComposeCompositionLocalUsage] 41 | private val LocalApple = staticCompositionLocalOf { "Apple" } 42 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 43 | src/test.kt:3: Warning: `CompositionLocal`s are implicit dependencies and creating new ones should be avoided. See https://slackhq.github.io/compose-lints/rules/#compositionlocals for more information. [ComposeCompositionLocalUsage] 44 | internal val LocalPlum: String = staticCompositionLocalOf { "Plum" } 45 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 46 | src/test.kt:4: Warning: `CompositionLocal`s are implicit dependencies and creating new ones should be avoided. See https://slackhq.github.io/compose-lints/rules/#compositionlocals for more information. [ComposeCompositionLocalUsage] 47 | val LocalPrune = compositionLocalOf { "Prune" } 48 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 49 | src/test.kt:5: Warning: `CompositionLocal`s are implicit dependencies and creating new ones should be avoided. See https://slackhq.github.io/compose-lints/rules/#compositionlocals for more information. [ComposeCompositionLocalUsage] 50 | private val LocalKiwi: String = compositionLocalOf { "Kiwi" } 51 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 52 | 0 errors, 4 warnings 53 | """ 54 | .trimIndent() 55 | ) 56 | } 57 | 58 | @Test 59 | fun `passes when a CompositionLocal is defined but it's in the allowlist`() { 60 | lint() 61 | .files( 62 | kotlin( 63 | """ 64 | val LocalBanana = staticCompositionLocalOf { "Banana" } 65 | val LocalPotato = compositionLocalOf { "Potato" } 66 | """ 67 | ) 68 | ) 69 | .allowCompilationErrors() 70 | .run() 71 | .expectClean() 72 | } 73 | 74 | @Test 75 | fun `getter is an error`() { 76 | lint() 77 | .files( 78 | kotlin( 79 | """ 80 | val LocalBanana get() = compositionLocalOf { "Prune" } 81 | val LocalPotato get() { 82 | return compositionLocalOf { "Prune" } 83 | } 84 | """ 85 | ) 86 | ) 87 | .allowCompilationErrors() 88 | .run() 89 | .expectErrorCount(2) 90 | .expect( 91 | expectedText = 92 | """ 93 | src/test.kt:2: Error: `CompositionLocal`s should be singletons and not use getters. Otherwise a new instance will be returned every call. [ComposeCompositionLocalGetter] 94 | val LocalBanana get() = compositionLocalOf { "Prune" } 95 | ~~~ 96 | src/test.kt:3: Error: `CompositionLocal`s should be singletons and not use getters. Otherwise a new instance will be returned every call. [ComposeCompositionLocalGetter] 97 | val LocalPotato get() { 98 | ~~~ 99 | 2 errors, 0 warnings 100 | """ 101 | .trimIndent() 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/SlotReusedDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2024 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.detector.api.Category 6 | import com.android.tools.lint.detector.api.Issue 7 | import com.android.tools.lint.detector.api.JavaContext 8 | import com.android.tools.lint.detector.api.Severity 9 | import com.android.tools.lint.detector.api.SourceCodeScanner 10 | import com.android.tools.lint.detector.api.TextFormat 11 | import com.android.tools.lint.detector.api.asCall 12 | import com.intellij.codeInsight.PsiEquivalenceUtil 13 | import com.intellij.psi.PsiElement 14 | import com.intellij.psi.PsiTypes 15 | import org.jetbrains.kotlin.psi.KtCallExpression 16 | import org.jetbrains.kotlin.psi.KtFunction 17 | import org.jetbrains.uast.UElement 18 | import org.jetbrains.uast.UMethod 19 | import org.jetbrains.uast.toUElement 20 | import org.jetbrains.uast.tryResolve 21 | import slack.lint.compose.util.Priorities 22 | import slack.lint.compose.util.findChildrenByClass 23 | import slack.lint.compose.util.slotParameters 24 | import slack.lint.compose.util.sourceImplementation 25 | 26 | class SlotReusedDetector : ComposableFunctionDetector(), SourceCodeScanner { 27 | 28 | companion object { 29 | 30 | val ISSUE = 31 | Issue.create( 32 | id = "SlotReused", 33 | briefDescription = "Slots should be invoked in at most one place", 34 | explanation = 35 | """ 36 | Slots should be invoked in at most once place to meet lifecycle expectations. \ 37 | Slots should not be invoked in multiple places in source code, where the invoking location changes based on some condition. This will preserve the slot's internal state when the invoking location changes. \ 38 | See https://slackhq.github.io/compose-lints/rules/#do-not-invoke-slots-in-more-than-once-place for more information. 39 | """, 40 | category = Category.CORRECTNESS, 41 | priority = Priorities.NORMAL, 42 | severity = Severity.ERROR, 43 | implementation = sourceImplementation(), 44 | ) 45 | } 46 | 47 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 48 | val composableBlockExpression = function.bodyBlockExpression ?: return 49 | val slotParameters = method.slotParameters(context.evaluator) 50 | 51 | val callExpressions = composableBlockExpression.findChildrenByClass().toList() 52 | 53 | slotParameters.forEach { slotParameter -> 54 | val slotElement: PsiElement? = slotParameter.sourceElement 55 | 56 | // Count all direct calls of the slot parameter. 57 | val slotParameterCallCount = 58 | callExpressions.count { callExpression -> 59 | val calleeElement: PsiElement? = 60 | callExpression.calleeExpression?.toUElement()?.tryResolve() 61 | 62 | calleeElement != null && 63 | slotElement != null && 64 | PsiEquivalenceUtil.areElementsEquivalent(calleeElement, slotElement) 65 | } 66 | 67 | // Count all instances where the slot parameter is passed to a slot argument for another 68 | // composable function. We make the assumption that any composable function that has a slot 69 | // and returns Unit will call the slot at least once (perhaps conditionally) because if it 70 | // didn't, what's the point of having the parameter? 71 | val slotParameterPassedAsSlotParameterCount = 72 | callExpressions.sumOf { callExpression -> 73 | val uCallExpression = callExpression.toUElement()?.asCall() ?: return@sumOf 0 74 | val psiMethod = uCallExpression.resolve() ?: return@sumOf 0 75 | 76 | val argumentMapping = context.evaluator.computeArgumentMapping(uCallExpression, psiMethod) 77 | 78 | argumentMapping.count { (expression, parameter) -> 79 | val argumentElement = expression.tryResolve() 80 | 81 | argumentElement != null && 82 | slotElement != null && 83 | // Called method is composable 84 | psiMethod.hasAnnotation("androidx.compose.runtime.Composable") && 85 | // Called method returns Unit 86 | psiMethod.returnType?.isAssignableFrom(PsiTypes.voidType()) == true && 87 | // Parameter is composable 88 | parameter.type.hasAnnotation("androidx.compose.runtime.Composable") && 89 | PsiEquivalenceUtil.areElementsEquivalent(argumentElement, slotElement) 90 | } 91 | } 92 | 93 | // Report an issue if the slot parameter was used in multiple places 94 | if (slotParameterCallCount + slotParameterPassedAsSlotParameterCount > 1) { 95 | context.report( 96 | ISSUE, 97 | slotParameter as UElement, 98 | context.getLocation(slotParameter as UElement), 99 | ISSUE.getExplanation(TextFormat.TEXT), 100 | ) 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ComposableFunctionNamingDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Category 7 | import com.android.tools.lint.detector.api.Issue 8 | import com.android.tools.lint.detector.api.JavaContext 9 | import com.android.tools.lint.detector.api.Severity 10 | import com.android.tools.lint.detector.api.StringOption 11 | import com.android.tools.lint.detector.api.TextFormat 12 | import org.jetbrains.kotlin.psi.KtFunction 13 | import org.jetbrains.uast.UMethod 14 | import slack.lint.compose.util.Priorities 15 | import slack.lint.compose.util.StringSetLintOption 16 | import slack.lint.compose.util.hasReceiverType 17 | import slack.lint.compose.util.returnsUnitOrVoid 18 | import slack.lint.compose.util.sourceImplementation 19 | 20 | class ComposableFunctionNamingDetector 21 | @JvmOverloads 22 | constructor( 23 | private val allowedNames: StringSetLintOption = 24 | StringSetLintOption(ALLOWED_COMPOSABLE_FUNCTION_NAMES) 25 | ) : ComposableFunctionDetector(allowedNames to ISSUE_UPPERCASE, allowedNames to ISSUE_LOWERCASE) { 26 | 27 | companion object { 28 | internal val ALLOWED_COMPOSABLE_FUNCTION_NAMES = 29 | StringOption( 30 | "allowed-composable-function-names", 31 | "A comma-separated list of regexes of allowed composable function names", 32 | null, 33 | "This property should define comma-separated list of regexes of allowed composable function names.", 34 | ) 35 | 36 | private val ISSUE_UPPERCASE = 37 | Issue.create( 38 | id = "ComposeNamingUppercase", 39 | briefDescription = "Unit Composables should be uppercase", 40 | explanation = 41 | """ 42 | Composable functions that return Unit should start with an uppercase letter.\ 43 | They are considered declarative entities that can be either present or absent in a composition and therefore follow the naming rules for classes.\ 44 | See https://slackhq.github.io/compose-lints/rules/#naming-composable-functions-properly for more information. 45 | """, 46 | category = Category.PRODUCTIVITY, 47 | priority = Priorities.NORMAL, 48 | severity = Severity.ERROR, 49 | implementation = sourceImplementation(), 50 | ) 51 | .setOptions(listOf(ALLOWED_COMPOSABLE_FUNCTION_NAMES)) 52 | 53 | private val ISSUE_LOWERCASE = 54 | Issue.create( 55 | id = "ComposeNamingLowercase", 56 | briefDescription = "Value-returning Composables should be lowercase", 57 | explanation = 58 | """ 59 | Composable functions that return a value should start with a lowercase letter.\ 60 | While useful and accepted outside of @Composable functions, this factory function convention has drawbacks that set inappropriate expectations for callers when used with @Composable functions.\ 61 | See https://slackhq.github.io/compose-lints/rules/#naming-composable-functions-properly for more information. 62 | """, 63 | category = Category.PRODUCTIVITY, 64 | priority = Priorities.NORMAL, 65 | severity = Severity.ERROR, 66 | implementation = sourceImplementation(), 67 | ) 68 | .setOptions(listOf(ALLOWED_COMPOSABLE_FUNCTION_NAMES)) 69 | 70 | val ISSUES = arrayOf(ISSUE_UPPERCASE, ISSUE_LOWERCASE) 71 | } 72 | 73 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 74 | // If it's a block we can't know if there is a return type or not from ktlint 75 | if (!function.hasBlockBody()) return 76 | val functionName = function.name?.takeUnless(String::isEmpty) ?: return 77 | val firstLetter = functionName.first() 78 | 79 | // If it's allowed, we don't report it 80 | val isAllowed = allowedNames.value.any { it.toRegex().matches(functionName) } 81 | if (isAllowed) return 82 | 83 | if (method.returnsUnitOrVoid(context.evaluator)) { 84 | // If it returns Unit or doesn't have a return type, we should start with an uppercase letter 85 | // If the composable has a receiver, we can ignore this. 86 | if (firstLetter.isLowerCase() && !function.hasReceiverType) { 87 | context.report( 88 | ISSUE_UPPERCASE, 89 | function, 90 | context.getNameLocation(function), 91 | ISSUE_UPPERCASE.getExplanation(TextFormat.TEXT), 92 | ) 93 | } 94 | } else { 95 | // If it returns value, the composable should start with a lowercase letter 96 | if (firstLetter.isUpperCase()) { 97 | context.report( 98 | ISSUE_LOWERCASE, 99 | function, 100 | context.getNameLocation(function), 101 | ISSUE_LOWERCASE.getExplanation(TextFormat.TEXT), 102 | ) 103 | } 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/M2ApiDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.client.api.UElementHandler 6 | import com.android.tools.lint.detector.api.BooleanOption 7 | import com.android.tools.lint.detector.api.Category.Companion.CORRECTNESS 8 | import com.android.tools.lint.detector.api.Issue 9 | import com.android.tools.lint.detector.api.JavaContext 10 | import com.android.tools.lint.detector.api.Severity.ERROR 11 | import com.android.tools.lint.detector.api.SourceCodeScanner 12 | import com.android.tools.lint.detector.api.StringOption 13 | import com.android.tools.lint.detector.api.TextFormat 14 | import com.intellij.psi.PsiNamedElement 15 | import org.jetbrains.uast.UCallExpression 16 | import org.jetbrains.uast.UElement 17 | import org.jetbrains.uast.UQualifiedReferenceExpression 18 | import org.jetbrains.uast.UResolvable 19 | import org.jetbrains.uast.kotlin.isKotlin 20 | import slack.lint.compose.util.OptionLoadingDetector 21 | import slack.lint.compose.util.Priorities.NORMAL 22 | import slack.lint.compose.util.StringSetLintOption 23 | import slack.lint.compose.util.sourceImplementation 24 | 25 | internal class M2ApiDetector 26 | @JvmOverloads 27 | constructor( 28 | private val allowList: StringSetLintOption = StringSetLintOption(ALLOW_LIST), 29 | private val workAroundMangling: BooleanOption = MANGLING_WORKAROUND, 30 | ) : OptionLoadingDetector(allowList to ISSUE), SourceCodeScanner { 31 | 32 | companion object { 33 | private const val M2Package = "androidx.compose.material" 34 | 35 | internal val ALLOW_LIST = 36 | StringOption( 37 | "allowed-m2-apis", 38 | "A comma-separated list of APIs in androidx.compose.material that should be allowed.", 39 | null, 40 | "This property should define a comma-separated list of APIs in androidx.compose.material that should be allowed.", 41 | ) 42 | 43 | internal val MANGLING_WORKAROUND = 44 | BooleanOption( 45 | "enable-mangling-workaround", 46 | "Try to work around name mangling.", 47 | false, 48 | "See https://github.com/slackhq/compose-lints/issues/167", 49 | ) 50 | 51 | val ISSUE = 52 | Issue.create( 53 | id = "ComposeM2Api", 54 | briefDescription = "Using a Compose M2 API is not recommended", 55 | explanation = 56 | """ 57 | Compose Material 2 (M2) is succeeded by Material 3 (M3). Please use M3 APIs.\ 58 | See https://slackhq.github.io/compose-lints/rules/#use-material-3 for more information. 59 | """, 60 | category = CORRECTNESS, 61 | priority = NORMAL, 62 | severity = ERROR, 63 | implementation = sourceImplementation(), 64 | ) 65 | .setOptions(listOf(ALLOW_LIST, MANGLING_WORKAROUND)) 66 | .setEnabledByDefault(false) 67 | } 68 | 69 | override fun getApplicableUastTypes() = 70 | listOf>( 71 | UCallExpression::class.java, 72 | UQualifiedReferenceExpression::class.java, 73 | ) 74 | 75 | override fun createUastHandler(context: JavaContext): UElementHandler? { 76 | // Only applicable to Kotlin files 77 | if (!isKotlin(context.uastFile?.lang)) return null 78 | return object : UElementHandler() { 79 | override fun visitCallExpression(node: UCallExpression) = checkNode(node) 80 | 81 | override fun visitQualifiedReferenceExpression(node: UQualifiedReferenceExpression) { 82 | val parent = node.uastParent 83 | if (parent is UQualifiedReferenceExpression && node == parent.receiver) { 84 | // This is part of a longer selector expression, so let the lint handle the final 85 | // reference 86 | // i.e. given 'androidx.compose.material.BottomNavigationDefaults.Elevation', we only want 87 | // to report 'androidx.compose.material.BottomNavigationDefaults.Elevation', and _not_ 88 | // 'androidx.compose.material.BottomNavigationDefaults' 89 | return 90 | } 91 | return checkNode(node) 92 | } 93 | 94 | private fun checkNode(node: UResolvable) { 95 | val resolved = node.resolve() ?: return 96 | val packageName = context.evaluator.getPackage(resolved)?.qualifiedName ?: return 97 | if (packageName == M2Package) { 98 | // Ignore any in the allow-list. 99 | val resolvedName = 100 | (resolved as? PsiNamedElement)?.name?.let { 101 | if (workAroundMangling.getValue(context)) { 102 | it.substringBefore("-") 103 | } else { 104 | it 105 | } 106 | } 107 | if (resolvedName in allowList.value) return 108 | context.report( 109 | issue = ISSUE, 110 | location = context.getLocation(node), 111 | message = ISSUE.getExplanation(TextFormat.TEXT), 112 | ) 113 | } 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/CompositionLocalUsageDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.client.api.UElementHandler 7 | import com.android.tools.lint.detector.api.Category 8 | import com.android.tools.lint.detector.api.Context 9 | import com.android.tools.lint.detector.api.Detector 10 | import com.android.tools.lint.detector.api.Issue 11 | import com.android.tools.lint.detector.api.JavaContext 12 | import com.android.tools.lint.detector.api.Severity 13 | import com.android.tools.lint.detector.api.SourceCodeScanner 14 | import com.android.tools.lint.detector.api.StringOption 15 | import com.android.tools.lint.detector.api.TextFormat 16 | import org.jetbrains.kotlin.lexer.KtTokens 17 | import org.jetbrains.kotlin.psi.KtProperty 18 | import org.jetbrains.kotlin.psi.KtPropertyAccessor 19 | import org.jetbrains.uast.UElement 20 | import org.jetbrains.uast.UField 21 | import org.jetbrains.uast.UMethod 22 | import org.jetbrains.uast.kotlin.isKotlin 23 | import slack.lint.compose.util.Priorities 24 | import slack.lint.compose.util.declaresCompositionLocal 25 | import slack.lint.compose.util.sourceImplementation 26 | 27 | class CompositionLocalUsageDetector : Detector(), SourceCodeScanner { 28 | 29 | companion object { 30 | 31 | internal val ALLOW_LIST = 32 | StringOption( 33 | "allowed-composition-locals", 34 | "A comma-separated list of CompositionLocals that should be allowed", 35 | null, 36 | "This property should define a comma-separated list of `CompositionLocal`s that should be allowed.", 37 | ) 38 | 39 | private val ALLOW_LIST_ISSUE = 40 | Issue.create( 41 | id = "ComposeCompositionLocalUsage", 42 | briefDescription = "CompositionLocals are discouraged", 43 | explanation = 44 | """ 45 | `CompositionLocal`s are implicit dependencies and creating new ones should be avoided. \ 46 | See https://slackhq.github.io/compose-lints/rules/#compositionlocals for more information. 47 | """, 48 | category = Category.PRODUCTIVITY, 49 | priority = Priorities.NORMAL, 50 | severity = Severity.WARNING, 51 | implementation = sourceImplementation(), 52 | ) 53 | .setOptions(listOf(ALLOW_LIST)) 54 | 55 | private val GETTER_ISSUE = 56 | Issue.create( 57 | id = "ComposeCompositionLocalGetter", 58 | briefDescription = "CompositionLocals should not use getters", 59 | explanation = 60 | """ 61 | `CompositionLocal`s should be singletons and not use getters. Otherwise a new \ 62 | instance will be returned every call. 63 | """, 64 | category = Category.PRODUCTIVITY, 65 | priority = Priorities.NORMAL, 66 | severity = Severity.ERROR, 67 | implementation = sourceImplementation(), 68 | ) 69 | .setOptions(listOf(ALLOW_LIST)) 70 | 71 | val ISSUES = arrayOf(ALLOW_LIST_ISSUE, GETTER_ISSUE) 72 | 73 | /** Loads a comma-separated list of allowed names from the [ALLOW_LIST] option. */ 74 | fun loadAllowList(context: Context): Set { 75 | return context.configuration 76 | .getOption(ALLOW_LIST_ISSUE, ALLOW_LIST.name) 77 | ?.splitToSequence(",") 78 | .orEmpty() 79 | .map(String::trim) 80 | .filter(String::isNotBlank) 81 | .toSet() 82 | } 83 | } 84 | 85 | private lateinit var allowList: Set 86 | 87 | override fun beforeCheckRootProject(context: Context) { 88 | super.beforeCheckRootProject(context) 89 | allowList = loadAllowList(context) 90 | } 91 | 92 | override fun getApplicableUastTypes(): List> = 93 | listOf(UField::class.java, UMethod::class.java) 94 | 95 | override fun createUastHandler(context: JavaContext): UElementHandler? { 96 | if (!isKotlin(context.uastFile?.lang)) return null 97 | return object : UElementHandler() { 98 | override fun visitField(node: UField) { 99 | val ktProperty = node.sourcePsi as? KtProperty ?: return 100 | if (ktProperty.declaresCompositionLocal && ktProperty.nameIdentifier?.text !in allowList) { 101 | context.report( 102 | ALLOW_LIST_ISSUE, 103 | node, 104 | context.getLocation(node), 105 | ALLOW_LIST_ISSUE.getExplanation(TextFormat.TEXT), 106 | ) 107 | } 108 | } 109 | 110 | override fun visitMethod(node: UMethod) { 111 | val source = node.sourcePsi 112 | if (source !is KtPropertyAccessor) return 113 | if (source.declaresCompositionLocal) { 114 | val reportable = source.node.findChildByType(KtTokens.GET_KEYWORD)?.psi ?: node 115 | context.report( 116 | GETTER_ISSUE, 117 | reportable, 118 | context.getLocation(reportable), 119 | GETTER_ISSUE.getExplanation(TextFormat.TEXT), 120 | ) 121 | } 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ParameterOrderDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.client.api.JavaEvaluator 7 | import com.android.tools.lint.detector.api.Category 8 | import com.android.tools.lint.detector.api.Issue 9 | import com.android.tools.lint.detector.api.JavaContext 10 | import com.android.tools.lint.detector.api.Severity 11 | import com.android.tools.lint.detector.api.SourceCodeScanner 12 | import org.jetbrains.kotlin.psi.KtFunction 13 | import org.jetbrains.kotlin.psi.KtFunctionType 14 | import org.jetbrains.kotlin.psi.KtNullableType 15 | import org.jetbrains.kotlin.psi.KtParameter 16 | import org.jetbrains.uast.UMethod 17 | import org.jetbrains.uast.UParameter 18 | import org.jetbrains.uast.toUElementOfType 19 | import slack.lint.compose.util.Priorities 20 | import slack.lint.compose.util.isFunctionalInterface 21 | import slack.lint.compose.util.isModifier 22 | import slack.lint.compose.util.runIf 23 | import slack.lint.compose.util.sourceImplementation 24 | 25 | class ParameterOrderDetector : ComposableFunctionDetector(), SourceCodeScanner { 26 | 27 | companion object { 28 | fun createErrorMessage(currentOrder: List, properOrder: List): String = 29 | createErrorMessage( 30 | currentOrder.joinToString { getText(it) }, 31 | properOrder.joinToString { getText(it) }, 32 | ) 33 | 34 | fun getText(uParameter: UParameter): String { 35 | return uParameter.sourcePsi?.text ?: "${uParameter.name}: ${uParameter.type.presentableText}" 36 | } 37 | 38 | private fun createErrorMessage(currentOrder: String, properOrder: String): String = 39 | """ 40 | Parameters in a composable function should be ordered following this pattern: params without defaults, modifiers, params with defaults and optionally, a trailing function that might not have a default param. 41 | Current params are: [$currentOrder] but should be [$properOrder]. 42 | See https://slackhq.github.io/compose-lints/rules/#ordering-composable-parameters-properly for more information. 43 | """ 44 | .trimIndent() 45 | 46 | val ISSUE = 47 | Issue.create( 48 | id = "ComposeParameterOrder", 49 | briefDescription = "Composable function parameters should be ordered", 50 | explanation = "This is replaced when reported", 51 | category = Category.PRODUCTIVITY, 52 | priority = Priorities.NORMAL, 53 | severity = Severity.ERROR, 54 | implementation = sourceImplementation(), 55 | ) 56 | } 57 | 58 | override fun visitComposable(context: JavaContext, method: UMethod, function: KtFunction) { 59 | // We need to make sure the proper order is respected. It should be: 60 | // 1. params without defaults 61 | // 2. modifiers 62 | // 3. params with defaults 63 | // 4. optional: function that might have no default 64 | 65 | // Let's try to build the ideal ordering first, and compare against that. 66 | val currentOrder = method.uastParameters 67 | 68 | // We look in the original params without defaults and see if the last one is a function. 69 | val hasTrailingFunction = function.hasTrailingFunction(context.evaluator) 70 | val trailingLambda = 71 | if (hasTrailingFunction) { 72 | listOf(method.uastParameters.last()) 73 | } else { 74 | emptyList() 75 | } 76 | 77 | // We extract the params without with and without defaults, and keep the order between them 78 | val (withDefaults, withoutDefaults) = 79 | method.uastParameters 80 | .runIf(hasTrailingFunction) { dropLast(1) } 81 | .partition { (it.sourcePsi as? KtParameter)?.hasDefaultValue() == true } 82 | 83 | // As ComposeModifierMissingCheck will catch modifiers without a Modifier default, we don't have 84 | // to care about that case. We will sort the params with defaults so that the modifier(s) go 85 | // first. 86 | val sortedWithDefaults = 87 | withDefaults.sortedWith( 88 | compareByDescending { it.isModifier(context.evaluator) } 89 | .thenByDescending { it.name == "modifier" } 90 | ) 91 | 92 | // We create our ideal ordering of params for the ideal composable. 93 | val properOrder = withoutDefaults + sortedWithDefaults + trailingLambda 94 | 95 | // If it's not the same as the current order, we show the rule violation. 96 | if (currentOrder != properOrder) { 97 | val errorLocation = context.getLocation(function.valueParameterList) 98 | context.report( 99 | ISSUE, 100 | function, 101 | errorLocation, 102 | createErrorMessage(currentOrder, properOrder), 103 | fix() 104 | .replace() 105 | .range(errorLocation) 106 | .with(properOrder.joinToString(prefix = "(", postfix = ")") { getText(it) }) 107 | .reformat(true) 108 | .build(), 109 | ) 110 | } 111 | } 112 | 113 | private fun KtFunction.hasTrailingFunction(evaluator: JavaEvaluator): Boolean { 114 | val shallowCheck = 115 | when (val outerType = valueParameters.lastOrNull()?.typeReference?.typeElement) { 116 | is KtFunctionType -> true 117 | is KtNullableType -> outerType.innerType is KtFunctionType 118 | else -> false 119 | } 120 | if (shallowCheck) return true 121 | 122 | // Fall back to thorough check in case of aliases 123 | val resolved = 124 | evaluator.getTypeClass(valueParameters.lastOrNull()?.toUElementOfType()?.type) 125 | ?: return false 126 | return resolved.isFunctionalInterface 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/M2ApiDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.checks.infrastructure.TestMode 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Issue 8 | import org.junit.Test 9 | 10 | class M2ApiDetectorTest : BaseComposeLintTest() { 11 | 12 | override fun getDetector(): Detector = M2ApiDetector() 13 | 14 | override fun getIssues(): List = listOf(M2ApiDetector.ISSUE) 15 | 16 | private val Stubs = 17 | arrayOf( 18 | kotlin( 19 | """ 20 | package androidx.compose.material 21 | 22 | import androidx.compose.runtime.Composable 23 | 24 | @Composable 25 | fun Text(text: String) { 26 | // no-op 27 | } 28 | 29 | @Composable 30 | fun Surface(content: @Composable () -> Unit) { 31 | // no-op 32 | } 33 | 34 | object BottomNavigationDefaults { 35 | val Elevation = 8.dp 36 | } 37 | 38 | enum class BottomDrawerValue { 39 | Closed, 40 | Open, 41 | Expanded 42 | } 43 | """ 44 | .trimIndent() 45 | ), 46 | kotlin( 47 | """ 48 | package androidx.compose.material.ripple 49 | 50 | import androidx.compose.runtime.Composable 51 | 52 | @Composable 53 | fun rememberRipple() 54 | """ 55 | .trimIndent() 56 | ), 57 | ) 58 | 59 | @Test 60 | fun smokeTest() { 61 | lint() 62 | .configureOption(M2ApiDetector.ALLOW_LIST, "Surface") 63 | .files( 64 | *Stubs, 65 | kotlin( 66 | """ 67 | import androidx.compose.material.BottomDrawerValue 68 | import androidx.compose.material.BottomNavigationDefaults 69 | import androidx.compose.material.Text 70 | import androidx.compose.material.ripple.rememberRipple 71 | import androidx.compose.runtime.Composable 72 | 73 | @Composable 74 | fun Example() { 75 | Text("Hello, world!") 76 | } 77 | 78 | @Composable 79 | fun AllowedExample() { 80 | Surface { 81 | 82 | } 83 | } 84 | 85 | @Composable 86 | fun Composite() { 87 | Surface { 88 | val ripple = rememberRipple() 89 | Text("Hello, world!") 90 | val elevation = BottomNavigationDefaults.Elevation 91 | val drawerValue = BottomDrawerValue.Closed 92 | } 93 | } 94 | """ 95 | ) 96 | .indented(), 97 | ) 98 | .allowCompilationErrors() 99 | .run() 100 | .expect( 101 | """ 102 | src/test.kt:9: Error: 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] 103 | Text("Hello, world!") 104 | ~~~~~~~~~~~~~~~~~~~~~ 105 | src/test.kt:23: Error: 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] 106 | Text("Hello, world!") 107 | ~~~~~~~~~~~~~~~~~~~~~ 108 | src/test.kt:24: Error: 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] 109 | val elevation = BottomNavigationDefaults.Elevation 110 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 111 | src/test.kt:25: Error: 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] 112 | val drawerValue = BottomDrawerValue.Closed 113 | ~~~~~~~~~~~~~~~~~~~~~~~~ 114 | 4 errors, 0 warnings 115 | """ 116 | .trimIndent() 117 | ) 118 | .expect( 119 | testMode = TestMode.FULLY_QUALIFIED, 120 | expectedText = 121 | """ 122 | src/test.kt:9: Error: 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] 123 | Text("Hello, world!") 124 | ~~~~~~~~~~~~~~~~~~~~~ 125 | src/test.kt:23: Error: 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] 126 | Text("Hello, world!") 127 | ~~~~~~~~~~~~~~~~~~~~~ 128 | src/test.kt:24: Error: 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] 129 | val elevation = androidx.compose.material.BottomNavigationDefaults.Elevation 130 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 131 | src/test.kt:25: Error: 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] 132 | val drawerValue = androidx.compose.material.BottomDrawerValue.Closed 133 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 134 | 4 errors, 0 warnings 135 | """ 136 | .trimIndent(), 137 | ) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/ModifierWithoutDefaultDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Issue 8 | import org.intellij.lang.annotations.Language 9 | import org.junit.Test 10 | 11 | class ModifierWithoutDefaultDetectorTest : BaseComposeLintTest() { 12 | 13 | override fun getDetector(): Detector = ModifierWithoutDefaultDetector() 14 | 15 | override fun getIssues(): List = listOf(ModifierWithoutDefaultDetector.ISSUE) 16 | 17 | @Test 18 | fun `errors when a Composable has modifiers but without default values`() { 19 | @Language("kotlin") 20 | val code = 21 | """ 22 | import androidx.compose.runtime.Composable 23 | import androidx.compose.ui.Modifier 24 | 25 | @Composable 26 | fun Something(modifier: Modifier) { } 27 | @Composable 28 | fun Something(modifier: Modifier = Modifier, modifier2: Modifier) { } 29 | """ 30 | .trimIndent() 31 | 32 | lint() 33 | .files(*commonStubs, kotlin(code)) 34 | .run() 35 | .expect( 36 | """ 37 | src/test.kt:5: Error: This @Composable function has a modifier parameter but it doesn't have a default value.See https://slackhq.github.io/compose-lints/rules/#modifiers-should-have-default-parameters for more information. [ComposeModifierWithoutDefault] 38 | fun Something(modifier: Modifier) { } 39 | ~~~~~~~~~~~~~~~~~~ 40 | src/test.kt:7: Error: This @Composable function has a modifier parameter but it doesn't have a default value.See https://slackhq.github.io/compose-lints/rules/#modifiers-should-have-default-parameters for more information. [ComposeModifierWithoutDefault] 41 | fun Something(modifier: Modifier = Modifier, modifier2: Modifier) { } 42 | ~~~~~~~~~~~~~~~~~~~ 43 | 2 errors, 0 warnings 44 | """ 45 | .trimIndent() 46 | ) 47 | .expectFixDiffs( 48 | """ 49 | Autofix for src/test.kt line 5: Add '= Modifier' default value.: 50 | @@ -5 +5 51 | - fun Something(modifier: Modifier) { } 52 | + fun Something(modifier: Modifier = Modifier) { } 53 | Autofix for src/test.kt line 7: Add '= Modifier' default value.: 54 | @@ -7 +7 55 | - fun Something(modifier: Modifier = Modifier, modifier2: Modifier) { } 56 | + fun Something(modifier: Modifier = Modifier, modifier2: Modifier = Modifier) { } 57 | """ 58 | .trimIndent() 59 | ) 60 | } 61 | 62 | @Test 63 | fun `passes when a Composable inside of an interface has modifiers but without default values`() { 64 | @Language("kotlin") 65 | val code = 66 | """ 67 | import androidx.compose.runtime.Composable 68 | import androidx.compose.ui.Modifier 69 | 70 | interface Bleh { 71 | @Composable 72 | fun Something(modifier: Modifier) 73 | } 74 | class BlehImpl : Bleh { 75 | @Composable 76 | override fun Something(modifier: Modifier) {} 77 | } 78 | @Composable 79 | actual fun Something(modifier: Modifier) {} 80 | """ 81 | .trimIndent() 82 | 83 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 84 | } 85 | 86 | @Test 87 | fun `passes when a Composable is an abstract function but without default values`() { 88 | @Language("kotlin") 89 | val code = 90 | """ 91 | import androidx.compose.runtime.Composable 92 | import androidx.compose.ui.Modifier 93 | 94 | abstract class Bleh { 95 | @Composable 96 | abstract fun Something(modifier: Modifier) 97 | } 98 | """ 99 | .trimIndent() 100 | 101 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 102 | } 103 | 104 | @Test 105 | fun `passes when a Composable has modifiers with defaults`() { 106 | @Language("kotlin") 107 | val code = 108 | """ 109 | import androidx.compose.runtime.Composable 110 | import androidx.compose.ui.Modifier 111 | 112 | @Composable 113 | fun Something(modifier: Modifier = Modifier) { 114 | Row(modifier = modifier) { 115 | } 116 | } 117 | @Composable 118 | fun Something(modifier: Modifier = Modifier.fillMaxSize()) { 119 | Row(modifier = modifier) { 120 | } 121 | } 122 | @Composable 123 | fun Something(modifier: Modifier = SomeOtherValueFromSomeConstant) { 124 | Row(modifier = modifier) { 125 | } 126 | } 127 | """ 128 | .trimIndent() 129 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 130 | } 131 | 132 | // https://github.com/slackhq/compose-lints/issues/408 133 | @Test 134 | fun `Modifier extensions are fine`() { 135 | @Language("kotlin") 136 | val code = 137 | """ 138 | import androidx.compose.runtime.Composable 139 | import androidx.compose.ui.Modifier 140 | 141 | @Composable 142 | fun Modifier.customBackground( 143 | foo: Foo, 144 | ): Modifier { 145 | // compute background based on Foo 146 | } 147 | """ 148 | .trimIndent() 149 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 150 | } 151 | 152 | // https://github.com/slackhq/compose-lints/issues/423 153 | @Test 154 | fun `Modifier receiver params don't count`() { 155 | @Language("kotlin") 156 | val code = 157 | """ 158 | import androidx.compose.runtime.Composable 159 | import androidx.compose.ui.Modifier 160 | 161 | /** 162 | * Automatically requests focus after initial composition. 163 | */ 164 | @Suppress("ModifierComposable") // Comment 165 | @Composable 166 | fun Modifier.focusAutoRequester(): Modifier { 167 | val focusRequester = remember { FocusRequester() } 168 | 169 | LaunchedEffect(Unit) { 170 | focusRequester.requestFocus() 171 | } 172 | 173 | return focusRequester(focusRequester) 174 | } 175 | """ 176 | .trimIndent() 177 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/UnstableReceiverDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // SPDX-License-Identifier: Apache-2.0 3 | package slack.lint.compose 4 | 5 | import com.android.tools.lint.detector.api.Detector 6 | import com.android.tools.lint.detector.api.Issue 7 | import org.intellij.lang.annotations.Language 8 | import org.junit.Test 9 | 10 | class UnstableReceiverDetectorTest : BaseComposeLintTest() { 11 | 12 | override fun getDetector(): Detector = UnstableReceiverDetector() 13 | 14 | override fun getIssues(): List = listOf(UnstableReceiverDetector.ISSUE) 15 | 16 | @Test 17 | fun `stable receiver types report no errors`() { 18 | @Language("kotlin") 19 | val code = 20 | """ 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.Stable 23 | import androidx.compose.runtime.StableMarker 24 | 25 | @StableMarker 26 | annotation class CustomStable 27 | 28 | @Stable 29 | interface ExampleInterface { 30 | @Composable fun Content() 31 | } 32 | 33 | @Stable 34 | class Example { 35 | @Composable fun Content() {} 36 | } 37 | 38 | @CustomStable 39 | class CustomExample { 40 | @Composable fun Content() {} 41 | } 42 | 43 | @Composable 44 | fun Example.OtherContent() {} 45 | 46 | @Stable 47 | enum class EnumExample { 48 | TEST; 49 | @Composable fun Content() {} 50 | } 51 | 52 | @Composable 53 | fun EnumExample.OtherContent() {} 54 | 55 | @Stable 56 | enum class EnumExample { 57 | TEST; 58 | @Composable fun Content() {} 59 | } 60 | 61 | @Composable 62 | fun EnumExample.OtherContent() {} 63 | 64 | // Primitives are ok 65 | @Composable 66 | fun String.OtherContent() {} 67 | @Composable 68 | fun Int.OtherContent() {} 69 | 70 | // Functions are ok 71 | @Composable 72 | fun (() -> Unit).OtherContent() {} 73 | @Composable 74 | fun Function.OtherContent() {} 75 | 76 | // Supertypes 77 | @Stable 78 | interface Presenter { 79 | @Composable fun present(): T 80 | } 81 | 82 | class HomePresenter : Presenter { 83 | @Composable override fun present(): String { return "hi" } 84 | } 85 | """ 86 | .trimIndent() 87 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 88 | } 89 | 90 | @Test 91 | fun `unstable receiver types report errors`() { 92 | @Language("kotlin") 93 | val code = 94 | """ 95 | import androidx.compose.runtime.Composable 96 | 97 | interface ExampleInterface { 98 | @Composable fun Content() 99 | } 100 | 101 | class Example { 102 | @Composable fun Content() {} 103 | } 104 | 105 | @Composable 106 | fun Example.OtherContent() {} 107 | 108 | // Supertypes 109 | interface Presenter { 110 | @Composable fun present() 111 | } 112 | 113 | class HomePresenter : Presenter { 114 | @Composable override fun present() { println("hi") } 115 | } 116 | """ 117 | .trimIndent() 118 | lint() 119 | .files(*commonStubs, kotlin(code)) 120 | .run() 121 | .expect( 122 | """ 123 | src/ExampleInterface.kt:4: Warning: Instance composable functions on non-stable classes will always be recomposed. If possible, make the receiver type stable or refactor this function if that isn't possible. See https://slackhq.github.io/compose-lints/rules/#unstable-receivers for more information. [ComposeUnstableReceiver] 124 | @Composable fun Content() 125 | ~~~~~~~ 126 | src/ExampleInterface.kt:8: Warning: Instance composable functions on non-stable classes will always be recomposed. If possible, make the receiver type stable or refactor this function if that isn't possible. See https://slackhq.github.io/compose-lints/rules/#unstable-receivers for more information. [ComposeUnstableReceiver] 127 | @Composable fun Content() {} 128 | ~~~~~~~ 129 | src/ExampleInterface.kt:12: Warning: Instance composable functions on non-stable classes will always be recomposed. If possible, make the receiver type stable or refactor this function if that isn't possible. See https://slackhq.github.io/compose-lints/rules/#unstable-receivers for more information. [ComposeUnstableReceiver] 130 | fun Example.OtherContent() {} 131 | ~~~~~~~ 132 | src/ExampleInterface.kt:16: Warning: Instance composable functions on non-stable classes will always be recomposed. If possible, make the receiver type stable or refactor this function if that isn't possible. See https://slackhq.github.io/compose-lints/rules/#unstable-receivers for more information. [ComposeUnstableReceiver] 133 | @Composable fun present() 134 | ~~~~~~~ 135 | src/ExampleInterface.kt:20: Warning: Instance composable functions on non-stable classes will always be recomposed. If possible, make the receiver type stable or refactor this function if that isn't possible. See https://slackhq.github.io/compose-lints/rules/#unstable-receivers for more information. [ComposeUnstableReceiver] 136 | @Composable override fun present() { println("hi") } 137 | ~~~~~~~ 138 | 0 errors, 5 warnings 139 | """ 140 | .trimIndent() 141 | ) 142 | } 143 | 144 | @Test 145 | fun `unstable receiver types with non-Unit return type report no errors`() { 146 | @Language("kotlin") 147 | val code = 148 | """ 149 | import androidx.compose.runtime.Composable 150 | 151 | interface ExampleInterface { 152 | @Composable fun Content(): String 153 | } 154 | 155 | class Example { 156 | @Composable fun Content(): String { return "hi" } 157 | } 158 | 159 | @Composable 160 | fun Example.OtherContent(): String { return "hi" } 161 | 162 | @get:Composable 163 | val Example.OtherContentProperty get() {} 164 | 165 | // Supertypes 166 | interface Presenter { 167 | @Composable fun present(): T 168 | } 169 | 170 | class HomePresenter : Presenter { 171 | @Composable override fun present(): String { return "hi" } 172 | } 173 | """ 174 | .trimIndent() 175 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/ViewModelInjectionDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.checks.infrastructure.TestLintTask 7 | import com.android.tools.lint.detector.api.Detector 8 | import com.android.tools.lint.detector.api.Issue 9 | import org.intellij.lang.annotations.Language 10 | import org.junit.Test 11 | import org.junit.runner.RunWith 12 | import org.junit.runners.Parameterized 13 | 14 | @RunWith(Parameterized::class) 15 | class ViewModelInjectionDetectorTest(private val viewModel: String) : BaseComposeLintTest() { 16 | 17 | companion object { 18 | @JvmStatic 19 | @Parameterized.Parameters(name = "viewModel = {0}") 20 | fun data(): Collection> { 21 | return listOf( 22 | arrayOf("viewModel"), 23 | arrayOf("weaverViewModel"), 24 | arrayOf("hiltViewModel"), 25 | arrayOf("injectedViewModel"), 26 | arrayOf("mavericksViewModel"), 27 | arrayOf("tangleViewModel"), 28 | ) 29 | } 30 | } 31 | 32 | override fun getDetector(): Detector = ViewModelInjectionDetector() 33 | 34 | override fun getIssues(): List = listOf(ViewModelInjectionDetector.ISSUE) 35 | 36 | override fun lint(): TestLintTask { 37 | return super.lint() 38 | .configureOption(ViewModelInjectionDetector.USER_FACTORIES, "tangleViewModel") 39 | } 40 | 41 | @Test 42 | fun `passes when a weaverViewModel is used as a default param`() { 43 | @Language("kotlin") 44 | val code = 45 | """ 46 | import androidx.compose.runtime.Composable 47 | import androidx.compose.ui.Modifier 48 | 49 | @Composable 50 | fun MyComposable( 51 | modifier: Modifier, 52 | viewModel: MyVM = $viewModel(), 53 | viewModel2: MyVM = $viewModel(), 54 | ) { } 55 | """ 56 | .trimIndent() 57 | 58 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 59 | } 60 | 61 | @Test 62 | fun `overridden functions are ignored`() { 63 | @Language("kotlin") 64 | val code = 65 | """ 66 | import androidx.compose.runtime.Composable 67 | 68 | @Composable 69 | override fun Content() { 70 | val viewModel = $viewModel() 71 | } 72 | """ 73 | .trimIndent() 74 | 75 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 76 | } 77 | 78 | @Test 79 | fun `errors when a weaverViewModel is used at the beginning of a Composable`() { 80 | @Language("kotlin") 81 | val code = 82 | """ 83 | import androidx.compose.runtime.Composable 84 | import androidx.compose.ui.Modifier 85 | 86 | @Composable 87 | fun MyComposable(modifier: Modifier) { 88 | val viewModel = $viewModel() 89 | } 90 | 91 | @Composable 92 | fun MyComposableNoParams() { 93 | val viewModel: MyVM = $viewModel() 94 | } 95 | 96 | @Composable 97 | fun MyComposableTrailingLambda(block: () -> Unit) { 98 | val viewModel: MyVM = $viewModel() 99 | } 100 | """ 101 | .trimIndent() 102 | 103 | val vmWordUnderline = "~".repeat(viewModel.length) 104 | lint() 105 | .files(*commonStubs, kotlin(code)) 106 | .run() 107 | .expect( 108 | """ 109 | src/test.kt:6: Error: Implicit dependencies of composables should be made explicit. 110 | Usages of $viewModel to acquire a ViewModel should be done in composable default parameters, so that it is more testable and flexible. 111 | See https://slackhq.github.io/compose-lints/rules/#viewmodels for more information. [ComposeViewModelInjection] 112 | val viewModel = $viewModel() 113 | ~~~~~~~~~~~~~~~~$vmWordUnderline~~~~~~~~ 114 | src/test.kt:11: Error: Implicit dependencies of composables should be made explicit. 115 | Usages of $viewModel to acquire a ViewModel should be done in composable default parameters, so that it is more testable and flexible. 116 | See https://slackhq.github.io/compose-lints/rules/#viewmodels for more information. [ComposeViewModelInjection] 117 | val viewModel: MyVM = $viewModel() 118 | ~~~~~~~~~~~~~~~~~~~~~~$vmWordUnderline~~ 119 | src/test.kt:16: Error: Implicit dependencies of composables should be made explicit. 120 | Usages of $viewModel to acquire a ViewModel should be done in composable default parameters, so that it is more testable and flexible. 121 | See https://slackhq.github.io/compose-lints/rules/#viewmodels for more information. [ComposeViewModelInjection] 122 | val viewModel: MyVM = $viewModel() 123 | ~~~~~~~~~~~~~~~~~~~~~~$vmWordUnderline~~ 124 | 3 errors, 0 warnings 125 | """ 126 | .trimIndent() 127 | ) 128 | } 129 | 130 | @Test 131 | fun `errors when a weaverViewModel is used in different branches`() { 132 | @Language("kotlin") 133 | val code = 134 | """ 135 | import androidx.compose.runtime.Composable 136 | import androidx.compose.ui.Modifier 137 | 138 | @Composable 139 | fun MyComposable(modifier: Modifier) { 140 | if (blah) { 141 | val viewModel = $viewModel() 142 | } else { 143 | val viewModel: MyOtherVM = $viewModel() 144 | } 145 | } 146 | """ 147 | .trimIndent() 148 | 149 | val vmWordUnderline = "~".repeat(viewModel.length) 150 | lint() 151 | .files(*commonStubs, kotlin(code)) 152 | .run() 153 | .expect( 154 | """ 155 | src/test.kt:7: Error: Implicit dependencies of composables should be made explicit. 156 | Usages of $viewModel to acquire a ViewModel should be done in composable default parameters, so that it is more testable and flexible. 157 | See https://slackhq.github.io/compose-lints/rules/#viewmodels for more information. [ComposeViewModelInjection] 158 | val viewModel = $viewModel() 159 | ~~~~~~~~~~~~~~~~$vmWordUnderline~~~~~~~~ 160 | src/test.kt:9: Error: Implicit dependencies of composables should be made explicit. 161 | Usages of $viewModel to acquire a ViewModel should be done in composable default parameters, so that it is more testable and flexible. 162 | See https://slackhq.github.io/compose-lints/rules/#viewmodels for more information. [ComposeViewModelInjection] 163 | val viewModel: MyOtherVM = $viewModel() 164 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~$vmWordUnderline~~ 165 | 2 errors, 0 warnings 166 | """ 167 | .trimIndent() 168 | ) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/ContentEmitterReturningValuesDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.checks.infrastructure.TestLintTask 7 | import com.android.tools.lint.detector.api.Detector 8 | import com.android.tools.lint.detector.api.Issue 9 | import org.intellij.lang.annotations.Language 10 | import org.junit.Test 11 | 12 | class ContentEmitterReturningValuesDetectorTest : BaseComposeLintTest() { 13 | 14 | override fun getDetector(): Detector = ContentEmitterReturningValuesDetector() 15 | 16 | override fun getIssues(): List = listOf(ContentEmitterReturningValuesDetector.ISSUE) 17 | 18 | override fun lint(): TestLintTask { 19 | return super.lint() 20 | .configureOption( 21 | ContentEmitterReturningValuesDetector.CONTENT_EMITTER_OPTION, 22 | "Potato,Banana", 23 | ) 24 | } 25 | 26 | @Test 27 | fun `passes when only one item emits up at the top level`() { 28 | @Language("kotlin") 29 | val code = 30 | """ 31 | import androidx.compose.runtime.Composable 32 | 33 | @Composable 34 | fun Something() { 35 | val something = rememberWhatever() 36 | Column { 37 | Text("Hi") 38 | Text("Hola") 39 | } 40 | LaunchedEffect(Unit) { 41 | } 42 | } 43 | """ 44 | .trimIndent() 45 | lint().files(kotlin(code)).allowCompilationErrors().run().expectClean() 46 | } 47 | 48 | @Test 49 | fun `passes when the composable is an extension function`() { 50 | @Language("kotlin") 51 | val code = 52 | """ 53 | import androidx.compose.runtime.Composable 54 | 55 | @Composable 56 | fun ColumnScope.Something() { 57 | Text("Hi") 58 | Text("Hola") 59 | } 60 | @Composable 61 | fun RowScope.Something() { 62 | Spacer16() 63 | Text("Hola") 64 | } 65 | """ 66 | .trimIndent() 67 | lint().files(kotlin(code)).allowCompilationErrors().run().expectClean() 68 | } 69 | 70 | @Test 71 | fun `errors when a Composable function has more than one indirect UI emitter at the top level`() { 72 | @Language("kotlin") 73 | val code = 74 | """ 75 | import androidx.compose.runtime.Composable 76 | import androidx.compose.ui.Text 77 | 78 | @Composable 79 | fun Something1() { 80 | Something2() 81 | } 82 | @Composable 83 | fun Something2(): String { 84 | Text("Hola") 85 | Something3() 86 | } 87 | @Composable 88 | fun Something3() { 89 | Potato() 90 | } 91 | @Composable 92 | fun Something4() { 93 | Banana() 94 | } 95 | @Composable 96 | fun Something5(): String { 97 | Something3() 98 | Something4() 99 | } 100 | @Composable 101 | fun Potato() { 102 | Text("Potato") 103 | } 104 | @Composable 105 | fun Banana() { 106 | Text("Banana") 107 | } 108 | """ 109 | .trimIndent() 110 | lint() 111 | .files(*commonStubs, kotlin(code)) 112 | .run() 113 | .expect( 114 | """ 115 | src/test.kt:9: Error: Composable functions should either emit content into the composition or return a value, but not both. If a composable should offer additional control surfaces to its caller, those control surfaces or callbacks should be provided as parameters to the composable function by the caller. See https://slackhq.github.io/compose-lints/rules/#do-not-emit-content-and-return-a-result for more information. [ComposeContentEmitterReturningValues] 116 | fun Something2(): String { 117 | ~~~~~~~~~~ 118 | src/test.kt:22: Error: Composable functions should either emit content into the composition or return a value, but not both. If a composable should offer additional control surfaces to its caller, those control surfaces or callbacks should be provided as parameters to the composable function by the caller. See https://slackhq.github.io/compose-lints/rules/#do-not-emit-content-and-return-a-result for more information. [ComposeContentEmitterReturningValues] 119 | fun Something5(): String { 120 | ~~~~~~~~~~ 121 | 2 errors, 0 warnings 122 | """ 123 | .trimIndent() 124 | ) 125 | } 126 | 127 | @Test 128 | fun `make sure to not report twice the same composable`() { 129 | @Language("kotlin") 130 | val code = 131 | """ 132 | import androidx.compose.runtime.Composable 133 | import androidx.compose.ui.Text 134 | 135 | @Composable 136 | fun Something(): String { 137 | Text("Hi") 138 | Text("Hola") 139 | Something2() 140 | return "hi" 141 | } 142 | @Composable 143 | fun Something2() { 144 | Text("Alo") 145 | } 146 | """ 147 | .trimIndent() 148 | lint() 149 | .files(*commonStubs, kotlin(code)) 150 | .run() 151 | .expect( 152 | """ 153 | src/test.kt:5: Error: Composable functions should either emit content into the composition or return a value, but not both. If a composable should offer additional control surfaces to its caller, those control surfaces or callbacks should be provided as parameters to the composable function by the caller. See https://slackhq.github.io/compose-lints/rules/#do-not-emit-content-and-return-a-result for more information. [ComposeContentEmitterReturningValues] 154 | fun Something(): String { 155 | ~~~~~~~~~ 156 | 1 errors, 0 warnings 157 | """ 158 | .trimIndent() 159 | ) 160 | } 161 | 162 | // https://github.com/slackhq/compose-lints/issues/339 163 | @Test 164 | fun `multiple emitters are not a warning in this lint`() { 165 | @Language("kotlin") 166 | val code = 167 | """ 168 | import androidx.compose.runtime.Composable 169 | 170 | @Composable 171 | fun Test(modifier: Modifier = Modifier) { 172 | Text(text = "TextOne") 173 | Text(text = "TextTwo") 174 | } 175 | """ 176 | .trimIndent() 177 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 178 | } 179 | 180 | // https://github.com/slackhq/compose-lints/issues/419 181 | @Test 182 | fun `ensure happy path returning is valid`() { 183 | @Language("kotlin") 184 | val code = 185 | """ 186 | import androidx.compose.runtime.Composable 187 | 188 | @Composable 189 | fun rememberInsetsController(): WindowInsetsControllerCompat? { 190 | val view = LocalView.current 191 | val window = remember { view.context.findActivity()?.window } ?: return null 192 | return remember { WindowCompat.getInsetsController(window, view) } 193 | } 194 | """ 195 | .trimIndent() 196 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/RememberMissingDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Issue 8 | import org.intellij.lang.annotations.Language 9 | import org.junit.Test 10 | 11 | class RememberMissingDetectorTest : BaseComposeLintTest() { 12 | 13 | override fun getDetector(): Detector = RememberMissingDetector() 14 | 15 | override fun getIssues(): List = listOf(RememberMissingDetector.ISSUE) 16 | 17 | @Test 18 | fun `passes when a non-remembered mutableStateOf is used outside of a Composable`() { 19 | @Language("kotlin") 20 | val code = 21 | """ 22 | import androidx.compose.runtime.mutableStateOf 23 | val msof = mutableStateOf("X") 24 | """ 25 | .trimIndent() 26 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 27 | } 28 | 29 | @Test 30 | fun `errors when a non-remembered mutableStateOf is used in a Composable`() { 31 | @Language("kotlin") 32 | val code = 33 | """ 34 | import androidx.compose.runtime.Composable 35 | import androidx.compose.runtime.mutableStateOf 36 | import androidx.compose.runtime.State 37 | 38 | @Composable 39 | fun MyComposable() { 40 | val something = mutableStateOf("X") 41 | } 42 | @Composable 43 | fun MyComposable(something: State = mutableStateOf("X")) { 44 | } 45 | """ 46 | .trimIndent() 47 | lint() 48 | .files(*commonStubs, kotlin(code)) 49 | .run() 50 | .expect( 51 | """ 52 | src/test.kt:7: Error: Using mutableStateOf in a @Composable function without it being inside of a remember function. 53 | If you don't remember the state instance, a new state instance will be created when the function is recomposed. 54 | See https://slackhq.github.io/compose-lints/rules/#state-should-be-remembered-in-composables for more information. [ComposeRememberMissing] 55 | val something = mutableStateOf("X") 56 | ~~~~~~~~~~~~~~~~~~~ 57 | src/test.kt:10: Error: Using mutableStateOf in a @Composable function without it being inside of a remember function. 58 | If you don't remember the state instance, a new state instance will be created when the function is recomposed. 59 | See https://slackhq.github.io/compose-lints/rules/#state-should-be-remembered-in-composables for more information. [ComposeRememberMissing] 60 | fun MyComposable(something: State = mutableStateOf("X")) { 61 | ~~~~~~~~~~~~~~~~~~~ 62 | 2 errors, 0 warnings 63 | """ 64 | .trimIndent() 65 | ) 66 | } 67 | 68 | @Test 69 | fun `passes when a remembered mutableStateOf is used in a Composable`() { 70 | @Language("kotlin") 71 | val code = 72 | """ 73 | import androidx.compose.runtime.Composable 74 | import androidx.compose.runtime.mutableStateOf 75 | import androidx.compose.runtime.State 76 | 77 | @Composable 78 | fun MyComposable( 79 | something: State = remember { mutableStateOf("X") } 80 | ) { 81 | val something = remember { mutableStateOf("X") } 82 | val something2 by remember { mutableStateOf("Y") } 83 | } 84 | """ 85 | .trimIndent() 86 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 87 | } 88 | 89 | @Test 90 | fun `passes when a rememberSaveable mutableStateOf is used in a Composable`() { 91 | @Language("kotlin") 92 | val code = 93 | """ 94 | import androidx.compose.runtime.Composable 95 | import androidx.compose.runtime.mutableStateOf 96 | import androidx.compose.runtime.State 97 | 98 | @Composable 99 | fun MyComposable( 100 | something: State = rememberSaveable { mutableStateOf("X") } 101 | ) { 102 | val something = rememberSaveable { mutableStateOf("X") } 103 | val something2 by rememberSaveable { mutableStateOf("Y") } 104 | } 105 | """ 106 | .trimIndent() 107 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 108 | } 109 | 110 | @Test 111 | fun `passes when a non-remembered derivedStateOf is used outside of a Composable`() { 112 | @Language("kotlin") 113 | val code = 114 | """ 115 | import androidx.compose.runtime.Composable 116 | import androidx.compose.runtime.derivedStateOf 117 | 118 | val dsof = derivedStateOf { "X" } 119 | """ 120 | .trimIndent() 121 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 122 | } 123 | 124 | @Test 125 | fun `errors when a non-remembered derivedStateOf is used in a Composable`() { 126 | @Language("kotlin") 127 | val code = 128 | """ 129 | import androidx.compose.runtime.Composable 130 | import androidx.compose.runtime.State 131 | import androidx.compose.runtime.derivedStateOf 132 | 133 | @Composable 134 | fun MyComposable() { 135 | val something = derivedStateOf { "X" } 136 | } 137 | @Composable 138 | fun MyComposable(something: State = derivedStateOf { "X" }) { 139 | } 140 | """ 141 | .trimIndent() 142 | lint() 143 | .files(*commonStubs, kotlin(code)) 144 | .run() 145 | .expect( 146 | """ 147 | src/test.kt:7: Error: Using derivedStateOf in a @Composable function without it being inside of a remember function. 148 | If you don't remember the state instance, a new state instance will be created when the function is recomposed. 149 | See https://slackhq.github.io/compose-lints/rules/#state-should-be-remembered-in-composables for more information. [ComposeRememberMissing] 150 | val something = derivedStateOf { "X" } 151 | ~~~~~~~~~~~~~~~~~~~~~~ 152 | src/test.kt:10: Error: Using derivedStateOf in a @Composable function without it being inside of a remember function. 153 | If you don't remember the state instance, a new state instance will be created when the function is recomposed. 154 | See https://slackhq.github.io/compose-lints/rules/#state-should-be-remembered-in-composables for more information. [ComposeRememberMissing] 155 | fun MyComposable(something: State = derivedStateOf { "X" }) { 156 | ~~~~~~~~~~~~~~~~~~~~~~ 157 | 2 errors, 0 warnings 158 | """ 159 | .trimIndent() 160 | ) 161 | } 162 | 163 | @Test 164 | fun `passes when a remembered derivedStateOf is used in a Composable`() { 165 | @Language("kotlin") 166 | val code = 167 | """ 168 | import androidx.compose.runtime.Composable 169 | import androidx.compose.runtime.State 170 | import androidx.compose.runtime.derivedStateOf 171 | import androidx.compose.runtime.remember 172 | 173 | @Composable 174 | fun MyComposable( 175 | something: State = remember { derivedStateOf { "X" } } 176 | ) { 177 | val something = remember { derivedStateOf { "X" } } 178 | val something2 by remember { derivedStateOf { "Y" } } 179 | } 180 | """ 181 | .trimIndent() 182 | lint().files(*commonStubs, kotlin(code)).run().expectClean() 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /compose-lint-checks/src/test/java/slack/lint/compose/PreviewPublicDetectorTest.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.detector.api.Detector 7 | import com.android.tools.lint.detector.api.Issue 8 | import org.intellij.lang.annotations.Language 9 | import org.junit.Test 10 | 11 | class PreviewPublicDetectorTest : BaseComposeLintTest() { 12 | 13 | private val stubs = 14 | kotlin( 15 | """ 16 | import androidx.compose.ui.tooling.preview.Preview 17 | import androidx.compose.ui.tooling.preview.PreviewParameterProvider 18 | 19 | @Preview 20 | annotation class CombinedPreviews 21 | 22 | class User 23 | class UserProvider : PreviewParameterProvider 24 | """ 25 | ) 26 | .indented() 27 | 28 | override fun getDetector(): Detector = PreviewPublicDetector() 29 | 30 | override fun getIssues(): List = listOf(PreviewPublicDetector.ISSUE) 31 | 32 | @Test 33 | fun `passes for non-preview public composables`() { 34 | @Language("kotlin") 35 | val code = 36 | """ 37 | import androidx.compose.runtime.Composable 38 | 39 | @Composable 40 | fun MyComposable() { } 41 | """ 42 | .trimIndent() 43 | lint().files(stubs, *commonStubs, kotlin(code)).run().expectClean() 44 | } 45 | 46 | @Test 47 | fun testDocumentationExample() { 48 | @Language("kotlin") 49 | val code = 50 | """ 51 | import androidx.compose.runtime.Composable 52 | import androidx.compose.ui.tooling.preview.Preview 53 | 54 | @Preview 55 | @Composable 56 | fun MyComposable() { } 57 | @CombinedPreviews 58 | @Composable 59 | fun MyComposable() { } 60 | """ 61 | .trimIndent() 62 | lint() 63 | .files(stubs, *commonStubs, kotlin(code)) 64 | .run() 65 | .expect( 66 | """ 67 | src/test.kt:4: Error: Composables annotated with @Preview that are used only for previewing the UI should not be public.See https://slackhq.github.io/compose-lints/rules/#preview-composables-should-not-be-public for more information. [ComposePreviewPublic] 68 | @Preview 69 | ^ 70 | src/test.kt:7: Error: Composables annotated with @Preview that are used only for previewing the UI should not be public.See https://slackhq.github.io/compose-lints/rules/#preview-composables-should-not-be-public for more information. [ComposePreviewPublic] 71 | @CombinedPreviews 72 | ^ 73 | 2 errors, 0 warnings 74 | """ 75 | .trimIndent() 76 | ) 77 | .expectFixDiffs( 78 | """ 79 | Autofix for src/test.kt line 4: Make 'private': 80 | @@ -6 +6 81 | - fun MyComposable() { } 82 | + private fun MyComposable() { } 83 | Autofix for src/test.kt line 7: Make 'private': 84 | @@ -9 +9 85 | - fun MyComposable() { } 86 | + private fun MyComposable() { } 87 | """ 88 | .trimIndent() 89 | ) 90 | } 91 | 92 | @Test 93 | fun `errors when a public preview composable uses preview params`() { 94 | @Language("kotlin") 95 | val code = 96 | """ 97 | import androidx.compose.runtime.Composable 98 | import androidx.compose.ui.tooling.preview.Preview 99 | import androidx.compose.ui.tooling.preview.PreviewParameter 100 | 101 | @Preview 102 | @Composable 103 | fun MyComposable(@PreviewParameter(User::class) user: User) { 104 | } 105 | @CombinedPreviews 106 | @Composable 107 | fun MyComposable(@PreviewParameter(User::class) user: User) { 108 | } 109 | """ 110 | .trimIndent() 111 | lint() 112 | .files(stubs, *commonStubs, kotlin(code)) 113 | .run() 114 | .expect( 115 | """ 116 | src/test.kt:5: Error: Composables annotated with @Preview that are used only for previewing the UI should not be public.See https://slackhq.github.io/compose-lints/rules/#preview-composables-should-not-be-public for more information. [ComposePreviewPublic] 117 | @Preview 118 | ^ 119 | src/test.kt:9: Error: Composables annotated with @Preview that are used only for previewing the UI should not be public.See https://slackhq.github.io/compose-lints/rules/#preview-composables-should-not-be-public for more information. [ComposePreviewPublic] 120 | @CombinedPreviews 121 | ^ 122 | 2 errors, 0 warnings 123 | """ 124 | .trimIndent() 125 | ) 126 | .expectFixDiffs( 127 | """ 128 | Autofix for src/test.kt line 5: Make 'private': 129 | @@ -7 +7 130 | - fun MyComposable(@PreviewParameter(User::class) user: User) { 131 | + private fun MyComposable(@PreviewParameter(User::class) user: User) { 132 | Autofix for src/test.kt line 9: Make 'private': 133 | @@ -11 +11 134 | - fun MyComposable(@PreviewParameter(User::class) user: User) { 135 | + private fun MyComposable(@PreviewParameter(User::class) user: User) { 136 | """ 137 | .trimIndent() 138 | ) 139 | } 140 | 141 | @Test 142 | fun `passes when a non-public preview composable uses preview params`() { 143 | @Language("kotlin") 144 | val code = 145 | """ 146 | import androidx.compose.runtime.Composable 147 | import androidx.compose.ui.tooling.preview.Preview 148 | import androidx.compose.ui.tooling.preview.PreviewParameter 149 | 150 | @Preview 151 | @Composable 152 | private fun MyComposable(@PreviewParameter(User::class) user: User) { 153 | } 154 | @CombinedPreviews 155 | @Composable 156 | internal fun MyComposable(@PreviewParameter(User::class) user: User) { 157 | } 158 | """ 159 | .trimIndent() 160 | lint().files(stubs, *commonStubs, kotlin(code)).run().expectClean() 161 | } 162 | 163 | @Test 164 | fun `passes when a private preview composable uses preview params`() { 165 | @Language("kotlin") 166 | val code = 167 | """ 168 | import androidx.compose.runtime.Composable 169 | import androidx.compose.ui.tooling.preview.Preview 170 | import androidx.compose.ui.tooling.preview.PreviewParameter 171 | 172 | @Preview 173 | @Composable 174 | private fun MyComposable(@PreviewParameter(User::class) user: User) {} 175 | 176 | @CombinedPreviews 177 | @Composable 178 | private fun MyComposable(@PreviewParameter(User::class) user: User) {} 179 | 180 | """ 181 | .trimIndent() 182 | lint().files(stubs, *commonStubs, kotlin(code)).run().expectClean() 183 | } 184 | 185 | // https://github.com/slackhq/compose-lints/issues/379 186 | @Test 187 | fun `test-only public previews are ok`() { 188 | @Language("kotlin") 189 | val code = 190 | """ 191 | import androidx.compose.runtime.Composable 192 | import androidx.compose.ui.tooling.preview.Preview 193 | import androidx.annotation.VisibleForTesting 194 | 195 | @VisibleForTesting 196 | @Preview 197 | @Composable 198 | fun MyComposable() {} 199 | """ 200 | .trimIndent() 201 | lint() 202 | .files( 203 | stubs, 204 | kotlin( 205 | """ 206 | package androidx.annotation 207 | 208 | annotation class VisibleForTesting 209 | """ 210 | ), 211 | *commonStubs, 212 | kotlin(code), 213 | ) 214 | .run() 215 | .expectClean() 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | **Unreleased** 5 | -------------- 6 | 7 | 1.4.2 8 | ----- 9 | 10 | _2024-10-22_ 11 | 12 | - **Enhancement**: Better handle name shadowing in `SlotReused` lint and reduce false positives. 13 | - Test against lint `31.8.0-alpha07`. 14 | - Various doc fixes. 15 | - Build against lint `31.7.1`. 16 | - Build against Kotlin `2.0.21`. Still targeting Kotlin 1.9 language version (lint 31.7.x's language version). 17 | 18 | Special thanks to [@SimonMarquis](https://github.com/SimonMarquis) and [@AlexVanyo](https://github.com/AlexVanyo) for contributing to this release! 19 | 20 | 1.4.1 21 | ----- 22 | 23 | _2024-10-02_ 24 | 25 | - **Fix**: Fix false positives reported by `ComposeContentEmitterReturningValues`. 26 | - **Fix**: Fix `content-emitters` configuration in docs. 27 | - **Fix**: Fix link to multipreview annotations in docs. 28 | 29 | Special thanks to [@erikghonyan](https://github.com/erikghonyan) for contributing to this release! 30 | 31 | 1.4.0 32 | ----- 33 | 34 | _2024-10-01_ 35 | 36 | - **New**: Implement `SlotReused` lint. See https://slackhq.github.io/compose-lints/rules/#do-not-invoke-slots-in-more-than-once-place for more information. 37 | - **Enhancement**: Report the function name for readability in `ComposeContentEmitterReturningValues`. 38 | - **Enhancement**: Check for inherited `@Preview` annotations up to four levels. 39 | - **Enhancement**: Allow `@VisibleForTesting`/`@TestOnly`-annotated preview composables to be public. 40 | - **Fix**: Don't report duplicate errors about multiple content emitters. 41 | - **Fix**: Normalize lint option loading to match with individual issues. 42 | - **Fix**: Use name of parameter if text is not available. 43 | - **Removed**: Delete obsolete `ComposeComposableModifier` lint check. 44 | - Various docs fixes. 45 | - Build against Lint `8.7.0`. 46 | - Update `api` and `minApi` to `16` (i.e. lint 8.7.0+). It's possible this may work with API 15 but we have not tested it. 47 | - Test against Lint `8.8.0-alpha04`. 48 | - Test against K2 UAST. 49 | - Build against Kotlin `2.0.20`. 50 | 51 | Special thanks to [@alexvanyo](https://github.com/alexvanyo), [@seve-andre](https://github.com/seve-andre), [@svenjacobs](https://github.com/svenjacobs), [@ychescale9](https://github.com/ychescale9), [@shahzadansari](https://github.com/shahzadansari), and [@kozaxinan](https://github.com/kozaxinan) for contributing to this release! 52 | 53 | 1.3.1 54 | ----- 55 | 56 | _2024-01-25_ 57 | 58 | - Lower the lint API back to `14`, not `15`. 59 | 60 | 1.3.0 61 | ----- 62 | 63 | _2024-01-25_ 64 | 65 | - **New**: Implement `ModifierComposed` check to lint against use of `Modifier.composed`, which is no longer recommended in favor of the new `Modifier.Node` API. 66 | - **New**: Implement `ComposeUnstableReceiver` check to warn when composable extension functions or composables instance functions have unstable receivers/containing classes. 67 | - **New**: Check for property accessors with composition locals. 68 | - **Enhancement**: The `ComposeComposableModifier` message now recommends the new `Modifier.Node` API. 69 | - **Enhancement**: Make lints **significantly** more robust to edge cases like typealiases, import aliases, parentheses, fully-qualified references, and whitespace. Our tests now cover all these cases. 70 | - **Enhancement**: Update `@Preview` detection to also detect Compose Desktop's own `@Preview` annotation. 71 | - **Enhancement**: Improve the `ComposeParameterOrder` check to only lint the parameter list and add a quickfix. 72 | - **Enhancement**: Add support for checking for loops in multiple content emitters. 73 | - **Fix**: Fix allowed names config for Unit-returning functions. 74 | - **Fix**: Ignore context receivers in multiple content emissions lint. 75 | - **Fix**: Allow nullable types for trailing lambdas in `ComposeParameterOrder`. 76 | - **Fix**: Best-effort work around name mangling when comparing name in M2ApiDetector's allow list. 77 | - **Fix**: Fix `ComposePreviewPublic` to always just require private, remove preview parameter configuration. 78 | - **Docs**: Improve docs for `ComposeContentEmitterReturningValues` 79 | - Build against lint-api `31.2.2`. 80 | - Test against lint-api `31.4.0-alpha06`. 81 | - Raise Kotlin apiVersion/languageVersion to `1.9.0`. 82 | 83 | Special thanks to [@jzbrooks](https://github.com/jzbrooks), [@joeMalebe](https://github.com/joeMalebe), [@dellisd](https://github.com/dellisd) for contributing to this release! 84 | 85 | 1.2.0 86 | ----- 87 | 88 | _2023-04-19_ 89 | 90 | - **Fix**: Only run `ComposeM2Api` checks on Kotlin files. 91 | - Update lint current and min API to 14, aka AGP 8.0.0+. 92 | 93 | 1.1.1 94 | ----- 95 | 96 | _2023-03-08_ 97 | 98 | * **Fix**: Use `setEnabledByDefault(false)` instead of `IGNORE` in `ComposeM2Api`. This is what we intended before, too, but didn't realize there was a dedicated API for it. Note that this changes configuration slightly as you must now explicitly enable the rule too and not just the severity. See the docs: https://slackhq.github.io/compose-lints/rules/#use-material-3. 99 | 100 | 1.1.0 101 | ----- 102 | 103 | _2023-03-07_ 104 | 105 | * **New**: Add `ComposeM2Api` rule. This rule can be used to lint against using "Material 2" (`androidx.compose.material`) APIs in codebases that have migrated to Material 3 (M3). This rule is disabled by default, see the docs for more information: https://slackhq.github.io/compose-lints/rules/#use-material-3. 106 | * **Enhancement**: Add `viewmodel-factories` lint option to `ComposeViewModelInjection`. This allows you to define your own known ViewModel factories. Thanks to [@WhosNickDoglio](https://github.com/WhosNickDoglio) for contributing this! 107 | * Build against lint-api to `30.4.2`. 108 | * Test against lint `31.1.0-alpha08`. 109 | 110 | 1.0.1 111 | ----- 112 | 113 | _2023-02-15_ 114 | 115 | ### Changes 116 | 117 | * Add installation instructions to index.md by @ZacSweers in https://github.com/slackhq/compose-lints/pull/44 118 | * Fix possible typo in README by @WhosNickDoglio in https://github.com/slackhq/compose-lints/pull/45 119 | * Hopefully fix publish-docs actions by @chrisbanes in https://github.com/slackhq/compose-lints/pull/47 120 | * Update lint-latest to v31.1.0-alpha04 by @slack-oss-bot in https://github.com/slackhq/compose-lints/pull/51 121 | * Update dependency mkdocs-material to v9.0.12 by @slack-oss-bot in https://github.com/slackhq/compose-lints/pull/50 122 | * Downgrade ComposeCompositionLocalUsage to warning by @chrisbanes in https://github.com/slackhq/compose-lints/pull/52 123 | * Misc mutable parameter fixes by @ZacSweers in https://github.com/slackhq/compose-lints/pull/49 124 | * Update plugin spotless to v6.15.0 by @slack-oss-bot in https://github.com/slackhq/compose-lints/pull/54 125 | * Update dependency gradle to v8 by @slack-oss-bot in https://github.com/slackhq/compose-lints/pull/55 126 | * Update Lint baseline by @chrisbanes in https://github.com/slackhq/compose-lints/pull/58 127 | 128 | ### New Contributors 129 | * @WhosNickDoglio made their first contribution in https://github.com/slackhq/compose-lints/pull/45 130 | 131 | **Full Changelog**: https://github.com/slackhq/compose-lints/compare/1.0.0...1.0.1 132 | 133 | 1.0.0 134 | ----- 135 | 136 | _2023-02-09_ 137 | 138 | Initial release! 139 | 140 | This is a near-full port of the original rule set to lint. It should be mostly at parity with the original rules as well. 141 | 142 | The lints target lint-api `30.4.0`/lint API `13` and target Java 11. 143 | 144 | See the docs for full usage and information: https://slackhq.github.io/compose-lints. 145 | 146 | **Notes** 147 | - `ComposeViewModelInjection` does not offer a quickfix yet. PRs welcome! 148 | - `ComposeUnstableCollections` is a warning by default rather than an error. 149 | - `CompositionLocalNaming` is not ported because this is offered in compose's bundled lint rules now. 150 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/ContentEmitterReturningValuesDetector.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2023 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose 5 | 6 | import com.android.tools.lint.client.api.UElementHandler 7 | import com.android.tools.lint.detector.api.Category 8 | import com.android.tools.lint.detector.api.Issue 9 | import com.android.tools.lint.detector.api.JavaContext 10 | import com.android.tools.lint.detector.api.Severity 11 | import com.android.tools.lint.detector.api.SourceCodeScanner 12 | import com.android.tools.lint.detector.api.TextFormat 13 | import org.jetbrains.kotlin.psi.KtCallExpression 14 | import org.jetbrains.kotlin.psi.KtFile 15 | import org.jetbrains.kotlin.psi.KtFunction 16 | import org.jetbrains.uast.UFile 17 | import org.jetbrains.uast.UMethod 18 | import org.jetbrains.uast.kotlin.isKotlin 19 | import org.jetbrains.uast.toUElementOfType 20 | import slack.lint.compose.util.OptionLoadingDetector 21 | import slack.lint.compose.util.Priorities 22 | import slack.lint.compose.util.emitsContent 23 | import slack.lint.compose.util.findChildrenByClass 24 | import slack.lint.compose.util.hasReceiverType 25 | import slack.lint.compose.util.isComposable 26 | import slack.lint.compose.util.returnsUnitOrVoid 27 | import slack.lint.compose.util.sourceImplementation 28 | import slack.lint.compose.util.unwrapParenthesis 29 | 30 | class ContentEmitterReturningValuesDetector 31 | @JvmOverloads 32 | constructor( 33 | private val contentEmitterOption: ContentEmitterLintOption = 34 | ContentEmitterLintOption(CONTENT_EMITTER_OPTION) 35 | ) : OptionLoadingDetector(contentEmitterOption to ISSUE), SourceCodeScanner { 36 | 37 | companion object { 38 | 39 | val CONTENT_EMITTER_OPTION = ContentEmitterLintOption.newOption() 40 | 41 | val ISSUE = 42 | Issue.create( 43 | id = "ComposeContentEmitterReturningValues", 44 | briefDescription = "Composable functions should emit XOR return", 45 | explanation = 46 | """ 47 | Composable functions should either emit content into the composition or return a value, but not both. \ 48 | If a composable should offer additional control surfaces to its caller, those control surfaces or callbacks should be provided as parameters to the composable function by the caller. \ 49 | See https://slackhq.github.io/compose-lints/rules/#do-not-emit-content-and-return-a-result for more information. 50 | """, 51 | category = Category.PRODUCTIVITY, 52 | priority = Priorities.NORMAL, 53 | severity = Severity.ERROR, 54 | implementation = sourceImplementation(), 55 | ) 56 | .setOptions(listOf(CONTENT_EMITTER_OPTION)) 57 | } 58 | 59 | internal val KtFunction.directUiEmitterCount: Int 60 | get() = 61 | bodyBlockExpression?.let { block -> 62 | block.statements 63 | .mapNotNull { it.unwrapParenthesis() } 64 | .filterIsInstance() 65 | .count { it.emitsContent(contentEmitterOption.value) } 66 | } ?: 0 67 | 68 | internal fun KtFunction.indirectUiEmitterCount(mapping: Map): Int { 69 | val bodyBlock = bodyBlockExpression ?: return 0 70 | return bodyBlock.statements 71 | .mapNotNull { it.unwrapParenthesis() } 72 | .filterIsInstance() 73 | .count { callExpression -> 74 | // If it's a direct hit on our list, it should count directly 75 | if (callExpression.emitsContent(contentEmitterOption.value)) return@count true 76 | 77 | val name = callExpression.calleeExpression?.text ?: return@count false 78 | // If the hit is in the provided mapping, it means it is using a composable that we know 79 | // emits 80 | // UI, that we inferred from previous passes 81 | val value = 82 | mapping 83 | .mapKeys { entry -> entry.key.name } 84 | .getOrElse(name) { 85 | return@count false 86 | } 87 | value > 0 88 | } 89 | } 90 | 91 | override fun getApplicableUastTypes() = listOf(UFile::class.java) 92 | 93 | override fun createUastHandler(context: JavaContext): UElementHandler? { 94 | if (!isKotlin(context.uastFile?.lang)) return null 95 | return object : UElementHandler() { 96 | override fun visitFile(node: UFile) { 97 | val file = node.sourcePsi as? KtFile ?: return 98 | // CHECK #1 : We want to find the composables first that are at risk of emitting content 99 | // from multiple sources. 100 | val composables = 101 | file 102 | .findChildrenByClass() 103 | .filter { 104 | val method = it.toUElementOfType() ?: return@filter false 105 | method.isComposable 106 | } 107 | // We don't want to analyze composables that are extension functions, as they might be 108 | // things like 109 | // BoxScope which are legit, and we want to avoid false positives. 110 | // We want only methods with a body 111 | .filter { it.hasBlockBody() } 112 | .filterNot { it.hasReceiverType } 113 | .toList() 114 | 115 | if (composables.isEmpty()) return 116 | 117 | // Now we want to get the count of direct emitters in them: the composables we know for a 118 | // fact that output UI 119 | val composableToEmissionCount = composables.associateWith { it.directUiEmitterCount } 120 | 121 | // We can start showing errors, for composables that emit at all (from the list of 122 | // known composables) 123 | val directEmissionsReported = composableToEmissionCount.filterValues { it > 0 }.keys 124 | for (composable in directEmissionsReported) { 125 | if ( 126 | composable.toUElementOfType()?.returnsUnitOrVoid(context.evaluator) != true 127 | ) { 128 | context.report( 129 | ISSUE, 130 | composable, 131 | context.getNameLocation(composable), 132 | ISSUE.getExplanation(TextFormat.TEXT), 133 | ) 134 | } 135 | } 136 | 137 | // Now we can give some extra passes through the list of composables, and try to get a more 138 | // accurate count. 139 | // We want to make sure that if these composables are using other composables in this file 140 | // that emit UI, those are taken into account too. For example: 141 | // @Composable fun Comp1() { Text("Hi") } 142 | // @Composable fun Comp2() { Text("Hola") } 143 | // @Composable fun Comp3() { Comp1() Comp2() } // This wouldn't be picked up at first, but 144 | // should after 1 loop 145 | var currentMapping = composableToEmissionCount 146 | 147 | var shouldMakeAnotherPass = true 148 | while (shouldMakeAnotherPass) { 149 | val updatedMapping = 150 | currentMapping.mapValues { (functionNode, _) -> 151 | functionNode.indirectUiEmitterCount(currentMapping) 152 | } 153 | when { 154 | updatedMapping != currentMapping -> currentMapping = updatedMapping 155 | else -> shouldMakeAnotherPass = false 156 | } 157 | } 158 | 159 | // Here we have the settled data after all the needed passes, so we want to show errors for 160 | // them, if they were not caught already by the 1st emission loop 161 | currentMapping 162 | .filterValues { it > 0 } 163 | .filterNot { directEmissionsReported.contains(it.key) } 164 | .keys 165 | .forEach { composable -> 166 | if ( 167 | composable.toUElementOfType()?.returnsUnitOrVoid(context.evaluator) != true 168 | ) { 169 | context.report( 170 | ISSUE, 171 | composable, 172 | context.getNameLocation(composable), 173 | ISSUE.getExplanation(TextFormat.TEXT), 174 | ) 175 | } 176 | } 177 | } 178 | } 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /compose-lint-checks/src/main/java/slack/lint/compose/util/Composables.kt: -------------------------------------------------------------------------------- 1 | // Copyright (C) 2022 Salesforce, Inc. 2 | // Copyright 2022 Twitter, Inc. 3 | // SPDX-License-Identifier: Apache-2.0 4 | package slack.lint.compose.util 5 | 6 | import com.android.tools.lint.client.api.JavaEvaluator 7 | import com.intellij.psi.PsiElement 8 | import org.jetbrains.kotlin.psi.KtCallExpression 9 | import org.jetbrains.kotlin.psi.KtCallableDeclaration 10 | import org.jetbrains.kotlin.psi.KtFunction 11 | import org.jetbrains.kotlin.psi.KtParameter 12 | import org.jetbrains.kotlin.psi.KtProperty 13 | import org.jetbrains.kotlin.psi.KtPropertyAccessor 14 | import org.jetbrains.kotlin.psi.psiUtil.referenceExpression 15 | import org.jetbrains.uast.UMethod 16 | import org.jetbrains.uast.UParameter 17 | import org.jetbrains.uast.toUElementOfType 18 | 19 | fun KtFunction.emitsContent(providedContentEmitters: Set): Boolean { 20 | return if (toUElementOfType()?.isComposable == true) { 21 | sequence { 22 | tailrec suspend fun SequenceScope.scan(elements: List) { 23 | if (elements.isEmpty()) return 24 | val toProcess = 25 | elements 26 | .mapNotNull { current -> 27 | if (current is KtCallExpression) { 28 | if (current.emitExplicitlyNoContent) { 29 | null 30 | } else { 31 | yield(current) 32 | current 33 | } 34 | } else { 35 | current 36 | } 37 | } 38 | .flatMap { it.children.toList() } 39 | return scan(toProcess) 40 | } 41 | scan(listOf(this@emitsContent)) 42 | } 43 | .any { it.emitsContent(providedContentEmitters) } 44 | } else { 45 | false 46 | } 47 | } 48 | 49 | private val KtCallExpression.emitExplicitlyNoContent: Boolean 50 | get() = calleeExpression?.text in ComposableNonEmittersList 51 | 52 | fun KtCallExpression.emitsContent(providedContentEmitters: Set): Boolean { 53 | val methodName = calleeExpression?.text ?: return false 54 | return ComposableEmittersList.contains(methodName) || 55 | ComposableEmittersListRegex.matches(methodName) || 56 | providedContentEmitters.contains(methodName) || 57 | containsComposablesWithModifiers 58 | } 59 | 60 | private val KtCallExpression.containsComposablesWithModifiers: Boolean 61 | get() = valueArguments.filter { it.isNamed() }.any { it.getArgumentName()?.text == "modifier" } 62 | 63 | /** 64 | * This is a denylist with common composables that emit content in their own window. Feel free to 65 | * add more elements if you stumble upon them in code reviews that should have triggered an error 66 | * from this rule. 67 | */ 68 | private val ComposableNonEmittersList = setOf("AlertDialog", "ModalBottomSheetLayout") 69 | 70 | /** 71 | * This is an allowlist with common composables that emit content. Feel free to add more elements if 72 | * you stumble upon them in code reviews that should have triggered an error from this rule. 73 | */ 74 | private val ComposableEmittersList by lazy { 75 | setOf( 76 | // androidx.compose.foundation 77 | "BasicTextField", 78 | "Box", 79 | "Canvas", 80 | "ClickableText", 81 | "Column", 82 | "Icon", 83 | "Image", 84 | "Layout", 85 | "LazyColumn", 86 | "LazyRow", 87 | "LazyVerticalGrid", 88 | "Row", 89 | "Text", 90 | // android.compose.material 91 | "BottomDrawer", 92 | "Button", 93 | "Card", 94 | "Checkbox", 95 | "CircularProgressIndicator", 96 | "Divider", 97 | "DropdownMenu", 98 | "DropdownMenuItem", 99 | "ExposedDropdownMenuBox", 100 | "ExtendedFloatingActionButton", 101 | "FloatingActionButton", 102 | "IconButton", 103 | "IconToggleButton", 104 | "LeadingIconTab", 105 | "LinearProgressIndicator", 106 | "ListItem", 107 | "ModalBottomSheetLayout", 108 | "ModalDrawer", 109 | "NavigationRail", 110 | "NavigationRailItem", 111 | "OutlinedButton", 112 | "OutlinedTextField", 113 | "RadioButton", 114 | "Scaffold", 115 | "ScrollableTabRow", 116 | "Slider", 117 | "SnackbarHost", 118 | "Surface", 119 | "SwipeToDismiss", 120 | "Switch", 121 | "Tab", 122 | "TabRow", 123 | "TextButton", 124 | "TopAppBar", 125 | // Accompanist 126 | "BottomNavigation", 127 | "BottomNavigationContent", 128 | "BottomNavigationSurface", 129 | "FlowColumn", 130 | "FlowRow", 131 | "HorizontalPager", 132 | "HorizontalPagerIndicator", 133 | "SwipeRefresh", 134 | "SwipeRefreshIndicator", 135 | "TopAppBarContent", 136 | "TopAppBarSurface", 137 | "VerticalPager", 138 | "VerticalPagerIndicator", 139 | "WebView", 140 | ) 141 | } 142 | 143 | val ComposableEmittersListRegex by lazy { 144 | Regex( 145 | listOf( 146 | "Spacer\\d*" // Spacer() + SpacerNUM() 147 | ) 148 | .joinToString(separator = "|", prefix = "(", postfix = ")") 149 | ) 150 | } 151 | 152 | val ModifierNames by lazy(LazyThreadSafetyMode.NONE) { setOf("Modifier", "GlanceModifier") } 153 | val ModifierQualifiedNames by 154 | lazy(LazyThreadSafetyMode.NONE) { 155 | setOf("androidx.compose.ui.Modifier", "androidx.glance.GlanceModifier") 156 | } 157 | 158 | val KtCallableDeclaration.isModifier: Boolean 159 | get() = ModifierNames.contains(typeReference?.text) 160 | 161 | fun UParameter.isModifier(evaluator: JavaEvaluator): Boolean { 162 | (sourcePsi as? KtParameter)?.let { 163 | if (it.typeReference?.text in ModifierNames) { 164 | return true 165 | } 166 | } 167 | // Fall back to more thorough approach 168 | return ModifierQualifiedNames.any { evaluator.typeMatches(type, it) } 169 | } 170 | 171 | fun UParameter.isSlotParameter(evaluator: JavaEvaluator): Boolean = 172 | typeReference?.type?.hasAnnotation("androidx.compose.runtime.Composable") == true && 173 | evaluator.getTypeClass(this.type).let { it != null && it.isFunctionalInterface } 174 | 175 | val KtCallableDeclaration.isModifierReceiver: Boolean 176 | get() = ModifierNames.contains(receiverTypeReference?.text) 177 | 178 | val KtFunction.modifierParameter: KtParameter? 179 | get() { 180 | val modifiers = valueParameters.filter { it.isModifier } 181 | return modifiers.firstOrNull { it.name == "modifier" } ?: modifiers.firstOrNull() 182 | } 183 | 184 | fun UMethod.modifierParameter(evaluator: JavaEvaluator): UParameter? { 185 | val modifiers = uastParameters.filter { it.isModifier(evaluator) } 186 | return modifiers.firstOrNull { it.name == "modifier" } ?: modifiers.firstOrNull() 187 | } 188 | 189 | fun UMethod.slotParameters(evaluator: JavaEvaluator): List = 190 | uastParameters.filter { it.isSlotParameter(evaluator) } 191 | 192 | val KtProperty.declaresCompositionLocal: Boolean 193 | get() { 194 | if (isVar || !hasInitializer()) return false 195 | 196 | val initializer = initializer?.unwrapParenthesis() ?: return false 197 | 198 | return initializer is KtCallExpression && 199 | initializer.referenceExpression()?.text in CompositionLocalReferenceExpressions 200 | } 201 | 202 | val KtPropertyAccessor.declaresCompositionLocal: Boolean 203 | get() { 204 | if (!isGetter) return false 205 | val body = bodyExpression ?: bodyBlockExpression 206 | val expression = 207 | body?.unwrapBlock()?.unwrapReturnExpression()?.unwrapParenthesis() ?: return false 208 | 209 | return expression is KtCallExpression && 210 | expression.referenceExpression()?.text in CompositionLocalReferenceExpressions 211 | } 212 | 213 | private val CompositionLocalReferenceExpressions by 214 | lazy(LazyThreadSafetyMode.NONE) { setOf("staticCompositionLocalOf", "compositionLocalOf") } 215 | 216 | val KtCallExpression.isRestartableEffect: Boolean 217 | get() = RestartableEffects.contains(calleeExpression?.text) 218 | 219 | // From https://developer.android.com/jetpack/compose/side-effects#restarting-effects 220 | // Also includes Circuit's produceRetainedState 221 | private val RestartableEffects by 222 | lazy(LazyThreadSafetyMode.NONE) { 223 | setOf("LaunchedEffect", "produceState", "produceRetainedState", "DisposableEffect") 224 | } 225 | --------------------------------------------------------------------------------