FlutterのCustom Lintと自動書き換え用Assistを手軽につくって、細かい指摘をLintにぶん投げる方法
まとめ
- custom_lintを使ってLintと自動書き換え用Assistを作る方法を紹介
- Lintを作るのは意外と手軽にできるし、簡単なものはメンテもそんなに難しくなさそう
- チーム内のルールをLintに落とし込んでおくと、後からジョインする人ためにもいいかも
どんな人のための記事?
- Flutter向けのLint作成に興味があるが、敷居が高くて作れてない方
- プロジェクトで定義した
HogeWidget
を 既存のWidgetの代わりに利用したい方 -
HogeWidget
へのコード変更を自動で行えるようにしてみたい方 -
components
ディレクトリから実装クラスであるxxxImpl
を直接呼べないようにしたい方
上記のように、レビューを挟まなくても自動的にチーム内のルールを適応したい人やチームのための記事です。
通常利用範囲のLintを自分の力で作成するためのハンズオン形式の記事となります。したがって、custom_lintにおける機能を隅から隅まで知りたい方向きではありませんのでご注意ください。
FlutterにおけるCustom Lint作成の決定版
dartが提供しているLint用のpackageとして analyzer_plugin
などもありますが、globでファイルを指定して、driverやchannnelを意識して実装しなきゃいけないようで、メンテするのが辛くなるのが目に見えています。
FlutterにおけるCustom Lint作成の決定版は custom_lint です。
こちらは、最近Flutter界隈で話題に上ることが多いINVERTASEのpackage。analyzer_plugin
と比較してかなり書きやすいです。
Riverpodの開発者のRemiさんも愛用していて、Riverpod用のLint packageも custom_lint を使って作成しているようです。
しかし、公式で案内されている解説用の動画やBlog記事の記法が最新版やexampleと違ったり、多くのFlutterエンジニアにあまり縁のないStreamを使った実装だったりして、0 → 1 が非常に難しいと感じました。
現状では日本語のK9iさんの記事とpackageのexampleをみるのが一番わかりやすいです。しかし、あくまでAssistにフォーカスされた記事なのでLintについては深く触れられていません。
リーズナブルにLint, Assistの両方をフォローした入門者向けの記事に需要があるかと考え、この記事を書くことにしました。
自作Lint用パッケージの作成
まず最初にLint用のpackageを作成する必要があります。
愚かな私は、ディレクトリ内でごにょごにょすれば実行できるだろうと考え、無駄な時間を消費したので、さっさとpackageを作りましょう。
あくまでプロジェクト用ということで、公開を想定しないのであれば、 project_root/lint
などに作るのがオススメです。
flutter create --template=package lint
自作Lintパッケージのpubspec.yamlに custom_lint, custom_lint_builderを追加
custom_lint_builder: ^0.5.1
custom_lint: ^0.5.1
flutter pub get
も実行しておきましょう。
自作LintパッケージにRuleを追加してみよう
自作LintパッケージのルートファイルにRuleを登録する
project_root/lint/lint.dart
にルールを登録していきます。
コードを見ればわかりますが、PluginBase
クラスでextendsしたカスタムクラスを使って、自作のルールをどんどん追加していく仕組みです。
library lint;
import 'package:custom_lint_builder/custom_lint_builder.dart';
PluginBase createPlugin() => _HogeLinter();
// Lint Ruleを登録するためのクラス
// PluginBaseクラスのメソッドをオーバーライドしてルールを登録していく
class _HogeLinter extends PluginBase {
// このメソッドでルールを登録
List<LintRule> getLintRules(CustomLintConfigs configs) {
return [
const HogeRule(), // 今から作成します
];
}
// Assist(入力補助)を行いたいときはこっちに登録
List<Assist> getAssists() => [
HogeAssist(), // 今から作成します
];
}
hogeという変数宣言を絶対に許さないLintを作る
Lint Ruleを学ぶには、手っ取り早く何かを作ってしまうのが一番簡単です。
まずは、hogeという変数宣言を絶対に許さないLintを作ってみましょう。
import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
class HogeRule extends DartLintRule {
const HogeRule() : super(code: _code);
static const _code = LintCode(
name: 'custom_name', // Lintの名称。有効可/無効化する際に使う
problemMessage: 'hogeという名前の変数宣言に対してエラーを出すLint', // Lintのエラーメッセージ
errorSeverity: ErrorSeverity.ERROR, // しかも赤色のErrorで絶対に許さないマンする
);
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
// ここにLintの処理を書く
// addVariableDeclarationは変数宣言の際に使う
// addXxxは豊富に用意されているため、必要に応じて使い分けられる
context.registry.addVariableDeclaration((node) {
// 変数宣言のノードからElementを取得する
final element = node.declaredElement;
// elementの名称がhogeだったらエラーを出す
if (element != null && element.name == 'hoge') {
reporter.reportErrorForNode(_code, node, []);
}
});
}
}
書いてみるとかなり簡単じゃないですか?
problemMessage
や ErrorSeverity
を柔軟に設定できるので、Ruleの強制度も表現できるのが良いですね!
自作Lintを利用するプロジェクト側の準備
せっかく作ったので、さっそく使ってみましょう。
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^2.0.0
# 追加 -----------------------------------------------------------------
custom_lint:
lint_learning:
path: ./lint # pathは先ほど準備したパスに合わせてください
# プロジェクトルートからの相対パスでOK
linter:
rules:
# 追加
analyzer:
plugins:
- lint
これで準備はOKですが、もし動かない場合は以下をするとビルドが走って有効になるはずです。
それ以降はLintのpackageを更新してすぐにLintが有効になります。
flutter pub get
dart run custom_lint
hogeという変数宣言を絶対に許さないLintを実際に動かしてみる
プロジェクトで実際に hoge
と変数宣言して、以下のようにLintエラーが出たら成功です。
project_root/lint/hoge_rule.dart
を書き換えてみると、リアルタイムにLintの内容を編集できます。これがかなり便利で、一度設定さえしてしまえばその後はかなり楽できる印象です。
特定のディレクトリから、特定のインスタンス宣言を避けるLintを作ってみよう
ここから少し発展して、 component
ディレクトリに xxxImpl
という実装クラスのインスタンスを作成しようとするとエラーを起こすLintを作成していきましょう。
適当なところに avoid_direct_call.dart
を作って AvoidDirectCallRule
を書いていきます。
import 'package:analyzer/error/error.dart';
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
class AvoidDirectCallRule extends DartLintRule {
const AvoidDirectCallRule() : super(code: _code);
static const _code = LintCode(
name: 'avoid_direct_call',
problemMessage: '直接実装クラスを呼び出さずに、抽象クラスに依存すること',
errorSeverity: ErrorSeverity.ERROR,
);
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
// ファイルのパスを取得
// componentをパスに含むファイルのみを対象にする
final path = resolver.source.uri;
if (!path.toString().contains('component')) return;
context.registry.addInstanceCreationExpression((node) {
// インスタンスの型名を取得
final createdType = node.constructorName.type.type;
// インスタンスの型がImplを含む場合はエラーを出す
if (createdType != null && createdType.toString().contains('Impl')) {
reporter.reportErrorForNode(_code, node, []);
}
});
}
}
いま作成した AvoidDirectCallRule
をルールに追加します。
project_root/lint/lint.dart
List<LintRule> getLintRules(CustomLintConfigs configs) {
return [
const HogeRule(),
const AvoidDirectCallRule(), // ここを追加
];
}
実装はたったこれだけです。
例えばcomponent/some_component.dart
で HogeImpl
のインスタンスを作成しようとすると、Lintでエラーを出してくれます。
ちなみに、 HogeImpl
の抽象クラスである SuperHoge
のfactoryでは HogeImpl
の呼び出しをしていますが、Lintエラーを吐くことなく正常に動作します。
依存関係を厳格にしたいプロジェクトの場合、これらの機能をうまくカスタマイズすることで、その実現をサポートすることができます。
特定のWidgetをワンクリックで置き換えるAssistを作ってみよう
まずはhoge変数をcrazyPotatoに置き換えるAssistを作成してみよう
Widgetの書き換えよりも、変数の書き換えの方が直感的でAssistの機能を理解しやすいので、まずはhoge変数をcrazyPotatoに置き換えるAssistを作成してみます。
適当なところに hoge_assist.dart
を作成して HogeAssist
を作成します。
import 'package:analyzer/source/source_range.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
class HogeAssist extends DartAssist {
Future<void> run(
CustomLintResolver resolver,
ChangeReporter reporter,
CustomLintContext context,
SourceRange target,
) async {
context.registry.addVariableDeclaration((node) {
// カーソルがターゲットノードの範囲外ならアシストを表示せずreturn
if (!target.intersects(node.sourceRange)) return;
// 宣言された要素を取得
final element = node.declaredElement;
if (element != null && element.name == 'hoge') {
final changeBuilder = reporter.createChangeBuilder(
message: 'hoge -> : crazyPotato', // クイックフィックスに表示されるmessage
priority: 1000, // クイックフィックスに表示される順序に影響する優先度
);
changeBuilder.addDartFileEdit((builder) {
builder.addSimpleReplacement(
SourceRange(
node.declaredElement!.nameOffset, // hogeの先頭から
node.declaredElement!.nameLength, // hogeの4文字を置換
),
'crazyPotato',
);
});
}
});
}
}
忘れずにAssistを登録します。
class _HogeLinter extends PluginBase {
// 省略...
// ここを追加
List<Assist> getAssists() => [
// Assistルールを追加
HogeAssist(),
];
}
すると、hogeという変数をワンクリックでcrazyPotatoに置き換えるAssistが完成しました。
いよいよWidgetを書き換える
さらに、クラス名を取得して選択的に適用することもできます。
ButtonAssist
は ElevatedButton
を HogeButton
に置き換えることができるAssistです。
import 'package:analyzer/source/source_range.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
class ButtonAssist extends DartAssist {
Future<void> run(
CustomLintResolver resolver,
ChangeReporter reporter,
CustomLintContext context,
SourceRange target,
) async {
// インスタンスを取得
context.registry.addInstanceCreationExpression((node) {
if (!target.intersects(node.sourceRange)) return;
// インスタンスの型を取得
final createdType = node.constructorName.type.type;
if (createdType != null && createdType.toString() == 'ElevatedButton') {
final changeBuilder = reporter.createChangeBuilder(
message: 'HogeButtonに変えちゃうAssist',
priority: 1000,
);
changeBuilder.addDartFileEdit((builder) {
builder.addReplacement(node.sourceRange, (builder) {
builder.write('''HogeButton(
onPressed: () {
print('hoge');
},
child: Text('hoge'),
)''');
});
});
}
});
}
}
簡単に書き換えができましたね。
さらに、プロパティのみを変更したりもできるので、ElevatedButton
の onPressed
を hogePressed
に置き換えるAssistも簡単に作成できます。
import 'package:analyzer/source/source_range.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
class ButtonAssist extends DartAssist {
Future<void> run(
CustomLintResolver resolver,
ChangeReporter reporter,
CustomLintContext context,
SourceRange target,
) async {
// インスタンスを取得
context.registry.addInstanceCreationExpression((node) {
if (!target.intersects(node.sourceRange)) return;
final createdType = node.constructorName.type.type;
if (createdType != null && createdType.toString() == 'ElevatedButton') {
// プロパティを取得
final args = node.argumentList.arguments;
final changeBuilder = reporter.createChangeBuilder(
message: 'onPressedをHogePressedに変える',
priority: 1000,
);
changeBuilder.addDartFileEdit((builder) {
for (var element in args) {
builder.addReplacement(element.sourceRange, (builder) {
final replaced =
element.toString().replaceAll('onPressed', 'hogePressed');
builder.write(replaced);
});
}
});
}
});
}
忘れずにLinterに登録するようにしましょう。よく忘れるので注意です!
まとめ
今回は、custom_lintを使った自作Lintの作成方法をまとめてみました。
Lintの作成にはかなり高いハードルを感じていましたが、custom_lintを使えばグッとハードルを下げ、柔軟にルールを設定できそうです。
Assistも同時に作成できるため、Convert機能を実装すればコードを書く手間をがっつり減らすことも可能です。
毎回レビューで小さい指摘を行うのは、reviewerにもrevieweeにも負担をかけてしまいます。
チーム内のコミュニケーションの問題にもなりえますし、そこから修正コミットをpushして…など出戻りの発生を考えると、Lintルールの整備による生産性の向上効果はバカにできないと思います。
逆に、すでに好ましくないコードが発生している場合に、Lintルールを先に設定してIDEにそれらをリストアップしてもらうような使い方もあるかもしれません。
これだけ簡単に作れるなら、運用も柔軟にできそうです。
ぜひお試しください!
謝辞
日本語で詳しい記事を書いてくださったK9iさんに。
おまけ
likeください!!
株式会社マインディアでは、Flutterリードエンジニア、Railsエンジニアを募集しております。
カジュアル面談などの場を用意しておりますので、気軽にお声がけください。
引き続き、Flutter周りの気になることを調査したり、ジュニアなエンジニアが思うことも記事にしていきますので、良かったらZennやTwitterのフォローをお願いします!
Discussion
dartの公式で使われてるanalyzer, analysis_serverのルールとかのコードも実装の参考になりますよね
custome_lint用に修正は必要ですけど
lint rule
修正
めっちゃいい情報ありがとうございます!
これを見るのが正攻法かつ確実ですね。
参考になりました!