为 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_lsppgls是专为 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。下文逐一展开。二、设计原则一用具体化的标识符类型替换单一identifier2.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 具体化的标识符与引用现在的文法把标识符按角色细分成了多种节点类型类别节点类型用途示例标识符identifiersschema_identifierpublic.users中的publicfunction_identifierauth.uid()中的uidtype_identifier类型定义/引用中的类型名column_identifierusers.email中的emailtable_identifierusers.email中的users…还有role_identifier、policy_identifier等引用referencesfunction_reference可带限定符的函数引用table_reference可带限定符的表引用column_reference可带限定符的列引用兜底ambiguousobject_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_identifiergrammar.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与partialSeq4.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.jsinsert: $ 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)逗号分隔列表。requireFirsttrue时首元素必选否则整段可空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 和合法的 whereselect * from users order join—— 末尾有合法的 order 和合法的 joinselect * from users order group—— 末尾有合法的 order 和合法的 groupselect * 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 anendfield 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规则末尾的可选子句应当公开publicOptional clauses at the end of a rule should be public.同样看order_target的例子nulls关键词可能出现也可能不出现。若不出现子句在$.direction或比较运算符处结束若出现则应在$.keyword_first或$.keyword_last处结束。为了区分这两种结束点必须开启一个新的公开子句order_target_nullsorder_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 anendfield 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中的endtokenBe careful withendtokens 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 dont need anendfield.有一类子句始终只由一个以空白分隔的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 mymail.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),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →