English · 한국어 CMake Link ExplorerA VS Code extension for the moments a CMake project stops you over linking.
Where the answers come fromIt does not parse
The codemodel carries targets, dependencies, macros, include paths and the
InstallNothing to build, no dependencies. It is plain JavaScript. Windows, double-clickGrab From a VSIXGrab the
To build one yourself from the repository (needs Node; the extension itself still has no dependencies):
Cloning and linkingEasier if you intend to edit it: macOS / Linux
Windows — symlinks can need elevation, so use a junction (
Checking it tookQuit VS Code completely and start it again.
From then on it wakes up when you open a CMake project or a C/C++ file. There is no icon to press first. The build directory is found by looking for Only when working on the extension itself: F5Open this repository in VS Code and press Link for includeThe most frequent place to get stuck.
Put the cursor on an
Take it and the right
An existing An existing call is only extended when extending it is right. A library
appended to a call that sits inside The keyword follows from where you included it.
Include from a header and link it Four verdicts
Calling out
Turning the dotted line solid is what the How it finds themHeader to target goes through three steps, reported in order of confidence.
Which target a file belongs to comes from CMake's source lists, read backwards. Limit — header-only INTERFACE libraries do not appear as targets in the CMake
codemodel, so they cannot be found. abseil's What Is This File Compiled With?
Run the command on a source file and out come the effective macros and include paths.
No CMakeLists spells this out. Only Every A Headers are compiled by nobody, so they are in no compile group. When the target has exactly one language group that is what including them means, so it is shown and marked as inferred; when C and C++ both sit in the target, nothing is picked. Choosing one would be inventing the answer. Targets
One row carries both directions.
Expanding shows both directions at once, with no folder to go through first. Keep expanding a child and it follows the chain in that direction only. Open a map and sizes appearOpen a map file in the Linker Map view and the same row gains how much of the binary that target occupies.
The Two questions then sit on one line.
Set How it worksIt does not parse CMakeLists.txt. It uses CMake's File API (3.14+). Drop a query file in the build directory and CMake writes the fully resolved
target graph as JSON into The reply is a snapshotThat reply is written when CMake configures, and nothing rewrites it when you
edit a So every command compares the reply against the
The status bar carries the same warning, and the tooltip names the edited files. A timestamp alone would fire on An open The status bar tracks this live: editing, saving or reverting a Transitive reductionThe File API's So it is reduced to the minimum set of edges that preserves reachability.
Six edges became three and every reachable relationship survived. There is
still a path from
Careful — the reduced graph is "the minimal shape of the link structure", not
"the list written in
Turn on Features
The jump is not a text searchLook for a target by searching for
Searching for Find Cycles and Unused TargetsTwo things a link graph can answer about itself.
A cycle is not visible in
|
| Excluded | Why |
|---|---|
| executables | they are entry points. Nothing linking them is normal |
install()ed libraries |
that is the deliverable, built for somebody outside |
| MODULE libraries | plugins, reached by dlopen rather than by linking |
| UTILITY targets | never a link target in the first place |
Compare With Another Build Tree
The same CMakeLists.txt configures differently on different platforms. If
day-to-day development happens on Windows and the product is built on Linux, that
difference is usually what "builds here, breaks there" turns out to be. Put the
two build trees side by side.
this tree C:/proj/out/build/x64-Debug
other tree /mnt/linux/proj/build
only in this tree (1)
win_shim [static] src/win/CMakeLists.txt:3
only in the other tree (1)
posix_shim [static] src/posix/CMakeLists.txt:3
differing targets (1)
"-" is only in this tree, "+" only in the other.
core
define - USE_IOCP
define + USE_EPOLL
include - src/win
include + src/posix
links - win_shim
links + posix_shim
Compare paths literally and everything differs
The same project has completely different path prefixes on two machines. Compare
C:/work/proj/src against /home/me/proj/src as strings and every include
reads as changed, which buries the signal.
So each path is taken relative to its own tree's source root — both of those
become src. Separators (\ vs /) are settled too, and case is ignored: one
side is usually Windows, where CMake can record a casing the checkout does not
have.
Deliberately not compared
| Excluded | Why |
|---|---|
| include paths outside the project | C:/SDK/include against /opt/sdk/include says where somebody installed an SDK |
| external libraries | the same dependency is ws2_32.lib on one side and -lz on the other |
Both fire on every target and drown the differences worth seeing.
Linker Map
LINKER MAP democore.map gnu-ld · 21.8 KB
├── memory regions
│ ├── FLASH 3.6 KB / 512.0 KB 0.71%
│ └── RAM 18.0 KB / 128.0 KB 14.1%
├── by object 21.8 KB total
│ ├── store_reader.o (libdemocore.a) 17.5 KB 80.2%
│ │ ├── .bss 16.0 KB 91.5%
│ │ └── .text 1.5 KB 8.4%
│ └── app.o 2.4 KB 10.8%
├── by section
├── largest symbols
└── why archive members were pulled in
└── math_utils.o ← app.o (math_project)
Supported formats
| How to produce one | Status | |
|---|---|---|
| GNU ld | -Wl,-Map=out.map |
verified against real arm-none-eabi-ld output |
| Apple ld64 | -Wl,-map,out.map |
verified against real Xcode linker output |
| LLVM lld | --Map= |
unsupported |
lld is left out on purpose. Guessing at a format without a real sample means quietly showing wrong numbers. It goes in when there is a sample to check against.
The format is detected from the file's content. Anything that is not a map file is rejected rather than misread.
What the GNU ld parsing had to get right
- wrapped section names — GNU ld pushes the address and size onto the next
line when the name is too long.
.text.engine_loadand everything like it qualifies, and missing this drops a large part of a real embedded map - archive members —
libfoo.a(bar.o)split into archive and object - the
Archive member includedtable — why each archive member was pulled in, and by which object referencing which symbol. The linker-level answer to the same question the Targets view's "Why Is This Linked?" asks Discarded input sections— what--gc-sectionscut. Kept out of the image total but shown separately- memory regions — the
Memory Configurationtable, turned into FLASH/RAM usage. Placement follows the address, so a.datawhose LMA alone is in FLASH viaAT>counts against RAM - symbols against linker script assignments — a line like
. = ALIGN(4)is not a symbol
C++ symbol demangling
Symbols in a map file are mangled, which makes the symbol list unreadable on
exactly the C++ projects where it matters most. They go through c++filt in one
pass.
__ZNSt3__111__introsortINS_17_ClassicAlgPolicyERNS_6__lessIvEE...
↓
std::__1::__introsort<std::__1::_ClassicAlgPolicy, ...>(...)
Point demanglerCommand elsewhere for a different toolchain
(arm-none-eabi-c++filt and so on).
With no c++filt around, a built-in demangler takes over. Windows usually has
none (Git for Windows does not bundle one either) while the maps come off a Linux
build, so there it is the normal path rather than a fallback.
The built-in one handles only the shapes worth reading: namespaced functions, member functions, constructors and destructors, operators, builtin types with pointers and references, and back-references. Templates and ABI tags are declined on purpose and keep their mangled name.
__ZN11log_wrapper3LogEPKcS1_ → log_wrapper::Log(char const*, char const*)
__ZN4TileaSERKS_ → Tile::operator=(Tile const&)
__ZNKSt3__110unique_ptrINS_... → (left alone)
Declining costs nothing, which the checked-in maps show if you count: of 503 mangled symbols, 489 are libc++ internals whose demangled form is a 200-character template nobody reads. The 14 that get read are all inside the subset above. Better to leave the rest mangled than to guess and quietly print a wrong name — the same reason lld's map format is not in yet.
Diff
Compare Two Map Files puts two builds side by side: what grew and shrank per object and per section, sorted by how much it moved. Unchanged rows are left out.
DIFF
├── total 44.1 KB → 13.5 KB -30.5 KB
├── by object
└── by section
├── __text -28.4 KB 40.2 KB → 11.8 KB
└── __got -288 B 648 B → 360 B
Settings
| Key | Default | Description |
|---|---|---|
cmakeLinkExplorer.buildDirectory |
"" |
Build directory. Empty means auto-detect |
cmakeLinkExplorer.configuration |
"" |
Which configuration to show in a multi-config generator (Debug/Release) |
cmakeLinkExplorer.showUtilityTargets |
false |
Show UTILITY targets |
cmakeLinkExplorer.showExternalLibraries |
true |
Show external libraries |
cmakeLinkExplorer.showTransitiveDependencies |
false |
Show the full closure instead of reducing it |
cmakeLinkExplorer.sortTargets |
structure |
structure = executables first, then by how many depend on it / size = by contribution to the image (needs a map) / name = alphabetical |
cmakeLinkExplorer.demangleSymbols |
true |
Demangle C++ symbols |
cmakeLinkExplorer.demanglerCommand |
c++filt |
Which demangler to use |
cmakeLinkExplorer.mapSymbolLimit |
200 |
Most symbols to show |
Tests
Build the generated inputs first (test/fixture/ and
test/sample-project/build/ are not committed):
./test/bootstrap.sh
Windows has no sh, so call what it does directly. The synthetic fixture needs
only Python; the real build tree part only means anything with cmake installed.
python test\make-fixture.py
python, not python3. On Windows python3 usually reaches a Microsoft Store
stub rather than an interpreter.
Then:
node test/run.js synthetic File API fixture
node test/run.js $PWD/test/sample-project/build a real CMake build tree
node test/run.js /path/to/any/build any CMake project
node test/tree-test.js target tree rendering
node test/map-test.js map parser + map tree
node test/map-test.js /path/to/x.map take one map file apart
node test/include-test.js include -> link resolution + CMakeLists editing
Inside a real VS Code extension host (activation, command registration, the tree, editor jumps, the map view):
macOS
CMAKE_LINK_TEST_LOG=/tmp/it.log \
"/Applications/Visual Studio Code.app/Contents/MacOS/Code" \
--user-data-dir=/tmp/clx-ud --extensions-dir=/tmp/clx-ext \
--extensionDevelopmentPath="$PWD" \
--extensionTestsPath="$PWD/test/integration" \
--disable-extensions "$PWD/test/sample-project"
cat /tmp/it.log
Windows (PowerShell)
$env:CMAKE_LINK_TEST_LOG = "$env:TEMP\it.log"
& "$env:LOCALAPPDATA\Programs\Microsoft VS Code\Code.exe" `
--user-data-dir="$env:TEMP\clx-ud" --extensions-dir="$env:TEMP\clx-ext" `
--extensionDevelopmentPath="$PWD" `
--extensionTestsPath="$PWD\test\integration" `
--disable-extensions "$PWD\test\sample-project"
Get-Content "$env:TEMP\it.log"
The extension host does not hand logs back over stdout, so CMAKE_LINK_TEST_LOG
catches them.
Giving it a separate profile through --user-data-dir and --extensions-dir
matters. Without them, an already-running VS Code makes it refuse with
Running extension tests from the command line is currently only supported if no other instance of Code is running. With them you keep working in your usual
window.
The map files in test/maps/ are real linker output and are checked in, so the
tests run with no toolchain installed. Regenerate them with
test/mapgen/generate.sh (the GNU ld half needs
brew install arm-none-eabi-binutils).
What has been verified
| Against | |
|---|---|
| synthetic File API fixture + backtraces + cycles/unused + tree comparison + staleness | 61 checks |
test/sample-project (real CMake 4.4) |
18 checks |
| googletest / abseil-cpp (121 targets) | 8 checks |
| target tree rendering | 18 checks |
| map parser + map tree + target join + demangler | 64 checks |
| include → link resolution + CMakeLists editing + compile settings | 56 checks |
| VS Code extension host (1.136, macOS + Windows) | 41 checks |
Performance
Measured on a large project (2,000 targets, 812,728 dependency edges as CMake reported them) with an 8,000 line CMakeLists.txt.
| Before | Now | |
|---|---|---|
loadModel (including transitive reduction) |
2,161 ms | 265 ms |
findCommand (a target late in the file) |
1,794 ms | 3 ms |
| demangling 200,000 symbols | 1,014 ms | ~13 ms |
Include resolution also went from walking directories per target to indexing the source tree once. At 2,000 targets, 50 lookups of a header that is not there went from 174 ms to 3 ms.
The main changes:
- transitive reduction — instead of walking the graph per dependency pair, word-wise OR over bitmaps of target indexes. The set CMake hands over is already a closure, so no graph traversal is needed at all
- CMakeLists parsing — "is this position inside a string" used to count from the start of the file every time; now it is an index of quote positions and a binary search. O(n²) → O(n log n)
- demangling — only the top symbols actually on screen, not the whole map.
c++filtis a synchronous call, and handing it everything freezes the extension host for about a second - header lookup — the source tree is indexed once and cached instead of walking directories per target. Rebuilt only on a miss, and only when the index is stale (for the write-a-header-then-include-it flow)
- sorting —
localeComparebuilds a collator on every call. One sharedIntl.Collatortook sorting 2,000 targets from 18 ms to 5 ms - tooltips — built on hover through
resolveTreeItemrather than eagerly for every row
Correctness fixes
Caught while measuring.
- path case — Windows and the default macOS volume are case-insensitive, and VS Code can hand back a different casing than CMake recorded (a drive letter alone is enough). Compared literally, it fails to find which target a file belongs to and the feature dies entirely. On case-insensitive filesystems the comparison ignores case
- symbol attribution — symbols a linker script defines at an output section
boundary (
_bss_startand the like) were attaching to an unrelated object in the preceding section. Reset per output section now, so those honestly report that they belong to nothing - map size — the file was read whole with no size check. Past V8's string
limit a
RangeErrorsurfaces from deep inside the runtime. Over 256 MB it is refused with a reason - neighbour list cache key — the cache left settings out of its key, so it
returned stale results unless
refresh()had been called first
Ahead
- LLVM lld map format (when a real sample turns up)
- MSVC
link.exe /MAPformat (likewise, when a real sample turns up) - graph view (webview with draggable nodes)
Licence and trademarks
MIT. See LICENSE.
Not affiliated with, endorsed by, or sponsored by Kitware, Inc. CMake is a trademark of Kitware, Inc., used here only to say what this extension works with. Visual Studio Code is a product of Microsoft Corporation.



