Custom lints for your Dart/Flutter project

Karthikeyan S
10 min readMay 21, 2023

Do you remember the last time your IDE warned you not to spam print in your flutter code? If yes, it probably was the avoid_print lint from the official lint rules written for dart. Its job is to remind devs to avoid printing to the console on production code.

Lint rules are a set of guidelines that developers follow to write clean, consistent and safe code. And most IDEs and code editors would ideally be able to analyze your program (using a code analysis server) and check for lint errors.

By default, all flutter projects use a set of lint rules called flutter_lints. And you can see that it includes avoid_print here in this config file. If needed, you can also specify the exact lint rules to be used for your project with the help of an analysis_options.yaml file. But what if you have set standards in your project that the official lint rules don’t cover? In that case, your only choice is to write a custom one. To write and set up custom lint rules, you practically have two options:
1. Using analyzer_plugin directly (if you love pain and suffering ☹️)
2. Using custom_lint package

Although writing custom lints with the analyzer_plugin is an option, it can be really daunting for the devs. It was not built with much focus on developer experience and there’s no promise of improvement in the near future either. But worry not, we now have the custom_lint package from Invertase. This package simplifies the process of writing custom lints for Dart programs, and in this blog post, we’ll take a look at how to use it. Let’s begin!

There are two steps to using custom_lint in your project:
1. Writing a package that contains all your custom lints.
2. Adding that package to your project so that it can be used.
Now, let us start with the first step.

Your dart custom lint package

Inside a new folder, create pubspec.yaml file and add the following:

# pubspec.yaml
name: my_lints
version: 0.0.1
publish_to: none

environment:
sdk: ">=2.16.0 <3.0.0"

dependencies:
# we will use analyzer for inspecting Dart files
analyzer:
analyzer_plugin:
# custom_lint_builder will give us tools for writing lints
custom_lint_builder:

Now, create a file inside lib directory called my_lints.dart and add the following code inside it :

import 'package:custom_lint_builder/custom_lint_builder.dart';

// Entrypoint of plugin
PluginBase createPlugin() => _MyLints();

// The class listing all the [LintRule]s and [Assist]s defined by our plugin
class _MyLints extends PluginBase {
// Lint rules
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => [];

// Assists
@override
List<Assist> getAssists() => [];
}

We use the getLintRules and getAssists of the class(_MyLints) that extends PluginBase to define lint rules and assist actions respectively. To compare, lint rules are used to throw warnings/errors for which a fix maybe available, while assists are just handy and helpful editor actions.

Trigger warning: The following examples may contain Harry Potter references that muggles are typically allergic to. So, if you’re a muggle with no sense of magic/don’t know who harry is, please hold on tight — there’s some useful examples at the end waiting for you.

Now that everything is set, let us start with the first lint rule. Let’s say that I’m building a Harry Potter fan project and decided to add a lint that makes sure voldemort’s name is not mentioned in any of the variable declarations.

Let’s proceed by creating a new file called dont_say_his_name.dart inside lib/lint_rules directory and start with a regex variable that matches voldemort and his other names.

// A regex to check if Voldemort's name is mentioned in a string
final _voldemortRegex = RegExp(
r'voldemort|\b(tom(?:[\s-]*marvolo)?[\s-]*riddle)\b',
caseSensitive: false,
);

Now, extend DartLintRule and create a lint class DontSayHisName. Inside it, create a LintCode object with information that will be used to display the error later and pass it to super. Then, override run and check for all the variable declarations using context.registry.addVariableDeclaration. Inside it, check if the _voldemortRegex matches element.name and if it did, report it as lint error using reporter.reportErrorForElement(code, element).

// Lint rule to not mention Voldemort in a variable's name
class DontSayHisName extends DartLintRule {
DontSayHisName() : super(code: _code);

// Lint rule metadata
static const _code = LintCode(
name: 'dont_say_his_name',
problemMessage: 'His name shall not be mentioned',
);

// `run` is where you analyze a file and report lint errors
// Invoked on a file automatically
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
// A call back fn that runs on all variable declarations in a file
context.registry.addVariableDeclaration((node) {
final element = node.declaredElement;
// `return` if the regex doesn't find a match
if (element == null || !_voldemortRegex.hasMatch(element.name)) return;

// report a lint error with the `code` and the respective `element`
reporter.reportErrorForElement(code, element);
});
}

// Possible fixes for the lint error go here
@override
List<Fix> getFixes() => [_ReplaceHisName()];
}

As you can see in the getFixes, we also have passed in a fix for that lint error called _ReplaceHisName. Let’s write that now. Create a class _ReplaceHisName that extends DartFix. Similar to the DartLintRule, the run method is the starting point. Inside the method, check for variable declarations and if any of them matches with the location of the lint error. If it does, create a ChangeBuilder object and use it to replace the substring that matches the regex with the help of replaceAll string method, return otherwise.

// Fix that replaces Voldermort's name with another string
class _ReplaceHisName extends DartFix {
@override
void run(
CustomLintResolver resolver,
ChangeReporter reporter,
CustomLintContext context,
AnalysisError analysisError,
List<AnalysisError> others,
) {
// Callback fn that runs on every variable declaration in a file
context.registry.addVariableDeclaration((node) {
final element = node.declaredElement;

// `return` if the current variable declaration is not where the lint
// error has appeared
if (element == null ||
!analysisError.sourceRange.intersects(node.sourceRange)) return;

// Create a `ChangeBuilder` instance to do file operations with an action
final changeBuilder = reporter.createChangeBuilder(
message: 'Replace his name',
priority: 1,
);
// Use the `changeBuilder` to make Dart file edits
changeBuilder.addDartFileEdit((builder) {
// Use the `builder` to replace the variable name
builder.addSimpleReplacement(
SourceRange(element.nameOffset, element.nameLength),
// the string to be replaced instead of variable name
element.name.replaceAll(
_voldemortRegex,
"HeWhoMustNotBeNamed",
),
);
});
});
}
}

Now that we have a lint rule in place, it's time to check out assists. For assists, let us create an action that inserts a random spell into a string literal. Start by creating a new file insert_spell_in_string_literal.dart inside lib/assists directory. Inside the file, we will extend DartAssist and create a class InsertSpellInStringLiteral for which the starting point is the run method just like the others. Here, check for all the string literals in a file using context.registry.addSimpleStringLiteral and if one is currently under the cursor.

Store a list of spells in a list and use math.Random to pick one from it. Now, use changeBuilder to make file changes and insert the randomly picked spell string to the beginning of the string literal using builder.addSimpleInsertion.

// Assist to suggest spells to insert in string literals
class InsertSpellInStringLiteral extends DartAssist {
@override
void run(
CustomLintResolver resolver,
ChangeReporter reporter,
CustomLintContext context,
SourceRange target,
) {
final rand = Random();
// A list of spells I know -- not much of a wizard!
final spells = [
"Accio",
"Alohomora",
"Lumos",
"Wingardium Leviosa",
"Expecto Patronum"
];

context.registry.addSimpleStringLiteral((node) {
// `return` if the visited node is not under the cursor
if (!target.intersects(node.sourceRange)) return;

// Create `ChangeBuilder` instance to do file operations with an action
final changeBuilder = reporter.createChangeBuilder(
priority: 1,
message: 'Insert a random spell in the string literal',
);
// Use the `changeBuilder` to make Dart file edits
changeBuilder.addDartFileEdit((builder) {
// Use the `builder` to insert a string at the beginning of string literal
builder.addSimpleInsertion(
node.offset + 1,
// A random value from the `spells` to insert
'${spells.elementAt(rand.nextInt(spells.length - 1))}! ',
);
});
});
}
}

Now that we have a lint rule and an assist defined, let us update the _MyLints to include them.

// The class listing all the [LintRule]s and [Assist]s defined by our plugin
class _MyLints extends PluginBase {
// Lint rules
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => [
DontSayHisName(),
];

// Assists
@override
List<Assist> getAssists() => [
InsertSpellInStringLiteral(),
];
}

Consuming the custom lints

To consume the lint rules and assists we just wrote in the package, first add analysis_options.yaml file to your project with the following config:

analyzer:
plugins:
- custom_lint

Then, you have to add 2 packages to the project — custom_lint and the custom lint package we just wrote to the pubspec.yaml file. Then, run pub get and restart your IDE/code editor.

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

Now, inside the lib/main.dart file, add the following code:


void main() {
// remove the next line.
// expect_lint: dont_say_his_name
var cuteLilVoldemort = "the wizard of mystery";

// place your cursor on the string literal and `ctrl + .`
// in VSCode (or whatever shortcut your IDE supports for assist actions)
// then, choose 'Insert a random spell in the string literal'.
var randomString = "sfgsdnkgj";
}

The variable cuteLilVoldemort contains voldemort’s name, and so the lint error dont_say_his_name should appear. When you click on quick fix, it should replace the voldemort’s name to HeWhoMustNotBeNamed. This should work as long as voldemort or tom marvolo riddle is mentioned.

And for the assist we added, click on the string literal and choose actions (Ctrl + . in VSCode). You should see an action “Insert a random spell in the string literal”. Choose that and you will see it do its job.

Some useful lints

Now that you know how this works, lets drop the harry potter references and write some useful custom lints. The first one is to make sure that all the methods in a Utils class are static.

Let’s start by creating util_methods_be_static.dart file inside the lib/lint_rules directory. Now, follow the same routine — extend DartLintRule and create a lint class UtilMethodsBeStatic. Inside it, create a LintCode object and then, override run method. Check for all the class declarations using context.registry.addClassDeclaration. Inside it, iterate through all the methods from the class using element.methods. If a method is not static, then report it as lint error using the method reporter.reportErrorForElement(code, element).

// Lint rule to make sure all `Utils` methods are static
class UtilMethodsBeStatic extends DartLintRule {
UtilMethodsBeStatic() : super(code: _code);

// Lint rule metadata
static const _code = LintCode(
name: 'util_methods_be_static',
problemMessage: 'Methods of Utils class should be static',
);

// `run` is where you analyze a file and report lint errors
// Invoked on a file automatically on every file edit
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
// A call back fn that runs on all class declarations in a file
context.registry.addClassDeclaration(
(node) {
final element = node.declaredElement;
// `return` if class name doesn't end with "Utils"
if (element == null || !element.name.endsWith("Utils")) {
return;
}
// iterate over all methods of the class
for (var method in element.methods) {
// if the method is `static`
if (!method.isStatic) {
// report a lint error with the `code` and the respective `element`
reporter.reportErrorForElement(code, method);
}
}
},
);
}

// Possible fixes for the lint error go here
@override
List<Fix> getFixes() => [_MakeUtilMethodsStatic()];
}

Now, for the fix create a class _MakeUtilMethodsStatic that extends DartFix. Inside the overridden run method, check for class declarations and iterate through its methods. If any of them match with the location of the lint error, create a ChangeBuilder object and use it to insert the static keyword before the method name.


// Fix that replaces adds `static` keyword to a `Utils` method
class _MakeUtilMethodsStatic extends DartFix {
@override
void run(
CustomLintResolver resolver,
ChangeReporter reporter,
CustomLintContext context,
AnalysisError analysisError,
List<AnalysisError> others,
) {
// Callback fn that runs on every class declaration in a file
context.registry.addClassDeclaration((node) {
final element = node.declaredElement;
if (element == null) return;

// Create a `ChangeBuilder` instance to do file operations with an action
final changeBuilder = reporter.createChangeBuilder(
message: 'Make the method static',
priority: 1,
);
// iterate over all methods of the class
for (var method in element.methods) {
// `return` if the current method is not where the lint
// error has appeared
final sourceRange = SourceRange(
method.nameOffset,
method.nameLength,
);
if (!analysisError.sourceRange.intersects(sourceRange)) continue;
// Use the `changeBuilder` to make Dart file edits
changeBuilder.addDartFileEdit((builder) {
// Use the `builder` to insert `static` keyword before method name
builder.addSimpleInsertion(
method.nameOffset,
'static ',
);
});
}
});
}
}

For the final lint, we’ll make it so that there’s only one Service class allowed per file. First, create one_service_class_per_file.dart file inside the lib/lint_rules directory. Repeat the same steps — extend DartLintRule and create a lint class OneServiceClassPerFile then, create a LintCode object and override run method. Now, access the file name using resolver.source.shortName and check if it ends with Service.dart. If it does, check for all the class declarations using context.registry.addClassDeclaration and use a counter to store the no. of classes in the file. If the count is more than 1, report that as lint error using the method reporter.reportErrorForElement(code, element).

// Lint rule to make sure there's only one `Service` class per file
class OneServiceClassPerFile extends DartLintRule {
OneServiceClassPerFile() : super(code: _code);

// Lint rule metadata
static const _code = LintCode(
name: 'one_service_class_per_file',
problemMessage: 'Only one Service class allowed per file',
);

// `run` is where you analyze a file and report lint errors
// Invoked on a file automatically on every file edit
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
// check if the name of the file ends with `Service.dart`
final fileName = resolver.source.shortName;
if (!fileName.endsWith("Service.dart")) return;

int classCount = 0;
// A call back fn that runs on all class declarations in a file
context.registry.addClassDeclaration((node) {
final element = node.declaredElement;
if (element == null) return;

// increment the `classCount`
classCount++;
// if `classCount` is more than 1
if (classCount > 1) {
// report a lint error with the `code` and the respective class
reporter.reportErrorForElement(code, element);
}
});
}
}

And finally, don’t forget to add them to the _MyLints and test them out. We’ve seen how to write lint rules and assists involving variables, string literals, classes, methods and file names. And I really hope this will help you get started on writing your own dart custom lints for your project using the custom_lints package. Thanks for reading this far ❤️

I’ve put it all together in this repo here. Do check it out and give a star ⭐️ if you find it useful!

--

--