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
What is this?cccc is a fast CLI tool (and Rust library) that measures Cognitive Complexity (SonarSource) and Cyclomatic Complexity (McCabe) for source code across many languages.
Is it just a CLI, or can I embed it as a library too?Both: the cccc binary is the CLI, but the complexity engine (cccc-core) and each language adapter are separate Rust crates you can depend on directly to pull in metrics for just the languages you need.
How do I install and run it?Grab a prebuilt binary from GitHub Releases (Linux, macOS, Windows), install via `cargo install` from crates.io, or build from source; then point it at files or directories to analyze.
Do I need a C compiler or anything besides Rust to build it?Most adapters are pure Rust, but a few need extra tooling: cccc-rb (Ruby) needs a C99 compiler and libclang for its FFI binding, while cccc-kt, cccc-py, cccc-c/cpp, cccc-pl, cccc-swift, cccc-java, cccc-dart, cccc-scala and cccc-cs compile their tree-sitter grammar's C source via `cc`.
Is Kotlin support fully available from crates.io?No — the crates.io build excludes Kotlin because cccc-kt depends on a Kotlin grammar only available via git, which crates.io rejects; use a GitHub Release binary or build from the repo to get Kotlin.
Which languages can cccc analyze?TypeScript/JavaScript, Rust, Go, PHP, Ruby, Scheme, Racket, Common Lisp, Emacs Lisp, Clojure, Kotlin, Python, Zig, C, C++, Perl, Swift, Java, Dart, Scala, C# and Visual Basic .NET, each via its own adapter crate sharing one engine and output format.
Can I scan a mixed-language codebase in one run, or restrict which languages are checked?Yes — directories are walked recursively and each file is routed to the right adapter by extension, so one run can cover a mixed tree; use --lang (and --exclude-lang) to include or exclude specific languages by comma-separated list.
What format is the output?JSON by default, compact and on one line so it pipes easily into jq or an artifact store; pass --pretty for an indented version of the same document.
How would I add support for a new language?Write an adapter crate that lowers that language's AST into cccc-core's normalized IR and calls cccc-core's analyze function, then register it with a single entry in cccc-cli's lang::LANGUAGES registry — no new binary or reimplemented metrics logic needed.
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