| |

Dart 66 🎯 Writing Custom Static Analysis Rules with AST (Abstract Syntax Tree) Visitors

⏱️ Estimated read: 12 min | |

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

ElementRequirement
Base classAnalysisRule
DiagnosticLintCode get diagnosticCode
Constructorsuper(name: ..., description: ...)
RegistrationregisterNodeProcessors
RegistryNodeLintRegistry

Visitor Class Requirements

ElementRequirement
Base classSimpleAstVisitor<void>
ConstructorReceives rule and context
MethodOverride visitXxx for registered node
Reportingrule.reportLint(node) or reportLint(child)

Common Node Types and Registration

Node TypeRegistration MethodVisitor Method
MethodInvocationaddMethodInvocationvisitMethodInvocation
AwaitExpressionaddAwaitExpressionvisitAwaitExpression
ImportDirectiveaddImportDirectivevisitImportDirective
ClassDeclarationaddClassDeclarationvisitClassDeclaration

Context Properties

PropertyMeaning
isInLibDirFile is in lib/
isInTestDirFile 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

PitfallWhy It HappensFix
// ignore does not workLintCode not static constUse static const
Rule never runsRegistered as lint but not enabledAdd diagnostics: { rule: true }
Rule reports on wrong callsNo target checkCheck node.target
Plugin not loadedMissing top-level plugin variableName it plugin
Changes not reflectedAnalysis server not restartedRestart 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

ItemValue
Rule base classAnalysisRule
Visitor base classSimpleAstVisitor<void>
Diagnostic templateLintCode (static const)
Node registrationNodeLintRegistry.addXxx
Reportingrule.reportLint(node)
Plugin entry pointTop-level plugin variable
Plugin registrationregistry.registerLintRule(Rule())
Activationplugins: + diagnostics: in analysis_options.yaml
Default stateLints disabled, warnings enabled
DebuggingNo print; restart analysis server

Key takeaways:

  • The rule class declares, the visitor class inspects. AnalysisRule holds metadata and registration; SimpleAstVisitor holds the logic .
  • Register only the node types you need. addMethodInvocation is faster than walking the whole tree. The analyzer invokes your visitor only for those nodes .
  • LintCode must be static const. Identity matching is how // ignore works. A non-const instance breaks suppression .
  • Check the target before the method name. node.methodName.name == 'print' matches foo.print() as well as bare print(). Check node.target if you mean only the bare call.
  • The context provides file information. context.isInLibDir and context.isInTestDir let 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.


Was this helpful?


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!