前言
年初的时候实现了一个编辑器代码诊断的功能,并写一篇文章 [工具]实现一个代码诊断的 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/… ,码字不易,喜不喜欢都顺手点个赞吧,谢谢。