vscode插件开发指南(三)-实战篇-语法校验实现

3,752 阅读10分钟

前言

我们的目标是实现一个基础库的插件,其功能包含以下内容:

  • 语法校验,校验API命令调用的正确性,参数类型的正确性,参数个数的正确性,关键参数和系统打通校验有效性等
  • 语法自动补全,辅助用户编写代码
  • 语法悬停提示,辅助说明语法
  • 其他功能
    • webview查看远程信息

本篇即为语法校验实现篇,我们先看一下最后的效果,是不是很神奇,快来和我一起探讨到底是怎么实现的吧~ 效果

完整代码可在我的github中查看。

一、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),是源代码的抽象语法结构的树状表现形式,这里特指编程语言的源代码。

如果你对这个概念很陌生,但你作为前端一定使用过webpackgulpbabel等工具,其原理都是通过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 如何解析代码

因为我们想要支持的文件类型后缀:vuetstsxjsjsx,因此我们需要编写一个通用的将文件代码转换为AST的方法,调研了市面上几种JavaScript Parser

1) vue文件:

  • @vue/compiler-domvue3.0解析AST处理工具,可以完整解析template部分,scriptstyle部分解析到具体位置,全部内容不解析,以content字符串的形式
  • vue-eslint-parser,可以解析templatescript部分,不会解析style部分,但解析script经测试只能解析第一个script标签,不支持解析ts类型
  • vue-template-compiler,只解析template部分

最终选取的方案:采用@vue/compiler-dom解析

  • 对应的script部分,由jsts对应的处理工具处理
  • template部分,按照解析的ast处理

采用此方案,需要额外特殊处理的一点是每个节点的位置需要特殊处理,因为script这部分由下述2)这部分处理后,是从0开始的,因为对于如template这部分,需要累积计算,下述具体实现代码中会体现

2)js文件/ts文件/jsx文件/tsx文件

工具非常多

工具说明jsjsxtstsx
esprima标准的js解析支持支持不支持不支持
espreeesprima的fork项目,API类似,优化了一些功能,eslint使用支持支持不支持不支持
@typescript-eslint/parsereslint解析ts使用的工具支持支持支持支持
@babel/parserbabel 使用,基于estree支持支持支持支持

方案,采用@babel/parser解析,支持jsts两种类型,并且支持jsx

但这里需要注意的是,由于业务里可能会使用一些提案中的语法,因此我们会需要增加相关plugin辅助解析,如装饰器

2.2 如何遍历AST

解析为AST之后,接下来就是如何去遍历AST,也调研了大概几种方案:

工具说明支持性备注
estraverseEsTools家族中的一个工具不支持JSXElementissue
@babel/traversebabel使用析支持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);
}
// ...

五、效果

动画效果 效果

插件配置: 配置

六、系列文章