news 2026/9/17 12:54:56

为 LSP 自动补全而生的增量式语法树:postgres_lsp 的 tree-sitter 文法设计指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
为 LSP 自动补全而生的增量式语法树:postgres_lsp 的 tree-sitter 文法设计指南

为 LSP 自动补全而生的增量式语法树:postgres_lsp 的 tree-sitter 文法设计指南

【免费下载链接】postgres_lspA Language Server for Postgres项目地址: https://gitcode.com/GitHub_Trending/po/postgres_lsp

导读

postgres_lsp(pgls)是专为 Postgres 打造的 Language Server,其自动补全(autocompletion)与 hover 信息等核心 LSP 能力,全部建立在一棵独特的 tree-sitter 语法树上。本指南以仓库中的 crates/pgls_treesitter_grammar/GRAMMAR_GUIDELINES.md 为骨架,结合 grammar.js 源码与测试用例,系统讲解这棵“非传统”语法树的三大设计原则——具体化标识符类型、部分文法(partial grammar)、规则结束标记("end"字段)——以及五条落地编写守则。读完本文,你将理解为什么 SQL 语法高亮文法不能直接拿来驱动 LSP,以及如何在 tree-sitter 中构建一棵“边输入边可用”的增量解析树。


一、定位:这不是一棵用于语法高亮的文法树

绝大多数 tree-sitter 文法的目标是:解析一份写完了的、语法正确的源文件,然后做语法高亮。而 grammar.js 文件头的注释明确写道:

A grammar specifically designed for use with the Postgres Language Server by Supabase-Community. It is tailored to provide autocompletions and other LSP features.

即这棵文法的设计目的完全不同——它要紧密配合 LSP 特性工作,主要在自动补全建议和 hover 信息这两个场景发挥作用。这些特性必须在 SQL正在被输入的过程中whilethe SQL is being typed)就可用,并且要能提供“最具体的情报”(the most specific intel possible)。

这意味着解析器面对的不是完整语句,而是这样的中间状态:

  • insert into |(光标处,想提示表名)
  • select public.us|(不确定us是列还是表)
  • select * from users order |(只想提示by

围绕“解析未完成语句并给出最有价值的补全”这个目标,仓库做出了三个关键设计选择(也是 GRAMMAR_GUIDELINES.md 的三个正文章节):

  1. 使用具体化的标识符类型(Use Specific Identifier Types);
  2. 文法必须支持部分匹配(We Need Partial Grammars);
  3. 文法必须能判断一个规则何时“结束”(We Need to Know When a Rule Finishes)。

下文逐一展开。


二、设计原则一:用具体化的标识符类型替换单一identifier

2.1 单一identifier的困境

在 pgls 所 fork 的原始文法中,只有一种identifier节点。于是:

select email from auth.users

被解析为:

keyword_select identifier keyword_from object_reference object_reference: identifier "." identifier

问题在于:标识符的种类只能靠上下文推断。如果处在select子句中,它可能是列或函数;如果处在from子句中,它可能是表或函数……所有语义信息都要事后从父节点、兄弟节点里反推,补全逻辑变得脆弱且低效。

2.2 具体化的标识符与引用

现在的文法把标识符按角色细分成了多种节点类型:

类别节点类型用途示例
标识符(identifiers)schema_identifierpublic.users中的public
function_identifierauth.uid()中的uid
type_identifier类型定义/引用中的类型名
column_identifierusers.email中的email
table_identifierusers.email中的users
…(还有role_identifierpolicy_identifier等)
引用(references)function_reference可带限定符的函数引用
table_reference可带限定符的表引用
column_reference可带限定符的列引用
兜底(ambiguous)object_referenceany_identifier无法更具体时使用

于是select email from auth.users现在被解析为:

keyword_select column_identifier keyword_from table_reference table_reference: schema_identifier "." table_identifier

这一改变直接服务于补全:在select子句中,我们只建议列(column);在table_identifier的位置上,我们只建议匹配该 schema 的表。

2.3 引用的多限定变体

引用节点(references)用于任何标识符可能被限定(qualified)的场景,例如:

select public.users.email from ... select auth.uid()

为了覆盖用户输入过程中的各种状态,每个引用规则都匹配 2~3 种限定变体。以源码 grammar.js 中的table_referencecolumn_reference为例:

table_reference: ($) => choice( seq( field("table_reference_1of2", $.schema_identifier), ".", field("table_reference_2of2", $.table_identifier), ), field("table_reference_1of1", $.any_identifier), ), column_reference: ($) => choice( seq( field("column_reference_1of3", $.schema_identifier), ".", field("column_reference_2of3", $.table_identifier), ".", field("column_reference_3of3", $.column_identifier), ), seq( field("column_reference_1of2", $.any_identifier), ".", field("column_reference_2of2", $.any_identifier), ), field("column_reference_1of1", $.any_identifier), ),

function_referencetype_referenceobject_reference(见 grammar.js)遵循同样的“从具体到兜底”的 choice 结构。

值得一提的是any_identifier的底层实现(_any_identifier,grammar.js)不止匹配普通标识符,还兼容:

  • 双引号字符串("auth"."users"这类带引号标识符);
  • SQL 参数:/[:$@?][a-zA-Z_][0-9a-zA-Z_]*/(覆盖:param$1@name?foo等驱动参数风格);
  • 反引号包裹的标识符。

这对于 hover 与补全“只见前缀不见全名”的场景至关重要。

2.4 从三个输入片段看解析过程

文档给出了三种典型输入状态下的解析结果,直观展示了“能确定多少就确定多少”的思路:

输入select pu|(前缀可能是 schema、别名、表或列名):

column_reference any_identifier (@column_reference_1of1)

输入select public.us|us可能是列或表名,public可能是别名、schema 或表):

column_reference any_identifier (@column_reference_1of2) "." any_identifier (@column_reference_2of2)

输入select public.users.em|(三个位置都能唯一确定):

column_reference: schema_identifier (@column_reference_1of3) "." table_identifier (@column_reference_2of3) "." column_identifier (@column_reference_3of3)

三、用 TreeSitter 字段收窄可能性:1ofN字段命名约定

上文看到column_reference里出现了一组奇怪的字段名:column_reference_1of1column_reference_1of2column_reference_2of2column_reference_1of3……

这正是第二个关键设计:利用 TreeSitter 的字段(fields)来编码“位置信息”

当解析出的节点是any_identifier时,我们确实不知道它的具体种类,但我们知道它出现在限定链中的第几个位置

  • 1of1:单独的标识符,可能是列、schema 或表;
  • 1of2:两段限定的第一段,可能是 schema、别名或表;
  • 2of2:两段限定的第二段,绝不可能是 schema(它前面已经有别的标识符了),只可能是列或表;
  • 1of3/2of3/3of3:三段限定下,语义已被完全锁定为 schema / table / column。

补全逻辑拿到这些字段名后,就能把“候选集合”快速收窄。例如看到2of2,就不必再向用户建议 schema;看到3of3,就直接按列名补全。

这种“以字段名编码解析歧义信息”的手法,是本文法最精巧的设计之一,也是后续"end"字段约定的同源思路。


四、设计原则二:部分文法(Partial Grammar)与partialSeq

4.1 问题:tree-sitter 只在无错误态下可靠解析

tree-sitter 的解析器在遇到错误时,会产生 ERROR 节点,此时查询(query)结果不可靠。但对 LSP 而言,用户输入必然是残缺的。

看一个简化版的insert规则:

insert: $ => seq( $.keyword_insert, $.keyword_into, $.table_reference, $.keyword_values, paren_list($._expression) ),

如果用户只输入了insert into |,我们希望立刻建议表名。但由于缺少values (...),整棵树进入错误态,补全无法可靠工作。

4.2partialSeq:要求首 token,其余全部可选

解决方法是让文法尽可能早地匹配规则,并把后续 token 视为可选。仓库提供了partialSeq辅助函数(完整实现见 grammar.js):

insert: $ => partialSeq( $.keyword_insert, $.keyword_into, $.table_reference, $.keyword_values, paren_list($._expression) ),

它等价于展开成:

insert: $ => prec.right(seq( $.keyword_insert, optional( seq( $.keyword_into, optional( seq( $.table_reference, optional( seq( $.keyword_values, optional( paren_list($._expression) ) ) ) ) ) ) ) ))

也就是说,从insert |开始,输入就会被匹配为 insert 规则,而文法清楚地知道接下来“可选地”出现什么 token。

4.3 右结合优先级与部分匹配

partialSeq使用**右优先级(right precedence)**展开。这样做的原因文档中给出了实例:对于select * from table left join,我们希望把最后两个 token 解析成一个完整的left_join子句($.keyword_left $.keyword_join),而不是拆成“一个只有left的 left_join”和“一个只有join的 join”。

partialSeq在源码中广泛使用。例如 grammar.js 的_explain_statementpartialSeq($.keyword_explain, ...)explain单关键词即可成句;with_querycteselectarraytable_statement等规则同样如此。

4.4 代价:冲突增多,需要 precedence / conflicts 控制

部分匹配的代价是显而易见的:既然一个关键词就能识别一条规则,文法冲突必然增多。例如:

alter table something rename |

现在既可能命中rename_object,也可能命中rename_column规则。

文档给出的处理手段有两种:

  • tree-sitter conflicts:显式声明冲突集合。但注意“添加太多 conflicts 会让 tree-sitter 变慢”;
  • precedence(优先级):通过结合优先级消解。

grammar.js 中的conflicts列表正体现了这一代价,例如:

conflicts: ($) => [ [$.any_identifier, $.column_identifier], [$.any_identifier, $.schema_identifier], [$.any_identifier, $.schema_identifier, $.table_identifier], [$.table_reference, $.column_reference], [$.function_reference, $.table_reference], [$.rename_column, $.rename_object], // ... ],

4.5 配套组合子:围绕“可选列表”的家族

partialSeq外,grammar.js 还定义了一组配套的组合子,共同支撑部分解析:

  • comma_list(rule, requireFirst):逗号分隔列表。requireFirst=true时首元素必选,否则整段可空(optional)——对应values (1, 2, |这样的中间态;
  • paren_list(rule, requireFirst)wrapped_in_parenthesis(comma_list(...)),带括号的列表;
  • token_delimited_list(rule, delimiter, requireFirst):以 token 分隔(如union/except/intersect)的列表,分隔符之后不要求立即构成完整序列,因此内部直接用partialSeq实现;
  • wrapped_in_parenthesis(rule)seq("(", rule, field("end", ")")),注意右括号被标记为"end"字段;
  • optional_parenthesis(rule)prec.right(choice(rule, wrapped_in_parenthesis(rule)))
  • parametric_type($, rule, params):处理numeric(p, s)varchar(n)这类带参数类型,为每个参数生成带字段名的节点。

这些组合子让“输入到一半”的列表、括号、类型参数都能保持无错误态,是增量补全的地基。


五、设计原则三:让文法“知道规则何时结束”——"end"字段

5.1 场景:order |之后只该提示by

我们希望只在有意义的地方建议关键词。当用户输入:

select * from users order |

唯一的补全建议应该是by

但有了partialSeq之后这变难了:关键词order本身就足以被解析为$.order规则,文法并不强制要求后面出现by或排序列。于是以下输入都会产生“无错误”的树:

  • select * from users order where—— 末尾有合法的 order 和合法的 where;
  • select * from users order join—— 末尾有合法的 order 和合法的 join;
  • select * from users order group—— 末尾有合法的 order 和合法的 group;
  • select * from users order limit—— 末尾有合法的 order 和合法的 limit。

5.2 用字段标记子句“真正的结束点”

为了过滤掉“文法上合法、但真实 SQL 中非法”的关键词,文法使用字段名标记子句真正的结束order_by规则如下(grammar.js 处的等价定义):

order_by: partialSeq( $.keyword_order, $.keyword_by, field("end", comma_list($.order_target, true)) ),

这样,order|确实被解析为order_by子句,但由于它没有携带end字段的子节点,我们知道该子句尚未完成。补全逻辑据此过滤掉那些“开启新子句”的关键词——只要上一个子句还没结束,就不建议开启新子句的关键词。


六、五条文法编写守则

为满足“每个规则都知道何时结束”的要求,文档总结出五条必须遵守的守则。这些守则构成了对本仓库文法做贡献时的硬性约定。

守则 1:一个分支内只能有一个"end"字段节点

Every branch in a clause can only ever haveonenode with an"end"field name.

多个可能的分支应该用choice分隔,并且每个分支的最后一个节点才标"end"

order_by里的order_target是教科书级示例:

order_target: ($) => choice( field("end", $._expression), seq( $._expression, seq( choice( field("end", $.direction), seq($.keyword_using, field("end", choice("<", ">", "<=", ">="))) ), optional($.order_target_nulls) ) ) ),

可以看到:第一个分支把"end"赋给$._expression(即order col就此结束);第二个分支则把"end"下沉到嵌套层的$.directionasc/desc)或比较运算符上。

守则 2:规则末尾的可选子句应当公开(public)

Optional clauses at the end of a rule should be public.

同样看order_target的例子:nulls关键词可能出现,也可能不出现。

  • 若不出现,子句在$.direction或比较运算符处结束;
  • 若出现,则应在$.keyword_first$.keyword_last处结束。

为了区分这两种结束点,必须开启一个新的公开子句order_target_nulls

order_target_nulls: ($) => seq( $.keyword_nulls, field("end", choice($.keyword_first, $.keyword_last)) ),

当解析器遇到nulls,它进入order_target_nulls子句:此时$.order_target已结束,但解析器要停留在order_target_nulls上,直到打开例如$.limit子句之前。

(对比:在 grammar.js 中可以看到大量_前缀的隐藏辅助规则,如_not_null_primary_key_if_exists_or_replace等——它们被用于组合关键词,而不是承担“结束判定”职责的公开子句。)

守则 3:每个公开规则都应该有"end"字段

Each public rule should have an"end"field name.

这是解析器判断子句是否结束的唯一途径。以alias子句为例:

alias: ($) => choice( partialSeq($.keyword_as, field("end", $.any_identifier)), field("end", $.any_identifier) ),

如果没有这两个endtoken,用户输入select * from auth.users u |时,alias子句永远不被标记为完成,补全逻辑就永远不会建议任何可补全的关键词。

守则 4:小心隐藏子句(hidden clauses)中的"end"token

Be careful with"end"tokens in hidden clauses.

隐藏子句(以下划线开头的规则)会被“展开/散落”到父规则中。文档给出了一个假设的坏例子:

select: ($) => partialSeq( $.keyword_select, $.column_identifier, optional($._alias), // 假设 _alias 是隐藏的 $.keyword_from, field("end", $.table_reference), );

此时用户输入select email as e|,展开后的树形是:

keyword_select column_identifier keyword_as any_identifier(@end)

@end出现在any_identifier上,导致select 语句被过早判定为已完成——尽管用户才刚刚打完别名。

反过来,如果确实想让table_reference的结束点成立,可以把它做成隐藏的$._table_reference并在其中放置"end"节点,子句依然会在正确的位置结束。因此结论是:

隐藏子句里是否放"end"字段,必须考虑它在所有可能的父语句位置上的语义是否都成立。

守则 5:单 token 规则不需要"end"字段

Single-Token rules don't need an"end"field.

有一类子句始终只由一个(以空白分隔的)token 构成,它们不需要partialSeq,也天然在匹配完成时结束。文档列举的例子包括$.literal$.bang$.any_identifier等。

测试文件 partial_no_errors.rs 中有一个SINGLE_TOKEN_RULES列表,正好是这条守则的工程化落点:

pub static SINGLE_TOKEN_RULES: &[&str] = &[ "any_identifier", "column_identifier", "schema_identifier", "table_identifier", "function_identifier", "type_identifier", "type", "role_identifier", "policy_identifier", "object_reference", "table_reference", "column_reference", "function_reference", "type_reference", "literal", "term", "parameter", "direction", "field", "bang", "op_other", "op_unary_other", "comment", "marginalia", ];

WITHOUT_END_RULES = ["program", "statement"]则标注了唯二两个不需要end的复合规则(因为它们是顶层容器,本身没有“被包含在更大子句中”的问题)。


七、从文法到 LSP:查询层与测试如何消费这棵树

7.1 查询层:pgls_treesitter中的 TS Query

文法生成的语言(pgls)由 pgls_treesitter_grammar crate 构建,消费方是 pgls_treesitter crate。后者通过 tree-sitter Query 直接在语法树上提取结构化信息。

以 relations.rs 为例,查询关系(表)的核心就一行:

static QUERY_STR: &str = r#" (table_reference) @ref "#;

正是因为文法在 grammar.js 中定义了table_reference: schema_identifier "." table_identifier | any_identifier,查询层才能稳定地从一个节点里分别提取schematable,再配合parts_of_reference_query拆分限定链。该文件下的测试覆盖了select * from usersselect * from public.usersselect * from "public"."users"insert into auth.accounts (...)alter table public.users ...等真实语句,验证 schema 与表名的正确提取(见 relations.rs)。

类似的查询模块还包括select_columnswhere_columnsinsert_columnstable_aliasesobject_referencesparameters(见 queries 目录),它们都直接消费本文所述的具体化节点与字段。

7.2 测试层:快照与“无错误”回归

仓库为这棵特殊文法准备了多层测试:

  • grammar_tests.rs:用tree_sitter::Parser解析示例 SQL,通过 insta 生成快照(snap),将整棵树的形态固化下来。测试样本包括select * from auth.users;update auth.users set email = 'my@mail.com';、多表 join、带引号标识符、带括号子查询等;
  • partial_no_errors.rs:核心回归测试,专门保证任何“未写完”的片段都不会产生错误态——它直接引用了pgls_query/vendor/libpg_query/test/sql/postgres_regress/select.sql等 Postgres 回归测试语料,把真实世界的大段 SQL 切割成前缀,逐一断言“部分解析无 ERROR”。

这些测试共同守住了一个契约:任何时刻、任意输入前缀,这棵树都必须是干净、可查询的——这正是增量补全成立的前提。


八、总结:三个原则、五条守则,一棵为 LSP 而生的树

回顾整份指南,pgls 的 tree-sitter 文法与传统语法高亮文法的根本差异在于它服务的目标是“输入中的 SQL”而非“写完的 SQL”:

  1. 具体化标识符:用schema_identifier/table_identifier/column_identifier/function_identifier等细分节点 +*_reference限定引用,把语义信息提前编码进语法树,配合1ofN字段命名约定,让补全逻辑无需猜测;
  2. 部分文法partialSeq让规则在首个关键词出现时即可成句,其余部分逐层可选,配合comma_listparen_listtoken_delimited_list等组合子,保证输入途中永不进入错误态;代价是冲突增多,需用conflicts与 precedence 平衡;
  3. 显式结束标记"end"字段告诉解析器与补全逻辑“一个子句真正结束了”,从而过滤掉非法关键词建议;五条编写守则(单分支单end、末尾可选子句公开、公开规则必有end、慎用隐藏子句中的end、单 token 规则免end)是维护这棵文法时的硬性约束。

如果你要为 postgres_lsp 贡献新的语法规则,最直接的切入点就是 GRAMMAR_GUIDELINES.md 这份指南 + grammar.js 中现成的组合子与规则范本,并让新规则通过 partial_no_errors.rs 与快照测试的双重校验——这棵树的每一根枝杈,最终都服务于编辑器里那个不断闪烁的光标。

【免费下载链接】postgres_lspA Language Server for Postgres项目地址: https://gitcode.com/GitHub_Trending/po/postgres_lsp

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 12:51:34

Excel下载文件名乱码、自动重命名问题全解析:前后端最佳实践

干这行这么多年&#xff0c;我敢打赌每个做开发或搞数据分析的朋友&#xff0c;都经历过这么一出&#xff1a;明明在系统里点了“导出报表”&#xff0c;浏览器“咔哒”一下下载了个文件&#xff0c;结果打开下载目录一看&#xff0c;文件名要么是浏览器自动生成的一串时间戳数…

作者头像 李华
网站建设 2026/9/17 12:51:33

裸金属云渲染:如何用物理机满血算力解决渲染效率与成本难题

干渲染这行的人&#xff0c;最怕的从来不是审美不够&#xff0c;而是机器不争气。场景一复杂&#xff0c;采样一拉高&#xff0c;本地工作站的CPU直接满载&#xff0c;风扇声音从嗡嗡变成嘶吼&#xff0c;画面转一圈要等半分钟&#xff0c;出图一张动辄一两个小时。到了交付周&…

作者头像 李华
网站建设 2026/9/17 12:48:38

Microduck四足为何不选ROS?从成本、实时控制到micro-ROS的选型逻辑

第一次拿到 Microduck 这台小四足的时候&#xff0c;我下意识翻了翻它的固件仓库&#xff0c;想看看底层到底跑的是什么系统。结果就和很多朋友的第一反应一样——怎么没有 ROS&#xff1f;跟着就有人问了一个很尖锐的问题&#xff1a;399 美元的机器人&#xff0c;为什么宁愿自…

作者头像 李华
网站建设 2026/9/17 12:47:48

1G到5G演进本质:从语音通信到确定性连接

1. 从“打电话的年代”到“万物互联的现在”&#xff1a;为什么我们得重新理解“G”这个字母你有没有试过&#xff0c;在地铁里刷短视频突然卡成PPT&#xff0c;而旁边人却在用手机开4K直播&#xff1f;或者刚买的新路由器标着“5G Wi-Fi”&#xff0c;结果发现和运营商说的“5…

作者头像 李华
网站建设 2026/9/17 12:44:32

OBS Studio 运行库报错、升级后打不开?三档完整修复指南

OBS Studio 运行库报错、升级后打不开&#xff1f;三档完整修复指南 【免费下载链接】obs-studio OBS Studio - Free and open source software for live streaming and screen recording 项目地址: https://gitcode.com/GitHub_Trending/ob/obs-studio 升级 OBS Studio…

作者头像 李华
网站建设 2026/9/17 12:44:30

Hyper-Extract 架构深潜:三层架构与数据流完全解析

Hyper-Extract 架构深潜&#xff1a;三层架构与数据流完全解析 【免费下载链接】Hyper-Extract Hypergraph is more powerful. Transform unstructured text into structured knowledge with LLMs. Graphs, hypergraphs, and spatio-temporal extractions — with one command.…

作者头像 李华