clang unused dim
Dims (fades) unused C/C++ symbols directly in the editor. Detection is pure text analysis over the
open document plus a workspace-wide token index — no clangd, no compiler, no language server, no
compile_commands.json. That is the whole point: it works on vendor embedded trees (Renesas
CC-RL / e2 studio / CS+, IAR, Keil) where a language server cannot realistically be configured.
The governing rule is asymmetric: dimming a symbol that is actually in use is the failure that
matters. A missed detection costs nothing. Wherever the evidence is incomplete, the extension goes
silent instead of guessing — so it under-reports by design, and the "What it cannot see"
section below is not a disclaimer, it is part of the specification.
What it can detect
- Unused local variables: a declaration inside a function body whose identifier appears exactly
once within that body.
- Unused parameters: a named parameter of a function definition that never appears in that
function's body.
- Unused
static functions: a static definition whose name appears nowhere else in the file.
static at file scope means file-local in C, so a single file holds every possible caller and the
verdict needs no cross-file evidence. Never emitted in a header.
- Unused file-scope
static variables: same reasoning, same single-file evidence, .c only.
- Unused non-static functions: reported only when the workspace index proves that no file in the
workspace calls the function. Without a ready index nothing is reported.
- Unused function prototypes (
void foo(void);): reported only on the same index proof, in
headers as well as .c files. A prototype is never judged from its own file — declaring a name for
other translation units is what a prototype is for, so "not used here" is true of every prototype
ever written and proves nothing. static prototypes are never dimmed.
- Unused
extern variables: in a .c file, an extern int g; that its own file never mentions
again is dimmed on its own — the declaration is pointless whatever other files do. In a header
that rule does not apply (declaring names for other files is what a header is for), so a header's
extern needs index proof. A file-scope definition (int g = 0;) needs index proof in either kind
of file.
- Unused
#includes: off by default, standard headers only, and advisory — see below.
Workspace index
Cross-file verdicts require a workspace-wide usage index. It is built once when the extension
activates and kept current from file-system events. Nothing is written to disk or to VS Code's
storage: a restored index would not reflect edits made while the extension was not running, and
dimming live code on stale evidence is exactly the failure this design avoids.
Which files participate, and how
Two tiers. Only the first is parsed as C.
| Tier |
Extensions |
What it contributes |
| Translation unit — decoded, masked, parsed as C |
.c .cpp .cxx .cc, and the headers .h .hpp .hh .hxx .h++ .inl .ipp .tcc |
declarations, uses, #include edges, and the ## / asm refusal markers |
| Uses-only — raw byte sweep, never parsed as C |
assembler .s .S .asm .s90 .s43 .s87 .a51; linker scripts .ld .icf .lds .ldi .sct .xcl .sub; include material .inc .src .tpp .def |
identifier occurrences only |
The uses-only tier exists because on an embedded tree the only reference to an interrupt handler is
often DW _Timer_IRQHandler in a hand-written startup.s, ENTRY(Reset_Handler) in a linker script,
or -entry=Timer_IRQHandler in a CC-RL .sub subcommand file. Those files are not C — parsing an IAR
.icf or an IAR/Keil ;-commented assembler listing as C actively deleted the handler name from the
use set — so they are swept for identifier runs and nothing else. Leading underscores are recorded both
stripped and intact, so CC-RL's _INTP0_handler counts as a use of C's INTP0_handler.
.cmd is deliberately not enumerated. On Windows it is overwhelmingly a batch script, and the byte
sweep reads the session-sticky refusal markers too: an ordinary build.cmd running
asrl -cpu=S2 startup.asm latches the inline-assembly marker, a echo ##### building ##### banner
latches the token-paste marker, and a UTF-16-saved one latches the skipped-file marker. The measured
cost was every cross-file verdict in every file, so it was tried and reverted. If you have a genuine TI
linker command file, its references are invisible to the index.
Files are read through VS Code's file-system API (so remote and virtual workspaces work) and decoded
with the workspace's configured encoding — Shift-JIS CC-RL sources decode correctly rather than becoming
mojibake. Reading happens in batches of 32 with the event loop released in between, and progress is shown
in the status bar. Directories .git, node_modules, dist, out, build, target are skipped.
Size handling: a translation unit over 500 KB is not parsed but still swept for uses, so a 4 MB vendor
iodefine.h contributes its identifiers instead of poisoning the whole session. Over 16 MB it is not
read at all. A uses-only file over 4 MB contributes nothing.
The 3000-file cap. If the scan glob matches more than 3000 files, no scan runs and the index goes to
disabled: single-file checks continue, every cross-file check stays silent. disabled lasts for the
rest of the window's session — reload the window to recover.
When verdicts are suppressed
Cross-file dimming appears only while the index is ready, and the previous verdict is dropped, not
kept, in every other state: before the first scan finishes, during any scan, while the index is stale
(more than 50 file events within two seconds — a git checkout, a code-generation step), when the
workspace is disabled, and when no folder is open. Edits made while the index is building are held and
applied afterwards rather than lost.
It is also withheld while any open C/C++ file has unsaved changes, and until the index has actually
re-read each file you save. The index reflects what is on disk; a call you have just typed elsewhere is
not in it yet. Single-file checks keep working while you type.
Even when ready, the index answers "do not dim" whenever it cannot prove absence of use: ## token
pasting seen anywhere in the workspace (a paste can synthesize any name), inline assembly seen anywhere
(its text is masked away), any file that could not be read, the current file not scanned, the name used
in the current file, or the name occurring only as a substring of another token (FOO via FOO_ISR).
Comments and string literals are masked before analysis, so an identifier mentioned only in a comment
does not count as a use.
What it cannot see
Read this section before trusting a verdict on unfamiliar code.
Known false positive: a line-continued #define ending in a static declaration. The statement
splitter skips a preprocessor directive only to the end of its physical line, so a macro body carried
over a trailing backslash leaks into the statement that follows it:
#define MK \
static int hidden
int shared_flag; /* wrongly dimmed, even when other files use it */
shared_flag is read as a static definition and dimmed. This verdict does not consult the workspace
index, so nothing downstream can rescue it. If your headers use that idiom, turn off
clangUnusedDim.staticVariables until the next release, which fixes it.
The index proves absence of use only within the opened workspace. A header consumed from an SDK
installed elsewhere, from a sibling repository, from a build variant that is not checked out, from a
tree under the excluded directories, or from a file whose extension is not enumerated will look
unused. This is the largest exposure and it has no workaround other than opening the consuming tree
as part of the workspace. It is most visible on a header, where it can reach the whole public surface.
A name assembled by an assembler macro is not recovered. #define V(n) handler_##n inside a
.s90 leaves the byte sweep only the fragments, never the joined spelling, so handler_0 can dim.
A static spelled through an unconditional #define STATIC static alias no longer dims. The
alias is recognized, but a macro-derived static is allowed to refuse a verdict and never to
produce one — because #ifdef DEBUG / #define STATIC static / #else / #define STATIC / #endif is a
real house idiom, and in the release branch the variable has external linkage. Distinguishing the two
textually is possible and is deferred to a later release; until then this is a lost true positive.
When the index is degraded, the tool goes silent rather than guessing. An unreadable or locked
file, a UTF-16 file, a file whose bytes do not match the configured encoding, or a ## token paste it
cannot follow all mark the workspace evidence incomplete for the session, and every cross-file kind
stops reporting until the index is rebuilt. This looks like "the feature stopped working"; it is the
feature working.
A mis-decoded document gets no dimming at all. If files.encoding is wrong for the file you are
editing, its text is not evidence, so nothing in it is dimmed — including the single-file kinds.
The preprocessor is not evaluated. Every #if branch is treated as live code, so a name used only
in a disabled branch still counts as used. When a conditional branch opens or closes a { } without
balancing it, function boundaries cannot be trusted and locals, parameters and functions are not
analyzed anywhere in that file.
A generic concatenator disables local dimming in that file. #define CAT(a,b) a##b can synthesize
any name, so no local or parameter is judged there. A bounded paste is narrower: state_##s
suppresses only names starting state_, and an unrelated tmp in the same file still dims. The same
refusal applies through the include graph — a paste macro defined in a header you include reaches you.
Runtime-owned names are never reported: main / WinMain / wmain / _tmain, and the newlib /
CC-RX low-level I/O surface (_write, _read, _sbrk, _fstat, init_iolib, close_all and their
undecorated twins). Their only caller lives outside every workspace, so "nobody calls it" is missing
evidence rather than evidence of disuse. A genuinely dead hand-written read() of your own is
therefore a permanent false negative.
Never analyzed at all (nothing in them is ever dimmed): K&R-style definitions; anything carrying
__attribute__; function-pointer parameters and C++ default-valued parameters; function headers
containing =; macro-generated definitions; struct / union / enum bodies and array initializers;
#include directives inside a comment; functions in a header (static inline helpers were the
motivating case — their locals and parameters are still analyzed); any function declared inline;
functions in a file containing an asm / __asm__ block; and a declaration containing @
(sfr unsigned char P0 @ 0xFFF00;).
Analyzed but imprecise: only whole function bodies count as scopes, so a shadowed variable, the
second declarator of int x, y;, and for (int i = ...) are not tracked; and a name is "used"
whenever the identifier reappears, so a same-named field, macro or local suppresses the report.
About the #include hint
This is a hint, not a verdict, and it ships turned off. Whether a header is required cannot be decided
from one file's text: it may supply a macro, a type reached through a typedef in another header, a
transitive dependency, or a pure side effect, and none of those leave a textual trace. Confirm with a
build before deleting an include.
To keep the guess narrow, a header is considered only when it is a recognized standard header whose
symbol list is known — stdio.h, stdlib.h, string.h, math.h, stdbool.h, stdint.h, assert.h,
time.h, ctype.h, errno.h. Everything else is left alone, always: every quoted "project.h", every
unrecognized system header, the file's own header (foo.c → "foo.h"), anything inside a #if /
#ifdef block, and any #include whose trailing comment mentions keep or used.
Settings
| Setting |
Default |
Turning it off |
clangUnusedDim.enabled |
true |
Stops all dimming without uninstalling. |
clangUnusedDim.opacity |
0.45 |
Not a toggle — 0 makes dimmed identifiers invisible, 1 leaves them at normal brightness. |
clangUnusedDim.localVariables |
true |
Stops dimming locals declared but never used in their function body. |
clangUnusedDim.parameters |
true |
Stops dimming parameters that never appear in the body. |
clangUnusedDim.staticFunctions |
true |
Stops dimming static functions unreferenced in their own file. |
clangUnusedDim.staticVariables |
true |
Stops dimming file-scope static variables unreferenced in their own file. |
clangUnusedDim.includeNonStaticFunctions |
true |
Stops the workspace-proven "nothing calls this function" verdict. Turn off if your callers live outside the opened workspace. |
clangUnusedDim.functionDeclarations |
true |
Stops the workspace-proven "nothing calls this prototype" verdict — the same horizon caveat applies, and it is most visible in shared headers. |
clangUnusedDim.externVariables |
true |
Stops dimming unused extern declarations and workspace-proven-unused global definitions. |
clangUnusedDim.includes |
false |
Off by default. Turn it on for the advisory standard-header #include hint above. |
Commands
Toggle Unused Dim (clangUnusedDim.toggle, ctrl+alt+u)
Requirements
VS Code 1.100.0 or later. The floor comes from vscode.workspace.decode, which is what resolves
files.encoding (including per-folder and per-language overrides and BOMs) so Shift-JIS sources are read
correctly. Nothing else is required — no compiler, no toolchain, no index configuration.
Virtual workspaces are supported in limited form. No language-server, compile_commands.json or build
integration exists, and none is planned: this extension is text analysis, and its accuracy ceiling is
the ceiling of text analysis.
License
MIT.