cccc README

The project's README from github.com/moznion/cccc. As one markdown file: README.md.

cccc - A tool/library for measurement of Cognitive Complexity and Cyclomatic Complexity

Workspace layout

The complexity engine is split from the language parser so it can be reused as a library and extended to other languages:

Crate Role
cccc-core Language-agnostic engine: a normalized IR (ir::Node), the scoring rules (engine::analyze), and the result/aggregation types. Depends only on serde.
cccc-cli The unified cccc binary. Owns argument parsing, config-file handling, file walking, parallelism, and output rendering, and holds the registry of bundled languages (lang::LANGUAGES) that routes each file to its adapter.
cccc-es ECMAScript/TypeScript adapter library: lowers the oxc AST into cccc-core's IR. Depends only on cccc-core + oxc — no CLI dependencies, so embedding it stays lightweight.
cccc-rs Rust adapter library: lowers the syn AST into cccc-core's IR. Depends only on cccc-core + syn — no CLI dependencies.
cccc-go Go adapter library: lowers the gosyn AST into cccc-core's IR. Depends only on cccc-core + gosyn — no CLI dependencies.
cccc-php PHP adapter library: lowers the php-rs-parser AST into cccc-core's IR. Depends only on cccc-core + php-rs-parser / php-ast — no CLI dependencies.
cccc-rb Ruby adapter library: lowers the ruby-prism AST into cccc-core's IR. Depends only on cccc-core + ruby-prism — no CLI dependencies. Note: ruby-prism is an FFI binding to the vendored Prism C source, so building this crate (unlike the others) needs a C99 compiler and libclang.
cccc-scheme Scheme (R7RS-small) + Racket adapter library: lowers the lispexp S-expression tree into cccc-core's IR. Depends only on cccc-core + lispexp (pure Rust) — no CLI dependencies.
cccc-lisp-kit Shared lowering kit for the Lisp-family adapters: the collector stack, the walk_regions code-vs-data traversal, and logical folding. A dialect adapter supplies just a reader preset + a head-symbol dispatch table. Re-exports cccc-core's IR and the pure-Rust lispexp reader.
cccc-lisp Lisp-family adapter library (Common Lisp, Emacs Lisp, …) built on cccc-lisp-kit. Its Dialect API also analyzes Scheme/Clojure by delegating to cccc-scheme/cccc-clojure (no duplicated lowering). No CLI dependencies.
cccc-clojure Clojure adapter library: lowers the lispexp S-expression tree into cccc-core's IR. Depends only on cccc-core + lispexp (pure Rust) — no CLI dependencies.
cccc-scheme Scheme (R7RS-small) adapter library: lowers the lispexp S-expression tree into cccc-core's IR. Depends only on cccc-core + lispexp (pure Rust) — no CLI dependencies.
cccc-kt Kotlin adapter library: lowers the exoego/tree-sitter-kotlin CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the Kotlin grammar — no CLI dependencies. Note: the grammar ships C source compiled by cc, so building this crate needs a C compiler (but not libclang, unlike cccc-rb). Not published to crates.io (the grammar is a git dependency); cccc-cli includes it via its default kotlin feature.
cccc-py Python adapter library: lowers the official tree-sitter-python CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the Python grammar — no CLI dependencies. Like cccc-kt, the grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-zig Zig adapter library: lowers the pure-Rust zigsyn AST into cccc-core's IR. Depends only on cccc-core + zigsyn — no CLI dependencies or C toolchain.
cccc-c C adapter library: lowers the official tree-sitter-c CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the C grammar — no CLI dependencies. Like cccc-kt/cccc-py, the grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-clike Shared lowering for the C-family adapters: tree-sitter-cpp's grammar is a superset of tree-sitter-c's, so cccc-c and cccc-cpp both construct a cccc_clike::SharedBuilder (tagged Language::C/Language::Cpp) instead of duplicating the lowering for what they share (functions, if, loops, switch, jumps, logical folding, preprocessor conditionals, calls). C++-only constructs (lambdas, catch, range-for) are gated on the language tag in the same visit. Depends only on cccc-core + tree-sitter — no grammar crate, no CLI dependencies.
cccc-cpp C++ adapter library: lowers the official tree-sitter-cpp CST into cccc-core's IR via cccc-clike. Depends only on cccc-core + cccc-clike + tree-sitter + the C++ grammar — no CLI dependencies.
cccc-pl Perl adapter library: lowers the tree-sitter-perl CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the Perl grammar — no CLI dependencies. Like cccc-kt/cccc-py, the grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-swift Swift adapter library: lowers the alex-pinkus/tree-sitter-swift CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the Swift grammar — no CLI dependencies. Like cccc-kt/cccc-py, the grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-java Java adapter library: lowers the tree-sitter-java-orchard CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the Java grammar — no CLI dependencies. Like cccc-kt/cccc-py, the grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-dart Dart adapter library: lowers the nielsenko/tree-sitter-dart CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the Dart grammar — no CLI dependencies. The grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-scala Scala adapter library: lowers the official tree-sitter-scala CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the Scala grammar — no CLI dependencies. Like cccc-kt/cccc-py, the grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-cs C# adapter library: lowers the WillBooster/tree-sitter-c-sharp CST into cccc-core's IR. Depends only on cccc-core + tree-sitter + the C# grammar — no CLI dependencies. Like cccc-kt/cccc-py, the grammar's C source is compiled by cc, so building needs a C compiler (no libclang).
cccc-vb Visual Basic .NET adapter library: lowers source into cccc-core's IR with its own pure-Rust lexer and recursive-descent parser (the available tree-sitter VB grammars fail on most real-world files). Depends only on cccc-core — no CLI dependencies or C toolchain.

Each adapter is a standalone library so that a consumer who only wants the metrics pulls in just that adapter (+ cccc-core + its parser), never clap / ignore / rayon. The cccc binary depends on all of them and dispatches by extension.

To support another language: (1) add an adapter crate that lowers its AST into cccc_core::ir::Node and calls cccc_core::engine::analyze, then (2) register it with one entry in cccc-cli's lang::LANGUAGES (and add the dependency) — no new binary, and no reimplementing the metrics or the CLI. cccc-es (oxc), cccc-rs (syn), cccc-go (gosyn), cccc-php (php-rs-parser), cccc-rb (ruby-prism), cccc-kt / cccc-py / cccc-pl (tree-sitter), cccc-swift (tree-sitter), cccc-c / cccc-cpp (tree-sitter), cccc-java (tree-sitter), cccc-dart (tree-sitter), cccc-scala (tree-sitter), cccc-cs (tree-sitter), cccc-vb (hand-written parser), cccc-scheme (lispexp), cccc-clojure (lispexp), cccc-lisp (lispexp, Common Lisp / Emacs Lisp / …), and cccc-zig (zigsyn) are the reference adapters: same shape, different parser. The Lisp-family adapters share their lowering skeleton via cccc-lisp-kit; cccc-c and cccc-cpp share theirs via cccc-clike.

See docs/ADDING_A_LANGUAGE.md for the full step-by-step guide, including the IR-node reference table, the logical-operator folding rule, and how to test the adapter.

use cccc_core::{engine::analyze, ir::Node};

let f = Node::Function {
    name: "f".into(), kind: "function".into(), line: 1,
    body: vec![Node::Branch { test: vec![], then: vec![], alternate: None }],
};
let report = analyze("example", &[f], vec![]);
assert_eq!(report.functions[0].cognitive, 1);  // one `if`

Install / build

Prebuilt binaries for Linux, macOS, and Windows are attached to each GitHub Release. To build from source:

cargo build --release
# single binary at ./target/release/cccc

Or install from crates.io:

cargo install cccc-cli

[!NOTE] The crates.io build does not support Kotlin. cccc-kt depends on a Kotlin grammar that is only available from git, and crates.io rejects git dependencies, so cccc-kt is not published and cccc-cli is published without its (default) kotlin feature. Use a GitHub Release binary or build from this repository to analyze Kotlin.

Usage

cccc <paths...> [options]

One binary handles every language. Pass one or more files or directories; directories are walked recursively (respecting .gitignore, always skipping node_modules). Each file is dispatched to the right front-end by its extension, so a directory mixing .ts, .rs, .go, and .php is analyzed in a single run. Restrict the languages with --lang (e.g. --lang go,rust).

Output is JSON by default — compact, on one line, ready to pipe into jq or an artifact store; --pretty prints the same document indented.

Options

Flag Description
--lang LIST Restrict analysis to these languages (comma-separated; canonical names or aliases, e.g. es,rust/rs,go,php). Default: all
--exclude-lang LIST Exclude these languages (comma-separated). The inverse of --lang; applied to all languages, or to --lang's set when both are given
--config PATH Use this config file instead of discovering one (must exist)
--no-config Do not look for or load a cccc.toml config file
--table Human-readable table instead of JSON
--ext EXTS | LANG=EXTS Extensions to analyze. Global form --ext ts,tsx filters across all languages; per-language form --ext es=ts,tsx overrides that language's extensions and routes them to it. Repeatable
--exclude GLOB Exclude files matching a glob (repeatable)
--max-cognitive N Exit non-zero if any function's cognitive complexity exceeds N
--max-cyclomatic N Exit non-zero if any function's cyclomatic complexity exceeds N
--min N Only report functions with complexity >= N
--top-cognitive N Show only the N most cognitively-complex functions, as a flat cross-file ranking
--top-cyclomatic N Show only the N most cyclomatically-complex functions, as a flat cross-file ranking
--no-ignore Do not respect .gitignore when walking directories
--cache Cache results and reuse them for files unchanged since the last run
--cache-file PATH Where to keep the results cache (implies --cache). Default: .cccc.cache next to the config file, or in the current directory
--no-cache Do not use the results cache, even if the config file enables it
--print-cache-file Print the resolved cache path (nothing when disabled) and exit; for tooling
--pretty Pretty-print the JSON output (default is compact, one line)
-j, --jobs N Number of files to analyze in parallel (default: logical CPU count)

Configuration file

Recurring options can be stored in a cccc.toml file so they don't have to be repeated on every run. By default cccc discovers one by walking up from the current directory, looking for cccc.toml (then .cccc.toml) in each ancestor; --config PATH selects an explicit file and --no-config disables discovery.

Resolution precedence is CLI flag > config file > built-in default: anything passed on the command line always wins. Supported keys (all optional):

# cccc.toml
languages         = ["es", "go"]        # same as --lang
exclude-languages = ["php"]             # same as --exclude-lang
exclude           = ["dist/**", "**/*.test.ts"]
table         = false
max-cognitive = 15
max-cyclomatic = 10
min           = 1
no-ignore     = false
jobs          = 8
pretty        = false               # indented JSON instead of the compact default
cache         = false               # reuse results for unchanged files
cache-file    = ".cccc.cache"       # cache location (does not enable by itself)

# Per-language extension overrides. Each entry replaces that language's default
# extensions (and routes those extensions to it). Keyed by a language's name or
# alias; languages without an entry keep their defaults.
[ext]
es = ["ts", "tsx"]      # analyze only .ts/.tsx as ECMAScript (not .js, .mjs, …)
go = ["go", "tmpl"]     # also route a custom .tmpl extension to the Go front-end

The config-file ext is a per-language table: it both narrows/extends which extensions a language claims and determines how a custom extension is routed. The same per-language form is available on the command line as --ext LANG=ext,ext (which overrides the config's entry for that language), alongside the global filter form --ext ext,ext.

(--top-cognitive/--top-cyclomatic and the input paths are command-line only.)

--top-cognitive and --top-cyclomatic are mutually exclusive. In top mode the output is a ranking ({ "metric", "top": [...], "summary" }) instead of the per-file files array; each entry carries its own path and line. The summary still reflects the full population.

--exclude takes a glob pattern and may be given multiple times. Each pattern is matched both against a file's path relative to the directory you passed (so dist/** is anchored at that root) and against its file name alone (so *.test.ts matches at any depth without a **/ prefix). * does not cross /; use ** to span directories. Brace alternation is supported, e.g. **/*.{test,spec}.ts. Excluded files are dropped whether found by walking a directory or named explicitly on the command line. An invalid pattern is an error (exit code 2). This is independent of --no-ignore and .gitignore handling.

Results cache

--cache (or cache = true in cccc.toml) makes repeat runs reuse the previous results for files that haven't changed, re-analyzing only what did. The output is byte-for-byte identical to an uncached run: the cache is an accelerator, never a source of errors — anything unexpected (a missing, corrupt, or version-mismatched cache file) just degrades to a full run. It pays off wherever cccc runs repeatedly over a mostly-unchanged tree: watch loops, pre-commit hooks, editor integrations, and CI. On large monorepos, warm runs measure ~3× (TS/JS via oxc) to ~17× (tree-sitter languages such as C) faster than cold ones — numbers and method in BENCHMARK.md.

An entry is reused only when it provably still describes the file's current content, checked cheapest-first:

  1. stat — size and mtime unchanged: trusted without reading the file. The steady local path; a fully warm run does nothing but stat.
  2. git's index — the mtime moved, but git calls the file clean and the index's blob SHA matches the one recorded at analysis time: still no read. One git ls-files/git status pair answers for the whole tree, which is what keeps fresh CI checkouts (every mtime reset) warm. git is consulted only after a stat check has failed, and never trusted beyond content: dirty or untracked files, non-git trees, and any git failure at all fall through to step 3.
  3. content re-hash — the file is read and its blob SHA re-derived from the bytes; the final authority, and the same value step 2 compares without reading. A mismatch means the content really changed, and only then is the file re-analyzed. (The blob SHA is SHA-1 — the same non-adversarial, per-path content comparison git itself rests on, not a cryptographic boundary.)

A hit that needed step 2 or 3 gets its refreshed mtime written back, so the next run takes the stat path again. Entries are also keyed to the analyzing language (--ext re-routing must not resurface another language's scores), and the whole cache to the cccc version that wrote it.

The cache lives in .cccc.cache next to the config file (so runs from any subdirectory share it), or where --cache-file points; add it to .gitignore. It needs no maintenance beyond that: every run rewrites it to match exactly the current tree — deleted files' entries are pruned, and its size stays proportional to the project, not its history — and deleting the file at any time is always safe (the next run is simply cold).

In CI

The git-index check is what makes the cache effective in CI, where every checkout resets every mtime: persist the cache file across runs and each run validates unchanged files off git's index instead of re-parsing them — measured 1.6–9.6× faster than a cold run (see BENCHMARK.md). Correctness never depends on which commit (or how stale a run) the restored cache came from: every entry is checked content-to-content. Under CI=… (GitHub Actions sets it) the git subprocesses start early so their latency hides behind file discovery.

On GitHub Actions, cccc-action wires the persistence up for you when the config sets cache = true. Wiring it by hand looks like:

- uses: actions/cache@v4
  with:
    path: .cccc.cache
    key: cccc-${{ github.sha }}
    restore-keys: cccc-
- run: cccc --cache --max-cognitive 15 src/

Tooling that needs the resolved cache location up front (the way cccc-action does) can ask cccc --print-cache-file, which prints the path the current config resolves to — or nothing when caching is disabled — and exits.

Examples

# JSON for one file
cccc src/app.ts

# Pretty table for a directory (any mix of supported languages)
cccc --table src/

# Only Go and Rust files under a mixed tree
cccc --lang go,rust .

# Everything except PHP
cccc --exclude-lang php .

# Analyze only .ts/.tsx as ECMAScript (not .js, .mjs, …)
cccc --ext es=ts,tsx src/

# CI gate: fail if any function exceeds cognitive complexity 15
cccc --max-cognitive 15 src/

# The 10 most cognitively-complex functions across the project
cccc --top-cognitive 10 src/

# Skip build output and test files
cccc --exclude 'dist/**' --exclude '**/*.{test,spec}.ts' src/

# Limit parallelism to 4 workers (default is the logical CPU count)
cccc -j 4 src/

# Recurring runs: reuse results for files unchanged since the last run
cccc --cache src/

Files are analyzed in parallel. The worker count defaults to the number of logical CPUs and can be capped with -j/--jobs; the output is identical regardless of the worker count.

GitHub Action

A composite action to install and run cccc in CI lives in its own repository: moznion/cccc-action.

- uses: moznion/cccc-action@v1
  with:
    path: src/           # analyze this; thresholds come from cccc.toml

Like thresholds, caching is driven by the config: when cccc.toml sets cache = true, the action persists the results cache across runs on its own (via actions/cache — no workflow wiring needed).

An example GitHub Actions workflow for continuously measuring complexity with k1LoW/octocov is available at .github/workflows/complexity.yml.

Output shape (JSON)

An object with files (per-file reports) and summary (a whole-project rollup). Each function is measured independently and nested functions appear under children. A file's totals sum every function at every depth plus module-level code.

The summary is computed over every function in every file (all nesting depths). Because complexity is right-skewed, it reports the distribution (sum/max/median/p90/p95) rather than a mean — the percentiles describe the tail where refactoring candidates live. It is unaffected by --min.

{
  "files": [
    {
      "path": "src/app.ts",
      "cognitive": 10,
      "cyclomatic": 10,
      "functions": [
        {
          "name": "handleRequest",
          "kind": "function",
          "line": 10,
          "cognitive": 7,
          "cyclomatic": 4,
          "children": []
        }
      ]
    }
  ],
  "summary": {
    "file_count": 1,
    "function_count": 3,
    "parse_error_count": 0,
    "parse_error_file_count": 0,
    "cognitive":  { "sum": 10, "max": 7, "median": 2, "p90": 7, "p95": 7 },
    "cyclomatic": { "sum": 10, "max": 4, "median": 3, "p90": 4, "p95": 4 }
  }
}

Parse errors

A file that fails to parse cleanly is still measured from whatever the parser recovered, and its parse_errors (an array of messages, omitted when empty) appears on that file's entry. The summary aggregates them — parse_error_count (total errors), parse_error_file_count (affected files), and parse_error_files (the affected paths, omitted when empty) — so a partial parse can't go unnoticed without inspecting every file entry, even in --top-* mode where per-file entries aren't emitted at all:

{
  "files": [
    {
      "path": "src/broken.py",
      "parse_errors": ["syntax error at line 6"],
      ...
    }
  ],
  "summary": {
    "parse_error_count": 1,
    "parse_error_file_count": 1,
    "parse_error_files": ["src/broken.py"],
    ...
  }
}

In --table mode the aggregate count is printed in the summary block, and a warning listing each affected file (with its error count) goes to stderr so it isn't lost in a long table:

$ cccc --table src/ >/dev/null
cccc: warning: 1 parse error(s) in 1 file(s); results for those files may be incomplete:
cccc:   src/broken.py (1)

Note: the top level is an object ({ files, summary }), so to post-process the per-file array with jq, start from .files — e.g. cccc src/ | jq '.files | sort_by(-.cognitive)'.

Benchmark

On zod's packages/zod/src (286 .ts files, 68,357 LOC), median wall-clock and peak memory over 5 runs on an Apple M4 Pro:

Tool Metrics Time Peak RSS
cccc (ECMAScript) cognitive + cyclomatic, per-function, full AST 15.5 ms 12.5 MB
ESLint + SonarJS cognitive + cyclomatic, per-function, full AST 1,807 ms (117× slower) 604 MB (48× more)
lizard cyclomatic only, heuristic parser 1,413 ms (91× slower) 45.7 MB
scc coarse per-file keyword count, no AST 8.3 ms (1.9× faster) 13.9 MB

Among tools that do the same job — both metrics, per-function, over a real AST — cccc is ~117× faster than ESLint+SonarJS (the only other tool that computes cognitive complexity) and uses ~48× less memory. scc is faster only because it never parses: it counts keywords per file, with no AST, no per-function data, and no cognitive complexity.

See BENCHMARK.md for the full methodology, the verify-then-time harness, per-run numbers, function-count sanity checks, and caveats.

Metric rules

Cyclomatic (McCabe): base 1 per function; +1 for each if/else if, ternary, for/for-in/for-of/while/do-while, case (excluding default), catch, each &&/||/??, and each explicit null guard such as an optional-chain segment. Null guards do not add cognitive complexity or nesting.

Cognitive (SonarSource):

Each function-like unit is scored independently (nesting resets to 0 at the function boundary); nested functions are reported as children rather than inflating the parent's own score.

The rules above are stated in TypeScript/JavaScript terms; each adapter maps its language onto the same IR, with the per-language differences below.

Rust (--lang rust)

Go (--lang go)

PHP (--lang php)

Ruby (--lang ruby)

Kotlin (--lang kotlin)

Python (--lang python)

Zig (--lang zig)

C (--lang c)

C++ (--lang cpp)

Perl (--lang perl)

Swift (--lang swift)

Java (--lang java)

Dart (--lang dart)

Scala (--lang scala)

C# (--lang csharp)

Visual Basic .NET (--lang vbnet)

cccc (moznion/cccc)

Searching Database…

472 ·GitHub
这是什么?cccc 是一个快速的命令行工具(也是 Rust 库),可为多种语言的源代码测量认知复杂度(SonarSource)和圈复杂度(McCabe)。
它只是一个 CLI,还是也可以作为库嵌入?两者都是:cccc 二进制是 CLI,而复杂度引擎(cccc-core)和各语言适配器是独立的 Rust crate,你可以直接依赖它们,只引入所需语言的度量功能。
怎么安装和运行它?从 GitHub Releases(Linux、macOS、Windows)获取预构建二进制,通过 crates.io 的 `cargo install` 安装,或从源码构建;然后把它指向要分析的文件或目录。
构建它除了 Rust 还需要 C 编译器或其他东西吗?大多数适配器是纯 Rust 的,但少数需要额外工具:cccc-rb(Ruby)的 FFI 绑定需要 C99 编译器和 libclang,而 cccc-kt、cccc-py、cccc-c/cpp、cccc-pl、cccc-swift、cccc-java、cccc-dart、cccc-scala 和 cccc-cs 则通过 `cc` 编译其 tree-sitter 语法的 C 源码。
从 crates.io 能获得完整的 Kotlin 支持吗?不能——crates.io 构建排除了 Kotlin,因为 cccc-kt 依赖一个只能通过 git 获取的 Kotlin 语法,而 crates.io 不允许这样做;要获得 Kotlin 支持,请使用 GitHub Release 二进制或从仓库构建。
cccc 能分析哪些语言?TypeScript/JavaScript、Rust、Go、PHP、Ruby、Scheme、Racket、Common Lisp、Emacs Lisp、Clojure、Kotlin、Python、Zig、C、C++、Perl、Swift、Java、Dart、Scala、C# 和 Visual Basic .NET,每种语言都有各自的适配器 crate,共享同一引擎和输出格式。
能一次扫描混合语言的代码库吗?能限制检查哪些语言吗?可以——目录会被递归遍历,每个文件按扩展名路由到正确的适配器,因此一次运行可以覆盖混合的目录树;使用 --lang(和 --exclude-lang)可通过逗号分隔的列表包含或排除特定语言。
输出是什么格式?默认为 JSON,紧凑且单行显示,便于通过管道传给 jq 或制品存储;传入 --pretty 可获得同一文档的缩进版本。
如何添加对新语言的支持?编写一个适配器 crate,把该语言的 AST 降级(lower)为 cccc-core 的规范化 IR 并调用 cccc-core 的 analyze 函数,然后在 cccc-cli 的 lang::LANGUAGES 注册表中添加一个条目进行注册——无需新的二进制文件,也无需重新实现度量逻辑。
What are similar projects?ast-grep, jevlint, perch, fallow, difftastic, liteparse, tree-sitter, flow
Creator
moznion
Size
8.5 MB
Stars
472
Age
4mo
Commits · 90 Days