LSP 最小示例:一个简单 Language Server 与 Theia 扩展示意
这一篇不打算做完整项目,而是用“伪代码 + 片段”的方式,大致走一遍:一个极简语言服务器能长什么样,Theia 这边需要准备哪些扩展代码,两端是如何连起来的。
一个极简 Language Server 的骨架
这里借用 TypeScript/Node 生态常见的写法,只保留关键结构(省略错误处理等细节)。
1// server.ts
2import {
3 createConnection,
4 ProposedFeatures,
5 TextDocuments,
6 InitializeParams,
7 CompletionItem,
8 CompletionItemKind,
9 TextDocumentPositionParams,
10} from 'vscode-languageserver/node';
11import { TextDocument } from 'vscode-languageserver-textdocument';
12
13const connection = createConnection(ProposedFeatures.all);
14const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
15
16connection.onInitialize((_params: InitializeParams) => {
17 return {
18 capabilities: {
19 textDocumentSync: documents.syncKind,
20 completionProvider: {},
21 },
22 };
23});
24
25// 一个非常简单的补全实现
26connection.onCompletion((_params: TextDocumentPositionParams): CompletionItem[] => {
27 return [
28 {
29 label: 'helloWorld',
30 kind: CompletionItemKind.Function,
31 detail: '插入 hello world',
32 insertText: 'console.log("hello world");',
33 },
34 ];
35});
36
37documents.listen(connection);
38connection.listen();
这个服务器做的事情:
- 声明自己支持文档同步和补全;
- 接收来自客户端的文档内容更新(
documents.listen(connection)); - 在任意位置请求补全时都返回一个固定的
helloWorld建议。
在 Theia 中声明一个语言客户端贡献点
在 Theia 这边,需要写一个扩展来告诉系统“有这样一个 Language Server 可以用”。
通常会定义一个 LanguageClientContribution 实现类,描述语言 ID、文件模式和如何启动服务器。
示意代码(省略 DI 细节,只看结构):
1// my-language-contribution.ts
2import { injectable } from '@theia/core/shared/inversify';
3import {
4 BaseLanguageClientContribution,
5 LanguageClientFactory,
6} from '@theia/languages/lib/browser';
7
8@injectable()
9export class MyLanguageClientContribution extends BaseLanguageClientContribution {
10 readonly id = 'myLang';
11 readonly name = 'MyLang';
12
13 constructor(
14 protected readonly languageClientFactory: LanguageClientFactory,
15 ) {
16 super();
17 }
18
19 protected createLanguageClientOptions() {
20 const documentSelector = ['myLang'];
21 return {
22 documentSelector,
23 initializationOptions: {},
24 };
25 }
26
27 protected createLanguageClient(connection: MessageConnection) {
28 const clientOptions = this.createLanguageClientOptions();
29 return this.languageClientFactory.get(this, connection, clientOptions);
30 }
31}
同时在前端/后端模块里绑定这个 contribution,并配置语言 ID 与文件扩展名的关联。
当编辑器打开 .mlg 之类的文件时,Theia 会根据语言 ID 决定走这个 LSP 客户端。
服务器如何被 Theia 启动
还需要一个“服务端贡献”,告诉 Theia 后端如何启动刚才写的 server.ts:
1// my-language-backend-module.ts 片段
2import { ContainerModule } from 'inversify';
3import {
4 LanguageServerContribution,
5} from '@theia/languages/lib/node';
6
7@injectable()
8class MyLanguageServerContribution implements LanguageServerContribution {
9 readonly id = 'myLang';
10 readonly name = 'MyLang';
11
12 start(clientConnection: IConnection): void {
13 // 这里通过 child_process 启动 server.js,然后用 JSON-RPC 桥接
14 const child = cp.spawn('node', [serverPath], { stdio: ['pipe', 'pipe', process.stderr] });
15 const serverConnection = createServerProcess('myLang', child);
16 forward(clientConnection, serverConnection);
17 }
18}
19
20export default new ContainerModule(bind => {
21 bind(LanguageServerContribution).to(MyLanguageServerContribution).inSingletonScope();
22});
这部分大致表达了三个点:
- 为某个
id/name注册 Language Server; - 用
spawn等方式启动语言服务器进程; - 用一个 JSON-RPC 桥接工具将客户端连接与服务器进程连接起来。
端到端效果会是什么样
把前面的 pieces 拼在一起后,大致效果是:
- 打开一个
*.mlg文件; - 前端 Monaco 编辑器通过 Theia 的语言注册知道这是
myLang; - Theia LSP 客户端为
myLang建立与后端语言服务器的连接; - 文档打开/变更时,LSP 文档同步消息被发送到服务器;
- 在编辑器中触发补全时,LSP 客户端发出
completion请求; - Language Server 返回
helloWorld补全项; - 前端将其映射为 Monaco 的补全列表项,用户可选中插入。
这个最小示意并不涉及复杂诊断、跳转或重构,但已经涵盖了:
- LSP Server 的基本结构;
- Theia 前端的语言客户端贡献;
- Theia 后端的语言服务器贡献;
- 前后端与 Monaco 的简单协作路径。
在实际项目中,再在这个骨架上堆砌更丰富的协议实现和 UI 展示即可。**