Harper 的 harper-comments:基于 tree-sitter 为 30 种编程语言精准提取并检查注释的实现剖析
【免费下载链接】harperOffline, privacy-first grammar checker. Fast, open-source, Rust-powered项目地址: https://gitcode.com/GitHub_Trending/har/harper
本文围绕harper-comments这个 Rust crate 展开。它是 Harper(一个离线、隐私优先的 Rust 语法/拼写检查器)中负责"只检查代码注释"的核心组件:通过封装 tree-sitter 语法树定位各语言的注释节点,并为 Go、JSDoc、JavaDoc 等结构化文档注释提供专门的解析器。读完本文,你将理解 Harper 是如何把"从源代码中摘出注释文本"变成一条可靠的解析流水线,以及如何通过spellchecker:ignore等机制精细控制检查范围。
一、crate 定位:一个"注释提取器"而非完整语法引擎
harper-comments的官方说明(harper-comments/README.md)只有寥寥数句,但它概括了该 crate 的两层职责:
- 通用层:作为 tree-sitter 的封装,帮助 Harper 定位"大量编程语言"中的注释;
- 专用层:为若干语言的结构化文档注释(如 Go 的
//go:指令)提供目的明确的解析器,这些解析器统一通过CommentParser自动启用。
从 harper-comments/Cargo.toml 可以确认其依赖面:核心依赖是harper-core(提供Parser、Token、Masker等抽象)、harper-tree-sitter(tree-sitter 封装)、harper-html(供 JavaDoc 使用),以及 26 个具体语言的 tree-sitter 语法包(tree-sitter-c、tree-sitter-rust、tree-sitter-go、tree-sitter-typescript等)。crate 入口 harper-comments/src/lib.rs 只导出一个公共类型:
mod comment_parser; mod comment_parsers; mod masker; pub use comment_parser::CommentParser;也就是说,外部使用者只需面向CommentParser一个 API 即可工作,内部的专用解析器是自动选择的——这正是 README 所说 "enabled automatically" 的含义。
二、CommentParser:按语言 ID 或文件名构建解析器
harper-comments/src/comment_parser.rs 是整个 crate 的中枢。CommentParser内部结构为:
pub struct CommentParser { inner: parsers::Mask<CommentMasker, Box<dyn Parser>>, }它由两部分组合而成:
CommentMasker:基于 tree-sitter 的遮罩器,负责在语法树中找出所有注释节点;- 内层
dyn Parser:真正"读懂"注释文本的解析器(按语言不同而不同)。
Parsertrait 的实现只有一行转发(parse方法委托给self.inner.parse(source)),但构造函数完成了全部语言路由逻辑。
2.1 支持的语言列表
new_from_language_id(language_id: &str, markdown_options: MarkdownOptions)将 30 个语言 ID 映射到对应的 tree-sitter 语法,节选自源码:
| 语言 ID | tree-sitter 语法 | 语言 ID | tree-sitter 语法 |
|---|---|---|---|
c/cpp | tree-sitter-c/tree-sitter-cpp | javascript/typescript/*react | tree-sitter-javascript/tree-sitter-typescript |
csharp | tree-sitter-c-sharp | kotlin | tree-sitter-kotlin-ng |
clojure | tree-sitter-clojure | lua | tree-sitter-lua |
cmake | tree-sitter-cmake | nix | tree-sitter-nix |
dart | harper-tree-sitter-dart | php | tree-sitter-php |
elixir | tree-sitter-elixir | powershell | tree-sitter-powershell |
go | tree-sitter-go | ruby | tree-sitter-ruby |
gleam | tree-sitter-gleam | rust | tree-sitter-rust |
groovy | tree-sitter-groovy | scala | tree-sitter-scala |
haskell/daml | tree-sitter-haskell | shellscript | tree-sitter-bash |
java | tree-sitter-java | solidity | tree-sitter-solidity |
swift/toml/zig | 对应语法包 |
2.2 文件名推断:与 LSP 文件类型对齐
除了按语言 ID 构建,CommentParser还提供new_from_filename(path: &Path, ...),它通过内部函数filename_to_filetype把文件扩展名转换为语言 ID。这份映射刻意与 LSP(Language Server Protocol)的文件类型命名保持一致,例如:
cpp、h→cpp;ex、exs→elixir;groovy、gradle→groovy;kt、kts→kotlin;sbt、sc、scala、mill→scala;bash、sh→shellscript;ps1、psd1、psm1→powershell。
源码注释特别叮嘱贡献者"try to keep this in sync withnew_from_language_id",即两张映射表必须同步维护。这解释了为何 harper-comments/tests/language_support.rs 的测试集里会出现common.mill、complex_gradle_build.gradle这类"非标准"文件。
2.3 注释节点的识别条件
遮罩阶段如何判断一个 tree-sitter 节点是注释?条件函数非常简单:
fn node_condition(n: &Node) -> bool { n.kind().contains("comment") }即节点类型字符串包含comment子串。这一宽松匹配覆盖了comment、line_comment、block_comment、multiline_comment等 tree-sitter 各语法中的常见命名,也是该 crate 能以极低成本接入新语言的诀窍所在。
三、专用解析器:为"结构化注释"定制规则
README 中提到的 "purpose-built parsers" 位于 harper-comments/src/comment_parsers/,共有六个:Go、JavaDoc、JsDoc、Lua、Solidity、Unit。它们在new_from_language_id中的选择逻辑为:
let comment_parser: Box<dyn Parser> = match language_id { "go" => Box::new(Go::new_markdown(markdown_options)), "java" => Box::new(JavaDoc::default()), "javascript" | "javascriptreact" | "typescript" | "typescriptreact" => { Box::new(JsDoc::new_markdown(markdown_options)) } "lua" => Box::new(Lua::new_markdown(markdown_options)), "solidity" => Box::new(Solidity::new_markdown(markdown_options)), _ => Box::new(Unit::new_markdown(markdown_options)), };除 Java 外,其余专用解析器默认以Markdown 解析器作为内层解析器(new_markdown),即"注释里的正文按 Markdown 规则来检查"——这意味着注释中写don't、their这类常见错误会被正常捕获,而代码块等 Markdown 结构也能被正确理解。
3.1 公共基础:剥离注释定界符
所有解析器共享 mod.rs 中的without_initiators工具函数:它从注释文本的首尾各去掉连续的注释定界字符(#、-、/、*、!)及空白,得到"净内容"区间。例如:
/// 这是一条注释→ 净内容这是一条注释;/** ... */的开头/**与行尾装饰星号被剥离;- 空注释
///得到空区间,由单测cleans_empty_comment直接验证。
随后各解析器把净内容交给内层解析器,并将结果 token 的span用push_by(actual.start)平移回原文坐标——这个"解析净文本 + 坐标回填"的模式保证了 Harper 诊断能精确指回源文件中的字符位置。
3.2 Go:跳过//go:构建指令
go.rs 针对 Go 特有的构建指令注释。若净内容以go:开头(匹配['g', 'o', ':', ..]),说明这是//go:build、//go:generate一类指令,Harper 找到该行第一个换行符并整体跳过,直接返回空 token 序列——避免把指令参数误当散文来检查。
3.3 JSDoc:块级标签与内联标签的双重豁免
jsdoc.rs 是复杂度最高的解析器,采用"逐行解析"策略:
- 按换行切分,对每行执行
without_initiators剥离*、//等定界符后交给内层 Markdown 解析器; - 块标签:若行内出现
@后紧跟单词(如@class、@param),则从该标签起至行尾的所有 token 标记为TokenKind::Unlintable(不检查),因为@param name the name中的name是标识符而非自然语言; - 内联标签:
mark_inline_tags函数扫描{@tag ...}形式的内联标签(如{@link MyClass}),将其整体标记为不可检查。该函数通过定位OpenCurly+@+Word的模式、再向后寻找CloseCurly来确定标签边界。
源码中附带了针对边界情况的回归测试:/** {@ */这种未闭合标签曾导致解析死循环(escapes_loop测试),以及{@link MyClass#foo}这种 JSDoc 自定义链接语法(handles_inline_link测试)的完整 token 断言。
3.4 JavaDoc:HTML 解析 + 装饰星号清洗
javadoc.rs 选择了与 JSDoc 不同的路线:JavaDoc 正文允许内嵌 HTML,因此它直接复用 harper-html 的HtmlParser解析净文本,而非 Markdown。其后续处理包括:
- 遍历 token 流,在每个换行之后删除连续的
*(装饰星号)与Spacetoken,还原对齐星号缩进带来的"假空格"; - 复用 JSDoc 的
mark_inline_tags处理内联标签; - 将形如
@tag word ...的四元组 token(@、标签名、空格、下一个单词)标记为Unlintable。
测试文件 javadoc_clean_simple.java 与 javadoc_complex.java 分别断言 0 个和 5 个 lint,覆盖了两类典型 JavaDoc。
3.5 Lua:整行@标签豁免
lua.rs 处理 LuaDoc 风格注释。其starts_with_prefix判断:若一行注释的净内容以@开头(如@param x number),则整行不做检查、仅产出换行 token;其余行照常按 Markdown 检查。这与 JSDoc 的"从标签起豁免"不同,是"整行豁免"策略,契合 LuaDoc 标签通常独占一行的习惯。
3.6 Solidity:复用 JSDoc 逻辑并豁免 SPDX 头
solidity.rs 在构造时把内层解析器设为JsDoc(Solidity 的 Natspec 注释语法与 JSDoc 同源),并额外增加一条规则:净内容以SPDX-开头的行(即// SPDX-License-Identifier: MIT这类许可证标识)整体跳过,避免检查MIT、GPL-3.0-or-later等许可证 ID 文本。
3.7 Unit:覆盖"大多数语言"的兜底解析器
unit.rs 是其余全部语言(C、Rust、Python 无关——本 crate 不含 Python,以及 Zig、Clojure、Elixir 等)的默认解析器。其文档注释自我定位为 "meant to covermostcases inmostprogramming languages"。它的逐行逻辑与 Lua 类似,另有一个关键机制:代码围栏(code fence)开关。当某行净内容以 ``` 开头时翻转in_code_fence标志,围栏内的行直接跳过——因为在注释块里用 Markdown 代码围栏粘贴示例代码时,围栏内容不应被当作英文散文来检查。
四、CommentMasker:遮罩、shebang 与spellchecker:ignore
"找到注释"这一步由 harper-comments/src/masker.rs 完成。CommentMasker包裹harper-tree-sitter提供的TreeSitterMasker,在其生成遮罩后做两件事:
1. shebang 处理。如果注释 span 从文件偏移 0 开始且以#!开头(真正的 shebang 只可能出现在首行),trim_leading_shebang会裁掉#!...第一行,但保留被 tree-sitter 合并进同一注释块的后续行继续检查。这解决了"shebang 后的注释块整体被屏蔽"的问题,对应测试ignore_shebang_1.sh~ignore_shebang_4.sh(前三个断言 0 lint,第四个因后续注释含错误断言 1 lint)。
2. 显式忽略指令。遮罩器内置默认忽略条件:注释文本若包含下列任一写法,则该注释整体不检查:
spellchecker:ignore / spellchecker: ignore spell-checker:ignore / spell-checker: ignore spellcheck:ignore / spellcheck: ignore harper:ignore / harper: ignore同时它暴露了new_with_ignore_condition构造器,允许嵌入方自定义忽略条件。CommentParser还通过create_ident_dict透传标识符字典能力,可以把代码中的自定义标识符并入词典,避免误报。
五、测试体系:以"期望 lint 数量"验证每种语言
harper-comments/tests/language_support.rs 是整个 crate 的语言支持验收层。它用create_test!宏批量生成测试:读取 harper-comments/tests/language_support_sources/ 下的源码文件,按文件名构建CommentParser,跑LintGroup::new_curated(dict, Dialect::American)全量 lint,断言 lint 数量等于预期值,并逐 token 校验 span 能取回真实文本(防止坐标回填错误)。
覆盖维度举例:
- 多语言多行注释:
multiline_comments.cpp、multiline_comments.ts、multiline_comments.sol各期望 4 lint; - dirty/clean 对照:
clean.lua0 lint vsdirty.lua1 lint,clean.zig0 vsdirty.zig5,clean.exs0 vsdirty.exs4; - JSDoc/JavaDoc:
jsdoc.ts期望 4,javadoc_complex.java期望 5; - 忽略机制:
ignore_comments.rs/.c/.sol/.ps1各期望 1(被忽略的注释贡献 0,未被忽略的保留 1); - 历史 issue 回归:
issue_96.lua、issue_132.rs、issue_229.js、issue_962.sh、issue_1097.lua等以 issue 命名的用例,锚定曾经出过 bug 的具体输入。
此外,单元层面还有针对性回归:comment_parser.rs 中的hang测试用 10 秒超时守护//{@j这类 Java 注释输入不挂死,与 jsdoc 的escapes_loop一起构成防死循环防线。
六、小结:这套设计值得借鉴的三个点
- "遮罩 + 专用解析器"两级架构:通用路径只依赖 tree-sitter 节点名包含
comment这一约定,新语言接入成本极低;真正需要理解文档注释语法的少数语言再挂专用Parser,且全部对上层透明(CommentParser是唯一公共 API)。 - "净文本解析 + span 回填"的统一坐标系:所有专用解析器都在剥离定界符后的净文本上做词法/语法分析,再用
push_by平移 token 位置,使 Harper 的诊断行号、列号与原文严格对齐——测试中"每个 token span 都能取回真实内容"的断言持续守护这一点。 - 豁免规则贴近真实工程场景:
//go:build指令、SPDX-License-Identifier头、@param标签、shebang、Markdown 代码围栏、spellchecker:ignore显式忽略——每一条豁免都对应开发者写注释时会遇到的真实误报源,而不是理想化的假设输入。
如果你要为 Harper 生态新增语言支持,路径也清晰可见:在 harper-comments/src/comment_parser.rs 的new_from_language_id与filename_to_filetype两处加入映射、在 harper-comments/Cargo.toml 添加对应 tree-sitter 依赖、按需在 harper-comments/src/comment_parsers/ 增加专用解析器,最后向 harper-comments/tests/language_support_sources/ 补充测试源码即可。
【免费下载链接】harperOffline, privacy-first grammar checker. Fast, open-source, Rust-powered项目地址: https://gitcode.com/GitHub_Trending/har/harper
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考