Skip to content

Commit

Permalink
Add decomposeComponentContext rule
Browse files Browse the repository at this point in the history
  • Loading branch information
Alexey Panov committed Feb 11, 2025
1 parent 2693afe commit bba4f12
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 68 deletions.
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dependencies {
}

kotlin {
jvmToolchain(8)
jvmToolchain(17)
}

tasks.withType<Test>().configureEach {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package org.example.detekt

import io.gitlab.arturbosch.detekt.api.CodeSmell
import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.Debt
import io.gitlab.arturbosch.detekt.api.Entity
import io.gitlab.arturbosch.detekt.api.Issue
import io.gitlab.arturbosch.detekt.api.Rule
import io.gitlab.arturbosch.detekt.api.Severity
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.psiUtil.containingClass

/**
* A custom Detekt rule to prevent the use of `defaultComponentContext`
* inside Composable functions.
*
* This rule applies only within `Activity` or `Fragment` classes and checks:
* 1. If the file imports `com.arkivanov.decompose.defaultComponentContext`.
* 2. If `defaultComponentContext` is used inside a Composable function.
* 3. If `defaultComponentContext` is used inside `setContent {}`.
*/
class DecomposeComponentContextRule(config: Config) : Rule(config) {
override val issue = Issue(
id = javaClass.simpleName,
severity = Severity.CodeSmell,
description = "Avoid using defaultComponentContext inside Composable functions.",
debt = Debt(mins = 1)
)

override fun visitNamedFunction(function: KtNamedFunction) {
super.visitNamedFunction(function)

// Ensure the function is inside an Activity or Fragment
val ktClass = function.containingClass() ?: return
if (ktClass.isActivity().not() && ktClass.isFragment().not()) return

// Check if the file imports `defaultComponentContext`
if (function.containingKtFile.hasDefaultComponentContextImport().not()) return

if (function.isComposableFun()) {
// Directly scan the function body for `defaultComponentContext
function.bodyExpression?.findDefaultComponentContextAndReport()
} else {
// If it's not a Composable, check if `setContent {}` is used
val setContentCall = function.findDescendantCalleeExpression("setContent")
?: return

// Extract the lambda block inside `setContent`
val lambdaExpression =
setContentCall.lambdaArguments.firstOrNull()?.getLambdaExpression() ?: return

// Scan the lambda block for `defaultComponentContext`
lambdaExpression.bodyExpression?.findDefaultComponentContextAndReport()
}
}

/**
* Checks if the Kotlin file imports `defaultComponentContext`.
*/
private fun KtFile.hasDefaultComponentContextImport(): Boolean {
return hasImport("import com.arkivanov.decompose.defaultComponentContext")
}

/**
* Finds occurrences of `defaultComponentContext` inside an expression
* and reports them as a code smell.
*/
private fun KtExpression.findDefaultComponentContextAndReport() {
val expression = findDescendantCalleeExpression("defaultComponentContext") ?: return

report(
CodeSmell(
issue = issue,
entity = Entity.from(expression),
message = "Avoid using defaultComponentContext inside Composable functions."
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import io.gitlab.arturbosch.detekt.api.Config
import io.gitlab.arturbosch.detekt.api.RuleSet
import io.gitlab.arturbosch.detekt.api.RuleSetProvider

class MyRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "MyRuleSet"
class DecomposeRuleSetProvider : RuleSetProvider {
override val ruleSetId: String = "DecomposeRuleSet"

override fun instance(config: Config): RuleSet {
return RuleSet(
ruleSetId,
listOf(
MyRule(config),
DecomposeComponentContextRule(config)
),
)
}
Expand Down
27 changes: 0 additions & 27 deletions src/main/kotlin/org/example/detekt/MyRule.kt

This file was deleted.

70 changes: 70 additions & 0 deletions src/main/kotlin/org/example/detekt/Utils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package org.example.detekt

import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtClass
import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtFunction
import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType

/**
* Finds a descendant function call expression with a specific name.
*
* @param text The function name to search for (e.g., "setContent").
* @return The matching `KtCallExpression`, or `null` if not found.
*/
internal fun KtExpression.findDescendantCalleeExpression(text: String): KtCallExpression? {
return collectDescendantsOfType<KtCallExpression>()
.find { it.calleeExpression?.text == text }
}

/**
* Checks if the Kotlin file contains a specific import statement.
*
* @param import The fully qualified import statement to check.
* @return `true` if the import exists, otherwise `false`.
*/
internal fun KtFile.hasImport(import: String): Boolean {
return importDirectives.any { it.text == import }
}

/**
* Determines if a function is marked with the `@Composable` annotation.
*
* @return `true` if the function is Composable, otherwise `false`.
*/
internal fun KtFunction.isComposableFun(): Boolean {
return annotationEntries.any { it.text == "@Composable" }
}

/**
* Determines if a class extends an `Activity`.
*
* @return `true` if the class is an Activity, otherwise `false`.
*/
internal fun KtClass.isActivity(): Boolean {
return hasSuperTypeEndingWith("Activity")
}

/**
* Determines if a class extends a `Fragment`.
*
* @return `true` if the class is a Fragment, otherwise `false`.
*/
internal fun KtClass.isFragment(): Boolean {
return hasSuperTypeEndingWith("Fragment")
}

/**
* Checks if the class has a superclass that ends with a given suffix.
*
* This is useful for detecting inheritance from `Activity`, `Fragment`, or any custom base classes.
*
* @param suffix The suffix to check (e.g., "Activity", "Fragment").
* @return `true` if the class inherits from a matching type, otherwise `false`.
*/
private fun KtClass.hasSuperTypeEndingWith(suffix: String): Boolean {
return superTypeListEntries
.mapNotNull { it.typeReference?.text }
.any { it.endsWith(suffix) }
}
Original file line number Diff line number Diff line change
@@ -1 +1 @@
org.example.detekt.MyRuleSetProvider
org.example.detekt.DecomposeRuleSetProvider
4 changes: 2 additions & 2 deletions src/main/resources/config/config.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
MyRuleSet:
MyRule:
DecomposeRuleSetProvider:
DecomposeComponentContextRule:
active: true
Loading

0 comments on commit bba4f12

Please sign in to comment.