前言

年初的时候实现了一个编辑器代码诊断的功能,并写一篇文章 [工具]实现一个代码诊断的 VSCode 插件 ,里面简单讲述了整体的实现思路。但最近有小伙伴问我,有没有具体的细节和源码?听到这句话之后,我意识到在那篇文章分享我又陷入了自 high,实际上没人看得太懂的,因此我希望通过这篇文章,来详细讲清实现细节,并附上了源码(点击这里)。

实现 VSCode 代码诊断和快速修复(二)
本文将从 0 开始,实现一个 VSCode 的代码诊断修复功能。

1. 起步

首先,我们需要站在巨人的肩膀上完成项目开始工作,找到官方示例,拷贝代码出来。

git clone https://github.com/microsoft/vscode-extension-samples.git

找到 lsp-sample 文件夹,我们现把 lsp-sample 运行起来,然后接着在这个例子中进行修改。

用 VSCode 打开这个项目,执行 npm install, 按下 F5 进如插件调试,它会进行编译,并打开一个新的窗口,在这个新窗口我们可以调试插件的功能。如果这一步失败了,有可能是你的 VSCode 版本太旧了,升级一下。

然后我们在调试窗口新建一个纯文本文件(plaintext),输入 AAA and BBB,会出现下图一样的界面:

实现 VSCode 代码诊断和快速修复(二)

这是 lsp-samle 实现的例子,就是连续输入大写字母,就会提示错误信息。那第一步就成功了。

2. 调试

在正式开始写功能之前,我们需要先学会调试,这里需要使用断点调试,打上断点后,首先启动语言服务器,选择 Attach to Server

实现 VSCode 代码诊断和快速修复(二)
启动完成后,我们需要重新 reload 调试窗口,command + R 刷新窗口,或者先停止 Launch Client 再启动。完了之后我们在调试窗口的文件输入的时候就会进入断点了。比如我删除了一个 B 字,如下图所示:

实现 VSCode 代码诊断和快速修复(二)
如果你没有成功,可能没有重启调试窗口,或者设置断点后没有重启 Attach to Server。当你完成这一步之后,可以正式开始写功能了。

3.核心代码

  • server.ts 是语言服务器
  • codeDiagnostics.ts 是用于收集诊断对象
  • codeActionProvider 是代码修复功能
  • ast-analysis 目录下是用于分析代码语法的

实现 VSCode 代码诊断和快速修复(二)

在 server.ts 中找到 validateTextDocument 函数,把里面的诊断逻辑去掉,我们根据需求自己实现代码诊断,用一个函数把后续的所有诊断导出。

async function validateTextDocument(textDocument: TextDocument): Promise<void> {
    const settings = await getDocumentSettings(textDocument.uri);
    // getDiagnostics 作为入口,后续所有诊断对象将从这里导出
    const diagnostics = getDiagnostics(textDocument, settings);
    // 通过连接,把诊断发送给客户端
    connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}

对代码解析,最好的方式就是使用 AST 语法树,以 Vue 为例,可以通过 @vue/compiler-dom 解析 Vue 代码,得到 template、script、style 的语法树,建立一个 ast-analysis 文件夹,用来分析 template、script、style 语法并返回诊断结果给入口函数。

import { Diagnostic, DiagnosticSeverity } from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
import analyzeScript from './ast-analysis/js';
import analyzeTemplate from './ast-analysis/vue-html';
import analyzeStyle from './ast-analysis/style';
import VueParser = require('@vue/compiler-dom');
export function getDiagnostics(textDocument: TextDocument, settings: any): Diagnostic[] {
    const text = textDocument.getText();
    const res = VueParser.parse(text);
    const [template, script, style] = res.children;
    return [
        ...analyzeTemplate(template),
        ...analyzeScript(script, textDocument),
        ...analyzeStyle(style),
    ];
}

如果是 react 的项目可以用 @babel/traverse 解析,可以在搜索引擎查一下,或者前往 astexplorer.net/ 这个网站试一下。

针对不同语言的诊断,我们需要先声明语言服务器对哪些文件类型生效,比如 Vue 为例,需要在 client/src/extension.ts 文件下修改一下:

//client/src/extension.ts
const clientOptions: LanguageClientOptions = {
    // Register the server for plain text documents
    documentSelector: [{ scheme: 'file', language: 'vue' }],
    synchronize: {
      fileEvents: workspace.createFileSystemWatcher('**/.clientrc')
    }
};

3.1 解析 TypeScript

由于 @vue/compiler-dom 没法解析 TypeScript,因此还需要借助 @babel/parser 去解析 TypeScript 才能得到 AST 树

import parser = require("@babel/parser");
export default function analyzeScript(script: any, textDocument: TextDocument) {
  const diagnostics: Diagnostic[] = [];
  const scriptAst = parser.parse(script.children[0]?.content, {
    sourceType: 'module',
    plugins: [
      'typescript',
      ['decorators', { decoratorsBeforeExport: true }],
      'classProperties',
      'classPrivateProperties',
    ],
  });
}

3.2 遍历 JS 的 AST 语法树

得到 AST 之后,我们要遍历 AST 语法树,查看每个代码节点,对于 JS 代码,我们需要用 @babel/traverse 来遍历

import parser = require("@babel/parser");
import { Diagnostic } from "vscode-languageserver/node";
import { genDiagnostics } from "../utils/diagnostic";
import { TextDocument } from "vscode-languageserver-textdocument";
// eslint-disable-next-line @typescript-eslint/no-var-requires
const traverse = require("@babel/traverse").default;
export default function analyzeScript(script: any, textDocument: TextDocument) {
  const diagnostics: Diagnostic[] = [];
  const scriptAst = parser.parse(script.children[0]?.content, {
    sourceType: 'module',
    plugins: [
      'typescript',
      ['decorators', { decoratorsBeforeExport: true }],
      'classProperties',
      'classPrivateProperties',
    ],
  });
  const scriptStart = script.loc.start.line - 1;
  traverse(scriptAst, {
    enter(path: any) {
      const { node } = path;
      if (isLongFunction(node)) {
        const diagnostic: Diagnostic = genDiagnostics(
          '单个函数不宜超过 80 行',
          getPositionRange(node, scriptStart)
        );
        diagnostics.push(diagnostic);
      }
    },
  });
  return diagnostics;
}

在上面我们学会了如何调试,我们就可以在遍历中,打断点调试,根据节点的信息,判断这些代码是否符合要求了。

3.3 遍历 html 的 AST 语法树

由于我在做的时候,没有找到很好的工具去遍历 html 的语法树,因此便写了一个遍历的方法,使用到了深度优先搜索去实现。 html 的语法树比较有规律,可以打断点看一下它的结构,基本每个 html 元素都有 children 属性,children 便是该元素下的子元素,直到 textcontent 为止。

dfs 函数会遍历语法树的每一个节点,我们每次进入节点的时候,用一个函数 templateHandler 去分析节点。

import { Diagnostic } from "vscode-languageserver/node";
import { TEMPLATE_PROP_SORTS } from "../utils/const";
import { AstTemplateInterface } from "../utils/type";
import { genDiagnostics, getRange } from "../utils/diagnostic";
export default function analyzeTemplate(template: any) {
  const diagnostics: Diagnostic[] = [];
  deepLoopData(template.children, templateHandler, diagnostics);
  return diagnostics;
}
export function deepLoopData(
  data: AstTemplateInterface[],
  handler: any,
  diagnostics: Diagnostic[]
) {
  function dfs(data: AstTemplateInterface[]) {
    for (let i = 0; i < data.length; i++) {
      handler(data[i], diagnostics);
      if (data[i]?.children?.length) {
        dfs(data[i].children);
      } else {
        continue;
      }
    }
  }
  dfs(data);
}
// 进入每一个语法树的节点都会进入这个函数
function templateHandler(
  currData: AstTemplateInterface,
  diagnostics: Diagnostic[]
) {
  if (!currData || !currData?.props) return;
  const { props } = currData;
}

断点看看 currData,可以清楚看到每个 html node 都包含了哪些信息,利用这些信息来诊断代码

4.具体功能实现

4.1 判断 Vue template 顺序是否合规

实现 VSCode 代码诊断和快速修复(二)
首先定好正确的顺序,用数组表示:

export const TEMPLATE_PROP_SORTS = [
  'defaultValueProp', // 默认属性,比如 disabled、required
  'attribute', // 元素属性,比如 class、id、 placeholder的属性
  'is',
  'for,if,else-if,else,show,cloak',
  'pre,once',
  'ref,key,slot',
  'bind,:', // 组件的 prop
  'model',
  'on,@',
  'html,text'
];

接着拿到 props 和 节点源码,去得到是否是好的排序,以及正确排序后的结果(用于快速修复)

function attributeOrderValidator(props: any[], source: string) {
  const propsName = props.map((prop) => {
    const isAttr = TEMPLATE_PROP_SORTS.some(
      (item) => item.indexOf(prop.name) >= 0
    );
    if (!isAttr) {
      // 针对默认属性,比如 disabled、required
      if (prop.value === undefined) {
        return {
          ...prop,
          name: "defaultValueProp",
        };
      } else if (
        prop.value &&
        prop.value.type === 2 &&
        typeof prop.value.content === "string"
      ) {
        // 这种类似元素属性,比如 class、id、 placeholder的属性
        return {
          ...prop,
          name: "attribute",
        };
      }
      return {
        ...prop,
        name: "bind",
      };
    }
    // 如果是 bind 则就是组件的 prop
    if (prop.name === "bind") {
      if (["key", "ref"].includes(prop.content)) {
        prop.name = prop.content;
      }
    }
    // 其他则叫什么就返回什么
    return prop;
  });
  // 得到 props 顺序后,拷贝一份
  const data = JSON.parse(JSON.stringify(propsName));
  // getAfterSortProps 是获取正确顺序的结果,这个函数就不贴上了,可以去仓库看代码
  const sortPropsName = getAfterSortProps(data);
  const isGoodSort =
    propsName.map((prop) => prop.name).join() ===
    sortPropsName.map((prop) => prop.name).join();
  let newText = "";
  try {
    const splitMatch = source.match(/[ns]+/);
    if (!isGoodSort && splitMatch) {
      const split = splitMatch[0];
      newText = sortPropsName.map((prop) => prop.loc.source).join(split);
    }
  } catch (error) {
    console.log(error);
  }
  return {
    isGoodSort,
    newText,
  };
}

然后在遍历 html 语法树的时候去判断每个元素的 prop 是否符合规则

function templateHandler(
  currData: AstTemplateInterface,
  diagnostics: Diagnostic[]
) {
  if (!currData || !currData?.props) return;
  const { props } = currData;
  if (props.length) {
    // 检查属性的顺序
    const { isGoodSort, newText } = attributeOrderValidator(
      props,
      currData.loc.source
    );
    if (!isGoodSort) {
      ...
    }
  }
}

如果不是好的排序,需要生成一个诊断对象,一个基础的诊断对象(diagnostics)包括下面几个属性:

  1. range: 诊断有问题的范围,也就是画波浪线的地方

  2. severity: 严重性,分别有四个等级,不同等级标记的颜色不同,分别是:

    • Error: 1
    • Warning: 2
    • Information:3
    • Hint:4
  3. message: 诊断的提示信息

  4. source: 来源,比如说来源是 Eslint

  5. data:携带数据,可以将修复好的数据放在这里,用于后面的快速修复功能

首先找到 range,也就是波浪线标注的范围,怎么获取要根据情况,比如属性的顺序问题,就应该从第一个属性开始,到最后一个属性结束。那么可以得到范围:

const range = {
    // 用第 0 个元素的位置表示开始位置
    start: {
      line: props[0].loc.start.line - 1, // 开始行,行位置
      character: props[0].loc.start.column - 1, // 这个就相当于列位置
    },
    // 用最后一个元素的位置表示结束位置
    end: {
      line: props[props.length - 1].loc.end.line - 1,
      character: props[props.length - 1].loc.end.column - 1,
    },
  };

减 1 是因为 vscode 是从 0 开始算,而 ast 的第一行是 1,要对准位置的话,需要减 1。

接着把修复好的数据放入 data 中,得到完整的代码:

if (!isGoodSort) {
  const range = {
    start: {
      line: props[0].loc.start.line - 1,
      character: props[0].loc.start.column - 1,
    },
    end: {
      line: props[props.length - 1].loc.end.line - 1,
      character: props[props.length - 1].loc.end.column - 1,
    },
  };
  const diagnostic: Diagnostic = genDiagnostics(
    "vue template 上的属性顺序",
    range
  );
  if (newText) {
    diagnostic.data = {
      title: "按照 Code Review 指南的顺序修复",
      newText, // 存入快熟修复的数据
    };
  }
  diagnostics.push(diagnostic);
}

4.2 判断嵌套层数是否超过 4 层

判断嵌套语句,实际上可以通过判断 {},也就是判断代码节点是否是 BlockStatement,如果是,则代表此处代码是一个区块语句,算作一层,当超过 4 层,就返回 true

实现 VSCode 代码诊断和快速修复(二)

// 层级超过4层
function isExceedBlockStatement(node: any, statementNum: number): any {
  if (statementNum > 4) return true;
  if (!node || !(node instanceof Object)) return false;
  if (node.type === 'BlockStatement') {
    statementNum++;
    const { body } = node;
    if (Array.isArray(body)) {
      return body.some((item) => {
        return isExceedBlockStatement(item, statementNum);
      });
    }
    return false;
  } else {
    return Object.keys(node).some((key) => {
      return isExceedBlockStatement(node[key], statementNum);
    });
  }
}

遍历 JS AST 树,如果有这样的代码,需要生成诊断对象:

export default function analyzeScript(script: any, textDocument: TextDocument) {
  const diagnostics: Diagnostic[] = [];
  const scriptStart = script.loc.start.line - 1;
  const scriptAst = parser.parse(script.children[0]?.content, {
    sourceType: 'module',
    plugins: [
      'typescript',
      ['decorators', { decoratorsBeforeExport: true }],
      'classProperties',
      'classPrivateProperties',
    ],
  });
  traverse(scriptAst, {
    enter(path: any) {
      if (
        node.type === 'ClassMethod' &&
        isExceedBlockStatement(node, 0)
      ) {
        const diagnostic: Diagnostic = genDiagnostics(
          '嵌套层级不超过 4 层 (if/else、循环、回调)',
          getPositionRange(node, scriptStart)
        );
        diagnostics.push(diagnostic);
      }
    }
  }
}

获取诊断范围函数,scriptStart 表示 js 代码开始行,为什么需要 scriptStart?是因为 vue 代码 scirpt 代码通常都写着 template 结束之后,而 js 的 ast 是从 js 代码开始位置 0 开始的,因此要加上 scirpt 代码的开始位置,才能得到代码准确的位置

function getPositionRange(node: any, scriptStart: number) {
  return {
    start: {
      line: node.loc.start.line - 1 + scriptStart,
      character: node.loc.start.column,
    },
    end: {
      line: node.loc.end.line - 1 + scriptStart,
      character: node.loc.end.column,
    },
  };
}

5. 快速修复

首先为语言服务器添加 code action 的能力,可以理解为赋予代码操作的能力:

connection.onInitialize((params: InitializeParams) => {
    const capabilities = params.capabilities;
    // ... 此处省略代码
    hasDiagnosticRelatedInformationCapability = !!(
            capabilities.textDocument &&
            capabilities.textDocument.publishDiagnostics &&
            capabilities.textDocument.publishDiagnostics.relatedInformation
    );
    if (hasCodeActionLiteralsCapability) {
        result.capabilities.codeActionProvider = {
            codeActionKinds: [CodeActionKind.QuickFix], // 快速修复功能!
        };
    }
    return result;
});

接着,connect 加入代码操作功能

async function provideCodeActions(params: CodeActionParams): Promise<CodeAction[]> {
    if (!params.context.diagnostics.length) {
        return [];
    }
    const textDocument = documents.all().find((item) => params.textDocument.uri === item.uri);
    if (isNullOrUndefined(textDocument)) {
        return [];
    }
    return quickfix(textDocument, params);
}
// 这一步是关键,这样就可以进行代码修复了
connection.onCodeAction(provideCodeActions);

剩下的,实现 quickfix 函数,我们可通过 params.context.diagnostics 得到之前收集好的诊断对象,我们将修复好的数据存放在诊断对象的 data 属性上,所以我们只需要在 edit change 的时候,把修复好的代码覆盖即可

export function quickfix(
	textDocument: TextDocument,
	params: CodeActionParams
): CodeAction[] {
    const diagnostics = params.context.diagnostics;
    // ... 此处省略代码
    const codeActions: CodeAction[] = [];
    diagnostics.forEach((diag) => {
        if (diag.severity === DiagnosticSeverity.Warning) {
            // diag.data 存放着已经修复好的数据
            if (diag.data) {
                codeActions.push({
                    title: (diag.data as any)?.title,
                    kind: CodeActionKind.QuickFix,
                    diagnostics: [diag],
                    edit: {
                        changes: {
                            [params.textDocument.uri]: [
                                {
                                    range: diag.range,
                                    newText: (diag.data as any)?.newText,
                                },
                            ],
                        },
                    },
                });
            } 
        }
    });
    return codeActions;
}

最终实现效果:

实现 VSCode 代码诊断和快速修复(二)

总结

本文描述了如何从 0 开始实现VSCode 的代码诊断修复的过程,介绍了如何启动一个 vscode 项目、如何调试代码,以及代码诊断和快速修复。如果需要看看一些原理和介绍,不妨点击看看这篇文章 [工具]实现一个代码诊断的 VSCode 插件 ,而本文则增加了实现的细节,感兴趣的掘友,可以跟着操作一番,或者从 GitHub下载代码跑一下,地址在这 github.com/hugheschoi/… ,码字不易,喜不喜欢都顺手点个赞吧,谢谢。