Source metrics for C
These metrics describe the source text that Glaurung can parse. They cannot tell you whether a
function is correct. Use glaurung source-metrics to choose functions to review, check
complexity limits, or track changes in pseudocode.
Examples rerun on 2026-09-05 with the engine checkout's installed native extension. Code references use 1f5afd69.
Rank functions
From a Glaurung checkout with the Python package installed, run this command on the included C files. Replace the path with your own file or directory.
$ uv run --no-sync glaurung source-metrics tests/decbench_corpus/src --limit 6 --sort cyclomatic
14 file(s), 61 lines, 30 function(s); top 6 by cyclomatic
function cyc cog nest loops lines calls location
fsm 10 16 4 1 6 0 tests/decbench_corpus/src/statemachine.c:1
dispatch 9 1 1 0 4 0 tests/decbench_corpus/src/switch_jt.c:1
bsearch_i 4 6 2 1 2 0 tests/decbench_corpus/src/sort.c:3
bubble 4 6 3 2 2 0 tests/decbench_corpus/src/sort.c:1
matmul 4 6 3 3 3 0 tests/decbench_corpus/src/matrix.c:1
str_cmp 4 2 1 1 1 0 tests/decbench_corpus/src/strops.c:2 dispatch has eight case arms and a default. Its
flat switch scores 9 for cyclomatic complexity and 1 for cognitive complexity. The fsm function nests a switch and conditionals inside a loop, so its cognitive score rises to 16.
Start with cognitive complexity to find nested logic; use cyclomatic complexity to inspect branching.
The default is --sort cognitive --limit 20. Use --limit 0 to show
every function. A directory search includes *.c and *.h files recursively.
You can also pass several paths.
Choose the input format
For ordinary C, omit --dialect. Glaurung reads each file separately and does
not resolve #include directives or check types.
For C-like pseudocode, add --dialect decompiled. This runs the
decompiler-text normalization pass before parsing. It can handle undeclared types, but
malformed syntax can still cause missing functions or wrong graphs.
For a file produced by gcc -E, pass its path explicitly and add --dialect preprocessed. Directory searches do not pick up .i files.
Both dialect modes change the text. Reported positions refer to that normalized text,
which Python exposes as report.source.
Read the metrics
A control-flow graph (CFG) records the possible transfers between parts of a function. In
the formula below, E counts edges and N counts nodes. Glaurung builds
this graph from the parsed C.
| Metric | What it counts | How to use it |
|---|---|---|
| cyclomatic (cyc) | Branch complexity from the control-flow graph. Computed as E − N + 2 over reachable nodes and edges, with a minimum of 1. | Find functions with many paths to inspect or test. |
| cognitive (cog) | A score that adds weight for nested control structures. An else-if chain does not add a nesting penalty; recursion adds no cost here. | Find deeply nested logic. A flat switch can score much lower than nested if statements. |
| max_nesting (nest) | Deepest level of nested control structures. Plain braces do not add a level. | Find functions that may benefit from early returns or smaller helper functions. |
| loops | Distinct loop headers targeted by edges that the graph builder marks as loop back edges. | Read with max_loop_depth when nested loops matter. |
| gotos / is_structured | Count of goto transfers. is_structured is true when that count is zero. | Track changes in pseudocode. Zero gotos does not prove the program behaves correctly. |
| unreachable_statements | Statements the parser's graph cannot reach from the function entry. Shown as dead in the file summary. | Inspect the reported code. Constant conditions are not folded, so the count can miss unreachable statements. |
| lines / code_lines / tokens | Definition length, lines where tokens begin, and token count. | Use tokens when formatting changes would distort a line-count comparison. |
| calls / callees | Call-expression count and distinct directly named targets. Calls through pointers or members can lack a target name. | Inspect dependencies. call_graph() includes named external targets but omits unnamed indirect calls. |
| halstead_* | Operator and operand counts, plus volume, difficulty, and effort calculated from them. | Keep the counting rules fixed across runs. These scores do not measure elapsed work or predict defects. |
decision_points counts each reachable node's outgoing edges beyond the first. Cyclomatic
complexity can be lower than decision_points + 1 when the graph has several places
where execution stops, such as a call that never returns. Use the same definition when comparing
results from different tools.
At file level, other_lines includes nonblank lines where no token starts. Those
can be comments or continuation lines of a multiline string. Do not use it as a comment count.
Export results
Add one of these options to the command above. For saved reports, redirect standard output to a file.
| Option | Output |
|---|---|
| --summary | One row per file: function and line counts, maximum complexity, goto count, and unreachable statements. |
| --json | Full reports, including per-function metrics and parser diagnostics. |
| --summary --json | File totals and metric distributions: minimum, median, mean, and maximum. |
| --csv | One row per function: path, function name, and 43 numeric metrics. The header names each column. |
--sort and --limit affect the function table. They do not filter JSON,
CSV, or file summaries. Keep the engine revision, input paths, and dialect with saved results
so you can repeat the measurement.
Set complexity limits
Repeat --fail-over METRIC=N to set several limits. Each check uses a strict greater-than
comparison: a score equal to the limit passes. The command checks every measured function, including
those hidden by --limit.
$ uv run --no-sync glaurung source-metrics tests/decbench_corpus/src \
--fail-over cyclomatic=8 --fail-over max_nesting=3 >/dev/null
3 threshold violation(s):
tests/decbench_corpus/src/statemachine.c:1: fsm: cyclomatic 10 > 8
tests/decbench_corpus/src/statemachine.c:1: fsm: max_nesting 4 > 3
tests/decbench_corpus/src/switch_jt.c:1: dispatch: cyclomatic 9 > 8
$ echo $?
1 These limits demonstrate the option; choose values for your own codebase. A violation returns exit status 1 and lists the function on standard error. Invalid threshold arguments or a run with no readable input return 2.
Use Python
analyze_path() reads a file. Use analyze(code) when you already have
the text. Both return a SourceReport with functions and diagnostics.
import glaurung
report = glaurung.source.analyze_path(
"tests/decompiler_fixtures/src/05_cleanup_and_state_machine.c"
)
print("function cognitive nesting gotos")
for f in report.hotspots(by="cognitive", limit=5):
print(f.name, f.cognitive, f.max_nesting, f.gotos)
print("functions:", len(report.functions))
print("diagnostics:", len(report.diagnostics)) function cognitive nesting gotos
fsm 30 3 3
process 20 2 7
functions: 2
diagnostics: 0 hotspots() sorts functions from highest to lowest and breaks ties by name. Use
limit=None for all functions in Python; the CLI uses --limit 0.
Use report.summary() for file totals and metric distributions, or report.to_dict() for the full report. To inspect a parser problem, read diagnostic.severity, diagnostic.message, and diagnostic.text; the last includes a source excerpt.
glaurung.source.features(report.source) returns numeric rows paired with function
names. Get the column order from glaurung.source.feature_names(). Pass report.source after normalization so the rows describe the same text as the report.
Replace pyjoern calls from Python
Import fast_cfgs_from_source from glaurung.source in place of
pyjoern. The file-based call is a drop-in replacement for scripts that read
graph topology. It returns a dictionary of function names and NetworkX graphs. Code that
reads Joern statement or type objects needs a different analysis. For data and control
dependence, see Dependence graphs; for joern-export, see Export graphs.
Install Glaurung with the graphs extra to include NetworkX. In a checkout, run
uv sync --locked --dev --extra graphs. The parser runs inside Python's
process; these calls do not start Java or Graphviz.
Change imports from pyjoern to glaurung.source. File paths stay
the same. The example below runs on the C fixtures included in the engine repository.
from glaurung.source import (
fast_cfgs_from_source,
parse_source,
parse_callgraph,
)
graphs = fast_cfgs_from_source("tests/decbench_corpus/src/branches.c")
for name, graph in sorted(graphs.items()):
print(name, graph.number_of_nodes(), graph.number_of_edges())
functions = parse_source("tests/decbench_corpus/src/branches.c")
function = functions["nested"]
print(function.name, function.start_line, function.end_line)
calls = parse_callgraph("tests/decbench_corpus/src/recursion.c")
print(sorted(calls.edges)) classify 5 4
nested 5 4
nested 2 2
[('ackermann', 'ackermann'), ('fib', 'fib')] parse_source() returns function objects with filename, start_line, end_line, callees, and cfg. Their metrics field exposes the measurements described above. Pass no_cfg=True when you only
need metadata and metrics; that mode does not need NetworkX.
For pseudocode, add is_decompilation=True. Glaurung normalizes the text in
memory and leaves the file unchanged. Parser diagnostics produce a warning by default. Add strict=True to raise SourceParseError on any diagnostic. Read function.diagnostics or graph.graph["diagnostics"] for the file's diagnostic objects.
Check your graph consumer
CFG nodes have id, is_entrypoint, and is_exitpoint attributes.
They do not contain Joern's statement objects. Glaurung joins chains of nodes and removes the
standalone function-end node for these comparison graphs, so some graphs have no flagged exit.
Node IDs are local to a function; relabel them before merging graphs.
parse_source(directory) searches C files and headers recursively, with (name, absolute_filename) keys to keep file-local functions separate. The CFG and call-graph helpers accept one file
at a time. The call graph includes named external targets and isolated definitions, but it does
not resolve indirect calls.
The API rejects explicit timeouts, lift_cfgs=False, and supergraph=False. parse_source() defaults to no_ddg=True and no_ast=True; asking for either graph raises an error. Graph shapes can differ between parsers, so
compare your results before changing a benchmark's parser.
The Python migration guide lists the supported options and result fields. The implementation uses the existing source_cfg comparison graphs. Use source.analyze() for complexity measurements.
Export graphs
glaurung source-graph writes a function's control-flow graph or syntax tree in
one of four formats. It replaces joern-export for those two graphs. Neither call
starts a JVM.
$ uv run --no-sync glaurung source-graph \
tests/decbench_corpus/src/branches.c --func classify
// tests/decbench_corpus/src/branches.c: classify
digraph "classify" {
node [shape=box, fontname=monospace, fontsize=10];
n0 [label="entry", kind="entry", span="0:0"];
n1 [label="exit", kind="exit", span="83:83"];
n2 [label="cond\la>b", kind="cond", span="30:33"];
n3 [label="return\lreturn a-b;", kind="return", span="35:46"];
n4 [label="cond\la<b", kind="cond", span="55:58"];
n5 [label="return\lreturn b-a;", kind="return", span="60:71"];
n6 [label="return\lreturn 0;", kind="return", span="72:81"];
n0 -> n2 [label="fall", kind="fall", back="false"];
n2 -> n3 [label="true", kind="true", back="false"];
n2 -> n4 [label="false", kind="false", back="false"];
n3 -> n1 [label="jump", kind="jump", back="false"];
n4 -> n5 [label="true", kind="true", back="false"];
n4 -> n6 [label="false", kind="false", back="false"];
n5 -> n1 [label="jump", kind="jump", back="false"];
n6 -> n1 [label="jump", kind="jump", back="false"];
}
Node labels carry the node kind and the source the node covers. Every node also carries a
span, the byte range it came from, so you can map a graph node back to the
text. Edges carry their kind and whether they close a loop.
| --graph-format | Reader |
|---|---|
| dot | Graphviz. Pipe to dot, or read it directly |
| graphml | Read by NetworkX, igraph, JGraphT, Gephi, and yEd |
| json | Node-link JSON. networkx.node_link_graph reads it unchanged |
| mermaid | Renders in Markdown and on GitHub without Graphviz |
Use --graph-format for the graph and --format for the command's own
output. Both accept json and they mean different things.
$ uv run --no-sync glaurung source-graph \
tests/decbench_corpus/src/branches.c --func classify --graph-format mermaid
%% tests/decbench_corpus/src/branches.c: classify
flowchart TD
n0["entry"]
n1["exit"]
n2["cond a>b"]
n3["return return a-b;"]
n4["cond a<b"]
n5["return return b-a;"]
n6["return return 0;"]
n0 -->|fall| n2
n2 -->|true| n3
n2 -->|false| n4
n3 -->|jump| n1
n4 -->|true| n5
n4 -->|false| n6
n5 -->|jump| n1
n6 -->|jump| n1
Add --repr ast for the syntax tree. The command writes to standard output by default.
Pass -o DIR to write one file per function instead, which is what
joern-export does.
$ uv run --no-sync glaurung source-graph tests/decbench_corpus/src \
--graph-format graphml -o graphs/
30 graph(s) written to graphs/
$ ls graphs/ | head -3
arith-000-addmul.graphml
arith-001-shifts.graphml
arith-002-signs.graphml File names carry the function's position as well as its name, because two definitions in one file can share a name after recovery.
| --repr | Graph |
|---|---|
| cfg | Control-flow graph. The general graph, with entry and exit as nodes |
| ast | Syntax tree, one per function definition |
| ddg | Data dependence: which write each read can see |
| cdg | Control dependence: which branch decides each statement |
| pdg | Both, on one node set. The graph a slice comes from |
These are the representations joern-export offers, apart from cpg14. That one is a code property graph, which this parser does not build; asking for it
returns an error rather than a different graph under its name. The three dependence graphs
are described under Dependence graphs.
Dependence graphs
Three graphs answer three questions about one function. --repr ddg asks which write
each read can see. --repr cdg asks which branch decides each statement.
--repr pdg is both on one node set, which is what a slice needs.
Control dependence
The example is nested: an if inside an if, with a
return in each arm.
$ uv run --no-sync glaurung source-graph \
tests/decbench_corpus/src/branches.c --repr cdg --func nested --graph-format mermaid
%% tests/decbench_corpus/src/branches.c: nested
flowchart TD
n0["entry"]
n1["exit"]
n2["cond x"]
n3["cond y"]
n4["return return z;"]
n5["return return -z;"]
n6["return return 0;"]
n2 -->|true| n3
n2 -->|false| n6
n3 -->|true| n4
n3 -->|false| n5
Each edge names the arm that decides it. joern-export --repr cdg writes its edges
with no label, so "runs when the guard holds" and "runs when it does not" read the same there.
import glaurung
code = open("tests/decbench_corpus/src/branches.c").read()
for function in glaurung.source.control_dependence(code):
if function["name"] != "nested":
continue
for node in function["nodes"]:
print(node["id"], node["kind"], node["depth"], node["ipdom"])
print(glaurung.source.backward_slice(code, "nested", 6))
# 0 entry depth 0 ipdom 2
# 1 exit depth 0 ipdom None
# 2 cond depth 0 ipdom 1
# 3 cond depth 1 ipdom 1
# 4 return depth 2 ipdom 1
# 5 return depth 2 ipdom 1
# 6 return depth 1 ipdom 1
# [0, 2, 6] depth is the longest chain of decisions above a node. The two returns inside both
ifs are at depth 2; the outer return is at depth 1; the entry is at 0. It is
computed on the graph, so a goto that leaves a block or a decompiler's flattened
dispatch does not change it the way a brace count would.
backward_slice returns every node that can affect the one you name, following control
and data dependence together. Slicing on node 6, the outer return 0;, gives [0, 2, 6]: itself, the branch that reaches it, and the entry that defines the branch's operand.
Neither inner arm is included.
Post-dominance is built with a virtual exit, so a region that cannot reach the function
end still has a tree. A body of L: goto L; has no post-dominator tree without one,
and its control-dependence graph would come back empty rather than wrong. Nodes in that position
are listed in unreachable_exit.
Program dependence
$ uv run --no-sync glaurung source-graph \
tests/decbench_corpus/src/branches.c --repr pdg --func nested
n2 -> n3 [label="true", kind="true", dependence="control"];
n2 -> n6 [label="false", kind="false", dependence="control"];
n3 -> n4 [label="true", kind="true", dependence="control"];
n3 -> n5 [label="false", kind="false", dependence="control"];
n0 -> n2 [label="x", dependence="data", variable="x"];
n0 -> n3 [label="y", dependence="data", variable="y"];
n0 -> n4 [label="z", dependence="data", variable="z"];
n0 -> n5 [label="z", dependence="data", variable="z"];
Every edge carries dependence, and every data edge also carries the variable.
Read this graph when you need both relations over the same nodes; read --repr ddg when you want data dependence at its own finer granularity, where a definition and a use inside
one statement stay separate nodes.
Compared with Joern
joern-export --repr cdg on the same fixtures. Joern's nodes are expression-granular
and ours are control-flow nodes, so the comparison is on the pairs of source lines each dependence
connects, which both tools can be judged on identically.
| Fixture | Joern | Glaurung | Agreed |
|---|---|---|---|
| 03_loop_shapes.c | 60 | 63 | 60 |
| 01_conditional_polarity.c | 36 | 37 | 36 |
| 13_loop_early_exit.c | 30 | 32 | 30 |
| 152_deep_nesting.c | 176 | 177 | 174 |
We report every control dependence Joern reports on the first three files. The two differences in the fourth are a single statement written across two lines: Joern attributes it to the continuation line and we attribute it to the line it starts on.
Reading decompiled output
Control depth measures nesting on recovered code. Compared against the original source for the same 84 functions, matched by name:
| Measure | Value |
|---|---|
| Matched functions | 84 |
| Deeper after decompilation | 26 |
| Same depth | 55 |
| Shallower | 3 |
| Median depth, source to decompiled | 1 to 2 |
The deepest case moves from 7 to 13. Execution tests pass on all of these, because the recovered code returns the right value at either depth. Read this beside the dead-store count: both describe output that is correct and harder to read than the source it came from.
Prove paths unreachable
Every graph above reads structure. This one asks whether a path can be taken by any input at all, by putting its branch conditions to an SMT solver. It is the question a code property graph cannot answer: reachability that has never been checked for satisfiability reports paths no input can reach, and on decompiler output that is not a rare case.
int decide(int x) {
if (x > 10) {
if (x < 5) { return 1; }
}
return 0;
} >>> code = open("decide.c").read()
>>> report = glaurung.source.path_feasibility(code, "decide")
>>> for path in report["paths"]:
... print(path)
{'decisions': 1, 'verdict': 'feasible', 'args': [0], 'why': None}
{'decisions': 2, 'verdict': 'feasible', 'args': [15], 'why': None}
{'decisions': 2, 'verdict': 'infeasible', 'args': None, 'why': None}
>>> report["infeasible"], report["unreachable_blocks"], report["total"]
(1, [268435648], True)
>>> report["redundant_guards"]
[{'path': 1, 'decision': 1, 'implied_by': 1}] x > 10 and x < 5 cannot both hold, so the third path is
refuted and the block behind it is code no input executes. The feasible paths carry an
input that takes them. And the second decision is forced: once
x > 10 holds, x < 5 is already false, which is a solver
result rather than a syntactic one — x > 10 forcing
x > 0 is the same finding and nothing about the two expressions is equal.
total is the field to read before trusting unreachable_blocks.
When a path is cut by a bound it carries no verdict, and "every path I looked at is
infeasible" is not "no input gets here" — so nothing is claimed dead unless the
enumeration covered the function.
A whole translation unit
glaurung.source.source_findings(code) answers for every function and returns
only the ones with something to say. A function whose paths are all feasible and whose
guards are all load bearing is the ordinary case, and listing it would bury the rest.
Four keyword arguments — max_paths, max_block_visits,
max_steps, solver_timeout_ms — move the bounds;
max_block_visits is the loop unroll depth and the one to raise first.
| Over tests/decompiler_fixtures/src | Value |
|---|---|
| Functions decided | 293 |
| Paths decided | 1,131 |
| Infeasible — no input takes them | 373 (33%) |
| Feasible | 746 |
| Undecided by the solver | 12 |
| Cut by a bound, no verdict | 510 |
A third of the decided paths cannot be taken. The shape that produces them is the
mutually-exclusive dispatch chain decompiler output is made of — if (first == 'H' && second == 0) ... if (first == 'C' && second == 0). Each && contributes two decisions, so a path can assume the first
test's left half held, its right half did not, and then that first == 'C'
holds — of one byte, at one program point, with no assignment between.
69_molar_mass.c::element_mass_centi alone accounts for 87 of them.
Inputs that break the function
The same walk asks whether some input reaches an operation and makes it undefined: a division by zero, or a shift at or past the operand width.
>>> src = open("54_sha256_block.c").read()
>>> for f in glaurung.source.source_findings(src):
... for v in f["undefined_behavior"]:
... print(f["function"], v)
rotate_right {'path': 0, 'property': 'shift_past_width', 'width': 32, 'args': [0, 2147483680]}
rotate_right {'path': 0, 'property': 'shift_past_width', 'width': 32, 'args': [0, 2147483648]}
>>> src = open("17_hash_table.c").read() # same loop
hash_slot {'path': 0, 'property': 'division_by_zero', 'width': None, 'args': [0, 0]} | Across 900 corpus functions | Value |
|---|---|
| Functions with an infeasible path | 23 |
| Functions with a provably unreachable block | 7 (8 blocks) |
| Functions with reachable undefined behavior | 4 |
Four functions in nine hundred. Grepping for << finds every shift;
asking whether an input reaches one and breaks it finds
rotate_right — (value >> amount) | (value << (32u - amount)),
undefined at amount == 0. That is the one function in this corpus whose
undefined behavior a person had to find by hand, and the checker rediscovered it from the
source with no annotation.
A satisfying assignment is not taken on trust. The solver and our interpreter disagree in
places: x / 0 is 0 concretely and all-ones under SMT-LIB. So
every witness is re-run under the interpreter, and then fed to the binary
gcc built from the same source — 745 solver-chosen inputs, 600 decided, none
diverged. That is a sharper probe than a fixed vector set, because a solver does not pick
round numbers.
What it will not answer, it says. A function outside the lowering's subset returns
abstained naming the construct rather than a verdict; of 900 corpus
functions, 403 do not lower and 204 more have a pointer parameter, for which there is no
input space to quantify over. Array bounds is the property this does not check: the extent
lives in the lowering and the LLIR carries an address, so a[i] and
*(p + i) are the same term by the time there is one to ask about.
Compare reports
Analyze both files, then call glaurung.source.compare(before, after). For two
versions of pseudocode, use dialect="decompiled" on both reads. For original C
compared with pseudocode, apply it only to the pseudocode.
The result contains matched functions with before and after values. Each delta
is after - before. It also lists added and removed names.
The totals include only matched functions.
Check those name lists first. Matching uses function names, so a rename appears as a removal and an addition. If parsing recovers the same name more than once, the comparison keeps only the last definition on each side. Compare corresponding files separately when different files reuse names.
The default comparison covers cyclomatic and cognitive complexity, nesting, gotos, unreachable statements, lines, and statements. A drop in nesting or gotos can help you choose which changes to review. Check the pseudocode against disassembly or execution tests before treating that drop as an improvement in decompilation.
Check parser results
A report can contain recovered functions alongside parser errors. Check report.diagnostics and the recovered function names before ranking damaged input. An empty report can mean the
file defines no functions, normalization removed the text, or parsing lost the definitions.
A recovered body may still be incomplete, and no diagnostics does not prove that parsing was correct. When a score changes sharply, inspect the function before concluding that its complexity changed.
If you need the CFGs, use glaurung.source.control_flow_graphs(code). The
separate glaurung.source_cfg API builds graphs intended to match Joern's representation
for comparison metrics. The metrics on this page use the general CFG.
Code and definitions
The metric reference gives the counting rules, including cognitive complexity and Halstead operators and operands. The implementation is in the C metrics module and the graph metrics module.
For arguments and result fields, read the CLI command and Python API. The Python tests cover ranking, malformed input, exports, and report comparisons.