loading...

How to write a custom rule in Lint

dbottillo profile image Daniele Bottillo ・6 min read

At my team at Deliveroo, we have recently decided to start using Google Truth instead of Hamcrest for test assertion but considering the number of unit tests in the codebase, migrating all the tests to Truth it’s almost impossible.
We’ve decided instead to have both libraries in the project and use Truth for new tests and do the conversion when touching an existing one. Furthermore, we also wanted to be notified about using Hamcrest when issuing a pull request in order to avoid reviewer to have to check this manually.

So we’ve decided to write a custom lint rule in order to detect an Hamcrest import and report it as a warning and because we are using Danger (https://github.com/danger/danger) it also reports on pull request which exactly what we want.

In order to write a custom lint rule, we need to create a module that contains the rule and import it in every module where you want to enforce the rule.

Let’s start with creating the lint module: in Android Studio just create a new folder called lint-rules and add a build.gradle file inside:

file: lint-rules/build.gradle
apply plugin: 'java-library'
apply plugin: "kotlin"

dependencies {
    compileOnly "com.android.tools.lint:lint-api:26.3.1"
    compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.3.21"
    testImplementation "com.android.tools.lint:lint:26.3.1"
    testImplementation "com.android.tools.lint:lint-tests:26.3.1"
    testImplementation "com.google.truth:truth:0.42"
}

Nothing strange here, the module is a Kotlin library and we have defined dependencies for lint, Kotlin and Google Truth (more on this later). We also need to add the module to settings.gradle in order to have it available for the rest of the project:

file: settings.gradle
include ':app'
include 'lint-rules'

So now that we have defined the module, we can add code to it :) First we need to create an IssueRegistry class, which is how lint let you inject your own issue check to the command:

file: lint-rules/src/main/java/IssueRegistry.kt
class IssueRegistry : com.android.tools.lint.client.api.IssueRegistry() {
    override val issues: List<Issue>
        get() = listOf()

    override val api: Int = com.android.tools.lint.detector.api.CURRENT_API
}

An IssueRegistry needs to expose two things: the list of issues to check and the current api value, lint documentation about the api value suggest just to return the current value so we can just focus on the list of issues.

What is an Issue in Lint? The best way to look at this, is to check the create method documentation on the Issue class:

/**
 * Creates a new issue. The description strings can use some simple markup;
 * see the [TextFormat.RAW] documentation
 * for details.
 *
 * @param id the fixed id of the issue
 * @param briefDescription short summary (typically 5-6 words or less), typically
 * describing the **problem** rather than the **fix**
 * (e.g. "Missing minSdkVersion")
 * @param explanation a full explanation of the issue, with suggestions for
 * how to fix it
 * @param category the associated category, if any
 * @param priority the priority, a number from 1 to 10 with 10 being most
 * important/severe
 * @param severity the default severity of the issue
 * @param implementation the default implementation for this issue
 * @return a new [Issue]
 */

That’s pretty straightforward! So we need an id, some text for the description and the explanation, a category, a priority, a severity and the actual implementation. Let’s then create a new IssueHamcrestImport:

val IssueHamcrestImport = Issue.create("HamcrestImport",
        "Hamcrest is deprecated",
        "Use Google Truth instead",
        CORRECTNESS,
        5,
        Severity.WARNING,
        TODO()
)

And that’s our Issue! So now we can return a list containing that issue but we still need to fill the TODO() section with the actual implementation.
But at least the registry is finished now:

file: lint-rules/src/main/java/IssueRegistry.kt
class IssueRegistry : com.android.tools.lint.client.api.IssueRegistry() {
    override val issues: List<Issue>
        get() = listOf(IssueHamcrestImport)

    override val api: Int = com.android.tools.lint.detector.api.CURRENT_API
}

val IssueHamcrestImport = Issue.create("HamcrestImport",
        "Hamcrest is deprecated",
        "Use Google Truth instead",
        CORRECTNESS,
        5,
        Severity.WARNING,
        TODO()
)

The last parameter of the Issue.create (or TODO()) it’s an implementation of the detector api, so let’s create a new detector:

file: lint-rules/src/main/java/HamcrestNamingPatternDetector.kt
class HamcrestNamingPatternDetector : Detector(), Detector.UastScanner {

}

But what’s exactly a Detector? Again, let’s have a look at the documentation:

/**
 * A detector is able to find a particular problem (or a set of related problems).
 * Each problem type is uniquely identified as an [Issue].
 *
 * Detectors will be called in a predefined order:
 *
 *  1.  Manifest file
 *  2.  Resource files, in alphabetical order by resource type
 *      (therefore, "layout" is checked before "values", "values-de" is checked before
 *      "values-en" but after "values", and so on.
 *  3.  Java sources
 *  4.  Java classes
 *  5.  Gradle files
 *  6.  Generic files
 *  7.  Proguard files
 *  8.  Property files
 */

Interesting, so a Detector is an object that can find problems in any of those files/sources. For our current use case we are interested into imports of sources classes but luckily we don’t have to parse manually all the files, lint is already doing that for us! We just need to specify what we are interesting in:

override fun getApplicableUastTypes() = listOf(UImportStatement::class.java)

Ok wait a moment, what’s UAST? Let’s have another look at the documentation:

* UAST is short for "Universal AST" and is an abstract syntax tree library
* which abstracts away details about Java versus Kotlin versus other similar languages
* and lets the client of the library access the AST in a unified way.

Wow, that’s amazing! It means that we don’t have to parse the file ourselves and we can just use the already defined UElement:

public interface UImportStatement : org.jetbrains.uast.UResolvable, org.jetbrains.uast.UElement {

That does provide use already all the imports. Wonderful!
OK now we need to define the actual detector implementation:

class HamcrestInvalidImportHandler(private val context: JavaContext) : UElementHandler() {
    override fun visitImportStatement(node: UImportStatement) {
        node.importReference?.let { importReference ->
            if (importReference.asSourceString().contains("org.hamcrest.")) {
                context.report(IssueHamcrestImport, node, context.getLocation(importReference), "Forbidden import")
            }
        }
    }
}

So Lint can offer us a UElementHandler that lets you visit different things like Class, Enum, Field, etc.. But here we are interested just in imports so we can override

override fun visitImportStatement(node: UImportStatement)

and then inspect the importReference to see what does it contains, if it contains org.hamcrest. then we can report the issue in the JavaContext object using the IssueHamcrestImport that we’ve created earlier.
The full implementation of the detector is:

class HamcrestNamingPatternDetector : Detector, Detector.UastScanner(){
    override fun getApplicableUastTypes() = listOf(UImportStatement::class.java)

    override fun createUastHandler(context: JavaContext) = HamcrestInvalidImportHandler(context)
}

class HamcrestInvalidImportHandler(private val context: JavaContext) : UElementHandler() {
    override fun visitImportStatement(node: UImportStatement) {
        node.importReference?.let { importReference ->
            if (importReference.asSourceString().contains("org.hamcrest.")) {
                context.report(IssueHamcrestImport, node, context.getLocation(importReference), "Forbidden import")
            }
        }
    }
}

And now back to our Issue definition, we can replace the TODO() with the actual detector:

val IssueHamcrestImport = Issue.create("HamcrestImport",
        "Hamcrest is deprecated",
        "Use Google Truth instead",
        CORRECTNESS,
        5,
        Severity.WARNING,
        Implementation(HamcrestNamingPatternDetector::class.java,
                EnumSet.of(Scope.JAVA_FILE, Scope.TEST_SOURCES))
)

Notice here that we can define the scope using an enumeration so you can define on which type of file you want to act on (JAVA_FILE also contains Kotlin files).

One thing missing is subscribing our custom registry to lint:

end of file: lint-rules/build.gradle
jar {
    manifest {
        attributes('Lint-Registry-v2': 'IssueRegistry')
    }
}

Finally we can add the custom lint check module to the module that we want to enforce the check:

dependencies {
    compile fileTree(dir: 'libs', include: ['*.jar'])
    ...all other dependencies...
    lintChecks project(":lint-rules")
}

And if you run ./gradlew lint on a project that contains tests using Hamcrest, now you should see something like this:
Lint output

One more thing, I’ve mentioned in the beginning the Truth dependency for the lint rules module because we can also write tests for our registry and detector:

class IssueRegistryTest {

    @Test
    fun `check explanation for hamcrest import is correct`() {
        val output = IssueRegistry().issues
                .joinToString(separator = "\n") { "- **${it.id}** - ${it.getExplanation(TextFormat.RAW)}" }

        assertThat("""
        - **HamcrestImport** - Use Google Truth instead
        """.trimIndent()).isEqualTo(output)
    }
}

and

class HamcrestImportDetectorTest {

    @Test
    fun `should not report imports that are not hamcrest`() {
        TestLintTask.lint()
                .files(TestFiles.java("""
            package foo;
            import foo.R;
            class Example {
            }""").indented())
                .issues(IssueHamcrestImport)
                .run()
                .expectClean()
    }

    @Test
    fun `should warning about hamcrest import`() {
        TestLintTask.lint()
                .files(TestFiles.java("""
          package foo;
          import org.hamcrest.MatcherAssert.assertThat;
          class Example {
          }""").indented())
                .issues(IssueHamcrestImport)
                .run()
                .expect("""
          |src/foo/Example.java:2: Warning: Forbidden import [HamcrestImport]
          |import org.hamcrest.MatcherAssert.assertThat;
          |       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
          |0 errors, 1 warnings""".trimMargin())
    }
}

The TestLintTask class lets us test our detector creating an in-memory file so we can assert the output of the check.

That’s it! You can find a working implementation on Github: https://github.com/dbottillo/Blog/tree/hamcrest_lint

Discussion

markdown guide