ast2md (“AST to Metrics Data”) is a multi-language static-analysis engine designed to extract coupling, stability, and structural dependency metrics from compiler ASTs.
The system is architected to support multiple language frontends. Swift and C are currently implemented, sharing the same analysis engine, graph model, and metric computation logic.
ast2md is suited for architecture evaluation, dependency audits, CI enforcement, and large-scale structural analysis.
Using compiler-emitted ASTs, ast2md derives a wide set of semantic and structural metrics:
- Afferent couplings (incoming references)
- Efferent couplings (outgoing references)
- Stability index
- Transitive reachability (incoming and outgoing)
- Categorized dependencies explicit types / abstract types / nested types / modules / methods
- Deep stability
- Ripple and impact effects
- Locality, pressure, skew
- Abstractness
- Longest dependency chains per unit
All metrics are computed using the same algorithms and formulas for every language backend. Differences between languages only affect how semantic units are discovered, not how they are analyzed.
- Frontend:
swiftcJSON AST - Nested, abstract, and protocol-aware
- File-based modules
- Rich semantic signal
- Frontend:
clang -Xclang -ast-dump=json - Flat semantic model
- No language-level nesting or abstraction
- No clustering, since C has no native namespace/module hierarchy
Despite this, all metrics are still computed identically. C units simply do not participate in module clustering.
These frontends rely on the ast dumps of the compilers, but
there is an internal messaging protocol: UnitDeps
pub struct UnitDeps {
pub(crate) name: String,
pub(crate) kind: Kind,
pub(crate) deps: Vec<(String, Kind)>,
}If a frontend emits these to the consumer thread via walk(), you can parse from src, or other dumps, the engine is totally decoupled from the frontend.
All backends emit the same semantic vocabulary:
ModuleExplicitType,ExplicitNestedTypeAbstractType,AbstractNestedTypeMethodNonInferred
Backends may omit kinds the language cannot express, but the engine does not special-case metrics.
Stable IDs are computed as:
xxh3_64_with_seed(name.bytes(), rotated(kind))For each semantic unit:
- transitive afferent reachability
- transitive efferent reachability
- per-kind dependency counts
- total reachability
- locality & skew
- ripple (downstream influence)
- impact (upstream influence)
- deep stability
- abstractness
- stability index
Ca / (Ca + Ce)
Units with zero afferent and efferent edges produce NaN stability (intentional isolation marker).
ast2md is shaped for:
- CI dependency validation
- architecture drift detection
- large-scale static analysis
- mixed Swift / C codebases
- research on dependency metrics
- fast incremental re-analysis
Swift and C differ only in semantic expressiveness, not in analysis rigor.
ast2md - AST Analyzer
Usage: ast2md [OPTIONS] --input <FILES>... --target <TARGET> --lang <LANG> [-- <ADDITIONAL_ARGS>...]
Arguments:
[ADDITIONAL_ARGS]...
Arguments passed after `--` are forwarded verbatim to the driver's compiler.
Options:
-i, --input <FILES>...
List of files to analyze
-o, --target <TARGET>
The target of the pipeline
Possible values:
- report: Produce a human-readable, color-coded report
- json: Emit all metrics and dependency graphs as JSON
- dot: Emit a graphviz dot representation of the full semantic context
- validator: Validate heuristics; return non-zero on excessive bad coupling.
Always returns a dump of all the offending units to stdout as a json.
- tui: Run as a interactive tui
-L, --lang <LANG>
The target of the pipeline
Possible values:
- swift: Emit JSON AST's for swift (needs .swiftmodules precompiled)
- c: Emits JSON AST for each C file (n translation units)
-c, --cap-bad-coupling <N>
Cap the maximum of "bad" coupling occurrences the tool will tolerate before returning a non-zero code.
This only works with the `validator` target and counts all bad efferent, afferent, and instable dependencies.
-t, --threshold <N>
Threshold for bad coupling metrics.
Stability uses it directly.
Afferent/efferent counts are normalized by their maximum.
Default: 0.75% (higher = more strict).
-h, --help
Print help (see a summary with '-h')
-V, --version
Print versionCompiler flags can be forwarded after --:
# Swift
ast2md -i File.swift -o report -L swift -- -I .ast2md_cache
# C
ast2md -i main.c util.c -o json -L c -- -I include/ast2md uses a generic, language-agnostic driver.
There are two execution strategies, selected by language:
Used for single-source ASTs
- Each file is compiled independently
- Incremental recompilation is decided per file
- ASTs are processed in parallel
- Cached JSON ASTs are reused when valid
Note
Single translation unit AST's were discarded due to being worse in both performance and size.
Beware of C ast's, if the codebase is too large, i.e jemalloc, sqlite,... AST dumps become so large that can completely flood the ram in modern systems, when analyzing C files at a scale, it is better to do it by submodules.
This is because C preprocessors inject ALL libc boilerplate into every single tranlation unit.
Recompilation happens per-file if:
- the cache file is missing,
- the source file is newer than it's cache,
- metadata cannot be read.
- any additional flags passed to the compiler have changed
The system always errs toward recomputing.
Stable, deterministic (under the deterministic flag) JSON including:
- unit metadata
- computed metrics
- afferent & efferent edges
Human-readable, color-coded report sorted by stability (under deterministic flag), you can expect it to be like this:
ExplicitType::t_u8 (0xC7C77BF56FF93144)
[ FULL: 10 afferent | 1 efferent ] stability=0.91
├─ total-reachability: 196 afferent | 2 efferent
├─ transitive: 186 afferent | 1 efferent
├─ abstract: 0 afferent | 0 efferent
├─ explicit: 0 afferent | 1 efferent
├─ nested-explicit: 0 afferent | 0 efferent
├─ nested-abstract: 0 afferent | 0 efferent
├─ methods: 10 afferent | 0 efferent
├─ modules: 0 afferent | 0 efferent
├─ visibility: 0.0996
├─ abstractness: 0.0000
├─ deep-stability: 0.9899
├─ pressure: 196
├─ skew: -9
├─ locality: 11
├─ 10 afferent dependencies
│ ├─ Method::_write_u8_fwd
│ ├─ Method::__populate
│ ├─ Method::ft_memctz_u32
│ ├─ Method::ft_memctz_u64
│ ├─ Method::ft_memctz_u128
│ ├─ Method::__maxu8
│ ├─ Method::_look4_u8_fwd_unsafe
│ ├─ Method::ft_q_sqrt_round
│ ├─ Method::ft_roundf
│ └─ Method::ft_pow_u8
└─ 1 efferent dependencies
└─ ExplicitType::unsigned charGraphViz DOT graph.
- Swift: clustered by module
- C: flat graph (no clustering)
CI-oriented validation mode.
Returns:
0if within limits (using the --threshold and --cap-bad-coupling)1otherwise
Prints offending units to stdout (json-serialized).
ast2md also ships with an interactive terminal UI for exploring the computed semantic graph after analysis. Built on top of ratatui its read-only: no mutation, just navigation across units, metrics,
and dependencies.
All TUI query results ARE SORTED, turning off the flag does nothing for this backend.
Invoke it with:
ast2md -L <lang> -o tui -i <files...>The UI is split into two zones:
- Left panel (Detail): the full coupling report for the currently selected unit (same content as the
reportformatter, but rendered as plain text for the TUI). - Right sidebar
- Top (Kind filter): select which kind of semantic units to browse (types, modules, methods, etc.).
- Bottom (Results list): units of the selected kind, sorted by stability index, optionally filtered by search.
Units are color-coded by stability and sorted in the following order:
- Gray: isolated (
NaNstability: zero afferent + zero efferent) - Red: unstable
- Yellow: medium stability
- Green: stable
Focus is explicit: the UI always has one “active” pane.
- Mouse scrolling integration
Tabcycles focus: Kind → Results → Detail → KindCtrl+d,Ctrl+u,jandkwork the same as in Neovim/to search for an entry of the semantic kind you've selectedqquits at any timeEscquits search, not the TUI
/enters search mode (typing goes into the search field shown in the Results title)Backspacedeletes a characterEnterexits search mode back to ResultsEscexits search mode back to Results- When querying nested types,
:-> internally stores\x1F(displayed as::)
Search is a case-insensitive substring filter over unit names within the current kind.
Internally, ast2md uses 0x1F (ASCII Unit Separator) as the nesting delimiter. The UI lets you type nested names naturally:
- When typing in search mode,
:is normalized into the internal delimiter. - Names are displayed using
::for readability.
So you can search like:
MyModule:MyTypeMyModule:MyType:doWork
And the engine will match the canonical internal form.
When focus is on Detail, Up/Down scrolls the report text (useful for large dependency lists).
- The TUI operates over the already-computed semantic context (same as
report/json/dotoutputs). - It’s intended for quickly finding hotspots (low-stability units), scanning dependency pressure, and drilling into specific units without rerunning the pipeline.
ast2md normalizes all semantic units across all languages into a single canonical string representation.
The engine uses 0x1F (ASCII Unit Separator) as an internal nesting delimiter.
Example canonical form:
MyMod\x1FMyType\x1FInner\x1FdoThing
- Cannot appear in identifiers
- UTF-8 safe
- Language-agnostic
- Allows fast byte-level hashing and comparison
- Avoids ambiguity across languages (
.,::,_, etc.)
This representation is engine-internal and stable.
The engine never splits on . or language-specific separators.
All hierarchy reconstruction is driven exclusively by 0x1F.
Example:
A␟B␟C
Produces:
AA␟BA␟B␟C
Swift uses this to infer module clusters. C does not, but still benefits from stable hashing and identity.
Canonical strings are emitted as-is:
{
"unit": {
"str": "MyMod\u001FMyType\u001FInner",
"kind": "ExplicitNestedType",
"hash": 123...
}
}This is intentional:
- lossless round-tripping
- consumer-controlled parsing
- cross-version stability
Only the following targets rewrite the separator:
--target report--target dot
0x1F → ::
cargo build --releaseOr alternatively:
./compile.shcompile.sh will strip metadata from your filesystem and other personal
information cargo embeds in your executable by default.
The only thing implementers have to conform is this trait, where UnitDeps are sent to the main consumer.
pub trait Lang {
fn compile_ast(file: &str, ctx: &Option<Vec<String>>) -> Result<Vec<u8>, Ast2MdError>;
fn walk<'a>(
ast: &'a mut [u8],
sx: Sender<UnitDeps>,
arena: Option<&'a Bump>,
) -> Result<(), Ast2MdError>;
}use ast2md::{SemanticCtx, Ast2MdError, langs::swift::SwiftLang};
use bumpalo::Bump;
let arena = Bump::new();
let mut ctx = SemanticCtx::new(&arena);
ctx.driver<SwiftLang>(&["MyFile.swift".into()], &None)?;
let out = ctx.serialize()?;
let q = ctx.query_top_down("Hello\x1FWorld");
// queries expect normalized names! suggestion: emit normalized in Lang::walk(...) directlyThe library API is language-agnostic; callers provide the desired frontend.
| Feature | Description |
|---|---|
hide_std |
Remove stdlib types from metrics |
emit_noninferred |
Include semantic units marked NonInferred |
deduplicate-edges |
Collapse repeated dependency edges |
pretty_print |
Pretty-formatted JSON outputs |
hide_synthesided_conformances |
Hide synthesized protocol conformances |
| deterministic | Always sort elements before dumping data |
You can find this very bench in benches/driver.rs (+30 concurrent files overall), you can run it with:
cargo benchSwift driver pipeline/golden
time: [388.86 µs 392.56 µs 395.90 µs]
Swift driver pipeline/lineal
time: [179.73 µs 180.51 µs 181.35 µs]
Swift driver pipeline/dag
time: [243.75 µs 244.95 µs 246.25 µs]
Swift driver pipeline/pathological
time: [1.2253 ms 1.2312 ms 1.2373 ms]
Swift driver pipeline/all
time: [1.7287 ms 1.7324 ms 1.7363 ms]
C minishell driver pipeline/minishell
time: [423.09 ms 423.64 ms 424.24 ms]
C tcc driver pipeline/tcc
time: [540.60 ms 543.11 ms 545.60 ms]Tests live under src/engine/test (integration) and the swift/C src files live in test/.
cargo test- Jaime Castro Enrique - castro.jaime [at] proton.me
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.