Dart 怎么阻止你的同事使用Getx(自定义Lint)

9,263 阅读16分钟

前言

  • 无规则不成方圆,不管什么平台,写什么代码。每一种编程语言都有着自己的语法标准,代码规范,并且在不断更新改进,达到优化语言性能的目的。

  • 俗话说代码不规范,维护两行泪,说的就是这个道理。我们应该遵守它们,避免代码看起来乱七八糟。

在之前的 Flutter Analysis Options - 掘金 (juejin.cn)中,我们提到了可以通过增加 analysis_options.yaml 为项目增加代码规范限制。

但是如果官方提供的 lint 已经不能满足你的需求,比如,阻止你的同事使用 Getx,咋办了?不怕,我们可以自定义 lint

截屏2022-10-27 16.27.36.png

截屏2022-10-27 16.48.53.png

截屏2022-10-27 16.49.20.png

analyzer_plugin

我们可以通过 analyzer_plugin 来编写属于自己的插件,用于错误分析。

analyzer_plugin 文档,注意,官方的文档,看看就行了,具体问题可以查看 issue

本文章是基于以下版本

dependencies:
  analyzer: ^4.7.0
  analyzer_plugin: ^0.11.1

插件结构

比如你想创建插件的名字是 custom_lint,那么你需要生成如下的项目结构。

├─ custom_lint
│  └─ tools
│     └─ analyzer_plugin
│        ├─ bin
│        │  └─ plugin.dart

入口

入口为 plugin.dart

void main(List<String> args, SendPort sendPort) {
  // Invoke the real main method in the plugin package. 
}

最小的插件

你需要继承 ServerPlugin,下面几个属性和方法是需要实现的。

这里先简单介绍,后面再细讲。

  • name 插件的名字
  • version 插件的版本
  • fileGlobsToAnalyze 希望被分析的文件类型
  • analyzeFile 分析文件,做一些操作
class CustomLintPlugin extends ServerPlugin {
  CustomLintPlugin()
      : super(resourceProvider: PhysicalResourceProvider.INSTANCE);
  @override
  List<String> get fileGlobsToAnalyze => <String>['**/*.dart'];

  @override
  String get name => 'custom_lint';

  @override
  String get version => '1.0.0';
  @override
  Future<void> analyzeFile({
    required AnalysisContext analysisContext,
    required String path,
  }) async {}
}

启动插件

void main(List<String> args, SendPort sendPort) {
  ServerPluginStarter(
    CustomLintPlugin(),
  ).start(sendPort);
}

将插件加入你的项目

比如我们创建了一个项目叫 example

  1. custom_lint 增加到 根目录 pubspec.yamldev_dependencies
dev_dependencies:
  custom_lint:
    path: 你插件的位置
  1. custom_lint 增加到根目录 analysis_options.yamlanalyzer plugins tag 下面
analyzer:
  plugins:
    custom_lint

这样子我们就做好了做一个插件的前期准备工作了。

增加分析文件代码

过滤文件

analyzeFile 方法中,我们需要再过滤下文件,fileGlobsToAnalyze 虽然设置了,但是感觉官方这里会返回跟 dart 相关的一些文件,比如 pubspec.yamlanalysis_options.yaml 等。

if (!path.endsWith('.dart')) { return; }

获取解析结果

关于解析 dart 文件,在前不久的文章 Flutter 法法路由 10.0 - 掘金 (juejin.cn)当中,有详细的介绍。

通过 getResolvedUnitResult 方法拿到分析的结果。

final ResolvedUnitResult result = await getResolvedUnitResult(path);

遍历解析结果

我们需要使用一个 AstVisitor, 来遍历解析结果,找到自己想要的部分。比如说我们这里想做一个限制 Class 名字前缀的 lint ,那么我们需要拿到文件中的 ClassDeclaration

class MyAstVisitor extends GeneralizingAstVisitor<void> {
  MyAstVisitor(this.classes);
  final List<ClassDeclaration> classes;
  @override
  void visitClassDeclaration(ClassDeclaration node) {
    classes.add(node);
    super.visitClassDeclaration(node);
  }
}

通过 result.unit.visitChildren 方法,遍历获取全部的 ClassDeclaration

      final List<ClassDeclaration> classes = <ClassDeclaration>[];
result.unit.visitChildren(MyAstVisitor(classes));

处理结果

如果我们发现 class 的名字不是以 'Candies' 开头的,那么这就是符合我们限制的情况。(这里也处理下以 _ 开头的类)

for (final ClassDeclaration classDeclaration in classes) {
  final String name = classDeclaration.name2.toString();
  final int startIndex = _getClassNameStartIndex(name);
  if (!name.substring(startIndex).startsWith('Candies')) {
  // TODO
  }
}
int _getClassNameStartIndex(String nameString) {
  int index = 0;
  while (nameString[index] == '_') {
    index++;
    if (index == nameString.length - 1) {
      break;
    }
  }
  return index;
}

生成错误

想让 ide 感知到错误,我们需要生成 AnalysisError,它包含了错误的一些信息。

属性描述默认
code这个错误的名字,唯一.必填
message描述这个错误的信息必填
url这个错误文档的链接.
type错误的类型.
CHECKED_MODE_COMPILE_TIME_ERROR
COMPILE_TIME_ERROR
HINT
LINT
STATIC_TYPE_WARNING
STATIC_WARNING
SYNTACTIC_ERROR
TODO
默认为 LINT.
severity这个错误的严肃性(一般我们修改的是这个).
INFO
WARNING
ERROR
默认为 INFO.
correction修复这个错误的一些描述.
contextMessages额外的信息帮助修复这个错误。

这里需要使用到 LineInfo 来获取该错误的位置,它是来之于 result(特别注意下,LineInfo 是针对 ide 来说的,就是说它的行列都是从 1 开始)。

final LineInfo lineInfo = result.lineInfo;
final List<AnalysisError> errors = <AnalysisError>[];
for (final ClassDeclaration classDeclaration in classes) {
  final String name = classDeclaration.name2.toString();
  final int startIndex = _getClassNameStartIndex(name);
  if (!name.substring(startIndex).startsWith('Candies')) {
    final CharacterLocation startLocation =
        lineInfo.getLocation(classDeclaration.name2.offset);
    final CharacterLocation endLocation =
        lineInfo.getLocation(classDeclaration.name2.end);
    final AnalysisError error = AnalysisError(
      AnalysisErrorSeverity.WARNING,
      AnalysisErrorType.LINT,
      Location(
        path,
        classDeclaration.name2.offset,
        classDeclaration.name2.length,
        startLocation.lineNumber,
        startLocation.columnNumber,
        endLine: endLocation.lineNumber,
        endColumn: endLocation.columnNumber,
      ),
      'Define a class name start with Candies',
      'perfer_candies_class_prefix',
    );
    errors.add(error);
  }
}

发送错误

最后将错误发送出去,这样子就完成了一个限制Class 名字前缀 lint

channel.sendNotification(
  AnalysisErrorsParams(path, errors).toNotification(),
);

完整代码

增加了异常处理,完整代码如下。

class CustomLintPlugin extends ServerPlugin {
  CustomLintPlugin()
      : super(resourceProvider: PhysicalResourceProvider.INSTANCE);
  @override
  List<String> get fileGlobsToAnalyze => <String>['**/*.dart'];

  @override
  String get name => 'custom_lint';

  @override
  String get version => '1.0.0';
  @override
  Future<void> analyzeFile({
    required AnalysisContext analysisContext,
    required String path,
  }) async {
    if (!path.endsWith('.dart')) {
      return;
    }

    try {
      final ResolvedUnitResult result = await getResolvedUnitResult(path);
      final LineInfo lineInfo = result.lineInfo;
      final List<ClassDeclaration> classes = <ClassDeclaration>[];
      result.unit.visitChildren(MyAstVisitor(classes));
      final List<AnalysisError> errors = <AnalysisError>[];
      for (final ClassDeclaration classDeclaration in classes) {
        final String name = classDeclaration.name2.toString();
        final int startIndex = _getClassNameStartIndex(name);
        if (!name.substring(startIndex).startsWith('Candies')) {
          final CharacterLocation startLocation =
              lineInfo.getLocation(classDeclaration.name2.offset);
          final CharacterLocation endLocation =
              lineInfo.getLocation(classDeclaration.name2.end);
          final AnalysisError error = AnalysisError(
            AnalysisErrorSeverity.WARNING,
            AnalysisErrorType.LINT,
            Location(
              path,
              classDeclaration.name2.offset,
              classDeclaration.name2.length,
              startLocation.lineNumber,
              startLocation.columnNumber,
              endLine: endLocation.lineNumber,
              endColumn: endLocation.columnNumber,
            ),
            'Define a class name start with Candies',
            'perfer_candies_class_prefix',
          );
          errors.add(error);
        }
      }

      channel.sendNotification(
        AnalysisErrorsParams(path, errors).toNotification(),
      );
    } on Exception catch (e, stackTrace) {
      channel.sendNotification(
        PluginErrorParams(
          false,
          e.toString(),
          stackTrace.toString(),
        ).toNotification(),
      );
    }
  }

  int _getClassNameStartIndex(String nameString) {
    int index = 0;
    while (nameString[index] == '_') {
      index++;
      if (index == nameString.length - 1) {
        break;
      }
    }
    return index;
  }
}

class MyAstVisitor extends GeneralizingAstVisitor<void> {
  MyAstVisitor(this.classes);
  final List<ClassDeclaration> classes;
  @override
  void visitClassDeclaration(ClassDeclaration node) {
    classes.add(node);
    super.visitClassDeclaration(node);
  }
}

清除缓存,重启服务

  1. 删除 .plugin_manager 文件夹,原因后面细讲。

macos: /Users/user_name/.dartServer/.plugin_manager/

windows: C:\Users\user_name\AppData\Local\.dartServer\.plugin_manager\

  1. View 下面找到 Command Palette

command_palette.png

  1. 输入 Restart Analysis Server

analysis_command.png

现在可以看到更新后的效果。

截屏2022-10-28 10.12.42.png

增加快速修复

我们需要实现 handleEditGetFixes 方法。

@override
Future<EditGetFixesResult> handleEditGetFixes(
      EditGetFixesParams parameters) async {
  return EditGetFixesResult(const <AnalysisErrorFixes>[]);
}

保存 AnalysisContextCollection

保存 AnalysisContextCollection,供 handleEditGetFixes 方法中使用。

late AnalysisContextCollection _analysisContextCollection;
@override
Future<void> afterNewContextCollection({
  required AnalysisContextCollection contextCollection,
}) async {
  _analysisContextCollection = contextCollection;
  return super
      .afterNewContextCollection(contextCollection: contextCollection);
}

保存错误

由于 ResolvedUnitResult 中的 errors 是不包含自定义的错误的,所以我们需要在 analyzeFile 方法中缓存下对应的错误。

下面代码删掉了无关部分。

 final Map<String, Set<AnalysisError>> _errorMap =
      <String, Set<AnalysisError>>{};
  @override
  Future<void> analyzeFile({
    required AnalysisContext analysisContext,
    required String path,
  }) async {
    final Set<AnalysisError> errors = <AnalysisError>{};
    _errorMap[path] = errors;
    channel.sendNotification(
      AnalysisErrorsParams(path, errors).toNotification(),
  }

获取错误

根据文件路径从缓存的错误中找到这个错误。

@override
Future<EditGetFixesResult> handleEditGetFixes(
      EditGetFixesParams parameters) async {
  final String file = parameters.file;
  if (_errorMap.containsKey(parameters.file)) {
    final List<AnalysisError> errors = _errorMap[file]!.toList();
    for (final AnalysisError error in errors) {
      if (error.code == 'perfer_candies_class_prefix') {}
    }
  }
  return EditGetFixesResult(const <AnalysisErrorFixes>[]);
}

生成快速修复

  1. 通过保存的 AnalysisContextCollection 创建 ChangeBuilder
  2. 通过 AnalysisContextCollectioncurrentSession 创建 ChangeBuilder
  3. 然后通过 ChangeBuilder 来生成 SourceChange
final String file = parameters.file;
if (_errorMap.containsKey(file)) {
  final AnalysisContext context =
      _analysisContextCollection.contextFor(file);
  final ResolvedUnitResult result = await getResolvedUnitResult(file);
  final List<AnalysisError> errors = _errorMap[file]!.toList();
  final List<AnalysisErrorFixes> sourceChanges = <AnalysisErrorFixes>[];
  for (final AnalysisError error in errors) {
    final int start = error.location.offset;
    final int end = error.location.offset + error.location.length;
    if (error.code == 'perfer_candies_class_prefix' &&
        start <= parameters.offset &&
        parameters.offset <= end) {
      final ChangeBuilder changeBuilder = ChangeBuilder(
        session: context.currentSession,
      );
      final String nameString = result.content.substring(
        start,
        end,
      );
      await changeBuilder.addDartFileEdit(
        file,
        (DartFileEditBuilder dartFileEditBuilder) {
          final int startIndex = _getClassNameStartIndex(nameString);
          final RegExp regExp = RegExp(nameString);
          final String replace =
              '${nameString.substring(0, startIndex)}Candies${nameString.substring(startIndex)}';
          for (final Match match in regExp.allMatches(result.content)) {
            dartFileEditBuilder.addSimpleReplacement(
              SourceRange(match.start, match.end - match.start),
              replace,
            );
          }
          //format
          dartFileEditBuilder.format(SourceRange(0, result.unit.length));
        },
      );
      final SourceChange sourceChange = changeBuilder.sourceChange;
      sourceChange.message = 'Use Candies as a class prefix.';
      // 同一个错误可能有多个修复
      // priority 按照从大到小排序
      sourceChanges.add(AnalysisErrorFixes(
        error,
        fixes: <PrioritizedSourceChange>[
          PrioritizedSourceChange(
            0,
            sourceChange,
          )
        ],
      ));
    }
  }
  return EditGetFixesResult(sourceChanges);
}

有意思的是,ChangeBuilder 有以下三个方法,但是对于快速修复来说,并不支持除了 dart 之外的文件,查看 issue.

  • addDartFileEdit
  • addYamlFileEdit
  • addGenericFileEdit

常用方法: 删除,插入,替换。

  • addDeletion
  • addInsertion
  • addSimpleInsertion
  • addReplacement
  • addSimpleReplacement

截屏2022-10-28 16.40.30.png

例子完整代码

实现一个限制 Class 名字前缀的 lint,还是比较简单的,不超过 200 行。

import 'dart:isolate';
import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer/dart/analysis/results.dart';
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/dart/ast/visitor.dart';
import 'package:analyzer/file_system/physical_file_system.dart';
import 'package:analyzer/source/line_info.dart';
import 'package:analyzer/source/source_range.dart';
import 'package:analyzer_plugin/plugin/plugin.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:analyzer_plugin/protocol/protocol_generated.dart';
import 'package:analyzer_plugin/starter.dart';
import 'package:analyzer_plugin/utilities/change_builder/change_builder_core.dart';
import 'package:analyzer_plugin/utilities/change_builder/change_builder_dart.dart';

void main(List<String> args, SendPort sendPort) {
  ServerPluginStarter(
    CustomLintPlugin(),
  ).start(sendPort);
}

class CustomLintPlugin extends ServerPlugin {
  CustomLintPlugin()
      : super(resourceProvider: PhysicalResourceProvider.INSTANCE);
  @override
  List<String> get fileGlobsToAnalyze => <String>['**/*.dart'];

  @override
  String get name => 'custom_lint';

  @override
  String get version => '1.0.0';

  final Map<String, List<AnalysisError>> _errorMap =
      <String, List<AnalysisError>>{};
  @override
  Future<void> analyzeFile({
    required AnalysisContext analysisContext,
    required String path,
  }) async {
    if (!path.endsWith('.dart')) {
      return;
    }

    try {
      final ResolvedUnitResult result = await getResolvedUnitResult(path);

      final Set<ClassDeclaration> classes = <ClassDeclaration>{};
      result.unit.visitChildren(MyAstVisitor(classes));
      final LineInfo lineInfo = result.lineInfo;
      final List<AnalysisError> errors = <AnalysisError>[];
      for (final ClassDeclaration classDeclaration in classes) {
        final String name = classDeclaration.name2.toString();
        final int startIndex = _getClassNameStartIndex(name);
        if (!name.substring(startIndex).startsWith('Candies')) {
          final CharacterLocation startLocation =
              lineInfo.getLocation(classDeclaration.name2.offset);
          final CharacterLocation endLocation =
              lineInfo.getLocation(classDeclaration.name2.end);
          final AnalysisError error = AnalysisError(
            AnalysisErrorSeverity.WARNING,
            AnalysisErrorType.LINT,
            Location(
              path,
              classDeclaration.name2.offset,
              classDeclaration.name2.length,
              startLocation.lineNumber,
              startLocation.columnNumber,
              endLine: endLocation.lineNumber,
              endColumn: endLocation.columnNumber,
            ),
            'Define a class name start with Candies',
            'perfer_candies_class_prefix',
          );

          errors.add(error);
        }
      }

      _errorMap[path] = errors;
      channel.sendNotification(
        AnalysisErrorsParams(path, errors).toNotification(),
      );
    } on Exception catch (e, stackTrace) {
      channel.sendNotification(
        PluginErrorParams(
          false,
          e.toString(),
          stackTrace.toString(),
        ).toNotification(),
      );
    }
  }

  int _getClassNameStartIndex(String nameString) {
    int index = 0;
    while (nameString[index] == '_') {
      index++;
      if (index == nameString.length - 1) {
        break;
      }
    }
    return index;
  }

  @override
  Future<EditGetFixesResult> handleEditGetFixes(
      EditGetFixesParams parameters) async {
    try {
      final String file = parameters.file;
      if (_errorMap.containsKey(file)) {
        final AnalysisContext context =
            _analysisContextCollection.contextFor(file);
        final ResolvedUnitResult result = await getResolvedUnitResult(file);
        final List<AnalysisError> errors = _errorMap[file]!.toList();
        final List<AnalysisErrorFixes> sourceChanges = <AnalysisErrorFixes>[];
        for (final AnalysisError error in errors) {
          final int start = error.location.offset;
          final int end = error.location.offset + error.location.length;
          if (error.code == 'perfer_candies_class_prefix' &&
              start <= parameters.offset &&
              parameters.offset <= end) {
            final ChangeBuilder changeBuilder = ChangeBuilder(
              session: context.currentSession,
            );

            final String nameString = result.content.substring(
              start,
              end,
            );

            await changeBuilder.addDartFileEdit(
              file,
              (DartFileEditBuilder dartFileEditBuilder) {
                final int startIndex = _getClassNameStartIndex(nameString);

                final RegExp regExp = RegExp(nameString);

                final String replace =
                    '${nameString.substring(0, startIndex)}Candies${nameString.substring(startIndex)}';

                for (final Match match in regExp.allMatches(result.content)) {
                  dartFileEditBuilder.addSimpleReplacement(
                    SourceRange(match.start, match.end - match.start),
                    replace,
                  );
                }
                //format
                dartFileEditBuilder.format(SourceRange(0, result.unit.length));
              },
            );

            final SourceChange sourceChange = changeBuilder.sourceChange;
            sourceChange.message = 'Use Candies as a class prefix.';
            // 同一个错误可能有多个修复
            // priority 按照从大到小排序
            sourceChanges.add(AnalysisErrorFixes(
              error,
              fixes: <PrioritizedSourceChange>[
                PrioritizedSourceChange(
                  0,
                  sourceChange,
                )
              ],
            ));
          }
        }
        return EditGetFixesResult(sourceChanges);
      }
    } on Exception catch (e, stackTrace) {
      channel.sendNotification(
        PluginErrorParams(false, e.toString(), stackTrace.toString())
            .toNotification(),
      );
    }
    return EditGetFixesResult(const <AnalysisErrorFixes>[]);
  }

  late AnalysisContextCollection _analysisContextCollection;
  @override
  Future<void> afterNewContextCollection({
    required AnalysisContextCollection contextCollection,
  }) async {
    _analysisContextCollection = contextCollection;
    return super
        .afterNewContextCollection(contextCollection: contextCollection);
  }
}

class MyAstVisitor extends GeneralizingAstVisitor<void> {
  MyAstVisitor(this.classes);
  final Set<ClassDeclaration> classes;
  @override
  void visitClassDeclaration(ClassDeclaration node) {
    classes.add(node);
    super.visitClassDeclaration(node);
  }
}

吐槽一下

面向源码开发

说实话,官方的文档,真的该更新下了,反正我看了好几遍,依然是很懵逼的。但是没关系,只要有源码,你就找到办法。

dart-lang/sdk: The Dart SDK, including the VM, dart2js, core libraries, and more. (github.com)

比如说,我们上面的代码还差 ignore ,那就是 ignore_for_this_fileignore_for_this_line. 我们需要新增关于 ignore2 个快速修复。以及我们对 ignore 的判断。

那么我们可以在源码里面搜索 class ignore ,你会发现 IgnoreInfo, 在源码中可以发现,官方是通过用 ignore:ignore_for_file: 对文件内容做正则,获取到哪些错误包含了 ignore ,这些 ignore 的位置。很容易猜到,以下规则:

  • ignore_for_file: 后面包含的错误 code ,不用再解析。
  • 如果某一行符合你定义的 lint,那么查看它的上一行是否包含 ignore:,并且包含这个 lintcode

对于快速修复,也很简单。

  • 如果文件本身没有 ignore_for_file:,那么直接在首行插入 ignore_for_file: code ;如果本身就有,那么找到位置,在它之后添加上该 lintcode
  • 如果该错误上一行没有 ignore:,那么直接在上一行插入 ignore: code ;如果本身就有,那么找到位置,在它之后添加上该 lintcode

很多代码都可以从官方的代码里面扣出来(baipiao),如果有文档那就更好了。

调试

sdk/debugging.md at master · dart-lang/sdk (github.com)

官方提供的调试方式,怎么说了,我想要的是这些?对于我来说,肯定是想要调试代码,查看当时的情况嘛。最后呢,我只能找到个折中的方式,后面细讲。

LineInfo

这个东西把我坑惨了,在做 ignore 的快速修复的时候,大家要注意它返回的行数和列数是从 1 开始的,但是它的 getOffsetOfLine 方法却是从 0 开始的。所以说,在这个体系下,如果你用 LineInfo 拿到的行列数,去使用 getOffsetOfLine 返回这一行的第一个字符的时候,记得减去 1

另外,getOffsetOfLine 方法返回的第一个字符,是包括空格的,就是说其实不是你想要的,你眼睛能看到第一个字符。所以你得向后找,第一个 trim 不等于空的,才是你想要的。

实验性

按照官方的说法,尽管我吐槽这么多,但是确实是能用。对于一个团队,能自定义一些 lint 来约束,还是蛮好的。

Note:  The plugin support is not currently available for general use.

because the plugin support is experimental

the documentation is preliminary

写到这里都几千字了,那么有没有那种简单快速就能自定义 lint 的方法呢?答案是肯定。

CandiesAnalyzerPlugin

candies_analyzer_plugin | Dart Package (flutter-io.cn) 是一个帮助快速创建自定义 lint 的插件。你可以直接通过命令就可以创建出一个插件的模板,并且写很少的代码就能自定义一个 lint

模版创建

  1. 激活插件

    执行命令 dart pub global activate candies_analyzer_plugin

  2. 到你的项目的根目录

    假设:

    你的项目叫做 example

    你想创建的插件叫做 custom_lint

    执行命令 candies_analyzer_plugin custom_lint, 一个简单插件模板创建成功.

  3. custom_lint 增加到 根目录 pubspec.yamldev_dependencies

dev_dependencies:
  # zmtzawqlp  
  custom_lint:
    path: custom_lint/
  1. custom_lint 增加到根目录 analysis_options.yamlanalyzer plugins tag 下面
analyzer:
  # zmtzawqlp  
  plugins:
    custom_lint

当分析结束的时候,在你的 ide 中就可以看到一些自定义的 lint

截屏2022-10-28 18.13.06.png

后面的步骤中的代码,都在创建的模版当中,你可以通过它,学习怎么自定义 lint

增添你的 lint

在下面的项目结构下面找到 plugin.dart

├─ example
│  ├─ custom_lint
│  │  └─ tools
│  │     └─ analyzer_plugin
│  │        ├─ bin
│  │        │  └─ plugin.dart

plugin.dart 是整个插件的入口。

启动插件

我们将在 main 方法中启动我们的插件.

CandiesLintsPlugin get plugin => CustomLintPlugin();

// This file must be 'plugin.dart'
void main(List<String> args, SendPort sendPort) {
  CandiesLintsStarter.start(
    args,
    sendPort,
    plugin: plugin,
  );
}

class CustomLintPlugin extends CandiesLintsPlugin {
  @override
  String get name => 'custom_lint';

  @override
  List<String> get fileGlobsToAnalyze => const <String>[
        '**/*.dart',
        '**/*.yaml',
        '**/*.json',
      ];

  @override
  List<DartLint> get dartLints => <DartLint>[
        // add your dart lint here
        PerferCandiesClassPrefix(),
        ...super.dartLints,
      ];

  @override
  List<YamlLint> get yamlLints => <YamlLint>[RemoveDependency(package: 'path')];

  @override
  List<GenericLint> get genericLints => <GenericLint>[RemoveDuplicateValue()];
}

创建一个 lint

在模板代码中,展示了如何创建一个关于 dartyaml其他文件lint 例子。

你只需要创一个新的类来继承 DartLint ,YamlLint, GenericLint 即可。

属性:

属性描述默认
code这个错误的名字,唯一.必填
message描述这个错误的信息必填
url这个错误文档的链接.
type在IDE中错误的类型.
CHECKED_MODE_COMPILE_TIME_ERROR
COMPILE_TIME_ERROR
HINT
LINT
STATIC_TYPE_WARNING
STATIC_WARNING
SYNTACTIC_ERROR
TODO
默认为 LINT.
severity这个错误的严肃性(一般我们修改的是这个).
INFO
WARNING
ERROR
默认为 INFO.
correction修复这个错误的一些描述.
contextMessages额外的信息帮助修复这个错误。

重要的方法:

方法描述重载
matchLint判断是否是你定义的lint必须
getDartFixes/getYamlFixes/getGenericFixes返回快速修复getYamlFixes/getGenericFixes 没有效果,保留它以备 dart team 未来某天支持, 查看 issue
dart lint

下面是一个 dart lint 的例子:

class PerferCandiesClassPrefix extends DartLint {
  @override
  String get code => 'perfer_candies_class_prefix';

  @override
  String? get url => 'https://github.com/fluttercandies/candies_analyzer_plugin';

  @override
  SyntacticEntity? matchLint(AstNode node) {
    if (node is ClassDeclaration) {
      final String name = node.name2.toString();
      final int startIndex = _getClassNameStartIndex(name);
      if (!name.substring(startIndex).startsWith('Candies')) {
        return node.name2;
      }
    }
    return null;
  }

  @override
  String get message => 'Define a class name start with Candies';

  @override
  Future<List<SourceChange>> getDartFixes(
    ResolvedUnitResult resolvedUnitResult,
    AstNode astNode,
  ) async {
    // get name node
    final Token nameNode = (astNode as ClassDeclaration).name2;
    final String nameString = nameNode.toString();
    return <SourceChange>[
      await getDartFix(
        resolvedUnitResult: resolvedUnitResult,
        message: 'Use Candies as a class prefix.',
        buildDartFileEdit: (DartFileEditBuilder dartFileEditBuilder) {
          final int startIndex = _getClassNameStartIndex(nameString);

          final RegExp regExp = RegExp(nameString);

          final String replace =
              '${nameString.substring(0, startIndex)}Candies${nameString.substring(startIndex)}';

          for (final Match match
              in regExp.allMatches(resolvedUnitResult.content)) {
            dartFileEditBuilder.addSimpleReplacement(
                SourceRange(match.start, match.end - match.start), replace);
          }

          dartFileEditBuilder.formatAll(resolvedUnitResult.unit);
        },
      )
    ];
  }

  int _getClassNameStartIndex(String nameString) {
    int index = 0;
    while (nameString[index] == '_') {
      index++;
      if (index == nameString.length - 1) {
        break;
      }
    }
    return index;
  }
}
yaml lint

下面是一个 yaml lint 的例子:

class RemoveDependency extends YamlLint {
  RemoveDependency({required this.package});
  final String package;
  @override
  String get code => 'remove_${package}_dependency';

  @override
  String get message => 'don\'t use $package!';

  @override
  String? get correction => 'Remove $package dependency';

  @override
  AnalysisErrorSeverity get severity => AnalysisErrorSeverity.WARNING;

  @override
  Iterable<SourceRange> matchLint(
    YamlNode root,
    String content,
    LineInfo lineInfo,
  ) sync* {
    if (root is YamlMap && root.containsKey(PubspecField.DEPENDENCIES_FIELD)) {
      final YamlNode dependencies =
          root.nodes[PubspecField.DEPENDENCIES_FIELD]!;
      if (dependencies is YamlMap && dependencies.containsKey(package)) {
        final YamlNode get = dependencies.nodes[package]!;
        int start = dependencies.span.start.offset;
        final int end = get.span.start.offset;
        final int index = content.substring(start, end).indexOf('$package: ');
        start += index;
        yield SourceRange(start, get.span.end.offset - start);
      }
    }
  }
}
generic lint

下面是一个 generic lint 的例子:

class RemoveDuplicateValue extends GenericLint {
  @override
  String get code => 'remove_duplicate_value';

  @override
  Iterable<SourceRange> matchLint(
    String content,
    String file,
    LineInfo lineInfo,
  ) sync* {
    if (isFileType(file: file, type: '.json')) {
      final Map<dynamic, dynamic> map =
          jsonDecode(content) as Map<dynamic, dynamic>;

      final Map<dynamic, dynamic> duplicate = <dynamic, dynamic>{};
      final Map<dynamic, dynamic> checkDuplicate = <dynamic, dynamic>{};
      for (final dynamic key in map.keys) {
        final dynamic value = map[key];
        if (checkDuplicate.containsKey(value)) {
          duplicate[key] = value;
          duplicate[checkDuplicate[value]] = value;
        }
        checkDuplicate[value] = key;
      }

      if (duplicate.isNotEmpty) {
        for (final dynamic key in duplicate.keys) {
          final int start = content.indexOf('"$key"');
          final dynamic value = duplicate[key];
          final int end = content.indexOf(
                '"$value"',
                start,
              ) +
              value.toString().length +
              1;

          final int lineNumber = lineInfo.getLocation(end).lineNumber;

          bool hasComma = false;
          int commaIndex = end;
          int commaLineNumber = lineInfo.getLocation(commaIndex).lineNumber;

          while (!hasComma && commaLineNumber == lineNumber) {
            commaIndex++;
            final String char = content[commaIndex];
            hasComma = char == ',';
            commaLineNumber = lineInfo.getLocation(commaIndex).lineNumber;
          }

          yield SourceRange(start, (hasComma ? commaIndex : end) + 1 - start);
        }
      }
    }
  }

  @override
  String get message => 'remove duplicate value';
}

调试

调试错误

在模板项目的结构下面找到 debug.dart,已经自动为你创建了 debug 的例子。你可以通过调试来编写符合你条件的 lint

├─ example
│  ├─ custom_lint
│  │  └─ tools
│  │     └─ analyzer_plugin
│  │        ├─ bin
│  │        │  └─ debug.dart

root 修改为你想要调试的项目路径, 默认为 example 的根目录

import 'dart:io';
import 'package:analyzer/dart/analysis/analysis_context.dart';
import 'package:analyzer/dart/analysis/analysis_context_collection.dart';
import 'package:analyzer_plugin/protocol/protocol_common.dart';
import 'package:analyzer_plugin/protocol/protocol_generated.dart';
import 'package:candies_analyzer_plugin/candies_analyzer_plugin.dart';
import 'plugin.dart';

Future<void> main(List<String> args) async {
  final String root = Directory.current.parent.parent.parent.path;
  final AnalysisContextCollection collection =
      AnalysisContextCollection(includedPaths: <String>[root]);

  final CandiesLintsPlugin myPlugin = plugin;
  for (final AnalysisContext context in collection.contexts) {
    for (final String file in context.contextRoot.analyzedFiles()) {
      if (!myPlugin.shouldAnalyzeFile(file, context)) {
        continue;
      }

      final bool isAnalyzed = context.contextRoot.isAnalyzed(file);
      if (!isAnalyzed) {
        continue;
      }

      final List<AnalysisError> errors =
          (await myPlugin.getAnalysisErrorsForDebug(
        file,
        context,
      ))
              .toList();
      for (final AnalysisError error in errors) {
        final List<AnalysisErrorFixes> fixes = await myPlugin
            .getAnalysisErrorFixesForDebug(
                EditGetFixesParams(file, error.location.offset), context)
            .toList();
        print(fixes.length);
      }

      print(errors.length);
    }
  }
}

更新代码

├─ example
│  ├─ custom_lint
│  │  └─ tools
│  │     └─ analyzer_plugin

你有2种方式更新代码。

  1. 删除 .plugin_manager 文件夹

注意, analyzer_plugin 文件夹下面的东西会复制到 .plugin_manager 下面,根据插件的路径加密生成对应的文件夹。

macos: /Users/user_name/.dartServer/.plugin_manager/

windows: C:\Users\user_name\AppData\Local\.dartServer\.plugin_manager\

如果你的代码改变了, 请删除掉 .plugin_manager 下面的文件

或者通过执行 candies_analyzer_plugin clear_cache 来删除 .plugin_manager 下面的文件.

  1. 把新的代码写到 custom_lint 下面

你可以把新代码写到 custom_lint 下面, 比如在 custom_lint.dart.

├─ example
│  ├─ custom_lint
│  │  ├─ lib
│  │  │  └─ custom_lint.dart

如果这样的话,你必须增加 custom_lint 引用到 analyzer_plugin\pubspec.yaml 当中

你必须使用 绝对路径,因为 analyzer_plugin 文件夹是会被复制到 .plugin_manager 下面的.

如果你不是要发布一个新的 package 的话,我不建议你使用第2种方式。

├─ example
│  ├─ custom_lint
│  │  ├─ lib
│  │  │  └─ custom_lint.dart
│  │  └─ tools
│  │     └─ analyzer_plugin
│  │        ├─ analysis_options.yaml
dependencies:
  custom_lint: 
    # absolute path  
    path: xxx/xxx/custom_lint
  candies_analyzer_plugin: any
  path: any
  analyzer: any
  analyzer_plugin: any

重启 dart analysis 服务

更新完毕代码之后,你可以通过在 vscode 中,通过下面的方式重启服务。

  1. View 下面找到 Command Palette

command_palette.png

  1. 输入 Restart Analysis Server

analysis_command.png

分析结束之后,你可以看到最新的结果.

Log

在被分析的项目根目录会生成 custom_lint.log,用于查看分析过程的信息。

  1. 为了性能,默认是关闭的,你可以打开.

    CandiesAnalyzerPluginLogger().shouldLog = true;

  2. 你可以更改日志的名字

    CandiesAnalyzerPluginLogger().logFileName = 'your name';

  3. 记录信息

   CandiesAnalyzerPluginLogger().log(
        'info',
        // which location custom_lint.log will be generated
        root: result.root,
      );
  1. 记录错误
   CandiesAnalyzerPluginLogger().logError(
     'analyze file failed:',
     root: analysisContext.root,
     error: e,
     stackTrace: stackTrace,
   );

配置

禁止一个 lint

编写的自定义 lints 默认是全部开启的。当然你可以通过在 analysis_options.yaml 增加配置来禁用它。

  1. 使用 ignore tag 来禁用.
analyzer:
  errors:
    perfer_candies_class_prefix: ignore
  1. 使用 exclude 来过滤掉不想分析的文件
analyzer:
  exclude:
    - lib/exclude/*.dart
  1. 通过将某个lint 设置为 false
linter:
  rules:
    # disable a lint
    perfer_candies_class_prefix: false 

包含文件

我们可以通过在 custom_lint(你定义的插件名字) 下面的 include 标记下面增加包含的文件。 如果我们做了这个设置,那么我们就只会分析这些文件。


# your plugin name
custom_lint:
  # if we define this, we only analyze include files
  include: 
    - lib/include/*.dart

自定义 lint 严肃性

你可以设置某个 lint 的严肃性。

比如 perfer_candies_class_prefix 把它的严肃性从 info 改为 warning.

支持 warning , info , error.

analyzer:
  errors:
    # override error severity
    perfer_candies_class_prefix: warning

Default lints

PerferClassPrefix

全部的类已某个前缀开始,这在一个团队指定类名规则的时候是非常有用的。

class PerferClassPrefix extends DartLint {
  PerferClassPrefix(this.prefix);

  final String prefix;

  @override
  String get code => 'perfer_${prefix}_class_prefix';
}

PreferAssetConst

asset 资源使用不要直接写字符串,而应该使用定义好的 constassets_generator | Dart Package (flutter-io.cn),摊牌不装了,就是打广告。

class PreferAssetConst extends DartLint {
  @override
  String get code => 'prefer_asset_const';
  @override
  String? get url => 'https://pub.flutter-io.cn/packages/assets_generator';      
}

PreferNamedRoutes

推荐使用命名路由,ff_annotation_route | Dart Package (flutter-io.cn),摊牌不装了,就是打广告。

class PreferNamedRoutes extends DartLint {
  @override
  String get code => 'prefer_named_routes';
  @override
  String? get url => 'https://pub.flutter-io.cn/packages/ff_annotation_route';     
}

PerferSafeSetState

在使用 setState 之前请先检查 mounted,特别是有童鞋在异步操作中调用 setState 的时候,记得检查 mounted

class PerferSafeSetState extends DartLint {
  @override
  String get code => 'prefer_safe_setState';
}

MustCallSuperDispose

此方法的实现应以调用继承的方法结束

class MustCallSuperDispose extends DartLint with CallSuperDisposeMixin {
  @override
  String get code => 'must_call_super_dispose';
}

EndCallSuperDispose

应该在方法的最后才调用 super.dispose()

class EndCallSuperDispose extends DartLint with CallSuperDisposeMixin {
  @override
  String get code => 'end_call_super_dispose';
}

注意事项

print lag

不要在插件的分析代码中使用 print ,这会导致 analysis 卡顿, 不知道是不是 bug ,反正挺坑的。

怎么让插件生效

只有当你在 pubspec.yamlanalysis_options.yaml 中添加了 custom_lint,分析才会进行,才会有快速修复。

  1. custom_lint 添加到 pubspec.yaml 中的 dev_dependencies , 查看 pubspec.yaml

  2. custom_lint 添加到 analysis_options.yaml 中的 analyzer plugins ,查看 analysis_options.yaml

在 vscode 中快速修复只支持 dart 文件.(android studio支持任何文件)

fixes are not support for yaml file and custom errors are not in ResolvedUnitResult errors [analyzer_plugin] · Issue #50306 · dart-lang/sdk (github.com)

提示自动导入在 vscode 无法完成

CompletionSuggestion not support auto import from library in vscode · Issue #50449 · dart-lang/sdk (github.com)

结语

终于到结语了,总的来说,能自定义 lint,对于人多的团队,还是很有必要的。大家在实际工作中,是否也想过做一些代码上的限制呢? 分享下你的经历或者 pr 个有趣的 lint,马老师已经在群里等着跟你对线了!把 lint 等级调成 error, 让你的同事写了代码都运行不起来才是王道!不听话的,统统让大宝打一顿!

Flutter,爱糖果,欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果QQ群:181398081

最最后放上 Flutter Candies 全家桶,真香。