A pure Swift library for demangling and remangling Swift mangled symbols, with full support for Swift 6 strict concurrency.
This project is derived from CwlDemangle by Matt Gallagher, which is itself a line-by-line translation of the Swift compiler's C++ Demangler into Swift. Building on that foundation, this library has been significantly expanded with remangling, type decoding, tree traversal/rewriting APIs, node interning (hash-consing), and a generic printer target system.
- Demangle mangled Swift symbols into a structured
Nodetree - Pretty-print demangled trees with configurable
DemangleOptions - Remangle modified trees back into valid mangled strings
- Decode types from mangled nodes via a pluggable
TypeBuilderprotocol - Traverse & rewrite trees with built-in iterators and
Node.Rewriter - Node interning (hash-consing) via
NodeCache— structurally equal subtrees share one instance, reducing memory ~4x for whole-binary demangling - Compact bulk storage via
NodeStore— an arena packing each node into 12 flat bytes with no object header, reference counting, or per-node allocation; printing and type decoding read straight from it without materializing aNodetree - Supports all mangling prefixes:
_T0,_$S,_$s,$S,$s,$e,_$e,@__swiftmacro_ - Swift 6 strict concurrency — all public types are
Sendable
- Swift 6.2+
- macOS 10.15+ / iOS 13+ / macCatalyst 13+ / tvOS 13+ / watchOS 6+ / visionOS 1+
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/MxIris-Reverse-Engineering/swift-demangling", from: "0.1.0"),
]Then add "Demangling" to your target's dependencies:
.target(
name: "YourTarget",
dependencies: [
.product(name: "Demangling", package: "swift-demangling"),
]
),import Demangling
// Demangle a mangled symbol into a Node tree
let node = try demangleAsNode("$s4main5helloyyF")
// Pretty-print with default options
let readable = node.print(using: .default)
// → "main.hello() -> ()"
// Pretty-print with sugar (e.g., Optional<Int> → Int?)
let sugared = node.print(using: .default.union(.synthesizeSugarOnTypes))"$s4main5helloyyF".isSwiftSymbol // true
"_objc_msgSend".isSwiftSymbol // false
"$s4main5helloyyF".stripManglePrefix // "4main5helloyyF"DemangleOptions is an OptionSet with several presets:
// Full detail (default)
node.print(using: .default)
// Compact output — shortened thunks, value witnesses, archetypes
node.print(using: .simplified)
// Interface-style — no where clauses, no extension contexts, sugar on types
node.print(using: .interface)
// Custom combination
var options: DemangleOptions = .default
options.insert(.synthesizeSugarOnTypes)
options.remove(.displayModuleNames)
node.print(using: options)let node = try demangleAsNode("$s4main5helloyyF")
// Debug dump (kind/contents tree)
print(node.description)
// kind=global
// kind=function
// kind=module, text="main"
// kind=identifier, text="hello"
// ...
// Access node properties
node.kind // .global
node.text // nil (leaf text content)
node.index // nil (leaf index content)
node.children // Node.Children collection
// Subscript access
node[child: 0] // first child (crashes if out of bounds)
node[safeChild: 0] // first child or nil
node.children.at(0) // same as safeChildNode conforms to Sequence with preorder traversal as default:
let node = try demangleAsNode("$s4main5helloyyF")
// Preorder (default)
for child in node {
print(child.kind)
}
// Other traversal orders
for child in node.postorder() { /* ... */ }
for child in node.inorder() { /* ... */ }
for child in node.levelorder() { /* ... */ }
// Find nodes by kind
let modules = node.all(of: .module)
let firstId = node.first(of: .identifier)
let hasType = node.contains(.type)Convert a (possibly modified) node tree back into a mangled string:
let node = try demangleAsNode("$s4main5helloyyF")
let mangled = try mangleAsString(node)
// → "$s4main5helloyyF"
// Check if a tree can be remangled
canMangle(node) // trueNode is immutable after creation. Use NodeBuilder to construct trees incrementally:
// Build a new node tree
let builder = NodeBuilder(kind: .tuple)
builder.addChild(element1)
builder.addChild(element2)
let tupleNode = builder.build()
// Non-mutating transformations (return new nodes)
let modified = NodeBuilder(node).addingChild(newChild)
let replaced = NodeBuilder(node).replacingDescendant(oldNode, with: newNode)
let changed = NodeBuilder(node).changingKind(.structure)NodeBuilder is the entry point for all of these: Node's own mutating and
copying helpers are internal, because a node handed out by the builder is
frozen — that is what makes cyclic trees unconstructible. Note also that a node
carries either contents or children, never both, so the builder has one
initializer for each (init(kind:contents:) and init(kind:children:)).
Subclass Node.Rewriter for bottom-up tree transformations:
class ModuleRenamer: Node.Rewriter {
override func visit(_ node: Node) -> Node {
if node.kind == .module, node.text == "OldName" {
return Node.create(kind: .module, text: "NewName")
}
return node
}
}
let rewriter = ModuleRenamer()
let rewritten = rewriter.rewrite(originalTree)Implement NodePrinterTarget to direct output to custom destinations:
struct HighlightedTarget: NodePrinterTarget {
/// Every printed fragment with the semantic state it came from.
private(set) var fragments: [(text: String, state: NodePrintState?)] = []
/// The type reference the current writes belong to (innermost wins).
private var typeReferenceScopes: [Node?] = []
/// UTF-8 bytes, not `String.count`: the printer uses this purely as a
/// delta probe to decide whether a nested print emitted anything, so the
/// one contract is that any non-empty write must change it. Appending a
/// combining mark leaves `String.count` untouched and would silently drop
/// a qualified-name separator.
var writtenUnitCount: Int { fragments.reduce(0) { $0 + $1.text.utf8.count } }
init() {}
mutating func write(_ content: String) {
fragments.append((content, nil))
}
// Note `@autoclosure`: the context is built lazily, so a target that
// ignores it never pays for it. This requirement has no default
// implementation — an eager `context: NodePrintContext?` parameter is a
// near-miss that fails to compile instead of silently doing nothing.
mutating func write(_ content: String, context: @autoclosure () -> NodePrintContext?) {
// context()?.state is .printIdentifier, .printKeyword, .printType, …
// For .printIdentifier, context()?.parentKind is the kind of the
// entity the identifier names (.protocol, .structure, .function, …),
// private and local names included.
fragments.append((content, context()?.state))
}
// Required so the printer can splice memoized fragments into the output
// without dropping the annotations they carry.
mutating func append(_ other: Self) {
fragments.append(contentsOf: other.fragments)
}
// Also defaultless, for the same near-miss reason as write(_:context:).
mutating func pushTypeReferenceScope(_ node: @autoclosure () -> Node?) {
typeReferenceScopes.append(node())
}
mutating func popTypeReferenceScope() {
typeReferenceScopes.removeLast()
}
}
let highlighted = NodePrinter<HighlightedTarget>.print(node, using: .default)Both rich-target hooks (write(_:context:) and pushTypeReferenceScope(_:))
take their payload as an @autoclosure and deliberately ship without
default implementations: a forwarding default would silently absorb an
implementation written against the older eager signature, leaving the printed
text byte-identical while every annotation vanished. popTypeReferenceScope()
has no default either — a method with no arguments has no near-miss witness to
absorb, but a target that implements push and forgets pop would inherit a
silent no-op, so its scope stack only grows and every write after the first
nominal reference is attributed to that nominal, again with byte-identical
text. Spelling all three out is required even for plain-text targets —
String's own conformance is the minimal shape to copy.
Implement the TypeBuilder protocol to construct your own type representations from demangled trees:
let decoder = TypeDecoder(builder: myTypeBuilder)
let type = try decoder.decodeMangledType(node: node)demangleAsNode interns the resulting tree through NodeCache.shared by default: leaf nodes are deduplicated at creation time, and the finished tree goes through a bottom-up subtree interning (hash-consing) pass. Structurally equal subtrees — across all demangled symbols — share a single Node instance, which reduces memory by roughly 4x when demangling a whole binary:
// Structurally equal subtrees are shared automatically
let node1 = try demangleAsNode(symbol1)
let node2 = try demangleAsNode(symbol2)
// e.g. the `Swift.Int` type subtree in both trees is the same instance
// Interned trees are retained by the cache; clear it when done to free memory
NodeCache.shared.clear()Because interned nodes are canonical, demangling the same symbol twice returns the identical (===) tree instance. Interning never changes structural equality (==), printing, or remangling results.
To skip only the whole-tree hash-consing pass — keeping canonical leaves, but not paying to canonicalize the interior of a tree you will not compare by identity:
let node = try demangleAsNode(symbol, internsSubtrees: false)Note this is not a way to keep the cache from growing: leaves are interned during the parse regardless, so every unique identifier, module and index in the input stays in NodeCache.shared for the process lifetime.
For demangle-and-discard work — demangle, extract a string or a classification, drop the tree — use the fully cache-free entry instead. It is the only entry that touches no global state at all, and its tree remangles byte-identically to the canonical path, so deriving lookup keys via mangleAsString is sound:
let node = try demangleAsNodeTransient(symbol)Rule of thumb: keeping the tree → demangleAsNode (canonical, ===-comparable instances); dropping the tree → demangleAsNodeTransient (nothing is retained behind your back). The transient tree is not canonical — never key logic by instance identity (=== / ObjectIdentifier) on it.
Recursion in the printer, remangler, and type decoder is bounded by fixed depth limits, the same model the Swift compiler uses — but calibrated so they actually fire before the stack dies in unoptimized builds (upstream's constants assume release-built frames and an 8MB stack). Every limit clears the deepest real-world symbol measured by 2× or more; a pathologically deep tree degrades to <<too complex>> (or a .tooComplex / type-lookup error) instead of crashing the process, identically in debug and release.
On Darwin every thread except the main one gets a 512KB stack, which only covers a few dozen levels of nesting. When the calling thread runs low, printing, remangling and demangling hop onto a pooled 8MB-stack worker; threads with room to spare (the main thread, or big threads you create yourself) run inline with zero overhead — which also keeps po node usable under LLDB. When you are about to make many calls from small-stack threads, wrap the batch so it pays for at most one hop:
// At most one thread hop for the whole batch; every call inside runs inline.
let results = StackSafeExecutor.withLargeStack {
symbols.map { try? demangleAsNode($0) }
}withLargeStack requires @_spi(Internals) import Demangling. If you drive the demangler from threads you create yourself, setting stackSize to 8MB or more has the same effect — the library detects the headroom and never hops.
An async pipeline has no synchronous batch to wrap. Run the task on the library's large-stack task executor instead (macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2 and later): every demangle, print and remangle inside it — in synchronous callees too — then finds a 16MB stack and runs inline.
try await withTaskExecutorPreference(StackSafeExecutor.taskExecutor) {
for symbol in symbols {
let node = try demangleAsNode(symbol)
results.append(node.print(using: .default))
}
}The executor's threads are its own — separate from the pool that serves the per-call hops — and child tasks inherit the preference while unstructured Task {} does not. Like withLargeStack, it is @_spi(Internals).
TypeDecoder is the deliberate exception: its TypeBuilder callbacks are your code and may be tied to an actor or a thread, so decoding always runs on the calling thread. Wrap deep batches in withLargeStack yourself.
When demangling a whole binary and keeping every result, NodeStore stores nodes in a flat arena instead of as individual class instances: 12 bytes per node, no object header, no reference counting, no per-node allocation. Build with NodeStoreBuilder, then freeze() into an immutable, Sendable store:
var builder = NodeStoreBuilder()
builder.reserveCapacity(expectedSymbolCount: symbols.count)
var rootIndices: [NodeStore.NodeIndex] = []
for symbol in symbols {
rootIndices.append(try builder.demangle(symbol))
}
let store = builder.freeze()reserveCapacity(expectedSymbolCount:) is optional but recommended when the symbol count is known up front (it usually is — one image, one builder): it pre-sizes every internal buffer from corpus-measured per-symbol constants, so a bulk build pays no buffer-regrowth copies and none of their transient memory spikes. An undersized estimate just degrades to normal growth; capacityUtilization reports used-versus-reserved per buffer.
Nodes are addressed by NodeReference, a 16-byte value handle that mirrors Node's accessors. Printing and type decoding read directly from the arena — no Node tree is materialized:
let reference = store.reference(at: rootIndices[0])
let readable = reference.print(using: .default)
for child in reference.children where child.kind == .identifier {
// `withTextUTF8` borrows the store's string table without allocating;
// `textUTF8Bytes` is the copying convenience when the bytes must escape
print(child.text ?? "")
}The builder hash-conses on insert, so structurally equal subtrees collapse to one index and NodeReference equality is O(1) within a store. This path never touches NodeCache.shared, so bulk indexing leaves global state untouched.
A NodeIndex is only meaningful in the store whose builder minted it. Debug builds enforce this: every index carries its builder's issuance tag, and handing it to another builder or store fails a precondition immediately instead of silently resolving to an unrelated node (release builds compile the tag out — same layout and behavior as before).
Interop with the Node API stays available in both directions — builder.intern(existingNode) imports a tree, and reference.materialize() rebuilds a standalone one:
var builder = NodeStoreBuilder()
let index = builder.intern(try demangleAsNode(symbol))
let store = builder.freeze() // `freeze()` consumes the builder
let node = store.reference(at: index).materialize()Measured on a SwiftUI dyld-cache corpus of 234,232 symbols: 619,688 unique nodes in 8.75 MB of flat storage (14.1 bytes per unique node), built no slower than the Node path.
NodeStoreBuilder wants its input set up front: freeze() is a one-shot barrier, nothing can be read before it and nothing interned after. When the input is discovered over time — type names surfacing while a user browses, late-arriving symbols demangled on demand, trees produced while resolving conformances — use SharedNodeStore: a long-lived, thread-safe arena whose intern/demangle return immediately usable, permanently valid references, with no freeze barrier:
let store = SharedNodeStore() // one per scope (per image / per process)
store.reserveCapacity(expectedSymbolCount: 10_000) // optional, same 0009 coefficients
let nameReference = store.intern(someNodeTree) // structurally equal trees resolve to
let sameReference = store.intern(equalCopy) // the same reference: nameReference == sameReference
let lateReference = try store.demangle(lateSymbol) // cache-free demangle straight into the arenaOne shared store means one arena for the whole scope: common subtrees deduplicate across everything ever interned, NodeReference's intrinsic ==/hash (store identity + index) is structural equality across the scope, and Set/Dictionary keys deduplicate naturally. Interning serializes on an internal lock (the demangle parse runs outside it); reads are lock-free apart from resolving the current buffer descriptor. References keep the storage alive even after the SharedNodeStore itself is released — interning stops, reading never breaks.
Pick by workload: input set known up front → NodeStoreBuilder + freeze() (drops its interning tables at freeze, reads with zero indirection); input discovered over time → SharedNodeStore. The shape to avoid is one private store per tree — NodeReference(interning:) in a loop — which forfeits both deduplication and compactness (measured at 110× the memory of one shared arena on repeated names).
- CwlDemangle by Matt Gallagher — the original Swift translation of the demangler
- Apple Swift — the upstream C++ demangler implementation
This project is licensed under the Apache License 2.0 with Runtime Library Exception, the same license as the Swift project and CwlDemangle, from which this library is derived.