analyzer_plugin_toolkit
Shared building blocks for annotation-driven Dart analyzer plugins.
These are the pieces such a plugin needs and none of them should have to write twice: recognising your annotation rather than a same-named one from somebody else's package, doing that fast enough to run on every node of every file, comparing elements the analyzer hands back in more than one shape, and following a value through the local aliases it hides behind.
Install
dependencies:
analyzer_plugin_toolkit: ^1.0.0
import 'package:analyzer_plugin_toolkit/analyzer_plugin_toolkit.dart';
This package is for people writing an analyzer plugin. If you are looking to use lint rules in your own project, you want a plugin package, not this.
Requires Dart 3.13.2 or later. Analyzer plugins first appeared in 3.10, but the element APIs this package is written against settled after that.
What it gives you
| API | Solves |
|---|---|
AnnotationFinder |
Does this declaration carry my annotation? Resolved by package, fast enough to ask everywhere. |
ElementCache |
Memoize a per-element answer without leaking memory in a long-running server. |
normalizeElement |
Compare elements the analyzer returns in more than one shape. |
referencedElement |
Find the declaration an expression denotes, through (), ! and this.. |
AliasResolver |
Follow a value through the local names it hides behind. |
The example/ directory sketches a complete rule using all five.
AnnotationFinder
The core of an annotation-driven rule: does this declaration carry my annotation?
final _annotations = AnnotationFinder('my_package');
bool isMarked(Element? element) => _annotations.has(element, 'Marker');
String? reasonFor(Element? element) =>
_annotations.valueOf(element, 'Marker')?.getField('reason')?.toStringValue();
Two things make this more than a name comparison.
It resolves by package. Matching on the class name alone would let
somebody else's Throws or Disposable drive rules that know nothing about
it. Matching on the exact library URI would be too strict instead: a package
may re-export its annotations from several libraries, and a rule should not
care which one the user imported. So the finder compares the declaring
package, which is the thing that actually identifies an annotation.
It is fast enough to run everywhere. Evaluating a constant is the most
expensive thing a rule does, and real code is full of annotations belonging to
somebody else (@override, @immutable, a generator's), so that evaluation
is almost always wasted. A candidate is therefore rejected by name before it
is evaluated. The remaining answer is memoized per element, because the same
element is asked about once per mention though the answer depends only on the
element.
ElementCache
The memoization underneath, usable directly:
final _isWidget = ElementCache<InterfaceElement, bool>('isWidget');
bool isWidget(InterfaceType type) =>
_isWidget.of(type.element, () => _computeIsWidget(type));
Keyed with an Expando, so an entry lives exactly as long as the element
it describes. That matters in the analysis server, which is long-running and
rebuilds element models as files change: a plain Map would pin every element
of every file ever analysed, and an LRU would need a size nobody can pick
correctly.
Values are boxed so that null can be cached. "This element carries no
annotation" is the common answer, and a cache that could not tell it apart
from "not computed yet" would recompute on every mention, which is the case
the cache exists for.
This is sound only because editing a file yields fresh element objects, so a memoized answer can never be read back for changed source. That guarantee is the analyzer's rather than this package's, so it is checked empirically rather than assumed:
dart run tool/verify_cache_invalidation.dart
Element helpers
normalizeElement undoes the two analyzer details that stop elements
comparing by identity: reading a field resolves to its synthetic getter, and a
member reached through a generic class resolves to a *Member wrapper. A rule
comparing the raw elements would fail to recognise the field it is tracking.
referencedElement looks through parentheses, null-assertions and this.
access to the declaration an expression denotes. Its anyTarget flag decides
whose state counts: by default only the enclosing instance's own members
resolve, so a rule about an object cleaning up after itself is not satisfied
by somebody else's field of the same name.
AliasResolver
Follows a value through the local aliases it hides behind, within one function body:
final aliases = AliasResolver.forBody(body);
if (aliases.refersTo(expression, field)) { /* ... */ }
Resolution returns a set, because one expression can refer to several
declarations: cond ? _a : _b is either one, and the loop variable of
for (final c in [_a, _b]) takes both in turn. A local is an alias for its
initializer only when it is never reassigned. The walk over the body is
deferred until the first question is asked, so building a resolver for a
member that turns out to have nothing of interest costs nothing.
Testing quick fixes
analyzer_testing ships a harness for rules but not for fixes. This
package does not ship one either, deliberately: such a harness must import
package:test, a library under lib/ may only import from dependencies,
and making test a real dependency here would push it onto every plugin built
on the toolkit, where it cannot coexist with the test_api that
flutter_test pins.
The plugins built on this toolkit each keep one in their own test/src/,
under about 200 lines. If you want the same thing, copy
test/src/fix_harness.dart from any of them: it is MIT licensed.
Development
dart analyze --fatal-infos # must be clean
dart test # the toolkit's own behaviour
dart run tool/verify_cache_invalidation.dart # the cache cannot go stale
The package is developed inside the arxdeus/qol_lints pub
workspace, alongside the plugins built on it, which is why its pubspec carries
resolution: workspace. See CONTRIBUTING.md for working on
it standalone, for the commit conventions, and for how a release is cut.
License
MIT. See LICENSE.
Libraries
- analyzer_plugin_toolkit
- Shared building blocks for annotation-driven Dart analyzer plugins.