How to write custom lint rules to maintain naming conventions
Android Lint offers over 400 different lint issues to keep our code maintainable and bug-free. But what if it’s not enough for you? Maybe you want to introduce a custom rule for checking if your method always has a return type or for naming RxJava functions in your project. In our current project at Kolibree (part of the Baracoda group), we use five naming conventions related to RxJava functions:
...Once
- when there is only one value emitted and after that, the complete event will come, for examplegetAccountOnce()
...Stream
- the stream might emit values or might never emit any value...OnceAndStream
- the stream will emit values as fast as it can after subscription....Completable
- the stream will finish without emitting any value. We only use this on methods that expose aCompletable
...Maybe
- the stream might emit a value or complete without emitting. Used only on methods that expose aMaybe
These assumptions were created based on a great article Reactive Frustrations written by Tomasz Polański. This really helps us maintain our codebase, which is strongly based on RxJava.
Additionally, you should check out the great lint rules by Niklas Baudy to support maintaining RxJava code.
Table of contents
How to create a custom lint rule
For this article, I'm assuming that you've already created an Android project.
First, we want to create a new Java/Kotlin module that will contain our Lint Rule. Let’s simply name it lint-rules
.
After that, we need to configure: lint-rules/build.gradle
1plugins {
2 id 'java-library'
3 id 'kotlin'
4}
5
6java {
7 sourceCompatibility = JavaVersion.VERSION_1_8
8 targetCompatibility = JavaVersion.VERSION_1_8
9}
10
11dependencies {
12 implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
13
14 def lint_version = '30.1.0-alpha03'
15 compileOnly "com.android.tools.lint:lint-api:$lint_version"
16
17 testImplementation "com.android.tools.lint:lint-tests:$lint_version"
18 testImplementation 'junit:junit:4.13.2'
19}
Be careful, we need to use compileOnly
for lint-api
!
Now we are ready to implement our lint rule. First, let’s clarify the most important classes and definitions required to implement the custom lint rule.
Issue - An issue is a potential bug in an Android application. An issue is discovered by a [Detector] and has an associated [Severity].
Detector - A detector is able to find a particular problem (or a set of related problems). Each problem type is uniquely identified as an [Issue].
Severity - Severity of an issue found by lint.
PSI - A PSI (Program Structure Interface) file represents a hierarchy of PSI elements (so-called PSI trees). A single PSI file (itself being a PSI element) may contain several PSI trees in specific programming languages. A PSI element, in turn, can have child PSI elements.
UAST - A Unified Abstract Syntax Tree is an abstraction layer on the PSI of different JVM languages. It provides a unified API for working with common language elements like classes and method declarations, literal values, and control flow operators.
Also you can and should check the official documentation about how the lint works.
Once you have some basic knowledge about that, we can start coding.
First, let’s create a new Kotlin file that will hold our lint issue RxJavaUnconventionalMethodNamingIssue
as well as RxIssueDetector
. Don’t worry about RxNodeVisitor
for now, we’ll come back to it later.
1object RxJavaUnconventionalMethodNamingIssue {
2 /**
3 * The fixed id of the issue
4 */
5 private const val ID = "RxJavaUnconventionalMethodNamingIssue"
6
7 /**
8 * The priority, a number from 1 to 10 with 10 being most important/severe
9 */
10 private const val PRIORITY = 7
11
12 /**
13 * Description short summary (typically 5-6 words or less), typically describing
14 * the problem rather than the fix (e.g. "Missing minSdkVersion")
15 */
16 private const val DESCRIPTION = "Wrong Rx function name suffix."
17
18 /**
19 * A full explanation of the issue, with suggestions for how to fix it
20 */
21 private const val EXPLANATION = """
22 Function is wrongly named. Please make sure that function name matches rules described in
23 https://upday.github.io/blog/reactive_frustrations_1/#reasoning-about-the-code
24 """
25
26 /**
27 * The associated category, if any @see [Category]
28 */
29 private val CATEGORY = Category.CUSTOM_LINT_CHECKS
30
31 /**
32 * The default severity of the issue
33 */
34 private val SEVERITY = Severity.WARNING
35
36 val ISSUE = Issue.create(
37 ID,
38 DESCRIPTION,
39 EXPLANATION,
40 CATEGORY,
41 PRIORITY,
42 SEVERITY,
43 Implementation(RxIssueDetector::class.java, Scope.JAVA_FILE_SCOPE)
44 )
45
46 class RxIssueDetector : Detector(), Detector.UastScanner {
47 override fun getApplicableUastTypes(): List<Class<out UElement>> =
48 listOf(UMethod::class.java)
49
50 override fun createUastHandler(context: JavaContext): UElementHandler =
51 RxNodeVisitor(context)
52 }
53}
Don't forget Issue and RxIssueDetector
The two most important parts of this object are Issue
and RxIssueDetector
. The first one is mostly self-explanatory. We define Issue
which will be called while reporting our code warnings.
RxIssueDetector
implements Detector.UastScanner
which is:
The interface to be implemented by lint detectors that wants to analyze Java source files (or other similar source files, such as Kotlin files).
This class contains two methods. getApplicableUastTypes
is for defining which AST types we would like to visit and check, and createUastHandler
is responsible for creating UElementHandler
which is responsible for visiting nodes and checking our implemented rules.
getApplicableUastTypes(): List<Class<out UElement>>
* Return the types of AST nodes that should be visited.createUastHandler(context: JavaContext): UElementHandler
* The [UElementHandler] is similar to a [UastVisitor], but it
* is used to only visit a single element. Detectors tell lint
* which types of elements they want to be called for by invoking
* [UastScanner.getApplicableUastTypes].
Now let’s focus on the main part, which is writing our method visitor.
Let’s write a simple one just for the Single
type. Here are the steps to perform the check:
- Check if the method return type is
Single
- Check if the method name ends with the
Once
suffix - If not, report the lint issue with the customized message
1class RxNodeVisitor(private val context: JavaContext) : UElementHandler() {
2 override fun visitMethod(node: UMethod) {
3 if (node.returnClassName() == "Single") {
4 if (!node.name.endsWith("Once")) {
5 reportIssue(node)
6 }
7 }
8 }
9
10 private fun UMethod.returnClassName(): String =
11 (returnTypeReference?.type as? PsiClassType)?.className ?: ""
12
13 private fun reportIssue(node: UMethod) {
14 context.report(
15 issue = ISSUE,
16 scopeClass = node,
17 location = context.getNameLocation(node),
18 message = """
19 [Single] returning functions should be named with suffix Once.
20 Example: removeAccountOnce()
21 """
22 )
23 }
24}
Now you need to register the RxJavaUnconventionalMethodNamingIssue
in your custom lint IssueRegistry
which will provide a list of checks to be performed on your Android project.
Create a new file named CustomLintRegistry
which will hold all values needed by lint to work properly.
1import com.android.tools.lint.client.api.IssueRegistry
2import com.android.tools.lint.detector.api.CURRENT_API
3
4class CustomLintRegistry : IssueRegistry() {
5 override val issues = listOf(RxJavaUnconventionalMethodNamingIssue.ISSUE)
6
7 override val api: Int = CURRENT_API
8
9 override val minApi: Int = 6
10}
The code here is very self-explanatory, but you might wonder why there is a 6
here. The minApi
value should match the version of our Android project's Gradle Plugin. You can look into Api.kt to check which version is compatible with your project.
After that, there is one last step to make your lint rule work. Register CustomLintRegistry
in your lint-rules/build.gradle
file. All you need to do is put the jar section on the bottom of the Gradle file. Be careful about the path to the CustomLintRegistry
as it will be different for your project.
1plugins {
2 id 'java-library'
3 id 'kotlin'
4}
5
6java {
7 sourceCompatibility = JavaVersion.VERSION_1_8
8 targetCompatibility = JavaVersion.VERSION_1_8
9}
10
11dependencies {
12 implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
13
14 def lint_version = '30.1.0-alpha03'
15 compileOnly "com.android.tools.lint:lint-api:$lint_version"
16
17 testImplementation "com.android.tools.lint:lint-tests:$lint_version"
18 testImplementation 'junit:junit:4.13.2'
19}
20
21jar {
22 manifest {
23 attributes('Lint-Registry-v2': 'com.baracoda.lint_rules.CustomLintRegistry')
24 }
25}
Now we are ready to integrate our lint checks into our modules to be checked. All you need to do is add lintChecks project(':lint-rules')
in the specific module.
1dependencies {
2 implementation fileTree(dir: 'libs', include: ['*.jar'])
3
4 // App module dependencies
5 lintChecks project(':lint-rules')
6}
Now just run the command in the Terminal: gradlew lint
and the report will be generated.
One check down, three to go
Congratulations! You’ve successfully written your lint rule!
But we’re not finished. We just implemented the rule for the Single
type, while omitting the three other checks. As you may have notices, all the checks will have similar behaviours. That’s why we will create some abstractions to unify the code and make it easily testable later.
1abstract class RxMethodChecker {
2 /**
3 * List of methods return class names to be checked
4 */
5 protected abstract val returnClassNames: List<String>
6 /**
7 * List of correct suffixes for given [returnClassNames]
8 */
9 protected abstract val suffixes: List<String>
10 /**
11 * Specific message about why the current method name is incorrect
12 */
13 abstract val message: String
14
15 fun isMethodReturningRxType(returnClassName: String): Boolean =
16 returnClassNames.contains(returnClassName)
17
18 fun checkMethodNameSuffix(methodName: String): Boolean =
19 suffixes.any { methodName.endsWith(it) }
20}
We then need to implement MethodChecker
for one of the Rx types. Let’s go with Observable
and Flowable
checks.
1class RxStreamMethodChecker : RxMethodChecker() {
2 override val returnClassNames: List<String> = listOf(
3 "Observable",
4 "Flowable"
5 )
6 override val suffixes: List<String> = listOf(
7 "Stream",
8 "Once"
9 )
10
11 override val message: String = """
12 [Observable] and [Flowable] returning functions should be named with suffix Stream or Once.
13 Example: removeAccountOnce(), getAccountsStream()
14 """
15}
And that’s just it for the specific implementation. Visit Github to see the MethodChecker
and other Rx-type implementations.
Next, we need to modify RxNodeVisitor
to hold references for method checkers and correctly check methods.
1class RxNodeVisitor(private val context: JavaContext) : UElementHandler() {
2 private val methodCheckers = listOf(
3 RxSingleMethodChecker(),
4 RxCompletableMethodChecker(),
5 RxMaybeMethodChecker(),
6 RxStreamMethodChecker()
7 )
8
9 override fun visitMethod(node: UMethod) {
10 val returnClassName = node.returnClassName()
11
12 methodCheckers.firstOrNull { it.isMethodReturningRxType(returnClassName) }
13 ?.checkMethod(node)
14 }
15
16 private fun RxMethodChecker.checkMethod(node: UMethod) =
17 checkMethodNameSuffix(node.name)
18 .also { methodNameCorrect ->
19 if (!methodNameCorrect) {
20 reportIssue(node)
21 }
22 }
23
24 private fun RxMethodChecker.reportIssue(node: UMethod) {
25 context.report(
26 issue = ISSUE,
27 scopeClass = node,
28 location = context.getNameLocation(node),
29 message = this.message
30 )
31 }
32
33 private fun UMethod.returnClassName(): String =
34 (returnTypeReference?.type as? PsiClassType)?.className ?: ""
35}
36view raw
The idea behind the code is almost the same as in our previous implementation. First, find which MethodChecker
can process the method name, and then check the method name on the proper MethodChecker
. If the method name isn’t correct, then report the issue.
That’s it! We’ve successfully implemented the lint rules for checking the Rx method names. The whole code can be found on GitHub.
But wait, what’s that?
Our project is a multimodule project, so we use internal
visibility modifier to keep things isolated. It seems like there is an issue with recognizing method names for that modifier.
The name “UAST” is a bit misleading; it is not some sort of superset of all possible syntax trees; instead, think of this as the “Java view” of all code. So, for example, there isn’t a UProperty
node which represents Kotlin properties. Instead, the AST will look the same as if the property had been implemented in Java: it will contain a private field and a public getter and a public setter (unless of course the Kotlin property specifies a private setter). If you’ve written code in Kotlin and have tried to access that Kotlin code from a Java file you will see the same thing — the “Java view” of Kotlin.
The next section, “PSI“, will discuss how to do more language specific analysis.
There’s the answer to our problem. Let’s take a look at a generated java code.
1// ExampleInternalRxTimeService.java
2package com.baracoda.rxrules;
3
4import io.reactivex.rxjava3.core.Completable;
5import io.reactivex.rxjava3.core.Flowable;
6import io.reactivex.rxjava3.core.Maybe;
7import io.reactivex.rxjava3.core.Single;
8import io.reactivex.rxjava3.functions.Function;
9import java.util.Calendar;
10import java.util.concurrent.TimeUnit;
11import kotlin.Metadata;
12import kotlin.jvm.internal.Intrinsics;
13import org.jetbrains.annotations.NotNull;
14
15@Metadata(
16 mv = {1, 5, 1},
17 k = 1,
18 d1 = {"\u00008\n\u0002\u0018\u0002\n\u0002\u0010\u0000\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0002\u0010\u000b\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0002\u0010\b\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0002\u0010\t\n\u0002\b\u0002\n\u0002\u0018\u0002\n\u0002\b\u0002\b\u0000\u0018\u00002\u00020\u0001B\u0005¢\u0006\u0002\u0010\u0002J\u0013\u0010\u0003\u001a\b\u0012\u0004\u0012\u00020\u00050\u0004H\u0000¢\u0006\u0002\b\u0006J\u0013\u0010\u0007\u001a\b\u0012\u0004\u0012\u00020\t0\bH\u0000¢\u0006\u0002\b\nJ\u0013\u0010\u000b\u001a\b\u0012\u0004\u0012\u00020\r0\fH\u0000¢\u0006\u0002\b\u000eJ\r\u0010\u000f\u001a\u00020\u0010H\u0000¢\u0006\u0002\b\u0011¨\u0006\u0012"},
19 d2 = {"Lcom/baracoda/rxrules/ExampleInternalRxTimeService;", "", "()V", "anythingMaybe", "Lio/reactivex/rxjava3/core/Maybe;", "", "anythingMaybe$app_debug", "getCurrentHourOnce", "Lio/reactivex/rxjava3/core/Single;", "", "getCurrentHourOnce$app_debug", "getCurrentTimeStream", "Lio/reactivex/rxjava3/core/Flowable;", "", "getCurrentTimeStream$app_debug", "resetServiceCompletable", "Lio/reactivex/rxjava3/core/Completable;", "resetServiceCompletable$app_debug", "app_debug"}
20)
21public final class ExampleInternalRxTimeService {
22 @NotNull
23 public final Single getCurrentHourOnce$app_debug() {
24 Single var10000 = Single.just(Calendar.getInstance().get(11));
25 Intrinsics.checkNotNullExpressionValue(var10000, "Single.just(Calendar.get…et(Calendar.HOUR_OF_DAY))");
26 return var10000;
27 }
28
29 @NotNull
30 public final Flowable getCurrentTimeStream$app_debug() {
31 Flowable var10000 = Flowable.timer(1L, TimeUnit.SECONDS).map((Function)null.INSTANCE);
32 Intrinsics.checkNotNullExpressionValue(var10000, "Flowable.timer(1, TimeUn…getInstance().time.time }");
33 return var10000;
34 }
35
36 @NotNull
37 public final Completable resetServiceCompletable$app_debug() {
38 Completable var10000 = Completable.complete();
39 Intrinsics.checkNotNullExpressionValue(var10000, "Completable.complete()");
40 return var10000;
41 }
42
43 @NotNull
44 public final Maybe anythingMaybe$app_debug() {
45 Maybe var10000 = Maybe.just(true);
46 Intrinsics.checkNotNullExpressionValue(var10000, "Maybe.just(true)");
47 return var10000;
48 }
49}
As you see, internal function names are modified. With that knowledge, we can apply a simple fix for that, just trim everything after the $
sign.
1@Suppress("UnstableApiUsage")
2class RxNodeVisitor(private val context: JavaContext) : UElementHandler() {
3 private val methodCheckers = listOf(
4 RxSingleMethodChecker(),
5 RxCompletableMethodChecker(),
6 RxMaybeMethodChecker(),
7 RxStreamMethodChecker()
8 )
9
10 override fun visitMethod(node: UMethod) {
11 val returnClassName = node.returnClassName()
12
13 methodCheckers.firstOrNull { it.isMethodReturningRxType(returnClassName) }
14 ?.checkMethod(node)
15 }
16
17 private fun RxMethodChecker.checkMethod(node: UMethod) =
18 checkMethodNameSuffix(node.pureMethodName())
19 .also { methodNameCorrect ->
20 if (!methodNameCorrect) {
21 reportIssue(node)
22 }
23 }
24
25 private fun RxMethodChecker.reportIssue(node: UMethod) {
26 context.report(
27 issue = ISSUE,
28 scopeClass = node,
29 location = context.getNameLocation(node),
30 message = this.message
31 )
32 }
33
34 private fun UMethod.returnClassName(): String =
35 (returnTypeReference?.type as? PsiClassType)?.className ?: ""
36
37 private fun UMethod.pureMethodName() =
38 name.split(KOTLIN_BYTECODE_DELIMITER)[0]
39
40 companion object {
41 private const val KOTLIN_BYTECODE_DELIMITER = "$"
42 }
43}
Conclusion
As we all know, a developer's work never ends. There is always a place for potential improvements, such as checking the package of the returning type to be sure that it comes from RxJava.
With everything I’ve presented to you in this article, I hope you can easily start writing your own rules. The whole project can be found on GitHub. Also, keep in mind that the official documentation is the best source of knowledge about Lint. May the force of coding be with you.