← Back to blog

Custom Linter Rules

Oleksandr Leushchenko

Senior Staff Engineer

22nd March, 2023

Need a custom tool? Invertase can help

Tired of investing in off-the-shelf software that doesn't quite fit your business? Invertase can build solutions tailored to your exact needs. Tell us what you're looking for here and we'll be in touch.

Flutter has rapidly become a popular framework for developing cross-platform mobile applications. One of the key factors contributing to its success is the Dart programming language.

Dart language proposes rich features (sound null safety, amazing support for asynchronous programming, etc.), and it is extremely easy to learn. It is so friendly that it even advises when you are doing something ineffective or wrong. The tool from the Dart SDK that serves this purpose is called an analyzer. Developers may use the dart analyze command-line tool to analyze their Dart code, though we rarely use it directly (except for, maybe, CI scripts) and rely on our IDEs most of the time.

Analyzer comes with dozens of dozens of linter rules (219 as of today🤓), each of which will help ensure that the code follows a consistent coding style and conforms to established best practices. They can identify potential bugs or issues that may be missed during code reviews, leading to improved code quality and fewer bugs in production.

Analyzer is essential for projects of any size, and it is hard to imagine modern mobile application development without it.

So, what is wrong with Analyzer?

Nothing.

Analyzer is a great tool that, as a friendly neighbourhood spider-man, helps us but stays in the shadow. While on big projects, it might cause performance issues, it’s still a worthwhile price for its benefits.

However, there is one issue that was opened in 2017 regarding a superpower that many experienced Dart developers dream about. I’m talking about the possibility of extending the linter with custom lints.

Why would I want to write custom lints?

Here are only some of the reasons:

  1. Enforce Team-Specific Standards: Custom linter rules allow you to enforce standards specific to your team or project. For example, you may want to require that all code comments are written in a particular format or that all classes have (or don’t have) a specific prefix. Custom linter rules allow you to enforce these standards, ensuring that all code meets the requirements at the earliest stage.
  2. Identify Package-Specific Issues: The Dart team can not include rules specific to all 3rd-party packages you are using, especially if these packages are not publicly available.
  3. Ensure Consistency: Custom linter rules can help ensure consistency across your codebase. By enforcing specific coding standards or best practices, you can ensure that all code is written in a similar style, making it easier to read and maintain.

Using custom linter rules, you can ensure that your code meets specific requirements and catches issues before they become more difficult to fix. On one sunny day, this issue will be resolved, and the support for custom lints will appear in the analyzer. Just wait.

The end.

But I want it now!

You are stubborn, I like that! In that case, I will tell you a secret. Custom linter rules have already been supported for many years! The problem is that the API is not documented; it can be changed at any moment, and the only way to understand how to write a custom lint is to read the code of existing custom linter packages. To make it more fun, it is really hard to debug your lint, even when you manage to write one. Are you still feeling adventurous regarding this API? In that case, check out my talk from Flutter Global Summit’23, where I’m writing the simplest plugin.

Writing code with undocumented “secret” API might be tough. Fortunately, there is a better way, and it is called custom_lint.

What is custom_lint

custom_lint is an alternative to analyzer_plugin but with a much better developer experience.

We learn the best when we practice, so I propose you open IDE and follow me from now on.

Let’s create two projects:

dart create -t package my_lints
dart create demo_app

Two folders should appear. my_lints folder contains the source code for our future custom linter, and demo_app would become a sandbox project where we will violate some rules (i.e. validate the linter’s work).

Custom linter

For now, we will work with the my_lints package only. Let’s start with adding the required dependencies: add analyzer, analyzer_plugin, and custom_lint_builder dependencies to my_lints. You may do this with this command:

dart pub add analyzer analyzer_plugin custom_lint_builder

Make sure the latest versions are added. At the time of writing this article, these are:

dependencies:
  analyzer: ^5.4.0
  analyzer_plugin: ^0.11.2
  custom_lint_builder: ^0.2.12

The last preparatory step is to create an entry point for the analyzer (sort of main function in our applications, but for the analyzer). You should already have a lib/my_lints.dart file. If not – create lib/my_lints.dart file manually. Important: if you name your package differently, use your package’s name instead of my_lint, i.e. lib/<your_package_name>.dart. Replace its content with the following:

import 'package:custom_lint_builder/custom_lint_builder.dart';

PluginBase createPlugin() => _MyLintsPlugin();

class _MyLintsPlugin extends PluginBase {
  @override
  List<LintRule> getLintRules(CustomLintConfigs _) => [];
}

custom_lint package will look for the createPlugin function that returns a PluginBase in the lib/my_lints.dart file, which is why it is important to keep the naming. Here we have a _MyLintsPlugin class that defines the list of custom linter rules. For now, the list is empty. Let’s fix that!

I swear it’s a Model (amend_model_suffix)

In many projects, there are naming conventions for classes, like “All models should end with Model suffix” or “All widgets should end with Widget”.

final person = PersonModel(); // Model = suffix for plain models
final carousel = MyCarouselWidget(); // Widget = suffix for widgets

I find this convention obsolete for projects with good structure (that’s the polite way to say that I don’t like it).

It is hard to predict all possible suffixes, so no linter rule checks them. So, let’s create one!

Create a lib/src/amend_model_suffix.dart file with the following content:

import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';

class AmendModelSuffix extends DartLintRule {
  AmendModelSuffix() : super(code: _code);

  static const _code = LintCode(
    name: 'amend_model_suffix',
    problemMessage: 'Amend Model suffix in class names',
    correctionMessage: 'Rename class',
  );

  static const _modelSuffix = 'Model';

  @override
  void run(
    CustomLintResolver resolver,
    ErrorReporter reporter,
    CustomLintContext context,
  ) {
    context.registry.addClassDeclaration((node) {
      final className = node.name.lexeme;
      if (className.endsWith(_modelSuffix)) {
        reporter.reportErrorForToken(code, node.name);
      }
    });
  }
}

That’s a lot of code, but fear not – we will review it in small chunks together. We start with a new class for the lint:

class AmendModelSuffix extends DartLintRule {...}

DartLintRule is a base class for all custom lints. It forces us to implement a run method, which will be called when the analyzer asks this rule to validate a file.

To initialize DartLintRule base class, we need to pass a LintCode. That class represents the error and contains the error name that developers will see in their IDEs and messages that may guide them. In our implementation, we extracted LintCode to a field and passed it to the base class constructor:

AmendModelSuffix() : super(code: _code);
...
static const _code = LintCode(...);

Let’s talk about the run method implementation now. On the first line of the implementation, we subscribe to the analysis of a Dart file. In this particular example, we are saying that we are interested in all class declarations:

context.registry.addClassDeclaration((node) {...});

The node parameter will contain a ClassDeclaration object containing all the class info. We get the class name from that object and then validate whether it ends with Model. When we find a class that violates the new rule, we ask the reporter to report the error by providing the error code (the same one we passed to the rule base class) and the information regarding where the error is located in the code. In this particular case, reportErrorForToken was used, so instead of providing the position of the class name in the file (which we would report via reportErrorForOffset), we sent a node.name – an object that contains all the required info.

The lint is ready! It’s time to return to the my_lints.dart file and add it to the list of rules:

@override
List<LintRule> getLintRules(CustomLintConfigs _) => [
  AmendModelSuffix(),
];

Time to test

It is time to test our new rule! Add the following dev_dependencies to the demo_app:

dev_dependencies:
  ...
  custom_lint: ^0.2.12
  my_lints:
    path: ../my_lints

custom_lint is the package that will run our custom lints.

To complete the implementation, add the following lines to analysis_options.yaml file:

analyzer:
  plugins:
    - custom_lint

Here we are attaching a custom plugin to the analyzer. Notice that it is custom_lint, and not my_lints, because my_lints is a set of rules for custom_lint.

Let’s violate the rule and create a class PersonModel anywhere in the demo_app project. You should see a warning in your IDE now!

I’m a bit lazy. Can you fix it?

Seeing errors in the code is beautiful. But you know what is even better? When your IDE automatically fixes them for you. Can we achieve it with custom_lint? You bet!

Get back to the amend_model_suffix.dart file. Add the following class:

... // other imports
import 'package:analyzer/source/source_range.dart';
import 'package:analyzer/error/error.dart';

...
class _AmendModelSuffixFix extends DartFix {
  @override
  void run(
    CustomLintResolver resolver,
    ChangeReporter reporter,
    CustomLintContext context,
    AnalysisError analysisError,
    List<AnalysisError> others,
  ) {
    context.registry.addClassDeclaration((node) {
      if (!analysisError.sourceRange.intersects(node.name.sourceRange)) return;

      final validName = node.name.lexeme.substring(
          0, node.name.lexeme.length - AmendModelSuffix._modelSuffix.length);

      final changeBuilder = reporter.createChangeBuilder(
          message: 'Rename to $validName', priority: 0);

      changeBuilder.addDartFileEdit((builder) {
        builder.addSimpleReplacement(
            SourceRange(node.name.offset, node.name.length), validName);
      });
    });
  }
}

A lot of code again! Let’s see what we have here. A new class _AmendModelSuffixFix that extends DartFix with the single method, which is really similar to what we had in DartLintRule but instead of ErrorReporter we have ChangeReporter now, and there are a few other fields.

Inside this method, we subscribe to the analysis of a Dart file, and this part is the same as in lint implementation.

With the !analysisError.sourceRange.intersects(node.name.sourceRange) predicate, we check whether the fix was called on the problem that is currently under the cursor (as it could be that there several violations of the rule in the same file).

After that, we calculate the new name and put it into the validName variable.

Then, we create a change builder – an object that might make changes in the Dart code. There, we are providing a message that will be shown to developers and the priority of the operation (0 is the lowest one).

Lastly, we are specifying what changes need to be made to the code. In that case, that would be a simple replacement with the defined source range.

To make the auto-fix work, we need to bind it with lint. For that, add the following method to the AmendModelSuffix lint implementation:

  @override
  List<Fix> getFixes() => [_AmendModelSuffixFix()];

Right after you save the file, get back to the demo_app and check whether the new rule has an auto-fix. Spoiler: yes, it does! 🙂

Bonus rule (long_pubspec)

pubspec.yaml file can contain lots of useful information, but mostly it consists of dependencies and dev_dependencies declarations. Long pubspec files indirectly may tell us that we need to split the package.

No one told us that the linter must be used for Dart files only! Let’s write a rule that validates pubspec.yaml files and put warnings if they are more than 100 lines long.

For that, let’s create a new file in the my_lints project. Let it be lib/src/long_pubspec.dart. Put the following content in this file:

import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';

class LongPubspec extends DartLintRule {
  LongPubspec() : super(code: _code);

  static const _code = LintCode(
    name: 'long_pubspec',
    problemMessage: 'This pubspec is too long, consider splitting it',
    errorSeverity: ErrorSeverity.WARNING,
  );

  @override
  List<String> get filesToAnalyze => const ['pubspec.yaml'];

  @override
  void run(
    CustomLintResolver resolver,
    ErrorReporter reporter,
    CustomLintContext context,
  ) {
    if (resolver.lineInfo.lineCount > 100) {
      reporter.reportErrorForOffset(code, 0, 0);
    }
  }
}

You may see many similarities with AmendModelSuffix. The main difference is that we override the pattern of files that the linter will care about and say that we are interested in pubspec.yaml files only.

For the implementation part, we don’t care about any entity in the pubspec.yaml file, we just care about its size.

To make the lint work, we need to add it to the list of lints in _MyLintsPlugin:

@override
List<LintRule> getLintRules(CustomLintConfigs _) => [
  AmendModelSuffix(),
  LongPubspec(),
];

Time to test the new rule! Add comment lines to the pubspec.yaml file in the demo_app project, so that this file will exceed 100 lines and save the file. A few seconds later, you’ll see a warning.

I want to use it on CI

Long story short, custom_lint is not analyzer, and if you run dart analyze . in the demo_app project, it will not show custom lints.

To see these warnings, you need to use the following command:

dart run custom_lint

Ultimate power

You may find the code from this article here.

I encourage you to experiment and think about what rules may help you and your team. Initially, I thought to add more examples, but the article is already too long. If you want more samples – don’t hesitate to ping me on Twitter, and it could be that there will be a second part where we will focus on more rules.

The end

Russia started an unfair and cruel war against my country. If you found this article interesting or useful, please donate to Ukraine’s Armed Forces. I can recommend my friends-volunteers the “Yellow Tape”. You can be 100% sure that the money will support our victory. Thanks in advance to everyone who participated.

Glory to Ukraine! 🇺🇦

Stay tuned for more updates and exciting news that we will share in the future. Follow us on Invertase TwitterLinkedin, and Youtube, and subscribe to our monthly newsletter to stay up-to-date. You may also join our Discord to have an instant conversation with us.

Oleksandr Leushchenko

Senior Staff Engineer

Categories

Tags