很多人第一次在编辑器里写Java时,都会觉得满屏白字黑底,看着像天书。后来知道了语法高亮这回事,才意识到那些花花绿绿不是装饰,而是把代码结构直接摆在你眼前的信息导航。但有一个细节被很多人忽略:Java关键字是字母全部小写的,而且一旦写错大小写,编译器会直接翻脸。IDE默认高亮方案虽然能标出关键字,却很少把“关键字大小写写错”这件事单独拎出来警告。所以我做这个小工具时,把“字母全部小写”和“单词高亮显示”这两件事绑在了一起,既能把代码关键字用颜色区分开,又能顺手揪出那些悄悄变成大写的危险写法。
这个小工具做出来之后,主要解决三件事:第一,把代码里的Java关键字全部识别出来并按词法类型上色;第二,检查那些被写成大写或混写的关键字,标红警告;第三,在注释、字符串、注解这些场景里不搞误伤。适合刚学Java的初学者、需要批量做代码审查的老手,以及想在文章、笔记、邮件里优雅展示代码片段的写作者。
1. 需求拆解:一个高亮工具为什么非要盯着“小写”不放
1.1 Java关键字的“小写”是语法边界,不是风格问题
先明确一个概念:Java关键字是语言预留的、具有特殊含义的单词,比如 public、static、void、class、if、for、while 这些。它们不是普通的标识符,而是语法的一部分。Java本身区分大小写,所以 Class 不等于 class,For 不等于 for,INT 也不等于 int。
这里有个容易混淆的点:String 和 System 这些类名是首字母大写的,它们不是关键字;class 是关键字,必须全小写。初学者经常一上来就把 class 写成 Class,编译器立刻报错。IDE虽然会给出错误提示,但错误列表里的信息对新手来说往往不够直观,不如在代码里直接把写错的关键字用红色高亮标出来,一眼就能看到问题在哪一行。
所以这个工具把“关键字高亮”和“小写校验”合并处理:先扫描代码,把所有单词从语法层面分离出来,再判断这个单词是否属于关键字表,最后检查大小写是否完全匹配。三步走完,既完成了高亮,也完成了纠错。
1.2 “单词高亮”不是“字母高亮”,边界处理才见真功夫
标题里的“单词高亮显示”很有意思。它强调的是关键词作为一个完整单词被高亮,而不是单词里的一部分被高亮。比如 classify 这个单词,里面有 class 这四个字母,但它不是Java关键字,不应该被标成关键字颜色。再比如 myPublic、publicValue,它们虽然包含关键字片段,但整体是合法的自定义标识符,同样不能误伤。
这背后的技术难点叫“词法边界”。在英文文本里,单词和单词之间通常靠空格、标点、换行隔开,但代码里还有下划线、美元符号、点号、尖括号这些特殊字符。Java标识符的合法字符是字母、数字、下划线和美元符号,其中数字不能开头。所以一个完整的Java单词,要么是连续的字母数字下划线美元符号,要么就是单个符号。
如果只用朴素的字符串查找去匹配关键字,classify 就会被错误命中,输出花里胡哨的高亮结果,误导读者。真正可靠的做法是先做“单词切分”,把代码切成一个个Token,再对切出来的完整单词做关键字判断。这个思路和我后面用的词法扫描方案是一致的。
1.3 适用场景与影响范围:从教学到评审全覆盖
这种小工具看起来不起眼,实际适用的场景比想象中广:
- 教学场景:初学者在终端里用记事本或轻量编辑器写作业时,没有IDE的实时高亮。把代码丢进这个小工具,能立刻看到关键字颜色和大小写错误,学习成本极低。
- 代码评审辅助:我见过不少团队在做Code Review时,光靠肉眼扫描上百行的代码,经常漏掉
public写成Public这种低级问题。用脚本批量检查代码文件并输出带颜色的报告,比人眼可靠得多。 - 文档写作:写技术博客、做PPT、往知识库贴代码片段时,很少有编辑器自带漂亮的Java高亮。这个工具可以把源码转换成带HTML标记的代码块,贴到网页里就有高亮效果。
- 教学实验:它也是一个很好的词法分析入门Demo。做一遍这个工具,基本就理解了编译器前端的第一步:“词法分析器到底在干什么”。
影响范围不在大,而在精准。把一个小问题解决透,价值就出来了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:从正则替换到Token扫描,怎么选才不踩坑
2.1 最朴素的正则替换方案,看上去很美
第一种想法是直接用正则表达式做全局替换,把关键字套上颜色标记。比如:
python复制import re
KEYWORD_SET = {'public', 'static', 'void', 'class', 'if', 'else'}
STYLE = {
'keyword': '\033[1;35m',
'reset': '\033[0m'
}
def simple_highlight(code):
pattern = re.compile(r'\b(?:' + '|'.join(sorted(KEYWORD_SET, key=len, reverse=True)) + r')\b')
return pattern.sub(lambda m: STYLE['keyword'] + m.group(0) + STYLE['reset'], code)
这个方案的思路是把所有关键字用 \b...\b 边界包裹起来匹配,替换成带ANSI色码的字符串。对于最简单、没有任何注释和字符串的代码,它确实能正常“上色”。
但它的致命伤也很明显:
- 注释里的关键字会被高亮。比如
// 这里写了一个class这一行注释,里面的class会被染成醒目的关键字颜色,读起来特别奇怪。 - 字符串里的关键字会被高亮。
System.out.println("class")里的class也会被染色,而它只是一个普通的字符串内容。 - 大小写错误无法被显式警告。
If x > 0这种代码,正则只会把If当作普通单词忽略掉,不会给它任何提示,这等于把最重要的纠错功能丢掉了。 - 边界处理有隐患。
\b边界对$和_的处理在Java标识符规则下虽然基本能凑合,但遇到my$class这种合法标识符,还是可能出现误判风险。
我在最开始写第一版时用的就是这种正则方案,给一个含大量注释的代码片段做高亮,出来的效果堪称灾难。整个注释块一半是紫色一半是绿色,看起来像调色盘打翻了。
2.2 词法分析状态机:让代码自己“说话”
正则方案的问题是它根本不懂代码结构,只是在文本上盲目匹配。真正可靠的做法是模拟词法分析器,逐字符扫描源码,用一个状态机来区分当前处于什么上下文。
思路很直白:代码文本就像一条输送带,工具从头到尾一个字符一个字符地读取,每读一个字符都根据“当前状态”决定下一个状态。状态包括:
- 代码区(CODE):正常写代码的位置。
- 单行注释(LINE_COMMENT):进入
//之后,一直持续到行尾。 - 块注释(BLOCK_COMMENT):进入
/*之后,持续到*/。 - 字符串(STRING):进入双引号之后,持续到匹配的双引号。
- 字符字面量(CHAR):进入单引号之后,持续到匹配的单引号。
- 注解(ANNOTATION):遇到
@开头的单词,比如@Override。
每个状态都有对应的“出口条件”。比如在字符串状态里,遇到反斜杠转义的下一个字符要跳过,不能把 \" 中的引号当真出口。这种逐字符处理的方式,天然就能解决注释和字符串里的关键字误判问题,因为工具知道当前正处在字符串内部,不会去匹配关键字。
有人会问,这样会不会太慢?这里有个数据:对一个几百KB的Java源文件,用Python写朴素的逐字符状态机,跑一遍大约在几十毫秒到几百毫秒之间,完全可接受。对个人工具和教学演示来说,性能不是瓶颈,正确性才是。
2.3 代码形态选择:命令行工具还是编辑器插件
这个工具最终做成什么形态,也是一个需要想清楚的问题。我选择的方案是独立命令行工具 + 可选HTML输出,原因有三:
第一,独立脚本没有IDE版本兼容性问题。Java的语法高亮机制在不同编辑器里差异很大,一个插件要适配多个编辑器成本很高,而一个纯Python脚本在任何操作系统上都能运行。
第二,命令行工具可以接入其他工作流。比如写个批量脚本,把所有 .java 文件都跑一遍,输出结果到一个HTML文件里,就等于生成了整个项目的语谱图。
第三,对教学演示来说,命令行里面直接看到带颜色的输出,比插件更直接地展示“高亮到底是怎么实现的”。
如果你需要在IDE里使用高亮,那可以直接用IDE自带的主题定制,不冲突。我这个工具对应的是“IDE之外的轻量场景”。
3. 实操落地:从零写一个关键字检查与高亮工具
3.1 准备好完整的Java关键字表
第一步是准备关键字表。Java的关键字和字面量不是只有十几个,而是几十个,写全了才不会漏。我整理了一份供参考:
python复制JAVA_KEYWORDS = {
# 控制与类结构
'abstract', 'assert', 'class', 'extends', 'final', 'implements',
'interface', 'native', 'new', 'package', 'private', 'protected',
'public', 'static', 'strictfp', 'super', 'synchronized', 'this',
'throw', 'throws', 'transient', 'volatile',
# 数据类型与流程控制
'boolean', 'byte', 'char', 'double', 'enum', 'float', 'int',
'long', 'short', 'void', 'var',
'break', 'case', 'catch', 'continue', 'default', 'do', 'else',
'for', 'goto', 'if', 'instanceof', 'return', 'switch', 'while',
'try', 'finally',
'import', 'module', 'opens', 'permits', 'provides', 'record',
'requires', 'sealed', 'to', 'uses', 'with', 'yield',
# 字面量
'true', 'false', 'null',
}
这里我加入了新版Java里出现的 record、sealed、permits、yield 等,以及 var。需要注意的是,const 和 goto 虽然是不再使用的保留字,但依然不能用作标识符,所以也应当保留在关键字表里。
整理这个表的时候要特别小心:JDK版本不同,关键字集合会有增减。如果只针对某一个版本,可以筛掉新关键字,但作为通用工具,宁可全一点。
3.2 用正则Token扫描替代手写状态机
其实完全手写状态机代码比较啰嗦,还有一个不错的中间路线:用正则表达式作为“扫描器”,一次把一个完整Token切出来。思路是定义一组命名分组,每个分组匹配一类Token,然后按顺序尝试匹配。
我第二版工具采用的方案如下:
python复制import re
TOKEN_PATTERN = re.compile(r"""
(?P<ANNOTATION>@[A-Za-z_$][\w$]*)
| (?P<COMMENT>//[^\n]*|/\*.*?\*/)
| (?P<STRING>"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')
| (?P<WORD>[A-Za-z_$][\w$]*)
| (?P<NUMBER>\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)
| (?P<OTHER>[^\sA-Za-z_$])
""", re.DOTALL | re.VERBOSE)
这个模式的核心逻辑是:从当前位置开始,优先匹配注解、注释、字符串、单词、数字,最后才是杂项符号。re.DOTALL 是为了让 .*? 能跨行匹配块注释。
由于正则表达式带有 re.VERBOSE,它内部会忽略空白和注释,所以可以在模式里写得很直观。这个扫描器的输出是一个个Token,每个Token都带有类型标签。下一步就是对WORD类型的Token做关键字判断:
python复制def classify_word(word):
if word in JAVA_KEYWORDS:
return 'keyword'
lower_word = word.lower()
if lower_word in JAVA_KEYWORDS:
return 'keyword-error'
return 'identifier'
这里有两个判断:一是完全匹配关键字,返回正常关键字;二是小写化之后匹配,说明大小写写错了,返回 keyword-error。这一步就是“字母全部小写”检查的核心实现。
3.3 高亮渲染:ANSI输出与HTML输出二选一
拿到Token类型后,渲染就很简单了。终端里用ANSI转义码,网页里用HTML标签。
我写了一个统一的渲染函数:
python复制STYLE_MAP = {
'keyword': '\033[1;35m', # 紫色
'keyword-error': '\033[1;31m', # 红色警告
'comment': '\033[2;90m', # 灰色
'string': '\033[0;32m', # 绿色
'annotation': '\033[0;33m', # 黄色
'reset': '\033[0m',
}
def render_ansi(token_type, text):
style = STYLE_MAP.get(token_type)
if style:
return style + text + STYLE_MAP['reset']
return text
再写一个 highlight 主函数,负责调用 TOKEN_PATTERN.finditer 遍历所有Token,然后拼装结果:
python复制def highlight_java(code, renderer=render_ansi):
out = []
for match in TOKEN_PATTERN.finditer(code):
kind = match.lastgroup
text = match.group(0)
if kind == 'WORD':
kind = classify_word(text)
out.append(renderer(kind, text))
return ''.join(out)
如果要用HTML输出,只需要换一个 renderer:
python复制import html
def render_html(token_type, text):
esc = html.escape(text)
if token_type == 'keyword':
return f'<span class="kw">{esc}</span>'
if token_type == 'keyword-error':
return f'<span class="kw-error">{esc}</span>'
if token_type == 'comment':
return f'<span class="comment">{esc}</span>'
if token_type == 'string':
return f'<span class="str">{esc}</span>'
if token_type == 'annotation':
return f'<span class="anno">{esc}</span>'
return esc
这样输出到HTML后,用一段简单的CSS就能得到很干净的代码块效果。整个过程一点不复杂,但正确性比第一版正则替换高了一个量级。
4. 常见问题与排查实录
4.1 注释和字符串里的关键字还是被高亮了,怎么办
出现这个问题的原因几乎都是因为匹配顺序不对。比如你先匹配了 WORD,再匹配 COMMENT,那注释里的单词会先被当作WORD抓走,整个注释结构就被拆碎了。
正确做法是把注释和字符串的匹配放在WORD之前,让它们“先到先得”。在我给的 TOKEN_PATTERN 里,ANNOTATION、COMMENT、STRING 都排在 WORD 前面,就是为了这个目的。
另外要注意块注释的匹配方式。我用了 /\*.*?\*/,这个模式在高亮单个代码片段时足够稳定。但如果你要处理一个不正常结束的注释,比如少了 */,那就需要额外加一层兜底逻辑,否则这个片段会一直匹配到结尾,导致整段代码被当成注释。我在处理第三方代码文件时遇到过几次这种残缺注释,加了一个保护逻辑:如果匹配失败,就强制把剩余部分当作普通代码输出。
4.2 classify 会被误判为关键字吗
这是个好问题。classify 里包含 class,但它是完整单词,不属于关键字表,所以 classify_word 会把它归为 identifier,不会上紫色。这就是Token扫描方案相比朴素文本替换的核心优势。
但这里有一个容易被忽略的细节:Java标识符可以用 $,比如 my$class。在 TOKEN_PATTERN 的 WORD 分组里,我用 [A-Za-z_$][\w$]* 来匹配,所以 my$class 会被当作一个完整单词来处理,不会拆成 my 和 class 两段。如果没有这个设计,$ 就会成为单词边界,class 被单独切出来,就会误判。
用中文场景做类比的话,这就像“麦克风”三个字本来是一个词,你不能因为看到“克”就把它当成“克”字单独处理。Token扫描的核心就是“先切词,再判断”。
4.3 大小写检查会不会误伤类名
Java类名通常首字母大写,比如 PublicClass 这种。classify_word 里首先判断 word in JAVA_KEYWORDS,如果不在,再判断 word.lower() in JAVA_KEYWORDS。PublicClass 的小写是 publicclass,不在关键字表里,所以不会被误判。
真正会被警告的是 Public、Static、Void 这类单词,它们小写化之后恰好落在关键字表里。这正好是想要的效果。当然,如果代码里有个局部变量叫 Public,理论上不合法也会被警告,这是合理的,因为Java不允许关键字作为标识符。
4.4 大文件扫描时性能如何
我用一个大约1MB的代码目录做过测试,里面包含几十个Java文件。逐文件扫描加上ANSI渲染,全部跑完大概在几百毫秒级别,表现不错。如果文件更大,还可以考虑两个优化方向:
- 改用流式处理,不要一次性把整个文件读进内存。
- 把关键字表存成
frozenset,提升in判断的哈希查找速度。
另外一个经验是:不要对同一个文件反复调 highlight_java。如果需要对一个代码库做批量检查,先把所有文件读进来,再循环调用处理函数,避免重复编译正则表达式。
4.5 常见问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 注释里的关键字变成紫色 | Token匹配顺序不对 | 把COMMENT和STRING放在WORD之前 |
| 字符串里的关键字变成紫色 | 字符串类型没有被优先识别 | 检查STRING分组是否在WORD之前 |
classify 被拆成 class 高亮 |
WORD正则不支持 $ 和 _ |
用 [A-Za-z_$][\w$]* 匹配单词 |
Public 没有被警告 |
检查逻辑里漏了 word.lower() 判断 |
加上小写化后的二次匹配 |
| 块注释跨行失败 | 正则没有DOTALL标志 | 编译时加上 re.DOTALL |
| 输出乱码 | 终端不支持ANSI色码 | 改用HTML渲染器 |
5. 扩展与实践心得:从一个高亮脚本到一套生产力小工具
5.1 扩展思路一:输出HTML代码片段嵌入到笔记
我把 render_html 和一小段默认CSS组合起来,可以生成如下风格的代码块:
html复制<style>
.kw { color: #af00db; font-weight: bold; }
.kw-error { color: #d13438; border-bottom: 2px solid #d13438; }
.str { color: #098658; }
.comment { color: #808080; }
.anno { color: #e07b00; }
pre.java-code { background: #1e1e1e; padding: 16px; border-radius: 8px; line-height: 1.6; }
</style>
<pre class="java-code">
<span class="kw">public</span> <span class="kw">class</span> Example { ... }
</pre>
这样生成的内容可以直接粘贴到支持HTML的笔记软件、团队Wiki或文章编辑器里,样式干净,也没有依赖关系。我做技术分享PPT时,经常把代码先跑一遍这个工具再截图,出来的效果比IDE自带的截图更统一。
5.2 扩展思路二:把关键字表换掉,支持更多语言
这个工具的核心逻辑跟具体语言无关,只要换一张关键字表和一个注释字符串的Token正则,就能支持Python、C、C++、JavaScript等语言。比如C语言的关键字表里要有 int、return、struct,但没有 public、class,把表换掉就好。
我在工具里预留了一个配置入口,用 LANG_CONFIG 字典维护不同语言的配置,这样调用的时候只需要指定语言参数:
python复制LANG_CONFIG = {
'java': {
'keywords': JAVA_KEYWORDS,
'pattern': TOKEN_PATTERN,
},
'c': {
'keywords': C_KEYWORDS,
'pattern': TOKEN_PATTERN,
},
}
这个扩展让工具从一个Java专用脚本变成了一个“微型多语言代码高亮引擎”。虽然复杂度不会增加太多,实用性却大幅提升了。
5.3 实际使用中积累的经验与建议
我实际用了这个小工具一段时间,最有价值的地方不是高亮本身,而是“字母全部小写”的检查逻辑。团队里某位刚转正的新人,在代码里把 public 写成了 Public,IDE报错后他盯着错误列表看了半天也没反应过来,后来我把代码丢进这个工具,红色警告一出,他立刻明白了问题在哪。这种直观反馈比任何手册都管用。
另外,我还加了一个统计模式:只输出警告清单,不上色。这样可以在终端里快速看到所有可疑的大写关键字行号和内容,非常适合批量检查。
最后分享一个小技巧:如果把输出方式从ANSI换成HTML,再配合定时任务跑一遍整个项目的源码目录,就能生成一份代码风格体检报告。比如统计一个类里出现了多少次 public、多少个 if,甚至能算出嵌套层数。高亮只是个起点,继续往下挖,你会发现词法分析这个老话题里还有很多可玩的东西。
