Skip to content
| Marketplace
Sign in
Visual Studio Code>Visualization>Elm FocusNew to Visual Studio Code? Get it now.
Elm Focus

Elm Focus

Charbel

|
1 install
| (0) | Free
Fades Elm code that neither influences nor is influenced by the current selection, by slicing the module's scope-resolved dependency graph.
Installation
Launch VS Code Quick Open (Ctrl+P), paste the following command, and press enter.
Copied to clipboard
More Info

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)

  1. npm install
  2. npm run build (bundles src/extension.ts → dist/extension.js with esbuild)
  3. 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.

  • Contact us
  • Jobs
  • Privacy
  • Manage cookies
  • Terms of use
  • Trademarks
© 2026 Microsoft