mxiris-reverse-engineering/swift-demangling
A pure Swift library for demangling and remangling Swift mangled symbols, with full support for Swift 6 strict concurrency.
Features
- 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
Requirements
- Swift 6.2+
- macOS 10.15+ / iOS 13+ / macCatalyst 13+ / tvOS 13+ / watchOS 6+ / visionOS 1+
Installation
Swift Package Manager
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"),
]
),Usage
Basic 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))Check if a String is a Swift Symbol
"$s4main5helloyyF".isSwiftSymbol // true
"_objc_msgSend".isSwiftSymbol // false
"$s4main5helloyyF".stripManglePrefix // "4main5helloyyF"Demangle Options
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)Inspecting the Node Tree
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 safeChildTree Traversal
Node 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)Remangling
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) // trueBuilding & Modifying Trees
Node 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:)).
Tree Rewriting
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)Custom Print Targets
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, …
// and context()?.parentKind gives the enclosing node kind.
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.
Type Decoding
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)Memory Management
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.
Deep Generic Nesting and Thread Stacks
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.
Bulk Demangling with NodeStore
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.
Incremental Interning with SharedNodeStore
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).
Acknowledgments
- CwlDemangle by Matt Gallagher — the original Swift translation of the demangler
- Apple Swift — the upstream C++ demangler implementation
License
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.
Package Metadata
Repository: mxiris-reverse-engineering/swift-demangling
Default branch: main
README: README.md