Skip to content

moznion/cccc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

55 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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

  • A fast CLI that measures Cognitive Complexity (SonarSource / G. Ann Campbell) and Cyclomatic Complexity (McCabe). Written in Rust; four language front-ends ship today, all sharing the same engine, CLI, flags, and output format:
    • cccc-es — TypeScript / JavaScript, via the oxc parser. Supports .ts, .tsx, .js, .jsx, .mts, .cts, .mjs, .cjs.
    • cccc-rs — Rust, via the syn parser. Supports .rs.
    • cccc-go — Go, via the gosyn parser. Supports .go.
    • cccc-php — PHP, via the php-rs-parser parser. Supports .php.
  • A Rust library for calculating cognitive and cyclomatic complexity in a language-agnostic way

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 Shared CLI machinery (argument parsing, file walking, parallelism, output rendering) as a library. A front-end calls cccc_cli::run(bin_name, version, analyze_fn, default_exts).
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-es-cli The cccc-es binary: a thin shell that wires the cccc-es adapter into the shared cccc-cli runner.
cccc-rs Rust adapter library: lowers the syn AST into cccc-core's IR. Depends only on cccc-core + syn — no CLI dependencies.
cccc-rs-cli The cccc-rs binary: a thin shell that wires the cccc-rs adapter into the shared cccc-cli runner.
cccc-go Go adapter library: lowers the gosyn AST into cccc-core's IR. Depends only on cccc-core + gosyn — no CLI dependencies.
cccc-go-cli The cccc-go binary: a thin shell that wires the cccc-go adapter into the shared cccc-cli runner.
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-php-cli The cccc-php binary: a thin shell that wires the cccc-php adapter into the shared cccc-cli runner.

The adapter and the binary are separate crates so that a library consumer who only wants the metrics pulls in just cccc-es (+ cccc-core + oxc), never clap / ignore / rayon.

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) add a tiny binary crate whose main calls cccc_cli::run(env!("CARGO_BIN_NAME"), env!("CARGO_PKG_VERSION"), analyze_source, DEFAULT_EXTS) — no need to reimplement either the metrics or the CLI. cccc-es (oxc), cccc-rs (syn), cccc-go (gosyn), and cccc-php (php-rs-parser) are the reference adapters: same shape, different parser.

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

cargo build --release
# binaries at ./target/release/cccc-es, ./target/release/cccc-rs, ./target/release/cccc-go, and ./target/release/cccc-php

Usage

cccc-es <paths...> [options]   # TypeScript / JavaScript
cccc-rs <paths...> [options]   # Rust
cccc-go <paths...> [options]   # Go
cccc-php <paths...> [options]  # PHP

All front-ends take the same flags and produce the same output format — the examples below use cccc-es, but cccc-rs, cccc-go, and cccc-php behave identically on .rs, .go, and .php files respectively.

Output is JSON by default. Pass one or more files or directories; directories are walked recursively (respecting .gitignore, always skipping node_modules).

Options

Flag Description
--table Human-readable table instead of JSON
--ext ts,tsx,... Override the set of analyzed extensions
--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
-j, --jobs N Number of files to analyze in parallel (default: logical CPU count)

--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.

Examples

# JSON for one file
cccc-es src/app.ts

# Pretty table for a directory
cccc-es --table src/

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

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

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

# Limit parallelism to 4 workers (default is the logical CPU count)
cccc-es -j 4 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-es in CI lives in its own repository: moznion/cccc-es-action.

- uses: moznion/cccc-es-action@v1
  with:
    path: src/
    max-cognitive: 15

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,
    "cognitive":  { "sum": 10, "max": 7, "median": 2, "p90": 7, "p95": 7 },
    "cyclomatic": { "sum": 10, "max": 4, "median": 3, "p90": 4, "p95": 4 }
  }
}

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-es 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 (cccc-es) 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, and each &&/||/??.

Cognitive (SonarSource):

  • +1 plus a nesting bonus for if, ternary, switch, loops, catch.
  • +1 flat (no bonus) for else/else if, labelled break/continue, each run of like logical operators, and recursion (call to the enclosing function's own name).
  • Nesting increases inside control-flow bodies and nested function bodies.

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. For Rust (cccc-rs): fn / impl methods / trait default methods / closures are the function-like units; if/else if/ else, match (a _ or bare-binding arm is the non-decision default), for/while/loop, labelled break/continue, and &&/|| map to the corresponding nodes. Rust has no ternary (if is an expression) and no try/catch (errors flow through ?), so those simply don't occur.

For Go (cccc-go): top-level functions / methods / function literals (closures) are the function-like units; if/else if/else, for (including for-range), switch/type-switch/select (a default clause is the non-decision arm), labelled break/continue/goto, and &&/|| map to the corresponding nodes. Go has no ternary and no try/catch (errors are returned values), so those simply don't occur.

For PHP (cccc-php): functions / methods / closures / fn arrow functions / property hooks are the function-like units; if/elseif/else, while/ do-while/for/foreach, switch and the match expression (a default arm is the non-decision case), catch clauses, multi-level break N/ continue N and goto, the ternary ?:, and &&/and/||/or/?? map to the corresponding nodes. && and and (likewise || and or) are the same normalized operator; ?? folds as a coalescing run.

About

A tool/library for measurement of "C"ognitive "C"omplexity and "C"yclomatic "C"omplexity

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors