前言
我们要写一个edm的语法插件,就需要用到vscode编程语言扩展。
先看一下vscode的编程语言扩展有哪些:
vscode是支持错误检查的,我们要写一个edm语法插件就需要用到代码扫描的诊断信息,这个诊断信息是以
vscode.Diagnostic
为载体呈现的。
诊断信息
下图是vscode.Diagnostic
类的成员和与相关类的关系:
以小到大,这些类为:
- Position: 定位到一行上的一个字符的坐标
- Range: 由起点和终点两个Position决定
- Location: 一个Range配上一个URI
- DiagnosticRelatedInformation: 一个Location配一个message
- Diagnostic: 主体是一个message字符串,一个Range和一个DiagnosticRelatedInformation.
URL是Uniform Resource Locator的缩写,译为"统一资源定位符"。URL是一种URI,它标识一个互联网资源,并指定对其进行操作或获取该资源的方法。
最大的缺点是当信息资源的存放地点发生变化时,必须对URL作相应的改变。因此人们正在研究新的信息资源表示方法,例如:URI(Universal Resource Identifier)即"通用资源标识" 、URN(Uniform Resource Name)即"统一资源名"和URC(Uniform Resource Citation)即"统一资源引用符"等。
URI还在进一步的研究当中。研究的方向就是弥补URL的缺点。
构造一个诊断信息
以下图的html代码为例,保存为test.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DOCUMENT</title>
<style></style> <!--edm不支持style标签-->
</head>
<body>
<tr>
<td >
</td>
</tr>
</body>
</html>
在这个例子中使用了style标签,在edm中不支持。
出现问题的是第8行的第5字符到第19字符,所以我们构造(7,4)到(7,18)这样两个Position为首尾的Range。
有了Range,加上问题描述字符串,和问题严重程序三项,就可以构造出一个Diagnostic。
let diagnostic1:vscode.Diagnostic = new vscode.Diagnostic(
new vscode.Range(
new vscode.Position(7,4),
new vscode.Position(7,18)
),
'edm不支持style标签',
vscode.DiagnosticSeverity.Warning
)
updateDiags完整的代码
export function updateDiags(
document: vscode.TextDocument,
collection: vscode.DiagnosticCollection
): void {
let diagnostics: vscode.Diagnostic = new vscode.Diagnostic(
new vscode.Range(new vscode.Position(7, 4), new vscode.Position(7, 18)),
'edm不支持style标签',
vscode.DiagnosticSeverity.Warning
);
diagnostics.source = 'edm Helper';
diagnostics.relatedInformation = [
new vscode.DiagnosticRelatedInformation(
new vscode.Location(
document.uri,
new vscode.Range(new vscode.Position(7, 0), new vscode.Position(7, 18))
),
'edm grammar check'
),
];
diagnostics.code = 102;
if (document && path.basename(document.uri.fsPath) === 'test.html') {
collection.set(document.uri, [diagnostics]);
} else {
collection.clear();
}
}
然后在active函数里调用刚刚写的方法
export function activate(context: ExtensionContext) {
const diag_coll = vscode.languages.createDiagnosticCollection('basic-lint-1');
if (vscode.window.activeTextEditor) {
updateDiags(vscode.window.activeTextEditor.document, diag_coll);
}
context.subscriptions.push(
vscode.window.onDidChangeActiveTextEditor((e: vscode.TextEditor | undefined) => {
if (e !== undefined) {
updateDiags(e.document, diag_coll);
}
})
);
context.subscriptions.push(
workspace.onDidChangeTextDocument((e: vscode.TextDocumentChangeEvent | undefined) => {
if (e !== undefined) {
updateDiags(e.document, diag_coll);
}
})
);
}
按F5运行一下,就可以看到检测结果啦
诊断结果
vscode.DiagnosticSeverity
的值以下四个,分别是下面的效果:
语言服务器协议LSP
除了Diagnostic
,我们还需要用到语言服务器协议LSP(language sever protocol)
。
首先 language server
是一种跨编辑器的语言支持实现规范。它由微软提出,目前 vscode 、vim、atom 都已经支持了这个规范。
LSP(language sever protocol)
是用来处理语言解析等等东西在各式ide里应用的玩意。ide主要干的活还是要提供各类语言的解析跳转高亮等等的东西,所以lsp就显得很重要。放两张图就能大概理解LSP是具体干什么的,为什么需要LSP。
LSP主要解决了几个问题:
1、语言插件的复用。举个例子:Eclipse里C++相关的支持是用java写的,原因很简单:eclipse本身是java写的。但是这样如果要在vscode里面写C++那就又得拿js写一遍,相当于重复造了轮子。
2、进程独立。语言解析这件事本身是很重的,有时候会需要花非常长的时间来完成,要是这时候整个vscode都卡住那就别玩了。所以干脆把这块东西单独抽出来放在服务器上。
LSP现在支持的功能大概如下:
所以实际上在涉及各种语言解析的时候,插件需要起一个server来处理,文件夹大体上就会长这样
├── client // 语言客户端
│ ├── package.json
│ ├── src
│ │ ├── test // 单元测试文件
│ │ └── extension.js // 语言客户端的入口文件
├── package.json // 插件的描述文件
└── server // 语言服务端
└── package.json
└── src
└── server.js // 语言服务端的入口文件
LSP生命周期
服务器的生命周期从客服端向服务端发送一个initialize请求开始,参数是一个InitializeParameter对象
interface InitializeParams {
/**
* The process Id of the parent process that started
* the server. Is null if the process has not been started by another process.
* If the parent process is not alive then the server should exit (see exit notification) its process.
*/
processId: number | null;
/**
* The rootPath of the workspace. Is null
* if no folder is open.
*
* @deprecated in favour of rootUri.
*/
rootPath?: string | null;
/**
* The rootUri of the workspace. Is null if no
* folder is open. If both `rootPath` and `rootUri` are set
* `rootUri` wins.
*/
rootUri: DocumentUri | null;
/**
* User provided initialization options.
*/
initializationOptions?: any;
/**
* The capabilities provided by the client (editor or tool)
*/
capabilities: ClientCapabilities;
/**
* The initial trace setting. If omitted trace is disabled ('off').
*/
trace?: 'off' | 'messages' | 'verbose';
/**
* The workspace folders configured in the client when the server starts.
* This property is only available if the client supports workspace folders.
* It can be `null` if the client supports workspace folders but none are
* configured.
*
* Since 3.6.0
*/
workspaceFolders?: WorkspaceFolder[] | null;
}
而服务器返回的是服务器的能力
/**
* The result returned from an initialize request.
*/
export interface InitializeResult<T = any> {
/**
* The capabilities the language server provides.
*/
capabilities: ServerCapabilities<T>;
/**
* Information about the server.
*
* @since 3.15.0
*/
serverInfo?: {
/**
* The name of the server as defined by the server.
*/
name: string;
/**
* The server's version as defined by the server.
*/
version?: string;
};
/**
* Custom initialization results.
*/
[custom: string]: any;
}
我们这里要用到的就是textDocumentSync
export interface _ServerCapabilities<T = any> {
/**
* Defines how text documents are synced. Is either a detailed structure defining each notification or
* for backwards compatibility the TextDocumentSyncKind number.
*/
textDocumentSync?: TextDocumentSyncOptions | TextDocumentSyncKind;
...
}
textDocumentSync的取值可以直接写具体的能力,也可以设置类型
export interface TextDocumentSyncOptions {
/**
* Open and close notifications are sent to the server. If omitted open close notification should not
* be sent.
*/
openClose?: boolean;
/**
* Change notifications are sent to the server. See TextDocumentSyncKind.None, TextDocumentSyncKind.Full
* and TextDocumentSyncKind.Incremental. If omitted it defaults to TextDocumentSyncKind.None.
*/
change?: TextDocumentSyncKind;
/**
* If present will save notifications are sent to the server. If omitted the notification should not be
* sent.
*/
willSave?: boolean;
/**
* If present will save wait until requests are sent to the server. If omitted the request should not be
* sent.
*/
willSaveWaitUntil?: boolean;
......
}
/**
* Defines how the host (editor) should sync
* document changes to the language server.
*/
export declare namespace TextDocumentSyncKind {
/**
* Documents should not be synced at all.
*/
const None = 0;
/**
* Documents are synced by always sending the full content
* of the document.
*/
const Full = 1;
/**
* Documents are synced by sending the full content on open.
* After that only incremental updates to the document are
* send.
*/
const Incremental = 2;
}
客户端收到initialize result之后,按照三次握手的原则,将返回一个initialized消息做确认。至此,一个服务端与客户端通信的生命周期就算是成功建立。
实现一个LSP服务
createConnection
服务端首先要获取一个Connection对象,通过vscode-languageserver提供的createConnection函数来创建Connection.
// Create a connection for the server, using Node's IPC as a transport.
// Also include all preview / proposed LSP features.
let connection = createConnection(ProposedFeatures.all);
Connection中对于LSP的消息进行了封装
onInitialize
监听客户端发送的initialize,返回服务端的能力,这里我们设置类型为增量监听,每次只传变化的部分。
connection.onInitialize((params: InitializeParams) => {
let capabilities = params.capabilities;
return {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental
}
};
});
根据三次握手的原则,客户端还会返回initialized notification进行通知,服务器在这向客服端发送信息了。
onInitialized
connection.onInitialized(() => {
connection.window.showInformationMessage('Hello World! form server side');
Listen on the connection
//在最后设置监听
// Listen on the connection
connection.listen();
连接成功时效果如图
客户端连接LSP服务
import * as path from "path";
import { workspace, ExtensionContext } from "vscode";
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from "vscode-languageclient/node";
let client: LanguageClient;
export function activate(context: ExtensionContext) {
// 服务端配置
let serverModule = context.asAbsolutePath(
path.join('server', 'out', 'server.js')
);
let serverOptions: ServerOptions = {
module: serverModule, transport: TransportKind.ipc
};
// 客户端配置
let clientOptions: LanguageClientOptions = {
// js代码触发事情
documentSelector: [{ scheme: 'file', language: 'html' }],
};
client = new LanguageClient(
'DemoLanguageServer',
'Demo Language Server',
serverOptions,
clientOptions
);
// 启动客户端,同时启动语言服务器
client.start();
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}
写一个完整的LSP工程
先写server端
package.json
server端主要是用到vscode-languageserver
和vscode-languageserver-textdocument
{
"name": "edm-helper-server",
"description": "Example implementation of a language server in node.",
"version": "1.0.0",
"author": "Microsoft Corporation",
"license": "MIT",
"engines": {
"node": "*"
},
"repository": {
},
"dependencies": {
"vscode-languageserver": "^7.0.0",
"vscode-languageserver-textdocument": "^1.0.1"
},
"scripts": {}
}
tsconfig.json
{
"compilerOptions": {
"target": "es2019",
"lib": ["ES2019"],
"module": "commonjs",
"moduleResolution": "node",
"sourceMap": true,
"strict": true,
"outDir": "out",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", ".vscode-test"]
}
server.ts
先引入依赖
import {
createConnection,
TextDocuments,
Diagnostic,
DiagnosticSeverity,
ProposedFeatures,
InitializeParams,
TextDocumentSyncKind,
InitializeResult,
} from "vscode-languageserver/node";
import { TextDocument } from "vscode-languageserver-textdocument";
这样我们就可以调用createConnection来创建连接了:
let connection = createConnection(ProposedFeatures.all);
还需要生成一个文本管理器用来监听文本的变化
let documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
初始化事件
connection.onInitialize((params: InitializeParams) => {
const result: InitializeResult = {
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
},
};
return result;
});
三次握手之后,我们可以在vscode上显示一条消息:
connection.onInitialized(() => {
connection.window.showInformationMessage("Hello World! form server side");
});
连接成功后监听文件变化,诊断html文件内容是否符合edm语法规范
documents.onDidChangeContent((change) => {
connection.window.showInformationMessage("validateTextDocument");
validateTextDocument(change.document); //检查是否符合语法
});
// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
// Listen on the connection
connection.listen();
检查的语法规则如下:
let errorList: any[] = [
{
content: "position:absolute",
message: "position is not support by edm",
},
{
content: "position:relative",
message: "position is not support by edm",
},
{
content: "position:fixed",
message: "position is not support by edm",
},
{
content: "position",
message: "position is not support by edm",
},
{
content: "class",
message: "class is not support by edm, use Inline CSS Style",
},
{
content: "<link>",
message: "<link> is not support by edm, use Inline CSS Style",
},
{
content: "</link>",
message: "<link> is not support by edm, use Inline CSS Style",
},
{
content: "<style>",
message: "<style> is not support by edm, use Inline CSS Style",
},
{
content: "</style>",
message: "<style> is not support by edm, use Inline CSS Style",
},
{
content: "margin",
message: "margin will fail in some mailboxes, use padding",
},
{
content: "rgb",
message:
"rgb will fail in some mailboxes, use hexadecimal color,like #ffffff",
},
{
content: "!important",
message: "!important is not support by edm",
},
{
content: "background: url",
message: "background image is not support by edm",
},
{
content: "background-image",
message: "background image is not support by edm",
},
{
content: "border-radius",
message: "border-radius is not support by edm, use Image",
},
{
content: "<div>",
message: "Do not use div, Use TABLE not DIV",
},
{
content: "</div>",
message: "Do not use div, Use TABLE not DIV",
},
{
content: "#[a-zA-Z_0-9]{3,3};",
message: "Do not abbreviate colors, such as #fff, write them as #ffffff",
},
];
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
// The validator creates diagnostics for all uppercase words length 2 and more
let text = textDocument.getText();
let regList = errorList.map((item) => {
return item.content;
});
let patternStr = regList.join("|");
let reg = new RegExp(patternStr, "g");
let m: RegExpExecArray | null;
let diagnostics: Diagnostic[] = [];
while ((m = reg.exec(text))) {
let diagnostic: Diagnostic = {
severity: DiagnosticSeverity.Warning,
range: {
start: textDocument.positionAt(m.index),
end: textDocument.positionAt(m.index + m[0].length),
},
message: errorList[regList.indexOf(m[0])].message,
source: "edmHelper",
};
diagnostic.relatedInformation = [
{
location: {
uri: textDocument.uri,
range: Object.assign({}, diagnostic.range),
},
message: "edm grammar",
},
];
diagnostics.push(diagnostic);
}
// Send the computed diagnostics to VSCode.
connection.sendDiagnostics({ uri: textDocument.uri, diagnostics });
}
client客户端
服务端开发完成后,我们继续写客户端
package.json
客户端要用到的库是vscode-languageclient
{
"name": "edm-helper-client",
"description": "VSCode part of a language server",
"author": "Microsoft Corporation",
"license": "MIT",
"version": "0.0.1",
"publisher": "vscode",
"repository": {
},
"engines": {
"vscode": "^1.52.0"
},
"dependencies": {
"vscode-languageclient": "^7.0.0"
},
"devDependencies": {
"@types/vscode": "^1.52.0",
"vscode-test": "^1.3.0"
}
}
tsconfig.json
与服务端的基本一致
{
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"lib": ["ES2019"],
"outDir": "out",
"rootDir": "src",
"sourceMap": true
},
"include": ["src"],
"exclude": ["node_modules", ".vscode-test"]
}
extension.ts
与之前给的例子一模一样
import * as path from "path";
import { ExtensionContext } from "vscode";
import {
LanguageClient,
LanguageClientOptions,
ServerOptions,
TransportKind,
} from "vscode-languageclient/node";
let client: LanguageClient;
export function activate(context: ExtensionContext) {
console.log("activate");
// The server is implemented in node
let serverModule = context.asAbsolutePath(
path.join("server", "out", "server.js")
);
let serverOptions: ServerOptions = {
module: serverModule,
transport: TransportKind.ipc,
};
let clientOptions: LanguageClientOptions = {
// js代码触发事情
documentSelector: [{ scheme: "file", language: "html" }],
};
// Create the language client and start the client.
client = new LanguageClient(
"languageServerExample",
"Language Server Example",
serverOptions,
clientOptions
);
// Start the client. This will also launch the server
client.start();
}
export function deactivate(): Thenable<void> | undefined {
if (!client) {
return undefined;
}
return client.stop();
}
组装运行
插件根目录底下的package.json
package.json
重点在入口文件和activationEvents的配置,配置在打开html文件的时候激活插件
"activationEvents": [
"onLanguage:html"
],
"main": "./client/out/extension",
{
"name": "edm-helper",
"description": "A language server example",
"author": "Microsoft Corporation",
"license": "MIT",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "https://github.com/Microsoft/vscode-extension-samples"
},
"publisher": "vscode-samples",
"categories": [],
"keywords": [
"multi-root ready"
],
"engines": {
"vscode": "^1.43.0"
},
"activationEvents": [
"onLanguage:html"
],
"main": "./client/out/extension",
"contributes": {
"configuration": {
"type": "object",
"title": "Example configuration",
"properties": {
"languageServerExample.maxNumberOfProblems": {
"scope": "resource",
"type": "number",
"default": 100,
"description": "Controls the maximum number of problems produced by the server."
},
"languageServerExample.trace.server": {
"scope": "window",
"type": "string",
"enum": [
"off",
"messages",
"verbose"
],
"default": "off",
"description": "Traces the communication between VS Code and the language server."
}
}
}
},
"scripts": {
"vscode:prepublish": "npm run compile",
"compile": "tsc -b",
"watch": "tsc -b -w",
"postinstall": "cd client && npm install && cd ../server && npm install && cd ..",
"test": "sh ./scripts/e2e.sh"
},
"devDependencies": {
"@types/mocha": "^8.0.3",
"@types/node": "^12.12.0",
"@typescript-eslint/parser": "^2.3.0",
"eslint": "^6.4.0",
"mocha": "^8.1.1",
"typescript": "^4.2.2"
}
}
tsconfig.json
我们还需要一个总的tsconfig.json,引用client和server两个目录:
{
"compilerOptions": {
"module": "commonjs",
"target": "es2019",
"lib": ["ES2019"],
"outDir": "out",
"rootDir": "src",
"sourceMap": true
},
"include": [
"src"
],
"exclude": [
"node_modules",
".vscode-test"
],
"references": [
{ "path": "./client" },
{ "path": "./server" }
]
}
配置vscode
下面我们在.vscode目录中写两个配置文件,使我们可以更方便地调试和运行。
.vscode/launch.json
有了这个文件之后,我们就有了运行的配置,可以通过F5来启动。
{
"version": "0.2.0",
"configurations": [
{
"type": "extensionHost",
"request": "launch",
"name": "Launch Client",
"runtimeExecutable": "${execPath}",
"args": ["--extensionDevelopmentPath=${workspaceRoot}"],
"outFiles": ["${workspaceRoot}/client/out/**/*.js"],
"preLaunchTask": {
"type": "npm",
"script": "watch"
}
},
{
"type": "node",
"request": "attach",
"name": "Attach to Server",
"port": 6009,
"restart": true,
"outFiles": ["${workspaceRoot}/server/out/**/*.js"]
},
],
"compounds": [
{
"name": "Client + Server",
"configurations": ["Launch Client", "Attach to Server"]
}
]
}
.vscode/tasks.json
配置npm compile和npm watch两个脚本。
这样⇧⌘B就可以对生成server和client的out目录下的js和map。
{
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "compile",
"group": "build",
"presentation": {
"panel": "dedicated",
"reveal": "never"
},
"problemMatcher": [
"$tsc"
]
},
{
"type": "npm",
"script": "watch",
"isBackground": true,
"group": {
"kind": "build",
"isDefault": true
},
"presentation": {
"panel": "dedicated",
"reveal": "never"
},
"problemMatcher": [
"$tsc-watch"
]
}
]
}
这时候就全部完成啦,按⇧⌘B生成js和js.map后。再摁F5调试就能看到效果啦。