🍁

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を意識して実装しなきゃいけないようで、メンテするのが辛くなるのが目に見えています。

https://pub.dev/packages/custom_lint

FlutterにおけるCustom Lint作成の決定版は custom_lint です。

こちらは、最近Flutter界隈で話題に上ることが多いINVERTASEのpackage。analyzer_pluginと比較してかなり書きやすいです。

Riverpodの開発者のRemiさんも愛用していて、Riverpod用のLint packageも custom_lint を使って作成しているようです。

しかし、公式で案内されている解説用の動画やBlog記事の記法が最新版やexampleと違ったり、多くのFlutterエンジニアにあまり縁のないStreamを使った実装だったりして、0 → 1 が非常に難しいと感じました。

https://zenn.dev/k9i/articles/20230506_material_button_assist

現状では日本語の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を追加

pubspec.yaml
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したカスタムクラスを使って、自作のルールをどんどん追加していく仕組みです。

project_root/lint/lint.dart
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を作ってみましょう。

project_root/lint/hoge_rule.dart
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, []);
      }
    });
  }
}

書いてみるとかなり簡単じゃないですか?
problemMessageErrorSeverity を柔軟に設定できるので、Ruleの強制度も表現できるのが良いですね!

自作Lintを利用するプロジェクト側の準備

せっかく作ったので、さっそく使ってみましょう。

project_root/pubspec.yaml
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  
  # 追加 -----------------------------------------------------------------
  custom_lint:
  lint_learning:
    path: ./lint # pathは先ほど準備したパスに合わせてください
		  # プロジェクトルートからの相対パスでOK
analysis_options.yaml
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.dartHogeImplのインスタンスを作成しようとすると、Lintでエラーを出してくれます。

ちなみに、 HogeImpl の抽象クラスである SuperHoge のfactoryでは HogeImpl の呼び出しをしていますが、Lintエラーを吐くことなく正常に動作します。

依存関係を厳格にしたいプロジェクトの場合、これらの機能をうまくカスタマイズすることで、その実現をサポートすることができます。

特定のWidgetをワンクリックで置き換えるAssistを作ってみよう

まずはhoge変数をcrazyPotatoに置き換えるAssistを作成してみよう

Widgetの書き換えよりも、変数の書き換えの方が直感的でAssistの機能を理解しやすいので、まずはhoge変数をcrazyPotatoに置き換えるAssistを作成してみます。

適当なところに hoge_assist.dart を作成して HogeAssist を作成します。

project_root/lint/assist/hoge_assist.dart
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を登録します。

project_root/lint/lint.dart
class _HogeLinter extends PluginBase {
	// 省略...

	// ここを追加
  
  List<Assist> getAssists() => [
    // Assistルールを追加
        HogeAssist(),
      ];
}

すると、hogeという変数をワンクリックでcrazyPotatoに置き換えるAssistが完成しました。

いよいよWidgetを書き換える

さらに、クラス名を取得して選択的に適用することもできます。

ButtonAssistElevatedButtonHogeButton に置き換えることができるAssistです。

project_root/lint/assist/button_assist.dart
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'),
                )''');
          });
        });
      }
    });
  }
}

簡単に書き換えができましたね。

さらに、プロパティのみを変更したりもできるので、ElevatedButtononPressedhogePressed に置き換える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さんに。

https://zenn.dev/k9i/articles/20230506_material_button_assist

おまけ

likeください!!
https://twitter.com/hagakun_yakuzai

株式会社マインディアでは、Flutterリードエンジニア、Railsエンジニアを募集しております。
カジュアル面談などの場を用意しておりますので、気軽にお声がけください。

引き続き、Flutter周りの気になることを調査したり、ジュニアなエンジニアが思うことも記事にしていきますので、良かったらZennTwitterのフォローをお願いします!

株式会社マインディア テックブログ

Discussion