news 2026/10/7 9:53:25

Ktlint 自定义 RuleSet 开发指南:从模板项目到自研规则实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Ktlint 自定义 RuleSet 开发指南:从模板项目到自研规则实战
  • 开发工具
  • 代码质量
  • Lint
  • 格式化

【免费下载链接】ktlint

An anti-bikeshedding Kotlin linter with built-in formatter

项目地址:https://gitcode.com/gh_mirrors/kt/ktlint
点击查看免费下载

导读

本文面向需要为 Ktlint 定制团队代码规范的开发者,系统讲解如何通过独立 RuleSet(一个包含一个或多个 RuleV2 的 JAR)扩展 Ktlint 的 lint 能力。文章以仓库自带的 ktlint-ruleset-template 模板项目为实战载体,完整覆盖规则编写、RuleSetProvider 注册、Gradle 构建、CLI 加载验证与单元测试等全流程,并结合ktlint-rule-engine-core、ktlint-cli的源码剖析规则引擎的调用机制。读完本文,你将能够独立开发、构建、发布并加载属于自己的 Ktlint 自定义规则集。

自定义 RuleSet 是什么

Ktlint 允许通过独立 RuleSet提供自定义规则。所谓 RuleSet,本质是一个 JAR 包,其中包含一个或多个 RuleV2 实现。加载该 JAR 后,Ktlint 会在标准规则之外执行其中的自定义规则,输出格式与内置规则完全一致(<rule-set-id>:<rule-id>形式的错误标识)。

仓库为开发者准备了两个上手入口:

  • 一份 PDF 演示文稿 Ktlint - building a custom ruleset .pdf,适合作为概念入门;
  • 仓库内现成的样例工程 ktlint-ruleset-template,可以直接克隆并在此基础上改造。

本文后续内容将以模板项目为主线展开实战。

认识模板项目 ktlint-ruleset-template

模板项目采用最小但完整的设计,文件结构如下:

ktlint-ruleset-template/ ├── build.gradle.kts # Gradle 构建脚本 └── src/ ├── main/ │ ├── kotlin/yourpkgname/ │ │ ├── CustomRuleSetProvider.kt # RuleSet 提供者 │ │ └── NoVarRule.kt # 规则实现 │ └── resources/META-INF/services/ │ └── io.github.ktlint.core.cli.ruleset.core.api.RuleSetV2Provider # ServiceLoader 注册文件 └── test/kotlin/yourpkgname/ ├── MetaInfServicesTest.kt # 验证 ServiceLoader 注册正确性 └── NoVarRuleTest.kt # 规则行为测试

整个工程围绕三个核心产物组织:规则实现(Rule)、规则集提供者(RuleSetProvider)、SPI 注册文件。三者缺一不可。

编写 Rule:规则的 lint 与格式化逻辑

规则文件 NoVarRule.kt 是一个典型的自定义规则示例,其目标是在代码中禁止使用var(建议改用val):

package yourpkgname import io.github.ktlint.core.rule.engine.core.api.AutocorrectDecision import io.github.ktlint.core.rule.engine.core.api.ElementType.VAR_KEYWORD import io.github.ktlint.core.rule.engine.core.api.RuleId import io.github.ktlint.core.rule.engine.core.api.RuleV2 import org.jetbrains.kotlin.com.intellij.lang.ASTNode public class NoVarRule : RuleV2( ruleId = RuleId("$CUSTOM_RULE_SET_ID:no-var"), about = RuleV2.About( maintainer = "Your name", repositoryUrl = "https://github.com/your/project/", issueTrackerUrl = "https://github.com/your/project/issues", ), ) { override fun beforeVisitChildNodes( node: ASTNode, emit: (offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> AutocorrectDecision, ) { if (node.elementType == VAR_KEYWORD) { emit(node.startOffset, "Unexpected var, use val instead", false) } } }

规则标识:ruleId 与 RuleSetId

RuleV2构造函数要求传入一个RuleId,其取值必须遵循<rule-set-id>:<rule-id>的命名约定。从 Rule.kt 的源码可以看到,RuleId与RuleSetId在初始化时都会调用IdNamingPolicy强制校验命名规范。特别需要注意的是:

  • standard是 Ktlint 项目保留的 RuleSet 前缀,自定义规则集严禁使用,否则在排查问题时无法区分规则的实际维护方;
  • 模板中通过常量CUSTOM_RULE_SET_ID = "custom-rule-set-id"(定义于 CustomRuleSetProvider.kt)统一管理规则集 ID,使最终规则标识为custom-rule-set-id:no-var(运行时日志中简写为custom:no-var)。

生命周期 hooks:规则引擎如何调用你

从 Rule.kt 的源码定义看,RuleV2提供了 4 个可覆写的生命周期钩子,Ktlint 规则引擎按深度优先顺序遍历 AST(Abstract Syntax Tree,抽象语法树)时依次触发:

Hook触发时机典型用途
beforeFirstNode(editorConfig: EditorConfig)遍历第一个节点之前,仅调用一次读取.editorconfig属性、初始化规则状态
beforeVisitChildNodes(node, emit)访问某节点的子节点之前(递归进行,形成深度优先遍历)检查节点并上报违规(模板的NoVarRule即在此实现)
afterVisitChildNodes(node, emit)某节点的所有子节点访问完毕之后需要子节点信息才能判定的规则(如缩进、换行类)
afterLastNode()遍历完最后一个节点之后,仅调用一次清理规则状态、输出汇总信息

提示:Ktlint 内置的标准规则集 ktlint-ruleset-standard 中包含了大量实现上述 hooks 的规则,是编写复杂规则的绝佳参考。

emit 函数与自动纠错(Autocorrect)

beforeVisitChildNodes与afterVisitChildNodes接收的emit回调签名如下:

(offset: Int, errorMessage: String, canBeAutoCorrected: Boolean) -> AutocorrectDecision

其语义为:上报一个违规(lint error),并告知引擎该违规是否可以被自动修正;返回的AutocorrectDecision决定引擎是否实际执行修正。根据 Rule.kt 的 KDoc 说明:

  • 在 lint 模式下emit应始终返回不进行修正;
  • 规则若支持自动修正,可通过emit的返回值判断是否获得修正许可,再修改 AST。

模板的NoVarRule中emit(node.startOffset, "Unexpected var, use val instead", false)的最后一个参数为false,表示该违规无法自动纠正(将var改为val可能改变代码语义,必须人工处理)——这也是为什么 CLI 输出中会标注cannot be auto-corrected。

用 PsiViewer 观察 AST

由于规则本质是操作 AST 节点,快速查看任意代码的 AST 结构对开发至关重要。Ktlint 官方推荐 IntelliJ IDEA 的PsiViewer 插件(PsiViewer)来可视化 Kotlin 代码的 AST。下图展示了 PsiViewer 的界面——左侧是源码,右侧树形结构即为 Ktlint 规则引擎遍历时所见到的 AST 节点层级:

借助该工具,你可以确认目标代码中某个语法元素(如var关键字)对应的elementType(即模板中用到的VAR_KEYWORD),从而精准编写节点匹配逻辑。

编写 RuleSetProvider 并注册 SPI

规则写好后,还需要一个RuleSetProvider负责向引擎提供规则实例。模板中的 CustomRuleSetProvider.kt 完整实现如下:

package yourpkgname import io.github.ktlint.core.cli.ruleset.core.api.RuleSetV2Provider import io.github.ktlint.core.rule.engine.core.api.RuleSetId import io.github.ktlint.core.rule.engine.core.api.RuleV2Provider internal val CUSTOM_RULE_SET_ID = "custom-rule-set-id" class CustomRuleSetProvider : RuleSetV2Provider(RuleSetId(CUSTOM_RULE_SET_ID)) { override fun getRuleProviders(): Set<RuleV2Provider> = setOf( RuleV2Provider { NoVarRule() }, ) }

从 RuleSetV2Provider.kt 源码可知,RuleSetV2Provider是一个抽象类,构造时接收RuleSetId,并需要覆写getRuleProviders()返回一组RuleV2Provider。而 RuleV2Provider.kt 提供了RuleV2Provider { NoVarRule() }这种便捷的工厂式构造,其底层逻辑是:每次调用createNewRuleInstance()时执行 provider 工厂函数创建全新实例。

这里有一个值得注意的源码级细节:RuleV2允许持有状态(如统计计数),但前提是RuleV2Provider每次调用工厂函数都返回新的实例(参见 Rule.kt 与 RuleV2Provider.kt 的 KDoc)。因为 Ktlint 会并行处理多个文件,单例规则实例在并发下会引入状态竞争,因此务必保持"每次提供新实例"的写法。

ServiceLoader 注册文件

Ktlint 依赖 Java 标准的ServiceLoader机制在 classpath 上发现所有 RuleSet。因此,RuleSetV2Provider的实现类全限定名必须写入 SPI 注册文件:

# 路径:ktlint-ruleset-template/src/main/resources/META-INF/services/io.github.ktlint.core.cli.ruleset.core.api.RuleSetV2Provider yourpkgname.CustomRuleSetProvider

该文件内容只有一行——提供者实现类的全限定名。文件路径与文件名必须与RuleSetV2Provider的全限定类名io.github.ktlint.core.cli.ruleset.core.api.RuleSetV2Provider一一对应,这是 Java SPI 的硬性约定。模板工程还配套了 MetaInfServicesTest.kt,它读取该资源并断言注册内容与实现类名一致,防止类重命名后注册失效。

Gradle 构建配置详解

模板的 build.gradle.kts 承担三类职责:依赖管理、Maven 发布、自检任务(dogfood)。该脚本刻意没有复用 ktlint 内部其他模块的构建逻辑,以保证外部开发者可以独立拷贝使用(对应 ktlint 的 issue #3048 决策)。

依赖配置

plugins { kotlin("jvm") version "2.4.20" `maven-publish` } dependencies { // ktlint CLI 及 API 依赖 implementation("io.github.ktlint.core:ktlint-cli-ruleset-core:2.0.0-SNAPSHOT") implementation("io.github.ktlint.core:ktlint-rule-engine-core:2.0.0-SNAPSHOT") // 测试依赖:JUnit 5、junit-platform-launcher、slf4j-simple、ktlint-test testImplementation("org.junit.jupiter:junit-jupiter:6.1.3") testImplementation("org.junit.platform:junit-platform-launcher:6.1.3") testImplementation("org.slf4j:slf4j-simple:2.0.20") testImplementation("io.github.ktlint.core:ktlint-test:2.0.0-SNAPSHOT") }

其中关键模块的作用:

  • ktlint-rule-engine-core:提供RuleV2、RuleId、RuleV2Provider、AutocorrectDecision等规则开发核心 API;
  • ktlint-cli-ruleset-core:提供RuleSetV2Provider基类(SPI 接口定义所在);
  • ktlint-test:提供测试辅助工具KtLintAssertThat,用于编写规则单元测试。

模板在仓库内部开发时通过dependencySubstitution将上述 Maven 坐标替换为本地项目(见build.gradle.kts中的configurations.all { resolutionStrategy.dependencySubstitution { ... } }块);外部开发者拷贝模板时应删除该块并确保在repositories中配置mavenCentral()。发布到 Maven 时,还需在settings.gradle.xml中配置dependencyResolutionManagement并启用mavenCentral()(模板注释中给出了建议写法)。

自检任务 ktlintCheck(dogfood 原则)

模板内置了名为ktlintCheck的自定义 Gradle 任务,它调用 Ktlint CLI 的入口类io.github.ktlint.core.Main,在加载标准规则集的同时,把本模块编译产物(自定义规则)加入 classpath,对项目自身源码执行检查——即"用自己写的规则检查自己的代码"(dogfood 原则):

val ktlintCheck by tasks.registering(JavaExec::class) { dependsOn(tasks.classes) group = LifecycleBasePlugin.VERIFICATION_GROUP mainClass = "io.github.ktlint.core.Main" classpath(ktlint, sourceSets.main.map { it.output }) args("--log-level=debug", "src/**/*.kt") } tasks.check { dependsOn(ktlintCheck) }

ktlint配置(val ktlint: Configuration by configurations.creating)声明了对io.github.ktlint.core:ktlint-cli:2.0.0-SNAPSHOT的依赖,用于提供 CLI 运行时。将ktlintCheck挂到check任务链上后,执行gradlew build或gradlew check时就会自动运行自定义规则的自检。

Maven 发布配置

模板通过maven-publish插件声明了mavenJava出版物,并附带sourcesJar与javadocJar,POM 中声明 Apache License 2.0。若不需要发布,可移除maven-publish插件、java { withSourcesJar(); withJavadocJar() }及publishing块。

构建模板项目

在仓库根目录执行(注意模板工程使用仓库根的 Gradle Wrapper):

$ cd ktlint-ruleset-template/ $ ../gradlew build

构建成功后在ktlint-ruleset-template/build/libs/下生成可加载的规则集 JAR(默认产物名称为ktlint-ruleset-template-1.0-SNAPSHOT.jar,同时build会触发ktlintCheck自检与全部单元测试)。

使用 Ktlint CLI 加载并验证自定义规则集

准备违规样例

先创建一个故意违反custom:no-var规则的 Kotlin 文件:

$ echo 'var v = 0' > test.kt

运行 Ktlint CLI

$ ktlint -R build/libs/ktlint-ruleset-template.jar --log-level=debug --relative test.kt

命令参数说明(均可从 KtlintCommandLine.kt 的 CLI 定义中确认):

  • -R, --ruleset <path>:指定包含额外规则集的 JAR 文件路径(可传多个,逗号分隔);
  • --log-level=debug:输出调试日志,可直观看到规则集加载与规则排序过程;
  • --relative:以相对当前工作目录的方式打印文件路径(见 KtlintCommandLine.kt),便于阅读输出。

完整输出解读

18:13:21.026 [main] DEBUG io.github.ktlint.core.internal.RuleSetsLoader - JAR ruleset provided with path "/../ktlint/ktlint-ruleset-template/build/libs/ktlint-ruleset-template.jar" 18:13:21.241 [main] DEBUG io.github.ktlint.core.Main - Discovered reporter with "baseline" id. 18:13:21.241 [main] DEBUG io.github.ktlint.core.Main - Discovered reporter with "checkstyle" id. 18:13:21.241 [main] DEBUG io.github.ktlint.core.Main - Discovered reporter with "json" id. 18:13:21.242 [main] DEBUG io.github.ktlint.core.Main - Discovered reporter with "html" id. 18:13:21.242 [main] DEBUG io.github.ktlint.core.Main - Discovered reporter with "plain" id. 18:13:21.242 [main] DEBUG io.github.ktlint.core.Main - Discovered reporter with "sarif" id. 18:13:21.242 [main] DEBUG io.github.ktlint.core.Main - Initializing "plain" reporter with {verbose=false, color=false, color_name=DARK_GRAY} [DEBUG] Rule with id 'standard:max-line-length' should run after the rule with id 'trailing-comma'. However, the latter rule is not loaded and is allowed to be ignored. For best results, it is advised load the rule. [DEBUG] Rules will be executed in order below (unless disabled): - standard:filename, - standard:final-newline, - standard:chain-wrapping, - standard:colon-spacing, - standard:comma-spacing, - standard:comment-spacing, - standard:curly-spacing, - standard:dot-spacing, - standard:import-ordering, - standard:keyword-spacing, - standard:modifier-order, - standard:no-blank-line-before-rbrace, - standard:no-consecutive-blank-lines, - standard:no-empty-class-body, - standard:no-line-break-after-else, - standard:no-line-break-before-assignment, - standard:no-multi-spaces, - standard:no-semi, - standard:no-trailing-spaces, - standard:no-unit-return, - standard:no-unused-imports, - standard:no-wildcard-imports, - standard:op-spacing, - standard:parameter-list-wrapping, - standard:paren-spacing, - standard:range-spacing, - standard:string-template, - custom:no-var, - standard:indent, - standard:max-line-length `text test.kt:1:1: Unexpected var, use val instead (cannot be auto-corrected)` 18:13:21.893 [main] DEBUG io.github.ktlint.core.Main - 872ms / 1 file(s) / 1 error(s)

对输出关键信息的解读:

  1. 规则集加载:RuleSetsLoader明确打印了从指定 JAR 路径加载的规则集,证明-R参数生效;
  2. 规则排序:自定义规则custom:no-var被插入到标准规则执行序列中间(位于standard:string-template之后、standard:indent之前)。规则执行顺序由规则引擎的RuleProviderSorter依据规则声明的依赖关系决定;日志中max-line-length should run after trailing-comma ...说明规则间可以声明先后依赖,未加载的依赖规则会被容忍并提示;
  3. 违规输出:test.kt:1:1: Unexpected var, use val instead (cannot be auto-corrected)中的text前缀与(cannot be auto-corrected)标记,对应 plain reporter 的默认输出格式,其中custom:no-var的违规信息完整复用了 NoVarRule.kt 中emit传入的errorMessage;
  4. 统计信息:末尾的872ms / 1 file(s) / 1 error(s)为本次检查耗时与结果汇总。

提示:多个自定义规则集可以同时加载,例如ktlint -R ruleset-a.jar -R ruleset-b.jar,Ktlint 会合并所有规则集后统一排序执行。

为自定义规则编写单元测试

模板工程提供了两类测试,可作为自定义规则的开发护城河:

规则行为测试

NoVarRuleTest.kt 借助ktlint-test模块的KtLintAssertThat断言规则行为:

class NoVarRuleTest { private val wrappingRuleAssertThat = assertThatRule { NoVarRule() } @Test fun `No var rule`() { val code = """ fun fn() { var v = "var" } """.trimIndent() wrappingRuleAssertThat(code) .hasLintViolationWithoutAutoCorrect(2, 5, "Unexpected var, use val instead") } }

它精确断言:在第 2 行第 5 列(即var关键字位置)产生一条不可自动修正的违规,且违规消息与emit中传入的文案一致。

SPI 注册测试

MetaInfServicesTest.kt 验证META-INF/services注册文件的内容恰好是CustomRuleSetProvider的全限定名,防止因类重命名或路径迁移导致规则集在运行时无法被发现。

规则引擎底层机制补充

遍历状态与提前终止

从 Rule.kt 源码可见,RuleV2内部维护TraversalState(NOT_STARTED / CONTINUE / STOP)三态:

  • startTraversalOfAST()标记规则实例开始参与本次遍历;同一实例一旦用于遍历,就不能再用于其他文件,这正是RuleV2Provider必须每次创建新实例的原因;
  • stopTraversalOfAST()可提前终止遍历——典型场景是.editorconfig中indent_size被设为 0 或 -1 时缩进规则无需继续执行。调用位置不同,终止行为不同:在beforeFirstNode中调用则后续节点全部跳过;在beforeVisitChildNodes中调用则跳过该节点的子节点但保留父链路回调;afterLastNode始终会被调用。

这些机制同样适用于自定义规则,可用于实现"条件触发即停止"的高效规则。

规则中的 editorconfig 属性

RuleV2构造函数还接受可选的usesEditorConfigProperties: Set<EditorConfigProperty<*>>(见 Rule.kt),声明规则实际消费的.editorconfig属性后,引擎会在beforeFirstNode(editorConfig: EditorConfig)中提供对应的配置值。自定义规则可借此支持团队化的开关与参数(例如"自定义规则的严格级别")。此外,实现RuleV2.Experimental、RuleV2.OfficialCodeStyle或RuleV2.OnlyWhenEnabledInEditorconfig接口(见 Rule.kt)可控制规则仅在.editorconfig显式允许时才执行。

实践要点与最佳实践

  • 规则集 ID 命名:切勿使用standard前缀,自定义规则集请使用有辨识度的 ID(如custom、公司/团队名),便于错误定位与责任归属;
  • 完整填写 About 信息:RuleV2.About中的维护者、仓库地址、问题追踪地址会被用于堆栈跟踪与 API 消费者展示,建议如实填写(Rule.kt);
  • 保持无状态:规则实例可能被并发使用,务必通过 provider 工厂每次返回新实例来隔离状态;
  • 善用现有参考:复杂规则(缩进、换行、空白处理)的写法可参考 ktlint-ruleset-standard 中 90 余条标准规则的实现与对应测试;
  • 加载多个规则集:-R可重复指定,多个自定义规则集会与标准规则一起排序执行;
  • 自检与测试:将ktlintCheck纳入check生命周期,并用KtLintAssertThat为每条规则编写精确到行列的断言,保证规则演进不回归;
  • 版本对齐:模板依赖2.0.0-SNAPSHOT系列坐标与 Kotlin2.4.20、JVM Toolchain 21,外部使用时请替换为当前发布的最新稳定版本,并确保 Kotlin/Java 版本与 Gradle 兼容矩阵一致。

至此,从规则编写、SPI 注册、构建发布到 CLI 验证与单元测试的完整闭环已经打通。直接克隆 ktlint-ruleset-template 改造,即可在几分钟内产出团队专属的 Ktlint 规则集。

  • 开发工具
  • 代码质量
  • Lint
  • 格式化

【免费下载链接】ktlint

An anti-bikeshedding Kotlin linter with built-in formatter

项目地址:https://gitcode.com/gh_mirrors/kt/ktlint
点击查看免费下载

相关推荐

上一篇:Texture(AsyncDisplayKit)hitTestSlop 完全指南:扩大节点可点击区域的最佳实践
下一篇:告别窗口遮挡烦恼:Windows窗口置顶神器AlwaysOnTop完全指南

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

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

ArkTS 表单工程:保养录入页的换行胶囊与四字段表单

ArkTS 表单工程&#xff1a;保养录入页的换行胶囊与四字段表单 App 58「车辆保养提醒」保养页&#xff08;Func1Tab&#xff09;&#xff0c;主题色 #008080 青蓝。本页是保养记录的"录入页"——白色单行 Header&#xff08;"记录保养"20 号加粗&#xff0…

作者头像 李华
网站建设 2026/10/7 9:51:18

GLiNER2模型训练教程:10行代码微调自己的NER与分类模型

GLiNER2模型训练教程&#xff1a;10行代码微调自己的NER与分类模型 【免费下载链接】GLiNER2 Unified Schema-Based Information Extraction 项目地址: https://gitcode.com/gh_mirrors/gl/GLiNER2 本教程带你从零开始完成 GLiNER2 模型训练&#xff1a;只需约 10 行 Py…

作者头像 李华
网站建设 2026/10/7 9:50:06

游戏引擎架构核心拆解:分层、Game Loop、数据驱动与多线程

聊游戏引擎架构&#xff0c;很多人一上来就扑向源码&#xff0c;打开Unreal或者Unity的仓库&#xff0c;准备从FEngineLoop或者PlayerLoop一行行啃。但说句实在话&#xff0c;如果你脑子里没有一张架构地图&#xff0c;源码读得越多&#xff0c;越容易被细节拉着走&#xff0c;…

作者头像 李华
网站建设 2026/10/7 9:49:27

Unity Shader Graph风格化水面特效复刻:塞尔达风之杖卡通渲染全解析

最近在准备一个卡通渲染风格的原型项目&#xff0c;其中水面是最难啃的一块。翻了很多资料后发现&#xff0c;网上关于风格化水面的内容要么只讲原理不给操作&#xff0c;要么直接丢一个写好的 Shader 让你复制&#xff0c;完全没讲透为什么这么连节点。本文就以《塞尔达传说&a…

作者头像 李华
网站建设 2026/10/7 9:48:26

K375S优联无线化改造:机械键盘协议级无线重构指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华