A VS Code extension that fades the Elm code which neither influences nor is influenced by the current selection. It does this with program slicing over a scope-resolved dependency graph of the workspace, not with text matching.
When your cursor is on a binding or reference, everything outside its causal
cone recedes, so you see only what matters to the thing you're looking at. The
graph spans every .elm module in the workspace, so the cone crosses file
boundaries: selecting an exported symbol lights its users in other modules, and
unrelated modules dim — in the editor and in the Explorer tree.
What "influence" means here
Elm is pure and immutable, so dataflow reduces to name resolution: an edge
A → B means A's body references B, i.e. B influences A. From an anchor:
- backward slice — what the anchor depends on (follow
A → Bout-edges) - forward slice — what depends on the anchor (follow the reverse edges)
The lit set is the union of both cones from the anchor. Everything else fades. (The spec phrase "doesn't influence or isn't influenced by", read literally as a disjunction of negatives, would fade almost everything; the intended and implemented reading is "connected in neither direction".)
Rendering uses three tiers so that lit code stays visually coherent:
| tier | what | opacity |
|---|---|---|
| focus | the slice (anchor ∪ backward ∪ forward) | full |
| context | one-hop definitions referenced by focus code | contextOpacity (0.62) |
| faded | everything else | fadeOpacity (0.3) |
The use case it's built for
Selecting a Msg constructor lights both where the message is produced
(onClick Increment in view) and where it's handled (Increment -> in
update), because both are references to the same constructor node. Tracing a
message end-to-end is the thing this tool does that go-to-references does not.
This is validated in validate.js.
Across modules
On activation the extension indexes every .elm file in the workspace
(excluding elm-stuff, node_modules, and .git), parsing and resolving each
into a module-local graph. Those graphs are merged into one workspace graph in a
shared id space, and the references local resolution leaves dangling are then
bound across modules:
- Qualified names (
Types.Increment,Html.Events.onClick) resolve through theimport's module name orasalias to the target module's exported binding. - Unqualified imported names resolve through the import's
exposinglist — includingexposing (..), the former resolution hole — against what the target module actually exports (its ownexposing (..),Type(..), or named list, with constructor visibility honoured).
A symbol from a module whose source is not in the workspace — Browser,
Html, anything from a package — has no node to resolve to, so it stays an
inert external leaf exactly as before. Resolution degrades safely to "external"
rather than guessing.
Because the graph is workspace-wide, a slice spans files. The extension renders
fades in every visible editor (so split views of other modules light and
fade together), and dims the filenames of out-of-slice modules in the
Explorer via a FileDecorationProvider. A whole file with no lit content
fades entirely. A pinned slice (below) stays frozen across files, so you can
open a dimmed module and read it without the focus shifting.
The index tracks edits incrementally (open buffers reparse on each change;
files changed on disk are re-read via a watcher); the merged graph is rebuilt
behind the render debounce only when some module's text actually changed. The
cross-module scenarios are validated headlessly in validate.js.
Install
Once published, install Elm Focus from the VS Code Marketplace (search for
"Elm Focus"), or build a .vsix yourself:
npm install
npm run package # produces elm-focus-<version>.vsix
code --install-extension elm-focus-*.vsix
Build & run (from source)
npm installnpm run build(bundlessrc/extension.ts→dist/extension.jswith esbuild)- Press F5 in VS Code to launch an Extension Development Host, open an
.elmfile, and move the cursor.
The two WebAssembly grammars in wasm/ (tree-sitter.wasm and
tree-sitter-elm.wasm) are vendored in the repository, so a fresh clone needs no
extra download. Their origin and licenses are documented in
THIRD-PARTY-NOTICES.md; see
CONTRIBUTING.md if you need to refresh them.
Toggle with the command Elm Focus: Toggle Focus Mode.
Pinning a slice
By default the slice follows the cursor, so moving into the faded region to read it immediately shifts the focus. Elm Focus: Pin / Unpin Current Slice freezes the current slice on whatever is under the cursor; you can then navigate anywhere — including into the dimmed code — without the focus moving. Run it again to unpin and resume following the cursor.
The pin is stored as the anchor symbol's identity (name + namespace + kind), not
a node id, so it survives reparses: it stays attached to the same symbol as you
edit, and is re-resolved against each rebuilt graph. It clears when the pinned
document is closed. (No default keybinding is bound, to avoid conflicts — bind
elmFocus.togglePin in your keybindings if you use it often.)
Validate the analysis core
The analysis core (parser / resolver / slicer / workspace) never imports
vscode, so it runs headlessly:
npm run validate
It runs four scenarios. The first parses a Browser.sandbox counter with the
real grammar and asserts that slicing from Increment yields focus
{Increment, Msg, update, view, main}, that Decrement is excluded from focus,
and that the constructor's production and match sites both light up. The second
confirms lexical scoping and shadowing resolve correctly (an inner let x
captures references that a naive matcher would wrongly attribute to a top-level
x). The third merges five modules and asserts that a forward slice from a
constructor in one module lights its users in the others — reached through an
exposing list, through exposing (..), and through a qualified
Module.Ctor — that an unrelated module is dimmed, and that a type edge crosses
modules too. The fourth gives two record types a same-named step field and
asserts that slicing from Model.step lights its own uses across modules — a
model.step read, a { model | step } update, and a { count = 0, step = 1 }
literal matched by shape — while leaving the sibling Model.count and the
unrelated Settings.step dark: field scoping is by type, not by name.
Configuration
| setting | default | meaning |
|---|---|---|
elmFocus.enabled |
true |
fade on selection change |
elmFocus.sliceFrom |
definition |
on an identifier, slice from the binding it names (definition) or from the enclosing declaration (expression) |
elmFocus.maxDepth |
0 |
transitive distance limit (0 = unbounded); lower keeps focus tight on highly-connected modules |
elmFocus.fadeOpacity |
0.3 |
opacity of unrelated code |
elmFocus.contextOpacity |
0.62 |
opacity of one-hop context |
elmFocus.includeTypeEdges |
true |
treat type references in annotations/signatures as dependencies |
Design decisions
- Constructors and types are first-class graph nodes. This is what makes
message-tracing work and keeps highlighting coherent across
view/update. - Record fields are type-scoped graph nodes. Each field declared in a record
type alias (
step : IntinModel) is a node identified by (owning type, field name), not by name alone — so selectingsteplights only the uses that touch that record type'sstep(amodel.stepread, a{ model | step }update, a{ count = 0, step = 1 }literal), and a same-named field on an unrelated record stays dark. Field access is the one place Elm resolves by type, not lexical scope, so this needs just enough typing to name the record: a binder's type comes from its annotation (model : Model), a literal's from matching its exact field set to a unique alias. No inference beyond that — no unification, no typing through function-application results. When the type can't be determined the field stays inert (see Known limitations), never guessed by name. The stricter alternative — resolve fields only at annotated sites and drop literal matching — was considered and rejected: records are usually constructed where no annotation sits directly on the literal, so it would leave most{ ... }construction sites dark. Structural matching recovers them, and its one risk (two aliases sharing a field set) is bounded by binding only on a unique match — a tie stays inert rather than picking one. - Qualified and unresolved names are inert external leaves.
List.mapis qualified; a bare name brought in byimport X exposing (..)can't be resolved without the imported module's interface. Both are treated as external — safe, at a small cost in precision. - Resolution is two-pass. All bindings in a scope are registered before any reference is resolved, so mutual recursion and forward references work.
- Fade granularity is declaration → case-branch / if-else-branch / let-binding
→ collection member. A lit declaration is refined by fading individual
casebranches andletbindings that contain no lit token (this is what isolates oneMsgbranch inside an otherwise-litupdate); by fading individual result branches of anif/else(the then-expressions and the final else) that contain no lit token, since they are mutually-exclusive alternatives exactly likecasebranches — the conditions are shared selectors that run regardless, so they are never faded as a unit; and by fading individual members of a partially-lit list, tuple, or record literal that contain no lit token (this is what dims the siblingbutton [ onClick Decrement ]inside a litview). Members of a collection and the branches of a conditional are interchangeable peers, so dimming one in isolation reads naturally; the arguments of a function application are not faded individually, because a call's target and arguments form one semantic unit rather than a list of peers. Member-fading is gated on the collection holding lit content: a list with no lit token has no peer to isolate, so it is left whole rather than fragmented — this keeps the lone[ text "-" ]label of a litbutton [ onClick Decrement ] [ text "-" ]lit, since that list is itself an argument of the (coherent) call.
Known limitations
- Resolution reaches workspace modules only. Names imported from modules
whose source is not in the workspace —
Browser,Html, any package — remain inert external leaves, since there is no node to resolve them to. A module must be indexed (an.elmfile in the workspace, outsideelm-stuff/node_modules) to participate in a cross-module slice. - The cross-module index assumes unique module names. Two indexed files that
declare the same
modulename collide; the last one merged wins as the resolution target. Real Elm projects forbid duplicate module names, so this only bites on stray scratch copies. - Record-field linking is bounded by what types it can name without inference.
A field use lights its type's other uses only when the owning record type is
determinable: the base binder is annotated with a record alias (
model : Model), or a record literal's exact field set matches a unique workspace alias. Everything else stays inert (no false links) rather than guessed — including an unannotated base, a literal whose field set matches zero or several aliases, a record typed only through a package type, a field accessor passed as a function (List.map .step), a{ step }pattern destructure, and any access chained more than one hop from a typed identifier (a.b.c). Single-file (un-merged) graphs don't bind fields at all, since binding happens in the workspace merge — the same reason a standalone module can't resolve its imports. - The merged graph is rebuilt by re-merging all module graphs whenever any module's text changes (behind the render debounce). The merge is linear in the total graph size; on a very large workspace the per-change rebuild is the main cost. Per-file parsing stays incremental.
- Explorer dimming needs a theme colour. Out-of-slice files are tinted with
the
disabledForegroundtheme colour; how visible the dimming is depends on the active colour theme. VS Code has no API to set file-tree opacity. - Sub-declaration fading stops at branches, let-bindings, and collection
members. Within a lit declaration, unrelated
casebranches,if/elseresult branches,letbindings, and list/tuple/record members fade, but other sub-expressions do not — in particular the arguments of a function application and anifcondition are not faded individually (by design; see Design decisions). So a focus-irrelevant arg that is not itself a collection member stays lit. - The module declaration and imports never fade.
- Type-edge breadth. With
includeTypeEdges, selecting a type lights every signature that mentions it; that is intended, but it is broader than a value slice. Selecting a value is unaffected (type sharing doesn't bleed across values). - Editor integration is type-checked and the analysis core — now including cross-module merge and resolution — is validated headlessly, but the in-editor decoration behavior (text fades and Explorer dimming) is not exercised by an automated test here — it requires an Extension Development Host (F5).
Contributing
Contributions are welcome — see CONTRIBUTING.md for setup,
the project layout, and the checks to run (npm run typecheck,
npm run validate, npm run build) before opening a PR.
License
MIT © Charbel Rami. The bundled Tree-sitter grammars are
redistributed under their own MIT licenses; see
THIRD-PARTY-NOTICES.md.