Elm Focus
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 → B out-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
the import's module name or as alias to the target module's exported
binding.
- Unqualified imported names resolve through the import's
exposing list —
including exposing (..), the former resolution hole — against what the
target module actually exports (its own exposing (..), 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 install
npm run build (bundles src/extension.ts → dist/extension.js with esbuild)
- Press F5 in VS Code to launch an Extension Development Host, open an
.elm file, 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 : Int in Model) is a node identified by (owning type,
field name), not by name alone — so selecting step lights only the uses that
touch that record type's step (a model.step read, 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.map is
qualified; a bare name brought in by import 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
case
branches and let bindings that contain no lit token (this is what isolates
one Msg branch inside an otherwise-lit update); by fading individual result
branches of an if/else (the then-expressions and the final else) that
contain no lit token, since they are mutually-exclusive alternatives exactly
like case branches — 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 sibling button [ onClick Decrement ] inside a lit view).
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 lit button [ 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 .elm file in the workspace, outside elm-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
module name 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
disabledForeground theme 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
case branches, if/else
result branches, let bindings, and list/tuple/record members fade, but other
sub-expressions do not — in particular the arguments of a function application
and an if condition 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.