Skip to content

fix: 修复语言重载与聊天颜色边界#718

Open
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-i18n-chat-boundaries
Open

fix: 修复语言重载与聊天颜色边界#718
zhibeigg wants to merge 1 commit into
TabooLib:dev/6.3.0from
zhibeigg:fix/703-i18n-chat-boundaries

Conversation

@zhibeigg

@zhibeigg zhibeigg commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

原有问题

语言重载、JSON 语言节点和颜色解析在边界输入下会丢失结构或保留旧状态:

  • TypeJsontranslate: 指令分支不可达,嵌套对象、数组和 hover 列表在转换时可能被扁平化或改变类型。
  • 语言资源匹配不够精确;某个内置资源缺失时可能中断整个重载流程。
  • 重载直接修改旧缓存,已从文件删除的语言键仍可能残留,并发读取时也可能看到更新一半的状态。
  • HEX/RGB 解析对前导零、零值、分量宽度和非法表达式处理不一致,非法文本可能被部分吞掉。

典型触发场景与后果

  • 语言 JSON 使用 translate、嵌套 hover 内容或数组:翻译节点失效,hover 数据类型错误,最终消息缺字段或无法发送。
  • 删除、重命名语言文件或缺少某个内置资源后执行 reload:全部语言重载被中断,或者已删除文本仍继续显示。
  • 玩家读取语言缓存的同时进行 reload:可能短暂读到新旧混合数据。
  • 使用 #000000、带前导零的 RGB、可变宽度分量或非法颜色表达式:黑色被误判、颜色值截断,或原始文本丢失。

本 PR 修改

  • 修复 TypeJsontranslate: 解析顺序,并保留嵌套对象、数组和 hover 列表的原始结构类型。
  • 精确匹配语言资源;缺少单个内置资源时记录告警并跳过,不再中断全部重载。
  • 使用 copy-on-write 快照重建并替换语言缓存,删除的键会消失,并发读取始终看到完整旧快照或完整新快照。
  • 严格校验六位 HEX 和三分量 RGB,正确处理零值、前导零和可变宽度分量;非法表达式完整保留原文。
  • 保持命名颜色和 RESET 的传统颜色码行为。
  • 审计确认 Locale 当前没有 Issue 所述 Unicode/CJK 分类逻辑,因此未新增无调用方 API。

修改目的

保证语言内容在复杂 JSON、连续重载和并发读取场景下保持结构完整与状态一致,同时让颜色边界输入可预测且不会破坏用户原文。

兼容性与行为变化

  • 保持公开语言缓存类型、引用和可变视图兼容。
  • reload 会正确移除源文件中已经删除的语言键。
  • 非法颜色表达式由“可能部分消费”改为“原样保留”。

验证

  • ./gradlew :module:minecraft:minecraft-chat:test :module:minecraft:minecraft-i18n:test --rerun-tasks --no-parallel
  • ./gradlew :module:minecraft:minecraft-chat:build :module:minecraft:minecraft-i18n:build --rerun-tasks --no-parallel
  • git diff --check(仅 Windows LF→CRLF 提示)
  • 独立最终复审:无 P1/P2

Refs #703

@FxRayHughes

Copy link
Copy Markdown
Contributor

Code Review — #718 fix: 修复语言重载与聊天颜色边界

这个 PR 的几处修复都站得住,其中 TypeJsontranslate 分支不可达、ResourceReaderkeys.first { } 在缺资源时抛异常中断整个 reload、颜色解析对非法输入的静默吞掉,都是明确的功能缺陷。我实测复现了资源匹配的三种误判和颜色解析的边界差异。

需要讨论的主要是 SnapshotHashMap 继承 HashMap 这个设计选择,以及 PR 描述里「已从文件删除的语言键仍可能残留」这个说法与代码不符。

审阅方式:读 patch + 对照源码 + 实测(资源匹配三场景、新旧颜色解析 16 组输入、命名颜色 alpha 位差异对下游的影响)。未实跑 gradle 测试


🟢 先说三个值得点出的真 bug

a. TypeJsontranslate 分支彻底不可达。 旧代码(TypeJson.kt:65):

val showType = formated(extra["type"].toString(), sender, *args)
when {
    showType == "keybind" -> ...
    // args:
    // - type: translate:1:Stone          ← 注释里的用法带参数
    showType == "translate" -> appendTranslation(showText, *showType.substringAfter(':').split(':').toTypedArray())

注释明确写了配置形态是 type: translate:1:Stone,而判断条件是 showType == "translate" 全等——带参数时永远不匹配,走到 else -> append(showText.colored())。而且即使匹配了也是错的showType == "translate" 成立时 showType.substringAfter(':') 返回整串 "translate"substringAfter 找不到分隔符时返回原串),参数会变成 ["translate"]。双重错误。

新代码用 parseJsonType 先拆出 typeName 再比对,修对了。

b. ResourceReader 的资源匹配。 旧代码 runningResourcesInJar.keys.first { it.startsWith("${Language.path}/$code") } 有两个问题,我实测复现:

=== 场景1: 存在 zh_CN_extra.yml 但缺 zh_CN.yml ===
  旧: lang/zh_CN_extra.yml   ← 误匹配到 _extra
  新: null

=== 场景2: 完全缺少该语言资源 ===
  旧: 抛 NoSuchElementException → 整个 reload 中断
  新: null → warning 后 continue

=== 场景3: 非配置扩展名干扰 ===
  旧: lang/zh_CN.md   ← 可能拿到 .md
  新: lang/zh_CN.yml

场景 2 是最严重的:Kotlin.first { } 在无匹配时抛 NoSuchElementException,而它在 Language.languageCode.forEach 循环内——一个语言缺失会中断所有后续语言的加载。新代码改成 firstOrNull + warning + return@forEach,是必要修复。

场景 1/3 的修法(比对 substringAfterLast('/').substringBeforeLast('.') == code 且校验扩展名)也正确。

c. 颜色解析吞掉非法文本。HexColor.translate&{...} 解析失败时(chatColor == null不推进 i,但也不 append 当前字符——外层 else 分支才 append。实际效果是 &{ 被逐字符 append(因为 if 分支没 append 也没跳过),后续内容正常输出。而解析成功时 i += match.length + 2 跳过整个表达式。

新代码结构清晰得多:找到 } → 尝试解析 → 成功则 append 颜色并 i = end; continue,否则 fall through 到 builder.append(in.charAt(i)) 逐字符保留。非法表达式完整保留原文,符合 PR 描述。


🟡 问题 1 — SnapshotHashMap 继承 HashMap 但父类状态永久为空

final class SnapshotHashMap<K, V> extends HashMap<K, V> {
    private final AtomicReference<HashMap<K, V>> snapshot;

所有读写都委托给 snapshot,父类 HashMap 自身的桶数组永远是空的。我数了一下,覆盖了 27 个方法(size/isEmpty/containsKey/containsValue/get/getOrDefault/keySet/values/entrySet/forEach/put/putAll/putIfAbsent/remove×2/replace×2/replaceAll/computeIfAbsent/computeIfPresent/compute/merge/clear/clone/equals/hashCode/toString),覆盖面确实完整。

继承的动机我理解Language.languageFileLanguageFile.nodes 的声明类型是 HashMap<String, X>,是公开 API,改成 MutableMap 会破坏 ABI。用继承保持类型兼容是务实的选择。

但有两处遗留风险值得注意:

  1. 序列化。 HashMap 实现了 Serializable,writeObjectprivate 无法覆盖,它会遍历父类自己的桶数组——序列化一个 SnapshotHashMap 会得到空 map。serialVersionUID = 1L 也声明了。虽然语言缓存不太可能被序列化,但这是个静默的正确性陷阱,建议加注释说明"不支持序列化"或者覆盖 writeReplace()

  2. 未来 JDK 新增的默认方法。 如果后续 JDK 给 Map/HashMap 加了新方法而没被覆盖,它会读到空的父类状态。这类"覆盖式代理"的维护成本在于每次升级 JDK 都要复查。加个注释提醒即可。

另外 replaceWith 的注释写的是「写入通过 CAS 一次替换」,但实现是 snapshot.set(...) 而非 compareAndSetset 对于"整体替换"场景是正确的(不需要基于旧值),只是注释与实现不符,建议把「CAS」改成「原子引用替换」。

替代方案(如果作者愿意):Language.languageFile 改成 val languageFile: MutableMap<String, LanguageFile> = ConcurrentHashMap(),reload 时用 keys.retainAll(loaded.keys) + putAll。缺点是替换不再原子(有中间态),但 ConcurrentHashMap 的读不会看到撕裂的单个 entry。取决于对"完整快照"的要求有多强。


🟡 问题 2 — PR 描述里「已从文件删除的语言键仍可能残留」与代码不符

描述里说:

重载直接修改旧缓存,已从文件删除的语言键仍可能残留

但旧代码是:

languageFile.clear()
languageFile.putAll(ResourceReader(Language::class.java).files)

clear() 之后不可能有残留。FileWatcher 回调里也是:

it.nodes.clear()
loadNodes(sourceFile, it.nodes, code)
loadNodes(Configuration.loadFromFile(file), it.nodes, code)

同样先 clear。所以「删除的键残留」这个问题我在旧代码里找不到对应。

真正被修复的是并发可见性clear()putAll() 之间存在一个窗口,此时并发读取会看到空的只有一半的缓存。玩家在 reload 瞬间触发消息发送就会拿不到语言节点。replaceWith 的原子引用替换消除了这个窗口——这个价值是实打实的,PR 描述后半句「并发读取时也可能看到更新一半的状态」说的就是它。

建议把描述里「已从文件删除的语言键仍可能残留」这句去掉或改写,否则评审时会去找一个不存在的 bug。如果作者确实观察到了残留现象,请补充触发路径——可能是我漏了某条 reload 路径。


🟡 问题 3 — keybind / selector / score 分支未同步改造

本 PR 只把 translate 改成基于 typeName 判断,其余分支仍用原始 showType

val (typeName, typeArgs) = parseJsonType(showType)
when {
    typeName == "keybind" -> appendKeybind(showText)
    typeName == "selector" -> appendSelector(showText)
    typeName == "translate" -> appendTranslation(showText, *typeArgs.toTypedArray())
    showType == "score" -> appendScore(...)              // ← 仍是 showType
    showType.startsWith("gradient") -> ...              // ← 仍是 showType

keybind / selector 改成了 typeName,行为上等价(这两个本来就不带参数,parseJsonType("keybind").first == "keybind")。但 score 保留了 showType == "score"——如果用户写 type: score 没问题,写 type: score:xxx 则不匹配。而 gradientstartsWith 所以带参数正常。

三种判断风格并存(typeName ==showType ==showType.startsWith)容易让后续维护者困惑。建议统一成 typeName ==,gradient 的参数从 typeArgs 取——这样 parseJsonType 的引入才算完整。


🔵 次要

a. parseColor 对逗号/连字符混用的处理。 判断顺序是先看有没有 ,,没有才看 -。所以 "1,2-3" 会按 , 分割成 ["1", "2-3"],长度 2 → 返回 null。行为正确(拒绝),只是这个短路顺序意味着 - 分隔符在含逗号时永远不生效。当前没问题,提一下备查。

另外负数 RGB:"-1-2-3" 会按 - 分割成 ["", "1", "2", "3"]split(-1) 保留空串),长度 4 → 返回 null。旧代码 "-1-2-3".split('-') 同样得 4 项,destructuring 取前 3 项 ["", "1", "2"]"".toIntOrNull() ?: 0 = 0,得 0x000102。新版拒绝更合理。

b. 命名颜色的返回值少了 alpha 位。parseToHexColor 命名颜色返回 chatColor.color.rgb(含 0xFF000000),新 parseColor 返回 & 0xFFFFFF。我实测确认这个差异在所有现有调用路径上都不影响结果

new Color(-1)        -> r=255 g=255 b=255
new Color(0xFFFFFF)  -> r=255 g=255 b=255
旧 white: (v shr 16) and 0xFF = 255
新 white: (v shr 16) and 0xFF = 255

TextBlock.kt:151Color(color.parseToHexColor()) 忽略 alpha,Int.mixUtil.kt:20-25)按 red/green/blue 扩展属性提取分量。所以视觉结果一致。但如果有插件把这个 int 存起来做等值比较,-1 != 16777215。属于极边缘情况,列出来备查。

c. #000000 在旧代码里其实没被误判。 PR 描述说「使用 #000000 ... 黑色被误判」,我实测旧代码 "#000000".substring(1).toIntOrNull(16) = 0,而 ?: 0 的 fallback 也是 0——结果都是 0,与合法解析结果相同。所以旧代码对 #000000返回值是正确的,只是无法区分"合法黑色"与"解析失败"(两者都返回 0,且都不 warning)。新代码用 Integer 可空返回把这两种情况分开了,这是真改进,但描述里说"黑色被误判"不太准确。

d. HexColor.translatetrim() 引入了新的容忍度。 新代码 in.substring(i + 2, end).trim(),即 &{ #ff0000 } 现在能解析。旧代码不 trim,带空格会失败。这是行为放宽(更友好),但没在兼容性说明里提。同理 parseColorpart.trim()&{ 10 , 20 , 30 } 可用——我实测旧代码这个输入返回 0x000000(全部 toIntOrNull 失败 fallback 0),新代码返回 0x0A141E。属于修复。

e. SnapshotHashMap.keySet().iterator() 每次都复制。 new HashMap<>(snapshot.get()).keySet().iterator() 会完整复制一次 map。Language.kt:146languageFile.keys 在 OpenAPI 调用里用到,语言文件数量不大(通常 < 10)所以无影响。但 LanguageFile.nodes 的键可能有数百个,如果有代码在热路径上遍历 nodes.keys 会有额外开销。当前仓库内没有这种用法。

f. addLanguage 的短路优化。 code.fold(false) { result, value -> languageCode.add(value) || result } —— 注意 || 的短路:languageCode.add(value) 在左侧所以一定会执行,不会漏加。写法正确。改动价值是避免重复 addLanguage 相同语言时的无谓 reload,合理。


🟢 已核对无误

结论
translate 分支不可达 showType == "translate" 与注释里的 translate:1:Stone 形态矛盾;且相等时 substringAfter(':') 返回整串。双重错误,parseJsonType 修对
parseJsonType 的实现 split(':')first 作类型名、drop(1) 作参数。"translate"("translate", []),"translate:1:Stone"("translate", ["1","Stone"])。语义正确
normalizeJsonValue 递归 处理 ConfigurationSection(走 getValues(false))、MapIterableArray 四种容器并递归,标量原样返回。解决了嵌套结构被扁平化的问题
init 的异常处理改进 旧代码整块 try { ... } catch (_: ClassCastException) {} —— 任何一个 arg 结构不对就静默丢弃全部 args。新代码逐项 as? + return@forEach,只跳过坏的那一项
jsonArgs.clear() 的加入 init 直接 addAll,同一 Type 实例被 init 两次会累积。reload 场景下 nodes 是新建的所以实际不会触发,但加上更稳
hover 列表支持 extra["hover"].toString() 对 List 会得到 [a, b] 字面串;新代码 is List<*> -> hoverText(hover.map { ... }) 走多行重载。真修复
findLanguageResource 的精确匹配 实测三场景(_extra 前缀误匹配、缺资源抛异常、非配置扩展名)全部复现,新实现三项都正确
缺资源不中断 reload first { }firstOrNull + warning + return@forEachKotlin.first 无匹配时抛 NoSuchElementException,在 forEach 内会中断整个循环。这是最有价值的一处修复
getTypeFromExtensionOrNull 存在 Configuration.kt:333 确认,用它校验扩展名合理(与配置模块的支持范围一致)
FileWatcher 回调改用新 map 旧代码 it.nodes.clear() 后分两次 loadNodes,并发读会看到不完整状态;新代码构建 reloadedreplaceNodes 一次替换
replaceLanguageFiles 的类型分支 if (target is SnapshotHashMap) replaceWith else clear+putAll。保留了对非 Snapshot 实现的兼容(例如测试注入普通 HashMap)
SnapshotHashMap 覆盖完整性 27 个方法全部覆盖,含 clone/equals/hashCode/toString。读方法委托快照,写方法基于快照复制后 set,不会漏读父类空状态
颜色解析的严格化 实测 16 组输入:#fff(旧 0x000FFF/新拒绝)、#gggggg#ffffffff1,2(旧 0x010200)、1,2,3,4300,0,0(旧溢出 0x12C0000)、-1,0,0(旧 -65536) 全部由"静默错误值"改为"拒绝并保留原文"。真改进
六位 HEX 的正则校验 #[0-9a-fA-F]{6} 精确六位。旧 substring(1).toIntOrNull(16) 会接受任意长度(#fff → 4095、#ffffffff → 溢出 null → fallback 0)
RGB 分量范围校验 `component < 0
split("\\" + separator, -1) 的 limit -1 保留尾部空串,所以 "1,2," 得 3 项但第 3 项解析失败 → null。正确拒绝
命名颜色 alpha 差异不影响下游 实测 new Color(-1)new Color(0xFFFFFF) 的 RGB 分量相同;Int.mix 按分量提取。三处调用点(TypeJson.kt:73TextBlock.kt:80,151)都不受影响
RESET 与命名颜色的传统色码 StandardColors.match 分支保持在 parseColor 之前判断(translateknownColor.isPresent() 优先),命名颜色仍走 toChatColor() 而非 hex。符合"保持传统颜色码行为"的描述
addLanguage 的 changed 判定 languageCode.add(value) 在 `
测试覆盖 ColorParsingTest 覆盖 HEX/RGB/前导零/零值/非法表达式/原文保留;LanguageBoundaryTest 覆盖资源匹配、快照替换、parseJsonTypenormalizeJsonValue。都是纯逻辑测试可在 CI 跑

总结

translate 分支不可达、缺资源抛异常中断整个 reload、颜色解析静默产生错误值,这三处都是实际影响功能的缺陷,修得对。clear()+putAll() 到原子引用替换的改造也解决了真实的并发可见性窗口。

建议处理:

  1. 问题 2(描述与代码不符)——「已从文件删除的语言键仍可能残留」在旧代码里找不到对应(clear() 在前)。真正修的是并发可见性窗口,建议修正描述,否则评审会去找不存在的 bug。
  2. 问题 3score / gradient 未同步)——三种判断风格并存,建议统一到 typeName,让 parseJsonType 的引入完整。
  3. 问题 1SnapshotHashMap 继承 HashMap)——设计动机(保持 HashMap 声明类型的 ABI)我理解,但建议加注释说明"不支持序列化"与"JDK 升级需复查新增默认方法";另外 replaceWith 的注释说 CAS 而实现是 set,建议改措辞。
  4. 🔵 c/d 两条(#000000 其实没被误判、trim() 引入的容忍度放宽)建议在描述/兼容性说明里微调表述。

说明:本次审阅未实跑 gradle 测试(含 PR 描述列出的两条命令)。以下为本机实测:ResourceReader 资源匹配三场景(_extra 误匹配、缺资源抛 NoSuchElementException、非配置扩展名);新旧颜色解析 16 组边界输入对比;命名颜色 alpha 位差异对 new Color(int)Int.mix 的影响。其余结论基于 patch 与仓库源码推导,已逐条注明依据位置。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants