A CpgNode is a single syntactic
element from the source program — a function, an if, a call, a literal. Nodes
are the shared substrate of the whole
Code Property Graph: the
AST,
CFG,
DFG, and
PDG overlays all attach their
edges to the same nodes. This page documents the node structure, the 45 node
kinds, and the small value types (SourceRange, TypeInfo, MethodSignature,
Visibility) that nodes carry.
CpgNode has public fields — you read node.kind and node.range
directly; they are not accessor methods.
pub struct CpgNode {
/// Unique node identifier within the graph.
pub id: NodeId,
/// Node kind with its associated data.
pub kind: CpgNodeKind,
/// Source code span (bytes + line/column).
pub range: SourceRange,
/// Original source text, present for terminals/leaves.
pub text: Option<Arc<str>>,
/// Language-neutral message semantics for a channel event, if any.
pub message_kind: Option<MsgKind>,
/// Frontend-normalized restriction or reflective-name operation, if any.
pub name_operation: Option<NameOperationKind>,
/// Typed lock semantics for a synchronization call, if any.
pub lock_kind: Option<LockKind>,
/// Nominal constructor result, when normalized.
pub allocation_type: Option<Box<TypeInfo>>,
/// Frontend-normalized move or borrow operation, if any.
pub ownership_operation: Option<OwnershipOperationKind>,
/// Extra key/value metadata, allocated only when nonempty.
pub properties: Option<Box<FxHashMap<PropertyKey, PropertyValue>>>,
/// AST children, in source order.
pub children: SmallVec<[NodeId; 4]>,
/// AST parent, if any.
pub parent: Option<NodeId>,
}Construct nodes with CpgNode::new(id, kind, range) and refine them with the
chainable builders with_text, with_message_kind, with_name_operation, with_lock_kind,
with_allocation_type, with_ownership_operation, with_property,
with_child, and with_parent:
use libcpg::{CpgNode, CpgNodeKind, LockKind, NodeId, SourceRange};
let node = CpgNode::new(
NodeId::new(0),
CpgNodeKind::Call { target: None, is_method: true },
SourceRange::default(),
)
.with_text("guard.acquire()")
.with_lock_kind(LockKind::AcquireWrite);message_kind is orthogonal to kind: it preserves channel semantics while a
send remains CpgNodeKind::Call, a select remains Match, and a parallel
composition remains Block. Frontends populate it; feature-free concurrency
analysis consumes it. See the channel-topology component.
name_operation preserves Rholang restriction, fresh-binder, quote, and drop
semantics while nodes retain their structural Block, Variable, or
UnaryOp kinds. See the NameFlow component.
lock_kind likewise preserves Call structure while a semantic adapter records
shared/exclusive acquisition or release. See the
deadlock-analysis component.
allocation_type preserves the exact nominal constructor result while the
syntax remains Call. JavaScript/TypeScript new and Java object creation are
normalized from typed grammar fields; downstream CHA/RTA/VTA analysis never
reparses node text. Legacy or unsupported input leaves the field None. See
virtual-call type refinement.
ownership_operation preserves Rust move, shared-borrow, or mutable-borrow
syntax while the node retains its structural kind. The Rust frontend derives
it from typed grammar fields; feature-free place-capability analysis consumes
it without reparsing source. See Rust place-capability
analysis.
When you add a node to a graph with add_node, the graph assigns the real
NodeId and overwrites the placeholder you passed — so NodeId::new(0)
is the idiomatic filler at construction time.
CpgNodeKind has 45 variants. Most are unit variants (Root, If,
Return), but many are struct variants that carry data (Function { signature },
Call { target, is_method }, Variable { name, .. }). Grouping them by role:
Figure — the 45 CpgNodeKind variants organised into structural,
function-level, variable, statement, expression, type, and special groups.
Source: diagrams/node-kind-taxonomy.puml.
The top-level shape of a compilation unit.
| Variant | Fields | Example |
|---|---|---|
Root |
— | the compilation unit / file root |
Module |
name: Arc<str> |
mod foo, a namespace |
Class |
name: Arc<str>, is_abstract: bool |
class Foo { } |
Struct |
name: Arc<str> |
struct Bar { } |
Enum |
name: Arc<str> |
enum Baz { } |
Trait |
name: Arc<str> |
trait T { } / interface I { } |
Impl |
for_type: Option<Arc<str>>, trait_name: Option<Arc<str>> |
impl T for Bar { } |
| Variant | Fields | Example |
|---|---|---|
Function |
signature: Box<MethodSignature> |
fn foo(x: i32) -> i32 |
Parameter |
name: Arc<str>, param_type: Option<Box<TypeInfo>>, is_variadic: bool |
x: i32, *args |
Block |
scope: ScopeId |
{ stmt1; stmt2; } |
Both free functions and methods use the single Function variant; the
distinction lives in the MethodSignature (its visibility
and is_static).
| Variant | Fields | Example |
|---|---|---|
Variable |
name: Arc<str>, var_type: Option<Box<TypeInfo>>, scope: ScopeId, is_mutable: bool |
let x = 5 |
Field |
name: Arc<str>, field_type: Option<Box<TypeInfo>>, visibility: Visibility |
struct S { x: i32 } |
Control-flow and structural statements. These are all unit variants — their operands are AST children, not inline fields.
| Variants |
|---|
Return, If, Else, While, For, Loop, Match, MatchArm, Break, Continue, Throw, Try, Catch, Finally |
The CfgExtractor reads these to lay down
control-flow edges: an If's children are interpreted as
[condition, then, else], a While's last child as its body, and so on.
| Variant | Fields | Example |
|---|---|---|
BinaryOp |
operator: Arc<str> |
a + b, x && y |
UnaryOp |
operator: Arc<str> |
-x, !flag |
Assignment |
operator: Arc<str> |
x = 1, y += 2 |
Call |
target: Option<NodeId>, is_method: bool |
foo(arg), obj.bar() |
MemberAccess |
member: Arc<str> |
obj.field |
IndexAccess |
— | arr[i] |
Identifier |
name: Arc<str>, definition: Option<NodeId> |
x (with resolved def) |
Literal |
kind: LiteralKind |
42, "hi", true |
Lambda |
captures: SmallVec<[NodeId; 4]> |
` |
Await |
— | fut.await |
Yield |
— | yield v |
Call.target and Identifier.definition hold resolved NodeIds when the
builder could resolve them, letting you jump straight from a use to its
definition or from a call site to its callee.
| Variant | Fields | Example |
|---|---|---|
TypeAnnotation |
type_info: Box<TypeInfo> |
: i32 |
GenericParam |
name: Arc<str> |
<T> |
Function signatures and node-owned type information are boxed because they are
rare and substantially larger than the common unit or name-bearing variants.
Without indirection, Rust sizes every CpgNodeKind for its largest variant and
every graph node pays for that space. Boxing these cold payloads first reduced
the language-feature benchmark layout from 592 to 168 bytes per CpgNode;
allocating the open property map only on first insertion reduces it further to
144 bytes while preserving ownership and exact semantics.
The box is visible in constructors but transparent to ordinary reads:
use libcpg::{CpgNodeKind, MethodSignature, TypeInfo, Visibility};
let function = CpgNodeKind::Function {
signature: Box::new(MethodSignature {
name: "parse".into(),
params: Default::default(),
return_type: Some(TypeInfo::new("Ast")),
is_static: false,
is_async: false,
visibility: Visibility::Public,
}),
};
if let CpgNodeKind::Function { signature } = &function {
assert_eq!(signature.name.as_ref(), "parse");
}For optional node types, var_type.as_deref() produces
Option<&TypeInfo>. Serde serializes Box<T> exactly like T; persisted JSON
does not gain an extra wrapper level.
| Variant | Fields | Example |
|---|---|---|
Comment |
is_doc: bool |
// note, /// doc |
Import |
path: Arc<str> |
use std::io, import os |
Attribute |
name: Arc<str> |
#[derive(..)], @Override |
Macro |
name: Arc<str> |
println! |
Error |
message: Arc<str> |
parser error-recovery node |
Unknown |
kind: Arc<str> |
grammar node with no direct mapping |
Error and Unknown are how libcpg stays robust: an unfamiliar or malformed
construct becomes a typed node (carrying the original grammar kind string)
rather than aborting construction.
CpgNodeKind carries payloads such as names, signatures, and types. When an
analysis only needs the variant, kind.tag() returns the corresponding
CpgNodeKindTag: a Copy, one-byte, 45-variant enum with a one-to-one mapping
to the taxonomy above. The graph-level cpg.node_tag(id) combines lookup and
classification and returns None for an unknown identifier.
use libcpg::{CodePropertyGraph, CpgNodeKindTag, NodeId};
fn branches(cpg: &CodePropertyGraph, id: NodeId) -> bool {
matches!(
cpg.node_tag(id),
Some(CpgNodeKindTag::If | CpgNodeKindTag::Match)
)
}Copying a tag is useful when the next operation mutates the graph: it ends the
immutable lookup borrow without cloning a payload-bearing CpgNodeKind.
Payload-sensitive code should continue to match on &node.kind.
Do not confuse CpgNodeKindTag with
pattern::NodeKindTag.
The former preserves all 45 variants exactly; the latter is an intentionally
coarse 29-way pattern-template vocabulary and maps kinds outside that
vocabulary to Unknown.
The Literal variant carries a LiteralKind (9 variants); the scalar kinds
embed their parsed value:
pub enum LiteralKind {
Integer(i64),
Float(f64),
String(Arc<str>),
Char(char),
Bool(bool),
Null, // null / None / nil
Array, // [1, 2, 3]
Object, // { key: value }
Regex(Arc<str>),
}name(&self) -> Option<&str> returns the identifying name for the kinds that
have one — Module, Class, Struct, Enum, Trait, Function (via its
signature), Variable, Field, Parameter, Identifier, MemberAccess,
Import, Attribute, Macro, and GenericParam — and None for the rest.
for func in cpg.functions() {
// start_line is 0-indexed; add 1 for human-facing output.
println!("fn {} at line {}", func.name().unwrap_or("<anonymous>"), func.range.start_line + 1);
}Five predicates classify a node by role. Their exact membership (from the source) is:
| Predicate | True for |
|---|---|
is_declaration() |
Module, Class, Struct, Enum, Trait, Function, Variable, Field, Parameter |
is_statement() |
Return, If, While, For, Loop, Match, Break, Continue, Throw, Try |
is_expression() |
BinaryOp, UnaryOp, Assignment, Call, MemberAccess, IndexAccess, Identifier, Literal, Lambda, Await, Yield |
is_control_flow() |
If, While, For, Loop, Match, Break, Continue, Return, Throw, Try |
is_error() |
Error |
Because node.kind is a public enum, the general query is nodes_by_kind, which
takes a predicate over &CpgNodeKind (it is not nodes_of_kind(kind)).
Use matches! to select struct variants without naming their fields:
use libcpg::{CpgNodeKind, LiteralKind};
// Count call sites.
let calls = cpg.nodes_by_kind(|k| matches!(k, CpgNodeKind::Call { .. })).count();
// Every string literal (useful for security review).
for node in cpg.nodes_by_kind(|k| matches!(k, CpgNodeKind::Literal { kind: LiteralKind::String(_) })) {
if let Some(text) = node.text.as_deref() {
println!("string literal {text:?} at line {}", node.range.start_line + 1);
}
}For the four most common kinds there are dedicated convenience iterators —
functions(), classes(), variables(), and calls() — each yielding
&CpgNode. To read the data inside a struct variant, pattern-match on
&node.kind:
use libcpg::CpgNodeKind;
for func in cpg.functions() {
if let CpgNodeKind::Function { signature } = &func.kind {
// `params` holds parameter *types* (each a `TypeInfo`); the parameter
// *names* live on the function's `Parameter` child nodes.
let param_types: Vec<&str> = signature.params.iter().map(|t| t.name.as_ref()).collect();
print!("fn {}({})", signature.name, param_types.join(", "));
if let Some(ret) = &signature.return_type {
print!(" -> {}", ret.name);
}
println!();
}
}NodeId is a compact, Copy handle — a newtype around u32:
pub struct NodeId(pub u32);Its API is NodeId::new(u32), as_u32() -> u32, and From conversions in
both directions (u32 → NodeId and NodeId → u32). There is no .index()
method:
use libcpg::NodeId;
let id = NodeId::new(7);
assert_eq!(id.as_u32(), 7);
let from_u32: NodeId = 7u32.into();
let back: u32 = id.into();
assert_eq!(back, 7);Node ids are stable across serialisation, which is why analysis code and the
on-disk form both refer to nodes by NodeId rather than by petgraph's internal
NodeIndex. Resolving an id to a node is cpg.node(id) -> Option<&CpgNode> (an
None for an unknown id).
Every node records where it came from. SourceRange has six u32 fields —
a byte span plus 0-indexed line/column endpoints:
pub struct SourceRange {
pub start: u32, // start byte offset
pub end: u32, // end byte offset (exclusive)
pub start_line: u32, // 0-indexed
pub start_col: u32, // 0-indexed
pub end_line: u32, // 0-indexed
pub end_col: u32, // 0-indexed
}Construct one with SourceRange::new(start, end, start_line, start_col, end_line, end_col) or SourceRange::from_bytes(start, end) (which zeroes the
line/column fields). Helpers: len() -> u32 (byte length), is_empty() -> bool,
and to_text_range() -> text_size::TextRange.
Because the byte offsets index the original text, you can recover a node's exact source slice when you retained the source:
use libcpg::CpgNode;
fn node_text<'a>(source: &'a str, node: &CpgNode) -> &'a str {
&source[node.range.start as usize .. node.range.end as usize]
}Nodes reference a handful of small value types.
Rholang normalizes reflective-name syntax without changing structural node
kinds: Restriction, FreshBinder, Quote, and Drop. Other frontends leave
CpgNode::name_operation as None. NameFlow consumes this typed metadata and
never reparses source.
A lightweight type descriptor. Note generics is a SmallVec of name
strings, not nested TypeInfos:
pub struct TypeInfo {
pub name: Arc<str>,
pub is_reference: bool,
pub is_mutable: bool,
pub generics: SmallVec<[Arc<str>; 2]>,
}Build one fluently:
use libcpg::TypeInfo;
let ty = TypeInfo::new("Vec")
.with_generic("String")
.with_reference(true);
assert_eq!(ty.name.as_ref(), "Vec");Carried by every Function node. Parameters are a SmallVec<[TypeInfo; 4]>:
pub struct MethodSignature {
pub name: Arc<str>,
pub params: SmallVec<[TypeInfo; 4]>,
pub return_type: Option<TypeInfo>,
pub is_static: bool,
pub is_async: bool,
pub visibility: Visibility,
}pub enum Visibility {
Public,
Private, // the Default
Protected,
Package, // package/module-private
Crate, // Rust `pub(crate)`
}A u32 newtype tagging lexical scopes, carried by Block and Variable nodes.
ScopeId::GLOBAL is the constant ScopeId(0); make others with
ScopeId::new(id).
Beyond the typed fields above, a node can hold ad-hoc metadata in its cold
properties map. None is the canonical empty state, and with_property
allocates the boxed map on first insertion. Keys are a small enum with a
Custom escape hatch. Values have seven logical forms but flat physical
ownership, so logical list depth does not become Rust call-stack depth:
pub enum PropertyKey { Name, Type, Scope, Visibility, Mutable, Static, Async, Custom(Arc<str>) }
let value = PropertyValue::List(vec![
PropertyValue::String("role".into()),
PropertyValue::Int(7),
PropertyValue::Null,
]);The historical constructor spellings remain. Scalar accessors and the borrowed
PropertyValueKind/PropertyList views replace recursive enum pattern matching:
use libcpg::{PropertyKey, PropertyValueKind};
if let Some(v) = node
.properties
.as_deref()
.and_then(|properties| properties.get(&PropertyKey::Name))
{
if let Some(name) = v.as_str() {
println!("name property: {name}");
}
}
if let PropertyValueKind::List(children) = value.kind() {
for child in children {
if let Some(integer) = child.as_int() {
println!("integer property: {integer}");
}
}
}The storage choice does not alter persistence: Serde writes None as the
legacy empty map {} and a present box as the map itself.
The node tape is postorder; a separate edge tape retains list order through checked ranges. Clone and drop are flat vector lifecycle operations, while equality, debug, validation, CPV1 encoding/decoding, and Serde use explicit cursor machines. See ADR-0040 and validation 35.
- Edges — how nodes are wired into the four overlays.
- Traversal — navigating from a node to its children, successors, and data-flow neighbours.
- Overview — the CPG at a glance.
api/graph-reference.md— the exhaustive node-type reference.