- 开发工具
【免费下载链接】language-server-protocol
Defines a common protocol for language servers.
本文基于开源仓库 language-server-protocol(LSP 规范仓库)中的 semanticTokens.md(3.19 版本规范)展开,深入讲解 LSP 语义标记(Semantic Tokens)的完整技术细节。通过本文,读者将掌握语义标记的 token 类型/修饰符体系、legend 整数编码机制、相对位置编码算法、增量(delta)传输机制,以及 full / range / refresh 三类请求与客户端-服务端能力协商的完整实战方案。
一、语义标记概览:为什么需要它
语义标记(Semantic Tokens)是自LSP 3.16.0起引入的一项请求能力。它由客户端发送给服务端,用于为给定文件解析语义标记。与传统基于正则表达式的语法高亮不同,语义标记依赖语言特有的符号信息——例如一个标识符是class、function还是variable,从而提供更准确、更丰富的着色信息。
由于一次语义标记请求通常会产生非常大的结果集,协议因此支持将 token 用数字进行编码以压缩传输体积;此外还提供增量(delta)可选支持,让服务端在文件编辑后只发送变化部分而非全量数据。
该特性在仓库中的正式定义位于 _specifications/lsp/3.19/language/semanticTokens.md,并被 3.19 规范总文档 第 649 行通过
{% include_relative language/semanticTokens.md %}引用,是 3.19 规范主体的组成部分。本文的源码证据均来自该仓库的 3.19 目录。
二、核心概念:Token 类型与修饰符
一个 token 由**一种 token 类型(token type)与一组 token 修饰符(token modifiers)**共同表示。token 类型类似于class或function,token 修饰符类似于static或async。
协议预定义了一套 token 类型和修饰符,但允许客户端扩展,并在对应的客户端能力(client capability)中宣告其支持的值。
2.1 预定义 Token 类型(SemanticTokenTypes)
export enum SemanticTokenTypes { namespace = 'namespace', /** * Represents a generic type. Acts as a fallback for types which * can't be mapped to a specific type like class or enum. */ type = 'type', class = 'class', enum = 'enum', interface = 'interface', struct = 'struct', typeParameter = 'typeParameter', parameter = 'parameter', variable = 'variable', property = 'property', enumMember = 'enumMember', event = 'event', function = 'function', method = 'method', macro = 'macro', keyword = 'keyword', modifier = 'modifier', comment = 'comment', string = 'string', number = 'number', regexp = 'regexp', operator = 'operator', /** * @since 3.17.0 */ decorator = 'decorator', /** * @since 3.18.0 */ label = 'label' }值得注意的是,type是通用类型的兜底(fallback):当类型无法映射到class、enum等具体类型时使用。decorator自 3.17.0 加入,label自 3.18.0 加入——这两点可以从仓库 3.18/3.19 的 metaModel.json 中枚举SemanticTokenTypes的取值序列得到印证(含decorator与label)。
2.2 预定义 Token 修饰符(SemanticTokenModifiers)
export enum SemanticTokenModifiers { declaration = 'declaration', definition = 'definition', readonly = 'readonly', static = 'static', deprecated = 'deprecated', abstract = 'abstract', async = 'async', modification = 'modification', documentation = 'documentation', defaultLibrary = 'defaultLibrary' }2.3 Token 格式(TokenFormat)
协议额外定义了一个 token 格式能力,以便未来扩展编码格式。目前唯一指定的格式是relative,表示 token 使用相对位置描述(详见下文"整数编码"):
export namespace TokenFormat { export const Relative: 'relative' = 'relative'; } export type TokenFormat = 'relative';三、整数编码与 Legend 机制
3.1 为什么需要 Legend
在能力协商层面,类型和修饰符使用字符串定义;但真实的编码发生在整数层面。因此服务端必须让客户端知道:它使用哪个数字对应哪种类型/修饰符。这一映射通过legend实现:
export interface SemanticTokensLegend { /** * The token types a server uses. */ tokenTypes: string[]; /** * The token modifiers a server uses. */ tokenModifiers: string[]; }3.2 类型查表与修饰符位标志
编码规则有两条核心约定:
- 类型按索引查表:
tokenType值为1即代表tokenTypes[1]。 - 修饰符用位标志(bit flags):一个 token 类型可以携带多个修饰符,因此
tokenModifier值为3时,先视为二进制0b00000011,表示第 0、1 位被置位,即[tokenModifiers[0], tokenModifiers[1]]。
3.3 相对位置的 5 整数元组
文件中的 token 位置有两种表达方式:绝对位置与相对位置。relative格式采用相对位置,因为文件编辑时大部分 token 彼此间保持相对稳定,这简化了服务端计算 delta的过程。
每个 token 用5 个整数表示。设文件中的第i个 token,其数组索引含义如下:
| 数组索引 | 字段 | 含义 |
|---|---|---|
5*i | deltaLine | token 所在行号,相对前一个 token 的行号 |
5*i+1 | deltaStart | token 起始字符,相对前一个 token 的起始字符(同行时为相对差值,否则相对 0) |
5*i+2 | length | token 的长度 |
5*i+3 | tokenType | 在SemanticTokensLegend.tokenTypes中查表;规范要求tokenType < 65536 |
5*i+4 | tokenModifiers | 每个置位 bit 在SemanticTokensLegend.tokenModifiers中查表 |
3.4 位置编码(Position Encoding)约束
deltaStart与length必须使用客户端与服务端在initialize请求期间协商一致的编码方式进行编码。该协商机制定义于 3.19 的 initialize.md:
- 客户端通过
general.positionEncodings(类型PositionEncodingKind[])宣告支持的位置编码;为保持向后兼容,UTF-16 是强制编码,若数组中缺失'utf-16',服务端仍可假定客户端支持 UTF-16;省略时默认为['utf-16'](见该文件第 632-653 行)。 - 服务端通过
ServerCapabilities.positionEncoding返回选定的编码;若客户端未提供任何编码,服务端唯一合法的返回值是'utf-16',省略时同样默认为'utf-16'(见第 759-773 行)。
3.5 跨行与重叠约束
- 一个 token 是否可以跨多行,由客户端能力
multilineTokenSupport决定。若不支持跨行,token 长度超过行尾时,应视为 token 在行尾结束,不会折行到下一行。 - 客户端能力
overlappingTokenSupport决定 token 之间是否允许重叠。
四、编码实战:从绝对位置到数字数组
规范用 3 个 token 的完整示例演示编码全过程。假设文件中有 3 个不重叠的单行 token:
{ line: 2, startChar: 5, length: 3, tokenType: "property", tokenModifiers: ["private", "static"] }, { line: 2, startChar: 10, length: 4, tokenType: "type", tokenModifiers: [] }, { line: 5, startChar: 2, length: 7, tokenType: "class", tokenModifiers: [] }第一步:设计 Legend
Legend 必须在注册时预先提供,并覆盖所有可能的类型与修饰符。本示例使用:
{ tokenTypes: ['property', 'type', 'class'], tokenModifiers: ['private', 'static'] }第二步:类型/修饰符转整数
利用 legend 将类型和修饰符编码为整数(类型查索引、修饰符用位标志):
{ line: 2, startChar: 5, length: 3, tokenType: 0, tokenModifiers: 3 }, { line: 2, startChar: 10, length: 4, tokenType: 1, tokenModifiers: 0 }, { line: 5, startChar: 2, length: 7, tokenType: 2, tokenModifiers: 0 }这里tokenType: 0对应tokenTypes[0](property),tokenModifiers: 3对应private与static两个修饰符。
第三步:转为相对位置
将每个 token 相对前一个 token 表示。第二个 token 与第一个同行,startChar取差值10 - 5;第三个 token 与第二个不同行,startChar保持不变:
{ deltaLine: 2, deltaStartChar: 5, length: 3, tokenType: 0, tokenModifiers: 3 }, { deltaLine: 0, deltaStartChar: 5, length: 4, tokenType: 1, tokenModifiers: 0 }, { deltaLine: 3, deltaStartChar: 2, length: 7, tokenType: 2, tokenModifiers: 0 }第四步:内联为单一数组
将每个 token 的 5 个字段平铺进单个数组,得到内存友好的表示:
// 1st token, 2nd token, 3rd token [ 2,5,3,0,3, 0,5,4,1,0, 3,2,7,2,0 ]五、Delta 机制:增量更新的数学原理
现在假设用户在文件开头输入了一个新的空行,文件中的 token 变为:
{ line: 3, startChar: 5, length: 3, tokenType: "property", tokenModifiers: ["private", "static"] }, { line: 3, startChar: 10, length: 4, tokenType: "type", tokenModifiers: [] }, { line: 6, startChar: 2, length: 7, tokenType: "class", tokenModifiers: [] }执行同样的变换后得到:
// 1st token, 2nd token, 3rd token [ 3,5,3,0,3, 0,5,4,1,0, 3,2,7,2,0]注意:只有第一个数字从2变成了3。delta 就定义在这两个数字数组之间,不解释这些数字的任何语义——这与服务端发送给客户端用于修改文件内容的文本编辑(text edits)是同一思想:字符级编辑不假设字符的含义。
于是,[ 2,5,3,0,3, 0,5,4,1,0, 3,2,7,2,0 ]变换为[ 3,5,3,0,3, 0,5,4,1,0, 3,2,7,2,0]只需一条编辑描述:
{ start: 0, deleteCount: 1, data: [3] }即:将数组中的第一个数字2替换为3。
5.1 多编辑的应用算法
语义 token 编辑在概念上与文档上的文本编辑行为一致:如果一条编辑描述包含 n 个编辑,所有 n 个编辑都基于数字数组的同一状态 Sm,将它们整体移到状态 Sm+1。客户端在应用编辑时不能假定它们是有序的。一个简单可靠的算法是:先对编辑排序,再从数组的尾部向头部逐个应用,从而避免前面编辑影响后面编辑的偏移量。
六、客户端能力:SemanticTokensClientCapabilities
语义标记请求的客户端能力定义如下(属性名可选:textDocument.semanticTokens):
interface SemanticTokensClientCapabilities { /** * Whether the implementation supports dynamic registration. If this is set to * `true`, the client supports the new `(TextDocumentRegistrationOptions & * StaticRegistrationOptions)` return value for the corresponding server * capability as well. */ dynamicRegistration?: boolean; /** * Which requests the client supports and might send to the server * depending on the server's capability. Please note that clients might not * show semantic tokens or degrade some of the user experience if a range * or full request is advertised by the client but not provided by the * server. If, for example, the client capability `requests.full` and * `request.range` are both set to true but the server only provides a * range provider, the client might not render a minimap correctly or might * even decide to not show any semantic tokens at all. */ requests: ClientSemanticTokensRequestOptions; /** * The token types that the client supports. */ tokenTypes: string[]; /** * The token modifiers that the client supports. */ tokenModifiers: string[]; /** * The formats the client supports. */ formats: TokenFormat[]; /** * Whether the client supports tokens that can overlap each other. */ overlappingTokenSupport?: boolean; /** * Whether the client supports tokens that can span multiple lines. */ multilineTokenSupport?: boolean; /** * Whether the client allows the server to actively cancel a * semantic token request, e.g. supports returning * ErrorCodes.ServerCancelled. If a server does so, the client * needs to retrigger the request. * * @since 3.17.0 */ serverCancelSupport?: boolean; /** * Whether the client uses semantic tokens to augment existing * syntax tokens. If set to `true`, client side created syntax * tokens and semantic tokens are both used for colorization. If * set to `false`, the client only uses the returned semantic tokens * for colorization. * * If the value is `undefined` then the client behavior is not * specified. * * @since 3.17.0 */ augmentsSyntaxTokens?: boolean; }6.1 请求选项子类型
客户端具体支持哪些请求,通过requests字段细分:
export type ClientSemanticTokensRequestOptions = { /** * The client will send the `textDocument/semanticTokens/range` request if * the server provides a corresponding handler. */ range?: boolean | { }; /** * The client will send the `textDocument/semanticTokens/full` request if * the server provides a corresponding handler. */ full?: boolean | ClientSemanticTokensRequestFullDelta; };其中full还可以进一步声明是否支持 delta:
export type ClientSemanticTokensRequestFullDelta = { /** * The client will send the `textDocument/semanticTokens/full/delta` request if * the server provides a corresponding handler. */ delta?: boolean; };6.2 能力协商的坑:请求能力与服务端能力必须匹配
规范在requests字段的注释中明确提醒了一个实战陷阱:客户端声明了requests.full与requests.range为 true,但服务端只提供 range 提供者时,客户端可能无法正确渲染 minimap,甚至决定完全不显示任何语义标记。因此客户端声明什么、服务端就应尽量提供什么,能力协商要保持对称。
七、服务端能力:SemanticTokensOptions 与注册选项
服务端能力属性名(可选):semanticTokensProvider,类型为SemanticTokensOptions | SemanticTokensRegistrationOptions:
export interface SemanticTokensOptions extends WorkDoneProgressOptions { /** * The legend used by the server. */ legend: SemanticTokensLegend; /** * Server supports providing semantic tokens for a specific range * of a document. */ range?: boolean | { }; /** * Server supports providing semantic tokens for a full document. */ full?: boolean | SemanticTokensFullDelta; }服务端是否支持全文档 delta:
/** * Semantic tokens options to support deltas for full documents */ export type SemanticTokensFullDelta = { /** * The server supports deltas for full documents. */ delta?: boolean; };注册选项(用于动态注册)继承文本文档注册选项、语义标记选项与静态注册选项:
export interface SemanticTokensRegistrationOptions extends TextDocumentRegistrationOptions, SemanticTokensOptions, StaticRegistrationOptions { }关键点:由于注册选项统一处理 range、full 与 delta 三类请求,用于注册语义标记请求的方法统一是
textDocument/semanticTokens,而不是下面描述的具体方法之一。这一点在 3.19 metaModel.json 中也有印证:textDocument/semanticTokens/full、/full/delta、/range三个请求中,仅full与full/delta声明了SemanticTokensRegistrationOptions作为注册选项。
八、请求一:请求整个文件的语义标记(full)
请求:
- method:
textDocument/semanticTokens/full - params:
SemanticTokensParams
export interface SemanticTokensParams extends WorkDoneProgressParams, PartialResultParams { /** * The text document. */ textDocument: TextDocumentIdentifier; }响应:
- result:
SemanticTokens | null
export interface SemanticTokens { /** * An optional result ID. If provided and clients support delta updating, * the client will include the result ID in the next semantic token request. * A server can then, instead of computing all semantic tokens again, simply * send a delta. */ resultId?: string; /** * The actual tokens. */ data: uinteger[]; }- 部分结果(partial result):
SemanticTokensPartialResult
export interface SemanticTokensPartialResult { data: uinteger[]; }- error:请求过程中出现异常时,返回错误 code 与 message。
SemanticTokens.resultId是 delta 机制的关键:一旦服务端提供了 resultId 且客户端支持 delta 更新,客户端会在下一次请求中携带该 ID,服务端即可只发送 delta而非重新计算全部 token。
九、请求二:整个文件的语义标记 Delta(full/delta)
请求:
- method:
textDocument/semanticTokens/full/delta - params:
SemanticTokensDeltaParams
export interface SemanticTokensDeltaParams extends WorkDoneProgressParams, PartialResultParams { /** * The text document. */ textDocument: TextDocumentIdentifier; /** * The result ID of a previous response. The result ID can either point to * a full response or a delta response, depending on what was received last. */ previousResultId: string; }响应:
- result:
SemanticTokens | SemanticTokensDelta | null
export interface SemanticTokensDelta { readonly resultId?: string; /** * The semantic token edits to transform a previous result into a new * result. */ edits: SemanticTokensEdit[]; }其中每个编辑:
export interface SemanticTokensEdit { /** * The start offset of the edit. */ start: uinteger; /** * The count of elements to remove. */ deleteCount: uinteger; /** * The elements to insert. */ data?: uinteger[]; }- 部分结果:
SemanticTokensPartialResult | SemanticTokensDeltaPartialResult
export interface SemanticTokensDeltaPartialResult { edits: SemanticTokensEdit[]; }- error:请求过程中出现异常时,返回错误 code 与 message。
注意previousResultId可以指向上一次收到的任意响应(全量响应或 delta 响应均可),服务端据此计算从该状态到新状态的编辑序列。
十、请求三:请求指定范围的语义标记(range)
在两种场景下,只计算可见范围内的语义标记是有益的:
- 加速渲染:用户打开文件时,仅渲染可见区域以加快 UI 响应。此场景下服务端还应同时实现
textDocument/semanticTokens/full,以支持无闪烁滚动与 minimap 的语义着色。 - 全量计算代价过高:如果为整个文档计算语义标记过于昂贵,服务端可以只提供 range 调用。但此时客户端可能无法正确渲染 minimap,甚至决定完全不显示任何语义标记。
服务端的响应范围允许超出请求范围,但前提是:超出部分的语义标记必须完整且正确。如果位于范围起点或终点的 token 与请求范围只有部分重叠,服务端应在响应中包含这些 token。
请求:
- method:
textDocument/semanticTokens/range - params:
SemanticTokensRangeParams
export interface SemanticTokensRangeParams extends WorkDoneProgressParams, PartialResultParams { /** * The text document. */ textDocument: TextDocumentIdentifier; /** * The range the semantic tokens are requested for. */ range: Range; }响应:
- result:
SemanticTokens | null - 部分结果:
SemanticTokensPartialResult - error:请求过程中出现异常时,返回错误 code 与 message。
十一、服务端发起的全量刷新:workspace/semanticTokens/refresh
与前三个由客户端发起的请求不同,workspace/semanticTokens/refresh请求由服务端发送给客户端。服务端可借此要求客户端刷新由该服务端提供语义标记的编辑器;作为结果,客户端应要求服务端重新计算这些编辑器的语义标记。
典型场景:服务端检测到项目级的配置变更,需要重新计算所有语义标记。注意客户端仍保留延迟重算的自由——例如某编辑器当前不可见时,客户端可以推迟重算。
11.1 客户端能力
- 属性名(可选):
workspace.semanticTokens - 类型:
SemanticTokensWorkspaceClientCapabilities
export interface SemanticTokensWorkspaceClientCapabilities { /** * Whether the client implementation supports a refresh request sent from * the server to the client. * * Note that this event is global and will force the client to refresh all * semantic tokens currently shown. It should be used with absolute care * and is useful for situation where a server, for example, detects a project * wide change that requires such a calculation. */ refreshSupport?: boolean; }规范特别强调:该事件是全局的,会强制客户端刷新当前显示的所有语义标记,必须极其谨慎地使用。
11.2 请求定义
- method:
workspace/semanticTokens/refresh - params:无
- result:
void - error:请求过程中出现异常时,返回错误 code 与 message。
十二、请求方法全景速查
结合 3.19 metaModel.json 中的请求元数据,可将语义标记相关的四条请求及其能力归属整理如下:
| 请求方法 | 方向 | 客户端能力 | 服务端能力 | 注册选项 |
|---|---|---|---|---|
textDocument/semanticTokens/full | 客户端 → 服务端 | textDocument.semanticTokens | semanticTokensProvider | SemanticTokensRegistrationOptions |
textDocument/semanticTokens/full/delta | 客户端 → 服务端 | textDocument.semanticTokens.requests.full.delta | semanticTokensProvider.full.delta | SemanticTokensRegistrationOptions |
textDocument/semanticTokens/range | 客户端 → 服务端 | textDocument.semanticTokens.requests.range | semanticTokensProvider.range | 无 |
workspace/semanticTokens/refresh | 服务端 → 客户端 | workspace.semanticTokens.refreshSupport | 无 | 无 |
十三、实现要点总结
结合规范文档与仓库源码,服务端实现语义标记时的关键决策清单如下:
- Legend 先行:在注册/能力声明时提供完整的
legend,覆盖所有会出现的类型与修饰符;类型与修饰符尽量取自预定义枚举(SemanticTokenTypes、SemanticTokenModifiers),自定义值需在客户端能力中声明。 - 统一采用
relative格式:目前协议唯一指定的格式,按"5 整数元组 + 相对位置"编码;tokenType保持小于 65536。 - 位置编码协商:
deltaStart与length依据initialize阶段协商的positionEncoding(默认为 UTF-16)编码,参考 initialize.md。 - 尊重客户端边界能力:
multilineTokenSupport为 false 时 token 不得跨行;overlappingTokenSupport为 false 时 token 不得重叠。 - 善用 resultId + delta:full 响应附带
resultId,客户端下次请求携带previousResultId,服务端返回SemanticTokensEdit[]描述数字数组的变换;多编辑应用采用"排序 + 从后往前应用"算法。 - range 与 full 的能力匹配:若只提供 range,需接受 minimap 渲染可能降级或完全不显示的后果;若返回超出请求范围的数据,必须保证其完整正确。
- 刷新请求谨慎使用:
workspace/semanticTokens/refresh是全局操作,仅在检测到项目级配置变更等场景使用,并依赖客户端refreshSupport能力。
以上全部内容均可在仓库对应规范文档与 3.19 metaModel.json 中交叉验证,读者可继续查阅 3.19 规范总文档 获取语义标记与其他请求能力的完整上下文。
- 开发工具
【免费下载链接】language-server-protocol
Defines a common protocol for language servers.
相关推荐
Dart SDK analysis_server 语义高亮实现机制:从 HighlightRegion 到 LSP SemanticTokens 的完整链路
Dart SDK analysis_server 语义高亮实现机制:从 HighlightRegion 到 LSP SemanticTokens 的完整链路 本
编程语言编译器语言运行时标准库开发工具Dart 语义高亮(Semantic Highlighting)设计解析:从 language fidelity 原则到 LSP semanticTokens 实现
Dart 语义高亮(Semantic Highlighting)设计解析:从 language fidelity 原则到 LSP semanticTokens
编程语言编译器语言运行时标准库开发工具如何在Helix编辑器中配置LSP客户端:提升代码编辑效率的完整指南
如何在Helix编辑器中配置LSP客户端:提升代码编辑效率的完整指南 Helix是一款后现代模态文本编辑器,以其高效的编辑体验和强大的功能而受到开发者喜爱。其中
代码编辑器开发工具CLI
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考