Flutter Custom Lint Rules: Mastering analysis_options.yaml

Master Flutter’s analysis_options.yaml: layer base lints, enable custom_lint plugins, tune severities, and wire it all into your editor and CI.

ASOasis
7 min read
Flutter Custom Lint Rules: Mastering analysis_options.yaml

Image used for representation purposes only.

Overview

Flutter projects live and breathe through the Dart analyzer. Lint rules transform that analyzer output into actionable guidance, catching bugs early and shaping a consistent codebase. This article shows how to take control of your linting by mastering analysis_options.yaml, layering base lint sets, enabling plugin‑driven custom lints, tuning severities, and wiring everything into your editor and CI.

The moving parts

Before we configure anything, it helps to name the pieces involved:

  • Dart analyzer: the engine that parses and type‑checks Dart code and reports diagnostics.
  • Linter rules: a large, extensible catalog of style and correctness checks. These are what you typically enable under linter: rules:.
  • Analyzer diagnostics: errors, warnings, and infos reported by the analyzer itself and by plugins.
  • analysis_options.yaml: the project‑level file that configures both the analyzer and the linter.
  • Base lint sets: curated rule bundles you can include (for example, flutter_lints).
  • Analyzer plugins: extend the analyzer with domain‑specific diagnostics (for example, custom_lint‑based plugins like riverpod_lint). Plugins can surface “lint‑like” findings that are not regular linter rules, but you configure their severity the same way.

Start with a solid base

Most Flutter apps begin by including a base ruleset and then layering overrides on top.

# analysis_options.yaml
include: package:flutter_lints/flutter.yaml

Why include?

  • Consistency: you inherit a maintained baseline aligned with modern Dart/Flutter.
  • Diff‑friendly: you only add what is unique to your project.
  • Upgradability: bump the package version and you get curated improvements.

Alternatives you may see in the wild include package:lints (Dart‑only projects), very_good_analysis, and other community presets. The mechanics are identical: include first, override second.

The anatomy of analysis_options.yaml

A typical file contains three sections:

include: package:flutter_lints/flutter.yaml

analyzer:
  plugins:
    - custom_lint     # enables plugin‑provided diagnostics (see below)
  exclude:
    - build/**
    - lib/**.g.dart
    - lib/**.freezed.dart
    - ios/Pods/**
    - android/**
  language:
    strict-inference: true
    strict-raw-types: true
    strict-casts: true
  errors:
    # Tweak severities for analyzer + linter + plugin diagnostics
    dead_code: warning
    always_use_package_imports: error
    invalid_annotation_target: ignore
    # Example of a plugin diagnostic (code name depends on the plugin)
    riverpod_missing_provider_scope: error

linter:
  rules:
    # Add rules that aren’t already in your base set
    - avoid_print
    - prefer_final_locals

Key points:

  • include pulls in a base config. Anything you declare after it overrides the base.
  • analyzer.plugins enables plugins (such as custom_lint) that surface extra diagnostics.
  • analyzer.exclude prevents the analyzer from traversing generated or third‑party code.
  • analyzer.language toggles strictness features that catch more issues at compile‑time.
  • analyzer.errors lets you elevate/demote severity for any diagnostic by its code name.
  • linter.rules is where you explicitly enable additional core linter rules.

Custom lint rules the easy way: plugins powered by custom_lint

For Flutter, the most ergonomic way to get domain‑specific rules is via custom_lint‑based plugins. Many ecosystem packages ship their own lints (for example, state‑management libraries). Setup is straightforward:

  1. Add dev dependencies in your pubspec.yaml
dev_dependencies:
  custom_lint: any            # the analyzer plugin runner
  some_package_lint: any      # e.g., riverpod_lint, freezed_lint, etc.
  1. Enable the plugin in analysis_options.yaml
analyzer:
  plugins:
    - custom_lint
  1. Run the analyzer
  • In IDEs, diagnostics appear automatically.
  • From the terminal, use one of:
    • dart analyze
    • flutter analyze
    • dart run custom_lint (handy while developing rules or to see plugin output directly)

How do you configure or silence a specific plugin rule?

  • Treat it like any other diagnostic. Find the rule’s code (from the analyzer output) and set its severity:
analyzer:
  errors:
    some_plugin_rule_code: warning   # or info | error | ignore

Some plugins also offer their own YAML blocks for fine‑grained behavior. When available, place those top‑level sections next to linter: and analyzer:. Consult each plugin’s README for the exact keys. Severity still flows through analyzer.errors.

Writing your own custom lints

If existing plugins don’t cover your needs, you can ship your own rules with the custom_lint builder API. The high‑level workflow looks like this:

  • Create a new Dart package (for example, my_company_lints).
  • Add dependencies: custom_lint_builder (for authoring) and analyzer.
  • Implement rules by visiting AST nodes and emitting diagnostics.
  • Optionally, attach automated fixes that appear in your IDE.
  • Publish the package (private or public). Consumers depend on it as a dev dependency, enable custom_lint in analyzer.plugins, and configure severities as usual.

A simplified skeleton:

// lib/src/my_rule.dart
import 'package:custom_lint_builder/custom_lint_builder.dart';

class PreferAsyncSuffix extends DartLintRule {
  PreferAsyncSuffix()
      : super(code: LintCode(name: 'prefer_async_suffix', problemMessage:
            'Async functions should end with `Async` to signal asynchrony.'));

  @override
  void run(CustomLintResolver resolver, ErrorReporter reporter, CustomLintContext context) {
    resolver.getLibraryResult().then((library) {
      for (final function in library.element.topLevelFunctions) {
        final returnsFuture = function.returnType.getDisplayString(withNullability: true).startsWith('Future');
        final endsWithAsync = function.name.endsWith('Async');
        if (returnsFuture && !endsWithAsync) {
          reporter.reportErrorForElement(code, function);
        }
      }
    });
  }
}

// lib/my_company_lints.dart
import 'package:custom_lint_builder/custom_lint_builder.dart';
import 'src/my_rule.dart';

PluginBase createPlugin() => _MyPlugin();

class _MyPlugin extends PluginBase {
  @override
  List<LintRule> getLintRules(CustomLintConfigs _) => [PreferAsyncSuffix()];
}

Consumers then add your package to dev_dependencies and keep analyzer.plugins: - custom_lint in their analysis_options.yaml. They can tune its severity with analyzer.errors.prefer_async_suffix.

Tuning severity and scope

You have three levers to right‑size signal vs noise:

  1. Globally in analysis_options.yaml
analyzer:
  errors:
    prefer_const_constructors: warning
    prefer_final_fields: info
    prefer_async_suffix: error   # custom rule from example above
  1. Locally in code (single hit or whole file)
// ignore: prefer_const_constructors
final theme = ThemeData();

// Or at file level:
// ignore_for_file: prefer_const_constructors, prefer_final_fields
  1. Per‑directory using multiple analysis_options.yaml files
  • Each package (or sub‑package in a mono‑repo) can have its own analysis_options.yaml, inheriting from a shared root via include:. This lets libraries be stricter than apps, or tests be looser than lib/.

Excluding generated and vendor code

Generated files and third‑party source inflate noise and slow analysis. Common excludes:

analyzer:
  exclude:
    - build/**
    - lib/**.g.dart
    - lib/**.freezed.dart
    - lib/**.mapper.dart
    - ios/Pods/**
    - android/**
    - test/**.mocks.dart

Tip: prefer exact patterns for your generators to avoid accidentally skipping real code.

Strictness settings that pay off

Under analyzer.language, consider enabling:

  • strict-inference: forces you to annotate or write code so type inference is unambiguous.
  • strict-raw-types: flags raw generic usages like List instead of List.
  • strict-casts: surfaces potentially unsafe casts.

These options catch subtle issues that lints alone may miss.

CI and editor integration

  • Terminal: dart analyze (or flutter analyze) returns a non‑zero exit code on errors. Add flags to tighten the gate in CI:
    • dart analyze –fatal-infos –fatal-warnings
  • IDEs: VS Code and IntelliJ/Android Studio read analysis_options.yaml automatically. When you change the file, your diagnostics update live.
  • Auto‑fixes: dart fix –apply can handle many linter suggestions. For plugin‑provided fixes, your IDE shows quick‑fixes if the rule supplies them.

Versioning and change management

  • Pin your base lint dependency versions (for example, flutter_lints) and upgrade intentionally. Read changelogs; new releases may add rules that turn into new warnings.
  • Keep a changelog for your team’s analysis_options.yaml. Treat it like code: PR review, rationale in commit messages.
  • In mono‑repos, maintain a top‑level analysis/common.yaml and let each package include it, then layer specific overrides.

Common pitfalls and how to avoid them

  • Forgetting analyzer.plugins: - custom_lint
    • Symptom: plugin lints never appear. Solution: add the plugin entry and restart analysis.
  • Duplicating rules across includes
    • Safe but noisy. Prefer enabling only the few you need outside the base set.
  • Disabling rules in linter.rules with boolean false
    • Not portable. Standard practice is to set analyzer.errors.: ignore.
  • Over‑broad exclude globs
    • Double‑check patterns so you don’t exclude real source files.

A production‑ready template

Use this as a starting point and tailor to your stack:

# analysis_options.yaml (Flutter)
include: package:flutter_lints/flutter.yaml

analyzer:
  plugins:
    - custom_lint
  exclude:
    - build/**
    - lib/**.g.dart
    - lib/**.freezed.dart
    - lib/**.mocks.dart
    - ios/Pods/**
    - android/**
  language:
    strict-inference: true
    strict-raw-types: true
    strict-casts: true
  errors:
    # Elevate a few high‑value rules
    avoid_print: error
    always_use_package_imports: error
    unnecessary_late: warning
    # Plugin diagnostics (codes depend on the plugin you use)
    prefer_async_suffix: error          # example from a custom plugin
    riverpod_missing_provider_scope: error

linter:
  rules:
    - prefer_final_locals
    - use_super_parameters
    - avoid_slow_async_io

Workflow checklist

  • Pick a base set and include it.
  • Enable custom_lint in analyzer.plugins to unlock plugin diagnostics.
  • Add plugin packages as dev dependencies (state managers, code generators, your own lints).
  • Exclude generated/vendor code.
  • Turn on strict language options.
  • Set severities in analyzer.errors and promote important ones to errors.
  • Keep local ignores rare and well‑commented.
  • Enforce in CI with dart analyze and fatal flags; auto‑fix what you can.

Closing thoughts

Custom lint rules let you encode team conventions and framework knowledge directly into your tooling. By structuring analysis_options.yaml thoughtfully, layering a base, adding targeted plugins, and right‑sizing severities, you get fast feedback, safer refactors, and a codebase that reads like one person wrote it—even when dozens did.

Related Posts