Dart 65 🎯 Custom Analyzer Plugins, Linter Rules, and analysis_options.yaml Configuration
Dart 3.10 and Flutter 3.38 introduced a brand-new official analyzer plugin system, one of the most significant changes to the Dart ecosystem in the area of static analysis. Before it existed, if you needed project-specific lint rules—enforcing a particular design pattern, checking the direction of dependencies between layers, or banning certain third-party APIs—you had essentially two options: petition the SDK for a rule (slow and uncertain), or depend on third-party packages like custom_lint (powerful, but adding an extra process and debugging complexity). The new plugin system fundamentally changes that situation: you can write custom rules that run natively inside the Dart analysis server, sharing exactly the same API, IDE integration, and command-line support as built-in lints.
The system was designed to solve two core problems with the old plugin architecture. The first is memory efficiency: the old system created a separate isolate for each plugin, and enabling several plugins caused the analysis server’s memory footprint to balloon . The new system loads all plugins into a single shared isolate, eliminating the duplication overhead. The second is consistency: old plugins required a separate command to run (such as dart run custom_lint), whereas the new system integrates directly into dart analyze and flutter analyze, with no additional toolchain . This means that when you write one custom rule, every team member’s IDE (VS Code, IntelliJ, Android Studio) and the CI pipeline see the same diagnostic immediately.
This chapter dissects the plugin system at three levels. First, how to build an analyzer plugin package from scratch, including the entry point convention and pubspec.yaml configuration. Second, the core rule-authoring API—how AnalysisRule and SimpleAstVisitor work together, and how to register and report diagnostics using LintCode and NodeLintRegistry. Third, the full analysis_options.yaml configuration syntax, including how to enable plugins, toggle individual rules, and suppress precisely with ignore comments. We will also discuss the crucial distinction between warnings and lints—warnings are enabled by default, lints are disabled by default—and the effect of that design decision on team workflow.
Key point: The new analyzer plugin system elevates custom static analysis from the domain of third-party tools to a first-class citizen, with rules running natively in the analysis server and sharing the same API and IDE experience as built-in lints, while analysis_options.yaml provides project-level enablement and per-rule fine-grained control through a top-level plugins: block.
Why custom analyzer plugins exist
The boundary problem of built-in lints. Dart ships hundreds of built-in lint rules covering language idioms, performance patterns, and API usage conventions . But built-in rules cannot—and should not—cover every scenario. If your team’s convention is that every async method must check isClosed before calling emit(), or that the UI layer may only import from the application layer and never the reverse, those are architectural decisions rather than language rules. Custom plugins turn those conventions from verbal agreements into compile-time checks .
The inspection blind spot for third-party dependencies. The standard analyzer knows nothing about third-party packages. It does not know that your BLoC base class requires an isClosed guard before emit(), nor that your company wraps a CommonText to replace the native Text for consistent fonts and styles. Custom rules can encode these package-specific usage patterns, preventing team members from unknowingly bypassing the wrappers .
The architectural flaws of the old plugin system. The old system started a separate isolate per plugin, which produced two practical problems: memory overhead grew linearly with the number of plugins, slowing down IDEs on memory-constrained development machines , and the workflow fragmented, because dart analyze could not see custom rules and you had to run a separate custom_lint command. The new system’s shared-isolate design and native dart analyze integration address both pain points directly .
The progression from rule to automatic fix. A lint rule that only reports “there is a problem here” is not enough. The new plugin system lets you attach a Quick Fix to a diagnostic, so a developer in the IDE can correct the issue with a single click or keystroke . For example, a “use isTrue instead of true” rule can carry a fix that automatically rewrites expect(x, true) to expect(x, isTrue). This “diagnostic plus fix” combination is the core advantage of custom plugins over plain code review .
Making team standards executable. Code review is subjective, inconsistent, and fluctuates with reviewer fatigue. Custom analyzer plugins turn “we do it this way” into “the tool enforces it this way.” Once a rule is enabled in analysis_options.yaml, any violation is flagged in real time in the IDE and blocks the merge in CI. That immediate feedback loop is far more effective than “please watch for this in the next review” .
a. Plugin package structure and the entry point convention
An analyzer plugin is first and foremost a standard Dart package. Its minimum dependency is analysis_server_plugin, which provides the Plugin base class and the PluginRegistry registration interface . Your pubspec.yaml declares that dependency along with the analyzer package (the version must stay in sync with the SDK).
name: acme_lints
version: 1.0.0
environment:
sdk: ^3.10.0
dependencies:
analysis_server_plugin: ^0.3.0
analyzer: ^8.0.0
The critical convention is the entry point file lib/main.dart. When the analysis server loads a plugin, it automatically generates code that imports this file and directly references a top-level plugin variable defined within it . That variable name is a hard contract—not a convention but a system requirement. If your plugin package has no top-level variable named plugin, the analysis server cannot instantiate your plugin.
import 'package:analysis_server_plugin/plugin.dart';
import 'package:analysis_server_plugin/registry.dart';
final plugin = AcmeLintsPlugin();
class AcmeLintsPlugin extends Plugin {
@override
String get name => 'acme_lints';
@override
void register(PluginRegistry registry) {
// Register all rules here
registry.registerLintRule(PreferBoolMatcher());
}
}
The register method is where your plugin introduces itself to the analyzer. You register each rule there, and PluginRegistry offers two key methods: registerLintRule registers a lint rule that is disabled by default, and registerWarningRule registers a warning rule that is enabled by default . This distinction matters a great deal—it determines whether a rule is “on out of the box” or “requires explicit opt-in” in a user’s project.
The name getter must match exactly the plugin key a user writes in analysis_options.yaml. If a user writes plugins: acme_lints:, your name must return 'acme_lints' . A mismatch causes registration to fail or the rule to go unrecognized.
b. The two-class structure of rules and visitors
Every analysis rule consists of two classes: a rule class extending AnalysisRule, and a visitor class extending SimpleAstVisitor . This separation is deliberate: the rule class declares “what this rule is” (metadata, diagnostic code, registered node types), while the visitor class defines “what this rule does” (the inspection logic and reporting conditions).
The rule class’s core responsibility is to supply a LintCode and register AST node handlers:
import 'package:analyzer/analysis_rule/analysis_rule.dart';
import 'package:analyzer/error/error.dart';
class PreferBoolMatcher extends AnalysisRule {
static const LintCode code = LintCode(
'prefer_bool_matcher',
'Use isTrue/isFalse instead of a raw boolean',
correctionMessage: 'Try using the isTrue or isFalse matcher',
);
PreferBoolMatcher()
: super(
name: 'prefer_bool_matcher',
description: 'Prefer boolean matchers in expect() calls.',
);
@override
LintCode get diagnosticCode => code;
@override
void registerNodeProcessors(
NodeLintRegistry registry,
LinterContext context,
) {
final visitor = _Visitor(this, context);
registry.addMethodInvocation(this, visitor);
}
}
A LintCode carries three elements: the diagnostic name (used for ignore comments and configuration toggles), the problem message (the text shown to developers in the IDE), and an optional correction message (suggesting how to fix it). The diagnostic name must be unique and typically uses lowercase_with_underscores style.
The registerNodeProcessors method is the bridge between the rule and the AST. NodeLintRegistry provides an add method for each AST node type—addMethodInvocation, addAwaitExpression, addImportDirective, and so on—and whichever method you call tells the analyzer “when code contains this kind of node, let my visitor handle it” . This on-demand registration is the key to performance: the analyzer does not need to walk the entire AST for every rule, only to invoke your visitor on the node types you declared interest in.
The visitor class holds the actual inspection logic:
class _Visitor extends SimpleAstVisitor<void> {
final PreferBoolMatcher rule;
final LinterContext context;
_Visitor(this.rule, this.context);
@override
void visitMethodInvocation(MethodInvocation node) {
if (node.methodName.name != 'expect') return;
if (node.argumentList.arguments.length != 2) return;
final secondArg = node.argumentList.arguments[1];
if (secondArg is BooleanLiteral) {
rule.reportLint(secondArg);
}
}
}
SimpleAstVisitor provides dozens of visitXxx methods, and you override only the ones you care about. The reportLint method takes an AST node and marks the diagnostic over the source range corresponding to that node . If you want to report a different range (say, only the method name rather than the entire invocation), reportLint has several overloads.
c. The plugin configuration syntax in analysis_options.yaml
Enabling a plugin requires only a top-level plugins: block in analysis_options.yaml. This is one of the most visible differences from the old system: old plugins were written under analyzer:, whereas new plugins occupy their own top-level section .
plugins:
acme_lints: ^1.0.0
Plugin values follow the same resolution rules as dependencies in pubspec.yaml: a version constraint fetches from pub.dev, path: specifies a local path, and git dependencies work too . During plugin development, using an absolute path is most convenient—after changing code, you only need to restart the analysis server to see the effect .
The default-behavior distinction is the easiest place to trip up in configuration. Warnings defined by a plugin are enabled by default; lint rules are disabled by default. That means if a rule is a lint rather than a warning, you must explicitly turn it on under diagnostics::
plugins:
acme_lints:
path: /path/to/acme_lints
diagnostics:
prefer_bool_matcher: true
avoid_print_in_lib: true
layer_dependency: false
The diagnostics block is a key-value map whose keys are LintCode names and whose values are boolean toggles. Setting a diagnostic to false disables it even if the plugin registered it as a warning, and setting it to true enables it even if it was registered as a lint. This gives project owners complete control over which rules are active without editing the plugin itself.
Suppression works exactly as it does for built-in lints. A single line can be silenced with a trailing comment, and a whole file with a leading comment:
expect(result, true); // ignore: prefer_bool_matcher
// ignore_for_file: prefer_bool_matcher, avoid_print_in_lib
The rule names used in ignore comments are the LintCode names, not the Dart class names. This is why diagnostic names must be stable—renaming one is a breaking change for every project that suppresses it. For plugin authors, treating diagnostic names as part of the public API is essential.
Complete Example Session
The following session builds a complete analyzer plugin from scratch and wires it into a consuming project. It demonstrates the plugin class, two rules with visitors, and the configuration that activates them.
// ============================================
// PART 1: THE PLUGIN ENTRY POINT
// ============================================
// File: lib/main.dart
import 'package:analysis_server_plugin/plugin.dart';
import 'package:analysis_server_plugin/registry.dart';
import 'rules/avoid_print_in_lib.dart';
import 'rules/prefer_is_true.dart';
final plugin = AcmeLintsPlugin();
class AcmeLintsPlugin extends Plugin {
@override
String get name => 'acme_lints';
@override
void register(PluginRegistry registry) {
registry.registerLintRule(AvoidPrintInLib());
registry.registerLintRule(PreferIsTrue());
}
}
// ============================================
// PART 2: THE FIRST RULE — RULE CLASS
// ============================================
// File: lib/rules/avoid_print_in_lib.dart
import 'package:analyzer/analysis_rule/analysis_rule.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
class AvoidPrintInLib extends AnalysisRule {
static const LintCode code = LintCode(
'avoid_print_in_lib',
'Avoid using print() in library code',
correctionMessage: 'Use a proper logger instead',
);
AvoidPrintInLib()
: super(
name: 'avoid_print_in_lib',
description: 'Disallows print() calls inside lib/.',
);
@override
LintCode get diagnosticCode => code;
@override
void registerNodeProcessors(
NodeLintRegistry registry,
LinterContext context,
) {
registry.addMethodInvocation(this, _Visitor(this));
}
}
// ============================================
// PART 3: THE FIRST RULE — VISITOR CLASS
// ============================================
class _Visitor extends SimpleAstVisitor<void> {
final AvoidPrintInLib rule;
_Visitor(this.rule);
@override
void visitMethodInvocation(MethodInvocation node) {
final target = node.target;
if (target != null) return; // only bare print(), not foo.print()
if (node.methodName.name == 'print') {
rule.reportLint(node);
}
}
}
// ============================================
// PART 4: THE SECOND RULE — RULE CLASS
// ============================================
// File: lib/rules/prefer_is_true.dart
import 'package:analyzer/analysis_rule/analysis_rule.dart';
import 'package:analyzer/error/error.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
class PreferIsTrue extends AnalysisRule {
static const LintCode code = LintCode(
'prefer_is_true',
'Use isTrue instead of a raw true literal',
correctionMessage: 'Replace true with isTrue',
);
PreferIsTrue()
: super(
name: 'prefer_is_true',
description: 'Prefer isTrue over boolean true in matchers.',
);
@override
LintCode get diagnosticCode => code;
@override
void registerNodeProcessors(
NodeLintRegistry registry,
LinterContext context,
) {
registry.addMethodInvocation(this, _MatcherVisitor(this));
}
}
// ============================================
// PART 5: THE SECOND RULE — VISITOR CLASS
// ============================================
class _MatcherVisitor extends SimpleAstVisitor<void> {
final PreferIsTrue rule;
_MatcherVisitor(this.rule);
@override
void visitMethodInvocation(MethodInvocation node) {
if (node.methodName.name != 'expect') return;
final args = node.argumentList.arguments;
if (args.length < 2) return;
final matcher = args[1];
if (matcher is BooleanLiteral && matcher.value == true) {
rule.reportLint(matcher);
}
}
}
// ============================================
// PART 6: THE CONSUMING PROJECT PUBSPEC
// ============================================
// File: consumer/pubspec.yaml
// name: my_app
// environment:
// sdk: ^3.10.0
// dependencies:
// acme_lints:
// path: ../acme_lints
# ============================================
# PART 7: ENABLING THE PLUGIN
# ============================================
# File: consumer/analysis_options.yaml
plugins:
acme_lints:
path: ../acme_lints
diagnostics:
avoid_print_in_lib: true
prefer_is_true: true
// ============================================
// PART 8: CODE THAT TRIGGERS THE RULES
// ============================================
// File: consumer/lib/example.dart
void greet() {
print('hello'); // avoid_print_in_lib
expect(value, true); // prefer_is_true
}
// ============================================
// PART 9: SUPPRESSING ONE DIAGNOSTIC
// ============================================
// File: consumer/lib/debug.dart
void debugOnly() {
print('temporary'); // ignore: avoid_print_in_lib
}
# ============================================
# PART 10: DISABLING A RULE GLOBALLY
# ============================================
# File: consumer/analysis_options.yaml
plugins:
acme_lints:
path: ../acme_lints
diagnostics:
avoid_print_in_lib: false # turn it off entirely
prefer_is_true: true
The ten parts cover the plugin entry point, the rule and visitor classes for two distinct rules, the consuming project’s dependency declaration, plugin activation, code that triggers the diagnostics, single-line suppression, and global disabling. What the session demonstrates is the complete lifecycle: a rule declared in a plugin package becomes a live diagnostic in a consuming project solely through configuration, with no changes to the consumer’s source beyond the analysis_options.yaml file.
Quick Reference
Plugin Package Layout
| File | Purpose |
|---|---|
pubspec.yaml | Declares analysis_server_plugin and analyzer deps |
lib/main.dart | Must expose a top-level plugin variable |
lib/rules/*.dart | One file per rule (rule class + visitor) |
analysis_options.yaml | Optional; used only when the plugin lints itself |
Plugin Class Members
| Member | Type | Role |
|---|---|---|
name | String | Must match the config key |
register | void | Called once to register rules |
registerLintRule | method | Rule off by default |
registerWarningRule | method | Rule on by default |
Rule and Visitor Roles
| Class | Base | Responsibility |
|---|---|---|
| Rule class | AnalysisRule | Metadata, LintCode, node registration |
| Visitor class | SimpleAstVisitor | Inspection logic, reportLint calls |
LintCode | value | Name, message, correction message |
NodeLintRegistry | value | Maps node types to visitors |
Configuration Keys
| Key | Location | Meaning |
|---|---|---|
plugins: | top level | Enables plugin packages |
path: | under plugin | Local plugin path |
| version constraint | under plugin | pub.dev source |
diagnostics: | under plugin | Per-rule boolean toggles |
ignore: | inline comment | Suppresses one line |
ignore_for_file: | file header | Suppresses whole file |
Default States
| Kind | Registered With | Default State |
|---|---|---|
| Lint | registerLintRule | Disabled |
| Warning | registerWarningRule | Enabled |
| Override on | diagnostics: {rule: true} | Enabled |
| Override off | diagnostics: {rule: false} | Disabled |
Best Practices
✅ Do This:
final plugin = AcmeLintsPlugin(); // ✅ top-level name
String get name => 'acme_lints'; // ✅ matches config key
registry.registerLintRule(MyRule()); // ✅ lint is opt-in
registry.addMethodInvocation(this, visitor); // ✅ narrow registration
rule.reportLint(node.methodName); // ✅ precise range
static const LintCode code = LintCode('my_rule', ...); // ✅ stable name
❌ Don’t Do This:
final myPlugin = AcmeLintsPlugin(); // ❌ wrong variable name
String get name => 'AcmeLints'; // ❌ case mismatch
registry.registerWarningRule(MyRule()); // ❌ surprising default
registry.addCompilationUnit(this, visitor); // ❌ too broad, slow
rule.reportLint(node); // ❌ noisy whole-node range
LintCode('myRule', ...); // ❌ inconsistent naming
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Plugin not loaded | No top-level plugin variable | Rename the variable to plugin |
| Rule never fires | Registered as lint, not enabled | Add diagnostics: {rule: true} |
| Rule name mismatch | name getter differs from config key | Make them identical |
| Diagnostic not suppressible | Unstable or duplicate LintCode name | Keep names unique and permanent |
| Analyzer crashes on load | Version mismatch with analyzer | Pin analyzer to the SDK version |
| Slow analysis | Registering a whole-file visitor | Register only the needed node type |
| Fix not offered | No correction wired up | Attach a correction producer |
ignore has no effect | Using class name, not LintCode name | Use the diagnostic name |
Real-World Examples
1. Ban print in library code
if (node.methodName.name == 'print') rule.reportLint(node);
2. Enforce layer imports
if (uri.startsWith('package:app/ui/') && fromApp) rule.reportLint(node);
3. Require isClosed before emit
if (name == 'emit' && !guarded) rule.reportLint(node.methodName);
4. Prefer CommonText over Text
if (name == 'Text') rule.reportLint(node.constructorName);
5. Disallow TODO in committed code
if (comment.contains('TODO')) rule.reportLint(node);
6. Enforce const constructors
if (!node.keyword?.lexeme.contains('const') ?? true) rule.reportLint(node);
7. Forbid dynamic in public APIs
if (type is DynamicType) rule.reportLint(node);
8. Require trailing commas
if (!args.hasTrailingComma) rule.reportLint(node.argumentList);
9. Ban certain packages
if (uri.startsWith('package:http/')) rule.reportLint(node);
10. Prefer named parameters
if (params.positional.isNotEmpty) rule.reportLint(node);
Visual
Plugin Load and Rule Registration Flow
┌──────────────────────────────────────────────┐
│ ANALYSIS SERVER STARTUP │
│ │
│ 1. Read analysis_options.yaml │
│ │ │
│ ▼ │
│ 2. Resolve plugin dependency (pub.dev/path) │
│ │ │
│ ▼ │
│ 3. Generate code importing lib/main.dart │
│ │ │
│ ▼ │
│ 4. Read top-level `plugin` variable │
│ │ │
│ ▼ │
│ 5. Call plugin.register(registry) │
│ │ │
│ ├── registerLintRule(A) → off │
│ └── registerWarningRule(B) → on │
│ │ │
│ ▼ │
│ 6. Apply diagnostics: overrides │
│ │
└──────────────────────────────────────────────┘
Rule and Visitor Collaboration
┌──────────────────────────────────────────────┐
│ RULE CLASS (AnalysisRule) │
│ ┌────────────────────────────────────────┐ │
│ │ LintCode: name, message, correction │ │
│ │ registerNodeProcessors(registry, ctx) │ │
│ └────────────────┬───────────────────────┘ │
│ │ registers │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ NodeLintRegistry │ │
│ │ addMethodInvocation(rule, visitor) │ │
│ └────────────────┬───────────────────────┘ │
│ │ invoked on match │
│ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ VISITOR (SimpleAstVisitor) │ │
│ │ visitMethodInvocation(node) │ │
│ │ → rule.reportLint(node) │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
Diagnostic Defaults and Overrides
┌──────────────────────────────────────────────┐
│ DEFAULT STATE │
│ │
│ registerLintRule ──▶ OFF by default │
│ registerWarningRule ──▶ ON by default │
│ │
│ OVERRIDE IN CONFIG │
│ │
│ diagnostics: │
│ my_lint: true ──▶ turns lint ON │
│ my_warn: false ──▶ turns warning OFF │
│ │
│ RESULTING STATE │
│ │
│ ┌───────────────┬──────────┬─────────────┐ │
│ │ Rule kind │ Default │ After tweak │ │
│ ├───────────────┼──────────┼─────────────┤ │
│ │ lint │ off │ on │ │
│ │ warning │ on │ off │ │
│ └───────────────┴──────────┴─────────────┘ │
└──────────────────────────────────────────────┘
Suppression Layers
┌──────────────────────────────────────────────┐
│ SUPPRESSION HIERARCHY │
│ │
│ Whole project analysis_options.yaml │
│ diagnostics: {rule: false} │
│ │ │
│ ▼ │
│ Whole file // ignore_for_file: rule │
│ │ │
│ ▼ │
│ Single line code(); // ignore: rule │
│ │
│ Each layer overrides only the scope below │
│ it, never the scope above. │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Plugin entry point | Top-level plugin variable in lib/main.dart |
| Plugin base | Plugin from analysis_server_plugin |
| Registration | register(PluginRegistry) |
| Lint default | Disabled until enabled in config |
| Warning default | Enabled unless disabled in config |
| Rule class | Extends AnalysisRule |
| Visitor class | Extends SimpleAstVisitor |
| Diagnostic code | LintCode with stable name |
| Node registration | NodeLintRegistry.addXxx |
| Enablement | Top-level plugins: block |
| Per-rule toggle | diagnostics: map |
Key takeaways:
- The
pluginvariable name is a hard contract. The analysis server generates code that references it directly; renaming it breaks loading . - Lints are opt-in, warnings are opt-out. This default protects projects from surprise diagnostics when a new plugin is added .
- Rule and visitor are separate classes by design. The rule declares metadata and registers node types; the visitor contains the inspection logic .
- Register only the node types you need. Broad registration slows analysis;
addMethodInvocationbeats walking whole compilation units . LintCodenames are public API. They appear inignorecomments anddiagnostics:keys, so renaming one is a breaking change .- Local
path:dependencies speed development. Point the consumer at the plugin source and restart the analysis server to test changes . - Diagnostics flow into
dart analyzeand the IDE equally. One configuration, one result, across every tool . - Suppression is layered. Project config, file header, and inline comment each override only their own scope.
- Version alignment with the SDK matters. A mismatched
analyzerdependency can crash the analysis server at load. - Quick fixes are the point of the system. A rule that reports without offering a correction leaves work on the developer .
Remember: Custom analyzer plugins move project standards out of human memory and into the toolchain. You build a plugin package, expose a top-level plugin variable, register rules through a PluginRegistry, and implement each rule as a rule class plus a visitor class. Then a consuming project enables the package with a top-level plugins: block and controls each diagnostic under diagnostics:. The result is that architectural decisions, package-specific usage patterns, and team conventions become real-time diagnostics in every IDE and hard gates in CI, all without a separate command or a third-party process in the loop.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!