Custom Lint Task Configuration in Gradle with Kotlin DSL

Custom Lint Task Configuration in Gradle with Kotlin DSL

Víctor Manuel Cañada, Mobile Development - Expert

Víctor Manuel Cañada

Mobile Development - Expert

October 7, 2024

Here we are with a cool example of how you can create Lint rules to control visibility between layers in a project based on Clean Architecture! In this structure, each layer has its own role: domain, data, and presentation shouldn’t be gossiping between each other more than necessary.

Why do we need these rules? Because sometimes, in large projects, the temptation to mix dependencies between layers is real. And if we allow the domain package to start depending on data or presentation, we’re breaking the sacred rules of Clean Architecture. That would be like having the brain (domain) directly connected to the muscles (presentation) or the veins (data) without going through the proper nerves. Madness!

With these Lint rules , we’re basically telling our code, “Hey, domain! No peeking at data or presentation!” It’s kind of like being the guardian of best practices, making sure only the right layers are communicating with each other.

What if we use modules?

Now, heads up: if instead of dividing your code into packages within a single module, you’re using separate modules for each layer (domain, data, presentation), you wouldn’t need all these custom rules! Modules already have their own magic for handling dependencies. They manage who can access what on their own, so you won’t have to worry about manually controlling visibility. But if you’re using packages within the same module, these rules are your best friend for keeping your code clean and organized.

So, in summary, these Lint rules are like setting boundaries for the packages within a single module, making sure domain, data, and presentation stay in their lane, fulfilling their role without poking around where they shouldn’t.

With that said… Let’s get down to business!!!

We’ll start by configuring Lint in an Android project using Gradle with Kotlin DSL (.gradle.kts), and we’ll do it step by step, with examples and custom rules included. Let’s dive in! 💪

🎯 Step 1: Configure the Lint Task inbuild.gradle.kts

First, let’s dive straight into the build.gradle.kts file, where we’ll define the Lint task and customize its reports—because let’s be honest, nobody likes sticking with the default report, right?

plugins {
    id("java-library")
    id("com.android.lint")
    alias(libs.plugins.jetbrains.kotlin.jvm)
}

lint {
    htmlReport = true
    htmlOutput = file("lint-report.html") // Archivo de reporte HTML
    textReport = true // También generará un reporte en texto
    checkReleaseBuilds = true // Verifica las compilaciones de release
    absolutePaths = false // Usa rutas relativas en los reportes
    ignoreTestSources = false // Incluye los test en los reportes
    abortOnError = true // Detiene el proceso si hay errores
}

java {
    sourceCompatibility = JavaVersion.VERSION_22
    targetCompatibility = JavaVersion.VERSION_22
}

dependencies {
    compileOnly(libs.lint.api)
    compileOnly(libs.lint.cli)
    compileOnly(libs.lint.checks)
    compileOnly(libs.lint.tests)
}

tasks.jar {
    manifest {
        attributes(
            "Lint-Registry-v2" to "com.example.custom_lint.CleanArchitectureIssueRegistry",
            "Lint-Vendor" to "The Roustic Chicken" // Reemplaza con el nombre de tu empresa
        )
    }
}

🎉 And done! Now you’ve got your build.gradle.kts file configured. Your Lint task is fully equipped to generate reports in HTML and text format, verify release builds, and more.

🔍 A Deep Dive into build.gradle.kts

1.Customize Lint to your liking

This is where we configure how we want Lint to behave. We’ll control the types of reports, whether we verify release builds, how errors are handled, and more.

lint {
    htmlReport = true
    htmlOutput = file("lint-report.html") // Archivo de reporte HTML
    textReport = true // También generará un reporte en texto
    checkReleaseBuilds = true // Verifica las compilaciones de release
    absolutePaths = false // Usa rutas relativas en los reportes
    ignoreTestSources = true // Tests en los reportes
    abortOnError = true // Detiene el proceso si hay errores
}
  • htmlReport = true: We want a visually friendly report in HTML format. The report will look nice, with colors and everything.

  • htmlOutput = file("lint-report.html"): Here, we define where we want the HTML report to appear. In this case, it will be saved in the "lint-report.html" file.

  • textReport = true: Just in case, we also generate a plain text report because it’s always good to have a simpler option

  • checkReleaseBuilds = true: We want Lint to be stricter with our release builds. No bugs should sneak into the final version.

  • absolutePaths = false: To avoid displaying full file paths in our reports, we use relative paths. That way, we don’t share more information than necessary.

  • ignoreTestSources = true: Tests matter! But this time, these rules don't apply to them.

  • abortOnError = true: Are there serious errors? Lint will stop everything. We don’t move forward until they’re resolved.

1.3. Adding Lint Dependencies

Time for dependencies! This is where we specify which Lint libraries we need for our custom rules.

dependencies {  
    compileOnly(libs.lint.api)  
    compileOnly(libs.lint.cli)  
    compileOnly(libs.lint.checks)  
    compileOnly(libs.lint.tests)  
}
  • libs.lint.api: This dependency allows us to write our own Lint rules.

  • libs.lint.cli: Gives us access to the Lint command line interface to run our rules.

  • libs.lint.checks: Includes standard Lint rules to check our code.

  • libs.lint.tests: Provides useful tools to test our custom rules.

1.4. Customize the JAR and Manifest

Finally, we configure the jar task to add a couple of details to the manifest, such as registering the custom rules we are going to define.

tasks.jar {  
    manifest {  
        attributes(  
            "Lint-Registry-v2" to "com.example.custom\_lint.CleanArchitectureIssueRegistry",  
            "Lint-Vendor" to "The Roustic Chicken" // Reemplaza con el nombre de tu empresa  
        )  
    }  
}
  • Lint-Registry-v2: Here we specify where Lint can find the custom rules, in this case in CleanArchitectureIssueRegistry.

  • Lint-Vendor: Customizes the name of the rule provider, which in this example we've called "The Roustic Chicken" or as I like to say, “El Pollo Campero”. Change this to your company's or project's name.

And with this, you have configured the Lint task in build.gradle.kts. Ready to catch any architectural errors or dependency violations that might slip through. Here’s to cleaner code!

🛠️ Step 2: Implement Custom Lint Rules

¡Now comes the magic! ✨ We’re going to write some custom Lint rules. Here, I’ll show you how to ensure that our beloved domain package doesn’t have unwanted dependencies on data or presentation.

Rule:DomainDependencyDetector

This little rule will monitor to ensure that the domain package stays pure and doesn’t depend on other data or presentation packages, because Clean Architecture demands it. 🤘

package com.example.custom_lint

import com.android.tools.lint.detector.api.*
import org.jetbrains.uast.UClass
import org.jetbrains.uast.getContainingUFile

class DomainDependencyDetector : Detector(), SourceCodeScanner {

    override fun applicableSuperClasses(): List<String>? =
        listOf("java.lang.Object")

    override fun visitClass(context: JavaContext, declaration: UClass) {
        val uFile = declaration.getContainingUFile()
        val packageName = uFile?.packageName ?: return

        val regexDomain = Regex("""^com\.example(?:\.home)?\.domain(?:\.\w+)*$""")

        if (regexDomain.matches(packageName)) {
            val imports = context.uastFile?.imports ?: return

            for (importStatement in imports) {
                val import = importStatement.asSourceString()
                val regexData = Regex("""^import\s+com\.example(?:\.\w+)?\.data(?:\.\w+)*""")
                val regexPresentation = Regex("""^import\s+com\.example(?:\.\w+)?\.presentation(?:\.\w+)*""")

                if (regexData.matches(import) || regexPresentation.matches(import)) {
                    context.report(
                        ISSUE, declaration,
                        context.getLocation(importStatement),
                        "El paquete domain no debe depender de data o presentation."
                    )
                }
            }
        }
    }

    companion object {
        val ISSUE: Issue = Issue.create(
            id = "ImportDependencyViolation",
            briefDescription = "Import Dependency Violation",
            explanation = "Ensures that imports comply with architectural rules.",
            category = Category.CUSTOM_LINT_CHECKS,
            priority = 6,
            severity = Severity.ERROR,
            implementation = Implementation(
                DomainDependencyDetector::class.java,
                Scope.JAVA_FILE_SCOPE
            )
        )
    }
}

🧩 What’s Happening Here?

1- Package Verification: The rule checks that the current package is part of domain.

2- Import Check: If an import from data or presentation is detected in the domain package, the alarm goes off 🚨.

3- Error Reporting: If something suspicious is found, the issue is reported with a clear message: "The domain package should not depend on data or presentation."

And voilà, you’ve implemented your first custom Lint rule. Wasn’t that difficult? 😊

🔍 A Deep Dive into DomainDependencyDetector

The DomainDependencyDetector rule aims to ensure that the domain package doesn’t depend on other packages, specifically data or presentation, thereby respecting the principles of Clean Architecture. Let’s see how it does this and how we use a regular expression to identify problematic packages

General Structure of DomainDependencyDetector

The detector extends Detector and implements the SourceCodeScanner interface. This allows it to scan source classes for patterns or rule violations. The key to this detector is reviewing imports in files within the domain package and checking if any depend on data or presentation.

How It Works Step by Step:

1. Applicable Superclasses:


override fun applicableSuperClasses(): List<String>? = listOf("java.lang.Object")

Here we specify that this rule applies to classes that extend java.lang.Object, which is practically any class in Java. This ensures that all classes in the project are analyzed.

2. Obtaining the Package and Regular Expression (Regex):

val uFile = declaration.getContainingUFile()
val packageName = uFile?.packageName ?: return

val regexDomain = Regex("""^com\.example(?:\.home)?\.domain(?:\.\w+)*$""")

First, we obtain the package name of the current class using getContainingUFile(). This gives us the source file (UFile), from which we then extract the package name.

Next, we define the regular expression regexDomain, which will check if the file belongs to the domain package. Let’s break down the regular expression:

  • ^com\.example: Matches a package starting with com.example.

  • (?:\.home)?: This is a non-capturing group that indicates .home is optional. This is useful if you have multiple variants of the domain package, such as com.example.domain or com.example.home.domain.

  • \.domain: Ensures the package contains .domain

  • (?:\.\w+)*: This part allows for sub-packages within domain, such as com.example.domain.usecase, by permitting any number of subdirectories with \w+ (representing a sequence of letters, numbers, or underscores).

This expression ensures that the rules are applied only if the file is within the domain package.

3. Verification of Imports:

val imports = context.uastFile?.imports ?: return
for (importStatement in imports) {
val import = importStatement.asSourceString()
val regexData = Regex("""^import\s+com\.example(?:\.\w+)?\.data(?:\.\w+)*""")
val regexPresentation = Regex("""^import\s+com\.example(?:\.\w+)?
\.presentation(?:\.\w+)*""")

Now that we know we’re in the domain package, it’s time to review all the imports in this file. Here, we define two new regular expressions: regexData and regexPresentation, which check if anything from the data or presentation packages is being imported.

  • regexData: Looks for imports from the data package. The expression com\.example(?:\.\w+)?\.data allows the import to come from any variant of the data package, such as com.example.data or com.example.submodule.data.

  • regexPresentation: Similar to the above, but searches for any imports coming from the presentation package.

if (regexData.matches(import) || regexPresentation.matches(import)) {
    context.report(
        ISSUE, declaration,
        context.getLocation(importStatement),
        "El paquete domain no debe depender de data o presentation."
    )
}

If any import matches regexData or regexPresentation, we report the error using context.report. Here, we specify the issue, its location, and a clear message indicating that the domain package should not depend on data or presentation.

In summary, this rule ensures that the domain package remains independent and "pure," adhering to the Clean Architecture principle that seeks a clear separation of responsibilities between the layers of the application.

🛠️ Rest of the Rules

Si eres un perfeccionista (como todos los buenos desarrolladores 😉), probablemente querrás crear más reglas para otros paquetes. No te preocupes, también te cubro aquí:

Rule: DataDependencyDetector

package com.example.custom_lint

class DataDependencyDetector : Detector(), SourceCodeScanner {

    override fun visitClass(context: JavaContext, declaration: UClass) {
        val packageName = declaration.getContainingUFile()?.packageName ?: return
        val regexData = Regex("""^com\.example(?:\.home)?\.data(?:\.\w+)*$""")

        if (regexData.matches(packageName)) {
            val imports = context.uastFile?.imports ?: return

            for (importStatement in imports) {
                val regexPresentation = Regex("""^import\s+com\.example(?:\.\w+)?\.presentation(?:\.\w+)*""")
                if (regexPresentation.matches(importStatement.asSourceString())) {
                    context.report(
                        ISSUE, declaration,
                        context.getLocation(importStatement),
                        "El paquete data no debe depender de presentation."
                    )
                }
            }
        }
    }

    companion object {
        val ISSUE: Issue = Issue.create(
            id = "DataDependency",
            briefDescription = "Dependencia prohibida en Data",
            explanation = "El paquete data no debe depender de presentation.",
            category = Category.CORRECTNESS,
            priority = 6,
            severity = Severity.ERROR,
            implementation = Implementation(
                DataDependencyDetector::class.java,
                Scope.JAVA_FILE_SCOPE
            )
        )
    }
}

Rule: PresentationDependencyDetector

package com.example.custom_lint

class PresentationDependencyDetector : Detector(), SourceCodeScanner {

    override fun visitClass(context: JavaContext, declaration: UClass) {
        val packageName = declaration.getContainingUFile()?.packageName ?: return
        val regexPresentation = Regex("""^com\.example(?:\.home)?\.presentation(?:\.\w+)*$""")

        if (regexPresentation.matches(packageName)) {
            val imports = context.uastFile?.imports ?: return

            for (importStatement in imports) {
                val regexData = Regex("""^import\s+com\.example(?:\.\w+)?\.data(?:\.\w+)*""")
                if (regexData.matches(importStatement.asSourceString())) {
                    context.report(
                        ISSUE, declaration,
                        context.getLocation(importStatement),
                        "El paquete presentation no debe depender de data."
                    )
                }
            }
        }
    }

    companion object {
        val ISSUE: Issue = Issue.create(
            id = "PresentationDependency",
            briefDescription = "Dependencia prohibida en Presentation",
            explanation = "El paquete presentation no debe depender de data.",
            category = Category.CORRECTNESS,
            priority = 6,
            severity = Severity.ERROR,
            implementation = Implementation(
                PresentationDependencyDetector::class.java,
                Scope.JAVA_FILE_SCOPE
            )
        )
    }
}

🧩Step 3: Register Rules in IssueRegistry

Finally, it’s time to register all these wonderful rules in a single IssueRegistry.

package com.example.custom_lint

class CleanArchitectureIssueRegistry : IssueRegistry() {
    override val issues = listOf(
        DomainDependencyDetector.ISSUE,
        DataDependencyDetector.ISSUE,
        PresentationDependencyDetector.ISSUE
    )
    override val api = CURRENT_API
    override val vendor: Vendor = Vendor("The Country Chicken")
}

🔗Diving into Registering Rules in IssueRegistry

To ensure our custom rules are used by Lint, we need to register them. This is where the CleanArchitectureIssueRegistry class comes into play, acting as the "catalog" for our custom rules. Let’s see how it works and why it’s important.

Key Elements:

1. The List of Issues:

override val issues = listOf(
    DomainDependencyDetector.ISSUE,
    DataDependencyDetector.ISSUE,
    PresentationDependencyDetector.ISSUE
)

The issues property is a list containing all the Lint rules we want to register. Each rule has a static Issue, such as DomainDependencyDetector.ISSUE, which defines the problem we’re looking for (and how it will be reported).

Here, we register three detectors:

  • DomainDependencyDetector: Ensures that the domain package does not depend on data or presentation.
  • DataDependencyDetector: Similar to the above, but checks that the data package does not depend on presentation.
  • PresentationDependencyDetector: Ensures that presentation does not depend on data.

By including these three detectors in the list of issues, Lint will know to execute these rules when analyzing the code.

2. Lint API:


override val api = CURRENT_API

This value tells Lint which version of the Lint API we are using. The value CURRENT_API ensures that we are using the latest version compatible with the system.

3. Vendor Information:


override val vendor: Vendor = Vendor("The Country Chicken")

The vendor field allows you to define a name that identifies who created these custom rules. This is useful if multiple individuals or teams are creating custom rules within the same organization.

🤔 Why Do We Need the IssueRegistry?

The IssueRegistry is the bridge between Lint and our custom rules. Without it, Lint wouldn’t know which rules to apply. Additionally, it allows Lint to integrate these rules into the standard analysis flow of any project using our rule library.

When Lint runs, it looks for classes that extend IssueRegistry to get the list of all rules that need to be executed. Registering your rules in IssueRegistry is a crucial step to ensure they are active during static code analysis.

What Happens When Lint Runs?

When Lint is executed (whether manually or as part of a build process), it goes through the following stages:

1- Loading the Registry: It looks for all classes extending IssueRegistry.

2- Registering Rules: Loads the rules listed in the issues property.

3- Running Analysis: Analyzes the source code using the registered rules.

4- Reporting Issues:If any rule detects a violation, Lint generates a report (in HTML, text, etc.), highlighting the problems found.

Thanks to the CleanArchitectureIssueRegistry, our rules are automatically executed every time Lint runs, ensuring that the code adheres to the defined architecture.

One Last Step!

We need to include our custom Lint rules in the build.gradle of all modules where we want them to apply. To do this, simply add the following line to the dependencies section of the corresponding module:

dependencies {
    lintChecks(project(":custom_lint"))
}

And now, to test it! Open the Android Studio terminal and type:

./gradlew lint

This command will run Lint and analyze all the files. If there are any violations of our rules, the errors will appear in the terminal, and reports will be generated in .txt and .html formats with all the details.

To see it in action, we’ve provided a test project on GitHub where you can experiment with the rules we’ve created. If you want to force an error and see how it works, just go to MainViewModel and, within the init method, create an instance of UserDto. Since UserDto belongs to the data layer and MainViewModel belongs to the presentation layer, our architecture rules will immediately throw an error. It’s perfect for seeing how Lint alerts you to such violations!

IMAGEN11.webp We force the error by creating an obj from the data layer in the presentation layer.

IMAGEN21.webp We run the Lint task, and the error is displayed

IMAGEN31.webp The paths where the reports are located are displayed.

IMAGEN41.webp The paths to where the reports are located are displayed.

IMAGEN51.webp Plain text error report.

IMAGEN61.webp HTML format error report.

Improvements

😅 Let's be honest, running the ./gradlew lint command every time you want to check if your code adheres to the rules can be a bit… inconvenient. You’re in the middle of development, adjusting details, and having to keep returning to the terminal isn’t the most streamlined process.

But don’t worry! There are ways to automate this and make Lint work without you having to lift a finger. For example:

  • Run Lint every time you make a commit: This ensures that your code complies with the rules before pushing any changes.
  • Run Lint every time you compile or run the project: By creating a couple of tasks, you can integrate Lint directly into your daily workflow.

But… that’s a story for another time, which we’ll cover in more detail in a future article. Stay tuned because it will make your life much easier! 😉

And that’s it, folks! We hope this journey through Lint rules and Clean Architecture has been as interesting as it is useful (and a bit fun, right?). Now, your layers are more protected than ever, and your code will thank you with fewer errors and more clarity.

See you in the next article, where we’ll continue unraveling the mysteries of development! Until then…

May the spirit of Pollo Campero be with you 🐔!

Víctor Manuel Cañada, Mobile Development - Expert

Víctor Manuel Cañada

Mobile Development - Expert

I discovered my passion for programming at age 8 with an 8-bit MSX that used Basic. The computer manual helped me learn programming logic, shaping my life since then. I'm motivated by learning and sharing knowledge. I enjoy fantasy epics and psychological thrillers. I'm also passionate about motorcycle rides, seeking remote landscapes and winding roads. My programming trick: If something goes wrong, use "Pollo Campero" as the key in your debug logs.


Our latest news

Interested in learning more about how we are constantly adapting to the new digital frontier?

Contract Testing as a Service: Support your clients
Contract Testing as a Service: Support your clients

Tech Insight

November 21, 2024

Contract Testing as a Service: Support your clients

Impact Innovation: How to evaluate and design sustainable digital products
Impact Innovation: How to evaluate and design sustainable digital products

Tech Insight

November 5, 2024

Impact Innovation: How to evaluate and design sustainable digital products

Overengineering in Software: How to Complicate Simple Things Unnecessarily
Overengineering in Software: How to Complicate Simple Things Unnecessarily

Tech Insight

October 21, 2024

Overengineering in Software: How to Complicate Simple Things Unnecessarily

PactFlow & Contract Testing: A Business Case Study
PactFlow & Contract Testing: A Business Case Study

Tech Insight

October 14, 2024

PactFlow & Contract Testing: A Business Case Study