【Flutter】Custom Lintを使って独自のLintルールを作成する
本記事は、Flutter Advent Calendar 2023の17日目の記事です。
はじめに
こんにちは!Altive株式会社のFlutterアプリ開発者の小林遼太(@naipaka)です🦙
弊社では、受託案件の開発を効率化するためにFlutterFireパッケージのラッパーパッケージを作成し、利用しています。
このパッケージの開発の過程で、特定のコーディングパターンを強制するようなLintルールが必要だと感じることがありました。
特に、StreamController
インスタンスを宣言した際にclose
メソッドの呼び出しを強制するclose_sinksのようなルールが欲しいと考えていました。
そこで、Dartのcustom_lintを使って独自のLintルールを作成してみることにしたので、この記事ではその作成過程を紹介します。
完成例
Config
インスタンスを宣言した際に、dispose
メソッドの呼び出しを強制するルールです。
クラスのフィールドと関数内のローカル変数に対してルールが適用されます。
Config
インスタンスを宣言した際に、dispose
メソッドの呼び出しを強制するルール
Custom Lintとは
custom_lintは、Dartプロジェクトの保守性を向上させるために、パッケージ作者や開発者が独自のLintルールを簡単に作成できるツールです。
これにより、DartのデフォルトLintルールを拡張し、特定のコーディングパターンや慣習に合わせたルールを定義することが可能になります。
特徴は以下の通りです。
- コマンドラインツール: CIでLintのリストを取得するためのコマンドラインを自分で書く必要がない。
- 簡易セットアップ: アナライザーサーバーやエラー処理を扱う必要がなく、Lintの作成に集中できる。
- デバッグ: Dartデバッガーを使用してLintを検査し、ブレークポイントを設定可能。
- ホットリスタート対応: Linterプラグインのソースコードを更新すると、IDE/アナライザーサーバーを再起動せずに動的にリスタートされる。
-
// ignore:
および// ignore_for_file:
の内蔵サポート: 特定の行やファイル全体のLint警告を無視するコメントに対応。 -
テスト:
// expect_lint
を使用した組み込みテストメカニズムを提供。 - print(...)と例外: プラグインがエラーやデバッグメッセージを出力した場合、ログファイルを生成する。
セットアップ
custom_lintのUsageセクションに従って、独自のLintルールを作成するためのセットアップを行います。
依存関係の追加
まずは、プロジェクトを作成し、custom_lint_builderを追加します。
今回はFlutterFireのラッパーパッケージに関するLintルールなので、flutterfire_lint
という名前でプロジェクトを作成します。
下記は執筆時点での最新バージョンです。
flutter create -t package packages/flutterfire_lint --project-name flutterfire_lint
name: flutterfire_lint
environment:
sdk: ^3.0.0
dependencies:
analyzer: ^6.2.0
analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.5.7
Lintルール定義ファイルの作成
プロジェクト内にlib/flutterfire_lint.dart
ファイルを作成し、Lintルールを定義します。
import 'package:custom_lint_builder/custom_lint_builder.dart';
// Lintルールのエントリポイント。
PluginBase createPlugin() => _FlutterFirePlugin();
class _FlutterFirePlugin extends PluginBase {
// 独自の警告/情報/エラーをリストで定義する。
List<LintRule> getLintRules(CustomLintConfigs configs) => [];
}
上記のgetLintRules
メソッドのリストに、DartLintRule
を継承した独自のLintルールを定義したクラスを追加します。
ルールの作成
それでは、Lintルールを作成していきます。
ルールの定義
今回は、flutterfire_configurator
パッケージのConfig
インスタンスを宣言した際に、dispose
メソッドの呼び出しを強制するルールを作成したいので、dispose_config
という名前でルールを定義し、Config
インスタンスの宣言時にdispose
メソッドの呼び出しを強制するメッセージを定義します。
Lintルールに引っかかった場合は、ここで定義したメッセージが表示されます。
class DisposeConfig extends DartLintRule {
const DisposeConfig() : super(code: _code);
static const _code = LintCode(
name: 'dispose_config',
problemMessage: "Undisposed instance of 'Config'.\n"
"Try invoking 'dispose' in the function in which the "
"'Config' was created.",
);
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
// TODO: 変数定義やメソッド呼び出しを検知し、条件に応じてLintを表示する。
}
}
変数定義やローカル変数宣言を検知
次は、run
メソッド内で、Config
インスタンスの宣言を検知し、dispose
メソッドの呼び出しを検知する処理を実装してきます。
参考としてclose_sinksのソースコードを見てみると、registerNodeProcessors
でFieldDeclaration
(フィールドの宣言)とVariableDeclarationStatement
(ローカル変数の宣言)のコールバックを登録していることがわかります。
CustomLintContext
から静的解析結果を監視できるLintRuleNodeRegistry
を取得できるので、同じようにFieldDeclaration
とVariableDeclarationStatement
のコールバックを登録します。
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
// フィールド宣言を監視。
context.registry.addFieldDeclaration((node) {});
// ローカル変数宣言を監視。
context.registry.addVariableDeclarationStatement((node) {});
}
各nodeには、FieldDeclaration
とVariableDeclarationStatement
と呼ばれるASTノードが渡され、これらを使用してフィールド宣言とローカル変数宣言を検知します。
Config
インスタンスの宣言を検知
フィールド宣言からまずは、フィールド宣言からConfig
インスタンスの宣言を検知する処理を実装します。
Config
インスタンスの宣言を検知するには、FieldDeclaration
からインスタンスの型を取得し、Config
インスタンスの型であることを確認する必要があります。
context.registry.addFieldDeclaration((node) {
// 1. フィールド宣言からインスタンスの型を取得。
final targetType = node.fields.type?.type;
if (targetType == null) {
return;
}
// 2. TypeCheckerを使用してインスタンスの型がConfigであることを確認。
final isConfig = const TypeChecker.fromName(
'Config',
packageName: 'flutterfire_configurator',
).isExactlyType(targetType);
if (!isConfig) {
return;
}
// 3. Configインスタンスの宣言を検知した場合、Lintを表示。
reporter.reportErrorForNode(_code, node);
});
1. フィールド宣言からインスタンスの型を取得
まず、FieldDeclaration
のfields
プロパティを調べます。これにはフィールドの宣言が含まれており、例えばlate Config<int> _intConfig;
の場合、fields
にはこの宣言が含まれます。
次に、このVariableDeclarationList
からフィールドの型を示すTypeAnnotation
を取得します。TypeAnnotation
のtype
プロパティには、宣言されたフィールドの具体的な型が含まれています。たとえば、late Config<int> _intConfig
の場合、type
にはConfig<int>
という型情報が格納されています。
この型情報を使用して、フィールドが特定の型(この例ではConfig
型)であるかを検証します。
2. TypeCheckerを使用してインスタンスの型がConfigであることを確認
TypeChecker
を使用して、DartType
がConfig
であることを確認します。
TypeChecker
は、custom_lint_core
で提供されているクラスで、特定の型をチェックするためのユーティリティです。
riverpod_lintのコードでも使用されているため、参考にしてみてください。
3. Configインスタンスの宣言を検知した場合、Lintを表示
Config
インスタンスの宣言を検知した場合、reportErrorForNode
メソッドを使用して、FieldDeclaration
のASTノードに対してLintを表示します。
これで、下記のような形で警告が表示されることが確認できました。
(dispose
メソッドの呼び出しを検知する処理を実装していないため、dispose
が定義されているのに警告が表示されているのは想定通りです)
Config
インスタンスの宣言を検知した場合、Lintを表示
Config
インスタンスの宣言からdispose
メソッドの呼び出しを検知
次は、Config
インスタンスの宣言からdispose
メソッドの呼び出しを検知する処理を実装します。
参考としてclose_sinksのソースコードを追ってみると、以下のような手順でclose
メソッドの呼び出しを検知していることがわかります。
-
FieldDeclaration
のnode
から自身またはその祖先ノードの中でCompilationUnit
(コードの一部分を構成する単位(たとえばファイル全体や一部のコードブロックなど)を表す)型のノードを探す -
CompilationUnit
の子ノードからMethodDeclaration
(メソッドの宣言)型やPrefixedIdentifier
(プレフィックス付きの識別子(たとえば変数や関数などの名前))型のノードなどを探す - 見つかったノード全てからフィールド宣言と同じフィールド名かつ
close
メソッドを呼び出しているかを確認し、もしフィールド宣言と同じフィールド名でclose
が一度も呼び出されていない場合は、FieldDeclaration
のASTノードに対してLintを表示する
今回は、MethodDeclaration
型のノードを一通り探すだけでやりたいことはできそうなので、MethodDeclaration
型のノードを探して、dispose
メソッドの呼び出しを検知する処理を実装します。
// `MethodDeclaration`型のノードを探す。
Iterable<MethodInvocation> traverseMethodInvocations(AstNode node) {
final methodInvocations = <MethodInvocation>[];
void visitNode(AstNode node) {
if (node is MethodInvocation) {
methodInvocations.add(node);
}
if (node is FunctionExpressionInvocation) {
final function = node.function;
if (function is MethodInvocation) {
methodInvocations.add(function);
}
}
node.childEntities.whereType<AstNode>().forEach(visitNode);
}
visitNode(node);
return methodInvocations;
}
// ...
context.registry.addFieldDeclaration((node) {
// ...
if (!isConfig) {
return;
}
// 自身またはその祖先ノードの中で`CompilationUnit`型のノードを探す。
// `CompilationUnit`はコードの一部分を構成する単位(たとえばファイル全体や一部のコードブロックなど)を表す
final compilationUnit = node.thisOrAncestorOfType<CompilationUnit>();
if (compilationUnit == null) {
return;
}
// `compilationUnit`の中で`MethodInvocation`型のノードを探す。
final methodInvocations = traverseMethodInvocations(compilationUnit);
// 1. `node.fields.variables`からフィールド名を取得。
for (final field in node.fields.variables) {
var isDisposed = false;
// 2. `methodInvocations`からメソッド宣言一覧を取得、
for (final methodInvocation in methodInvocations) {
// 3. `methodInvocation.realTarget`からメソッドの実行対象を取得。
final target = methodInvocation.realTarget;
if (target is! SimpleIdentifier) {
continue;
}
final methodName = methodInvocation.methodName.name;
// 4. メソッドの実行対象がフィールド名と一致し、メソッド名が`dispose`であることを確認。
if (target.name == field.name.lexeme && methodName == 'dispose') {
isDisposed = true;
break;
}
}
if (!isDisposed) {
// 5. フィールド宣言と同じフィールド名で`dispose`が一度も呼び出されていない場合は、Lintを表示。
reporter.reportErrorForNode(_code, field);
}
}
});
実装内容を説明していきます。
node.fields.variables
からフィールド名を取得
1. node.fields.variables
からフィールド名を取得します。
node.fields.variables
には、フィールド宣言の一覧が含まれており、例えばlate Config<int> _intConfig;
の場合、node.fields.variables
には_intConfig
というフィールド宣言が含まれます。
methodInvocations
からメソッド宣言一覧を取得
2. methodInvocations
には、MethodInvocation
型のノードの一覧が含まれています。
methodInvocations
は、compilationUnit
の中でMethodInvocation
型のノードを探す処理で取得しているため、compilationUnit
の中で呼び出されているメソッドの一覧が含まれています。
methodInvocation.realTarget
からメソッドの実行対象を取得
3. methodInvocation.realTarget
には、メソッドの実行対象が含まれています。
例えば、_intConfig.dispose()
の場合、_intConfig
がメソッドの実行対象になります。
dispose
であることを確認
4. メソッドの実行対象がフィールド名と一致し、メソッド名がmethodInvocation.realTarget
がSimpleIdentifier
型であることを確認し、methodInvocation.methodName.name
がdispose
であることを確認します。
SimpleIdentifier
型は、プレフィックスや追加の構造を伴わない基本的な変数、関数、またはオブジェクトの名前を指します。
これにより、_intConfig.dispose()
のような形でdispose
メソッドが呼び出されているかを確認できます。
dispose
が一度も呼び出されていない場合は、Lintを表示
5. フィールド宣言と同じフィールド名でここまでで、_intConfig.dispose()
のような形でdispose
メソッドが呼び出されているかを確認できました。
最後に、フィールド宣言と同じフィールド名でdispose
が一度も呼び出されていない場合は、FieldDeclaration
のASTノードに対してLintを表示します。
これで、下記のような形で警告が表示されることが確認できました!
Config
インスタンスの宣言からdispose
メソッドの呼び出しがないことを検知した場合、Lintを表示
ルールのテスト
Custom Lintとはで紹介したように、custom_lintには組み込みのテストメカニズムが用意されています。
今回は、riverpod_lintを参考に、// expect_lint
を使用した組み込みテストメカニズムを使用して、ルールのテストを実装します。
セットアップ
riverpod_lintでは、packages/riverpod_lint_flutter_test
にテスト用のパッケージを作成しています。
同様に、packages/flutterfire_lint_test
にテスト用のパッケージを作成し、flutterfire_lint
パッケージやConfig
が定義されているflutterfire_configurator
パッケージを依存関係に追加します。
flutter create -t package packages/flutterfire_lint_test --project-name flutterfire_lint_test
name: flutterfire_lint_test
publish_to: none
environment:
sdk: ^3.0.0
dependencies:
flutter:
sdk: flutter
flutterfire_configurator:
dev_dependencies:
custom_lint: ^0.5.7
flutter_test:
sdk: flutter
flutterfire_lint:
path: ../flutterfire_lint
dependency_overrides:
flutterfire_configurator:
path: ../flutterfire_configurator
テストの実装
今回のテスト対象である、dispose_config.dart
に対するtest/src/dispose_config.dart
ファイルを作成し、// expect_lint
を使用した組み込みテストメカニズムを使用して、ルールのテストを実装します。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutterfire_configurator/configurator.dart';
class MyWidget extends StatefulWidget {
const MyWidget({super.key});
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late Config<int> _intConfig1;
// expect_lint: dispose_config
late Config<int> _intConfig2;
final streamController = StreamController<int>();
void initState() {
_intConfig1 = Config(
value: 1,
subscription: streamController.stream.listen((event) {}),
);
_intConfig2 = Config(
value: 2,
subscription: streamController.stream.listen((event) {}),
);
debugPrint(_intConfig1.value.toString());
debugPrint(_intConfig2.value.toString());
super.initState();
}
Future<void> dispose() async {
await _intConfig1.dispose();
await streamController.close();
super.dispose();
}
Widget build(BuildContext context) {
return Container();
}
}
上記のコードでは、// expect_lint: dispose_config
を使用して、dispose_config
ルールが適用されることをテストしています。
_intConfig1
はdispose
メソッドが呼び出されているため、dispose_config
ルールに引っかかりませんが、_intConfig2
はdispose
メソッドが呼び出されていないため、dispose_config
ルールに引っかかる想定です。
では、テストを実行してみましょう。
❯ cd packages/flutterfire_lint_test
❯ dart run custom_lint
Analyzing... 0.0s
No issues found!
想定通り、_intConfig2
に対してdispose_config
ルールが適用されていることが確認できました!
まとめ
今回は、custom_lintを使って独自のLintルールを作成する方法を紹介しました。
Lintルールを作成することで、特定のコーディングパターンや慣習に合わせたルールを定義することが可能になります。
独自パッケージの開発でLintルールを作成する際は、ぜひこちらの記事を参考にしてみてください!
参考
Discussion