自定义Flutter Lint插件实现自己的Dart语法规则(静态代码检查分析)

摘要:

本文实现了一个自定义的语法检查插件,功能是:当新写一个dart类,如果类名中包含ViewModel,那么必须添加前缀HDW。在vscode中效果如下:

1.png

在网上搜索自定义Dart语法检查自定义Dart lint最终都会导向 Customizing static analysis 这篇文档。文档中介绍了Dart Static analysis的功能和使用方式。

如在if语句使用了错误的变量名称,提示如下错误。

void main() {
  var count = 0;
  if (counts < 10) {
    count++;
  }
  print(count);
}

提示
error • Undefined name 'counts'. • lib/main.dart:3:7 • undefined_identifier

但是文章标题中所谓Customizing(自定义)指的是自定义修改配置检查规则、设置工程中文件的检查匹配范围、调整某些规则的检查级别(由warning提升到error)等。具体方式是,首先在工程目录下添加analysis_options.yaml文件:

include: package:pedantic/analysis_options.1.8.0.yaml

analyzer:
  exclude: #忽略检测的文件配置
    - lib/client.dart
    - lib/server/*.g.dart
    - test/_data/**
  strong-mode: #设置某些规则为严格模式
    implicit-casts: false

linter:
  rules: #开启或禁用某些规则
    avoid_shadowing_type_parameters: false
    await_only_futures: true

但这种自定义不是我们想要的。我们想要的不仅仅是Dart官网为我们提供的语法检查,我们需要自己能去分析当前代码的AST(抽象语法树),进而写出一些符合自己团队内部语法约定或业务约定的自定义规则。这可行吗?可以的,而且这个功能就是Dart Static analysis提供,并且仍然可以配置在analysis_options.yaml中。但是不知为何官网没有提供相关的文档描述和教程,网上能搜到的文章也很少,并且写这种自定义规则在工程创建和调试上都会遇到很多点,所以我打算用一个自定义规则的完整示例,将整个过程呈现出来。

这个示例的需求是:当新写一个dart类,如果类名中包含ViewModel,那么必须添加前缀HDW。(不要纠结这个规则的实际意义,就当是业务命名的强约束吧😂)

//此处需要报错,并提示用户必须添加HDW前缀
class ViewModel {
  
}

Analyzer plugin简介

自定义符合自己团队内部语法约定或业务约定的规则,可以通过analyzer plugin实现。

通过analyzer plugin写的这些规则,其使用方法,检查效果,以及在VSCode或AndroidStudio中的表现形式都与Dart Static analysis提供效果完全相同。

在了解analyzer plugin是什么之前,我们先想一下,本文第一节所说的Dart Static analysis是如何工作的?为什么工程中的analysis_options.yaml文件配置会生效,并且它是如何生效的?

直观的答案是”Dart SDK提供的功能“,打开github的dart-lang项目,相关的代码在pkg下的analysis_server、analysis_cli、analysis_plugin等文件夹中。我们安装Flutter后执行Flutter doctor会下载对应版本的Dart SDK,在flutter/bin/cache/dart-sdk/bin目录下我们可以找到dart sdk提供的工具包,其中dartanalyzer是提供语法分析和检查的”服务“(这里称之为服务而不仅仅是工具)。

2.png

可以直接在命令行中执行它,对某个具体的文件进行语法分析:

3.png

dartanalyzer不仅仅只是一个命令行工具,它可以被理解为一个本地的服务器应用。我们可以指定目录参数开启一个本地dartanalyzer服务,然后通过跨进程的通信通道,将我们需要检测的文件变成一个命令发送给这个服务,服务会把检测好的结果以约定好的格式返回(具体通信格式可以参考dart-lang下analysis_server)。当我们使用VSCode(或AndroidStudio)打开Dart工程时,IDE中安装的Dart插件会自动开启对应于本工程的dartanalyzer服务。以VSCode为例,输入命令>open analyzer diagnostics 可以打开当前dartanalyzer服务对应的web信息面板:

4.png
5.png

这里需要强调的是,用vscdoe打开多个Dart项目,每个dart项目都会对应不同的服务(不同的新建的dartanalyzer进程实例)。还有,web信息面板默认是不启动的,只有显式的执行open analyzer diagnostics才会开启。这个面板中的信息在后面我们自定义检查规则中会用到。

当dartanalyzer启动时,会寻找项目目录下的analysis_options.yaml文件,以此文件中的内容作为analyzer的配置信息。比如,读取exclude字段,过滤不需要检查的文件。

细心的同学可能已经发现,在信息菜单中有Plugins选项。没错,这里就是今天的主角 analyzer plugin。此时点击菜单中Plugins选项,会发现未加载任何Plugins

6.png

那什么是analyzer plugin呢,这里简介一下:

  • 首先一个analyzer plugin是一个独立的Dart项目,任何人都可以创建自己的plugin工程,建立pubspec.yaml 给工程命名。
  • 在analyzer plugin项目中添加一些约定的东西就可以被dartanalyzer服务加载。
  • 在analyzer plugin项目中可以获得dartanalyzer服务传入的编译单元 进而获得文件代码的AST,这样就可以自己写代码进行分析处理。
  • 在analyzer plugin项目中可以使用dart analyzer sdk提供的API,将自己分析的结果,以约定的格式回传给dartanalyzer服务。dartanalyzer服务根据回传内容可以提示用户error 、warning、在IDE(如vscode)中提示用户如何修改,提示用户优化建议等等。

自定义plugin 示例

github上dart-lang中有很详细的readme但是其中target packagehost packagebootstrap packageplugin package以及对默认加载潜规则的描述让人很难一次性成功写出一个Demo。这里建议先看我的示例步骤,运行起来后,再回头阅读readme,可以事半功倍。

第一步,建立名为test_plugin的工程

name: test_plugin

version: 0.0.1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  analyzer_plugin: ^0.3.0
  quick_log: any
  path: any

dev_dependencies:
  test: any
7.png

第二步,建立启动入口

在当前test_plugin工程目录下建立两个文件,(注意这里文件路径和文件名均是默认的潜规则,不能自定义改变)

  • ./tools/analyzer_plugin/pubspec.yaml

    name: test_plugin_bootstrap
    version: 0.0.1
    
    environment:
      sdk: '>=2.7.0 <3.0.0'
    
    dependencies:
      test_plugin:
        path: /Users/david/Desktop/test_plugin
        # 此处必须是绝对路径,下文会解释为什么
    
  • ./tools/analyzer_plugin/bin/plugin.dart

    import 'dart:isolate';
    
    void main(List<String> args, SendPort sendPort) {
      print("start");
    }
    

好了,目前我们已经有了一个最最简单的plugin,下面我们建立一个test_project,让test_project加载这个plugin。

8.png
name: test

version: 0.0.1

environment:
  sdk: ">=2.7.0 <3.0.0"

dependencies:
  pedantic: ^1.9.2

dev_dependencies:
  test_plugin:
    path: ../test_plugin/


9.png

执行pub upgrade并不能加载这个插件到dartanalyzer服务中,还需要配置一下analysis_options.yaml

include: package:pedantic/analysis_options.yaml

analyzer:
  plugins:
    - test_plugin
10.png

在analysis_options.yaml的plugins中添加test_plugin后,一系列的神奇反应发生了:

  • 首先每次analysis_options.yaml发生改变,VScode 都会通知到dartanalyzer服务
  • dartanalyzer服务发现当前需要添加一个名字叫test_plugin的插件
  • 去哪里找这个test_plugin插件呢?没错,就是到当前工程的pubspec.lock中查看地址
  • 找到test_plugin所在目录后,会检查当前目录有没有./tools/analyzer_plugin/pubspec.yaml./tools/analyzer_plugin/bin/plugin.dart这两个文件(这就是为什么第一步中这两个文件的目录地址和文件名都不能随意更改的原因)。确定文件存在后会将analyzer_plugin目录copy到dartServer下的缓存区中,(注意是直接复制过去,之后运行的代码也是copy后的代码,这就是为什么后面每次修改plugin.dart中的内容要重启dartanalyzer服务的原因,并且也是为什么上面path中必须是绝对路径的原因)
  • dartanalyzer服务此时会启动当前analyzer_plugin/bin/plugin.dart中的main方法,将插件加载起来

这时我们重新打开web信息面板:

11.png

可以看到,test_plugin已经被成功加载到test_project启动的dartanalyzer服务中了(PS:可以稍稍思考一下这句话三个单词对应项目的关系)。但是最后一行显示 not running for unknown reason,这是因为我们在main函数中啥也没写:

void main(List<String> args, SendPort sendPort) {
  print("start");
}

这里的sendPort对应就是当前dartanalyzer服务,由此开始,我们就可以写具体的逻辑了。我们当然可以将代码都写在main函数所在的文件,但是由于analyzer_plugin下的内容是直接copy到缓存中的。将所有逻辑代码写在test_plugin的lib下是个更好的选择。在lib下添加三个文件:

12.png

start.dart最简单,就是提供一个全局方法让main函数调用

13.png

具体看下:

starter.dart


void start(List<String> args, SendPort sendPort) {
  mirrorLog.info('-----------restarted-------------');
  ServerPluginStarter(MirrorPlugin(PhysicalResourceProvider.INSTANCE))
      .start(sendPort);
}

其中MirrorPlugin是继承ServerPlugin自定义的插件类,通过ServerPluginStarter将其加载。具体代码在mirror_plugin.dart中:


class MirrorPlugin extends ServerPlugin {

  @override
  void contentChanged(String path) {
    // 每次在vscode中修改文件都会触发
    mirrorLog.info("contentChanged$path");
    AnalysisDriverGeneric driver = super.driverForPath(path);
    driver.addFile(path);
  }

  @override
  AnalysisDriverGeneric createAnalysisDriver(plugin.ContextRoot contextRoot) {
    // 插件加载时调用
    final dartDriver = contextBuilder.buildDriver(analysisRoot);
    runZonedGuarded(() {
      // 创建一个监听服务,没有文件改动或新建文件,都会触发listen
      dartDriver.results.listen((analysisResult) {
        _processResult(dartDriver, analysisResult);
      });
    }, (e, stackTrace) {
      channel.sendNotification(
          plugin.PluginErrorParams(false, e.toString(), stackTrace.toString())
              .toNotification());
    });
    return dartDriver;
  }

  void _processResult(
      AnalysisDriver driver, ResolvedUnitResult analysisResult) {
    // 具体处理每个编译单元的语法分析,注意这里是Resolved Unit,也就是说我们可以从AST中直接获取对应的element。(PS:这句注释理解起来比较费力的话,可以先看下官网关于Dart语法树相关的文档)
    try {
      if (analysisResult.unit != null &&
          analysisResult.libraryElement != null) {
        // 将编译单元丢给自定义的MirrorChecker处理,下面会分析MirrorChecker
        final mirrorChecker = MirrorChecker(analysisResult.unit);
        // 获取分析后的结果
        final issues = mirrorChecker.enumToStringErrors();
        mirrorLog.info("MirrorCheckerissues: $issues");
        if (issues.isNotEmpty) {
          channel.sendNotification(
            // 将结果发回给dartanalyzer服务,vscode会自动显示在编辑器中
            plugin.AnalysisErrorsParams(
              analysisResult.path,
              issues
                  .map((issue) => analysisErrorFor(
                      analysisResult.path, issue, analysisResult.unit))
                  .toList(),
            ).toNotification(),
          );
        } else {
          // 返回空结果
          channel.sendNotification(
              plugin.AnalysisErrorsParams(analysisResult.path, [])
                  .toNotification());
        }
      } else {
        // 返回空结果
        channel.sendNotification(
            plugin.AnalysisErrorsParams(analysisResult.path, [])
                .toNotification());
      }
    } on Exception catch (e, stackTrace) {
       // 返回空结果
      channel.sendNotification(
          plugin.PluginErrorParams(false, e.toString(), stackTrace.toString())
              .toNotification());
    }
  }
}

最后也是最关键的文件,mirror_visitor.dart


class MirrorChecker {
  Iterable<MirrorCheckerIssue> enumToStringErrors() {
    final visitor = _MirrorVisitor();
    visitor.unitPath = unitPath;
    _compilationUnit.accept(visitor);
    return visitor.issues;
  }
}
// 创建一个集成与RecursiveAstVisitor的语法树Visitor
class _MirrorVisitor extends RecursiveAstVisitor<void> {

  @override
  void visitClassDeclaration(ClassDeclaration node) {
    // 在本示例中只需要检查ClassDeclaration语法节点即可
    node.visitChildren(this);
    if (node.declaredElement.displayName.contains('ViewModle') &&
        !node.declaredElement.displayName.startsWith('HDW')) {、
      // 判断当前类是ViewModle 但是没有添加HDW业务前缀时,添加一个错误报告
      _issues.add(
        MirrorCheckerIssue(
          plugin.AnalysisErrorSeverity.ERROR,
          plugin.AnalysisErrorType.LINT,
          node.offset,
          node.length,
          '您的模型类未添加HDW前缀',
          '可以改为HDW${node.declaredElement.displayName}',
        ),
      );
    }
  }
}

好了,到目前为止,所有的关键代码都写好了。我们回到test_project工程, 执行>restart analysis server 重启当前工程的dartanalyzer服务。

14.png

检查一下信息面板, 成功运行结果如下(有些时候缓存不会更新的,自己吧.plugin_manager/xxxxx/ 下面的缓存删除就行):

15.png

在vs中的效果:

16.png

是不是很酷😎

有没感觉哪里不对劲,是的,这么多代码不可能一次性写出的。我们一般都是写一点调试一点。那么这个plugin可以调试运行吗?很遗憾!不能完整的调试运行!

但没关系!

如果你要自己开发plugin的话,首先除了MirrorVisitor以外,其他部分的代码都直接使用本示例demo中的代码即可。主要自定义的逻辑都在MirrorVisitor中。我写了一个测试用例,点击即可断点调试你自己的MirrorVisitor了

17.png

关键代码写好后,我们总归是要在工程中实际检验的,这时怎么办?使用mirrorLog.info("xxxxxx");

这其实是个不是办法的办法,其实就是写入文件

  • 到logger/log.dart的最后一行 把注释调整一下,(额 打开就知道我再说什么了)

  • 重启dartanalyzer服务,这时桌面上就有了一个output.log文件

  • 在自己感觉可能出问题代码附近 使用 mirrorLog.info("xxxxxx");写入一些日志

  • 在整个插件运行过程中output.log的内容会持续添加,可以在终端执行 tail -f ~/Desktop/output.log 实时查看日志。

写好的test_plugin可以发布到pubsepc上,这样组内就可以共同使用同样的自定义规则了。

文中所有代码都上传到这里了,喜欢就给个赞吧!

参考文档

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,948评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,371评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,490评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,521评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,627评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,842评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,997评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,741评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,203评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,534评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,673评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,339评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,955评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,770评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,000评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,394评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,562评论 2 349

推荐阅读更多精彩内容