Quarkdown 的 locale-table-processor:用 KSP 在编译期把 JDK Locale 数据固化为运行时语言表
【免费下载链接】quarkdown🪐 Markdown with superpowers: from ideas to papers, presentations, websites, books, and knowledge bases.项目地址: https://gitcode.com/GitHub_Trending/qu/quarkdown
导读
Quarkdown 文档里用.doclang {locale}设置文档语言,并据此解析English、it、fr-CA这类 locale 标识符。为了在运行时彻底摆脱对 JDKjava.util.Locale的依赖、保证任何平台上的结果都确定且一致,仓库用 quarkdown-locale-table-processor 这个 KSP 处理器,在构建期把构建 JDK 的 CLDR 语言数据抽取成两张 Kotlin 常量表(Languages与Territories)编译进核心库。读完本文,你将掌握这个处理器的完整运行机制、生成产物形态、它在 Quarkdown 本地化体系中的调用位置,以及如何把它接入自己的 Kotlin 项目。
一、模块定位:为什么需要在构建期生成 locale 表
1.1 要解决的问题
Quarkdown 的文档元数据支持用.doclang指定文档语言,取值可以是不区分大小写的英文全名(English、Italian、French (Canada))或IETF BCP 47 语言标签(en、it、fr-CA),参见 docs/document-metadata.qd。本地化功能依赖这一能力:它决定内容本地化使用的目标 locale、中文等 locale 的专属字体样式,以及 HTMLlang属性。
但直接依赖 JDK 的java.util.Locale有两大痛点:
- 平台不一致:不同 JDK 发行版、不同 CLDR 版本提供的 locale 展示名可能不同,导致同一份文档在不同机器上解析出不同结果;
- 运行期耦合:CLI、LSP、服务器等组件在运行时仍需 JDK 的 locale 数据,增加了运行环境约束。
locale-table-processor的答案是:在编译期完成数据抽取,把结果固化进产物。正如 README 所述:将这份数据打包进核心库后,.doclang使用的 locale 解析在运行时变得平台无关且确定,不再依赖 JDK。
1.2 产出两张表
处理器构建期生成两张“代码 → 英文名”映射表:
| 表 | 依据标准 | 示例 |
|---|---|---|
Languages | ISO 639 语言代码 | it→Italian |
Territories | ISO 3166 国家/地区代码 | IT→Italy |
从源码看,两张表正是由 LocaleTableCodeGenerator.kt 中的tables列表声明,输出属性名分别为Languages和Territories,KDoc 分别为 “Languages by ISO 639 code” 和 “Territories by ISO 3166 country code”。
二、处理器内部实现剖析
该模块只包含三个 Kotlin 源文件加一个服务注册文件,分工非常清晰。
2.1 入口:LocaleTableSymbolProcessorProvider
LocaleTableSymbolProcessorProvider.kt 实现 KSP 的SymbolProcessorProvider接口,负责实例化处理器。KSP 通过SPI(服务提供者接口)机制发现它,注册文件位于 META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider,其内容仅一行:
com.quarkdown.processor.locale.LocaleTableSymbolProcessorProvider2.2 处理器:LocaleTableSymbolProcessor
LocaleTableSymbolProcessor.kt 是核心处理器。值得注意的设计点是:它不读取任何源码符号——既没有注解、也没有待处理的类型,而是在首个process轮次无条件地生成唯一输出文件:
override fun process(resolver: Resolver): List<KSAnnotated> { if (!invoked) { invoked = true codeGenerator .createNewFile(Dependencies(aggregating = false), PACKAGE_NAME, FILE_NAME) .bufferedWriter() .use { it.write(LocaleTableCodeGenerator().buildSource()) } } return emptyList() }invoked标志保证文件只生成一次,避免多轮处理重复写入;Dependencies(aggregating = false)声明产物不聚合依赖任何源文件,只要处理器运行即可生成;process恒返回空列表,即不延迟处理任何符号;- 输出包名
com.quarkdown.core.localization.table、文件名LocaleTables,由LocaleTableCodeGenerator中的常量定义。
2.3 代码生成器:LocaleTableCodeGenerator
LocaleTableCodeGenerator.kt 负责“抽取数据 → 生成源码文本”。
语言表的抽取逻辑(languages()):
val codes = Locale.getISOLanguages().toSet() + Locale.getAvailableLocales().mapNotNull { it.language.takeIf(String::isNotBlank) } return codes.namesBy { Locale(it).getDisplayLanguage(Locale.ENGLISH) }语言代码集合是ISO 639 代码与 JDK 可用 locale 的语言代码的并集,后者会补充yue这类三字母代码。源码用@Suppress("DEPRECATION")注释解释了为何使用Locale(String)构造器:它保留iw等旧代码,而forLanguageTag会将其规范化。
地区表的抽取逻辑(territories()):
Locale.getISOCountries().toSet().namesBy { Locale.Builder().setRegion(it).build().getDisplayCountry(Locale.ENGLISH) }数据清洗:namesBy会把「展示名为空」或「展示名与代码相同」的条目剔除,避免XX这类无意义的映射污染表。
生成格式:数据以toSortedMap()排序后,按codes与names两个并行listOf字面量输出为internal val声明,代码注释特别强调排序是为了配合NameTable的二分查找;文件头部还会生成一行“Generated at build time … Do not edit.”的防误改提示。
三、生成产物:LocaleTables.kt与NameTable
3.1 生成源码形态
处理器生成的LocaleTables.kt(包名com.quarkdown.core.localization.table)大致如下(示意,实际由构建 JDK 决定数据):
// Generated at build time by the `quarkdown-locale-table-processor` KSP processor. Do not edit. package com.quarkdown.core.localization.table /** * Languages by ISO 639 code. */ internal val Languages: NameTable = NameTable( codes = listOf( "aa", "ab", ... ), names = listOf( "Afar", "Abkhazian", ... ), ) /** * Territories by ISO 3166 country code. */ internal val Territories: NameTable = NameTable( codes = listOf( "AD", "AE", ... ), names = listOf( "Andorra", "United Arab Emirates", ... ), )3.2NameTable:面向二分查找的只读索引
消费端 NameTable.kt 定义了一个基于「有序并行列表」的只读索引,提供三个操作:
contains(code):codes.binarySearch(code) >= 0,用于判断代码是否存在;nameOf(code):查代码对应的英文名,缺失返回null;codeOf(name):按英文名不区分大小写反查代码(equals(name, ignoreCase = true)),这也正是.doclang {English}大小写不敏感特性的底层来源。
由于codes有序,所有查询均为 O(log n) 二分查找。
四、在 Quarkdown 运行时中的消费链路
4.1LocaleLoader抽象与默认实现
LocaleLoader.kt 定义 locale 检索接口:
all:所有受支持的基础语言 locale(不含地区变体);fromTag(tag):按标签解析,如en、en-US、it、fr-CA;fromName(name):按英文名解析,如English、Italian、French (Canada);find(identifier):先按名称、再按标签解析的兜底方法。
其伴生对象SYSTEM明确指向表驱动的默认实现:
val SYSTEM: LocaleLoader get() = TableLocaleLoader4.2TableLocaleLoader:如何用两张表解析
TableLocaleLoader.kt 是两张表的直接消费者:
all遍历Languages.codes,构造不带地区的基础 locale;fromTag:拆分-子标签,语言子标签小写后经Languages.contains校验,地区则取第一个能命中Territories的子标签(大写化后);fromName:经LocaleDisplayName.split拆出语言名与可选地区名,分别用Languages.codeOf、Territories.codeOf反查。
4.3TableLocale与LocaleDisplayName
TableLocale.kt 实现Locale接口,displayName用checkNotNull(Languages.nameOf(code))从表中取英文名,组合出tag(如en-US)与shortTag(如en)。
LocaleDisplayName.kt 定义了Language (Territory)展示名格式的双向能力:format用于拼装(如French (Canada)),split用于解析(对含括号的英文地区名,如Cocos (Keeling) Islands,按首尾(… )配对切分,保证往返一致)。
4.4 完整解析链
TableLocaleLoader→NameTable(Languages/Territories)→TableLocale构成了LocaleLoader.SYSTEM的完整链路;再往上是 ContextLocalization.kt 中通过LocaleLoader.SYSTEM.fromTag("en")得到的默认 locale。.doclang的 locale 解析、docs/localization.qd描述的.localization本地化表键名、以及LocaleNotSetException提示“Tip:.doclang {locale}”(见 LocalizationExceptions.kt),最终都落在这两张编译期生成的表上。
五、测试验证:确定性行为的证据
LocaleTest.kt 对表驱动解析做了系统验证,可作为行为契约:
| 测试点 | 断言 |
|---|---|
| 默认检索器 | TableLocaleLoader即LocaleLoader.SYSTEM |
English/Italian | 标签、全名、大小写变体(eNgLiSh、iTaLiAn)解析一致,displayName正确 |
en-US/fr-CA | 标签与全名(English (United States)、French (Canada))双向一致,countryCode正确 |
| 含括号地区名 | en-CC的displayName为English (Cocos (Keeling) Islands),且可往返解析回原 locale |
| CJK | zh/Chinese/ja/ko均命中且isCJK()为真 |
| 非法输入 | fromTag/fromName/find对nonexistent均返回null |
| 全量加载 | all序列非空 |
六、接入自己的 Kotlin 项目
6.1 构建配置
处理器已注册进主构建:settings.gradle.kts 中include("quarkdown-locale-table-processor")。在消费方quarkdown-core中,仅需在 build.gradle.kts 添加一行 KSP 依赖:
plugins { kotlin("jvm") id("com.google.devtools.ksp") } dependencies { ksp(project(":quarkdown-locale-table-processor")) }构建时,KSP 会自动发现LocaleTableSymbolProcessorProvider并运行处理器,生成的LocaleTables.kt出现在build/generated/ksp/main/kotlin下,随编译进入产物。
6.2 可复用的三件套
若要复刻这套“编译期固化 JDK 数据”的模式,可以照搬本模块的三层结构:
- Provider:实现
SymbolProcessorProvider,在META-INF/services中注册全限定类名; - Processor:持有
CodeGenerator,首轮无条件输出(invoked防重); - CodeGenerator:用构建 JDK 的
java.util.Locale抽取数据 → 清洗 → 排序 → 生成 Kotlin 源码文本。
6.3 注意事项
- 数据源 = 构建 JDK:表内容由执行构建的那台机器上的 JDK CLDR 数据决定,因此团队内应统一构建 JDK 版本,保证产物一致;
- 运行时零 JDK 依赖:
LocaleTables.kt是纯 Kotlin 常量,NameTable只做二分查找,运行环境(包括 GraalVM native image 等受限场景)无需任何java.util.Locale能力; - 不要手改产物:生成文件头部明确标注 “Do not edit.”,任何修改都会在下次构建时被覆盖。
七、小结
locale-table-processor用不到三个源文件,把「构建期数据抽取」与「运行时确定性」结合得很好:LocaleTableSymbolProcessor驱动生成、LocaleTableCodeGenerator负责抽取与排版、NameTable提供二分查找索引,最终由TableLocaleLoader支撑.doclang的 locale 解析。对于需要在多平台产物中固化系统数据的项目,这是一个值得借鉴的 KSP 实践范式。
【免费下载链接】quarkdown🪐 Markdown with superpowers: from ideas to papers, presentations, websites, books, and knowledge bases.项目地址: https://gitcode.com/GitHub_Trending/qu/quarkdown
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考