前言
年初的时候实现了一个编辑器代码诊断的功能,并写一篇文章 [工具]实现一个代码诊断的 VSCode 插件 ,里面简单讲述了整体的实现思路。但最近有小伙伴问我,有没有具体的细节和源码?听到这句话之后,我意识到在那篇文章分享我又陷入了自 high,实际上没人看得太懂的,因此我希望通过这篇文章,来详细讲清实现细节,并附上了源码(点击这里)。
本文将从 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
,会出现下图一样的界面:
这是 lsp-samle 实现的例子,就是连续输入大写字母,就会提示错误信息。那第一步就成功了。
2. 调试
在正式开始写功能之前,我们需要先学会调试,这里需要使用断点调试,打上断点后,首先启动语言服务器,选择 Attach to Server
启动完成后,我们需要重新 reload 调试窗口,
command + R
刷新窗口,或者先停止 Launch Client 再启动。完了之后我们在调试窗口的文件输入的时候就会进入断点了。比如我删除了一个 B 字,如下图所示:
如果你没有成功,可能没有重启调试窗口,或者设置断点后没有重启
Attach to Server
。当你完成这一步之后,可以正式开始写功能了。
3.核心代码
- server.ts 是语言服务器
- codeDiagnostics.ts 是用于收集诊断对象
- codeActionProvider 是代码修复功能
- ast-analysis 目录下是用于分析代码语法的
在 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 顺序是否合规
首先定好正确的顺序,用数组表示:
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)包括下面几个属性:
-
range
: 诊断有问题的范围,也就是画波浪线的地方 -
severity
: 严重性,分别有四个等级,不同等级标记的颜色不同,分别是:- Error: 1
- Warning: 2
- Information:3
- Hint:4
-
message
: 诊断的提示信息 -
source
: 来源,比如说来源是 Eslint -
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
// 层级超过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; }
最终实现效果:
总结
本文描述了如何从 0 开始实现VSCode 的代码诊断修复的过程,介绍了如何启动一个 vscode 项目、如何调试代码,以及代码诊断和快速修复。如果需要看看一些原理和介绍,不妨点击看看这篇文章 [工具]实现一个代码诊断的 VSCode 插件 ,而本文则增加了实现的细节,感兴趣的掘友,可以跟着操作一番,或者从 GitHub下载代码跑一下,地址在这 github.com/hugheschoi/… ,码字不易,喜不喜欢都顺手点个赞吧,谢谢。