Skip to content
| Marketplace
Sign in
Visual Studio Code>Programming Languages>Preprocessor Indent ViewNew to Visual Studio Code? Get it now.
Preprocessor Indent View

Preprocessor Indent View

Hazem Al Indari

|
2 installs
| (0) | Free
See #if / #else / #endif blocks indented to the surrounding code, without changing the file on disk.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

Preprocessor Indent View

by Hazem Al Indari

Renders #if / #elif / #else / #endif blocks indented to the surrounding code, with their bodies one level deeper — without changing the file on disk.

What is committed — the conventional layout, directives at column 0:

void update(void) {
#if (CONFIG_TRACE)
    emit_trace();
#else
#if (CONFIG_COUNTERS)
    bump_counter();
#endif
#endif
    finish();
}

What you see — nothing on disk changed:

void update(void) {
    #if (CONFIG_TRACE)
        emit_trace();
    #else
        #if (CONFIG_COUNTERS)
            bump_counter();
        #endif
    #endif
    finish();
}

The repository keeps the conventional layout every formatter and every other contributor expects; the indented reading is local to your editor.

Hiding inactive code

Turn on ppIndentView.hideInactive and branches the preprocessor would throw away are folded out of the way, with the #if line left standing over them at reduced opacity:

void update(void) {
    #if (CONFIG_TRACE)  ⋯  6 lines dropped
    #else
        bump_counter();
    #endif
    finish();
}

The 6 lines dropped label is how you tell a dead branch from an ordinary brace fold, since VS Code gives both the same background band and that band cannot be scoped to one extension's folds — see Known limits. Turn it off with ppIndentView.foldHeaderLabel.

Three fade levels are in play, because the fold header is not the same thing as what it hides:

Level Default
Dead code a fold has taken dimOpacity 0.05 — all but invisible, and seen only if you unfold by hand
The #if/#else it collapsed onto foldHeaderOpacity 0.3 — the one dead line still on screen, so it stays readable
#if/#endif of a branch that does compile directiveOpacity 0.45 — recedes, but you can still see a conditional is there

Strictly increasing, deliberately: dead code must never come out brighter than scaffolding around code that really compiles. The header sits between the two because VS Code paints a band across every collapsed fold header — see Known limits — and a band with a ghost inside it reads as a stray selection rather than as folded code.

Where nothing in a group survives, the #endif goes too and the whole group collapses onto one line. Toggle it from the second status bar button, the command palette (Preprocessor Indent View: Toggle Hiding Inactive Code) or Ctrl+Alt+4.

Folded, not deleted — VS Code has no way to give a line zero height, so this is folding, applied for you.

By default the folds are sticky: expand one and it goes straight back, immediately — the re-fold is issued on the event itself, with no debounce, so there is no window in which the code is readable. That covers the chevron, the fold commands, and the incidental ways VS Code opens a region for you — moving the cursor into it, Go to Definition, a find result. Inactive code is then reachable only by turning hiding off (the status bar button, or Ctrl+Alt+4), which is the point: "hidden" should not mean "hidden until you click the arrow".

A notification says so when it happens, rather than leaving the editor looking broken, and carries a Show inactive code button that turns hiding off — the escape hatch, where you need it. It is rate limited to one every few seconds, since a cursor wandering through inactive code triggers the re-fold repeatedly. Silence it with ppIndentView.notifyOnRefold.

Set ppIndentView.keepFolded to false to be able to peek into a branch and have it stay open; then Preprocessor Indent View: Re-fold Inactive Code puts things back when you are done.

Telling it what is defined

Most of the time you do not have to. The file's own #define / #undef are tracked, and the headers it includes are read to find the rest, so a project whose flags live in a config header needs no macro list at all — see Following includes.

What settings are for is the macros that arrive from outside any header, i.e. your build's -D flags:

"ppIndentView.defines": [
    "CONFIG_TRACE=0",       // defined, with a value
    "NDEBUG",               // defined as 1
    "!CONFIG_LEGACY"        // definitely NOT defined
]

These are seeded before the file starts, exactly as -D would be — so #ifndef X / #define X 1 / #endif correctly leaves your value alone rather than overwriting it. !NAME is what makes #ifdef NAME provably dead, so list the macros you know are off, not only the ones that are on.

The evaluator handles the whole C constant-expression grammar — defined(), __has_include(), integer/char literals in every base, arithmetic, comparison, bitwise, shifts, &&/||/!, ?:, nested parentheses, macros that expand to other macros, and \-continued conditions.

Following includes

#include "quoted.h" is followed; #include <angled.h> never is. That is C's own line between your code and the toolchain's, it needs no build system to interpret, and it stops dead at the SDK and libc — which is the point, since those are enormous and irrelevant to the conditionals you are reading.

ppIndentView.includePaths defaults to ["include", "inc", "src", "."], so the common layouts need no configuration — the including file's own directory is tried first, then each root in order, and a directory that does not exist simply never matches. Override it for anything unusual:

"ppIndentView.includePaths":   ["Core/Inc", "Drivers"],
"ppIndentView.forcedIncludes": ["DEBUG_FLAGS.h"]     // the compiler's -include

forcedIncludes has no default, because it cannot have a sensible one — it mirrors a -include flag that only your build knows about. Set it when a build prepends a config header, since files relying on that never mention it and their flags are otherwise unresolvable. Note that a header reached transitively through the file's own quoted includes needs no help here.

__has_include("...") is answered from the filesystem, so the widespread "optional local overrides" pattern resolves properly:

#if defined(__has_include)
#if __has_include("DEBUG_FLAGS.local.h")
#include "DEBUG_FLAGS.local.h"      // followed if it is really there
#endif
#endif
#ifndef DEBUG_ROTATE_SCREEN
#define DEBUG_ROTATE_SCREEN 1       // ... and skipped if the override won
#endif

Headers are read once and cached against their modification time, and an unsaved buffer beats the copy on disk — so flipping a flag updates every file that reads it without saving first.

Two fade levels

With the dead branches folded away, what is left on screen is scaffolding, and it comes in two kinds that deserve different treatment:

dimOpacity 0.05 the #if / #else still standing over a folded dead branch — pure residue, so it all but vanishes
directiveOpacity 0.45 the #if / #elif / #else / #endif of a branch that really compiles — recedes, but stays readable
void update(void) {
    #if CONFIG_TRACE          <- 0.45, scaffolding around live code
        emit_trace();         <- full brightness, this compiles
    #else                 ⋯   <- 0.05, dead branch folded behind it
    #endif                    <- 0.45
    finish();
}

#include, #define and #pragma are never faded — they are code, not scaffolding. The gutter chevron is not faded either, so a collapsed region stays findable however faint its header becomes. Set directiveOpacity to 1 to leave live directives at full brightness.

Presence versus value

Following includes raises a question the settings-only version never faced: is a macro that nothing defines actually undefined? Saying yes is what makes #ifndef defaults and plain include guards resolve at all, so ppIndentView.assumeUndefined decides when to:

always (default) regardless of unresolved includes. Real projects quote headers that live in the toolchain or a package tree, so the closure is essentially never complete — and whenIncludesResolve then yields exactly what never does, which makes a poor default
whenIncludesResolve only if every #include "..." was found. Stricter, and worth it when includePaths really does cover the project: a wrong path then folds nothing rather than folding the wrong thing
never fold only what headers prove outright

It is confined to presence. The value of a macro that was never seen stays unknown:

#ifndef X / #ifdef X / defined(X) answerable — X is taken to be undefined
#if X not answerable — stays visible, rather than folding as C's 0 would

This holds in every mode, always included — so #ifdef SOMETHING_FROM_YOUR_BUILD can be wrong, while #if SOMETHING_FROM_YOUR_BUILD cannot. List your -D macros in ppIndentView.defines and both are right. The status bar tooltip always says which mode applied and what went unresolved.

Three states, not two

A branch is folded only when it provably cannot compile. Anything the evaluator cannot settle is unresolved and stays completely visible — and the status bar says how many there are, so the view never quietly looks more complete than it is.

The button carries an open eye while hiding is off and a closed one while it is on:

inactive: shown hiding is off — click to fold
inactive: 142 lines hidden everything resolved, dead branches folded
inactive: 142 lines hidden, 3 unresolved 3 branches depend on macros nothing could pin down
inactive: none hidden, 3 unresolved nothing resolved — check includePaths in the tooltip

Hover the button for what was and was not resolved: how many includes were followed, which could not be found, and whether the presence assumption was in force.

The asymmetry is deliberate. Folding too little is a cosmetic disappointment; folding live code away is a trap.

Short-circuiting stretches a partial list a long way — #if defined(A) && defined(B) is dead as soon as !A is known, whatever B turns out to be.

How it works

The transform is purely additive — from canonical form a directive moves 0 → ambient + 4×depth and a body line moves ambient → ambient + 4×depth. Nothing ever moves left. That is the only direction VS Code's decoration API can express, which is what makes this possible at all: each line gets a before decoration containing N non-breaking spaces.

ppindent.js holds the pure layout logic and has no vscode dependency, so it is unit-testable. ppdead.js does the same for conditional evaluation: a file plus a macro table in, a per-line active / dead / unknown verdict and a set of fold ranges out, with no editor involved. It walks the file once with the table mutating as it goes — #ifndef X / #define X means nothing unless a macro's state is read at the point the condition sits — and calls back out for anything needing I/O. ppinclude.js provides that half: it decides what to read and in what order, but takes readFile as an argument, so it too is tested against a virtual tree rather than fixtures on disk.

Requirements

Source files must be in canonical form — all conditional directives at column 0. A directive that is already indented deeper than its target cannot be corrected, because decorations can only add. Running clang-format with the default IndentPPDirectives: None guarantees this.

Install

Search for Preprocessor Indent View in the Extensions view, or install a .vsix from the releases page with Extensions: Install from VSIX….

There is no build step and no dependencies — it is plain JavaScript.

Running from a clone

Point VS Code at the clone and it loads the extension into a second window, where you can open any folder you like to try it on:

code --extensionDevelopmentPath="<path-to-clone>" "<folder-to-test-on>"

The code is read straight from the working tree, so an edit needs only a reload of that window — nothing to package, nothing to install.

Opening the clone in VS Code and pressing F5 does the same thing — .vscode/launch.json provides the Run Extension configuration.

Do not link a clone into ~/.vscode/extensions with mklink /J or a symlink. Earlier versions of these instructions said to, and it no longer works: current VS Code (verified on 1.135) treats extensions.json in that directory as the authoritative list of what is installed and does not scan for stray folders. A linked clone is silently ignored — it never appears under Installed, and reloading does not help.

Building a .vsix locally

To test the packaged form, or to install into your normal window rather than a second one:

python media/build_vsix.py
code --install-extension preprocessor-indent-view-<version>.vsix --force

media/build_vsix.py writes the .vsix directly from package.json and .vscodeignore, so it needs no Node toolchain — only Python. Releases are still packaged by the real vsce in CI, so anything it gets subtly wrong shows up there. Remember that a packaged install has to be rebuilt and reinstalled after every edit; --extensionDevelopmentPath above is the shorter loop.

Turning it on and off

The indent overlay ships off and hiding inactive code ships on, so a fresh install starts by hiding rather than indenting. A button appears at the right-hand end of the status bar whenever a C/C++ file is open:

⌸ #if indent: on showing the indented view — click to see the file as stored
⊘ #if indent: off showing the raw file — click to indent

Same thing from the command palette (Preprocessor Indent View: Toggle) or Ctrl+Alt+3.

A second button beside it controls hiding inactive code, and appears only while the file actually has conditionals in it.

Two more commands, both palette-only:

Command
Re-fold Inactive Code puts folds back after peeking, with keepFolded off
Toggle VS Code's Fold Highlight (Ctrl+Alt+5) turns the band VS Code paints across collapsed folds off, so dead branches read as folded rather than highlighted — see Troubleshooting

The button writes the ppIndentView.enabled setting rather than a session flag, so the choice survives a window reload and the button always shows the real state.

Turn it off before hand-editing indentation. The # line's position is derived from its body's indent, so the two always move together — with the overlay on you are not seeing the columns you are actually typing, and it is very easy to "correct" a file that was already right.

Settings

Setting Default Meaning
ppIndentView.enabled false Render the indented view
ppIndentView.indentSize 4 Spaces per level
ppIndentView.languages ["c","cpp"] Language IDs to apply to
ppIndentView.maxFileLines 20000 Skip larger files to keep redraws cheap
ppIndentView.showStatusBarItem true Show the status bar buttons
ppIndentView.hideInactive true Fold away branches that cannot compile
ppIndentView.keepFolded true Re-fold an inactive branch, immediately, if anything expands it
ppIndentView.notifyOnRefold true Say so when that happens, with a button to show the code
ppIndentView.defines [] Macros from outside any header, i.e. your -D flags — NAME, NAME=VAL, !NAME
ppIndentView.includePaths ["include","inc","src","."] Where to look when following #include "..."
ppIndentView.forcedIncludes [] Headers read first, the compiler's -include
ppIndentView.assumeUndefined always When an undefined-nowhere macro counts as undefined — also whenIncludesResolve / never
ppIndentView.dimInactive true Dim dead branches while hiding is on
ppIndentView.foldHeaderLabel true Label a dead branch's fold header with how many lines it dropped
ppIndentView.restoreUserFoldBackground true Paint the band back on folds this extension did not make
ppIndentView.userFoldBackground theme colour The colour of that repainted band
ppIndentView.dimOpacity 0.05 How faint dead code a fold has taken is — lower is fainter
ppIndentView.foldHeaderOpacity 0.3 How faint the fold header — the #if/#else a dead branch collapses onto — is
ppIndentView.directiveOpacity 0.45 How faint live #if/#endif scaffolding is — 1 to leave it alone

Out of the box, hiding is on and the indent overlay is off. Switching hiding on also switches the overlay off, every time — the two do not read well together, since a folded branch leaves its #if line as the fold header and shifting that right pushes the collapsed placeholder out of line with the code around it.

That is a one-shot nudge on the transition, not a lock: turn the overlay back on afterwards and it stays on for as long as hiding remains on. It goes off again the next time you switch hiding off and on.

Known limits

These follow from decorations being a rendering overlay, not a real remap of the text:

  • Indent guides are drawn at real columns, so they sit misaligned against shifted text. Turn them off ("editor.guides.indentation": false) if it bothers you.
  • Word wrap computes wrap points on the real text, so continuation lines are not shifted.
  • Box/column selection (alt+drag) uses real columns.
  • Diff and merge editors do not carry the decorations — code review shows canonical form. Arguably a feature.
  • The cursor sits at the real column; the status bar column number reflects the file, not the view.

And these apply to hiding inactive code:

  • It is folding. Each dead branch keeps one visible line. Folds are lost on window reload (VS Code restores its own folds, not ours) and reapplied when the file next becomes active — with keepFolded on, also the moment anything expands one.
  • Nothing is read from c_cpp_properties.json, compile_commands.json, or your build. Macros come from the file, the quoted headers it includes, and ppIndentView.defines — so build-system -D flags have to be listed by hand.
  • Angled includes are never followed, by design. Anything only an SDK header defines is unknown.
  • A header that depends on what its includer defined first is ambiguous — there may be one right answer per translation unit — so only what the header itself establishes is used.
  • Function-like macros are not expanded. #ifdef IS_ENABLED is answerable; #if IS_ENABLED(X) is not.
  • It contributes fold ranges only while hiding is on, and only for ppIndentView.languages. Those ranges sit alongside whatever your C/C++ language server provides; with no other folding provider installed for C/C++, brace folding is unavailable in a file while hiding is on.
  • "editor.foldingStrategy": "indentation" disables it, because VS Code then ignores every folding provider.
  • VS Code paints its own band across every collapsed fold header, so a folded #if line carries a highlight this extension did not put there, and it cannot be suppressed for one extension's folds — only for all folds, or none. A dead branch is therefore told apart from a function body you folded yourself by foldHeaderLabel and foldHeaderOpacity, not by its background. See A folded branch looks highlighted.

Troubleshooting

A folded branch looks highlighted, as if something were selected

Symptom. Every collapsed #if carries a coloured band across the full width of the line. It reads as a leftover selection, and turning dimOpacity down does not remove it — it makes it worse, because the text inside the band becomes a ghost and the band is then all you see.

It is not this extension. VS Code paints that band across the header line of every collapsed fold, whoever folded it. Ten-second check: fold an ordinary function body or a switch case somewhere with no preprocessor in sight. If it gets the same band, this is what you are looking at.

The colour is editor.foldBackground, which defaults to 30% of your selection colour — hence the "am I selecting something?" feeling.

The fix is one command. Run Preprocessor Indent View: Toggle VS Code's Fold Highlight from the palette, or Ctrl+Alt+5. It sets editor.foldBackground to a transparent colour — which turns the band off for every fold — and ppIndentView.restoreUserFoldBackground then paints it back on the folds this extension did not make. Dead branches end up with no band, the folds you made yourself keep one. Press it again to put the colour back exactly as it was.

The band it paints back is matched to the exact colour VS Code was using: the command reads editor.foldBackground out of VS Code's own Generate Color Theme From Current Settings dump before overriding it, so a repainted band and a native one are indistinguishable. Nothing to configure.

If you switch theme afterwards, press the command twice to re-capture — the matched colour is a literal, and once the native colour is overridden the theme's own value can no longer be read.

It writes your global settings, which is why it is a command you press rather than something that happens on its own, and why nothing undoes it behind you — not on deactivate, not on uninstall. The previous value is saved, so the second press restores it, or removes the key if there wasn't one.

To do it by hand instead, put this in settings.json and never press the command:

"workbench.colorCustomizations": {
    "editor.foldBackground": "#00000000"
}

restoreUserFoldBackground picks that up on its own, so the repaint still happens. Setting it to a visible colour instead ("#ffffff08") keeps the native band on every fold, dead branches included.

Two things that reliably catch people out:

  • editor.foldBackground is a theme colour, not a setting. Searching the Settings UI for foldBackground finds nothing at all. It exists only as a key inside workbench.colorCustomizations, which is JSON-only — open it with Preferences: Open User Settings (JSON). Once you are inside that object, typing " completes every available colour key.
  • Leaving the block commented out with // is valid JSONC, so VS Code parses the file happily and silently ignores the customization. If nothing changed after a reload, check that it is actually uncommented.

Why it takes turning the band off globally. It is one global colour with no per-range or per-provider hook, and workbench.colorCustomizations scopes by theme only — not by language, and not by who created the fold. The band is also drawn over extension decorations rather than under them (VS Code's own description of the colour says it "must not be opaque so as not to hide underlying decorations"), so it cannot be painted over either. Removing it everywhere and putting it back selectively is the only order of operations available.

On the extension writing your settings. It does so only when you run the toggle command, never on its own — that colour is global, and an extension has no natural claim on it. Because the write is your explicit choice it also persists: nothing restores it on deactivate or uninstall, since a choice that quietly undoes itself is worse than one that stays. The previous value is saved in extension state so the same command puts it back exactly as it was, including removing the key if there wasn't one, and other colorCustomizations keys are merged rather than replaced.

What is available is the layer above it: attachment text and inline styles render on top of the band. That is the layer foldHeaderLabel and foldHeaderOpacity work in, and it is why a dead branch reads as #if (CONFIG_TRACE) ⋯ 6 lines dropped while a folded function body stays unlabelled. Distinguished by what is written on the line, since the background is not available.

Folds stopped being marked at all, and the extension is gone

If you used Toggle VS Code's Fold Highlight and then uninstalled or disabled the extension, the editor.foldBackground override it wrote is still in your settings, and nothing is left to paint the band back on. Every fold, in every language, is then unmarked.

That is deliberate — the command changes a global colour only when you ask, and nothing undoes it behind you — but it does mean the tidy order is to press the command again before uninstalling. If it is already gone, remove the override by hand:

"workbench.colorCustomizations": {
    "editor.foldBackground": "#00000000"    // <- delete this line
}

A leftover "ppIndentView.userFoldBackground" in the same block is harmless but can go too.

Dead code looks dimmed twice over

The C/C++ extension dims inactive preprocessor regions itself: C_Cpp.dimInactiveRegions is on by default, at C_Cpp.inactiveRegionOpacity 0.55. It computes the same regions this extension does, so the two stack — two independent fades on one line, and the result is fainter than either setting says.

It is also redundant, since this extension folds the branch away as well as fading it. Turn it off:

"C_Cpp.dimInactiveRegions": false

microchip.mplab-clangd and other clangd-based extensions have the same feature under clangd.inactiveRegions.*, including a useBackgroundHighlight option that paints a solid background rather than fading — worth knowing about if faded lines have a block of colour behind them that does not track your folds.

Tests

No test framework and no dependencies — the suites are plain Node scripts:

npm test        # cases, invariants, dead, include, extension

Without Node installed, VS Code's own runtime works:

$env:ELECTRON_RUN_AS_NODE = 1
& "$env:LOCALAPPDATA\Programs\Microsoft VS Code\Code.exe" test/cases.test.js

cases.test.js is written out by hand on purpose. An earlier version of this work compared a generated "styled" fixture against a generated "canonical" one — both produced by the same code — which only proved the two were mutual inverses, not that either was right. It passed while the output was actually wrong. Case 3 is the regression that exposed it: a nested block whose body sits deeper than the enclosing block's shallowest line.

invariants.test.js checks properties of the output rather than a golden file, so it can fail independently of how the layout was computed:

  1. no line ever moves left (a negative shift is not renderable as a decoration);
  2. body lines at the same conditional depth all shift equally, preserving relative alignment;
  3. a block's directives render exactly one level above its body;
  4. the view is losslessly invertible back to canonical;
  5. the input really is canonical.

dead.test.js states the expected active / dead / unknown verdict for every line of every case, not just the folds. That is the point: the interesting failure mode is not folding too little, it is folding a branch that turns out to be live, so the suite also asserts outright that no fold range ever covers a line that is not dead.

include.test.js runs the include follower over a virtual filesystem, so the config-header shapes it has to cope with are declared in the test rather than committed as fixtures. It pins the safety rule as hard as the feature: a macro set inside a branch that could not be resolved must not be believed, and an include that could not be found must withdraw the presence assumption for the whole file.

extension.test.js drives the real activate() against a stubbed vscode. The stub deliberately mimics the awkward parts of the API — its decoration types are frozen and its dispose() really disposes — because both of those caught bugs that a more permissive stub had waved through.

License

GNU General Public License v3.0 or later — see LICENSE.

Copyright (C) 2026 Hazem Al Indari

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
  • Your Privacy Choices
  • Consumer Health Privacy
© 2026 Microsoft