- 开发工具
- 代码质量
- 静态分析
【免费下载链接】jscpd
Copy/paste detector for source code. 220+ languages, Rust engine, SARIF/HTML/badge reporters, GitHub Action, MCP server for AI agents.
jscpd(Copy/paste detector)不仅是支持 220+ 语言的重复代码检测器,还内置了一套基于 token 流的圈复杂度(cyclomatic complexity)估算引擎。本文以仓库中的 summary-demo 示例目录 为主线,逐行拆解 jscpd 如何用"每个函数一条路径 + 每条分支一条路径"的规则统计 C、Rust、Swift、Python、TypeScript 五个文件的复杂度,并深入 Rust 源码验证其计数原理、与 lizard 的差异来源,以及--complexity免检测快速模式的使用方法。读完本文,你将能独立复现示例中的全部命令行输出,并理解 jscpd 复杂度数值背后的判定逻辑。
一、示例目录:五个可手算的文件
summary-demo 下放着五个短文件(每种语言一个),全部设计为可以用手算出圈复杂度,用于验证 jscpd 的计数结果。命令在仓库根目录、使用默认阈值执行:
jscpd fixtures/summary-demo --summary --summary-by complexity --no-colors --no-tips原文档给出的"每个文件考察点"对照表如下:
| 文件 | 考察的技术点 | 路径数 |
|---|---|---|
c/checkout.c | 无关键字可识别的函数、&&、\|\|、case | 13 |
rust/status.rs | match分支、match guard、'static生命周期 | 9 |
swift/Profile.swift | String?、?.、??、guard、真正的三元表达式 | 8 |
python/report.py | 全是if/or/for的 docstring;名为case和when的参数 | 5 |
typescript/form.ts | 可选属性city?:、?.、??、箭头函数 | 4 |
其中c/checkout.c与rust/status.rs对应源码分别为 c/checkout.c 与 rust/status.rs。注意:这个 README 本身也会被扫描(按 markdown 格式),但由于正文是散文而非代码,其if、or、for只是单词不是分支,所以复杂度为 0——数据格式(JSON、YAML、TOML、lock 文件)同理得 0。
二、按复杂度排名:完整命令与输出解读
在仓库根目录运行:
jscpd fixtures/summary-demo --summary --summary-by complexity --no-colors --no-tips # Found 0 clones. # # Summary (by complexity; 6 files, 6 folders analyzed) # Top files: # TOKENS LINES SIZE CX DUP% PATH # 176 32 710 13 0.0 c/checkout.c # 112 24 481 9 0.0 rust/status.rs # 88 21 446 8 0.0 swift/Profile.swift # 103 19 584 5 0.0 python/report.py # 96 19 441 4 0.0 typescript/form.ts # 1142 84 4.3K 0 0.0 README.md命令中每个参数的含义:
--summary:开启代码库摘要输出(默认关闭);--summary-by complexity:指定排序指标为复杂度(CX列),可用值还有tokens、lines、size,对应源码中的SummaryMetric枚举(见 rust/crates/cpd-core/src/summary.rs);--no-colors:关闭 ANSI 颜色;--no-tips:关闭结尾的使用提示。
输出中CX列的值与上表的手算路径数完全一致,而DUP%为 0.0 说明这五个文件彼此没有重复。摘要行的 "6 files" 包含了README.md本身——它按 markdown 扫描、复杂度为 0,印证了"散文不是分支"的规则。
从源码看,摘要表的每个文件行都由 FileSummary 结构 承载:tokens(token 总数)、lines(最大 token 起始行)、bytes(文件字节数)、duplicated_lines/duplicated_tokens(按显示路径归并的重复量)、complexity(圈复杂度估算)。排序逻辑在 compute_summary:按主指标降序、同值按路径升序,并用--summary-top截断为 Top-N;文件夹行按"直接父目录"聚合(每个文件只归入一个目录行,不做祖先累加)。
三、--complexity:不做克隆检测的快速模式
如果只关心复杂度、不关心重复代码,可以跳过克隆检测:
jscpd fixtures/summary-demo --complexity --summary-top 3 --no-colors --no-tips # Complexity (by complexity; 6 files, 6 folders analyzed) # Top files: # TOKENS LINES SIZE CX PATH # 176 32 710 13 c/checkout.c # 112 24 481 9 rust/status.rs # 88 21 446 8 swift/Profile.swift--complexity与--summary的区别在于:它只做文件遍历与 token 化,从不启动检测阶段,因此在大代码库上明显更快,且没有DUP%列。--summary-top 3只保留前三行;-r ai输出紧凑形式;-r json会把结果写入jscpd-complexity.json。
对应的实现位于 rust/crates/cpd/src/complexity.rs:scan()用RunConfig { similarity: 1.0, .. }强制跳过检测(函数签名只服务克隆检测,该模式永远不检测,所以也不必为语法树付费),随后调用compute_summary(&sources, &[], opts.summary_top, by, …)——克隆列表传空,重复相关指标自然为 0。run()还会对与克隆相关的选项给出警告:
- 设置
--threshold或--exit-code时警告"没有重复率或克隆数可用来门控"; - 设置
--baseline、--baseline-from-ref、--update-baseline、--fail-on-new-clones时警告"基线族选项被忽略"; - 设置
--history时警告"没有克隆检测就没有重复趋势"; - 设置
--kind时警告"它过滤克隆类型,而本模式不检测克隆"。
可用 reporter 仅限console/console-full、ai、json,其他 reporter 会被警告忽略。
四、逐文件计数原理详解
核心规则一句话:圈复杂度 = 每个函数 1 条路径 + 每条分支 1 条路径;语言没有可靠函数标记时退化为全文件基线 1。下面按文件逐个验证。
4.1c/checkout.c— 13
C 语言给函数没有任何关键字。double line_total(…) {是靠其左括号前面紧跟一个名字来识别的,以此与if (…) {、switch (…) {结尾的) {区分开。计数明细:
line_total:if、||、if、&&→ 4 条分支;shipping_zone:3 个case标签(default不计为分支)→ 3 条分支;free_shipping:&&、||、&&→ 3 条分支;- 3 个函数 → 3 条路径。
合计 3 + 4 + 3 + 3 = 13。
源码佐证:在 rust/crates/cpd-core/src/summary.rs 中,c/cpp/java/clike等格式启用了braced_declarations: true,由scan_complexity记录每个(前的"头",再用PARENTHESISED_STATEMENTS(switch、try、while、if等)排除语句头,从而把真正的函数{与分支{区分开。case属于共享决策 token 表,而default不在表中。
4.2rust/status.rs— 9
Rust 的match对每个分支没有关键字,只有=>。每个分支都计一次,同时每个match反扣一次,因为 N 个分支是 N 条路径、即 N-1 条分支(与switch计case标签、不计default同理)。计数明细:
status_for:5 个分支 + 1 个 guard(Method::Post if authorized)→ 6;其中match反扣 1,所以 1 + 4 + 1 = 6(1 个函数路径 + 4 净分支 + 1 guard);describe:3 个分支 → 3,match反扣 1 后为 1 + 2 = 3。
合计 6 + 3 = 9。此外&'static str里的'static是孤立的单引号,绝不能被误认作字符串起点。
源码佐证:rust格式的规则在 summary.rs:extra: &["=>"](每个=>计 1 分支)、declarations: &["fn"](每个fn计 1 函数)、arm_groups: &["match"](每个match在最后decisions.saturating_sub(groups)中反扣 1)。'static里的'是字符串字面量的一部分,不会被计入。
4.3swift/Profile.swift— 8
在 Swift 中?通常表示可选类型而非三元运算符:String?与manager?.nickname中的?归属于前面的 token、不是分支;而age >= 18 ? "adult" : "minor"把?放在"开放位置"上,是真正的三元表达式,计 1。??是分支,guard也是分支。三个函数合计 2 + 3 + 3 = 8:
displayName:??→ 1 条分支 + 1 函数 = 2;ageLabel:guard+ 三元?→ 2 条分支 + 1 函数 = 3;managerName:两个??→ 2 条分支 + 1 函数 = 3。
源码佐证:swift规则在 summary.rs:extra: &["guard"]、question: QuestionMark::Optional、declarations: &["func"]。QuestionMark::Optional意味着裸?只在"未附着"(前面 token 与它不相邻)时才计为分支,?.永不分支,?:在可选语义下也不分支(仅 Kotlin/Groovy 的 Elvis 语义才计)。
4.4python/report.py— 5
summarize含for、if、elif→ 4 条分支 + 1 函数 = 5;headline无分支 → 1。合计 5 + 1 - 1 = 5。关键难点在于"看起来像分支但不是"的两类情况:
- docstring 正文:
"""…If a courier misses the slot, or the parcel is held…"""中的If、or、while、for、when只是注释文字; - 用作名字的关键字:
headline(case, when)把case、when两个分支关键字当作参数名——文件自己列为参数、赋过值或从对象上读取的名字,就归该文件所有,无论另一种语言是否保留它。
源码佐证:docstring 的处理在 triple_quoted 函数:tokenizer 按行处理,会把三引号字符串内容当作普通单词流入,因此需要识别"""/'''的开关来跳过字符串体(python等格式由 has_triple_quoted_strings 判定)。名字归属由 locally_bound 处理:obj.case(点后成员)、case = 3(赋值且非==/=>)、f(case, when)(参数/实参列表中关键字后紧跟逗号或右括号)三种形态都会把该词标记为"本文件自己的名字",在scan_complexity中直接跳过,防止误计分支。
4.5typescript/form.ts— 4
city?: string是可选属性,customer.address?.city是可选访问,都不算分支;shippingLabel里的??是分支 → 2(1 函数 + 1 分支);- 箭头函数
hasContact里的||是分支 → 2(1 函数 + 1 分支)。
合计 2 + 2 = 4。
源码佐证:typescript规则在 summary.rs:question: QuestionMark::Optional(?:可选属性不计分支)、declarations: &["function", "=>"]——注意=>在这里是箭头函数声明而非 Rust 的match分支,计入函数数而非分支数。
五、与 lizard 的对照:两处分歧的根源
summary-demo 的 README 提供了与 lizard 的对照验证方式:
pip install lizard lizard fixtures/summary-demo| 文件 | CX | lizard | 差异原因 | | ---- | -- | ------ | ------------------------------------------------- | |c/checkout.c| 13 | 13 | | |rust/status.rs| 9 | 5 | lizard 对match只计 1 次,无论多少分支 | |swift/Profile.swift| 8 | 5 | lizard 不把??计为分支 | |python/report.py| 5 | 5 | | |typescript/form.ts| 4 | 4 | |
结论:lizard 与 jscpd 在除两行外的所有行上一致,而仅有的两处差异都是 lizard 数少了路径——这正是 CHANGELOG 中记录的设计取舍:"lizard counts amatchonce regardless of arm count, and doesn't count??"。换言之,jscpd 的计数在语义上更贴合"N 个分支就是 N 条路径"的圈复杂度定义。
六、从示例到实战:通用判定规则速查
综合以上五个文件与源码实现,jscpd 的复杂度估算遵循以下规则:
- 共享决策 token(ASCII 大小写不敏感,覆盖 SQL、PL/SQL、Fortran、COBOL、BASIC、Pascal 等大写关键字语言):
if、elif/elsif/elseif、unless、for/foreach、while/until、case/cond/when、catch/rescue/except、andalso/orelse、&&、||、and、or、?、??——完整列表见 is_decision_token; - 双字符运算符合并:通用 tokenizer 会把
&&拆成两个&,joined_token 在扫描时把相邻的单个标点重新拼成&&、||、??、?.、?:、=>,否则非 JS 语言的短路运算符永远不可达; - 散文与数据格式零复杂度:has_control_flow 与 is_markup 共同定义
is_code——markdown、JSON、YAML、TOML、CSV、diff、gettext 等格式永远得 0,HTML/CSS/模板同理("if" 在 HTML 属性里、and在媒体查询里都是单词); - 语言专属规则:language_rules 按格式配置
extra(Rust 的=>、Swift 的guard、Go 的select、Erlang 的receive)、declarations(fn/func/def/function/=>)、question语义(Ternary / Optional / OptionalWithElvis)与braced_declarations(C 系)。
掌握这四条规则后,你就可以在真实项目上运行jscpd --complexity --summary-top 20快速定位复杂度最高的文件,或用--summary --summary-by complexity在常规克隆检测的同时输出复杂度排名,作为代码健康度评估与重构优先级的量化依据。更完整的 JSON 输出可配合-r json与 summary-render 中的渲染逻辑集成进 CI 报告。
- 开发工具
- 代码质量
- 静态分析
【免费下载链接】jscpd
Copy/paste detector for source code. 220+ languages, Rust engine, SARIF/HTML/badge reporters, GitHub Action, MCP server for AI agents.
相关推荐
StickySwitch最佳实践:避免常见陷阱和错误配置的10个技巧
StickySwitch最佳实践:避免常见陷阱和错误配置的10个技巧 StickySwitch是Android开发中一款功能强大的开关控件库,以其独特的粘性动画
Foundry Forge Lint 规则解析:cyclomatic-complexity(圈复杂度检测)
Foundry Forge Lint 规则解析:cyclomatic complexity(圈复杂度检测) 本文是 Foundry 仓库内置静态分析工具 for
区块链开发工具PHP 代码复杂度计算实战:sebastian/complexity 在 ShowDoc 仓库中的原理与使用指南
PHP 代码复杂度计算实战:sebastian/complexity 在 ShowDoc 仓库中的原理与使用指南 导读 sebastian/complexity
文档知识库后端前端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考