Architecture¶
demangle turns a mangled symbol name back into the declaration a programmer wrote.
It supports several unrelated mangling schemes, is written in pure Python with no
dependencies, and is meant to be embedded in tools that process millions of symbols.
Those three facts pull in different directions, and the architecture exists to resolve them.
The central problem¶
A demangler has two plausible shapes and each is wrong on its own.
Shape one: parse straight to a string. Fast, small, and what nearly every existing demangler does. But the caller gets an opaque string. A tool that wants the namespace, the template arguments, or the parameter types has to re-parse the output with a regular expression -- and C++ declaration syntax is not a language you can pull apart with regular expressions.
Shape two: parse to an abstract syntax tree. Structured and inspectable, but it allocates an object per grammar node. In Python that cost is measurable: building the tree takes about a fifth longer than building the string, and most callers only ever wanted the string.
The resolution: parsers write to a builder¶
No parser in this codebase constructs its own output. Each one is written against the
Builder protocol in core/builder.py and calls methods like builder.pointer(inner)
or builder.template(name, args) as it recognises grammar productions. What those
calls produce is the builder's business, not the parser's.
Two builders ship:
| Builder | Produces | Use |
|---|---|---|
SpellingBuilder |
Spelling pairs, immediately concatenable |
demangle() -- the hot path |
AstBuilder |
Node trees |
parse() -- structured access |
The parser is written once and stays honest about the grammar; the cost model is chosen by the caller. Adding a third backend -- emitting JSON, or a token stream for a syntax highlighter -- means writing one class and touching no parser.
This is the single most important thing to understand about the codebase. A change that
makes a parser build strings directly, however locally convenient, breaks it. A test
enforces the rule as far as a test can reach -- tests/test_architecture.py fails if a
scheme imports core.spelling -- but a parser that concatenates strings itself and
hands the result to builder.name() imports nothing, and only review catches that.
Expressions¶
An expression inside a type goes through Builder.expression(form, parts), so it
reaches the tree as structure rather than one opaque node. parts interleaves the
production's fixed text with its operands' handles and form names the shape --
binary, conditional, call, sizeof.
One method rather than one per operator. Fifteen methods would be fifteen things every
future builder must implement, and would still not cover the next operator someone
mangles. The parser already owns operator spelling -- it comes from tables.py, which
exists to be checked against the ABI -- so what is left for the builder to decide is
structure, and form plus operands is that.
Brackets are parts like any other. Whether an operand needs them is a precedence
question only the parser can answer, so it arrives settled; a consumer walking the tree
sees a paren expression wrapping the operand rather than punctuation glued into a
string.
Rust reaches the same place by a different route, and the difference is worth knowing.
Its printer emits one linear stream of fragments and a sink decides what to do with
them: TextSink concatenates, TreeSink remembers where each production began and
ended. So the tree is not a second traversal that has to be kept in step with the
text -- it is the same traversal with its boundaries kept, and a tree renders to what
demangle() returns by construction rather than by test. Rust can do this because its
spelling is strictly left to right; a C-family declarator, which wraps the name it
declares, cannot be recovered from a linear stream and needs the builder proper.
When a scheme needs its own spelling¶
core/spelling.py implements C-family declarator placement. A scheme whose output
looks like a C declaration uses it and gets the hard part for free. A scheme whose
output does not -- MSVC, where the calling convention sits inside the parentheses and
the spacing rules differ -- supplies its own node kinds and renderer instead,
subclassing core.ast.Node so walk(), find() and spell() keep working.
AstBuilder is shared regardless.
That is the extension point, not a workaround: forcing every scheme through one
renderer would mean MSVC-shaped branches inside core.
Layout¶
demangle/
api.py demangle(), parse(), detect() -- the public surface
_signature.py signature(): the parts of a name rather than its spelling
filter.py demangling the symbols out of text that is not only symbols
cli.py the `demangle` command
__main__.py `python -m demangle`, the same command
core/
reader.py a bounds-checked cursor; the input primitive for a new parser
builder.py the Builder protocol -- the contract between parser and output
spelling.py SpellingBuilder: C++ declarator placement, the fast path
ast.py AstBuilder and the Node hierarchy
errors.py the exception hierarchy
limits.py the bounds a parser reads before it recurses or emits
style.py named styles, and composing one for a single call
decorations.py what a symbol table adds around a name, which belongs to no scheme
plugin.py LanguagePlugin: the contract a scheme implements
cache.py bounded memoisation
registry.py scheme discovery, including third-party plugins
schemes/
itanium/ the Itanium C++ ABI (GCC, Clang, and everything that follows them)
msvc/ Microsoft's scheme, spelled as `llvm-undname` spells it
rust/ Rust legacy (_ZN) and v0 (_R)
swift/ Swift, both the current mangling and Swift 3's
d/ D, as GNU binutils reverses it
go/ Go package paths and receivers
nim/ Nim, whose symbols are ordinary C identifiers
objc/ Objective-C
pascal/ Free Pascal
delphi/ Borland/Embarcadero Delphi and C++Builder
gnuv2/ pre-Itanium C++: g++ before 3.0, cfront/ARM, Lucid, HP aCC, EDG
codewarrior/ Metrowerks CodeWarrior, the other pre-Itanium C++ mangling
ada/ Ada, as GNAT encodes it
jni/ the C function a Java `native` method is called through
core never imports from schemes; schemes/* never import from each other. Both are
enforced by a test; the cross-scheme rule's test is itself tested against a constructed
violation.
core/style.py imports no scheme: it names each scheme's option module by dotted path
and imports it on first use, so core stays free of schemes at import time.
Why declarator placement is its own module¶
C++ spells a type around the name it declares -- int (*f)(char) -- so a type under
construction is a pair of strings with a hole between them, not a string. Keeping that
hole in the right place through nested declarators is the part of a demangler most often
subtly wrong, so core/spelling.py does it once, with its own tests, for every scheme
whose output looks like a C declaration. Its
reference has the
mechanics.
Substitutions are load-bearing, not an optimisation¶
The Itanium scheme compresses repeated components into back-references: S_, S0_,
S1_. The numbering is implicit -- it depends entirely on which components the
encoder considered substitutable, and in what order. Add one entry the specification
does not, or miss one it does, and every later back-reference in the name resolves to
the wrong component.
The rules are not folklore; they are ABI section 5.1.10, and
schemes/itanium/substitutions.py implements them as a separate, directly testable
component with the specification quoted at each decision. Rust v0 has its own
back-reference scheme with different rules, kept in its own module for the same reason.
Adding a scheme¶
A scheme is a directory under schemes/ exposing a LanguagePlugin (core/plugin.py).
Nothing in core knows the list of schemes at import time: core/registry.py finds the
built-ins lazily and third-party plugins through the demangle.languages entry-point
group, so a separate distribution can add a scheme without a patch to this one. Those
are read only when a caller runs load_plugins(): a plugin may carry a built-in's name
and replace it, so that is the caller's choice, never an install's.
Detection is the part with a cost model. It runs on every non-mangled symbol in a
binary -- in a typical binary, most of them -- so detect is expected to be a prefix
test, and the registry narrows what a name is offered to before any detect runs. A
scheme with a fixed first character declares it (first_characters), and a name
starting with anything else never reaches it. A scheme whose names are ordinary C
identifiers has none, so it declares a DETECT_SCREEN: the markers and openings that a
name must hold for its detect to say yes, and a name with none of them is not offered
to it. The schemes whose names carry no marker at all -- Ada and the two pre-Itanium
C++ manglings, whose detect has to parse -- are offered last, behind everything a
prefix settles.
Adding a scheme is the walk-through.
Performance¶
Design rules, in the order they matter:
- The common call must not allocate an AST. Hence the builder protocol.
- Detection precedes parsing. See Adding a scheme.
- Results are memoised. Symbol tables repeat names heavily -- the same
std::allocator<char>appears thousands of times in one binary. Caches are bounded so a long-running process cannot grow without limit. __slots__on every hot class. Node and Spelling instances are created in the millions.- No regular expressions in the hot parse loops. The Itanium, MSVC, Rust and Swift parsers advance by character dispatch; a regular expression appears there only to test a whole token or a suffix. The smaller schemes (JNI, Objective-C, Pascal, Delphi, CodeWarrior, Ada, Nim) use them over short names.
benchmarks/ measures all of this against real symbol corpora, and
benchmarks/bench.py --check runs in CI on every pull request, so a regression fails
the build rather than waiting for someone to complain.
Correctness¶
Correctness is defined against the reference implementations, not against our own
reading of the specifications. Most schemes have one: llvm-cxxfilt and GNU c++filt
for Itanium, llvm-undname for MSVC, the rustc-demangle crate for Rust,
c++filt --format=dlang for D and --format=gnat for Ada, Embarcadero's own unmangler
for Delphi, and the third-party cwdemangle for CodeWarrior. Where no distribution
ships one, it is built here -- Swift's and pre-Itanium C++'s from the compiler's own
sources, and rustc-demangle behind a small front end, since the Rust readers inside
the C++ demanglers are not it. Nim, Free Pascal and Objective-C are checked against what
the compiler itself recorded for each symbol. Go and JNI have no reference anywhere, and
are held to a property instead: re-mangling what was read has to reproduce the symbol.
CONFORMANCE.md records what each is measured against and what the
measurement says.
tests/conformance/ holds frozen corpora with the reference output recorded next to
each name; CONTRIBUTING.md says how their pass
counts are pinned. The harness in tools/ regenerates those corpora and can run a live
differential against the reference binaries when they are installed.
The contract at the boundary is deliberately narrow: demangle() never raises and
returns its input unchanged when it cannot do better, because a wrong expansion is
worse than a mangled name -- it matches neither spelling. Callers who need to know the
difference use parse(), which raises.