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!、RuleMeta、MetadataRegistry、AnalysisFilter等)。本文所指的“Analyser”即 crates/pgls_analyser 目录,其 Cargo.toml 中依赖了pgls_query(基于 libpg_query 的 AST 表示)、pgls_diagnostics(诊断类型与渲染)、pgls_console(markup 输出)等 crate。
从源码结构看,pgls_analyser的规则按“组(group)”组织,目前规则全部位于src/lint/safety/目录下(对应“safety”组),已有banDropColumn、addingFieldWithDefault、requireConcurrentIndexCreation等 50 余条规则,每条规则对应一个.rs文件。核心入口Analyser定义在 src/lib.rs:它接收AnalyserParams(拆分后的 SQL 语句列表、可选的schema_cache)与AnalyserConfig(LinterOptions与AnalysisFilter),内部通过LinterRuleRegistry::builder(&filter)构建规则注册表,然后对每条语句依次执行所有已启用规则的run函数,汇总返回Vec<LinterDiagnostic>。这也是“规则针对每条语句运行”这一模型的最直接源码证据。
规则命名约定:ban与use前缀
在动手写代码前,必须先确定规则名称。项目遵循一套与规则语义强绑定的命名约定:
| 前缀 | 语义 | 适用场景 | 示例 |
|---|---|---|---|
ban | 禁止某个单一概念 | 规则的唯一意图是禁止某种写法 | banDropColumn、banTruncate、banUpdateWithoutWhere |
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是可选的,可选值为info、warn、error,缺省为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)”:
- 告诉用户错误是什么:通常是诊断信息(diagnostic message)本身;
- 告诉用户为什么触发:通常通过附加节点(label/detail)实现;
- 告诉用户应该怎么做:通常用代码建议(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 + Debug,run接收&LinterRuleContext<Self>并返回Vec<LinterDiagnostic>;- 遍历 AST:通过
ctx.stmt()拿到当前语句的根节点,用pgls_query::NodeEnum的变体(如AlterTableStmt、AlterTableCmd)做模式匹配,再比对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_analyser中Rule是LinterRule的别名,RuleContext是LinterRuleContext的别名,RuleDiagnostic是LinterDiagnostic的别名(见 src/lib.rs),因此上面例子中的impl Rule for BanDropColumn即impl 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的最大值,用无符号小整数类型让取值范围在类型层面自解释。
接着把选项类型接到规则上,并实现serde的Serialize/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>/目录下创建快照测试。每个测试文件应满足:
- 以一个注释说明该测试检查什么;
- 首行包含
-- expect_lint/<group>/<ruleName>或-- expect_no_diagnostics; - 包含会触发(或不会触发)规则的合法 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 中的生命周期可以被概括为:
- 按
ban/use约定起名; - 用
just new-lintrule safety <name> (<severity>)生成骨架; - 在
src/lint/safety/<rule_name>.rs中实现LinterRule::run,用rule_category!()与LinterDiagnostic的链式方法践行“三支柱”; - 如有需要,定义
Options类型并用serde属性对齐postgres-language-server.jsonc配置; - 编写符合格式约束的文档(单行简介、
## Examples、expect_diagnostic); - 用
debug_test快速验证,再在tests/specs/safety/<RuleName>/下补快照测试并跑cargo insta; - 运行
just gen-lint刷新生成代码,just f/just l保证格式与 lint 通过; - 提交并开启 PR,必要时用
deprecated: true标记废弃规则。
若想深入理解规则运行时的细节,建议继续阅读 crates/pgls_analyse 中的宏与元数据实现、src/linter_context.rs 的上下文类型,以及 crates/pgls_analyser/tests/specs/safety 下各规则的测试用例(如banDropColumn、addingFieldWithDefault、requireConcurrentIndexCreation的快照),这些都是学习既有规则写法的最佳范例。
【免费下载链接】postgres_lspA Language Server for Postgres项目地址: https://gitcode.com/GitHub_Trending/po/postgres_lsp
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考