| |

Dart 65 🎯 Custom Analyzer Plugins, Linter Rules, and analysis_options.yaml Configuration

⏱️ Estimated read: 17 min | |

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

FilePurpose
pubspec.yamlDeclares analysis_server_plugin and analyzer deps
lib/main.dartMust expose a top-level plugin variable
lib/rules/*.dartOne file per rule (rule class + visitor)
analysis_options.yamlOptional; used only when the plugin lints itself

Plugin Class Members

MemberTypeRole
nameStringMust match the config key
registervoidCalled once to register rules
registerLintRulemethodRule off by default
registerWarningRulemethodRule on by default

Rule and Visitor Roles

ClassBaseResponsibility
Rule classAnalysisRuleMetadata, LintCode, node registration
Visitor classSimpleAstVisitorInspection logic, reportLint calls
LintCodevalueName, message, correction message
NodeLintRegistryvalueMaps node types to visitors

Configuration Keys

KeyLocationMeaning
plugins:top levelEnables plugin packages
path:under pluginLocal plugin path
version constraintunder pluginpub.dev source
diagnostics:under pluginPer-rule boolean toggles
ignore:inline commentSuppresses one line
ignore_for_file:file headerSuppresses whole file

Default States

KindRegistered WithDefault State
LintregisterLintRuleDisabled
WarningregisterWarningRuleEnabled
Override ondiagnostics: {rule: true}Enabled
Override offdiagnostics: {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

PitfallWhy It HappensFix
Plugin not loadedNo top-level plugin variableRename the variable to plugin
Rule never firesRegistered as lint, not enabledAdd diagnostics: {rule: true}
Rule name mismatchname getter differs from config keyMake them identical
Diagnostic not suppressibleUnstable or duplicate LintCode nameKeep names unique and permanent
Analyzer crashes on loadVersion mismatch with analyzerPin analyzer to the SDK version
Slow analysisRegistering a whole-file visitorRegister only the needed node type
Fix not offeredNo correction wired upAttach a correction producer
ignore has no effectUsing class name, not LintCode nameUse 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

ItemValue
Plugin entry pointTop-level plugin variable in lib/main.dart
Plugin basePlugin from analysis_server_plugin
Registrationregister(PluginRegistry)
Lint defaultDisabled until enabled in config
Warning defaultEnabled unless disabled in config
Rule classExtends AnalysisRule
Visitor classExtends SimpleAstVisitor
Diagnostic codeLintCode with stable name
Node registrationNodeLintRegistry.addXxx
EnablementTop-level plugins: block
Per-rule togglediagnostics: map

Key takeaways:

  • The plugin variable 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; addMethodInvocation beats walking whole compilation units .
  • LintCode names are public API. They appear in ignore comments and diagnostics: 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 analyze and 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 analyzer dependency 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.


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!