How to write custom lint rules to maintain naming conventions

Tech
Filip Skowron
By
Filip Skowron
|
4 Sept 2023

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 example getAccountOnce()
  • ...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 a Completable
  • ...Maybe - the stream might emit a value or complete without emitting. Used only on methods that expose a Maybe

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 .

lint rules kotlin module

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}
Copy to clipboard

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}
Copy to clipboard

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 Singletype. Here are the steps to perform the check:

  1. Check if the method return type is Single
  2. Check if the method name ends with the Once suffix
  3. 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}
Copy to clipboard

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}
Copy to clipboard

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}
Copy to clipboard

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}
Copy to clipboard

Now just run the command in the Terminal: gradlew lint and the report will be generated.

lint terminal

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}
Copy to clipboard

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}
Copy to clipboard

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
Copy to clipboard

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?

internal visibility modifier

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}
Copy to clipboard

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}
Copy to clipboard

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.