LSP 最小示例:一个简单 Language Server 与 Theia 扩展示意

这一篇不打算做完整项目,而是用“伪代码 + 片段”的方式,大致走一遍:一个极简语言服务器能长什么样,Theia 这边需要准备哪些扩展代码,两端是如何连起来的。

这里借用 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 这边,需要写一个扩展来告诉系统“有这样一个 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 后端如何启动刚才写的 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 展示即可。**