一、AST概念
这里我们需要先了解一个概念:AST,全称Abstract Syntax Tree
,翻译为中文为抽象语法树
wikipedia定义:
In computer science, an abstract syntax tree (AST), or just syntax tree, is a tree representation of the abstract syntactic structure of source code written in a programming language.
翻译为:
在计算机科学中,抽象语法树(abstract syntax tree或者缩写为AST),或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。
如果你对这个概念很陌生,但你作为前端一定使用过webpack
、gulp
、babel
等工具,其原理都是通过JavaScript Parser
把代码转化为一颗抽象语法树(AST),这颗树定义了代码的结构,通过操纵这颗树,我们可以精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化、变更等操作,举个例子:
const a = 1;
console.log(a);
复制代码
转化为AST后,生成下面这样一个结构:
{
"type": "File",
"start": 0,
"end": 28,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 2,
"column": 15
}
},
"errors": [],
"program": {
"type": "Program",
"start": 0,
"end": 28,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 2,
"column": 15
}
},
"sourceType": "module",
"interpreter": null,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 12,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 12
}
},
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 11,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 11
}
},
"id": {
"type": "Identifier",
"start": 6,
"end": 7,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 7
},
"identifierName": "a"
},
"name": "a"
},
"init": {
"type": "NumericLiteral",
"start": 10,
"end": 11,
"loc": {
"start": {
"line": 1,
"column": 10
},
"end": {
"line": 1,
"column": 11
}
},
"extra": {
"rawValue": 1,
"raw": "1"
},
"value": 1
}
}
],
"kind": "const"
},
{
"type": "ExpressionStatement",
"start": 13,
"end": 28,
"loc": {
"start": {
"line": 2,
"column": 0
},
"end": {
"line": 2,
"column": 15
}
},
"expression": {
"type": "CallExpression",
"start": 13,
"end": 27,
"loc": {
"start": {
"line": 2,
"column": 0
},
"end": {
"line": 2,
"column": 14
}
},
"callee": {
"type": "MemberExpression",
"start": 13,
"end": 24,
"loc": {
"start": {
"line": 2,
"column": 0
},
"end": {
"line": 2,
"column": 11
}
},
"object": {
"type": "Identifier",
"start": 13,
"end": 20,
"loc": {
"start": {
"line": 2,
"column": 0
},
"end": {
"line": 2,
"column": 7
},
"identifierName": "console"
},
"name": "console"
},
"computed": false,
"property": {
"type": "Identifier",
"start": 21,
"end": 24,
"loc": {
"start": {
"line": 2,
"column": 8
},
"end": {
"line": 2,
"column": 11
},
"identifierName": "log"
},
"name": "log"
}
},
"arguments": [
{
"type": "Identifier",
"start": 25,
"end": 26,
"loc": {
"start": {
"line": 2,
"column": 12
},
"end": {
"line": 2,
"column": 13
},
"identifierName": "a"
},
"name": "a"
}
]
}
}
],
"directives": []
},
"comments": []
}
复制代码
你可以自己登陆astexplorer.net/网站,实际感受一下
二、代码解析与处理
2.1 如何解析代码
因为我们想要支持的文件类型后缀:vue
,ts
,tsx
,js
,jsx
,因此我们需要编写一个通用的将文件代码转换为AST的方法,调研了市面上几种JavaScript Parser
1) vue文件:
@vue/compiler-dom
,vue3.0
解析AST处理工具,可以完整解析template
部分,script
和style
部分解析到具体位置,全部内容不解析,以content
字符串的形式vue-eslint-parser
,可以解析template
和script
部分,不会解析style
部分,但解析script
经测试只能解析第一个script
标签,不支持解析ts
类型vue-template-compiler
,只解析template
部分
最终选取的方案:采用@vue/compiler-dom
解析
- 对应的
script
部分,由js
或ts
对应的处理工具处理 template
部分,按照解析的ast处理
采用此方案,需要额外特殊处理的一点是每个节点的位置需要特殊处理,因为script这部分由下述2)这部分处理后,是从0开始的,因为对于如template这部分,需要累积计算,下述具体实现代码中会体现
2)js文件/ts文件/jsx文件/tsx文件
工具非常多
工具 | 说明 | js | jsx | ts | tsx |
---|---|---|---|---|---|
esprima | 标准的js解析 | 支持 | 支持 | 不支持 | 不支持 |
espree | esprima的fork项目,API类似,优化了一些功能,eslint使用 | 支持 | 支持 | 不支持 | 不支持 |
@typescript-eslint/parser | eslint解析ts使用的工具 | 支持 | 支持 | 支持 | 支持 |
@babel/parser | babel 使用,基于estree | 支持 | 支持 | 支持 | 支持 |
方案,采用@babel/parser
解析,支持js
和ts
两种类型,并且支持jsx
但这里需要注意的是,由于业务里可能会使用一些提案中的语法,因此我们会需要增加相关plugin
辅助解析,如装饰器
2.2 如何遍历AST
解析为AST之后,接下来就是如何去遍历AST,也调研了大概几种方案:
工具 | 说明 | 支持性 | 备注 |
---|---|---|---|
estraverse | EsTools家族中的一个工具 | 不支持JSXElement | issue |
@babel/traverse | babel使用析 | 支持JSXElement |
方案,采用@babel/traverse
(这也是上面选择@babel/parser
的一个原因之一,我们希望是在一个体系下的技术,同时babel
对于前端来说,也更加熟悉)
2.3 辅助工具
辅助工具,如做节点判断等,如有需要可以参考
Esutils
,辅助操作工具,做一些节点判断等@babel/helpers
,辅助操作工具,做一些节点判断等,结合@babel/types
2.4 通用实现
// server/src/utils/ast.ts
import {
TextDocument
} from 'vscode-languageserver-textdocument';
import traverse from "@babel/traverse";
const vueParse = require('@vue/compiler-dom');
const parser = require('@babel/parser');
// js、ts、jsx、tsx解析递归
function parseAndTraverse(callback: (path: any, start?: number) => any, code: string, start: number = 0) {
try {
const astObj = parser.parse(code, {
sourceType: "module",
plugins: [ // 增加插件支持jsx、ts以及提案中的语法
"jsx",
"typescript",
["decorators", { decoratorsBeforeExport: true }],
"classProperties",
"classPrivateProperties",
"classPrivateMethods",
"classStaticBlock",
"doExpressions",
"exportDefaultFrom",
"functionBind",
"importAssertions",
"moduleStringNames",
"partialApplication",
["pipelineOperator", {proposal: "minimal"}],
"privateIn",
["recordAndTuple", {syntaxType: "hash"}],
"throwExpressions",
"topLevelAwait"
]
});
traverse(astObj, {
enter(path: any) {
callback(path, start);
}
});
} catch(err) {
console.log(err);
}
}
/*
@desc 生产AST遍历函数
@param callback 节点访问回调,参数path,节点,start节点需要累积的位置,在校验定位时,需要用此矫正位置信息
*/
export default function ast(callback: (path: any, start?: number) => any, textDocument: TextDocument) {
try {
const text = textDocument.getText();
const { languageId } = textDocument;
// vue文件解析
if (languageId === 'vue') {
const vueAstObj = vueParse.parse(text);
// vue文件js的部分
const scriptObjArr = vueAstObj.children.filter((item: any) => item.tag === 'script');
const len = scriptObjArr.length;
for (let i = 0; i < len; i++) {
const scriptItem = scriptObjArr[i];
if (!scriptItem) {
return;
}
const scriptStringArr = scriptItem.children;
// 循环每一段js
const scriptStringArrLen = scriptStringArr.length;
for (let j = 0; j < scriptStringArrLen; j++) {
const scriptString = scriptStringArr[j].content;
// 位置需要计算累计,故计算出起始位置
const location = scriptStringArr[j].loc;
parseAndTraverse(callback, scriptString, location.start.offset);
}
}
} else if (['javascript', 'typescript', 'javascriptreact', 'typescriptreact'].indexOf(languageId) > -1) {
parseAndTraverse(callback, text);
}
} catch (err) {
};
}
复制代码
三、语法校验
3.1 语法设计
这里我仅示意关键实现,如我们设计一个语法
tyc_test.a(1) // 合法,支持2个参数,分别为number、string类型,最少可设置1个参数,最多设置2个参数,如果设置参数多余2个,warn提示,并增加自动修复快捷操作
tyc_test['a'](1, 'a') // 合法
// 其余调用均不存在,需要报错
tyc_test.b() // 报错,不存在
// 如果是变量调用,我们不校验, 如需要,可做作用域链分析,查找变量,这里不展开了
const c = 'a';
tyc_test[c] // 暂时不校验
复制代码
3.2 具体实现
那我们怎么来实现呢?
1)涉及API
这里我们先讲一下涉及到的一个关键vscode API
- Diagnostic:诊断信息,其包含了具体的类型,位置、提示信息等,如:
{
severity: DiagnosticSeverity.Error,
range: {
start: textDocument.positionAt(start + scriptStart),
end: textDocument.positionAt(end + scriptStart)
},
message: '当前命令不存在',
source: 'vscode-example-tyc'
}
复制代码
2)具体实现
我们在语言服务器的server
中,监听文档变更等,在这里面我们可以拿到文档的字符串的内容,以及文档的文件类型等信息
// ....
connection.onDidChangeConfiguration(change => {
if (hasConfigurationCapability) {
// 重置所有已缓存的文档配置
documentSettings.clear();
} else {
globalSettings = <ExampleSettings>(
(change.settings['vscode-example-tyc'] || defaultSettings)
);
}
// 重新验证所有打开的文本文档
documents.all().forEach(validateTextDocument);
});
// ...
// 文档变更时触发(第一次打开或内容变更)
documents.onDidChangeContent(change => {
validateTextDocument(change.document);
});
// lint文档函数
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
let diagnostics: Diagnostic[] = [];
// 获取当前文档设置
let settings = await getDocumentSettings(textDocument.uri);
// 校验
diagnostics.push(...lint(textDocument, hasDiagnosticRelatedInformationCapability, settings));
// 发送诊断结果
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}
复制代码
这里我们将具体的lint单独拆分为一个模块,无副作用,可返回诊断信息。实现如下:
// server/src/lint.ts
import {
Diagnostic,
DiagnosticSeverity,
} from 'vscode-languageserver';
import {
TextDocument
} from 'vscode-languageserver-textdocument';
// import * as helpers from "@babel/helpers";
// import * as t from "@babel/types";
import ast from './utils/ast';
enum TypeCollection {
'number',
'string',
'function'
}
const paramsRules = {
a: {
min: 1,
max: 2,
typeArray: [TypeCollection.number, TypeCollection.string]
}
};
const commandList = Object.keys(paramsRules);
const typeNameObj = {
[TypeCollection.number]: ['NumericLiteral'],
[TypeCollection.string]: ['StringLiteral'],
[TypeCollection.function]: ['ArrowFunctionExpression', 'FunctionExpression']
};
export default function lint(textDocument: TextDocument, hasDiagnosticRelatedInformationCapability: boolean, settings: any) {
let diagnostics: Diagnostic[] = [];
ast((path: any, scriptStart: any) => {
try {
const node = path.node;
const { type, expression, start, end } = node;
// tyc_test调用判断
if (type === 'ExpressionStatement' && expression.type === 'CallExpression' && expression.callee.type === 'MemberExpression' && expression.callee.object.name === 'tyc_test') {
// property包含了属性信息,computed识别调用方式
const { property, computed} = expression.callee;
// 当前语法位置
const range = {
start: textDocument.positionAt(start + scriptStart),
end: textDocument.positionAt(end + scriptStart)
};
// 变量暂时不校验
if(property && ((property.type === 'Identifier' && !computed) || property.type === 'StringLiteral')) {
const name = property.name || property.value;
if (!commandList.includes(name)) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range,
message: '当前命令不存在',
source: 'vscode-example-tyc'
});
} else {
// 参数个数校验
const { arguments: args } = expression;
const { min, max, typeArray } = paramsRules[name as keyof typeof paramsRules];
const len = args.length;
if (len < min || len > max) {
const isError = len < min;
let diagnostic: Diagnostic | null = null;
// 允许用户关闭弱提示
if (settings.warning) {
const moreParams = args.slice(max);
diagnostic = {
severity: DiagnosticSeverity.Warning,
range,
message: `设置了${moreParams.length}个无意义的参数: ${moreParams.map((item: any) => item.value).join(',')}`,
source: 'vscode-example-tyc',
};
}
if (isError) {
diagnostic = {
severity: DiagnosticSeverity.Error,
range,
message: `参数少于规定参数个数`,
source: 'vscode-example-tyc'
};
// 补充信息说明
if (hasDiagnosticRelatedInformationCapability) {
// 可补充更多信息,这里不展开了
// diagnostic.relatedInformation = [];
}
}
diagnostic && diagnostics.push(diagnostic);
}
// 参数类型校验
if (typeArray) {
typeArray.map((item: (TypeCollection | string), index: number) => {
// 按理来说不需要拷贝,但是很奇怪不拷贝会出异常
const currentParam = {...args[index]};
if (args[index] && item !== '' && currentParam.type !== 'Identifier' && !typeNameObj[item as TypeCollection].includes(currentParam.type)) {
diagnostics.push({
severity: DiagnosticSeverity.Error,
range,
message: `第${index+1}个参数类型不对, 请输入${TypeCollection[item as TypeCollection]}类型`,
source: 'vscode-example-tyc',
});
}
});
}
}
}
}
} catch (err) {
console.log(err);
}
}, textDocument);
return diagnostics;
}
复制代码
需要注意的是,如果有异步校验,则上述lint
函数需要修改为具有副作用的,动态添加diagnostics
,并在响应后手动触发诊断结果更新,比如笔者的项目中有解析到关键参数,发接口远程验证并反馈的,如下
// 因为异步接口校验,因此需要动态添加diagnostics,并在响应后手动触发诊断结果更新
// 直接修改diagnostics,并调用回调更新
asyncLint(textDocument, hasDiagnosticRelatedInformationCapability, settings, diagnostics, () => {
// 发送诊断结果
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
});
复制代码
四、自动修复
4.1 涉及API
这里首先我们还是先介绍一下涉及的vscode API
1)注册命令
- 贡献点:contributes.commands
command
:命令唯一标识title
:命令标题,搜索展示
- 注册命令:commands
- 语法:
registerCommand(command: string, callback: (args: any[]) => any, thisArg?: any): Disposable
- 语法:
2)文本编辑:TextEditor
- 操作对象:
vscode.window.activeTextEditor
- 语法:
edit(callback: (editBuilder: TextEditorEdit) => void, options?: {undoStopAfter: boolean, undoStopBefore: boolean}): Thenable<boolean>
3)注册代码交互:registerCodeActionsProvider
- 语法:
registerCodeActionsProvider(selector: DocumentSelector, provider: CodeActionProvider, metadata?: CodeActionProviderMetadata): Disposable
4)代码交互对象:CodeAction
- 语法:
new CodeAction(title: string, kind?: CodeActionKind): CodeAction
4.2 具体实现
1)首先是注册命令的贡献点配置
// package.json
"commands": [
{
"command": "vscode-example-tyc.fixParameters",
"title": "fixParameters"
},
{
"command": "vscode-example-tyc.fixDeclare",
"title": "fixDeclare"
}
]
复制代码
2) 注册代码交互
// client/src/provider/autofix/index.ts
import * as vscode from 'vscode';
import { file } from '../../config';
import fixParameters from './fixParameters';
class CodeActionProvider {
provideCodeActions (
document: vscode.TextDocument,
_range: vscode.Range | vscode.Selection,
_context: vscode.CodeActionContext,
_token: vscode.CancellationToken): vscode.ProviderResult<vscode.CodeAction[]> {
// 拿到当前文档全部诊断信息
const diagnostic: readonly vscode.Diagnostic[] = _context.diagnostics.filter(item => item.source === 'vscode-example-tyc');
let result: vscode.CodeAction[] = [];
// 自动修复命令注册
result.push(...fixParameters(diagnostic, document, _range, _context, _token));
return result;
}
}
class CodeActionProviderMetadata {
providedCodeActionKinds = [ vscode.CodeActionKind.QuickFix ];
}
export default function autofix (context: vscode.ExtensionContext) {
// 自动修复命令
context.subscriptions.push(vscode.languages.registerCodeActionsProvider(
file,
new CodeActionProvider(),
new CodeActionProviderMetadata()
));
};
复制代码
3)具体的代码交互对象生成
// client/src/provider/autofix/fixParameters.ts
import * as vscode from 'vscode';
export default function (
diagnostic: readonly vscode.Diagnostic[],
document: vscode.TextDocument,
_range: vscode.Range | vscode.Selection,
_context: vscode.CodeActionContext,
_token: vscode.CancellationToken) {
return diagnostic.filter(item => item.message.indexOf('无意义的参数') > -1).map(item => {
const autoFixQuickFix = new vscode.CodeAction('自动删除无意义的参数', vscode.CodeActionKind.QuickFix);
// 在这里调用全局命令,并传入参数
autoFixQuickFix.command = {
title: '自动删除无意义的参数',
command: 'vscode-example-tyc.fixParameters',
arguments: [item, document, _range, _context, _token]
};
return autoFixQuickFix;
});
}
复制代码
4)全局命令注册,也即点击自动修复真正执行的逻辑
// client/src/provider/autofix/fixParametersCommand.ts
import * as vscode from 'vscode';
export default function fixParameters () {
return vscode.commands.registerCommand('vscode-example-tyc.fixParameters', (...argus) => {
const diagnostic = argus[0];
const document = argus[1];
const range = diagnostic.range;
// 调用文本编辑修改
vscode.window.activeTextEditor.edit((editBuilder) => {
const text = document.getText(range);
const deleteText = diagnostic.message.match(/设置了(\d{1})个无意义的参数/);
const deleteNum = deleteText && deleteText[1] || 0;
editBuilder.replace(range, text.replace(/\(([^(]*?)\)/, (...args) => {
let params = args[1].split(',');
const last = params.pop();
// 兼容tyc_test['a'](1, 'a', 3, );情况
if (last && last.trim()) {
params.push(last);
}
const len = params.length;
return `(${params.slice(0, len - deleteNum)})`;
}));
});
});
};
// 在上述注册代码交互中引入全局命令注册
// client/src/provider/autofix/index.ts
import fixParametersCommand from './fixParametersCommand';
// ...
export default function autofix (context: vscode.ExtensionContext) {
// 自动修复命令
context.subscriptions.push(fixParametersCommand());
// ...
};
复制代码
5)在插件入口文件引入
// client/src/extension.ts
import autofix from './provider/autofix/index';
// ...
export function activate(context: vscode.ExtensionContext) {
// ...
// 自动修复
autofix(context);
}
// ...
复制代码
五、效果
动画效果
插件配置:
六、系列文章
七、其他
完整代码可在我的github中查看。