Skip to content
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ a store that has no rows.
| Any new store query | Add an index if the query will run on hot paths (definition, hover, references) |
| `internal/beam` ETF tag handling | The ERTS external term format spec. One wrong field width desynchronises every later term in the chunk (`EXPORT_EXT` carries its arity as an integer term, `NEW_FUN_EXT` as a raw byte) |
| `internal/workspace/runtime.go` (mutation queue, readiness, subscribers) | `internal/lsp` write coordination (`IndexCoordinator`), `watch.go` event filtering, failed-watch retries and one-shot coverage reconciliation, `workspace/watch` subscribers, and the `Close` ordering: watchers stop before the queue drains, and the queue drains before the store checkpoints |
| A new failure or degraded state | Report it through the workspace `notify.Reporter` (`IndexCoordinator.Reporter()`): a condition key with `Set`, and `Clear` with a message when it stops. A log line alone is not seen in an editor. Report only on state changes, never per file or per request. See "Telling the user" in `docs/architecture.md` |
| `internal/daemon` (protocol, registries, endpoint) | `ContractVersion`, the reserved method and kind names in `registry.go`, `client.go` multiplexing (one reader, ids matched to callers, notifications interleaved), and `docs/daemon.md`. Socket paths must stay short: `sockaddr_un` is capped near 104 bytes |

## Token walking
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- **Go-to-definition reaches the line that declared a generated function** — a function a macro generated used to resolve to the top of its module. Dexter now reads the line from the compiled module's debug info, which is standard compiler output, so no framework is special-cased. A generator that expands each function at the line of the call that declared it, or stamps it with `@file {file, line}`, sends definition, call hierarchy, the references declaration, and `dexter lookup` (including `--strict`) to that line. The line is used only when it was compiled from the file being opened; a BEAM older than the source still gives its line, because Dexter cannot compile the project and the last compile's line is closer than the module line; only edits to the declaring file move it, and the next compile makes it exact. A function whose only recorded line is the module line, such as one a `@before_compile` hook made, goes to the call in its module that declares it by name. When several calls spell the name, as an Ash action and the code interface that runs it do, the macro whose calls name the most of the module's generated functions wins, and a tie keeps the module line. A function with a clause per DSL call goes to every clause. A line past the end of the file is never returned. A module compiled without debug info falls back to its Docs chunk annotation, which is often, but not always, the same line. A generated module with no source of its own, such as one `Module.create` made or a Spark DSL entity, goes to the file it was compiled from, rebased onto the project when it was built elsewhere, and so does go-to-definition on its name. A module a macro made with `defmodule` and a name it computed records no line of its own, so its name goes to its first function's line. A bare call to a generated function of an imported module now resolves. Ash code interfaces go to their `define` line with released Ash, and from the recorded line with an Ash release that includes [ash-project/ash#2971](https://github.com/ash-project/ash/pull/2971) ([#108](https://github.com/remoteoss/dexter/issues/108))

- **Failures and degraded states are shown in the editor** — before, almost every problem went only to the log, so the editor showed a language server that did nothing. Now each condition that stops Dexter from working, or makes it work with less, is a `window/showMessage` in every attached editor: an index written by a newer or older Dexter build, a damaged index that is being rebuilt, an index that another process holds locked or that cannot be opened for another cause, an index that cannot be used, a fast build that fell back to the slow path, files that could not be indexed (one aggregate message), a root that is the home directory or not a project, file watching that is unavailable or does not cover some directories, FSEvents falling back to fsnotify, a workspace with no Elixir standard library, a session with no `mix` (told to that editor only), a Mix project whose formatter cannot run (each project on its own), an Elixir/OTP mismatch that stops the fast formatter (told once; Dexter then formats through `mix format` and does not start the failing BEAM again on each save), and a rename that could not change some files. Each message says what happened, what Dexter does about it, and what to do. Conditions that stop say so, and an editor that attaches later receives the conditions that are still active. Cold builds, rebuilds, and large incremental passes show LSP work-done progress where the editor supports it. `lookup`, `references`, and `reindex` state a rebuilding or unusable index on stderr, and `workspace/status` lists every active condition

- **`dexter lsp` explains why it cannot start** — when the proxy cannot reach a daemon (a daemon from another build, a different spelling of the root, a workspace held by `dexter init`, or a daemon that does not start), it answers the editor's `initialize` request with an error and a `window/showMessage` that carry the full explanation and the fix, instead of printing to stderr and exiting

- **`--root`/`-C` names the workspace on every command** — `dexter init --root ~/project`, `dexter lookup --root ~/project MyApp.Repo`, and `dexter lsp --root ~/project` all run as if they had been started in that directory, so an agent, a script, or an editor wrapper can index or query a project from anywhere. Relative paths, a `reindex` target included, resolve from the named root

- **Watching and runtime locations recover without periodic reindexes** — a directory the kernel refuses to watch (an inotify watch limit on a large tree) is tracked instead of disabling the whole native watcher. Dexter reconciles once when coverage is lost, retries only failed registrations, and reconciles once when coverage returns; it does not run recurring full-tree passes that cause CPU spikes. Runtime files use the environment-independent `/tmp/dexter-<uid>` directory so a GUI editor and a shell cannot derive different ownership locks. Elixir and mix detection also searches the standard mise, asdf, and Homebrew locations and falls back to a login shell, so an editor that starts the daemon with a stripped PATH no longer disables stdlib indexing or formatting
Expand Down Expand Up @@ -34,6 +38,10 @@

- **Compressed BEAM files are read** — a module compiled with the `compressed` option, as some Erlang dependencies are, is a gzip stream around the BEAM container. Dexter rejected it as an invalid BEAM, so its exports were missing from completion and generated-function navigation. It is now decompressed, with the same size limit as an uncompressed file

- **A locked index was deleted under the process that held it** — when the index did not open, the daemon deleted and rebuilt it for any error, including `database is locked`. A `dexter init` or an older release that wrote it with a rollback journal then committed into a deleted file and lost its work in silence. Dexter now deletes the index only when it is damaged. It waits up to 30 seconds for a lock and then fails with a message in the editor, and it keeps the index for permission, disk-space, and similar errors, which a rebuild does not fix

- **Two spellings of one directory got two daemons on macOS** — the default macOS file system ignores case, but the workspace identity kept the case the caller typed, so `~/Code/app` and `~/code/app` got two locks and two daemons that built one index at the same time. The identity now uses the case the file system stores, so the second spelling reaches the first daemon and the editor shows the root-mismatch message

- **Git worktrees nested inside a project were indexed as part of it** — a linked worktree checked out below the project root (Claude Code's `.claude/worktrees/`, or any gitignored worktree folder) is a full copy of the repository, so every go-to-definition returned one result per checkout. The indexer, the incremental sweep, the native watcher and single-file updates now skip any directory that is a linked worktree, including worktrees of bare repositories; submodules and Mix git dependencies stay indexed. Adding, changing, moving or removing such a worktree, including its `mix.exs` and `mix.lock`, no longer reindexes the enclosing project, and an existing index drops the worktree's files at the next start. A worktree that git still records stays skipped after its `.git` file is gone, as during `git worktree remove`, until `git worktree prune`. Starting Dexter inside such a worktree no longer climbs to the enclosing checkout's `.dexter/dexter.db` either: the worktree is its own project
- **Branch switches were missed when the project root is a linked worktree or a submodule** — the HEAD poller read `<root>/.git/HEAD`, but in such a checkout `.git` is a file that names the git directory. The poller now follows that file, so a branch switch reconciles the index even when the native file watcher is unavailable
- **Find references through an injected alias was slow and memory-hungry on large projects** — a module such as `MyApp.Repo`, aliased by a `__using__` block that most of the project uses, made every references query read and tokenize each file that used the injector: about 20,000 files and 2.4 GB of allocations per query on a large monorepo. Only files that contain a candidate reference are read now, which brought that query from 1–2 s to under 0.3 s and its allocations to about 70 MB
Expand Down
106 changes: 41 additions & 65 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/remoteoss/dexter/internal/daemon"
"github.com/remoteoss/dexter/internal/indexer"
"github.com/remoteoss/dexter/internal/lsp"
"github.com/remoteoss/dexter/internal/stdlib"
"github.com/remoteoss/dexter/internal/store"
"github.com/remoteoss/dexter/internal/version"
Expand Down Expand Up @@ -247,7 +248,7 @@ func findProjectRootWithMissing(path string, allowMissing bool) string {
path = filepath.Dir(path)
}
root := store.FindProjectRoot(path, "mix.exs")
if home, homeErr := os.UserHomeDir(); homeErr == nil && sameDir(root, home) {
if home, homeErr := os.UserHomeDir(); homeErr == nil && store.SameDir(root, home) {
if mixRoot := findMarkerBefore(path, "mix.exs", home); mixRoot != "" {
return mixRoot
}
Expand All @@ -256,7 +257,7 @@ func findProjectRootWithMissing(path string, allowMissing bool) string {
}

func findMarkerBefore(path, marker, stop string) string {
for dir := path; !sameDir(dir, stop); dir = filepath.Dir(dir) {
for dir := path; !store.SameDir(dir, stop); dir = filepath.Dir(dir) {
if info, err := os.Stat(filepath.Join(dir, marker)); err == nil && info.Mode().IsRegular() {
return dir
}
Expand All @@ -268,31 +269,6 @@ func findMarkerBefore(path, marker, stop string) string {
return ""
}

// projectMarkers are the cheap signals that a directory is, or carries, a
// Dexter workspace. They match what store.FindProjectRoot trusts, and an
// a Dexter marker means an actual database, not an empty directory left by an
// interrupted operation.
func looksLikeProjectRoot(dir string) bool {
return regularFile(filepath.Join(dir, "mix.exs")) ||
gitMarker(filepath.Join(dir, ".git")) ||
regularFile(store.DBPath(dir)) ||
regularFile(store.LegacyDBPath(dir))
}

func hasDexterMarker(dir string) bool {
return regularFile(store.DBPath(dir)) || regularFile(store.LegacyDBPath(dir))
}

func regularFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}

func gitMarker(path string) bool {
info, err := os.Stat(path)
return err == nil && (info.IsDir() || info.Mode().IsRegular())
}

// requireProjectRoot refuses to treat a directory that shows no sign of being
// an Elixir project as a workspace. A mistyped directory is far more likely
// than the intent to index one: a `dexter lookup` in the home directory would
Expand All @@ -303,49 +279,18 @@ func requireProjectRoot(dir string, allowNonProject bool) {
if allowNonProject {
return
}
if home, err := os.UserHomeDir(); err == nil && sameDir(dir, home) {
if hasDexterMarker(dir) {
if store.IsHomeDir(dir) {
if store.HasIndex(dir) {
return
}
fatal(fmt.Errorf("refusing to use %s as a workspace: it is your home directory, not a project\nhint: run from a project, pass --root <path>, or pass -y/--yes if you really mean it", dir))
}
if looksLikeProjectRoot(dir) {
if store.LooksLikeProject(dir) {
return
}
fatal(fmt.Errorf("refusing to use %s as a workspace: no mix.exs, .git, or Dexter database found, so it does not look like an Elixir project\nhint: run from a project, pass --root <path>, or pass -y/--yes to index it anyway", dir))
}

// warnProjectRoot is the LSP's version of the same check. An editor, unlike a
// shell command, is authoritative about what the user opened, and refusing to
// start would leave them with no language server and only a log line to
// explain it — so this warns loudly and serves the directory anyway. The warning
// is written to stderr, which every LSP client keeps in its server log.
func warnProjectRoot(dir string) {
if home, err := os.UserHomeDir(); err == nil && sameDir(dir, home) {
if hasDexterMarker(dir) {
return
}
log.Printf("Warning: %s is your home directory, not a project; indexing it because the editor asked. Set --root <path> in the editor's dexter command if that is wrong.", dir)
return
}
if looksLikeProjectRoot(dir) {
return
}
log.Printf("Warning: %s does not look like an Elixir project (no mix.exs, .git, or Dexter database); indexing it because the editor asked. Set --root <path> if that is the wrong directory.", dir)
}

// sameDir reports whether two paths name the same directory. Stat is the
// authority so a symlinked spelling (or a case-insensitive filesystem) cannot
// sneak a home directory past the check.
func sameDir(a, b string) bool {
ai, aErr := os.Stat(a)
bi, bErr := os.Stat(b)
if aErr != nil || bErr != nil {
return filepath.Clean(a) == filepath.Clean(b)
}
return os.SameFile(ai, bi)
}

// defaultIdleTimeout resolves the daemon idle timeout. DEXTER_DAEMON_IDLE_TIMEOUT
// overrides the built-in default for every daemon this machine spawns, including
// ones an editor starts, so it can be set once in a shell profile.
Expand Down Expand Up @@ -457,6 +402,7 @@ func cmdReindex(target string, allowNonProject bool) {
if err := client.Call(callCtx, daemon.MethodReindex, daemon.ReindexParams{Target: target}, &result); err != nil {
fatal(err)
}
printIndexNotes(result.Notes, queryOptions{})
switch {
case result.Missing:
fmt.Fprintf(os.Stderr, "Nothing to reindex at %s: it does not exist and nothing is indexed there\n", target)
Expand Down Expand Up @@ -484,8 +430,9 @@ func cmdLookup(projectRoot string, module string, function string, strict bool,
}, &result); err != nil {
fatal(err)
}
printIndexNotes(result.Notes, opts)
if len(result.Locations) == 0 {
warnIfIndexBuilding(result.Ready, opts)
warnIfIndexBuilding(result.Ready, result.Notes, opts)
if strict {
os.Exit(1)
}
Expand All @@ -511,8 +458,9 @@ func cmdReferences(projectRoot string, module string, function string, opts quer
}, &result); err != nil {
fatal(err)
}
printIndexNotes(result.Notes, opts)
if len(result.Locations) == 0 {
warnIfIndexBuilding(result.Ready, opts)
warnIfIndexBuilding(result.Ready, result.Notes, opts)
fmt.Fprintf(os.Stderr, "No references found for %s", module)
if function != "" {
fmt.Fprintf(os.Stderr, ".%s", function)
Expand Down Expand Up @@ -667,9 +615,13 @@ func cmdStopIncompatible(ctx context.Context, root string, e *daemon.Incompatibl
// the index, watchers, and language caches are shared with every other
// frontend. The daemon starts on demand: it belongs to the workspace, not to
// this editor, the CLI, or any other frontend that happens to reach it first.
//
// The daemon checks the root and tells the editor when it does not look like a
// project. When the proxy cannot attach at all, ProxyLSP has already told the
// editor why, as the answer to its initialize request; fatal only repeats it on
// stderr for the editor's log.
func cmdLSP(projectRoot string) {
projectRoot = findProjectRoot(projectRoot)
warnProjectRoot(projectRoot)
log.SetOutput(os.Stderr)
log.Printf("Dexter LSP proxy v%s starting (root: %s, daemon log: %s)", version.Version, projectRoot, daemonLogPath(projectRoot))
if err := daemon.ProxyLSP(context.Background(), projectRoot, os.Stdin, os.Stdout); err != nil {
Expand Down Expand Up @@ -716,13 +668,37 @@ func (o queryOptions) waitReadyMs() int {
// control protocol never see it: every response carries `ready`, which is the
// programmatic way to make the same decision, and the daemon logs any request
// that actually blocked on the build.
func warnIfIndexBuilding(ready bool, opts queryOptions) {
func warnIfIndexBuilding(ready bool, notes []daemon.Note, opts queryOptions) {
if ready || opts.quiet || envFlag("DEXTER_QUIET") {
return
}
for _, note := range notes {
switch note.Key {
case lsp.CondIndexRebuild, lsp.CondIndexUnavailable, lsp.CondIndexFallback:
return // printIndexNotes already said why the index is incomplete
}
}
fmt.Fprintln(os.Stderr, "note: the workspace index is still building; re-run shortly for complete results")
}

// printIndexNotes states on stderr that the index is being rebuilt or cannot
// be used, the same conditions an editor shows, so that an incomplete answer is
// not taken as complete. An error is printed even with --quiet: the answer is
// wrong without it. Info notes are left out; warnIfIndexBuilding covers a
// first build.
func printIndexNotes(notes []daemon.Note, opts queryOptions) {
quiet := opts.quiet || envFlag("DEXTER_QUIET")
for _, note := range notes {
message := strings.TrimPrefix(note.Message, "Dexter: ")
switch {
case note.Severity == "error":
fmt.Fprintf(os.Stderr, "error: %s\n", message)
case note.Severity == "warning" && !quiet:
fmt.Fprintf(os.Stderr, "note: %s\n", message)
}
}
}

func formatInt(n int) string {
s := fmt.Sprintf("%d", n)
if len(s) <= 3 {
Expand Down
Loading
Loading