A fast, full-featured Elixir LSP optimized for large Elixir codebases.
- Features
- Quick start
- Editor setup
- Why build another LSP?
- Performance
- CLI usage
- Hover documentation
- Rename
- Lightning-fast formatting
- LSP options
- Index database location (.dexter.db)
- Debugging
- Development (building from source)
- Releasing
- Contributing
- License
- Fast indexing — cold index completes in ~11s on a 57k-file Elixir monorepo, ~100ms on Oban, ~300ms on the Elixir standard library (measured on an M1 MacBook Pro). After your first index, incremental indexing makes sure that you never have to reindex the whole codebase again.
- Go-to-definition — jump to any module, function, type, or variable definition. Resolves aliases, imports,
defdelegatechains,useinjections, and the Elixir stdlib. Handles all definition forms:def,defp,defmacro,defprotocol,defimpl,defstruct, and more. - Go-to-references — find all usages of a function or module across the codebase, including through
import,usechains, anddefdelegate. - Hover documentation —
@doc,@moduledoc,@typedoc, and@specannotations rendered as Markdown when you hover over a symbol. - Autocompletion — modules, functions, types, and variables with full snippet support. Resolves through aliases, imports,
useinjections, and the Elixir stdlib. Works for qualified calls (MyApp.Repo.|), bare function calls, and module prefixes. - Rename — rename modules, functions, and variables with automatic file renaming when the convention is followed.
- No compilation required — the index is built by parsing source files directly, not by compiling your project. Dexter works immediately on any codebase, even ones that don't compile.
- Monorepo and umbrella support — a single index at the repository root covers all apps and shared libraries. Go-to-definition, find references, and rename work cross-project out of the box.
- Format on save — formats
.ex,.exs, and.heexfiles on save via a persistent Elixir process. Near-instant after the first save. Formatter plugins (Styler, Phoenix.LiveView.HTMLFormatter) are loaded from your project's_build— no install needed. Syntax errors are surfaced as diagnostics. - Elixir stdlib indexing — jump to
Enum,String,Mix, and other bundled modules by indexing your local Elixir installation sources. - Signature help — parameter hints as you type function calls.
- Workspace symbols — search for any module or function across the entire codebase.
- Call hierarchy — navigate incoming and outgoing calls.
- Code actions — add missing aliases with a single action.
- Document symbols — outline view of all functions and modules in the current file.
- Document highlight — highlight all occurrences of the symbol under the cursor.
- Variable support — go-to-definition, rename, and completion for local variables via tree-sitter, with correct scoping across
case,with,for, and other block constructs. - Git branch switch detection — automatically reindexes when you switch branches.
More features
- Delegate following —
defdelegate fetch(id), to: MyApp.Repojumps toMyApp.Repo.fetch, respectingas:renames. - Alias resolution —
alias MyApp.Handlers.Foo,alias MyApp.Handlers.Foo, as: Cool,alias MyApp.Handlers.{Foo, Bar}. - Import resolution — bare function calls resolved through
importdeclarations. - Type definitions —
@typeand@opaqueare indexed for go-to-definition and hover. - Folding ranges — collapse functions and modules in your editor.
- Monorepo-aware formatting — walks up from the file to find the nearest
.formatter.exs, so subprojects with their own formatter configs (including nestedsubdirectories:configs) just work. - Heredoc awareness — code examples in
@moduledoc/@docare skipped. - Module nesting — correctly tracks
endkeywords to attribute functions to the right module.
# 1. Install dependencies (if you don't already have them)
brew install sqlite # or your preferred package manager
# 2. Install dexter (builds from source if no prebuilt binary is available for your platform)
mise plugin add dexter https://github.com/remoteoss/dexter.git && mise use -g dexter@latest
# or, with asdf:
# asdf plugin add dexter https://github.com/remoteoss/dexter.git && asdf install dexter latest && asdf global dexter latest
# 3. Add .dexter.db to your .gitignore
echo ".dexter.db*" >> .gitignore
# 4. Configure your editor (see below)
# The LSP server auto-builds the index on first startup — no need to run dexter init manually.
# You can still run it explicitly if you prefer: dexter init ~/code/my-elixir-projectYou can also build from source directly.
Dexter works with any editor that supports the Language Server Protocol. Below are setup instructions for the most common ones — if your editor isn't listed, point it at dexter lsp over stdio.
Install the Dexter VS Code Extension.
Or, if you prefer to install from source: dexter-vscode.
If you installed via Mise or ASDF, you're all done!
But if Dexter is not on your PATH, set the binary path in your editor settings:
{
"dexter.binary": "/Users/you/.local/share/mise/shims/dexter"
}To enable format-on-save, update your VS Code settings:
// global in your editor
{
"editor.formatOnSave": true,
}
// or, for Elixir specifically
{
"[elixir]": {
"editor.formatOnSave": true
// you may need to set Dexter as your default Elixir formatter, depending on your setup
"editor.defaultFormatter": "remoteoss.dexter-lsp"
},
"[phoenix-heex]": { "editor.formatOnSave": true }
}Add to your LSP configuration (e.g., after/plugin/lsp.lua):
vim.lsp.config('dexter', {
cmd = { 'dexter', 'lsp' },
root_markers = { '.dexter.db', '.git', 'mix.exs' },
filetypes = { 'elixir', 'eelixir', 'heex' },
init_options = {
followDelegates = true, -- jump through defdelegate to the target function
-- stdlibPath = "", -- override Elixir stdlib path (auto-detected)
-- debug = false, -- verbose logging to stderr (view with :LspLog)
},
})
vim.lsp.enable 'dexter'That's it. Go-to-definition (gd, <C-]>, or whatever you have mapped to vim.lsp.buf.definition()) will now use dexter alongside any other attached LSP servers. Formatting happens automatically on save — no BufWritePre autocommand needed.
If you want a dedicated binding just for dexter:
vim.keymap.set("n", "<leader>va", function()
vim.lsp.buf.definition({ filter = function(client) return client.name == "dexter" end })
end)local lspconfig = require("lspconfig")
local configs = require("lspconfig.configs")
configs.dexter = {
default_config = {
cmd = { "dexter", "lsp" }, -- update this if you don't have Dexter in your PATH
filetypes = { "elixir", "eelixir", "heex" },
root_dir = lspconfig.util.root_pattern(".dexter.db", "mix.exs", ".git"),
},
}
lspconfig.dexter.setup({})Install the dexter-zed extension:
- Clone the extension:
git clone https://github.com/remoteoss/dexter-zed.git - In Zed, open the command palette (
Cmd+Shift+P) and run "zed: install dev extension" - Select the
dexter-zed/directory
Configure the binary path in Zed's settings.json:
{
"lsp": {
"dexter": {
"binary": {
"path": "/Users/you/.local/share/mise/shims/dexter", // or wherever `which dexter` points to
"arguments": ["lsp"],
}
}
}
}Remote has one of the largest Elixir codebases in existence (at least that we're aware of), now around 57k files. As our codebase has grown, we've had more and more struggles with language servers. We had found that they simply couldn't keep up with such a large codebase. On large codebases like ours, existing LSPs take hours to index, and even after indexing, operations like go-to-definition and go-to-references are still slow. On top of that, changing branches means a whole new round of indexing. The result has been frustration. Many of us on the engineering team had all but given up on the idea of ever having a working LSP.
Dexter is designed with speed and efficiency as core guiding principles. It takes a different approach from other Elixir LSPs, parsing source files directly as text and storing everything in SQLite so lookups are fast. The speed difference is noticeable on codebases of all sizes. Although Dexter isn't fully aware of the compiled state of the code like compilation-based LSPs, some clever parsing and deferring complex macro following to runtime allow it to get very close. In fact, you probably wouldn't even notice this limitation if you weren't reading this.
Measured on a 57k-file Elixir monorepo (330k definitions, 2.7M references) on a 32GB M1 MacBook Pro:
| Operation | Time |
|---|---|
| Cold first-time index | ~11s |
| Lookup (LSP or CLI) | ~10ms |
| Single file reindex (on save) | ~10ms |
| Full reindex (no changes) | ~2s |
| Format on save | <1ms |
The CLI commands are available for scripting and manual use.
# First time — indexes all .ex/.exs files (including deps/ and the Elixir standard library)
dexter init ~/code/my-elixir-project
# Re-init from scratch (deletes existing index)
dexter init --force ~/code/my-elixir-project
# Print timing breakdown for each indexing phase (walk, parse, store)
dexter init --profile ~/code/my-elixir-projectDexter auto-detects your Elixir installation. If it can't find it (e.g. a non-standard install, it's not in your PATH, etc.), set:
export DEXTER_ELIXIR_LIB_ROOT="/path/to/elixir/lib"# Find where a module is defined
dexter lookup MyApp.Repo
# => /path/to/lib/my_app/repo.ex:1
# Find where a function is defined (follows defdelegates by default)
dexter lookup MyApp.Repo get
# => /path/to/lib/my_app/repo.ex:15
# Don't follow defdelegates
dexter lookup --no-follow-delegates MyApp.Accounts fetch
# => /path/to/lib/my_app/accounts.ex:5
# Strict mode — exit 1 if exact function not found (no fallback to module)
dexter lookup --strict MyApp.Repo nonexistent
# => (exit code 1)# Find all usages of a module
dexter references MyApp.Repo
# => /path/to/lib/my_app/accounts.ex:12
# => /path/to/lib/my_app/posts.ex:8
# Find all usages of a specific function
dexter references MyApp.Repo get
# => /path/to/lib/my_app/accounts.ex:45Exits 1 with a message to stderr if no references are found.
The LSP does this for you automatically, but if for some reason you need to, you can reindex files manually via the CLI.
# Re-index a single file (~10ms)
dexter reindex /path/to/lib/my_app/repo.ex
# Re-index the whole project (only re-parses changed files)
dexter reindex ~/code/my-elixir-projectWhen running as an LSP server, dexter automatically:
- Reindexes files on save (
textDocument/didSave) - Runs an incremental reindex on startup
- Watches
.git/HEADfor branch switches and reindexes when detected - Periodically reindexes every 30 seconds
Dexter serves hover docs (textDocument/hover) for functions, modules, and types. When you hover over a symbol, it looks up the definition in the index and reads the @doc, @moduledoc, @typedoc, or @spec annotations from the source file.
The hover response shows the function signature (with @spec if present), followed by the doc string:
def fetch_user(id, opts)
@spec fetch_user(binary(), keyword()) :: {:ok, User.t()} | {:error, term()}
Fetches a user by ID. Options are passed to the underlying query.
Dexter resolves hover (and go-to-definition) based on which segment of a dotted expression your cursor is on:
| Cursor position | Expression | Resolves to |
|---|---|---|
On Repo in MyApp.Repo.all |
MyApp.Repo |
The MyApp.Repo module |
On all in MyApp.Repo.all |
MyApp.Repo.all |
The all function |
On MyApp in MyApp.Repo.all |
MyApp |
The MyApp module |
Dexter supports renaming modules, functions, and variables across the codebase via textDocument/rename (F2 in most editors).
Place your cursor on any segment of a module name and invoke rename. Dexter highlights just the last segment for editing — the parent namespace is preserved automatically. For example, renaming Repo in MyApp.Repo to Repository renames the module to MyApp.Repository.
What gets updated:
- The
defmoduledeclaration - All aliases, imports, and uses referencing the module
- All call sites
- All submodules (renaming
MyApp.Fooalso renamesMyApp.Foo.Bar,MyApp.Foo.Baz, etc.)
File renaming after a module rename: If the source file follows the Elixir naming convention (module MyApp.SomeRepo → file some_repo.ex), dexter renames the file alongside the module. For submodules, the containing directory segment is also renamed to match (e.g., renaming MyApp.Companies to MyApp.Clients moves lib/companies/services/do_something.ex → lib/clients/services/do_something.ex). After the rename, dexter opens the new file automatically if your editor supports window/showDocument.
When path renaming won't happen: If the file name doesn't match the snake_case form of the module's last segment — for example, a file named my_custom_name.ex that defines MyModule.SomeRepo — the file stays in place and only the contents are updated.
Files not open in the editor are written directly to disk; open buffers receive edits via the LSP workspace edit response.
Place your cursor on a function name (qualified or bare) and invoke rename. Dexter updates:
- All
def/defp/defmacro/defguard/etc. clauses @specand@callbackannotations- Direct calls and pipe calls (
|> function_name) import Module, only: [function_name: ...]lines- Transitive call sites via
__using__chains
Renaming is blocked for functions defined in stdlib or deps.
Place your cursor on a local variable and invoke rename. Dexter uses tree-sitter to find all occurrences within the enclosing function scope and renames them in a single edit. This is file-local only.
Go-to-definition also works for variables — it jumps to the first occurrence (pattern match or assignment) in scope.
Dexter formats files on save via textDocument/willSaveWaitUntil using a persistent Elixir process per .formatter.exs. This persistent formatter server starts once when you open the first file in a project under a given .formatter.exs, so formatting is near-instant.
Plugins (Styler, Phoenix.LiveView.HTMLFormatter, etc.) are loaded from
your project's _build/dev/lib. So as long as your formatter plugins are installed and compiled, everything is ready to
go.
If the persistent process can't start, dexter falls back to running mix format directly.
Syntax errors found by the formatter are surfaced as LSP diagnostics pointing to the exact line and column, with a warning at the hint location (e.g. "the do on line 52 does not have a matching end"). Diagnostics clear on the next successful format (which again, is nearly instantaneous!).
Nested .formatter.exs: Dexter walks up from the file to the mix root and uses the nearest .formatter.exs. A file in config/ uses config/.formatter.exs if it exists (for projects using subdirectories:), falling back to the root config.
Elixir detection: The mix and elixir binaries are derived from the same Elixir install used for stdlib detection, so the correct version is always used regardless of which tool manager you use (mise, asdf, etc.).
Dexter reads initializationOptions from your editor configuration:
followDelegates(boolean, default:true): followdefdelegatetargets on lookup.stdlibPath(string): override the Elixir stdlib directory to index. Defaults to auto-detection; use this if your install is non-standard.debug(boolean, default:false): enable verbose logging to stderr. Logs timing and resolution details for every definition, hover, references, and rename request. Can also be enabled via theDEXTER_DEBUG=trueenvironment variable.
Dexter creates .dexter.db at the root of your project when you start the LSP for the first time. But if you prefer, you can run dexter init yourself in the root of your project. Where you place it determines what gets indexed.
When the LSP server starts, it walks up from the project root looking for .dexter.db, preferring .git as the anchor point. This means if you initialised from the monorepo root, the server will find the right database even when Neovim's rootUri points to a sub-app (e.g. because mix.exs is there).
Monorepo root (recommended if using an Elixir monorepo or umbrella structure) — Put the index at the root of your repository, next to .git. This indexes everything: all apps, all shared libraries, and all deps. Go-to-definition works across the entire codebase.
cd ~/code/my-monorepo # where .git lives
dexter init .Single app — Put the index inside a specific Mix project. Go-to-definition works within that app and its deps, but not across other apps in the monorepo.
cd ~/code/my-monorepo/apps/my_app
dexter init .If something isn't working as expected, start by forcing a full reindex to rule out a stale/corrupted index. It's unlikely that this is actually the problem, but it's better to have a clean slate just to be sure.
dexter init --force ~/code/my-elixir-projectIf the issue persists, enable debug mode to get verbose logs. You can do this in two ways:
- Set the
debugoption in your editor's LSPinitializationOptions(see LSP options) - Or set the
DEXTER_DEBUG=trueenvironment variable before launching your editor
Debug mode logs timing and resolution details for every definition, hover, references, and rename request to stderr. In Neovim you can usually view these at ~/.local/state/nvim/lsp.log. In VS Code, you can see them in Output > Dexter.
When filing an issue, please include:
- Your Dexter version (
dexter --version) - Your Elixir version (
elixir --version) - The debug logs from the failing operation
- A minimal code snippet that reproduces the issue, if possible
Requires Go 1.21+, SQLite, and Elixir.
git clone https://github.com/remoteoss/dexter.git
cd dexter
mise install # install dependencies
make build # to build from source
make test # to test# 1. Create a release branch with the version bump
make release VERSION=0.2.0
# 2. Push the branch and merge it into main
# 3. Tag and push the tag
make tag VERSION=0.2.0This updates the version in internal/version/version.go on a release branch. After merging to main, make tag creates and pushes the git tag. Users can then upgrade via mise:
mise plugin update dexter && mise install dexter@latestThe plugin update step is required to pick up newly tagged releases. Without it, mise install dexter@latest will resolve against a stale list.
If the release changes how Elixir files are parsed or what gets stored in the index (e.g. a new definition kind, a change to delegate resolution), also bump IndexVersion in internal/version/version.go. Dexter will automatically rebuild the index when users upgrade to a binary with a higher IndexVersion — no manual dexter init --force required.
Dexter is a new project and we're actively expanding its capabilities. If you come across code that causes issues, do let us know so we can support it. Bug reports, pull requests, and feature suggestions are all welcome on GitHub. We try to address issues quickly and would love to hear what you'd like to see next.
Dexter is released under the MIT License.
