Dart 66 🎯 Writing Custom Static Analysis Rules with AST (Abstract Syntax Tree) Visitors
A custom lint rule is not a pattern matcher over source text. It is a visitor over a syntax tree. When the Dart analyzer parses a file, it produces an Abstract Syntax Tree—a structured representation where every declaration, expression, and identifier is a node with a type and children. A lint rule walks that tree, and each time it encounters a node it cares about, it runs a check. If the check fails, it reports a diagnostic at that node’s location. This is the mechanism behind every built-in Dart lint, and it is the mechanism you use when you write your own.
The new analysis_server_plugin system formalizes this into a two-class pattern: a rule class that declares what the rule is and which node types it needs, and a visitor class that implements what happens when those nodes are encountered . The rule class extends AnalysisRule and provides a LintCode. The visitor class extends SimpleAstVisitor and overrides visitXxx methods. This separation is deliberate—it keeps metadata, registration, and inspection logic in distinct places, and it allows the analyzer to invoke only the visitors relevant to the nodes present in a file.
Key point: A custom lint rule consists of an AnalysisRule subclass that declares a LintCode and registers node types with NodeLintRegistry, plus a SimpleAstVisitor subclass that overrides visitXxx methods and calls reportLint when a violation is found.
Why AST visitors work this way
The parsing problem. A lint rule cannot reason about raw text. It needs structure: is this identifier the name of a method declaration or the target of an invocation? Is this await inside a lib/ directory or a test/ directory? The AST provides that structure. The analyzer parses the file once and builds the tree; the rule walks the tree and inspects node properties .
The efficiency problem. Walking the entire tree for every rule would be wasteful. Most rules care about one or two node types—a method invocation, an import directive, an await expression. The NodeLintRegistry lets the rule declare exactly which node types it needs. The analyzer then invokes the visitor only for those nodes, skipping the rest of the tree . A rule that checks print() calls registers addMethodInvocation and never sees a class declaration.
The isolation problem. The visitor pattern decouples traversal from inspection. The analyzer controls the traversal order; the rule controls what happens at each node. This means a rule does not need to know how to walk a tree—it only needs to override the methods for the nodes it cares about. SimpleAstVisitor provides dozens of visitXxx methods; you override only the ones you need .
The reporting problem. When a violation is found, the rule does not throw an exception or print a message. It calls reportLint with the node that violates the rule. The analyzer maps that node to a source range and produces a diagnostic at that location. Different overloads of reportLint allow reporting over the entire node or a specific child (such as the method name rather than the whole invocation) .
The context problem. Some rules need information beyond the node itself—the file path, whether the file is in lib/, or the library being analyzed. The LinterContext object passed to the visitor provides this. In the official example, context.isInLibDir is used to restrict a rule to library code .
a. The rule class: metadata and node registration
The rule class extends AnalysisRule. It has three responsibilities: declare a LintCode, provide a constructor with the rule name and description, and register the node types the visitor needs.
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,
) {
final visitor = _Visitor(this, context);
registry.addMethodInvocation(this, visitor);
}
}
The LintCode contains the diagnostic name—used in ignore comments—the problem message shown in the IDE, and an optional correction message . It must be a static constant because the analyzer uses identity to match diagnostics for suppression. If multiple instances of the same code exist, // ignore: rule_name will not work reliably .
The registerNodeProcessors method is where the rule declares its interest. registry.addMethodInvocation(this, visitor) tells the analyzer: when you encounter a MethodInvocation node, call this visitor . The this argument associates the registration with this specific rule, which matters when multiple rules share a visitor or when the analyzer needs to attribute diagnostics.
b. The visitor class: inspection and reporting
The visitor extends SimpleAstVisitor<void>. It holds a reference to the rule and the context, and overrides the visitXxx method that corresponds to the registered node type.
class _Visitor extends SimpleAstVisitor<void> {
final AvoidPrintInLib rule;
final LinterContext context;
_Visitor(this.rule, this.context);
@override
void visitMethodInvocation(MethodInvocation node) {
if (node.methodName.name != 'print') return;
if (node.target != null) return; // only bare print(), not foo.print()
if (context.isInLibDir) {
rule.reportLint(node.methodName);
}
}
}
The method name check is the simplest form of inspection. node.methodName.name returns the identifier text. If it is not print, the visitor returns immediately—no diagnostic is reported. The target check distinguishes a bare print() call from a method call on an object (foo.print()). Reporting on node.methodName rather than node highlights only the identifier in the IDE, which is more precise than highlighting the entire call .
The context.isInLibDir check restricts the rule to library code. Without it, the rule would also flag print() in tests and examples, where it is often intentional. This is a common pattern for rules that should apply only to production code .
For rules that need to report multiple diagnostics or inspect more complex conditions, the visitor can hold state or query the context. But the core structure remains the same: check a node, call reportLint if the condition is violated.
c. Registering the rule and configuring activation
A rule is not active until it is registered with a plugin and enabled in analysis_options.yaml. The plugin entry point is lib/main.dart, which must export a top-level variable named plugin .
// lib/main.dart
import 'package:analysis_server_plugin/plugin.dart';
import 'package:analysis_server_plugin/registry.dart';
import 'rules/avoid_print_in_lib.dart';
final plugin = MyLintsPlugin();
class MyLintsPlugin extends Plugin {
@override
String get name => 'my_lints';
@override
void register(PluginRegistry registry) {
registry.registerLintRule(AvoidPrintInLib());
}
}
The registerLintRule method registers the rule as a lint, which means it is disabled by default. A rule registered with registerWarningRule is enabled by default . This distinction matters: if you register a rule as a lint and do not enable it in the consuming project’s configuration, it will never run.
The consuming project enables the plugin in analysis_options.yaml with a top-level plugins: block:
plugins:
my_lints:
path: ../my_lints
diagnostics:
avoid_print_in_lib: true
The path: value points to the local plugin directory during development. The diagnostics: block is where individual lint rules are enabled by name. The name is the first argument to LintCode, not the class name . If you rename the diagnostic code, every project that suppresses or enables it breaks—treat it as public API.
Complete Example Session
The following session builds a rule that flags DateTime.now() calls outside of test files, demonstrating node inspection, context usage, and reporting.
// ============================================
// PART 1: RULE CLASS
// ============================================
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 AvoidDateTimeNow extends AnalysisRule {
static const LintCode _code = LintCode(
'avoid_datetime_now',
'Avoid DateTime.now() outside tests',
correctionMessage: 'Inject a clock or pass the time as a parameter',
);
AvoidDateTimeNow()
: super(
name: 'avoid_datetime_now',
description: 'Disallows direct DateTime.now() usage in production code.',
);
@override
LintCode get diagnosticCode => _code;
@override
void registerNodeProcessors(
NodeLintRegistry registry,
LinterContext context,
) {
final visitor = _Visitor(this, context);
registry.addMethodInvocation(this, visitor);
}
}
// ============================================
// PART 2: VISITOR CLASS
// ============================================
class _Visitor extends SimpleAstVisitor<void> {
final AvoidDateTimeNow rule;
final LinterContext context;
_Visitor(this.rule, this.context);
@override
void visitMethodInvocation(MethodInvocation node) {
// Only static calls: DateTime.now(), not foo.now()
if (node.target is! SimpleIdentifier) return;
if ((node.target as SimpleIdentifier).name != 'DateTime') return;
if (node.methodName.name != 'now') return;
// Skip test directories
if (context.isInTestDir) return;
rule.reportLint(node);
}
}
// ============================================
// PART 3: PLUGIN ENTRY POINT
// ============================================
// lib/main.dart
import 'package:analysis_server_plugin/plugin.dart';
import 'package:analysis_server_plugin/registry.dart';
import 'rules/avoid_datetime_now.dart';
final plugin = MyLintsPlugin();
class MyLintsPlugin extends Plugin {
@override
String get name => 'my_lints';
@override
void register(PluginRegistry registry) {
registry.registerLintRule(AvoidDateTimeNow());
}
}
# ============================================
# PART 4: CONSUMING PROJECT CONFIG
# ============================================
# analysis_options.yaml
plugins:
my_lints:
path: /path/to/my_lints
diagnostics:
avoid_datetime_now: true
# ============================================
# PART 5: CODE THAT TRIGGERS THE RULE
# ============================================
// lib/service.dart
void logTime() {
final now = DateTime.now(); // avoid_datetime_now
print(now);
}
The five parts cover the rule class with its LintCode, the visitor with target and method name checks, the plugin registration, the configuration that activates the rule, and the code that triggers it. What the session demonstrates is that the visitor is where the logic lives—the rule class is metadata and registration, and the visitor is inspection and reporting.
Quick Reference
Rule Class Requirements
| Element | Requirement |
|---|---|
| Base class | AnalysisRule |
| Diagnostic | LintCode get diagnosticCode |
| Constructor | super(name: ..., description: ...) |
| Registration | registerNodeProcessors |
| Registry | NodeLintRegistry |
Visitor Class Requirements
| Element | Requirement |
|---|---|
| Base class | SimpleAstVisitor<void> |
| Constructor | Receives rule and context |
| Method | Override visitXxx for registered node |
| Reporting | rule.reportLint(node) or reportLint(child) |
Common Node Types and Registration
| Node Type | Registration Method | Visitor Method |
|---|---|---|
MethodInvocation | addMethodInvocation | visitMethodInvocation |
AwaitExpression | addAwaitExpression | visitAwaitExpression |
ImportDirective | addImportDirective | visitImportDirective |
ClassDeclaration | addClassDeclaration | visitClassDeclaration |
Context Properties
| Property | Meaning |
|---|---|
isInLibDir | File is in lib/ |
isInTestDir | File is in test/ |
Best Practices
✅ Do This:
static const LintCode _code = LintCode('rule_name', 'Message'); // ✅ static const
registry.addMethodInvocation(this, visitor); // ✅ specific node
rule.reportLint(node.methodName); // ✅ precise range
if (node.target is! SimpleIdentifier) return; // ✅ type check
❌ Don’t Do This:
final code = LintCode('rule_name', 'Message'); // ❌ not const, breaks ignore
registry.addCompilationUnit(this, visitor); // ❌ too broad, slow
rule.reportLint(node); // ❌ noisy range
if (node.methodName.name == 'print') { ... } // no target check // ❌ matches foo.print()
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
// ignore does not work | LintCode not static const | Use static const |
| Rule never runs | Registered as lint but not enabled | Add diagnostics: { rule: true } |
| Rule reports on wrong calls | No target check | Check node.target |
| Plugin not loaded | Missing top-level plugin variable | Name it plugin |
| Changes not reflected | Analysis server not restarted | Restart analysis server |
Real-World Examples
1. Flag print() in lib
if (node.methodName.name == 'print' && context.isInLibDir) rule.reportLint(node);
2. Flag await in test
if (context.isInTestDir) rule.reportLint(node);
3. Check import URI
if (node.uri.stringValue?.startsWith('package:http/') ?? false) rule.reportLint(node);
4. Flag class without final fields
if (node.fields.isFinal) return; rule.reportLint(node);
5. Check method return type
if (node.returnType == null) rule.reportLint(node.name);
6. Flag specific annotation
if (node.metadata.any((m) => m.name.name == 'Deprecated')) rule.reportLint(node);
7. Check argument count
if (node.argumentList.arguments.length != 2) rule.reportLint(node);
8. Flag raw string literal
if (node.stringValue.contains('\$')) rule.reportLint(node);
9. Check boolean literal
if (node is BooleanLiteral) rule.reportLint(node);
10. Flag empty catch block
if (node.body.statements.isEmpty) rule.reportLint(node);
Visual
AST Visitor Flow
┌──────────────────────────────────────────────┐
│ Source file │
│ │ │
│ ▼ │
│ Analyzer parses to AST │
│ │ │
│ ▼ │
│ Tree walker visits nodes │
│ │ │
│ ▼ │
│ Node type registered? │
│ ├─ NO → skip │
│ └─ YES → call visitor.visitXxx(node) │
│ │ │
│ ▼ │
│ Visitor inspects node │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ violation ok │
│ │ │ │
│ ▼ ▼ │
│ reportLint return │
│ diagnostic │
└──────────────────────────────────────────────┘
Rule and Visitor Separation
┌──────────────────────────────────────────────┐
│ RULE CLASS (AnalysisRule) │
│ - LintCode (name, message) │
│ - registerNodeProcessors │
│ - Declares interest in node types │
│ │ │
│ ▼ │
│ NodeLintRegistry │
│ - addMethodInvocation(rule, visitor) │
│ │ │
│ ▼ │
│ VISITOR CLASS (SimpleAstVisitor) │
│ - visitMethodInvocation(node) │
│ - Inspection logic │
│ - Calls rule.reportLint() │
└──────────────────────────────────────────────┘
Reporting Range
┌──────────────────────────────────────────────┐
│ node = print('hello') │
│ rule.reportLint(node) │
│ → highlights "print('hello')" │
│ │
│ rule.reportLint(node.methodName) │
│ → highlights "print" │
│ │
│ More precise range = better UX │
└──────────────────────────────────────────────┘
Plugin Activation Chain
┌──────────────────────────────────────────────┐
│ lib/main.dart │
│ final plugin = MyPlugin(); │
│ │ │
│ ▼ │
│ plugin.register(registry) │
│ registry.registerLintRule(Rule()) │
│ │ │
│ ▼ │
│ analysis_options.yaml │
│ plugins: │
│ my_lints: │
│ path: ... │
│ diagnostics: │
│ rule_name: true │
│ │ │
│ ▼ │
│ Rule active in IDE and dart analyze │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Rule base class | AnalysisRule |
| Visitor base class | SimpleAstVisitor<void> |
| Diagnostic template | LintCode (static const) |
| Node registration | NodeLintRegistry.addXxx |
| Reporting | rule.reportLint(node) |
| Plugin entry point | Top-level plugin variable |
| Plugin registration | registry.registerLintRule(Rule()) |
| Activation | plugins: + diagnostics: in analysis_options.yaml |
| Default state | Lints disabled, warnings enabled |
| Debugging | No print; restart analysis server |
Key takeaways:
- The rule class declares, the visitor class inspects.
AnalysisRuleholds metadata and registration;SimpleAstVisitorholds the logic . - Register only the node types you need.
addMethodInvocationis faster than walking the whole tree. The analyzer invokes your visitor only for those nodes . LintCodemust be static const. Identity matching is how// ignoreworks. A non-const instance breaks suppression .- Check the target before the method name.
node.methodName.name == 'print'matchesfoo.print()as well as bareprint(). Checknode.targetif you mean only the bare call. - The context provides file information.
context.isInLibDirandcontext.isInTestDirlet rules apply conditionally based on location . - Registering as a lint means disabled by default. You must enable the rule by name in the consuming project’s
diagnostics:block . - Restart the analysis server after changes. The plugin is loaded once at startup; there is no hot reload for rule code .
Remember: Writing a custom lint rule is a two-class exercise. The AnalysisRule subclass declares a LintCode and registers the node types it cares about with NodeLintRegistry. The SimpleAstVisitor subclass overrides the corresponding visitXxx methods and calls reportLint when it finds a violation. The visitor is where your logic lives—the rule class is just the envelope. Register the rule in a plugin’s lib/main.dart, enable it by name in analysis_options.yaml, and restart the analysis server to see the diagnostic. The AST is the structure; the visitor is how you navigate it; reportLint is how you speak.
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!