postgres_lsp 自定义 lint 规则开发指南:从命名、实现、配置到测试的完整流程
2026/9/18 0:11:33 网站建设 项目流程

postgres_lsp 自定义 lint 规则开发指南:从命名、实现、配置到测试的完整流程

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

导读

本文以 postgres_lsp 项目的pgls_analysercrate 为核心,系统讲解如何在其基于 libpg_query 抽象语法树(AST)的 lint 基础设施上创建一条全新的安全类(safety)规则。你将掌握规则命名约定、declare_lint_rule!宏的声明语法、LinterRuletrait 的run实现方式、serde驱动的规则选项配置、文档编写规范、快照测试流程,以及借助just工具链与代码生成脚本自动化完成全流程的实战方法。读完本文,你可以独立为该项目贡献一条经过完整测试、文档齐备且可被postgres-language-server.jsonc配置的 lint 规则。

背景:pgls_analyser在项目中的定位

postgres_lsp 是一个面向 Postgres 的语言服务器,其代码仓库由多个 crate 组成。其中crates/pgls_analyser是分析器核心 crate,负责对 SQL 语句进行 lint 检查;crates/pgls_analyse则提供宏与基础类型(如declare_lint_rule!RuleMetaMetadataRegistryAnalysisFilter等)。本文所指的“Analyser”即 crates/pgls_analyser 目录,其 Cargo.toml 中依赖了pgls_query(基于 libpg_query 的 AST 表示)、pgls_diagnostics(诊断类型与渲染)、pgls_console(markup 输出)等 crate。

从源码结构看,pgls_analyser的规则按“组(group)”组织,目前规则全部位于src/lint/safety/目录下(对应“safety”组),已有banDropColumnaddingFieldWithDefaultrequireConcurrentIndexCreation等 50 余条规则,每条规则对应一个.rs文件。核心入口Analyser定义在 src/lib.rs:它接收AnalyserParams(拆分后的 SQL 语句列表、可选的schema_cache)与AnalyserConfigLinterOptionsAnalysisFilter),内部通过LinterRuleRegistry::builder(&filter)构建规则注册表,然后对每条语句依次执行所有已启用规则的run函数,汇总返回Vec<LinterDiagnostic>。这也是“规则针对每条语句运行”这一模型的最直接源码证据。

规则命名约定:banuse前缀

在动手写代码前,必须先确定规则名称。项目遵循一套与规则语义强绑定的命名约定:

前缀语义适用场景示例
ban禁止某个单一概念规则的唯一意图是禁止某种写法banDropColumnbanTruncatebanUpdateWithoutWhere
use强制/要求某个单一概念规则的唯一意图是强制使用某种写法useMyRuleName(文档中的示例名)

规则名的格式为ban<Concept>/use<Concept>,即前缀加驼峰概念名。例如仓库中真实的 ban_drop_column.rs 声明了name: "banDropColumn"。需要说明的是,当前仓库已实现的规则以ban为主,use前缀是文档定义的约定方向;新增规则时应遵循同样的原则,让规则名本身就能说明意图。

just new-lintrule一键搭建规则骨架

由于一条规则需要同时创建和更新多个文件(规则实现、注册表、选项类型、文档、测试等),手动维护非常繁琐,项目提供了基于 Just 的命令来生成规则骨架。just不在 Rust 工具链中,需先用系统包管理器单独安装。

生成一条新 lint 规则的命令如下:

just new-lintrule safety useMyRuleName (<severity>)

其中severity是可选的,可选值为infowarnerror,缺省为error。从 justfile 的源码可以看到,该命令实际执行的是:

cargo run -p xtask_codegen -- new-lintrule --category=lint --name=useMyRuleName --group=safety --severity=<severity> just gen-lint

即先调用xtask/codegen生成规则相关文件,再运行gen-lint触发完整的代码生成流程。生成的骨架文件中,规则实现位于pgls_analyser/src/lint/safety/use_my_new_rule_name.rs,你需要在这个文件里完成规则逻辑。骨架会同时生成注册表、选项类型别名等配套代码(src/options.rs 中即为自动生成的各规则Options类型别名列表)。

[!TIP] 你不必在一个 PR 里把规则做到完美。项目鼓励先规划、再分多个 PR 逐步完善;如果对 API 还不熟悉,可以在 issue 中先描述计划。

实现规则的三支柱原则

项目对规则的信息传达有明确要求:规则应当对用户足够有信息量,并给出尽可能多的解释。写规则时须遵守三条“支柱(pillars)”:

  1. 告诉用户错误是什么:通常是诊断信息(diagnostic message)本身;
  2. 告诉用户为什么触发:通常通过附加节点(label/detail)实现;
  3. 告诉用户应该怎么做:通常用代码建议(code action)实现;若代码建议不适用,则用 note 告知用户修复方式。

这三条支柱直接映射到诊断类型的构建 API。查看 src/linter_rule.rs 可知,LinterDiagnostic提供了一系列链式方法:LinterDiagnostic::new(category, span, title)创建诊断并设置标题(对应支柱 1);label(span, msg)/detail(span, msg)附加说明(对应支柱 2);note(msg)footer_list(message, list)warning(msg)等追加页脚信息(对应支柱 3 的 note 路径)。诊断内部通过RuleAdvice结构(src/linter_rule.rs)统一承载 details、notes 与 suggestion list,并最终由Advices::record交给诊断渲染器输出。

以真实的ban_drop_column为例(ban_drop_column.rs):

impl LinterRule for BanDropColumn { type Options = (); fn run(ctx: &LinterRuleContext<Self>) -> Vec<LinterDiagnostic> { let mut diagnostics = Vec::new(); if let pgls_query::NodeEnum::AlterTableStmt(stmt) = &ctx.stmt() { for cmd in &stmt.cmds { if let Some(pgls_query::NodeEnum::AlterTableCmd(cmd)) = &cmd.node && cmd.subtype() == pgls_query::protobuf::AlterTableType::AtDropColumn { diagnostics.push(LinterDiagnostic::new( rule_category!(), None, markup! { "Dropping a column may break existing clients." }, ).detail(None, "You can leave the column as nullable or delete the column once queries no longer select or modify the column.")); } } } diagnostics } }

可以看到规则实现的三个要点:

  • type Options = ();Options关联类型不一定要用,但必须定义;没有自定义选项时写()即可。LinterRuletrait 定义在 src/linter_rule.rs,其约束为type Options: Default + Clone + Debugrun接收&LinterRuleContext<Self>并返回Vec<LinterDiagnostic>
  • 遍历 AST:通过ctx.stmt()拿到当前语句的根节点,用pgls_query::NodeEnum的变体(如AlterTableStmtAlterTableCmd)做模式匹配,再比对protobuf::AlterTableType::AtDropColumn这样的子类型枚举来精确定位目标节点。也就是说,规则本质上是“在 AST 上做模式匹配”;
  • 上下文携带数据库信息LinterRuleContext还能通过ctx.schema_cache()拿到数据库 schema 缓存(仅当用户配置了数据库连接时才可用)。例如adding_field_with_default规则会读取schema_cache.version.major_version来判断 Postgres 主版本,从而决定非易变 DEFAULT 是否安全(见 adding_field_with_default.rs)。

写完实现后,记得用just f格式化、just l执行 lint。

声明规则:declare_lint_rule!

规则类型本身通过declare_lint_rule!宏声明。宏定义在 crates/pgls_analyse/src/macros.rs,它做了两件事:调用declare_rule!生成一个空枚举类型并为它实现RuleMeta(记录 version、name、文档、severity 等元数据),同时在当前模块声明一个rule_category!宏,用于在编译期静态注入该规则的诊断类别。基本用法:

use pgls_analyse::declare_lint_rule; declare_lint_rule! { /// Documentation pub(crate) ExampleRule { version: "next", name: "myRuleName", severity: Severity::Error, recommended: false, } }

各字段含义:

字段说明
version规则的引入版本;新规则通常写"next",表示随下一个版本发布
name规则在配置与诊断中使用的名称(驼峰,对应postgres-language-server.jsonc中的键)
severity默认严重级别,可选Severity::Info/Severity::Warning/Severity::Error
recommended是否属于推荐启用的规则集合
sources(可选)灵感来源,值为&'static [RuleSource]
deprecated(可选)标记规则已废弃,值为true

标注规则来源(sources)

如果新规则借鉴了其他生态的既有规则(如 Squawk),可以添加sources元数据,每个来源用RuleSource的一个变体表示。例如实现与 Squawk 的ban-drop-column行为一致的规则:

use pgls_analyse::{declare_lint_rule, RuleSource}; declare_lint_rule! { /// Documentation pub(crate) ExampleRule { version: "next", name: "myRuleName", severity: Severity::Error, recommended: false, sources: &[RuleSource::Squawk("ban-drop-column")], } }

仓库中真实规则普遍带有此标注,例如BanDropColumn声明了sources: &[RuleSource::Squawk("ban-drop-column")]。项目根目录下的 agentic/port_squawk_rules.md 等文件记录了将 Squawk 规则移植到本项目的规则与过程,可作参考。

使用rule_category!

declare_lint_rule!会在所在模块内声明rule_category!宏,它展开为当前规则对应的诊断类别(如lint/safety/banDropColumn)。相比动态解析类别名字符串,它的优势是在编译期静态注入类别并校验其已正确注册到pgls_diagnostics。用法如下:

impl Rule for BanDropColumn { type Options = Options; fn run(ctx: &RuleContext<Self>) -> Vec<RuleDiagnostic> { vec![RuleDiagnostic::new( rule_category!(), None, "message", )] } }

注意实际 crate 中 trait 名与类型名做了重新导出:pgls_analyserRuleLinterRule的别名,RuleContextLinterRuleContext的别名,RuleDiagnosticLinterDiagnostic的别名(见 src/lib.rs),因此上面例子中的impl Rule for BanDropColumnimpl LinterRule for BanDropColumn

为规则添加可配置选项

配置文件的形态

规则支持通过postgres-language-server.jsonc配置文件定制。假设规则myRule支持以下选项:behavior"A"/"B"/"C"之一)、threshold(0 到 255 的整数)、behaviorExceptions(字符串数组),配置写法如下:

{ "linter": { "rules": { "safety": { "myRule": { "level": "warn", "options": { "behavior": "A", "threshold": 20, "behaviorExceptions": ["one", "two"] } } } } } }

项目根目录的 postgres-language-server.jsonc 就是该配置文件的真实示例,pgls_configurationcrate 负责将其解析为配置结构。

定义 Rust 选项类型

第一步是创建选项的 Rust 数据表示。文档给出的示例:

#[derive(Clone, Debug, Default)] pub struct MyRuleOptions { behavior: Behavior, threshold: u8, behavior_exceptions: Box<[Box<str>]> } #[derive(Clone, Debug, Defaul)] pub enum Behavior { #[default] A, B, C, }

这里有两个值得注意的实践:

  • Box<[Box<str>]>而不是Vec<String>:盒装切片与盒装字符串只占两个字(two words),而Vec/String各占三个字(three words),能节省内存。这是文档明确给出的性能考虑;
  • u8承载 0–255 的整数threshold的上限 255 正好是u8的最大值,用无符号小整数类型让取值范围在类型层面自解释。

接着把选项类型接到规则上,并实现serdeSerialize/Deserialize(编译器会提示缺失这些 trait):

impl Rule for MyRule { type Options = MyRuleOptions; }

serde属性对齐 JSON 配置

规则选项的 JSON 形态由以下serde属性控制:

  • rename_all = "camelCase":把所有字段重命名为驼峰风格,与postgres-language-server.jsonc的命名风格保持一致(如 Rust 字段behavior_exceptions对应 JSON 键behaviorExceptions);
  • deny_unknown_fields:当配置中出现多余字段时报错,避免拼写错误被静默忽略;
  • default(结构体级):字段缺失时使用Default值,使字段可选。

同时可以配合#[cfg_attr(feature = "schemars", derive(JsonSchema))]在启用schemarsfeature 时生成 JSON Schema。完整示例:

#[derive(Debug, Default, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "schemars", derive(JsonSchema))] #[serde(rename_all = "camelCase", deny_unknown_fields, default)] pub struct MyRuleOptions { #[serde(default, skip_serializing_if = "is_default")] main_behavior: Behavior, #[serde(default, skip_serializing_if = "is_default")] extra_behaviors: Vec<Behavior>, } #[derive(Debug, Default, Clone)] #[cfg_attr(feature = "schemars", derive(JsonSchema))] pub enum Behavior { #[default] A, B, C, }

注意这里文档示例将main_behavior字段命名为驼峰mainBehavior(省略下划线),与rename_all = "camelCase"的作用相呼应。项目对“是否加选项”持保守态度:选项要尽量少,只在确实需要时引入,加选项之前值得先讨论。

运行时如何拿到选项

规则在run中通过ctx.options()取回自己的选项。底层机制可以从 src/linter_options.rs 窥见:LinterOptions内部是LinterRules,即FxHashMap<RuleKey, RuleOptions>的包装;RuleOptions(TypeId, Box<dyn Any>)保存类型擦除的选项值,value::<O>()通过TypeId校验后向下转型取回具体类型。LinterOptions::rule_options::<R>()则按规则的RuleKey查表并克隆出R::Options。也就是说,配置解析后按规则名存入类型擦除的容器,运行时再按类型还原,这正是“每条规则拿到的永远是自己的选项类型”的保证。

编写规则文档:格式与约束

规则文档是代码生成与规则页面渲染的重要输入,必须遵守以下硬性规则:

  • 第一段必须是规则的一句话简介,且必须写在一行内:该段落会被用作规则列表页的表格内容,换行会破坏表格布局;
  • 后续段落可自由补充细节;
  • 文档必须有## Examples标题,其下按顺序包含### Invalid### Valid两个小节,且### Invalid在前(先展示规则何时触发);
  • 如果规则有选项,必须写在## Options小节;
  • 每个代码块必须声明语言为sql
  • ### Invalid中的每个片段必须使用expect_diagnostic代码块属性:代码生成脚本会根据该属性为片段生成并附加一条诊断,一个片段必须且只能产生一条诊断
  • ### Valid中的片段可以只有一个;
  • 可以用ignore代码块属性告诉代码生成脚本“不要为某个 invalid 片段生成诊断”。

一个完整的规则文档示例(与真实banDropColumn的实现几乎一致):

declare_lint_rule! { /// Dropping a column may break existing clients. /// /// Update your application code to no longer read or write the column. /// /// You can leave the column as nullable or delete the column once queries no longer select or modify the column. /// /// ## Examples /// /// ### Invalid /// /// ```sql,expect_diagnostic /// alter table test drop column id; /// ``` /// pub BanDropColumn { version: "next", name: "banDropColumn", recommended: true, severity: Severity::Error, sources: &[RuleSource::Squawk("ban-drop-column")], } }

文档生成器会据此确保规则对该 SQL 恰好产生一条诊断,并把该诊断的快照纳入规则文档页面。

测试规则:快速测试与快照测试

快速测试:debug_test

想快速验证规则行为,可打开 src/lib.rs 中的debug_test函数(源码中该测试带#[ignore]属性,默认跳过):

  • 移除#[ignore]宏;
  • SQL这个&str的内容改成你需要的语句;
  • RuleFilter::Rule(..)中传入你的组与规则名,例如RuleFilter::Rule("safety", "banDropColumn")

运行后,规则产生的所有诊断会打印到控制台。

快照测试:tests/specs

规则实现并文档化后,必须在tests/specs/<group>/<ruleName>/目录下创建快照测试。每个测试文件应满足:

  1. 以一个注释说明该测试检查什么;
  2. 首行包含-- expect_lint/<group>/<ruleName>-- expect_no_diagnostics
  3. 包含会触发(或不会触发)规则的合法 SQL。

addSerialColumn规则为例的目录结构(对应仓库中真实存在的 tests/specs/safety/addSerialColumn):

tests/specs/safety/addSerialColumn/ ├── basic.sql # 触发规则的基础用例 ├── basic.sql.snap # 自动生成的快照 ├── bigserial.sql # 测试 bigserial 类型 ├── bigserial.sql.snap ├── generated_stored.sql # 测试 GENERATED ... STORED ├── generated_stored.sql.snap ├── valid_regular_column.sql # 合法用例——不应触发 └── valid_regular_column.sql.snap

触发诊断的用例示例:

-- expect_lint/safety/addSerialColumn -- Test adding serial column to existing table ALTER TABLE prices ADD COLUMN id serial;

不应触发诊断的用例示例:

-- Test adding regular column (should be safe) -- expect_no_diagnostics ALTER TABLE prices ADD COLUMN name text;

测试入口在 tests/rules_tests.rs:它通过pgls_test_macros::gen_tests!自动扫描tests/specs/**/*.sql生成测试;每个用例先用pgls_statement_splitter拆分 SQL,再用pgls_query::parse解析出 AST,交给只启用当前规则的Analyser运行,最后做两件事:一是用insta生成/比对.snap快照(包含输入与诊断输出),二是解析测试文件首行的expect_*注释,断言实际产生的诊断类别与数量完全匹配(见 rules_tests.rs 中的Expectation逻辑)。正因如此,测试文件必须声明expect_*注释,否则会直接 panic。

运行与更新测试:

# 运行测试并生成快照 cargo test -p pgls_analyser --test rules_tests # 审查并接受新增/变更的快照 cargo insta test --accept # 或交互式审查快照 cargo insta review

.sql.snap快照文件是自动生成的,应提交到仓库,它们记录了每个用例的预期诊断输出。

触发代码生成:just gen-lint

项目内大量代码是由xtask/codegen脚本自动生成的(例如 src/options.rs 顶部就标注了 “Generated file, do not edit by hand”)。CI 会确保生成代码与实际代码保持同步,一旦不同步就会构建失败。因此完成规则实现后,必须运行代码生成以刷新所有依赖它的文件:

just gen-lint

该命令在 justfile 中定义,会调用cargo run -p xtask_codegen -- gen-lint(生成分析器相关代码,包括注册表、选项类型别名、规则索引与文档等)。新增规则后不运行它,CI 会报错。

提交与废弃规则

提交变更

规则实现、测试、文档与生成代码都完成后,就可以提交并开启 Pull Request:

git add -A git commit -m 'feat(pgls_analyser): myRuleName'

废弃规则

当规则需要被废弃(避免破坏性变更)时,在宏中追加deprecated: true字段即可,并在文档中说明废弃原因。一个完整的废弃示例:

use pgls_analyse::declare_lint_rule; declare_lint_rule! { /// Dropping a column may break existing clients. /// /// Update your application code to no longer read or write the column. /// /// You can leave the column as nullable or delete the column once queries no longer select or modify the column. /// /// ## Examples /// /// ### Invalid /// /// ```sql,expect_diagnostic /// alter table test drop column id; /// ``` /// pub BanDropColumn { version: "next", name: "banDropColumn", recommended: true, severity: Severity::Error, deprecated: true, sources: &[RuleSource::Squawk("ban-drop-column")], } }

总结与进阶路径

从命名、宏声明、run实现、serde选项、文档规范到快照测试与代码生成,一条完整的 lint 规则在 postgres_lsp 中的生命周期可以被概括为:

  1. ban/use约定起名;
  2. just new-lintrule safety <name> (<severity>)生成骨架;
  3. src/lint/safety/<rule_name>.rs中实现LinterRule::run,用rule_category!()LinterDiagnostic的链式方法践行“三支柱”;
  4. 如有需要,定义Options类型并用serde属性对齐postgres-language-server.jsonc配置;
  5. 编写符合格式约束的文档(单行简介、## Examplesexpect_diagnostic);
  6. debug_test快速验证,再在tests/specs/safety/<RuleName>/下补快照测试并跑cargo insta
  7. 运行just gen-lint刷新生成代码,just f/just l保证格式与 lint 通过;
  8. 提交并开启 PR,必要时用deprecated: true标记废弃规则。

若想深入理解规则运行时的细节,建议继续阅读 crates/pgls_analyse 中的宏与元数据实现、src/linter_context.rs 的上下文类型,以及 crates/pgls_analyser/tests/specs/safety 下各规则的测试用例(如banDropColumnaddingFieldWithDefaultrequireConcurrentIndexCreation的快照),这些都是学习既有规则写法的最佳范例。

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

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询