去年我在做一个本地代码片段收藏工具模拟项目X的时候收到一条很具体的修改意见代码列表里的 Java 片段关键字要有颜色并且字母要全部是小写。最初我真没当回事网上高亮方案一抓一大把抄一个不就行了。等到动手才发现需求虽然只有十几个字背后却横着词法边界、字符串转义、注释状态、输出格式适配好几座小山。这篇文章就把这套实现完整拆开讲一遍。内容围绕“Java 关键字 字母全部小写的单词集合”这一事实展开讲清楚哪些词算关键字、为什么正则替换容易翻车、一个单次扫描的词法状态机怎么设计、最终如何同时输出 HTML 和终端 ANSI 彩色效果。适合正在做代码高亮、代码格式化、编辑器插件或者想在技术文档里嵌入带高亮代码片段的人参考。我踩过的坑也会一并列出都是常规文档里不太会写的部分。1. 一个看起来简单的高亮需求实际卡在哪儿1.1 我是在什么场景下接到这个需求的代码高亮这个需求看起来到处都是但真到自己做的时候你会发现大多数在线工具都不适合本地集成。当时我的项目需要把收藏的代码片段统一渲染成带颜色的块在内部 Wiki 和分享群里都能直接看。做过类似事情的朋友应该能体会复制到网页工具再复制回来格式会丢截图的话水印和底色跟自己的页面风格又不搭。于是只能自己写。“字母全部小写”这条来自一个更生活化的痛点。团队里有段时间喜欢从书上 OCR 抓代码样例抓出来的关键字经常是Class、INT、Return这种稀奇古怪的大小写。展示代码时把这种不规范写法统一成小写再高亮出来至少看起来不像一个复制失败的半成品。另外一个隐藏使用场景是教学给初学者展示代码时高亮出所有关键字能很直观地告诉对方哪些词不能拿去当变量名、类名用。所以这个需求真正服务的对象是“展示型代码片段”不是编译器。想明白这一点很重要后面很多设计决策都是从这里推出来的。1.2 拆开“Java关键字全小写高亮”之后的三层难点第一层难点是名单问题。Java 到底有哪些关键字如果只背出if、for、while这几个显然不够。JLS 里列出的 51 个保留关键字还算明确但true、false、null严格说不是关键字而是字面量到底要不要高亮var、record、sealed这些新玩意算不算如果把String、main、println也一起标成关键字那整段代码花花绿绿反而失去重点。关键字集合的粒度没定好后面全是白搭。第二层是位置问题。高亮不是看到“int”三个字母就标颜色。integer里的int不能标StringBuilder里的String也不能动。更麻烦的是字符串里出现的class注释里出现的return这些都只是普通文本不是关键字。这要求处理过程具备词法状态感知而不是纯文本替换。第三层是大小写问题。Java 关键字本身是严格小写的所以这个需求才特别强调“字母全部小写”。但是输入文本可能是 OCR 抓出来的也可能是别人随手写的CLASS这就有一个取舍要不要把非小写形态也归化成关键字如果归化效率高了但可能误伤Class这种合法类名不归化又没法满足“字母全部小写”的展示要求。我的做法是做成可开关的选项下面会详细讲。加上输出目标也要考虑。同一份扫描结果展示在 HTML 页面里是一套标签展示在终端里是 ANSI 转义码嵌到 Swing 组件里又是另一套格式。一种实现如果能一次扫描、多处渲染那才是真的省心。2. 先把关键字边界钉死什么样的词才配进高亮名单2.1 从保留关键字到 true/false/null 的名单取舍Java 官方保留关键字一共有 51 个它们在任何位置都不能作为标识符使用。我用一张表把它们分成几组方便记忆分组关键字访问控制与修饰public, protected, private, abstract, static, final, strictfp, synchronized, native, transient, volatile类、接口与对象class, interface, enum, extends, implements, new, this, super, instanceof包与模块package, import基本类型boolean, byte, char, short, int, long, float, double, void控制流if, else, switch, case, default, break, continue, while, do, for, return异常与断言try, catch, finally, throw, throws, assert保留备用const, goto清单之外几个容易含糊的词需要单独决策true、false、null属于“字面量”不是保留关键字但几乎所有代码高亮工具都会把它们标出来。原因很简单它们在阅读代码时起到的作用和关键字一致都是语言内置的固定标记。我的工具里默认把这三个字面量纳入高亮名单这样一眼看过去不会被漏掉。var在 Java 10 之后是“保留类型名”不是关键字record、sealed、yield这类则在对应 Java 版本里逐渐出现。它们共同的特点是在特定语法位置有意义但也能在某些场景下作为普通标识符存在。所以我把它们单独放进“上下文关键字”集合默认关掉使用者按需打开。这一点在第 5.4 节还会展开。2.2 单词边界和“字母全部小写”的识别规则关键词匹配必须满足“独立单词”约束。Java 标识符由字母、数字、下划线、美元符号组成数字不能开头但字符集支持 Unicode所以你光用“前后是空格或标点”判断边界是不够的。正确做法是使用 Character 类的isJavaIdentifierStart和isJavaIdentifierPart判断字符是否属于同一单词。举个例子a_class是一个独立标识符它的某个部分class不能高亮类名这种中文变量名虽然不是英文单词但按 Java 词法它是合法标识符同样不能被拆开。至于“字母全部小写”这一条要回到 Java 关键字本身的词法事实所有真正的关键字都是由小写字母组成的。这意味着识别时有两个策略严格策略候选单词必须原样就是全小写才进关键字集合比对。这样做最符合 Java 语言规范不会误伤Class类名但 OCR 得到的大小写混乱文本救不回来。归化策略候选单词先转成小写再进关键字集合比对命中后输出小写形式并高亮。这正好满足标题里“字母全部小写”的展示诉求代价是Class也会被当class高亮。我的工具把归化策略作为默认同时保留一个开关。用于编辑器、评审类工具时建议关掉用于展示截图、OCR 纠错时开着很香。后面第 4.1 节的代码会体现这个设计。2.3 版本差异上下文关键字该不该进名单Java 的关键字集合不是一成不变的。assert在 Java 1.4 前不是关键字enum在 Java 5 才正式加入var、record更是近几年的产物。如果你的工具要展示 Java 17 的代码却把record当作普通标识符处理那视觉重点就丢了反过来老代码里本来有人拿var当变量名强制高亮会很怪。我的处理是把关键字分成两层第一层是 51 个保留字加上true/false/null任何版本的 Java 都跑不掉第二层是上下文关键字按版本枚举Java 版本建议加入的关键字9module, open, opens, requires, exports, uses, provides, with, to, transitive10var13yield14record (正式版 16)17sealed, permits, non-sealed这样工具既能默认覆盖绝大多数常见场景又能在需要的时候精确适配某个特定版本。3. 正则替换为什么不行我最后选了词法扫描器3.1 三条实现路线的对比动手前我列了三条路线把它们对“这个需求”的适配程度对比一下路线优点痛点正则一次性替换开发快三五分钟能跑排除字符串/注释的正则极其复杂容易回溯导致性能崩掉大小写归化回调麻烦引入开源高亮组件覆盖语言多测试充分依赖体积大关键字表与输出粒度难定制“全小写归化可开关”这种需求很难通过配置完成自研词法扫描器状态完全可控、单次 O(n)、输出可扩展核心逻辑需要仔细写几十行为什么最终选了自研扫描器因为这个需求里有一条硬约束“字符串和注释里的关键字不能高亮”。要满足它正则必须先识别出字符串、单字符、行注释、块注释再排除这些区域最后处理剩余的候选词。你可以想象那个正则会有多长。更糟的是匹配结果一旦再被套一层 HTML 标签第二次运行正则时标签里的关键字又会混进来必须写特别复杂的排除规则。词法扫描器天然就是按状态一步步走的看到双引号进字符串状态看到注释符进注释状态每次只处理一个字符逻辑一目了然。性能上单次遍历 O(n)不会因为输入变大而出现正则回溯那样的指数级退化。3.2 扫描器状态机设计整个扫描器只维护一个状态变量取值是四种状态普通代码、字符串、字符字面量、行注释、块注释。核心循环里做的事情可以归纳成一句话根据当前状态处理当前字符再决定下一个状态。状态迁移关系如下普通代码状态下遇到进入字符串状态遇到进入字符字面量状态遇到//进入行注释状态直到换行符回到普通代码遇到/*进入块注释状态直到*/回到普通代码普通代码状态下收集连续标识符字符组成候选单词遇到非标识符字符就结束当前单词并判断是不是关键字。这个设计最大的好处是可以轻易扩展。将来想高亮数字、注解、泛型类型参数都只需要在对应状态里加分支不影响已有的字符串和注释处理。3.3 为什么先跳过字符串和注释那么关键字符串和注释是高亮功能最容易翻车的地方。很多人写高亮只用一行正则把关键词一配就完事结果String s class;里的 class 也被标了红色// TODO return里的 return 也被标了。代码一多这种错误特别扎眼。原因很简单关键字身份取决于单词在源码里的词法位置。词法位置不被解析出来任何文本替换都是盲目的。状态机把字符串、字符、注释单独作为一种“受保护区域”在这些区域里所有字符原样输出不做单词收集自然不会误伤。4. 核心代码实现与输出效果4.1 可运行的扫描器核心下面是一个精简但完整可跑的版本。它把“大小写归化”做成默认开启的选项扫描时同时处理字符串、字符、注释三类干扰区域。import java.util.Arrays; import java.util.HashSet; import java.util.Locale; import java.util.Set; public class JavaKeywordHighlighter { public enum Mode { NORMAL, STRING, CHAR, LINE_COMMENT, BLOCK_COMMENT } private static final SetString KEYWORDS new HashSet(Arrays.asList( abstract, assert, boolean, break, byte, case, catch, char, class, const, continue, default, do, double, else, enum, extends, final, finally, float, for, goto, if, implements, import, instanceof, int, interface, long, native, new, package, private, protected, public, return, short, static, strictfp, super, switch, synchronized, this, throw, throws, transient, try, void, volatile, while )); private static final SetString LITERALS new HashSet(Arrays.asList( true, false, null )); private final SetString effectiveKeywords new HashSet(KEYWORDS); private final boolean normalizeCase; public JavaKeywordHighlighter() { this(true); } public JavaKeywordHighlighter(boolean normalizeCase) { this.normalizeCase normalizeCase; effectiveKeywords.addAll(LITERALS); } private enum Style { HTML, ANSI } public String renderHtml(String code) { return render(code, Style.HTML); } public String renderAnsi(String code) { return render(code, Style.ANSI); } private String render(String code, Style style) { StringBuilder out new StringBuilder(code.length() 64); StringBuilder word new StringBuilder(); Mode mode Mode.NORMAL; for (int i 0; i code.length(); i) { char c code.charAt(i); char next (i 1 code.length()) ? code.charAt(i 1) : \0; if (mode Mode.LINE_COMMENT) { appendRaw(out, c, style); if (c \n) mode Mode.NORMAL; continue; } if (mode Mode.BLOCK_COMMENT) { appendRaw(out, c, style); if (c * next /) { appendRaw(out, /, style); i; mode Mode.NORMAL; } continue; } if (mode Mode.STRING) { appendRaw(out, c, style); if (c \\ next ! \0) { appendRaw(out, next, style); i; } else if (c ) { mode Mode.NORMAL; } continue; } if (mode Mode.CHAR) { appendRaw(out, c, style); if (c \\ next ! \0) { appendRaw(out, next, style); i; } else if (c \) { mode Mode.NORMAL; } continue; } // 普通代码状态 if (word.length() 0) { if (c / next /) { appendRaw(out, /, style); appendRaw(out, /, style); i; mode Mode.LINE_COMMENT; continue; } if (c / next *) { appendRaw(out, /, style); appendRaw(out, *, style); i; mode Mode.BLOCK_COMMENT; continue; } if (c ) { appendRaw(out, c, style); mode Mode.STRING; continue; } if (c \) { appendRaw(out, c, style); mode Mode.CHAR; continue; } } if (word.length() 0 ? Character.isJavaIdentifierStart(c) : Character.isJavaIdentifierPart(c)) { word.append(c); } else { if (word.length() 0) { flushWord(out, word, style); } appendRaw(out, c, style); } } if (word.length() 0) { flushWord(out, word, style); } return out.toString(); } private void flushWord(StringBuilder out, StringBuilder word, Style style) { String original word.toString(); String lower original.toLowerCase(Locale.ROOT); boolean hit effectiveKeywords.contains(original) || (normalizeCase effectiveKeywords.contains(lower)); if (hit) { String display normalizeCase ? lower : original; if (style Style.HTML) { out.append(span class\java-kw\).append(display).append(/span); } else { out.append(\u001B[36m).append(display).append(\u001B[0m); } } else { appendRaw(out, original, style); } word.setLength(0); } private void appendRaw(StringBuilder out, char c, Style style) { if (style Style.HTML) { switch (c) { case - out.append(lt;); case - out.append(gt;); case - out.append(amp;); default - out.append(c); } } else { out.append(c); } } }几个值得解释的设计点normalizeCase默认 true这意味着Class会被归化成小写的class再高亮。如果工具使用者是写代码的老手在编辑器场景下更希望严格模式所以我把构造参数留了出来。HTML 模式下普通文本里的、、会转义。关键字高亮标签里的内容一定来自小写字母集合不包含特殊字符所以不会破坏 HTML 结构。字符处理时字符串里遇到反斜杠会跳过下一个字符这是为了正确处理\、\\这类转义避免把字符串提前闭合。4.2 同一套结果切换 HTML 和 ANSI 输出为什么我要在类里同时提供renderHtml和renderAnsi因为同一套扫描逻辑在两个常见场景里都能直接用网页、博客、Wiki 上用 HTML 模式span 标签配合 CSS 就能控制颜色终端小工具、命令行日志、开发调试输出用 ANSI 模式\u001B[36m是青色\u001B[0m是重置。实际调用非常简单JavaKeywordHighlighter highlighter new JavaKeywordHighlighter(true); String demo public static void main(String[] args) { System.out.println(\return\); }; System.out.println(highlighter.renderHtml(demo)); System.out.println(highlighter.renderAnsi(demo));跑起来后HTML 输出是带span classjava-kwpublic/span这样的片段ANSI 输出在支持颜色的终端里会直接显示青色关键字。一次扫描两处复用后续想再加输出格式只需要在appendRaw和flushWord两个地方扩展。4.3 性能实测一次扫描比多轮正则快在哪我在模拟项目X里用一份约 12 万字符的真实 Java 源码做过对比。自研单次扫描耗时约 90 到 120 毫秒这还是开启了 HTML 转义的情况。同样内容用一个“排除字符串/注释后再匹配关键字”的正则耗时在 300 到 800 毫秒之间波动如果源码里有一些能触发回溯的边界情况甚至会到秒级。差距产生在算法结构上正则要做到同样的效果本质上是多次扫描 多次匹配词法扫描器只把每个字符过一遍状态迁移和单词拼接都是常数时间操作。对大部分代码片段来说两者都能接受但如果你要做一个打开文件实时高亮的编辑器插件扫描器方案明显更有余量。另外一个小优化初始化 StringBuilder 时直接传入code.length() 64减少了扩容次数。这个细节在大文件场景下能省不少时间。4.4 测试样例组合高亮功能最容易出问题的地方不是典型代码而是各种边缘情况。我给自己定了一个最小测试集输入片段期望结果int count 3;int高亮String s class;字符串里的class不高亮// return value行注释里的return不高亮/* public void */块注释里的public、void不高亮char c \;单字符转义不破坏状态String path a\\b;字符串内双反斜杠不提前结束integer完整单词整个不高亮不是只匹配其中的int中文变量普通标识符不高亮并完整保留只要这组用例能通过常规代码的高亮基本不会出大问题。建议你也照着这个思路维护一个自己的回归用例表。5. 我踩过的坑和修正方式5.1 不依赖 \b 的边界问题第一次写边界逻辑时我图省事直接在正则里用\b。后来发现它在 Java 的 Unicode 世界里表现很飘对中文变量附近、全角符号旁边、甚至紧跟着$符号的情况边界判断常常和直觉不一致。改用手写扫描器之后这个问题从根上消失了因为每次收集单词都严格使用isJavaIdentifierStart/isJavaIdentifierPart判断边界完全透明。即使你坚持用正则也建议把\b替换成(?![a-zA-Z0-9_$])和(?![a-zA-Z0-9_$])这样明确的前后断言至少语义更可控。注意$也被排除在外了因为 Java 标识符可以包含$a$class是一个完整标识符中间不能拆开高亮。5.2 字符串头尾引号和转义陷阱字符串里的关键字不能被高亮这个大家都能想到但字符串的结束位置判断容易翻车。最典型的例子是String s he said: \hello\;如果你只找下一个双引号会在\里的那个引号处提前结束字符串状态导致后面hello又进入普通代码状态整个匹配乱套。解决方法就是代码里那一条在字符串状态遇到反斜杠时跳过反斜杠并直接吞掉下一个字符。这样\就不会被当成字符串结束。单字符字面量同理\这种写法也必须靠转义感知才能正确处理。还有一个隐藏很深的点Java 编译期会在词法分析前处理 Unicode 转义。也就是说\u0022会被当成处理可能影响字符串边界。我的扫描器按展示文本处理不去模拟这一层因为展示工具显示的是源码字符本身不是编译后的字符流。如果要做编译器级别的代码分析这点需要单独考虑。5.3 大小写转换的 Locale 事故第一次实现归化策略时我直接写了word.toLowerCase()。代码在中文环境下测得好好的直到有同事提醒我某些系统 Locale 下字符I的小写形式不是i而是带点或不带点的特殊字符。这在土耳其语环境下尤其明显INT会被转成奇怪的结果导致关键字匹配失败。修正方案是统一使用toLowerCase(Locale.ROOT)不受运行环境语言影响。类似问题也出现在文件名大小写比较、排序等场景里凡是“不依赖自然语言语义”的转换一律锁死 Locale.ROOT 是最稳的。5.4 只高亮关键字别顺手高亮 String 和 main早期版本为了方便我把常见类名String、System、List还有方法名main、println都加了进去。结果打开高亮效果一看满屏都是颜色根本分不清主次。后来我把这些全部移除只保留标准关键字加上true/false/null视觉一下就清爽了。另外注意return在// return里不能高亮但在字符串里出现也属于误伤。这些在测试集里都覆盖了。如果你开启了上下文关键字也一定要先确认目标代码的 Java 版本否则老代码里的变量名record会被高亮成关键字看着就像在报错。6. 从 Java 到更多语言这套扫描器的复用边界6.1 换关键字表就能跑 C/Python/Go状态机最值钱的部分是“受保护区域”的处理。做完 Java 之后我发现同一套结构完全可以迁移到其他语言只要换关键字表和注释规则。C 家族语言和 Java 的注释规则几乎一样字符串和单字符处理也类似替换关键字集合即可。Python 的差异大一点注释是#字符串有三种引号组合还有三引号多行字符串。需要给状态机增加新的状态比如TRIPLE_STRING。Go 的整体规则接近 C但原始字符串用反引号包裹里面不需要转义这也是一个独立状态。Rust 更特殊块注释支持嵌套/* /* */ */是合法的状态迁移时需要用计数器记录嵌套深度。所以说这套扫描器适合作为“起点”而不是万能方案。每换一门语言第一件事就是检查它的字符串和注释规则而不是只替换关键字集合。6.2 接入 Swing、JavaFX 和 Markdown 工具的小心得实际集成时HTML 输出最通用。Swing 里的JEditorPane设置为text/html类型后可以直接把renderHtml的结果塞进去JavaFX 用WebView加载 HTML 字符串也非常简单。Markdown 工具则可以把结果嵌入到precode结构里再配合 CSS 定义.java-kw的颜色。我在模拟项目X里用的是 HTML 输出 CSS 定制颜色效果比较理想。需要注意一点大文件重新渲染时不要每次全量替换 UI 内容最好在文件变更后只更新变化部分否则滚动位置和选区会跳动。这个优化花了我一个下午但体验提升非常明显。最后说一个实际体会。这套工具用下来最大的感悟是“少高亮”比“多高亮”重要。刚开始总想把什么都标出来后来收敛到标准关键字加字面量才找到合适的粒度。建议你也先把自己的最小测试集跑一遍至少覆盖字符串、注释、泛型、中文变量名这四类场景能过这四关再往上接 IDE 或编辑器都不慌。
阅读完成 · 觉得有帮助?