为 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 的三个正文章节):
- 使用具体化的标识符类型(Use Specific Identifier Types);
- 文法必须支持部分匹配(We Need Partial Grammars);
- 文法必须能判断一个规则何时“结束”(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_identifier | public.users中的public |
function_identifier | auth.uid()中的uid | |
type_identifier | 类型定义/引用中的类型名 | |
column_identifier | users.email中的email | |
table_identifier | users.email中的users | |
…(还有role_identifier、policy_identifier等) | ||
| 引用(references) | function_reference | 可带限定符的函数引用 |
table_reference | 可带限定符的表引用 | |
column_reference | 可带限定符的列引用 | |
| 兜底(ambiguous) | object_reference、any_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_reference与column_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_reference、type_reference、object_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_1of1、column_reference_1of2、column_reference_2of2、column_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_statement用partialSeq($.keyword_explain, ...)让explain单关键词即可成句;with_query、cte、select、array、table_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"下沉到嵌套层的$.direction(asc/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,查询层才能稳定地从一个节点里分别提取schema和table,再配合parts_of_reference_query拆分限定链。该文件下的测试覆盖了select * from users、select * from public.users、select * from "public"."users"、insert into auth.accounts (...)、alter table public.users ...等真实语句,验证 schema 与表名的正确提取(见 relations.rs)。
类似的查询模块还包括select_columns、where_columns、insert_columns、table_aliases、object_references、parameters(见 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”:
- 具体化标识符:用
schema_identifier/table_identifier/column_identifier/function_identifier等细分节点 +*_reference限定引用,把语义信息提前编码进语法树,配合1ofN字段命名约定,让补全逻辑无需猜测; - 部分文法:
partialSeq让规则在首个关键词出现时即可成句,其余部分逐层可选,配合comma_list、paren_list、token_delimited_list等组合子,保证输入途中永不进入错误态;代价是冲突增多,需用conflicts与 precedence 平衡; - 显式结束标记:
"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),仅供参考