From b887331a0c196f52ccf91452130ea1e506748d4b Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Tue, 14 Jul 2026 11:34:19 +1000 Subject: [PATCH 01/17] Add built-in MCP server (dexter mcp) Expose the index to AI agents over the Model Context Protocol, modeled on gopls mcp. Nine tools, addressed by module/function name rather than file positions because Elixir modules are not tied to files: - dexter_workspace, dexter_search, dexter_definition, dexter_references, dexter_module_api, dexter_file_outline, dexter_implementations, dexter_call_hierarchy, dexter_reindex Transports: stdio (dexter mcp), streamable HTTP (dexter mcp --listen), and attached mode on a running LSP session (dexter lsp --mcp-listen) sharing open buffers and caches. dexter mcp --instructions prints an agent-facing usage guide. Reuses the LSP server internals: reindexing via the extracted Server.ReindexWorkspace (backgroundReindex body, now also callable blocking), reference collection via Server.CollectReferences, and doc extraction via the tokenizer. No index schema or parser changes. Uses the official github.com/modelcontextprotocol/go-sdk. --- CHANGELOG.md | 6 + README.md | 28 +++ cmd/main.go | 133 +++++++++++++- docs/architecture.md | 1 + go.mod | 8 +- go.sum | 26 ++- integration_test.go | 174 ++++++++++++++++++ internal/lsp/api.go | 114 ++++++++++++ internal/lsp/hover.go | 2 +- internal/lsp/server.go | 207 +++++++++++---------- internal/mcp/call_hierarchy.go | 89 +++++++++ internal/mcp/definition.go | 115 ++++++++++++ internal/mcp/file_outline.go | 109 ++++++++++++ internal/mcp/implementations.go | 120 +++++++++++++ internal/mcp/instructions.md | 27 +++ internal/mcp/mcp.go | 160 +++++++++++++++++ internal/mcp/mcp_test.go | 184 +++++++++++++++++++ internal/mcp/module_api.go | 194 ++++++++++++++++++++ internal/mcp/references.go | 67 +++++++ internal/mcp/reindex.go | 24 +++ internal/mcp/search.go | 39 ++++ internal/mcp/serve.go | 20 +++ internal/mcp/tools_test.go | 307 ++++++++++++++++++++++++++++++++ internal/mcp/workspace.go | 82 +++++++++ internal/store/store.go | 48 +++++ internal/store/store_test.go | 86 +++++++++ 26 files changed, 2246 insertions(+), 124 deletions(-) create mode 100644 internal/lsp/api.go create mode 100644 internal/mcp/call_hierarchy.go create mode 100644 internal/mcp/definition.go create mode 100644 internal/mcp/file_outline.go create mode 100644 internal/mcp/implementations.go create mode 100644 internal/mcp/instructions.md create mode 100644 internal/mcp/mcp.go create mode 100644 internal/mcp/mcp_test.go create mode 100644 internal/mcp/module_api.go create mode 100644 internal/mcp/references.go create mode 100644 internal/mcp/reindex.go create mode 100644 internal/mcp/search.go create mode 100644 internal/mcp/serve.go create mode 100644 internal/mcp/tools_test.go create mode 100644 internal/mcp/workspace.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4c935..eaae6b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Added + +- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Nine tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, and incremental reindexing. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide + ## [0.7.1] - 2026-06-12 ### Added diff --git a/README.md b/README.md index c3f5726..b2e835b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ A fast, full-featured Elixir LSP optimized for large Elixir codebases. - [Look up definitions](#look-up-definitions) - [Find references](#find-references) - [Reindexing files manually](#reindexing-files-manually) +- [MCP server](#mcp-server) - [Hover documentation](#hover-documentation) - [Cursor-position-aware resolution](#cursor-position-aware-resolution) - [Rename](#rename) @@ -453,6 +454,33 @@ When running as an LSP server, dexter automatically: - Runs an incremental reindex on startup - Watches `.git/HEAD` for branch switches and reindexes when detected +## MCP server + +Dexter includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) server, modeled on `gopls mcp`, so AI agents can navigate Elixir codebases through the index instead of grep. Tools cover symbol search, definitions with docs and specs, references, module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, and incremental reindexing. + +Register it with your MCP client. For Claude Code: + +```sh +claude mcp add dexter -- dexter mcp +``` + +Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server indexes the project on first use, keeps the index fresh across git branch switches, and exposes a `dexter_reindex` tool for agents to call after editing files. + +Useful variants: + +```sh +# Serve over streamable HTTP instead of stdio +dexter mcp --listen localhost:8092 + +# Print the agent-facing usage guide (save as context for clients that want it) +dexter mcp --instructions + +# Expose MCP from a running LSP session (shares open buffers and caches) +dexter lsp --mcp-listen=localhost:8092 +``` + +The MCP server and an editor LSP can run side by side: both read the same `.dexter/dexter.db` index. + ## Hover documentation 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. diff --git a/cmd/main.go b/cmd/main.go index 2434880..3252d15 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,17 +1,23 @@ package main import ( + "context" "fmt" "io/fs" "log" + "net" + "net/http" "os" + "os/signal" "path/filepath" "runtime" "sync" "sync/atomic" + "syscall" "time" dexter_lsp "github.com/remoteoss/dexter/internal/lsp" + dexter_mcp "github.com/remoteoss/dexter/internal/mcp" "github.com/remoteoss/dexter/internal/parser" "github.com/remoteoss/dexter/internal/stdlib" "github.com/remoteoss/dexter/internal/store" @@ -100,6 +106,7 @@ func main() { }, } + var lspMCPListen string lspCmd := &cobra.Command{ Use: "lsp [path]", Short: "Start the LSP server (stdio)", @@ -109,10 +116,33 @@ func main() { if err != nil { return err } - cmdLSP(projectRoot) + cmdLSP(projectRoot, lspMCPListen) return nil }, } + lspCmd.Flags().StringVar(&lspMCPListen, "mcp-listen", "", "Also serve MCP over streamable HTTP on this address, sharing the LSP session") + + var mcpListen string + var mcpInstructions bool + mcpCmd := &cobra.Command{ + Use: "mcp [path]", + Short: "Start the MCP server (stdio)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if mcpInstructions { + fmt.Print(dexter_mcp.Instructions) + return nil + } + projectRoot, err := resolvePath(args, 0) + if err != nil { + return err + } + cmdMCP(projectRoot, mcpListen) + return nil + }, + } + mcpCmd.Flags().StringVar(&mcpListen, "listen", "", "Serve MCP over streamable HTTP on this address instead of stdio") + mcpCmd.Flags().BoolVar(&mcpInstructions, "instructions", false, "Print the MCP instructions file and exit") versionCmd := &cobra.Command{ Use: "version", @@ -122,7 +152,7 @@ func main() { }, } - rootCmd.AddCommand(initCmd, reindexCmd, lookupCmd, referencesCmd, lspCmd, versionCmd) + rootCmd.AddCommand(initCmd, reindexCmd, lookupCmd, referencesCmd, lspCmd, mcpCmd, versionCmd) if err := rootCmd.Execute(); err != nil { os.Exit(1) @@ -498,9 +528,57 @@ func cmdReferences(projectRoot string, module string, function string) { } } -func cmdLSP(projectRoot string) { +func cmdLSP(projectRoot string, mcpListen string) { projectRoot = findProjectRoot(projectRoot) + s := openStoreForServer(projectRoot) + defer func() { + if err := s.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close store: %v\n", err) + } + }() + + log.SetOutput(os.Stderr) + log.Printf("Dexter LSP v%s starting (root: %s)", version.Version, projectRoot) + + server := dexter_lsp.NewServer(s, projectRoot) + + // Attached MCP mode: serve MCP over HTTP from the same process, sharing + // the live LSP server so tools see open editor buffers and warm caches. + // The LSP connection's lifetime is authoritative: when it ends, the MCP + // listener goes with it. + var httpSrv *http.Server + if mcpListen != "" { + ln, err := net.Listen("tcp", mcpListen) + if err != nil { + fatal(err) + } + log.Printf("MCP server listening on %s", ln.Addr()) + h := dexter_mcp.NewHandler(dexter_mcp.Config{LSP: server, Store: s, ProjectRoot: projectRoot}) + httpSrv = &http.Server{Handler: dexter_mcp.HTTPHandler(h)} + go func() { + if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { + log.Printf("MCP server error: %v", err) + } + }() + } + + serveErr := dexter_lsp.Serve(server, os.Stdin, os.Stdout) + + if httpSrv != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + } + if serveErr != nil { + fatal(serveErr) + } +} + +// openStoreForServer opens the index with the recovery behavior long-running +// servers need: a corrupted database or an index version mismatch triggers a +// full rebuild instead of an error. +func openStoreForServer(projectRoot string) *store.Store { const maxOpenAttempts = 3 var s *store.Store for attempt := 1; ; attempt++ { @@ -532,16 +610,59 @@ func cmdLSP(projectRoot string) { fatal(openErr) } } + return s +} + +// cmdMCP starts the headless MCP server. Logs go to stderr; stdout belongs to +// the MCP stdio transport. +func cmdMCP(projectRoot string, listen string) { + projectRoot = findProjectRoot(projectRoot) + + log.SetOutput(os.Stderr) + s := openStoreForServer(projectRoot) defer func() { if err := s.Close(); err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to close store: %v\n", err) } }() - log.SetOutput(os.Stderr) - log.Printf("Dexter LSP v%s starting (root: %s)", version.Version, projectRoot) + server := dexter_lsp.NewServer(s, projectRoot) + if root, ok := stdlib.Resolve(s, "", projectRoot); ok { + server.SetStdlibRoot(root) + } + + // Serve only once the index reflects the current tree: an empty index is + // built from scratch, an existing one gets a fast incremental update. + server.ReindexWorkspace() + server.WatchGitHead() + + h := dexter_mcp.NewHandler(dexter_mcp.Config{LSP: server, Store: s, ProjectRoot: projectRoot}) + + log.Printf("Dexter MCP v%s starting (root: %s)", version.Version, projectRoot) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + if listen != "" { + ln, err := net.Listen("tcp", listen) + if err != nil { + fatal(err) + } + log.Printf("MCP server listening on %s", ln.Addr()) + httpSrv := &http.Server{Handler: dexter_mcp.HTTPHandler(h)} + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = httpSrv.Shutdown(shutdownCtx) + }() + if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { + fatal(err) + } + return + } - if err := dexter_lsp.Serve(os.Stdin, os.Stdout, s, projectRoot); err != nil { + if err := dexter_mcp.RunStdio(ctx, h); err != nil && ctx.Err() == nil { fatal(err) } } diff --git a/docs/architecture.md b/docs/architecture.md index 00e2610..5484102 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,6 +9,7 @@ Dexter is a fast Elixir LSP server. It indexes module and function definitions f - `internal/store/` — SQLite layer. Tables: `files` (path + mtime), `definitions` (module, function, kind, line, file_path, delegate_to, delegate_as), `refs` (module, function, line, file_path, kind). - `internal/lsp/` — LSP server. `server.go` handles all LSP methods. `elixir.go` contains pure functions for cursor expression extraction, alias/import/use extraction (tokenizer-based), and use-chain parsing. `rename.go` has rename helpers. `hover.go` has hover formatting. `documents.go` is an in-memory open-buffer store. - `internal/treesitter/` — Tree-sitter integration for scope-aware variable rename and go-to-references. +- `internal/mcp/` — Model Context Protocol server (`dexter mcp`). One file per tool, gopls-style; tools are name-based (module/function, not file+position) and call the store plus the exported facade in `internal/lsp/api.go`. ## LSP feature map diff --git a/go.mod b/go.mod index c2da4ee..94ffde1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.1 require ( github.com/mattn/go-sqlite3 v1.14.38 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/spf13/cobra v1.10.2 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-elixir v0.3.5 @@ -16,13 +17,16 @@ require ( replace github.com/tree-sitter/tree-sitter-elixir => github.com/elixir-lang/tree-sitter-elixir v0.3.5 require ( + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-pointer v0.0.1 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.3.4 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.8.0 // indirect - golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect ) diff --git a/go.sum b/go.sum index a8ff704..6a99e0e 100644 --- a/go.sum +++ b/go.sum @@ -6,9 +6,13 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elixir-lang/tree-sitter-elixir v0.3.5 h1:Ir60dE/aHPt80uil58ukW1CTC+15l4jHax/iHBsW9HI= github.com/elixir-lang/tree-sitter-elixir v0.3.5/go.mod h1:wNBVf64kzvhSbZ8ojVtBF1jRiqGY0lsuK5Kx/60s6Z0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -18,6 +22,8 @@ github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc= github.com/mattn/go-sqlite3 v1.14.38 h1:tDUzL85kMvOrvpCt8P64SbGgVFtJB11GPi2AdmITgb4= github.com/mattn/go-sqlite3 v1.14.38/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -25,8 +31,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= -github.com/segmentio/encoding v0.3.4 h1:WM4IBnxH8B9TakiM2QD5LyNl9JSndh88QbHqVC+Pauc= -github.com/segmentio/encoding v0.3.4/go.mod h1:n0JeuIqEQrQoPDGsjo8UNd1iA0U8d8+oHAA4E3G3OxM= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -62,6 +68,8 @@ github.com/tree-sitter/tree-sitter-ruby v0.23.1 h1:T/NKHUA+iVbHM440hFx+lzVOzS4dV github.com/tree-sitter/tree-sitter-ruby v0.23.1/go.mod h1:kUS4kCCQloFcdX6sdpr8p6r2rogbM6ZjTox5ZOQy8cA= github.com/tree-sitter/tree-sitter-rust v0.23.2 h1:6AtoooCW5GqNrRpfnvl0iUhxTAZEovEmLKDbyHlfw90= github.com/tree-sitter/tree-sitter-rust v0.23.2/go.mod h1:hfeGWic9BAfgTrc7Xf6FaOAguCFJRo3RBbs7QJ6D7MI= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.lsp.dev/jsonrpc2 v0.10.0 h1:Pr/YcXJoEOTMc/b6OTmcR1DPJ3mSWl/SWiU1Cct6VmI= go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= @@ -90,6 +98,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -97,9 +107,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211110154304-99a53858aa08/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8 h1:OH54vjqzRWmbJ62fjuhxy7AxFFgoHN0/DPc/UrL8cAs= -golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -107,10 +116,11 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/integration_test.go b/integration_test.go index 1f43e58..a3328a0 100644 --- a/integration_test.go +++ b/integration_test.go @@ -1,11 +1,16 @@ package main import ( + "bufio" + "context" "os" "os/exec" "path/filepath" "strings" "testing" + "time" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/remoteoss/dexter/internal/store" ) @@ -564,3 +569,172 @@ func TestIntegration_LegacyMigration(t *testing.T) { t.Errorf("expected lookup to work after migration, got: %s", out) } } + +// mcpConnect spawns `dexter mcp ` over stdio and returns a connected +// MCP client session. +func mcpConnect(t *testing.T, binary, root string) *sdkmcp.ClientSession { + t.Helper() + cmd := exec.Command(binary, "mcp", root) + cmd.Dir = root + cmd.Stderr = os.Stderr + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "integration-test", Version: "0.0.1"}, nil) + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + t.Cleanup(cancel) + session, err := client.Connect(ctx, &sdkmcp.CommandTransport{Command: cmd}, nil) + if err != nil { + t.Fatalf("connecting to dexter mcp: %v", err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +func mcpToolText(t *testing.T, res *sdkmcp.CallToolResult) string { + t.Helper() + var b strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*sdkmcp.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} + +func TestIntegration_MCPStdio(t *testing.T) { + binary := buildDexter(t) + root := scaffoldProject(t) + runDexter(t, binary, root, "init", root) + + session := mcpConnect(t, binary, root) + ctx := context.Background() + + tools, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatal(err) + } + names := map[string]bool{} + for _, tool := range tools.Tools { + names[tool.Name] = true + } + for _, want := range []string{"dexter_workspace", "dexter_search", "dexter_definition", "dexter_references", "dexter_module_api", "dexter_file_outline", "dexter_implementations", "dexter_call_hierarchy", "dexter_reindex"} { + if !names[want] { + t.Errorf("tool %s not advertised; got %v", want, names) + } + } + + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "dexter_workspace"}) + if err != nil { + t.Fatal(err) + } + if res.IsError { + t.Fatalf("dexter_workspace errored: %s", mcpToolText(t, res)) + } + out := mcpToolText(t, res) + for _, want := range []string{"Project root:", "mix.exs", "definitions"} { + if !strings.Contains(out, want) { + t.Errorf("workspace output missing %q:\n%s", want, out) + } + } + + res, err = session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "dexter_definition", Arguments: map[string]any{"module": "MyApp.Repo", "function": "get"}}) + if err != nil { + t.Fatal(err) + } + out = mcpToolText(t, res) + if !strings.Contains(out, "lib/my_app/repo.ex") { + t.Errorf("definition output missing location:\n%s", out) + } +} + +func TestIntegration_MCPStdio_EmptyIndexBuildsOnStartup(t *testing.T) { + binary := buildDexter(t) + root := scaffoldProject(t) + // No `dexter init`: the MCP server must build the index before serving. + + session := mcpConnect(t, binary, root) + res, err := session.CallTool(context.Background(), &sdkmcp.CallToolParams{Name: "dexter_search", Arguments: map[string]any{"query": "process_event"}}) + if err != nil { + t.Fatal(err) + } + out := mcpToolText(t, res) + if !strings.Contains(out, "MyApp.Handlers.Webhooks.process_event") { + t.Errorf("search after auto-index missing symbol:\n%s", out) + } +} + +func TestIntegration_MCPInstructions(t *testing.T) { + binary := buildDexter(t) + out := runDexter(t, binary, t.TempDir(), "mcp", "--instructions") + for _, want := range []string{"dexter_workspace", "dexter_reindex"} { + if !strings.Contains(out, want) { + t.Errorf("instructions missing %q", want) + } + } +} + +// TestIntegration_LSPWithMCPListen starts `dexter lsp --mcp-listen=localhost:0` +// (LSP on stdio, MCP over streamable HTTP from the same process) and calls an +// MCP tool while the LSP is running. +func TestIntegration_LSPWithMCPListen(t *testing.T) { + binary := buildDexter(t) + root := scaffoldProject(t) + runDexter(t, binary, root, "init", root) + + cmd := exec.Command(binary, "lsp", "--mcp-listen=localhost:0", root) + cmd.Dir = root + stdin, err := cmd.StdinPipe() // held open: the LSP session's lifetime + if err != nil { + t.Fatal(err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + t.Fatal(err) + } + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = stdin.Close() + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }) + + // Parse the bound address from stderr. + addrCh := make(chan string, 1) + go func() { + scanner := bufio.NewScanner(stderr) + for scanner.Scan() { + line := scanner.Text() + if i := strings.Index(line, "MCP server listening on "); i >= 0 { + addrCh <- strings.TrimSpace(line[i+len("MCP server listening on "):]) + break + } + } + // Keep draining so the child never blocks on a full stderr pipe. + for scanner.Scan() { + } + }() + var addr string + select { + case addr = <-addrCh: + case <-time.After(30 * time.Second): + t.Fatal("timed out waiting for MCP listen address on stderr") + } + + client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "integration-test", Version: "0.0.1"}, nil) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + session, err := client.Connect(ctx, &sdkmcp.StreamableClientTransport{Endpoint: "http://" + addr}, nil) + if err != nil { + t.Fatalf("connecting to attached MCP server: %v", err) + } + defer func() { _ = session.Close() }() + + res, err := session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "dexter_definition", Arguments: map[string]any{"module": "MyApp.Repo", "function": "get"}}) + if err != nil { + t.Fatal(err) + } + out := mcpToolText(t, res) + if !strings.Contains(out, "lib/my_app/repo.ex") { + t.Errorf("attached-mode definition output missing location:\n%s", out) + } +} diff --git a/internal/lsp/api.go b/internal/lsp/api.go new file mode 100644 index 0000000..433f199 --- /dev/null +++ b/internal/lsp/api.go @@ -0,0 +1,114 @@ +package lsp + +import ( + "context" + "io" + "sort" + "strings" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + "go.uber.org/zap" + + "github.com/remoteoss/dexter/internal/store" +) + +// This file is the exported, name-based surface of the LSP server used by +// callers outside the LSP session (the MCP server and the CLI). Everything +// here delegates to the same internals the LSP handlers use, so results are +// identical regardless of which front end asked. + +// Serve runs the Server over the given reader/writer (typically +// stdin/stdout). It blocks until the connection closes. +func Serve(server *Server, in io.Reader, out io.Writer) error { + logger, _ := zap.NewProduction() + stream := jsonrpc2.NewStream(stdinoutCloser{in, out}) + conn := jsonrpc2.NewConn(stream) + server.client = protocol.ClientDispatcher(conn, logger) + server.conn = conn + + handler := protocol.ServerHandler(server, nil) + ctx := context.Background() + + conn.Go(ctx, handler) + <-conn.Done() + return conn.Err() +} + +// SetStdlibRoot records the Elixir stdlib directory so lookups can classify +// stdlib symbols. The LSP session sets this during Initialize; headless +// callers (MCP) set it explicitly after resolving the stdlib themselves. +func (s *Server) SetStdlibRoot(root string) { + s.stdlibRoot = root +} + +// StdlibRoot returns the Elixir stdlib directory, or "" if not detected. In +// attached MCP mode this is set by Initialize after the Handler is built, so +// callers must read it per request rather than caching it. +func (s *Server) StdlibRoot() string { + return s.stdlibRoot +} + +// CollectReferences gathers references to module (or module.function) across +// the workspace, name-based. It mirrors the collection performed by the LSP +// References handler: direct refs, transitive refs through static __using__ +// import chains, bare intra-module calls in definition files, and refs to +// defdelegate facades that target the function. Results are deduplicated by +// file+line, stdlib-filtered, and sorted by file then line. +func (s *Server) CollectReferences(module, function string) []store.ReferenceResult { + refResults, err := s.store.LookupReferences(module, function) + if err != nil { + return nil + } + + if function != "" { + // Transitive refs via static __using__ import chains. Call sites of + // use-injected functions are attributed to the injecting module in the + // store, so we look up refs under each injector too. + for _, mod := range s.findModulesWhoseUsingImports(module) { + if transitive, err := s.store.LookupReferences(mod, function); err == nil { + refResults = append(refResults, transitive...) + } + } + + // Bare intra-module calls in definition files are not indexed. + refResults = append(refResults, s.findBareCallRefs(module, function)...) + + // Follow defdelegate in reverse: calls to facades that delegate here. + if s.followDelegates { + if delegates, err := s.store.LookupDelegatesTo(module, function); err == nil { + for _, del := range delegates { + if delegateRefs, err := s.store.LookupReferences(del.Module, del.Function); err == nil { + refResults = append(refResults, delegateRefs...) + } + refResults = append(refResults, s.findBareCallRefs(del.Module, del.Function)...) + } + } + } + } + + type refKey struct { + filePath string + line int + } + seen := make(map[refKey]struct{}, len(refResults)) + var out []store.ReferenceResult + for _, r := range refResults { + if s.stdlibRoot != "" && strings.HasPrefix(r.FilePath, s.stdlibRoot) { + continue + } + k := refKey{r.FilePath, r.Line} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { + if out[i].FilePath != out[j].FilePath { + return out[i].FilePath < out[j].FilePath + } + return out[i].Line < out[j].Line + }) + return out +} diff --git a/internal/lsp/hover.go b/internal/lsp/hover.go index 104d737..e4aebc4 100644 --- a/internal/lsp/hover.go +++ b/internal/lsp/hover.go @@ -9,7 +9,7 @@ import ( ) func (s *Server) hoverFromFile(function string, result store.LookupResult) (*protocol.Hover, error) { - text, _, ok := s.readFileText(result.FilePath) + text, _, ok := s.ReadFileText(result.FilePath) if !ok { return nil, nil } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 93603cb..5ee9966 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -22,7 +22,6 @@ import ( "go.lsp.dev/jsonrpc2" "go.lsp.dev/protocol" "go.lsp.dev/uri" - "go.uber.org/zap" "github.com/remoteoss/dexter/internal/parser" "github.com/remoteoss/dexter/internal/stdlib" @@ -136,24 +135,6 @@ type stdinoutCloser struct { func (s stdinoutCloser) Close() error { return nil } -// Serve starts the LSP server on the given reader/writer (typically stdin/stdout). -func Serve(in io.Reader, out io.Writer, s *store.Store, projectRoot string) error { - server := NewServer(s, projectRoot) - - logger, _ := zap.NewProduction() - stream := jsonrpc2.NewStream(stdinoutCloser{in, out}) - conn := jsonrpc2.NewConn(stream) - server.client = protocol.ClientDispatcher(conn, logger) - server.conn = conn - - handler := protocol.ServerHandler(server, nil) - ctx := context.Background() - - conn.Go(ctx, handler) - <-conn.Done() - return conn.Err() -} - // backgroundReindex runs in the background. If the index is empty it does a // full init, otherwise it does an incremental mtime-based update. func (s *Server) backgroundReindex() { @@ -164,98 +145,110 @@ func (s *Server) backgroundReindex() { return } defer s.reindexing.Unlock() + s.reindexWorkspace() + }() +} - start := time.Now() - reindexed := 0 - isEmpty := s.store.IsEmpty() - - if isEmpty { - log.Printf("No index found, building from scratch...") - if s.client != nil { - if err := s.client.ShowMessage(context.Background(), &protocol.ShowMessageParams{ - Type: protocol.MessageTypeInfo, - Message: "Dexter: building index for the first time, go-to-definition will be available shortly...", - }); err != nil { - log.Printf("ShowMessage: %v", err) - } +// ReindexWorkspace runs the same full-or-incremental reindex as +// backgroundReindex, but blocking, and reports how many files were updated. +func (s *Server) ReindexWorkspace() (int, time.Duration) { + s.reindexing.Lock() + defer s.reindexing.Unlock() + return s.reindexWorkspace() +} + +func (s *Server) reindexWorkspace() (int, time.Duration) { + start := time.Now() + reindexed := 0 + isEmpty := s.store.IsEmpty() + + if isEmpty { + log.Printf("No index found, building from scratch...") + if s.client != nil { + if err := s.client.ShowMessage(context.Background(), &protocol.ShowMessageParams{ + Type: protocol.MessageTypeInfo, + Message: "Dexter: building index for the first time, go-to-definition will be available shortly...", + }); err != nil { + log.Printf("ShowMessage: %v", err) } } + } - seen := make(map[string]struct{}) - walkAndIndex := func(root string, indexRefs bool) { - _ = parser.WalkElixirFiles(root, func(path string, d fs.DirEntry) error { - seen[path] = struct{}{} - - if !isEmpty { - info, err := d.Info() - if err != nil { - return nil - } - storedMtime, found := s.store.GetFileMtime(path) - currentMtime := info.ModTime().UnixNano() - if found && storedMtime == currentMtime { - return nil - } - } + seen := make(map[string]struct{}) + walkAndIndex := func(root string, indexRefs bool) { + _ = parser.WalkElixirFiles(root, func(path string, d fs.DirEntry) error { + seen[path] = struct{}{} - defs, refs, err := parser.ParseFile(path) + if !isEmpty { + info, err := d.Info() if err != nil { return nil } - if !indexRefs { - refs = nil - } - if err := s.store.IndexFileWithRefs(path, defs, refs); err != nil { - log.Printf("Warning: reindex %s: %v", path, err) + storedMtime, found := s.store.GetFileMtime(path) + currentMtime := info.ModTime().UnixNano() + if found && storedMtime == currentMtime { + return nil } - reindexed++ + } + + defs, refs, err := parser.ParseFile(path) + if err != nil { return nil - }) - } + } + if !indexRefs { + refs = nil + } + if err := s.store.IndexFileWithRefs(path, defs, refs); err != nil { + log.Printf("Warning: reindex %s: %v", path, err) + } + reindexed++ + return nil + }) + } - // Index stdlib first (definitions only). - if s.stdlibRoot != "" { - walkAndIndex(s.stdlibRoot, false) - } + // Index stdlib first (definitions only). + if s.stdlibRoot != "" { + walkAndIndex(s.stdlibRoot, false) + } - walkAndIndex(s.projectRoot, true) + walkAndIndex(s.projectRoot, true) - // Prune store entries for files no longer on disk - if storedPaths, err := s.store.ListFilePaths(); err == nil { - var toRemove []string - for _, storedPath := range storedPaths { - if _, ok := seen[storedPath]; !ok { - toRemove = append(toRemove, storedPath) - } - } - if len(toRemove) > 0 { - _ = s.store.RemoveFiles(toRemove) + // Prune store entries for files no longer on disk + if storedPaths, err := s.store.ListFilePaths(); err == nil { + var toRemove []string + for _, storedPath := range storedPaths { + if _, ok := seen[storedPath]; !ok { + toRemove = append(toRemove, storedPath) } } - - // Collapse the WAL back to disk now that the (potentially large) reindex - // is complete, so the -wal file does not stay parked at its high-water - // mark for the lifetime of the LSP process. - if err := s.store.Checkpoint(); err != nil { - log.Printf("Warning: WAL checkpoint after reindex: %v", err) + if len(toRemove) > 0 { + _ = s.store.RemoveFiles(toRemove) } + } - elapsed := time.Since(start).Round(time.Millisecond) - log.Printf("Background reindex: %d files updated (%s)", reindexed, elapsed) + // Collapse the WAL back to disk now that the (potentially large) reindex + // is complete, so the -wal file does not stay parked at its high-water + // mark for the lifetime of the LSP process. + if err := s.store.Checkpoint(); err != nil { + log.Printf("Warning: WAL checkpoint after reindex: %v", err) + } - if isEmpty && s.client != nil { - if err := s.client.ShowMessage(context.Background(), &protocol.ShowMessageParams{ - Type: protocol.MessageTypeInfo, - Message: fmt.Sprintf("Dexter: index built (%d files in %s)", reindexed, elapsed), - }); err != nil { - log.Printf("ShowMessage: %v", err) - } + elapsed := time.Since(start).Round(time.Millisecond) + log.Printf("Background reindex: %d files updated (%s)", reindexed, elapsed) + + if isEmpty && s.client != nil { + if err := s.client.ShowMessage(context.Background(), &protocol.ShowMessageParams{ + Type: protocol.MessageTypeInfo, + Message: fmt.Sprintf("Dexter: index built (%d files in %s)", reindexed, elapsed), + }); err != nil { + log.Printf("ShowMessage: %v", err) } - }() + } + return reindexed, elapsed } -// watchGitHead polls .git/HEAD mtime and triggers reindex on branch switches. -func (s *Server) watchGitHead() { +// WatchGitHead polls .git/HEAD mtime and triggers reindex on branch switches. +func (s *Server) WatchGitHead() { go func() { headPath := filepath.Join(s.projectRoot, ".git", "HEAD") var lastMtime int64 @@ -397,7 +390,7 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara if !s.initialized { s.initialized = true s.backgroundReindex() - s.watchGitHead() + s.WatchGitHead() } if params.Capabilities.Window != nil && params.Capabilities.Window.ShowDocument != nil { @@ -1870,7 +1863,7 @@ func (s *Server) lookupThroughUseOf(fullModule, functionName string) []store.Loo if err != nil || len(modResults) == 0 { return nil } - fileText, _, ok := s.readFileText(modResults[0].FilePath) + fileText, _, ok := s.ReadFileText(modResults[0].FilePath) if !ok { return nil } @@ -4274,7 +4267,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro specPrefix := "@spec " + functionName callbackPrefix := "@callback " + functionName for filePath := range defFilePaths { - fileText, _, ok := s.readFileText(filePath) + fileText, _, ok := s.ReadFileText(filePath) if !ok { continue } @@ -4308,7 +4301,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro if r.Kind != "import" { continue } - lineText, ok := s.getFileLine(r.FilePath, r.Line) + lineText, ok := s.FileLine(r.FilePath, r.Line) if !ok { continue } @@ -4318,7 +4311,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro } } for filePath := range importFilePaths { - fileText, _, ok := s.readFileText(filePath) + fileText, _, ok := s.ReadFileText(filePath) if !ok { continue } @@ -4341,7 +4334,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro if s.isDepsFile(del.FilePath) { continue } - fileText, open, ok := s.readFileText(del.FilePath) + fileText, open, ok := s.ReadFileText(del.FilePath) if !ok { continue } @@ -4587,7 +4580,7 @@ func (mr *moduleRename) readFiles() map[string]moduleFileInfo { resultsCh := make(chan fileResult, len(mr.sitesByFile)) for fp := range mr.sitesByFile { go func() { - text, open, ok := mr.server.readFileText(fp) + text, open, ok := mr.server.ReadFileText(fp) if ok { resultsCh <- fileResult{fp, strings.Split(text, "\n"), open} } else { @@ -4896,7 +4889,7 @@ func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) * resultsCh := make(chan fileResult, len(sitesByFile)) for fp := range sitesByFile { go func() { - text, open, ok := s.readFileText(fp) + text, open, ok := s.ReadFileText(fp) if ok { resultsCh <- fileResult{fp, strings.Split(text, "\n"), open} } else { @@ -5075,11 +5068,11 @@ func isDepsFileUncached(filePath string) bool { } } -// readFileText returns the contents of filePath, preferring the in-memory +// ReadFileText returns the contents of filePath, preferring the in-memory // document store for editor-owned (didOpen) buffers. The second return // indicates whether the file is currently open in the editor — transient // entries loaded from disk via GetOrLoad are NOT reported as open. -func (s *Server) readFileText(filePath string) (text string, open bool, ok bool) { +func (s *Server) ReadFileText(filePath string) (text string, open bool, ok bool) { uri := string(uri.File(filePath)) if t, found := s.docs.GetIfOpen(uri); found { return t, true, true @@ -5090,12 +5083,12 @@ func (s *Server) readFileText(filePath string) (text string, open bool, ok bool) return "", false, false } -// getFileLine returns the text of line lineNum (1-based) from the file at +// FileLine returns the text of line lineNum (1-based) from the file at // filePath, preferring the in-memory document store for editor-owned // buffers. Transient entries loaded via GetOrLoad fall through to the // disk path. For closed files, only reads up to the target line instead // of the whole file. -func (s *Server) getFileLine(filePath string, lineNum int) (string, bool) { +func (s *Server) FileLine(filePath string, lineNum int) (string, bool) { // Editor-owned buffer: extract the single line from memory uri := string(uri.File(filePath)) if text, ok := s.docs.GetIfOpen(uri); ok { @@ -5135,7 +5128,7 @@ func (s *Server) findBareCallRefs(module, functionName string) []store.Reference } var refs []store.ReferenceResult for filePath := range defFilePaths { - fileText, _, ok := s.readFileText(filePath) + fileText, _, ok := s.ReadFileText(filePath) if !ok { continue } @@ -5227,7 +5220,7 @@ func (s *Server) SignatureHelp(ctx context.Context, params *protocol.SignatureHe } // Read the definition file, preferring the in-memory doc store - fileText, _, ok2 := s.readFileText(result.FilePath) + fileText, _, ok2 := s.ReadFileText(result.FilePath) if !ok2 { return nil, nil } @@ -5432,7 +5425,7 @@ func (s *Server) PrepareCallHierarchy(ctx context.Context, params *protocol.Call r := defResults[0] nameCol := 0 - if defLine, ok := s.getFileLine(r.FilePath, r.Line); ok { + if defLine, ok := s.FileLine(r.FilePath, r.Line); ok { if col := findTokenColumn(defLine, functionName); col >= 0 { nameCol = col } @@ -5514,7 +5507,7 @@ func (s *Server) IncomingCalls(ctx context.Context, params *protocol.CallHierarc } nameCol := 0 - if defLine, ok := s.getFileLine(r.FilePath, callerLine); ok { + if defLine, ok := s.FileLine(r.FilePath, callerLine); ok { if col := findTokenColumn(defLine, callerFunc); col >= 0 { nameCol = col } @@ -5604,7 +5597,7 @@ func (s *Server) OutgoingCalls(ctx context.Context, params *protocol.CallHierarc } nameCol := 0 - if defLine, ok := s.getFileLine(td.FilePath, td.Line); ok { + if defLine, ok := s.FileLine(td.FilePath, td.Line); ok { if col := findTokenColumn(defLine, key.function); col >= 0 { nameCol = col } diff --git a/internal/mcp/call_hierarchy.go b/internal/mcp/call_hierarchy.go new file mode 100644 index 0000000..741e9f9 --- /dev/null +++ b/internal/mcp/call_hierarchy.go @@ -0,0 +1,89 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "go.lsp.dev/protocol" +) + +type CallHierarchyParams struct { + Module string `json:"module" jsonschema:"fully-qualified module owning the function"` + Function string `json:"function" jsonschema:"function name without arity"` + Direction string `json:"direction,omitempty" jsonschema:"'incoming' (callers), 'outgoing' (callees), or 'both' (default)"` +} + +const maxCallsPerDirection = 50 + +func (h *Handler) callHierarchyHandler(ctx context.Context, req *mcp.CallToolRequest, args CallHierarchyParams) (*mcp.CallToolResult, any, error) { + module := strings.TrimSpace(args.Module) + function := strings.TrimSpace(args.Function) + if module == "" || function == "" { + return nil, nil, fmt.Errorf("module and function must not be empty") + } + direction := strings.ToLower(strings.TrimSpace(args.Direction)) + switch direction { + case "": + direction = "both" + case "incoming", "outgoing", "both": + default: + return nil, nil, fmt.Errorf("direction must be 'incoming', 'outgoing', or 'both', got %q", args.Direction) + } + + // The LSP call-hierarchy handlers are name-based: they only read the + // module/function pair from Item.Data, so a synthetic item works. + item := protocol.CallHierarchyItem{ + Data: map[string]interface{}{"module": module, "function": function}, + } + + var b strings.Builder + fmt.Fprintf(&b, "Call hierarchy for %s.%s:\n", module, function) + found := false + + if direction == "incoming" || direction == "both" { + calls, err := h.lsp.IncomingCalls(ctx, &protocol.CallHierarchyIncomingCallsParams{Item: item}) + if err != nil { + return nil, nil, fmt.Errorf("incoming calls: %w", err) + } + fmt.Fprintf(&b, "\nIncoming (callers): %d\n", len(calls)) + for i, c := range calls { + if i == maxCallsPerDirection { + fmt.Fprintf(&b, " ... and %d more\n", len(calls)-maxCallsPerDirection) + break + } + lines := make([]string, 0, len(c.FromRanges)) + for _, r := range c.FromRanges { + lines = append(lines, fmt.Sprintf("%d", r.Start.Line+1)) + } + fmt.Fprintf(&b, " ← %s (%s:%d) calls at line %s\n", c.From.Name, h.relPath(uriToPath(c.From.URI)), c.From.Range.Start.Line+1, strings.Join(lines, ", ")) + } + found = found || len(calls) > 0 + } + + if direction == "outgoing" || direction == "both" { + calls, err := h.lsp.OutgoingCalls(ctx, &protocol.CallHierarchyOutgoingCallsParams{Item: item}) + if err != nil { + return nil, nil, fmt.Errorf("outgoing calls: %w", err) + } + fmt.Fprintf(&b, "\nOutgoing (callees): %d\n", len(calls)) + for i, c := range calls { + if i == maxCallsPerDirection { + fmt.Fprintf(&b, " ... and %d more\n", len(calls)-maxCallsPerDirection) + break + } + fmt.Fprintf(&b, " → %s (%s:%d)\n", c.To.Name, h.relPath(uriToPath(c.To.URI)), c.To.Range.Start.Line+1) + } + found = found || len(calls) > 0 + } + + if !found { + fmt.Fprintf(&b, "\nNo calls found. Check the module/function names (dexter_search can help), or call dexter_reindex if files changed recently.\n") + } + return textResult(b.String()), nil, nil +} + +func uriToPath(u protocol.DocumentURI) string { + return u.Filename() +} diff --git a/internal/mcp/definition.go b/internal/mcp/definition.go new file mode 100644 index 0000000..2da320b --- /dev/null +++ b/internal/mcp/definition.go @@ -0,0 +1,115 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/lsp" + "github.com/remoteoss/dexter/internal/store" +) + +type DefinitionParams struct { + Module string `json:"module" jsonschema:"fully-qualified module name, e.g. MyApp.Accounts (aliases are not resolved)"` + Function string `json:"function,omitempty" jsonschema:"function/macro/type name without arity; omit to look up the module itself"` +} + +func (h *Handler) definitionHandler(ctx context.Context, req *mcp.CallToolRequest, args DefinitionParams) (*mcp.CallToolResult, any, error) { + module := strings.TrimSpace(args.Module) + if module == "" { + return nil, nil, fmt.Errorf("module must not be empty") + } + function := strings.TrimSpace(args.Function) + + if function == "" { + return h.moduleDefinition(module) + } + + // Direct definitions first: they tell us whether this is a defdelegate facade. + direct, err := h.store.LookupFunction(module, function) + if err != nil { + return nil, nil, fmt.Errorf("looking up function: %w", err) + } + + var b strings.Builder + if len(direct) == 0 { + // No direct definition. The function may still resolve through a + // defdelegate chain recorded under a different arity/name form. + resolved, err := h.store.LookupFollowDelegate(module, function) + if err != nil { + return nil, nil, fmt.Errorf("looking up function: %w", err) + } + if len(resolved) == 0 { + return textResult(fmt.Sprintf("%s.%s is not in the index. It may be private to a use-chain (injected via __using__), dynamically generated by a macro, or misspelled. Try dexter_search or dexter_module_api %s.", module, function, module)), nil, nil + } + for _, r := range resolved { + h.writeDefinition(&b, module, function, r) + } + return textResult(b.String()), nil, nil + } + + for _, r := range direct { + h.writeDefinition(&b, module, function, r) + if r.Kind == "defdelegate" && r.DelegateTo != "" { + targetFn := function + if r.DelegateAs != "" { + targetFn = r.DelegateAs + } + targets, err := h.store.LookupFollowDelegate(module, function) + if err == nil && len(targets) > 0 { + fmt.Fprintf(&b, "\nDelegates to %s.%s:\n", r.DelegateTo, targetFn) + for _, t := range targets { + h.writeDefinition(&b, r.DelegateTo, targetFn, t) + } + } + } + } + return textResult(b.String()), nil, nil +} + +func (h *Handler) moduleDefinition(module string) (*mcp.CallToolResult, any, error) { + results, err := h.store.LookupModule(module) + if err != nil { + return nil, nil, fmt.Errorf("looking up module: %w", err) + } + if len(results) == 0 { + return textResult(fmt.Sprintf("Module %s is not in the index. Use dexter_search to find the right name, or dexter_reindex if it was just created.", module)), nil, nil + } + + var b strings.Builder + for _, r := range results { + fmt.Fprintf(&b, "%s %s - %s:%d\n", moduleKindLabel(r.Kind), module, h.relPath(r.FilePath), r.Line) + if r.Kind != "defimpl" { + if text, _, ok := h.lsp.ReadFileText(r.FilePath); ok { + if doc := lsp.NewTokenizedFile(text).ExtractModuledoc(r.Line - 1); doc != "" { + fmt.Fprintf(&b, "\n%s\n", strings.TrimRight(doc, "\n")) + } + } + } + } + return textResult(b.String()), nil, nil +} + +// writeDefinition renders one definition with location, @spec/@doc, and the +// definition head line. +func (h *Handler) writeDefinition(b *strings.Builder, module, function string, r store.LookupResult) { + fmt.Fprintf(b, "%s (%s) - %s:%d\n", symbolName(module, function, r.Arity), r.Kind, h.relPath(r.FilePath), r.Line) + + text, _, ok := h.lsp.ReadFileText(r.FilePath) + if !ok { + return + } + tf := lsp.NewTokenizedFile(text) + doc, spec := tf.ExtractDocAbove(r.Line - 1) + if spec != "" { + fmt.Fprintf(b, "%s\n", spec) + } + if head, ok := h.lsp.FileLine(r.FilePath, r.Line); ok { + fmt.Fprintf(b, "%s\n", strings.TrimRight(head, " \t")) + } + if doc != "" { + fmt.Fprintf(b, "\n%s\n", strings.TrimRight(doc, "\n")) + } +} diff --git a/internal/mcp/file_outline.go b/internal/mcp/file_outline.go new file mode 100644 index 0000000..e74390f --- /dev/null +++ b/internal/mcp/file_outline.go @@ -0,0 +1,109 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/parser" +) + +type FileOutlineParams struct { + File string `json:"file" jsonschema:"path to a .ex/.exs file, absolute or relative to the project root"` +} + +func (h *Handler) fileOutlineHandler(ctx context.Context, req *mcp.CallToolRequest, args FileOutlineParams) (*mcp.CallToolResult, any, error) { + if strings.TrimSpace(args.File) == "" { + return nil, nil, fmt.Errorf("file must not be empty") + } + path := h.resolvePath(args.File) + if _, err := os.Stat(path); err != nil { + return textResult(fmt.Sprintf("File not found: %s", h.relPath(path))), nil, nil + } + + // Parse fresh from disk so the outline is correct even when the index is stale. + defs, _, err := parser.ParseFile(path) + if err != nil { + return nil, nil, fmt.Errorf("parsing %s: %w", h.relPath(path), err) + } + if len(defs) == 0 { + return textResult(fmt.Sprintf("%s defines no modules or functions.", h.relPath(path))), nil, nil + } + + // Split into module declarations (in line order) and their members. + type moduleEntry struct { + def parser.Definition + members []parser.Definition + } + var modules []*moduleEntry + byName := make(map[string]*moduleEntry) + var orphans []parser.Definition + + sorted := make([]parser.Definition, len(defs)) + copy(sorted, defs) + sort.SliceStable(sorted, func(i, j int) bool { return sorted[i].Line < sorted[j].Line }) + + for _, d := range sorted { + if d.Function == "" { + e := &moduleEntry{def: d} + modules = append(modules, e) + byName[d.Module] = e + } + } + for _, d := range sorted { + if d.Function == "" { + continue + } + if e, ok := byName[d.Module]; ok { + e.members = append(e.members, d) + } else { + orphans = append(orphans, d) + } + } + + var b strings.Builder + fmt.Fprintf(&b, "%s\n", h.relPath(path)) + for _, e := range modules { + fmt.Fprintf(&b, "\n%s %s (line %d)\n", moduleKindLabel(e.def.Kind), e.def.Module, e.def.Line) + for _, m := range e.members { + b.WriteString(" " + memberLine(m) + "\n") + } + } + for _, m := range orphans { + b.WriteString(memberLine(m) + "\n") + } + return textResult(b.String()), nil, nil +} + +func moduleKindLabel(kind string) string { + switch kind { + case "module": + return "defmodule" + default: // defprotocol, defimpl + return kind + } +} + +func memberLine(d parser.Definition) string { + label := d.Kind + switch d.Kind { + case "type", "opaque", "callback", "macrocallback": + label = "@" + d.Kind + } + line := fmt.Sprintf("%4d: %s %s/%d", d.Line, label, d.Function, d.Arity) + if d.Params != "" { + line += fmt.Sprintf(" (%s)", d.Params) + } + if d.DelegateTo != "" { + target := d.DelegateTo + if d.DelegateAs != "" { + target += "." + d.DelegateAs + } + line += " → " + target + } + return line +} diff --git a/internal/mcp/implementations.go b/internal/mcp/implementations.go new file mode 100644 index 0000000..6273eee --- /dev/null +++ b/internal/mcp/implementations.go @@ -0,0 +1,120 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ImplementationsParams struct { + Module string `json:"module" jsonschema:"behaviour or protocol module, fully qualified"` + Function string `json:"function,omitempty" jsonschema:"callback name; when set, locate its definition in each implementor"` +} + +func (h *Handler) implementationsHandler(ctx context.Context, req *mcp.CallToolRequest, args ImplementationsParams) (*mcp.CallToolResult, any, error) { + module := strings.TrimSpace(args.Module) + if module == "" { + return nil, nil, fmt.Errorf("module must not be empty") + } + + modResults, err := h.store.LookupModule(module) + if err != nil { + return nil, nil, fmt.Errorf("looking up module: %w", err) + } + + // Protocol: implementations are the defimpl rows indexed under the protocol name. + isProtocol := false + var impls, decls []int + for i, r := range modResults { + switch r.Kind { + case "defprotocol": + isProtocol = true + decls = append(decls, i) + case "defimpl": + impls = append(impls, i) + } + } + if isProtocol { + var b strings.Builder + fmt.Fprintf(&b, "%s is a protocol (defprotocol at %s:%d).\n", module, h.relPath(modResults[decls[0]].FilePath), modResults[decls[0]].Line) + if len(impls) == 0 { + fmt.Fprintf(&b, "No defimpl implementations found in the index.\n") + return textResult(b.String()), nil, nil + } + fmt.Fprintf(&b, "\nImplementations (%d):\n", len(impls)) + for _, i := range impls { + r := modResults[i] + fmt.Fprintf(&b, " %s:%d\n", h.relPath(r.FilePath), r.Line) + } + fmt.Fprintf(&b, "\nNote: the defimpl target type is on the cited line (defimpl %s, for: Type).\n", module) + return textResult(b.String()), nil, nil + } + + // Behaviour: modules that declare @behaviour or `use` this module. + implementors, err := h.store.LookupBehaviourImplementors(module) + if err != nil { + return nil, nil, fmt.Errorf("looking up implementors: %w", err) + } + if len(implementors) == 0 { + if len(modResults) == 0 { + return textResult(fmt.Sprintf("Module %s is not in the index. Use dexter_search to find the right name.", module)), nil, nil + } + return textResult(fmt.Sprintf("No modules declare @behaviour %s (or use it) in the index.", module)), nil, nil + } + + var b strings.Builder + + if args.Function != "" { + // Locate the callback's implementation in each implementor. + function := strings.TrimSpace(args.Function) + cbs, err := h.store.LookupCallbackDef(module, function) + if err != nil { + return nil, nil, fmt.Errorf("looking up callback: %w", err) + } + if len(cbs) == 0 { + return textResult(fmt.Sprintf("%s does not define a @callback named %s. List its callbacks with dexter_module_api.", module, function)), nil, nil + } + fmt.Fprintf(&b, "Implementations of callback %s.%s:\n", module, function) + arities := make(map[int]bool, len(cbs)) + for _, cb := range cbs { + arities[cb.Arity] = true + } + found := 0 + for _, impl := range implementors { + defs, err := h.store.LookupFunction(impl.Module, function) + if err != nil { + continue + } + for _, d := range defs { + if !arities[d.Arity] { + continue + } + fmt.Fprintf(&b, " %s - %s:%d\n", symbolName(impl.Module, function, d.Arity), h.relPath(d.FilePath), d.Line) + found++ + } + } + if found == 0 { + fmt.Fprintf(&b, " (none of the %d implementor(s) define %s; they may rely on a default implementation injected via use)\n", len(implementors), function) + } + return textResult(b.String()), nil, nil + } + + fmt.Fprintf(&b, "Modules implementing behaviour %s (%d):\n", module, len(implementors)) + const maxImpls = 50 + for i, impl := range implementors { + if i == maxImpls { + fmt.Fprintf(&b, " ... and %d more\n", len(implementors)-maxImpls) + break + } + fmt.Fprintf(&b, " %s - %s\n", impl.Module, h.relPath(impl.FilePath)) + } + if cbs, err := h.store.ListModuleCallbacks(module); err == nil && len(cbs) > 0 { + fmt.Fprintf(&b, "\nCallbacks defined by %s:\n", module) + for _, cb := range cbs { + fmt.Fprintf(&b, " @%s %s/%d\n", cb.Kind, cb.Function, cb.Arity) + } + } + return textResult(b.String()), nil, nil +} diff --git a/internal/mcp/instructions.md b/internal/mcp/instructions.md new file mode 100644 index 0000000..a515005 --- /dev/null +++ b/internal/mcp/instructions.md @@ -0,0 +1,27 @@ +# Dexter: Elixir code intelligence + +Dexter indexes every module, function, and call site in this Elixir workspace +by parsing source directly (no compilation needed). Use these tools instead of +grep or reading whole files whenever you navigate or ask questions about +Elixir code: they resolve aliases, imports, defdelegate chains, use-chain +injection, and the Elixir stdlib, which text search cannot. + +Which tool for which question: + +- Locate a symbol by name fragment: `dexter_search` +- Where or what is Module.function: `dexter_definition` +- Understand a module before reading its source: `dexter_module_api` +- Who calls or uses something: `dexter_references` or `dexter_call_hierarchy` +- Implementations of a behaviour or protocol: `dexter_implementations` +- What a specific file defines: `dexter_file_outline` +- Project layout and index freshness: `dexter_workspace` + +After you create, edit, or delete Elixir files by any means, call +`dexter_reindex` (fast, incremental) so results stay accurate. Git branch +switches are picked up automatically. + +Elixir specifics: modules are not tied to files (use `dexter_file_outline` for +a file, `dexter_definition` for a module); pass fully-qualified module names, +not aliases; function names take no arity; functions defined inside a +`__using__` quote block may not be indexed, so an empty lookup can mean +macro-generated code. diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go new file mode 100644 index 0000000..f3bd22b --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,160 @@ +// Package mcp implements dexter's Model Context Protocol server. It exposes +// the index as a set of coarse, agent-oriented tools (modeled on gopls mcp), +// addressed by module/function name rather than file positions because Elixir +// modules are not tied to files. +package mcp + +import ( + _ "embed" + "fmt" + "path/filepath" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/lsp" + "github.com/remoteoss/dexter/internal/store" + "github.com/remoteoss/dexter/internal/version" +) + +// Instructions is the agent-facing usage guide, offered to MCP clients via the +// server's instructions field and printable with `dexter mcp --instructions`. +// +//go:embed instructions.md +var Instructions string + +// Handler carries the state shared by all tool handlers. In headless mode +// (`dexter mcp`) the lsp.Server is constructed without a client connection; in +// attached mode (`dexter lsp --mcp-listen`) it is the live LSP session, so +// tools see open editor buffers and warm caches. +type Handler struct { + lsp *lsp.Server + store *store.Store + projectRoot string +} + +type Config struct { + LSP *lsp.Server + Store *store.Store + ProjectRoot string +} + +func NewHandler(cfg Config) *Handler { + return &Handler{ + lsp: cfg.LSP, + store: cfg.Store, + projectRoot: cfg.ProjectRoot, + } +} + +// NewServer returns an MCP server with all dexter tools registered. +func NewServer(h *Handler) *mcp.Server { + srv := mcp.NewServer( + &mcp.Implementation{Name: "dexter", Title: "Dexter Elixir language tools", Version: version.Version}, + &mcp.ServerOptions{Instructions: Instructions}, + ) + + // The pointer hints distinguish explicit false from unset; clients must + // treat unset pessimistically (destructive, open world). + readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(bool)} + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_workspace", + Annotations: readOnly, + Description: "Overview of the Elixir workspace: Mix projects, index size, stdlib status. Call once at the start of Elixir work.", + }, h.workspaceHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_search", + Annotations: readOnly, + Description: "Locate Elixir modules and functions by fuzzy name match. More precise than grep for finding symbols: results are exact definitions with file:line.", + }, h.searchHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_definition", + Annotations: readOnly, + Description: "Definition of an Elixir module or function by name: location, @doc/@spec, and source snippet, following defdelegate to the real implementation. Use instead of grep or reading files to answer where or what a symbol is.", + }, h.definitionHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_references", + Annotations: readOnly, + Description: "All call sites of an Elixir module or function, resolved through aliases, imports, and use-chain injection that grep cannot see. Use for any 'who calls or uses X' question.", + }, h.referencesHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_module_api", + Annotations: readOnly, + Description: "A module's public API in one call: moduledoc, functions with signatures and doc summaries, macros, delegates, types, callbacks, and submodules. Use before reading a module's source.", + }, h.moduleAPIHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_file_outline", + Annotations: readOnly, + Description: "Everything an Elixir file defines: modules, functions, macros, and types with line numbers. Use instead of reading a file to map its contents; one Elixir file can define many modules.", + }, h.fileOutlineHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_implementations", + Annotations: readOnly, + Description: "Implementations of an Elixir behaviour (@behaviour/use) or protocol (defimpl), optionally locating one callback in each implementor. Grep cannot resolve these relationships.", + }, h.implementationsHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_call_hierarchy", + Annotations: readOnly, + Description: "Incoming callers and outgoing callees of an Elixir function, with file:line locations. Use to trace execution paths without reading files.", + }, h.callHierarchyHandler) + + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_reindex", + Annotations: &mcp.ToolAnnotations{DestructiveHint: new(bool), IdempotentHint: true, OpenWorldHint: new(bool)}, + Description: "Update dexter's index after creating, editing, or deleting Elixir files so lookups stay accurate. Incremental and fast; the only tool that writes, and it writes only dexter's own index database.", + }, h.reindexHandler) + + return srv +} + +func textResult(text string) *mcp.CallToolResult { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: text}}} +} + +// relPath renders p relative to the project root when it is inside it. +func (h *Handler) relPath(p string) string { + if rel, err := filepath.Rel(h.projectRoot, p); err == nil && !strings.HasPrefix(rel, "..") { + return rel + } + return p +} + +// resolvePath interprets a user-supplied path against the project root. +func (h *Handler) resolvePath(p string) string { + if filepath.IsAbs(p) { + return p + } + return filepath.Join(h.projectRoot, p) +} + +// symbolName renders Module.function/arity (or just the module name). +func symbolName(module, function string, arity int) string { + if function == "" { + return module + } + return fmt.Sprintf("%s.%s/%d", module, function, arity) +} + +// firstDocLine returns the first non-empty line of a doc string, truncated. +func firstDocLine(doc string) string { + for _, line := range strings.Split(doc, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + const max = 120 + if len(line) > max { + return line[:max-3] + "..." + } + return line + } + return "" +} diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go new file mode 100644 index 0000000..7a3180f --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,184 @@ +package mcp + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/lsp" + "github.com/remoteoss/dexter/internal/parser" + "github.com/remoteoss/dexter/internal/store" + "github.com/remoteoss/dexter/internal/version" +) + +// testEnv is a full in-memory MCP round trip: client session <-> server with +// all tools registered, backed by a real store in a temp dir. Going through +// the SDK session exercises schema inference and argument validation, not +// just the handler bodies. +type testEnv struct { + t *testing.T + store *store.Store + root string + session *mcp.ClientSession +} + +func setupTestEnv(t *testing.T) *testEnv { + t.Helper() + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + if err := s.SetIndexVersion(version.IndexVersion); err != nil { + t.Fatal(err) + } + + server := lsp.NewServer(s, root) + h := NewHandler(Config{LSP: server, Store: s, ProjectRoot: root}) + + ctx := context.Background() + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := NewServer(h).Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.1"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = session.Close() }) + + return &testEnv{t: t, store: s, root: root, session: session} +} + +// indexFile writes an Elixir source file under the project root and indexes it. +func (e *testEnv) indexFile(relPath, content string) string { + e.t.Helper() + path := filepath.Join(e.root, relPath) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + e.t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + e.t.Fatal(err) + } + defs, refs, err := parser.ParseFile(path) + if err != nil { + e.t.Fatal(err) + } + if err := e.store.IndexFileWithRefs(path, defs, refs); err != nil { + e.t.Fatal(err) + } + return path +} + +func (e *testEnv) callTool(name string, args map[string]any) string { + e.t.Helper() + res, err := e.session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + e.t.Fatalf("CallTool(%s): %v", name, err) + } + if res.IsError { + e.t.Fatalf("CallTool(%s) returned tool error: %s", name, resultText(res)) + } + return resultText(res) +} + +func (e *testEnv) callToolExpectError(name string, args map[string]any) string { + e.t.Helper() + res, err := e.session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + return err.Error() + } + if !res.IsError { + e.t.Fatalf("CallTool(%s) succeeded, want error; got: %s", name, resultText(res)) + } + return resultText(res) +} + +func resultText(res *mcp.CallToolResult) string { + var b strings.Builder + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + b.WriteString(tc.Text) + } + } + return b.String() +} + +func wantContains(t *testing.T, got string, wants ...string) { + t.Helper() + for _, w := range wants { + if !strings.Contains(got, w) { + t.Errorf("output missing %q.\nFull output:\n%s", w, got) + } + } +} + +func wantNotContains(t *testing.T, got string, unwanted ...string) { + t.Helper() + for _, w := range unwanted { + if strings.Contains(got, w) { + t.Errorf("output unexpectedly contains %q.\nFull output:\n%s", w, got) + } + } +} + +func TestListTools(t *testing.T) { + e := setupTestEnv(t) + res, err := e.session.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + want := []string{ + "dexter_call_hierarchy", + "dexter_definition", + "dexter_file_outline", + "dexter_implementations", + "dexter_module_api", + "dexter_references", + "dexter_reindex", + "dexter_search", + "dexter_workspace", + } + if len(res.Tools) != len(want) { + t.Errorf("registered %d tools, want %d", len(res.Tools), len(want)) + } + var got []string + for _, tool := range res.Tools { + got = append(got, tool.Name) + } + for _, w := range want { + found := false + for _, g := range got { + if g == w { + found = true + } + } + if !found { + t.Errorf("tool %s not registered; got %v", w, got) + } + } + + for _, tool := range res.Tools { + a := tool.Annotations + if a == nil { + t.Errorf("tool %s has no annotations", tool.Name) + continue + } + if a.OpenWorldHint == nil || *a.OpenWorldHint { + t.Errorf("tool %s not marked closed-world", tool.Name) + } + wantReadOnly := tool.Name != "dexter_reindex" + if a.ReadOnlyHint != wantReadOnly { + t.Errorf("tool %s ReadOnlyHint = %v, want %v", tool.Name, a.ReadOnlyHint, wantReadOnly) + } + } +} diff --git a/internal/mcp/module_api.go b/internal/mcp/module_api.go new file mode 100644 index 0000000..36b64c7 --- /dev/null +++ b/internal/mcp/module_api.go @@ -0,0 +1,194 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/lsp" + "github.com/remoteoss/dexter/internal/store" +) + +type ModuleAPIParams struct { + Module string `json:"module" jsonschema:"fully-qualified module name, e.g. MyApp.Accounts (aliases are not resolved)"` + IncludePrivate bool `json:"include_private,omitempty" jsonschema:"also list defp/defmacrop definitions (default false)"` +} + +func (h *Handler) moduleAPIHandler(ctx context.Context, req *mcp.CallToolRequest, args ModuleAPIParams) (*mcp.CallToolResult, any, error) { + module := strings.TrimSpace(args.Module) + if module == "" { + return nil, nil, fmt.Errorf("module must not be empty") + } + + modResults, err := h.store.LookupModule(module) + if err != nil { + return nil, nil, fmt.Errorf("looking up module: %w", err) + } + var moduleDef *store.LookupResult + implCount := 0 + isProtocol := false + for i := range modResults { + switch modResults[i].Kind { + case "defimpl": + implCount++ + case "defprotocol": + isProtocol = true + moduleDef = &modResults[i] + case "module": + if moduleDef == nil { + moduleDef = &modResults[i] + } + } + } + if moduleDef == nil { + return textResult(fmt.Sprintf("Module %s is not in the index. Use dexter_search to find the right name, or dexter_reindex if the module was just created.", module)), nil, nil + } + + var b strings.Builder + kind := "module" + if isProtocol { + kind = "protocol" + } + fmt.Fprintf(&b, "%s %s - %s:%d\n", kind, module, h.relPath(moduleDef.FilePath), moduleDef.Line) + if isProtocol && implCount > 0 { + fmt.Fprintf(&b, "%d defimpl implementation(s); list them with dexter_implementations.\n", implCount) + } + + if moduledoc := h.extractModuledoc(moduleDef.FilePath, moduleDef.Line); moduledoc != "" { + fmt.Fprintf(&b, "\n%s\n", strings.TrimRight(moduledoc, "\n")) + } + + funcs, err := h.store.ListModuleFunctions(module, !args.IncludePrivate) + if err != nil { + return nil, nil, fmt.Errorf("listing functions: %w", err) + } + callbacks, err := h.store.ListModuleCallbacks(module) + if err != nil { + return nil, nil, fmt.Errorf("listing callbacks: %w", err) + } + + // Bucket by section, preserving store order (name, arity). + sections := map[string][]store.CompletionResult{} + for _, f := range funcs { + sections[sectionFor(f.Kind)] = append(sections[sectionFor(f.Kind)], f) + } + + docs := h.newDocExtractor() + writeSection := func(title string, entries []store.CompletionResult) { + if len(entries) == 0 { + return + } + fmt.Fprintf(&b, "\n%s:\n", title) + for _, e := range entries { + sig := fmt.Sprintf("%s/%d", e.Function, e.Arity) + if e.Params != "" { + sig = fmt.Sprintf("%s(%s)", e.Function, e.Params) + } + line := fmt.Sprintf(" %s [%s:%d]", sig, h.relPath(e.FilePath), e.Line) + if e.Kind == "defdelegate" { + if target := h.delegateTarget(module, e.Function, e.Arity); target != "" { + line += " → " + target + } + } + if doc := docs.docFor(e.FilePath, e.Line); doc != "" { + line += "\n " + doc + } + b.WriteString(line + "\n") + } + } + + writeSection("Functions", sections["functions"]) + writeSection("Macros", sections["macros"]) + writeSection("Guards", sections["guards"]) + writeSection("Delegates", sections["delegates"]) + writeSection("Types", sections["types"]) + writeSection("Private functions", sections["private"]) + writeSection("Callbacks (this module is a behaviour)", callbacks) + + if subs, err := h.store.ListSubmodules(module); err == nil && len(subs) > 0 { + fmt.Fprintf(&b, "\nSubmodules (%d):\n", len(subs)) + const maxSubs = 20 + for i, s := range subs { + if i == maxSubs { + fmt.Fprintf(&b, " ... and %d more\n", len(subs)-maxSubs) + break + } + fmt.Fprintf(&b, " %s\n", s) + } + } + + if len(funcs) == 0 && len(callbacks) == 0 { + fmt.Fprintf(&b, "\nNo functions indexed for this module.\n") + } + return textResult(b.String()), nil, nil +} + +func sectionFor(kind string) string { + switch kind { + case "defmacro": + return "macros" + case "defguard": + return "guards" + case "defdelegate": + return "delegates" + case "type", "opaque": + return "types" + case "defp", "defmacrop", "defguardp": + return "private" + default: + return "functions" + } +} + +// delegateTarget renders "Target.function" for a defdelegate entry. +func (h *Handler) delegateTarget(module, function string, arity int) string { + results, err := h.store.LookupFunction(module, function) + if err != nil { + return "" + } + for _, r := range results { + if r.Kind == "defdelegate" && r.Arity == arity && r.DelegateTo != "" { + target := r.DelegateTo + "." + function + if r.DelegateAs != "" { + target = r.DelegateTo + "." + r.DelegateAs + } + return target + } + } + return "" +} + +func (h *Handler) extractModuledoc(filePath string, defLine int) string { + text, _, ok := h.lsp.ReadFileText(filePath) + if !ok { + return "" + } + return lsp.NewTokenizedFile(text).ExtractModuledoc(defLine - 1) +} + +// docExtractor extracts @doc summaries, tokenizing each source file at most once. +type docExtractor struct { + h *Handler + files map[string]*lsp.TokenizedFile +} + +func (h *Handler) newDocExtractor() *docExtractor { + return &docExtractor{h: h, files: make(map[string]*lsp.TokenizedFile)} +} + +func (d *docExtractor) docFor(filePath string, defLine int) string { + tf, ok := d.files[filePath] + if !ok { + if text, _, found := d.h.lsp.ReadFileText(filePath); found { + tf = lsp.NewTokenizedFile(text) + } + d.files[filePath] = tf // cache nil results too + } + if tf == nil { + return "" + } + doc, _ := tf.ExtractDocAbove(defLine - 1) + return firstDocLine(doc) +} diff --git a/internal/mcp/references.go b/internal/mcp/references.go new file mode 100644 index 0000000..4f27213 --- /dev/null +++ b/internal/mcp/references.go @@ -0,0 +1,67 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ReferencesParams struct { + Module string `json:"module" jsonschema:"fully-qualified module name, e.g. MyApp.Accounts (aliases are not resolved)"` + Function string `json:"function,omitempty" jsonschema:"function name; omit to list references to the module itself (aliases, imports, uses, qualified calls)"` +} + +const maxReferenceLines = 100 + +func (h *Handler) referencesHandler(ctx context.Context, req *mcp.CallToolRequest, args ReferencesParams) (*mcp.CallToolResult, any, error) { + module := strings.TrimSpace(args.Module) + if module == "" { + return nil, nil, fmt.Errorf("module must not be empty") + } + function := strings.TrimSpace(args.Function) + + refs := h.lsp.CollectReferences(module, function) + if len(refs) == 0 { + target := module + if function != "" { + target = module + "." + function + } + return textResult(fmt.Sprintf("No references to %s found in the index. If files changed recently, call dexter_reindex first.", target)), nil, nil + } + + target := module + if function != "" { + target = module + "." + function + } + + var b strings.Builder + fmt.Fprintf(&b, "%d reference(s) to %s:\n", len(refs), target) + + written := 0 + files := 0 + var lastFile string + truncated := 0 + for _, r := range refs { + if written >= maxReferenceLines { + truncated++ + continue + } + if r.FilePath != lastFile { + fmt.Fprintf(&b, "\n%s\n", h.relPath(r.FilePath)) + lastFile = r.FilePath + files++ + } + srcLine := "" + if line, ok := h.lsp.FileLine(r.FilePath, r.Line); ok { + srcLine = strings.TrimSpace(line) + } + fmt.Fprintf(&b, " %d: %s\n", r.Line, srcLine) + written++ + } + if truncated > 0 { + fmt.Fprintf(&b, "\n... and %d more reference(s) not shown. Narrow the search (e.g. pass a function name) to see the rest.\n", truncated) + } + return textResult(b.String()), nil, nil +} diff --git a/internal/mcp/reindex.go b/internal/mcp/reindex.go new file mode 100644 index 0000000..663ed1b --- /dev/null +++ b/internal/mcp/reindex.go @@ -0,0 +1,24 @@ +package mcp + +import ( + "context" + "fmt" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/version" +) + +type ReindexParams struct{} + +func (h *Handler) reindexHandler(ctx context.Context, req *mcp.CallToolRequest, args ReindexParams) (*mcp.CallToolResult, any, error) { + // A version mismatch requires a full rebuild, which must not happen under a + // live store handle; that is handled at server startup instead. + if stored := h.store.GetIndexVersion(); stored != version.IndexVersion { + return textResult(fmt.Sprintf("Index version %d does not match this binary (%d). Restart dexter mcp to rebuild the index.", stored, version.IndexVersion)), nil, nil + } + + updated, elapsed := h.lsp.ReindexWorkspace() + return textResult(fmt.Sprintf("Reindexed %d file(s) in %s. The index is up to date.", updated, elapsed.Round(time.Millisecond))), nil, nil +} diff --git a/internal/mcp/search.go b/internal/mcp/search.go new file mode 100644 index 0000000..fc4801f --- /dev/null +++ b/internal/mcp/search.go @@ -0,0 +1,39 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type SearchParams struct { + Query string `json:"query" jsonschema:"fuzzy symbol query, e.g. 'Accounts.fetch' or 'fetch_user'"` + IncludeStdlib bool `json:"include_stdlib,omitempty" jsonschema:"also match Elixir stdlib symbols (default false)"` +} + +func (h *Handler) searchHandler(ctx context.Context, req *mcp.CallToolRequest, args SearchParams) (*mcp.CallToolResult, any, error) { + query := strings.TrimSpace(args.Query) + if query == "" { + return nil, nil, fmt.Errorf("query must not be empty") + } + + var exclude []string + if stdlibRoot := h.lsp.StdlibRoot(); !args.IncludeStdlib && stdlibRoot != "" { + exclude = append(exclude, stdlibRoot) + } + results, err := h.store.SearchSymbols(query, exclude...) + if err != nil { + return nil, nil, fmt.Errorf("searching symbols: %w", err) + } + if len(results) == 0 { + return textResult(fmt.Sprintf("No symbols matched %q. Try a shorter or less specific query; matching is fuzzy on module and function names.", query)), nil, nil + } + + var b strings.Builder + for _, r := range results { + fmt.Fprintf(&b, "%s (%s) - %s:%d\n", symbolName(r.Module, r.Function, r.Arity), r.Kind, h.relPath(r.FilePath), r.Line) + } + return textResult(b.String()), nil, nil +} diff --git a/internal/mcp/serve.go b/internal/mcp/serve.go new file mode 100644 index 0000000..d73146c --- /dev/null +++ b/internal/mcp/serve.go @@ -0,0 +1,20 @@ +package mcp + +import ( + "context" + "net/http" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// RunStdio serves MCP over stdin/stdout until ctx is canceled or the client +// disconnects. +func RunStdio(ctx context.Context, h *Handler) error { + return NewServer(h).Run(ctx, &mcp.StdioTransport{}) +} + +// HTTPHandler returns a streamable-HTTP handler serving MCP. Each session +// gets its own protocol server; they all share the Handler. +func HTTPHandler(h *Handler) http.Handler { + return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return NewServer(h) }, nil) +} diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go new file mode 100644 index 0000000..bdeabcc --- /dev/null +++ b/internal/mcp/tools_test.go @@ -0,0 +1,307 @@ +package mcp + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +const accountsSource = `defmodule MyApp.Accounts do + @moduledoc """ + The accounts context. + """ + + @doc """ + Fetches a user by id. + """ + @spec fetch_user(integer()) :: {:ok, map()} | {:error, :not_found} + def fetch_user(id) do + {:ok, %{id: id}} + end + + def list_users(opts) do + opts + end + + defp validate(id), do: id + + defdelegate create_user(attrs), to: MyApp.Accounts.Creator, as: :create + + @type user_id :: integer() +end +` + +const creatorSource = `defmodule MyApp.Accounts.Creator do + def create(attrs) do + attrs + end +end +` + +const workerSource = `defmodule MyApp.Worker do + def run do + MyApp.Accounts.fetch_user(1) + end + + def run_all do + MyApp.Accounts.list_users([]) + MyApp.Accounts.fetch_user(2) + end +end +` + +func setupProject(t *testing.T) *testEnv { + t.Helper() + e := setupTestEnv(t) + e.indexFile("mix.exs", "defmodule MyApp.MixProject do\nend\n") + e.indexFile("lib/my_app/accounts.ex", accountsSource) + e.indexFile("lib/my_app/accounts/creator.ex", creatorSource) + e.indexFile("lib/my_app/worker.ex", workerSource) + return e +} + +func TestWorkspaceTool(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_workspace", nil) + wantContains(t, out, + "Project root: "+e.root, + "mix.exs", + "definitions", + "references", + ) +} + +func TestSearchTool(t *testing.T) { + e := setupProject(t) + + out := e.callTool("dexter_search", map[string]any{"query": "fetch_user"}) + wantContains(t, out, "MyApp.Accounts.fetch_user/1", "lib/my_app/accounts.ex") + + out = e.callTool("dexter_search", map[string]any{"query": "zzz_nothing_matches"}) + wantContains(t, out, "No symbols matched") + + errText := e.callToolExpectError("dexter_search", map[string]any{"query": " "}) + wantContains(t, errText, "query must not be empty") +} + +func TestDefinitionTool_Function(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_definition", map[string]any{"module": "MyApp.Accounts", "function": "fetch_user"}) + wantContains(t, out, + "MyApp.Accounts.fetch_user/1 (def)", + "lib/my_app/accounts.ex:10", + "@spec fetch_user(integer())", + "Fetches a user by id.", + "def fetch_user(id) do", + ) +} + +func TestDefinitionTool_FollowsDelegate(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_definition", map[string]any{"module": "MyApp.Accounts", "function": "create_user"}) + wantContains(t, out, + "(defdelegate)", + "Delegates to MyApp.Accounts.Creator.create", + "lib/my_app/accounts/creator.ex", + ) +} + +func TestDefinitionTool_Module(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_definition", map[string]any{"module": "MyApp.Accounts"}) + wantContains(t, out, + "defmodule MyApp.Accounts - lib/my_app/accounts.ex:1", + "The accounts context.", + ) +} + +func TestDefinitionTool_NotFound(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_definition", map[string]any{"module": "MyApp.Missing"}) + wantContains(t, out, "not in the index") +} + +func TestReferencesTool(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_references", map[string]any{"module": "MyApp.Accounts", "function": "fetch_user"}) + wantContains(t, out, + "reference(s) to MyApp.Accounts.fetch_user", + "lib/my_app/worker.ex", + "MyApp.Accounts.fetch_user(1)", + "MyApp.Accounts.fetch_user(2)", + ) +} + +func TestReferencesTool_DelegateFacade(t *testing.T) { + e := setupProject(t) + // Calls to the facade MyApp.Accounts.create_user should count as + // references to the delegate target Creator.create. + e.indexFile("lib/my_app/caller.ex", `defmodule MyApp.Caller do + def go(attrs) do + MyApp.Accounts.create_user(attrs) + end +end +`) + out := e.callTool("dexter_references", map[string]any{"module": "MyApp.Accounts.Creator", "function": "create"}) + wantContains(t, out, "lib/my_app/caller.ex") +} + +func TestReferencesTool_Truncation(t *testing.T) { + e := setupProject(t) + var b strings.Builder + b.WriteString("defmodule MyApp.Spammy do\n def go do\n") + for i := 0; i < maxReferenceLines+20; i++ { + fmt.Fprintf(&b, " MyApp.Accounts.list_users(%d)\n", i) + } + b.WriteString(" end\nend\n") + e.indexFile("lib/my_app/spammy.ex", b.String()) + + out := e.callTool("dexter_references", map[string]any{"module": "MyApp.Accounts", "function": "list_users"}) + wantContains(t, out, "more reference(s) not shown") +} + +func TestModuleAPITool(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_module_api", map[string]any{"module": "MyApp.Accounts"}) + wantContains(t, out, + "module MyApp.Accounts - lib/my_app/accounts.ex:1", + "The accounts context.", + "Functions:", + "fetch_user(id)", + "Fetches a user by id.", + "Delegates:", + "create_user(attrs)", + "→ MyApp.Accounts.Creator.create", + "Types:", + "user_id/0", + "Submodules", + "Creator", + ) + wantNotContains(t, out, "validate") + + out = e.callTool("dexter_module_api", map[string]any{"module": "MyApp.Accounts", "include_private": true}) + wantContains(t, out, "validate") +} + +func TestFileOutlineTool(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_file_outline", map[string]any{"file": "lib/my_app/accounts.ex"}) + wantContains(t, out, + "defmodule MyApp.Accounts (line 1)", + "def fetch_user/1", + "defp validate/1", + "defdelegate create_user/1", + "→ MyApp.Accounts.Creator.create", + "@type user_id/0", + ) + + out = e.callTool("dexter_file_outline", map[string]any{"file": "lib/nope.ex"}) + wantContains(t, out, "File not found") +} + +func TestFileOutlineTool_NestedModules(t *testing.T) { + e := setupProject(t) + e.indexFile("lib/my_app/outer.ex", `defmodule MyApp.Outer do + def outer_fun, do: :ok + + defmodule Inner do + def inner_fun, do: :ok + end +end +`) + out := e.callTool("dexter_file_outline", map[string]any{"file": "lib/my_app/outer.ex"}) + wantContains(t, out, + "defmodule MyApp.Outer (line 1)", + "defmodule MyApp.Outer.Inner (line 4)", + "def inner_fun/0", + ) +} + +func TestImplementationsTool_Behaviour(t *testing.T) { + e := setupProject(t) + e.indexFile("lib/my_app/notifier.ex", `defmodule MyApp.Notifier do + @callback deliver(map()) :: :ok | {:error, term()} +end +`) + e.indexFile("lib/my_app/email_notifier.ex", `defmodule MyApp.EmailNotifier do + @behaviour MyApp.Notifier + + @impl true + def deliver(msg) do + :ok + end +end +`) + out := e.callTool("dexter_implementations", map[string]any{"module": "MyApp.Notifier"}) + wantContains(t, out, + "Modules implementing behaviour MyApp.Notifier", + "MyApp.EmailNotifier", + "@callback deliver/1", + ) + + out = e.callTool("dexter_implementations", map[string]any{"module": "MyApp.Notifier", "function": "deliver"}) + wantContains(t, out, + "Implementations of callback MyApp.Notifier.deliver", + "MyApp.EmailNotifier.deliver/1", + "lib/my_app/email_notifier.ex", + ) +} + +func TestImplementationsTool_Protocol(t *testing.T) { + e := setupProject(t) + e.indexFile("lib/my_app/size.ex", `defprotocol MyApp.Size do + def size(data) +end +`) + e.indexFile("lib/my_app/size_impls.ex", `defimpl MyApp.Size, for: BitString do + def size(binary), do: byte_size(binary) +end + +defimpl MyApp.Size, for: Map do + def size(map), do: map_size(map) +end +`) + out := e.callTool("dexter_implementations", map[string]any{"module": "MyApp.Size"}) + wantContains(t, out, + "MyApp.Size is a protocol", + "Implementations (2)", + "lib/my_app/size_impls.ex:1", + "lib/my_app/size_impls.ex:5", + ) +} + +func TestCallHierarchyTool(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_call_hierarchy", map[string]any{"module": "MyApp.Accounts", "function": "fetch_user"}) + wantContains(t, out, + "Call hierarchy for MyApp.Accounts.fetch_user", + "Incoming (callers)", + "MyApp.Worker.run/0", + "lib/my_app/worker.ex", + ) + + out = e.callTool("dexter_call_hierarchy", map[string]any{"module": "MyApp.Worker", "function": "run", "direction": "outgoing"}) + wantContains(t, out, "Outgoing (callees)", "MyApp.Accounts.fetch_user") + wantNotContains(t, out, "Incoming") + + errText := e.callToolExpectError("dexter_call_hierarchy", map[string]any{"module": "MyApp.Worker", "function": "run", "direction": "sideways"}) + wantContains(t, errText, "direction must be") +} + +func TestReindexTool(t *testing.T) { + e := setupProject(t) + + // Write a new file WITHOUT indexing it: the tool must pick it up. + path := filepath.Join(e.root, "lib/my_app/fresh.ex") + if err := os.WriteFile(path, []byte("defmodule MyApp.Fresh do\n def new_fun, do: :ok\nend\n"), 0644); err != nil { + t.Fatal(err) + } + + out := e.callTool("dexter_reindex", nil) + wantContains(t, out, "Reindexed 1 file(s)") + + out = e.callTool("dexter_search", map[string]any{"query": "new_fun"}) + wantContains(t, out, "MyApp.Fresh.new_fun/0") +} diff --git a/internal/mcp/workspace.go b/internal/mcp/workspace.go new file mode 100644 index 0000000..bfb1644 --- /dev/null +++ b/internal/mcp/workspace.go @@ -0,0 +1,82 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/version" +) + +type WorkspaceParams struct{} + +func (h *Handler) workspaceHandler(ctx context.Context, req *mcp.CallToolRequest, args WorkspaceParams) (*mcp.CallToolResult, any, error) { + var b strings.Builder + fmt.Fprintf(&b, "Dexter %s\n", version.Version) + fmt.Fprintf(&b, "Project root: %s\n", h.projectRoot) + + if projects := findMixProjects(h.projectRoot); len(projects) > 0 { + fmt.Fprintf(&b, "\nMix projects:\n") + for _, p := range projects { + fmt.Fprintf(&b, " %s\n", p) + } + } else { + fmt.Fprintf(&b, "\nNo mix.exs found at the project root. The index may cover a plain directory of Elixir files.\n") + } + + if stdlibRoot := h.lsp.StdlibRoot(); stdlibRoot != "" { + fmt.Fprintf(&b, "\nElixir stdlib: %s (indexed; stdlib symbols resolve in lookups)\n", stdlibRoot) + } else { + fmt.Fprintf(&b, "\nElixir stdlib: not detected. Set DEXTER_ELIXIR_LIB_ROOT to enable stdlib lookups.\n") + } + + st, err := h.store.Stats() + if err != nil { + return nil, nil, fmt.Errorf("reading index stats: %w", err) + } + fmt.Fprintf(&b, "\nIndex: %d files, %d definitions, %d references\n", st.Files, st.Definitions, st.References) + + if stored := h.store.GetIndexVersion(); stored != version.IndexVersion { + fmt.Fprintf(&b, "WARNING: index version %d does not match this binary (%d). Restart dexter mcp to rebuild.\n", stored, version.IndexVersion) + } + fmt.Fprintf(&b, "\nThe index updates automatically on git branch switches. After you edit, create, or delete Elixir files, call dexter_reindex before trusting lookups.\n") + + return textResult(b.String()), nil, nil +} + +// findMixProjects lists mix.exs locations relative to root: the root itself, +// umbrella apps under apps/, and direct children with their own mix.exs. +// The scan is deliberately shallow; no full tree walk. +func findMixProjects(root string) []string { + var projects []string + if _, err := os.Stat(filepath.Join(root, "mix.exs")); err == nil { + projects = append(projects, "mix.exs") + } + for _, pattern := range []string{"apps/*/mix.exs", "*/mix.exs"} { + matches, _ := filepath.Glob(filepath.Join(root, pattern)) + for _, m := range matches { + if rel, err := filepath.Rel(root, m); err == nil && rel != "mix.exs" { + projects = append(projects, rel) + } + } + } + sort.Strings(projects) + return dedupeStrings(projects) +} + +func dedupeStrings(in []string) []string { + out := in[:0] + var prev string + for i, s := range in { + if i == 0 || s != prev { + out = append(out, s) + } + prev = s + } + return out +} diff --git a/internal/store/store.go b/internal/store/store.go index 61335fc..f210137 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -677,6 +677,54 @@ func (s *Store) ListModuleFunctions(module string, publicOnly bool) ([]Completio return results, rows.Err() } +// ListModuleCallbacks returns the @callback and @macrocallback definitions of +// the given behaviour module (these are excluded from ListModuleFunctions). +func (s *Store) ListModuleCallbacks(module string) ([]CompletionResult, error) { + rows, err := s.db.Query( + "SELECT module, function, arity, kind, file_path, line, params FROM definitions WHERE module = ? AND kind IN ('callback', 'macrocallback') GROUP BY function, arity ORDER BY function, arity LIMIT 100", + module, + ) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + var results []CompletionResult + for rows.Next() { + var r CompletionResult + if err := rows.Scan(&r.Module, &r.Function, &r.Arity, &r.Kind, &r.FilePath, &r.Line, &r.Params); err != nil { + return nil, err + } + results = append(results, r) + } + return results, rows.Err() +} + +// IndexStats summarizes the size of the index. +type IndexStats struct { + Files int + Definitions int + References int +} + +// Stats returns row counts for the files, definitions, and refs tables. +func (s *Store) Stats() (IndexStats, error) { + var st IndexStats + for _, q := range []struct { + query string + dst *int + }{ + {"SELECT COUNT(*) FROM files", &st.Files}, + {"SELECT COUNT(*) FROM definitions", &st.Definitions}, + {"SELECT COUNT(*) FROM refs", &st.References}, + } { + if err := s.db.QueryRow(q.query).Scan(q.dst); err != nil { + return IndexStats{}, err + } + } + return st, nil +} + type LookupResult struct { Module string // populated by bulk queries; empty for single-module lookups FilePath string diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 5c9efa0..20b4cf9 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1313,3 +1313,89 @@ func TestFindProjectRoot(t *testing.T) { } }) } + +func TestListModuleCallbacks(t *testing.T) { + s, dir := setupTestStore(t) + defer func() { _ = s.Close() }() + + path := writeElixirFile(t, dir, "lib/notifier.ex", `defmodule MyApp.Notifier do + @callback deliver(map()) :: :ok | {:error, term()} + @callback name() :: String.t() + @macrocallback render(term()) :: Macro.t() + + def dispatch(msg) do + :ok + end +end +`) + + defs, _, err := parser.ParseFile(path) + if err != nil { + t.Fatal(err) + } + if err := s.IndexFile(path, defs); err != nil { + t.Fatal(err) + } + + results, err := s.ListModuleCallbacks("MyApp.Notifier") + if err != nil { + t.Fatal(err) + } + if len(results) != 3 { + t.Fatalf("expected 3 callbacks, got %d: %+v", len(results), results) + } + kinds := map[string]string{} + for _, r := range results { + kinds[r.Function] = r.Kind + if r.Function == "dispatch" { + t.Error("regular function included in callbacks") + } + } + if kinds["deliver"] != "callback" { + t.Errorf("deliver kind = %q, want callback", kinds["deliver"]) + } + if kinds["render"] != "macrocallback" { + t.Errorf("render kind = %q, want macrocallback", kinds["render"]) + } +} + +func TestStats(t *testing.T) { + s, dir := setupTestStore(t) + defer func() { _ = s.Close() }() + + st, err := s.Stats() + if err != nil { + t.Fatal(err) + } + if st.Files != 0 || st.Definitions != 0 || st.References != 0 { + t.Errorf("empty store stats = %+v, want zeros", st) + } + + path := writeElixirFile(t, dir, "lib/worker.ex", `defmodule MyApp.Worker do + def run do + MyApp.Accounts.fetch_user(1) + end +end +`) + defs, refs, err := parser.ParseFile(path) + if err != nil { + t.Fatal(err) + } + if err := s.IndexFileWithRefs(path, defs, refs); err != nil { + t.Fatal(err) + } + + st, err = s.Stats() + if err != nil { + t.Fatal(err) + } + if st.Files != 1 { + t.Errorf("Files = %d, want 1", st.Files) + } + if st.Definitions < 2 { // module + run + t.Errorf("Definitions = %d, want >= 2", st.Definitions) + } + if st.References < 1 { + t.Errorf("References = %d, want >= 1", st.References) + } +} From 012d62a0cdc9883ff50eb7aabc3cd894f7840731 Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Tue, 1 Sep 2026 14:22:55 +1000 Subject: [PATCH 02/17] Watch files in MCP mode (fsnotify) Headless MCP servers get no editor LSP events, so lookups went stale until an agent chose to call dexter_reindex. Watch the project tree with fsnotify instead: file writes reindex the changed file (debounced), deletes drop entries (including whole directories), and new directories are watched and indexed as they appear. deps/, _build/, node_modules, .git, and .dexter are not watched; deps change only through mix and are covered by the startup reindex. On watcher overflow the workspace is reindexed incrementally; if watching is unavailable the server logs a warning and degrades to branch-switch detection plus dexter_reindex. --- CHANGELOG.md | 2 +- README.md | 2 +- cmd/main.go | 12 +++ go.mod | 1 + go.sum | 2 + internal/mcp/instructions.md | 6 +- internal/mcp/mcp.go | 2 +- internal/mcp/watch.go | 189 +++++++++++++++++++++++++++++++++++ internal/mcp/watch_test.go | 153 ++++++++++++++++++++++++++++ internal/mcp/workspace.go | 2 +- 10 files changed, 364 insertions(+), 7 deletions(-) create mode 100644 internal/mcp/watch.go create mode 100644 internal/mcp/watch_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index eaae6b2..7a9408a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Nine tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, and incremental reindexing. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide +- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Nine tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, and incremental reindexing. The headless server watches the project tree (fsnotify) so the index stays fresh without editor events. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide ## [0.7.1] - 2026-06-12 diff --git a/README.md b/README.md index b2e835b..7458916 100644 --- a/README.md +++ b/README.md @@ -464,7 +464,7 @@ Register it with your MCP client. For Claude Code: claude mcp add dexter -- dexter mcp ``` -Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server indexes the project on first use, keeps the index fresh across git branch switches, and exposes a `dexter_reindex` tool for agents to call after editing files. +Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server indexes the project on first use and keeps the index fresh by watching the project tree (fsnotify) and detecting git branch switches; a `dexter_reindex` tool forces an immediate update if a lookup ever seems stale. Useful variants: diff --git a/cmd/main.go b/cmd/main.go index 3252d15..d709ebf 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -636,6 +636,18 @@ func cmdMCP(projectRoot string, listen string) { server.ReindexWorkspace() server.WatchGitHead() + // Headless servers get no editor events, so watch the tree directly. + watcher, err := dexter_mcp.WatchFiles(s, projectRoot, func() { server.ReindexWorkspace() }) + if err != nil { + log.Printf("Warning: file watching unavailable (%v); the index updates on branch switches and via dexter_reindex", err) + } else { + defer func() { + if err := watcher.Close(); err != nil { + log.Printf("Warning: closing file watcher: %v", err) + } + }() + } + h := dexter_mcp.NewHandler(dexter_mcp.Config{LSP: server, Store: s, ProjectRoot: projectRoot}) log.Printf("Dexter MCP v%s starting (root: %s)", version.Version, projectRoot) diff --git a/go.mod b/go.mod index 94ffde1..c5ae6fd 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/remoteoss/dexter go 1.26.1 require ( + github.com/fsnotify/fsnotify v1.10.1 github.com/mattn/go-sqlite3 v1.14.38 github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 6a99e0e..c8a4d7c 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/elixir-lang/tree-sitter-elixir v0.3.5 h1:Ir60dE/aHPt80uil58ukW1CTC+15l4jHax/iHBsW9HI= github.com/elixir-lang/tree-sitter-elixir v0.3.5/go.mod h1:wNBVf64kzvhSbZ8ojVtBF1jRiqGY0lsuK5Kx/60s6Z0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= diff --git a/internal/mcp/instructions.md b/internal/mcp/instructions.md index a515005..e4de50e 100644 --- a/internal/mcp/instructions.md +++ b/internal/mcp/instructions.md @@ -16,9 +16,9 @@ Which tool for which question: - What a specific file defines: `dexter_file_outline` - Project layout and index freshness: `dexter_workspace` -After you create, edit, or delete Elixir files by any means, call -`dexter_reindex` (fast, incremental) so results stay accurate. Git branch -switches are picked up automatically. +The index updates automatically: file changes are watched (fsnotify) and git +branch switches are detected. If a lookup ever seems stale, `dexter_reindex` +forces an immediate incremental update. Elixir specifics: modules are not tied to files (use `dexter_file_outline` for a file, `dexter_definition` for a module); pass fully-qualified module names, diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index f3bd22b..96be403 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -109,7 +109,7 @@ func NewServer(h *Handler) *mcp.Server { mcp.AddTool(srv, &mcp.Tool{ Name: "dexter_reindex", Annotations: &mcp.ToolAnnotations{DestructiveHint: new(bool), IdempotentHint: true, OpenWorldHint: new(bool)}, - Description: "Update dexter's index after creating, editing, or deleting Elixir files so lookups stay accurate. Incremental and fast; the only tool that writes, and it writes only dexter's own index database.", + Description: "Force an immediate incremental reindex. The index already updates automatically as files change; use this only when a lookup seems stale. The only tool that writes, and it writes only dexter's own index database.", }, h.reindexHandler) return srv diff --git a/internal/mcp/watch.go b/internal/mcp/watch.go new file mode 100644 index 0000000..e784908 --- /dev/null +++ b/internal/mcp/watch.go @@ -0,0 +1,189 @@ +package mcp + +import ( + "errors" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + + "github.com/remoteoss/dexter/internal/parser" + "github.com/remoteoss/dexter/internal/store" +) + +// debounceWindow batches bursts of filesystem events (editor saves, git +// operations) into one reindex pass. +const debounceWindow = 300 * time.Millisecond + +// Watcher keeps the index in sync with filesystem changes. Editors drive +// index updates through LSP events, but a headless MCP server gets none, so +// it watches the project tree directly (fsnotify). +type Watcher struct { + fsw *fsnotify.Watcher + store *store.Store + root string + resync func() // full incremental reindex, used when events were lost + wg sync.WaitGroup +} + +// WatchFiles watches projectRoot recursively and incrementally reindexes +// Elixir files as they change. resync is invoked when the watcher loses +// events (queue overflow) and the whole tree must be reconciled. Callers +// should treat an error as degraded service, not fatal: the index still +// updates on startup, on git branch switches, and via dexter_reindex. +func WatchFiles(s *store.Store, projectRoot string, resync func()) (*Watcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + w := &Watcher{fsw: fsw, store: s, root: projectRoot, resync: resync} + if err := w.watchTree(projectRoot); err != nil { + _ = fsw.Close() + return nil, err + } + w.wg.Add(1) + go w.loop() + return w, nil +} + +// Close stops the watcher and waits for the event loop to exit. +func (w *Watcher) Close() error { + err := w.fsw.Close() + w.wg.Wait() + return err +} + +// skipDir reports whether a directory's subtree is not watched: build output, +// VCS metadata, and deps, which change only through mix and are covered by +// the startup reindex. +func skipDir(name string) bool { + switch name { + case "_build", ".git", "node_modules", "deps", ".dexter": + return true + } + return false +} + +// watchTree adds watches for root and every eligible directory below it. +func (w *Watcher) watchTree(root string) error { + return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil + } + if skipDir(d.Name()) { + return filepath.SkipDir + } + return w.fsw.Add(path) + }) +} + +func (w *Watcher) loop() { + defer w.wg.Done() + + pending := make(map[string]struct{}) + var timer *time.Timer + var timerC <-chan time.Time + + schedule := func() { + if timer == nil { + timer = time.NewTimer(debounceWindow) + timerC = timer.C + } else { + timer.Reset(debounceWindow) + } + } + + for { + select { + case ev, ok := <-w.fsw.Events: + if !ok { + return + } + // Directory events matter for watch maintenance; file events only + // for Elixir sources. Everything else is noise. + if parser.IsElixirFile(ev.Name) || ev.Op.Has(fsnotify.Create) || ev.Op.Has(fsnotify.Remove) || ev.Op.Has(fsnotify.Rename) { + pending[ev.Name] = struct{}{} + schedule() + } + case err, ok := <-w.fsw.Errors: + if !ok { + return + } + if errors.Is(err, fsnotify.ErrEventOverflow) && w.resync != nil { + log.Printf("Warning: file watcher overflowed, reindexing workspace") + w.resync() + continue + } + log.Printf("Warning: file watcher: %v", err) + case <-timerC: + timer = nil + timerC = nil + batch := pending + pending = make(map[string]struct{}) + w.apply(batch) + } + } +} + +// apply reconciles the index with a batch of changed paths. +func (w *Watcher) apply(batch map[string]struct{}) { + for path := range batch { + info, err := os.Stat(path) + switch { + case err != nil: + w.removePath(path) + case info.IsDir(): + if skipDir(filepath.Base(path)) { + continue + } + // New directory (e.g. git checkout, mkdir && write): watch it and + // index any Elixir files already inside, since their create events + // may predate the watch. + if err := w.watchTree(path); err != nil { + log.Printf("Warning: watching %s: %v", path, err) + } + _ = parser.WalkElixirFiles(path, func(p string, _ fs.DirEntry) error { + w.reindexFile(p) + return nil + }) + case parser.IsElixirFile(path): + w.reindexFile(path) + } + } +} + +func (w *Watcher) reindexFile(path string) { + defs, refs, err := parser.ParseFile(path) + if err != nil { + log.Printf("Warning: %s: %v", path, err) + return + } + if err := w.store.IndexFileWithRefs(path, defs, refs); err != nil { + log.Printf("Warning: %s: %v", path, err) + } +} + +// removePath drops a deleted file from the index. A deleted path may have +// been a directory, so entries under it are dropped too. +func (w *Watcher) removePath(path string) { + _ = w.store.RemoveFile(path) + stored, err := w.store.ListFilePaths() + if err != nil { + return + } + prefix := path + string(os.PathSeparator) + var under []string + for _, p := range stored { + if strings.HasPrefix(p, prefix) { + under = append(under, p) + } + } + if len(under) > 0 { + _ = w.store.RemoveFiles(under) + } +} diff --git a/internal/mcp/watch_test.go b/internal/mcp/watch_test.go new file mode 100644 index 0000000..c37cbfc --- /dev/null +++ b/internal/mcp/watch_test.go @@ -0,0 +1,153 @@ +package mcp + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/remoteoss/dexter/internal/store" +) + +// eventually polls cond until it returns true or the deadline passes. +// Filesystem notification latency varies by platform, so watcher assertions +// must poll rather than sleep. +func eventually(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("timed out waiting for %s", what) +} + +func setupWatcher(t *testing.T) (*store.Store, string) { + t.Helper() + root := t.TempDir() + s, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + + w, err := WatchFiles(s, root, nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := w.Close(); err != nil { + t.Errorf("closing watcher: %v", err) + } + }) + return s, root +} + +func writeFile(t *testing.T, root, rel, content string) string { + t.Helper() + path := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func moduleIndexed(s *store.Store, module string) func() bool { + return func() bool { + results, err := s.LookupModule(module) + return err == nil && len(results) > 0 + } +} + +func TestWatcher_NewFileIndexed(t *testing.T) { + s, root := setupWatcher(t) + writeFile(t, root, "lib/fresh.ex", "defmodule MyApp.Fresh do\n def hello, do: :ok\nend\n") + eventually(t, "new file to be indexed", moduleIndexed(s, "MyApp.Fresh")) +} + +func TestWatcher_ModifiedFileReindexed(t *testing.T) { + s, root := setupWatcher(t) + path := writeFile(t, root, "lib/acc.ex", "defmodule MyApp.Acc do\n def old_fun, do: :ok\nend\n") + eventually(t, "initial index", func() bool { + r, _ := s.LookupFunction("MyApp.Acc", "old_fun") + return len(r) > 0 + }) + + if err := os.WriteFile(path, []byte("defmodule MyApp.Acc do\n def new_fun, do: :ok\nend\n"), 0644); err != nil { + t.Fatal(err) + } + eventually(t, "modified file to be reindexed", func() bool { + newR, _ := s.LookupFunction("MyApp.Acc", "new_fun") + oldR, _ := s.LookupFunction("MyApp.Acc", "old_fun") + return len(newR) > 0 && len(oldR) == 0 + }) +} + +func TestWatcher_DeletedFileRemoved(t *testing.T) { + s, root := setupWatcher(t) + path := writeFile(t, root, "lib/gone.ex", "defmodule MyApp.Gone do\nend\n") + eventually(t, "initial index", moduleIndexed(s, "MyApp.Gone")) + + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + eventually(t, "deleted file to leave the index", func() bool { + results, err := s.LookupModule("MyApp.Gone") + return err == nil && len(results) == 0 + }) +} + +func TestWatcher_DeletedDirectoryRemoved(t *testing.T) { + s, root := setupWatcher(t) + writeFile(t, root, "lib/sub/a.ex", "defmodule MyApp.Sub.A do\nend\n") + writeFile(t, root, "lib/sub/b.ex", "defmodule MyApp.Sub.B do\nend\n") + eventually(t, "initial index", func() bool { + a, _ := s.LookupModule("MyApp.Sub.A") + b, _ := s.LookupModule("MyApp.Sub.B") + return len(a) > 0 && len(b) > 0 + }) + + if err := os.RemoveAll(filepath.Join(root, "lib/sub")); err != nil { + t.Fatal(err) + } + eventually(t, "deleted directory's files to leave the index", func() bool { + a, _ := s.LookupModule("MyApp.Sub.A") + b, _ := s.LookupModule("MyApp.Sub.B") + return len(a) == 0 && len(b) == 0 + }) +} + +func TestWatcher_NewDirectoryWatched(t *testing.T) { + s, root := setupWatcher(t) + // Create the directory and its file separately so the file event can only + // be seen by a watch added after the directory appeared. + if err := os.MkdirAll(filepath.Join(root, "lib/newdir"), 0755); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + writeFile(t, root, "lib/newdir/mod.ex", "defmodule MyApp.NewDir.Mod do\nend\n") + eventually(t, "file in new directory to be indexed", moduleIndexed(s, "MyApp.NewDir.Mod")) +} + +func TestWatcher_SkipsDepsAndNonElixir(t *testing.T) { + s, root := setupWatcher(t) + writeFile(t, root, "deps/pkg/lib/dep.ex", "defmodule DepPkg.Ignored do\nend\n") + writeFile(t, root, "lib/notes.txt", "defmodule NotElixir do\nend\n") + + // Anchor on a real file so the negative checks below observe a watcher + // that has demonstrably processed events. + writeFile(t, root, "lib/anchor.ex", "defmodule MyApp.Anchor do\nend\n") + eventually(t, "anchor file to be indexed", moduleIndexed(s, "MyApp.Anchor")) + + if r, _ := s.LookupModule("DepPkg.Ignored"); len(r) != 0 { + t.Error("file under deps/ was indexed by the watcher") + } + if r, _ := s.LookupModule("NotElixir"); len(r) != 0 { + t.Error("non-Elixir file was indexed") + } +} diff --git a/internal/mcp/workspace.go b/internal/mcp/workspace.go index bfb1644..9e41e74 100644 --- a/internal/mcp/workspace.go +++ b/internal/mcp/workspace.go @@ -44,7 +44,7 @@ func (h *Handler) workspaceHandler(ctx context.Context, req *mcp.CallToolRequest if stored := h.store.GetIndexVersion(); stored != version.IndexVersion { fmt.Fprintf(&b, "WARNING: index version %d does not match this binary (%d). Restart dexter mcp to rebuild.\n", stored, version.IndexVersion) } - fmt.Fprintf(&b, "\nThe index updates automatically on git branch switches. After you edit, create, or delete Elixir files, call dexter_reindex before trusting lookups.\n") + fmt.Fprintf(&b, "\nThe index updates automatically as files change and on git branch switches; dexter_reindex forces an immediate update.\n") return textResult(b.String()), nil, nil } From 811a3f185f0a43bb7cd29db7eb5931c1c962fdaa Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Tue, 1 Sep 2026 16:11:48 +1000 Subject: [PATCH 03/17] Add dexter_rename_symbol MCP tool Workspace-wide rename of a module or function with the same on-disk semantics as the editor rename: changes are written to disk, files following the naming convention are moved, and the index is updated. The tool reports every file changed and moved; git provides review and revert. The exported RenameFunction/RenameModule wrappers carry the same validation as the LSP handler and reuse its machinery unchanged, except that edits the LSP would hand to an editor as TextEdits (open buffers in attached mode) are also written to disk, since an MCP caller has no editor to deliver them to. --- CHANGELOG.md | 2 +- README.md | 2 +- integration_test.go | 4 +- internal/lsp/api.go | 115 +++++++++++++++++++++++++++++++++++ internal/lsp/api_test.go | 94 ++++++++++++++++++++++++++++ internal/lsp/server.go | 49 ++++++++++++--- internal/mcp/instructions.md | 2 + internal/mcp/mcp.go | 6 ++ internal/mcp/mcp_test.go | 3 +- internal/mcp/rename.go | 55 +++++++++++++++++ internal/mcp/rename_test.go | 87 ++++++++++++++++++++++++++ 11 files changed, 404 insertions(+), 15 deletions(-) create mode 100644 internal/lsp/api_test.go create mode 100644 internal/mcp/rename.go create mode 100644 internal/mcp/rename_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9408a..0129612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Nine tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, and incremental reindexing. The headless server watches the project tree (fsnotify) so the index stays fresh without editor events. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide +- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Ten tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, incremental reindexing, and workspace-wide rename with the same on-disk semantics as the editor rename. The headless server watches the project tree (fsnotify) so the index stays fresh without editor events. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide ## [0.7.1] - 2026-06-12 diff --git a/README.md b/README.md index 7458916..25625a6 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ When running as an LSP server, dexter automatically: ## MCP server -Dexter includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) server, modeled on `gopls mcp`, so AI agents can navigate Elixir codebases through the index instead of grep. Tools cover symbol search, definitions with docs and specs, references, module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, and incremental reindexing. +Dexter includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) server, modeled on `gopls mcp`, so AI agents can navigate Elixir codebases through the index instead of grep. Tools cover symbol search, definitions with docs and specs, references, module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, incremental reindexing, and workspace-wide rename. Register it with your MCP client. For Claude Code: diff --git a/integration_test.go b/integration_test.go index a3328a0..5c8e023 100644 --- a/integration_test.go +++ b/integration_test.go @@ -615,7 +615,7 @@ func TestIntegration_MCPStdio(t *testing.T) { for _, tool := range tools.Tools { names[tool.Name] = true } - for _, want := range []string{"dexter_workspace", "dexter_search", "dexter_definition", "dexter_references", "dexter_module_api", "dexter_file_outline", "dexter_implementations", "dexter_call_hierarchy", "dexter_reindex"} { + for _, want := range []string{"dexter_workspace", "dexter_search", "dexter_definition", "dexter_references", "dexter_module_api", "dexter_file_outline", "dexter_implementations", "dexter_call_hierarchy", "dexter_reindex", "dexter_rename_symbol"} { if !names[want] { t.Errorf("tool %s not advertised; got %v", want, names) } @@ -664,7 +664,7 @@ func TestIntegration_MCPStdio_EmptyIndexBuildsOnStartup(t *testing.T) { func TestIntegration_MCPInstructions(t *testing.T) { binary := buildDexter(t) out := runDexter(t, binary, t.TempDir(), "mcp", "--instructions") - for _, want := range []string{"dexter_workspace", "dexter_reindex"} { + for _, want := range []string{"dexter_workspace", "dexter_reindex", "dexter_rename_symbol"} { if !strings.Contains(out, want) { t.Errorf("instructions missing %q", want) } diff --git a/internal/lsp/api.go b/internal/lsp/api.go index 433f199..2729369 100644 --- a/internal/lsp/api.go +++ b/internal/lsp/api.go @@ -2,7 +2,9 @@ package lsp import ( "context" + "fmt" "io" + "os" "sort" "strings" @@ -112,3 +114,116 @@ func (s *Server) CollectReferences(module, function string) []store.ReferenceRes }) return out } + +// RenameSummary reports what a rename changed on disk. +type RenameSummary struct { + FilesChanged []string + FilesMoved map[string]string // old path → new path (conventional module renames) +} + +// RenameFunction renames module.functionName to newName across the workspace, +// writing every change to disk. It performs the same validation as the LSP +// rename. Edits the LSP path would hand to an editor as TextEdits (open +// buffers in attached mode) are written to disk too, since an MCP caller has +// no editor to deliver them to. +func (s *Server) RenameFunction(module, functionName, newName string) (RenameSummary, error) { + if !isValidFunctionName(newName) { + return RenameSummary{}, fmt.Errorf("invalid function name %q: must match [a-z_][a-z0-9_?!]*", newName) + } + defs, err := s.store.LookupFunction(module, functionName) + if err != nil { + return RenameSummary{}, err + } + if len(defs) == 0 { + return RenameSummary{}, fmt.Errorf("function %s.%s not found in the index", module, functionName) + } + if existing, err := s.store.LookupFunction(module, newName); err == nil && len(existing) > 0 { + return RenameSummary{}, fmt.Errorf("function %s.%s already exists", module, newName) + } + + edit, files, err := s.renameFunctionEdits(module, functionName, newName) + if err != nil { + return RenameSummary{}, err + } + if err := s.writeEditsToDisk(edit); err != nil { + return RenameSummary{}, err + } + return RenameSummary{FilesChanged: files}, nil +} + +// RenameModule renames oldModule (and its submodules) to newModule across the +// workspace, writing changes and conventional file moves to disk. +func (s *Server) RenameModule(oldModule, newModule string) (RenameSummary, error) { + if !isValidModuleName(newModule) { + return RenameSummary{}, fmt.Errorf("invalid module name %q: must be CamelCase segments separated by dots", newModule) + } + if defs, err := s.store.LookupModule(oldModule); err != nil || len(defs) == 0 { + if err != nil { + return RenameSummary{}, err + } + return RenameSummary{}, fmt.Errorf("module %s not found in the index", oldModule) + } + + edit, moved, files, err := s.renameModuleEdits(context.Background(), oldModule, newModule, "") + if err != nil { + return RenameSummary{}, err + } + if err := s.writeEditsToDisk(edit); err != nil { + return RenameSummary{}, err + } + return RenameSummary{FilesChanged: files, FilesMoved: moved}, nil +} + +// writeEditsToDisk applies a WorkspaceEdit's TextEdits to their files on disk +// and reindexes them. The rename machinery only produces TextEdits for open +// editor buffers; headless servers have none, so this is usually a no-op. +func (s *Server) writeEditsToDisk(edit *protocol.WorkspaceEdit) error { + if edit == nil { + return nil + } + for docURI, edits := range edit.Changes { + path := uriToPath(docURI) + text, _, ok := s.ReadFileText(path) + if !ok { + return fmt.Errorf("reading %s to apply rename edits", path) + } + if err := os.WriteFile(path, []byte(applyTextEdits(text, edits)), 0644); err != nil { + return err + } + } + if len(edit.Changes) > 0 { + paths := make([]string, 0, len(edit.Changes)) + for docURI := range edit.Changes { + paths = append(paths, uriToPath(docURI)) + } + s.reindexPaths(paths) + } + return nil +} + +// applyTextEdits applies non-overlapping TextEdits to text. Positions use the +// same line/byte-column convention the rename machinery produces them in. +func applyTextEdits(text string, edits []protocol.TextEdit) string { + sorted := make([]protocol.TextEdit, len(edits)) + copy(sorted, edits) + sort.Slice(sorted, func(i, j int) bool { + a, b := sorted[i].Range.Start, sorted[j].Range.Start + if a.Line != b.Line { + return a.Line > b.Line + } + return a.Character > b.Character + }) + + lines := strings.Split(text, "\n") + for _, e := range sorted { + start, end := e.Range.Start, e.Range.End + if int(start.Line) >= len(lines) || int(end.Line) >= len(lines) { + continue + } + prefix := lines[start.Line][:start.Character] + suffix := lines[end.Line][end.Character:] + replacement := strings.Split(prefix+e.NewText+suffix, "\n") + lines = append(lines[:start.Line], append(replacement, lines[end.Line+1:]...)...) + } + return strings.Join(lines, "\n") +} diff --git a/internal/lsp/api_test.go b/internal/lsp/api_test.go new file mode 100644 index 0000000..f34ea79 --- /dev/null +++ b/internal/lsp/api_test.go @@ -0,0 +1,94 @@ +package lsp + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +func TestApplyTextEdits(t *testing.T) { + tests := []struct { + name string + text string + edits []protocol.TextEdit + want string + }{ + { + name: "single token on one line", + text: "def fetch_user(id) do\n fetch_user(id)\nend\n", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: protocol.Position{Line: 0, Character: 4}, End: protocol.Position{Line: 0, Character: 14}}, NewText: "get_user"}, + }, + want: "def get_user(id) do\n fetch_user(id)\nend\n", + }, + { + name: "two tokens on the same line applied right to left", + text: "fetch_user(fetch_user(1))\n", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: protocol.Position{Line: 0, Character: 0}, End: protocol.Position{Line: 0, Character: 10}}, NewText: "get_user"}, + {Range: protocol.Range{Start: protocol.Position{Line: 0, Character: 11}, End: protocol.Position{Line: 0, Character: 21}}, NewText: "get_user"}, + }, + want: "get_user(get_user(1))\n", + }, + { + name: "multi-line span replacement", + text: "a\nold one\nold two\nb\n", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: protocol.Position{Line: 1, Character: 0}, End: protocol.Position{Line: 2, Character: 7}}, NewText: "new one\nnew two\nnew three"}, + }, + want: "a\nnew one\nnew two\nnew three\nb\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := applyTextEdits(tt.text, tt.edits); got != tt.want { + t.Errorf("got:\n%q\nwant:\n%q", got, tt.want) + } + }) + } +} + +// A rename requested through the exported API must land on disk even for +// files an editor holds open (attached mode), since the caller has no editor +// to deliver TextEdits to. +func TestRenameFunction_WritesOpenBuffers(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", `defmodule MyApp.Accounts do + def fetch_user(id), do: id +end +`) + openSrc := `defmodule MyApp.Caller do + def go(id), do: MyApp.Accounts.fetch_user(id) +end +` + indexFile(t, server.store, server.projectRoot, "lib/caller.ex", openSrc) + openPath := filepath.Join(server.projectRoot, "lib/caller.ex") + server.docs.Set(string(uri.File(openPath)), openSrc) // simulate didOpen + + summary, err := server.RenameFunction("MyApp.Accounts", "fetch_user", "get_user") + if err != nil { + t.Fatal(err) + } + server.backgroundWork.Wait() + + if len(summary.FilesChanged) != 2 { + t.Errorf("FilesChanged = %v, want both files", summary.FilesChanged) + } + data, err := os.ReadFile(openPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "MyApp.Accounts.get_user(id)") { + t.Errorf("open buffer's file not written to disk:\n%s", data) + } + results, err := server.store.LookupFunction("MyApp.Accounts", "get_user") + if err != nil || len(results) == 0 { + t.Errorf("index not updated after rename: %v, %v", results, err) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 5ee9966..130de70 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -4172,7 +4173,8 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr if existing, err := s.store.LookupFunction(fullModule, params.NewName); err == nil && len(existing) > 0 { return nil, fmt.Errorf("function %s.%s already exists", fullModule, params.NewName) } - return s.renameFunctionEdits(fullModule, functionName, params.NewName) + edit, _, err := s.renameFunctionEdits(fullModule, functionName, params.NewName) + return edit, err } } else if moduleRef != "" { fullModule := resolveModule(moduleRef, aliases) @@ -4186,7 +4188,8 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr if !isValidModuleName(newModule) { return nil, fmt.Errorf("invalid module name %q: must be CamelCase segments separated by dots", params.NewName) } - return s.renameModuleEdits(ctx, fullModule, newModule, uriToPath(params.TextDocument.URI)) + edit, _, _, err := s.renameModuleEdits(ctx, fullModule, newModule, uriToPath(params.TextDocument.URI)) + return edit, err } } } @@ -4195,8 +4198,9 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr } // renameFunctionEdits builds a WorkspaceEdit renaming all occurrences of -// module.functionName to newName across the codebase. -func (s *Server) renameFunctionEdits(module, functionName, newName string) (*protocol.WorkspaceEdit, error) { +// module.functionName to newName across the codebase. The second return lists +// every file it edited. +func (s *Server) renameFunctionEdits(module, functionName, newName string) (*protocol.WorkspaceEdit, []string, error) { // Collect all (filePath, lineNumber) pairs — definitions + references type siteKey struct { filePath string @@ -4225,7 +4229,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro // Definition sites defResults, err := s.store.LookupFunction(module, functionName) if err != nil { - return nil, nil + return nil, nil, nil } for _, r := range defResults { addSite(r.FilePath, r.Line) @@ -4234,7 +4238,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro // Direct reference sites (calls, imports — skip alias/use which are module-level) refResults, err := s.store.LookupReferences(module, functionName) if err != nil { - return nil, nil + return nil, nil, nil } for _, r := range refResults { if r.Kind == "alias" || r.Kind == "use" { @@ -4322,6 +4326,11 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro edit := s.buildTextEdits(sites, functionName, newName) + changedFiles := make(map[string]bool, len(sites)) + for _, site := range sites { + changedFiles[site.filePath] = true + } + // Update defdelegate lines that forward to this function: add or update // the `as:` option so the facade keeps working after the rename. if s.followDelegates { @@ -4359,6 +4368,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro continue } + changedFiles[del.FilePath] = true fileURI := protocol.DocumentURI(uri.File(del.FilePath)) if open { if edit.Changes == nil { @@ -4383,7 +4393,12 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro } } - return edit, nil + files := make([]string, 0, len(changedFiles)) + for fp := range changedFiles { + files = append(files, fp) + } + sort.Strings(files) + return edit, files, nil } // renameModuleEdits builds a WorkspaceEdit renaming oldModule to newModule, @@ -4393,14 +4408,16 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro // parallel goroutines. Only open buffers are included in the returned // WorkspaceEdit, keeping the response small and avoiding editor freezes. // Files following the naming convention are also renamed/moved. -func (s *Server) renameModuleEdits(ctx context.Context, oldModule, newModule, triggerFilePath string) (*protocol.WorkspaceEdit, error) { +// renameModuleEdits' extra returns list the files it moved (old path to new +// path, open and closed alike) and the files it edited. +func (s *Server) renameModuleEdits(ctx context.Context, oldModule, newModule, triggerFilePath string) (*protocol.WorkspaceEdit, map[string]string, []string, error) { mr := s.buildModuleRename(oldModule, newModule) // Check for collisions: verify that none of the target module names // (including submodules) already exist, and that no destination file // paths are occupied. if err := mr.checkCollisions(); err != nil { - return nil, err + return nil, nil, nil, err } mr.collectSites() @@ -4436,7 +4453,19 @@ func (s *Server) renameModuleEdits(ctx context.Context, oldModule, newModule, tr } } - return &protocol.WorkspaceEdit{Changes: openChanges}, nil + moved := make(map[string]string, len(movedFiles)+len(openMovedFiles)) + for from, to := range movedFiles { + moved[from] = to + } + for from, to := range openMovedFiles { + moved[from] = to + } + files := make([]string, 0, len(mr.sitesByFile)) + for fp := range mr.sitesByFile { + files = append(files, fp) + } + sort.Strings(files) + return &protocol.WorkspaceEdit{Changes: openChanges}, moved, files, nil } // moduleRename holds the state for a module rename operation. diff --git a/internal/mcp/instructions.md b/internal/mcp/instructions.md index e4de50e..f1fd749 100644 --- a/internal/mcp/instructions.md +++ b/internal/mcp/instructions.md @@ -15,6 +15,8 @@ Which tool for which question: - Implementations of a behaviour or protocol: `dexter_implementations` - What a specific file defines: `dexter_file_outline` - Project layout and index freshness: `dexter_workspace` +- Rename a module or function everywhere: `dexter_rename_symbol` (writes the + changes; review with `git diff`) The index updates automatically: file changes are watched (fsnotify) and git branch switches are detected. If a lookup ever seems stale, `dexter_reindex` diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 96be403..02fb6a0 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -112,6 +112,12 @@ func NewServer(h *Handler) *mcp.Server { Description: "Force an immediate incremental reindex. The index already updates automatically as files change; use this only when a lookup seems stale. The only tool that writes, and it writes only dexter's own index database.", }, h.reindexHandler) + mcp.AddTool(srv, &mcp.Tool{ + Name: "dexter_rename_symbol", + Description: "Rename an Elixir module or function across the whole workspace, exactly like an editor rename: writes the changes to disk, moves files that follow the naming convention, and updates the index. Reports every file changed; review with git diff.", + Annotations: &mcp.ToolAnnotations{OpenWorldHint: new(bool)}, + }, h.renameHandler) + return srv } diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 7a3180f..90eeff8 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -145,6 +145,7 @@ func TestListTools(t *testing.T) { "dexter_module_api", "dexter_references", "dexter_reindex", + "dexter_rename_symbol", "dexter_search", "dexter_workspace", } @@ -176,7 +177,7 @@ func TestListTools(t *testing.T) { if a.OpenWorldHint == nil || *a.OpenWorldHint { t.Errorf("tool %s not marked closed-world", tool.Name) } - wantReadOnly := tool.Name != "dexter_reindex" + wantReadOnly := tool.Name != "dexter_reindex" && tool.Name != "dexter_rename_symbol" if a.ReadOnlyHint != wantReadOnly { t.Errorf("tool %s ReadOnlyHint = %v, want %v", tool.Name, a.ReadOnlyHint, wantReadOnly) } diff --git a/internal/mcp/rename.go b/internal/mcp/rename.go new file mode 100644 index 0000000..937f872 --- /dev/null +++ b/internal/mcp/rename.go @@ -0,0 +1,55 @@ +package mcp + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/lsp" +) + +type RenameParams struct { + Module string `json:"module" jsonschema:"module being renamed, or the module owning the function"` + Function string `json:"function,omitempty" jsonschema:"if set, rename this function; otherwise rename the module itself (and its submodules)"` + NewName string `json:"new_name" jsonschema:"new function name (e.g. get_user), or new fully-qualified module name (e.g. MyApp.Clients)"` +} + +func (h *Handler) renameHandler(ctx context.Context, req *mcp.CallToolRequest, args RenameParams) (*mcp.CallToolResult, any, error) { + module := strings.TrimSpace(args.Module) + function := strings.TrimSpace(args.Function) + newName := strings.TrimSpace(args.NewName) + if module == "" || newName == "" { + return nil, nil, fmt.Errorf("module and new_name must not be empty") + } + + var summary lsp.RenameSummary + var err error + var target string + if function != "" { + target = fmt.Sprintf("%s.%s to %s", module, function, newName) + summary, err = h.lsp.RenameFunction(module, function, newName) + } else { + target = fmt.Sprintf("%s to %s", module, newName) + summary, err = h.lsp.RenameModule(module, newName) + } + if err != nil { + return nil, nil, err + } + + var b strings.Builder + fmt.Fprintf(&b, "Renamed %s across %d file(s). The index is updated.\n", target, len(summary.FilesChanged)) + if len(summary.FilesMoved) > 0 { + fmt.Fprintf(&b, "\nFiles moved to follow the naming convention:\n") + for from, to := range summary.FilesMoved { + fmt.Fprintf(&b, " %s → %s\n", h.relPath(from), h.relPath(to)) + } + } + fmt.Fprintf(&b, "\nChanged files:\n") + for _, fp := range summary.FilesChanged { + fmt.Fprintf(&b, " %s\n", h.relPath(fp)) + } + fmt.Fprintf(&b, "\nReview with git diff; revert with git checkout.\n") + return textResult(b.String()), nil, nil +} diff --git a/internal/mcp/rename_test.go b/internal/mcp/rename_test.go new file mode 100644 index 0000000..742e958 --- /dev/null +++ b/internal/mcp/rename_test.go @@ -0,0 +1,87 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func readFile(t *testing.T, root, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestRenameTool_Function(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_rename_symbol", map[string]any{ + "module": "MyApp.Accounts", "function": "fetch_user", "new_name": "get_user", + }) + wantContains(t, out, + "Renamed MyApp.Accounts.fetch_user to get_user", + "lib/my_app/accounts.ex", + "lib/my_app/worker.ex", + "git diff", + ) + + accounts := readFile(t, e.root, "lib/my_app/accounts.ex") + wantContains(t, accounts, "def get_user(id)", "@spec get_user(integer())") + wantNotContains(t, accounts, "fetch_user") + + worker := readFile(t, e.root, "lib/my_app/worker.ex") + wantContains(t, worker, "MyApp.Accounts.get_user(1)", "MyApp.Accounts.get_user(2)") + + // The rename reindexes what it wrote: lookups resolve the new name only. + wantContains(t, e.callTool("dexter_definition", map[string]any{"module": "MyApp.Accounts", "function": "get_user"}), "get_user/1 (def)") + wantContains(t, e.callTool("dexter_definition", map[string]any{"module": "MyApp.Accounts", "function": "fetch_user"}), "not in the index") +} + +func TestRenameTool_Module_MovesFiles(t *testing.T) { + e := setupProject(t) + out := e.callTool("dexter_rename_symbol", map[string]any{ + "module": "MyApp.Accounts", "new_name": "MyApp.Users", + }) + wantContains(t, out, + "Renamed MyApp.Accounts to MyApp.Users", + "Files moved to follow the naming convention:", + "lib/my_app/accounts.ex → lib/my_app/users.ex", + "lib/my_app/accounts/creator.ex → lib/my_app/users/creator.ex", + ) + + if _, err := os.Stat(filepath.Join(e.root, "lib/my_app/accounts.ex")); !os.IsNotExist(err) { + t.Error("old module file still exists after rename") + } + wantContains(t, readFile(t, e.root, "lib/my_app/users.ex"), "defmodule MyApp.Users do") + wantContains(t, readFile(t, e.root, "lib/my_app/users/creator.ex"), "defmodule MyApp.Users.Creator do") + wantContains(t, readFile(t, e.root, "lib/my_app/worker.ex"), "MyApp.Users.fetch_user(1)") + + wantContains(t, e.callTool("dexter_definition", map[string]any{"module": "MyApp.Users"}), "defmodule MyApp.Users") +} + +func TestRenameTool_Errors(t *testing.T) { + e := setupProject(t) + + errText := e.callToolExpectError("dexter_rename_symbol", map[string]any{ + "module": "MyApp.Accounts", "function": "fetch_user", "new_name": "NotValid", + }) + wantContains(t, errText, "invalid function name") + + errText = e.callToolExpectError("dexter_rename_symbol", map[string]any{ + "module": "MyApp.Accounts", "function": "fetch_user", "new_name": "list_users", + }) + wantContains(t, errText, "already exists") + + errText = e.callToolExpectError("dexter_rename_symbol", map[string]any{ + "module": "MyApp.Missing", "new_name": "MyApp.New", + }) + wantContains(t, errText, "not found") + + // Failed renames must not touch disk. + if s := readFile(t, e.root, "lib/my_app/accounts.ex"); !strings.Contains(s, "def fetch_user(id)") { + t.Error("failed rename modified files") + } +} From 84b938339b8ab072d1af411d5ba4ef01f5d2c0d7 Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 2 Sep 2026 07:47:07 +1000 Subject: [PATCH 04/17] Serialize watcher writes and fix MCP rename delivery Three fixes for the MCP integration, none touching LSP behavior: The file watcher now holds the reindex lock while writing to the index. Without it, a file created after a concurrent workspace reindex's walk had passed its directory could be indexed by the watcher and then removed by the reindex's prune, with the create event already consumed, leaving the symbol missing until the file changed again. Open-buffer rename edits from an MCP rename are forwarded to a live LSP client as workspace/applyEdit (attached mode), so the editor applies them and stays in sync, exactly as an editor-initiated rename would. Writing those files behind the editor's back left the buffer stale and a later save would have reverted the rename. Without a client they are written to disk directly; headless servers have no open buffers. RenameFunction and RenameModule wait for the rename's background reindex before returning, so the reported "index is updated" is true when the tool call completes rather than eventually. --- cmd/main.go | 2 +- internal/lsp/api.go | 45 ++++++++++++++++++++++-------- internal/lsp/api_test.go | 57 ++++++++++++++++++++++++++++++++++++-- internal/mcp/watch.go | 23 ++++++++------- internal/mcp/watch_test.go | 40 ++++++++++++++++++++++++-- 5 files changed, 139 insertions(+), 28 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index d709ebf..ca5777d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -637,7 +637,7 @@ func cmdMCP(projectRoot string, listen string) { server.WatchGitHead() // Headless servers get no editor events, so watch the tree directly. - watcher, err := dexter_mcp.WatchFiles(s, projectRoot, func() { server.ReindexWorkspace() }) + watcher, err := dexter_mcp.WatchFiles(server, s, projectRoot) if err != nil { log.Printf("Warning: file watching unavailable (%v); the index updates on branch switches and via dexter_reindex", err) } else { diff --git a/internal/lsp/api.go b/internal/lsp/api.go index 2729369..c2fb938 100644 --- a/internal/lsp/api.go +++ b/internal/lsp/api.go @@ -115,17 +115,26 @@ func (s *Server) CollectReferences(module, function string) []store.ReferenceRes return out } +// WithReindexLock runs fn while holding the reindex lock, serializing it with +// ReindexWorkspace and the background reindexes. The MCP file watcher wraps +// its index writes in it so they cannot interleave with a concurrent +// workspace reindex's walk-and-prune. +func (s *Server) WithReindexLock(fn func()) { + s.reindexing.Lock() + defer s.reindexing.Unlock() + fn() +} + // RenameSummary reports what a rename changed on disk. type RenameSummary struct { FilesChanged []string FilesMoved map[string]string // old path → new path (conventional module renames) } -// RenameFunction renames module.functionName to newName across the workspace, -// writing every change to disk. It performs the same validation as the LSP -// rename. Edits the LSP path would hand to an editor as TextEdits (open -// buffers in attached mode) are written to disk too, since an MCP caller has -// no editor to deliver them to. +// RenameFunction renames module.functionName to newName across the workspace +// with the same validation and on-disk semantics as the LSP rename. It returns +// once the index reflects the rename. Open-buffer edits are delivered per +// deliverEdits. func (s *Server) RenameFunction(module, functionName, newName string) (RenameSummary, error) { if !isValidFunctionName(newName) { return RenameSummary{}, fmt.Errorf("invalid function name %q: must match [a-z_][a-z0-9_?!]*", newName) @@ -145,9 +154,12 @@ func (s *Server) RenameFunction(module, functionName, newName string) (RenameSum if err != nil { return RenameSummary{}, err } - if err := s.writeEditsToDisk(edit); err != nil { + if err := s.deliverEdits(edit); err != nil { return RenameSummary{}, err } + // The machinery reindexes what it wrote in the background; callers are + // promised an up-to-date index. + s.backgroundWork.Wait() return RenameSummary{FilesChanged: files}, nil } @@ -168,19 +180,28 @@ func (s *Server) RenameModule(oldModule, newModule string) (RenameSummary, error if err != nil { return RenameSummary{}, err } - if err := s.writeEditsToDisk(edit); err != nil { + if err := s.deliverEdits(edit); err != nil { return RenameSummary{}, err } + s.backgroundWork.Wait() return RenameSummary{FilesChanged: files, FilesMoved: moved}, nil } -// writeEditsToDisk applies a WorkspaceEdit's TextEdits to their files on disk -// and reindexes them. The rename machinery only produces TextEdits for open -// editor buffers; headless servers have none, so this is usually a no-op. -func (s *Server) writeEditsToDisk(edit *protocol.WorkspaceEdit) error { - if edit == nil { +// deliverEdits routes a WorkspaceEdit's TextEdits to whoever owns the +// documents. The rename machinery only produces TextEdits for open editor +// buffers; with a live LSP client (attached mode) they are forwarded as a +// workspace/applyEdit request so the editor applies them and syncs back via +// didChange, exactly as an editor-initiated rename would. Without a client +// they are written to disk directly; headless servers have no open buffers, +// so that path is a defensive no-op in practice. +func (s *Server) deliverEdits(edit *protocol.WorkspaceEdit) error { + if edit == nil || len(edit.Changes) == 0 { return nil } + if s.client != nil { + _, err := s.client.ApplyEdit(context.Background(), &protocol.ApplyWorkspaceEditParams{Edit: *edit}) + return err + } for docURI, edits := range edit.Changes { path := uriToPath(docURI) text, _, ok := s.ReadFileText(path) diff --git a/internal/lsp/api_test.go b/internal/lsp/api_test.go index f34ea79..dc4f6f3 100644 --- a/internal/lsp/api_test.go +++ b/internal/lsp/api_test.go @@ -1,6 +1,7 @@ package lsp import ( + "context" "os" "path/filepath" "strings" @@ -52,9 +53,8 @@ func TestApplyTextEdits(t *testing.T) { } } -// A rename requested through the exported API must land on disk even for -// files an editor holds open (attached mode), since the caller has no editor -// to deliver TextEdits to. +// Without a live client, a rename requested through the exported API must +// land on disk even for files marked open (the defensive fallback path). func TestRenameFunction_WritesOpenBuffers(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() @@ -92,3 +92,54 @@ end t.Errorf("index not updated after rename: %v, %v", results, err) } } + +// fakeClient records ApplyEdit requests; other client methods are never +// called by the rename path. +type fakeClient struct { + protocol.Client + applied *protocol.WorkspaceEdit +} + +func (f *fakeClient) ApplyEdit(_ context.Context, params *protocol.ApplyWorkspaceEditParams) (bool, error) { + f.applied = ¶ms.Edit + return true, nil +} + +// With a live client (attached mode), open-buffer edits go to the editor via +// workspace/applyEdit; dexter must not write those files behind its back. +func TestRenameFunction_ForwardsOpenBufferEditsToClient(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + fc := &fakeClient{} + server.client = fc + + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", `defmodule MyApp.Accounts do + def fetch_user(id), do: id +end +`) + openSrc := `defmodule MyApp.Caller do + def go(id), do: MyApp.Accounts.fetch_user(id) +end +` + indexFile(t, server.store, server.projectRoot, "lib/caller.ex", openSrc) + openPath := filepath.Join(server.projectRoot, "lib/caller.ex") + server.docs.Set(string(uri.File(openPath)), openSrc) + + if _, err := server.RenameFunction("MyApp.Accounts", "fetch_user", "get_user"); err != nil { + t.Fatal(err) + } + + if fc.applied == nil { + t.Fatal("no workspace/applyEdit request reached the client") + } + if len(fc.applied.Changes) != 1 { + t.Errorf("ApplyEdit carried %d files, want 1", len(fc.applied.Changes)) + } + data, err := os.ReadFile(openPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "get_user") { + t.Error("open buffer's file was written to disk despite a live client") + } +} diff --git a/internal/mcp/watch.go b/internal/mcp/watch.go index e784908..46400a9 100644 --- a/internal/mcp/watch.go +++ b/internal/mcp/watch.go @@ -12,6 +12,7 @@ import ( "github.com/fsnotify/fsnotify" + "github.com/remoteoss/dexter/internal/lsp" "github.com/remoteoss/dexter/internal/parser" "github.com/remoteoss/dexter/internal/store" ) @@ -25,23 +26,25 @@ const debounceWindow = 300 * time.Millisecond // it watches the project tree directly (fsnotify). type Watcher struct { fsw *fsnotify.Watcher + server *lsp.Server store *store.Store root string - resync func() // full incremental reindex, used when events were lost wg sync.WaitGroup } // WatchFiles watches projectRoot recursively and incrementally reindexes -// Elixir files as they change. resync is invoked when the watcher loses -// events (queue overflow) and the whole tree must be reconciled. Callers -// should treat an error as degraded service, not fatal: the index still -// updates on startup, on git branch switches, and via dexter_reindex. -func WatchFiles(s *store.Store, projectRoot string, resync func()) (*Watcher, error) { +// Elixir files as they change. Index writes hold the server's reindex lock so +// they cannot interleave with a concurrent workspace reindex's walk-and-prune +// (which would drop a file indexed after the walk passed its directory). On +// event overflow the whole workspace is reindexed. Callers should treat an +// error as degraded service, not fatal: the index still updates on startup, +// on git branch switches, and via dexter_reindex. +func WatchFiles(server *lsp.Server, s *store.Store, projectRoot string) (*Watcher, error) { fsw, err := fsnotify.NewWatcher() if err != nil { return nil, err } - w := &Watcher{fsw: fsw, store: s, root: projectRoot, resync: resync} + w := &Watcher{fsw: fsw, server: server, store: s, root: projectRoot} if err := w.watchTree(projectRoot); err != nil { _ = fsw.Close() return nil, err @@ -114,9 +117,9 @@ func (w *Watcher) loop() { if !ok { return } - if errors.Is(err, fsnotify.ErrEventOverflow) && w.resync != nil { + if errors.Is(err, fsnotify.ErrEventOverflow) { log.Printf("Warning: file watcher overflowed, reindexing workspace") - w.resync() + w.server.ReindexWorkspace() continue } log.Printf("Warning: file watcher: %v", err) @@ -125,7 +128,7 @@ func (w *Watcher) loop() { timerC = nil batch := pending pending = make(map[string]struct{}) - w.apply(batch) + w.server.WithReindexLock(func() { w.apply(batch) }) } } } diff --git a/internal/mcp/watch_test.go b/internal/mcp/watch_test.go index c37cbfc..cdc9367 100644 --- a/internal/mcp/watch_test.go +++ b/internal/mcp/watch_test.go @@ -1,11 +1,13 @@ package mcp import ( + "fmt" "os" "path/filepath" "testing" "time" + "github.com/remoteoss/dexter/internal/lsp" "github.com/remoteoss/dexter/internal/store" ) @@ -25,6 +27,11 @@ func eventually(t *testing.T, what string, cond func() bool) { } func setupWatcher(t *testing.T) (*store.Store, string) { + s, root, _ := setupWatcherWithServer(t) + return s, root +} + +func setupWatcherWithServer(t *testing.T) (*store.Store, string, *lsp.Server) { t.Helper() root := t.TempDir() s, err := store.Open(root) @@ -33,7 +40,8 @@ func setupWatcher(t *testing.T) (*store.Store, string) { } t.Cleanup(func() { _ = s.Close() }) - w, err := WatchFiles(s, root, nil) + server := lsp.NewServer(s, root) + w, err := WatchFiles(server, s, root) if err != nil { t.Fatal(err) } @@ -42,7 +50,7 @@ func setupWatcher(t *testing.T) (*store.Store, string) { t.Errorf("closing watcher: %v", err) } }) - return s, root + return s, root, server } func writeFile(t *testing.T, root, rel, content string) string { @@ -151,3 +159,31 @@ func TestWatcher_SkipsDepsAndNonElixir(t *testing.T) { t.Error("non-Elixir file was indexed") } } + +// Watcher writes hold the reindex lock, so files created while a workspace +// reindex runs can never be indexed between its walk and its prune (which +// would remove them with their create event already consumed). +func TestWatcher_SerializesWithWorkspaceReindex(t *testing.T) { + s, root, server := setupWatcherWithServer(t) + writeFile(t, root, "lib/base.ex", "defmodule MyApp.Base do\nend\n") + eventually(t, "base file", moduleIndexed(s, "MyApp.Base")) + + const n = 12 + done := make(chan struct{}) + go func() { + defer close(done) + for i := 0; i < n; i++ { + server.ReindexWorkspace() + } + }() + for i := 0; i < n; i++ { + writeFile(t, root, fmt.Sprintf("lib/race_%d.ex", i), fmt.Sprintf("defmodule MyApp.Race%d do\nend\n", i)) + time.Sleep(20 * time.Millisecond) + } + <-done + + for i := 0; i < n; i++ { + mod := fmt.Sprintf("MyApp.Race%d", i) + eventually(t, mod+" to survive concurrent reindexes", moduleIndexed(s, mod)) + } +} From b5a28a6fa1e7128766b78a20dba6b18b7e71bef7 Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 2 Sep 2026 08:04:51 +1000 Subject: [PATCH 05/17] Report editor-rejected rename edits as errors workspace/applyEdit responses carry an applied flag; a rename whose open-buffer edits the editor refused was still reported as complete. deliverEdits now surfaces the rejection as an error. --- internal/lsp/api.go | 5 ++++- internal/lsp/api_test.go | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/internal/lsp/api.go b/internal/lsp/api.go index c2fb938..f8cbbc4 100644 --- a/internal/lsp/api.go +++ b/internal/lsp/api.go @@ -199,7 +199,10 @@ func (s *Server) deliverEdits(edit *protocol.WorkspaceEdit) error { return nil } if s.client != nil { - _, err := s.client.ApplyEdit(context.Background(), &protocol.ApplyWorkspaceEditParams{Edit: *edit}) + applied, err := s.client.ApplyEdit(context.Background(), &protocol.ApplyWorkspaceEditParams{Edit: *edit}) + if err == nil && !applied { + err = fmt.Errorf("editor did not apply the rename edits for open files") + } return err } for docURI, edits := range edit.Changes { diff --git a/internal/lsp/api_test.go b/internal/lsp/api_test.go index dc4f6f3..a34ea03 100644 --- a/internal/lsp/api_test.go +++ b/internal/lsp/api_test.go @@ -98,11 +98,12 @@ end type fakeClient struct { protocol.Client applied *protocol.WorkspaceEdit + reject bool } func (f *fakeClient) ApplyEdit(_ context.Context, params *protocol.ApplyWorkspaceEditParams) (bool, error) { f.applied = ¶ms.Edit - return true, nil + return !f.reject, nil } // With a live client (attached mode), open-buffer edits go to the editor via @@ -143,3 +144,27 @@ end t.Error("open buffer's file was written to disk despite a live client") } } + +// An editor may refuse a workspace edit (applied: false); the rename must +// report failure, not success, when open-buffer edits were not applied. +func TestRenameFunction_ReportsRejectedApplyEdit(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + server.client = &fakeClient{reject: true} + + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", `defmodule MyApp.Accounts do + def fetch_user(id), do: id +end +`) + openSrc := `defmodule MyApp.Caller do + def go(id), do: MyApp.Accounts.fetch_user(id) +end +` + indexFile(t, server.store, server.projectRoot, "lib/caller.ex", openSrc) + openPath := filepath.Join(server.projectRoot, "lib/caller.ex") + server.docs.Set(string(uri.File(openPath)), openSrc) + + if _, err := server.RenameFunction("MyApp.Accounts", "fetch_user", "get_user"); err == nil { + t.Fatal("rename reported success despite the editor rejecting the edit") + } +} From ea514c872d394c80eca7b9a37ecf79049d28be6f Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Sat, 5 Sep 2026 18:33:02 -0400 Subject: [PATCH 06/17] Let the editor move files a module rename renames Renaming a module from the file that defines it moved that file on disk while the editor still held the buffer, and handed the editor TextEdits for the path just deleted. Neovim applied them to the stale buffer, so the next save recreated the old file holding the new module name: two files defining the same module, and the project then couldn't compile due to duplicate modules. Open files are now moved by the client, through a rename resource operation ordered right after that file's own TextEdits so the edited buffer travels to the new path; the server touches neither path. Closed files still move server-side, which is what keeps large renames off the wire. Clients without resourceOperations rename the module in place and leave the file where it is, so nothing is deleted under a live buffer. go.lsp.dev/protocol types documentChanges as []TextDocumentEdit and cannot carry resource operations, so workspace_edit.go defines the wire types and renameHandler answers textDocument/rename ahead of the generated dispatcher. Two other bugs also fixed: - `alias Old.{A, B}` names the module once as the prefix while the index records one reference per member, so a member's full name never appears on the line and the group kept pointing at the old module. - Every member on such a line resolves to the same prefix edit. TextEdits are relative to the original buffer, so emitting it once per member made the editor apply it repeatedly (Old -> NewNewNew...). Overlapping edits are now dropped; the on-disk path rewrites the line as it goes and never sees the second match. --- CHANGELOG.md | 7 + docs/architecture.md | 14 + internal/lsp/rename.go | 37 +++ internal/lsp/rename_test.go | 506 ++++++++++++++++++++++++++++++--- internal/lsp/server.go | 267 ++++++++++++----- internal/lsp/server_test.go | 3 + internal/lsp/workspace_edit.go | 113 ++++++++ 7 files changed, 834 insertions(+), 113 deletions(-) create mode 100644 internal/lsp/workspace_edit.go diff --git a/CHANGELOG.md b/CHANGELOG.md index fe4c935..2c7b299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [Unreleased] + +### Fixed + +- **Module rename left the old file behind** — renaming a module from the file that defines it moved that file on disk while the editor still held the buffer, so the next save recreated the old file with the new module name and the project no longer compiled (`cannot define module X because it is currently being defined in ...`). Open files are now moved by the editor, through a `rename` resource operation in the reply, and the server touches neither path +- **Grouped aliases were not renamed** — `alias Old.{A, B}` (and the `require`/`import` forms) kept pointing at the old module after a module rename, and in open buffers the shared prefix was rewritten once per member (`Old` → `NewNewNew...`) + ## [0.7.1] - 2026-06-12 ### Added diff --git a/docs/architecture.md b/docs/architecture.md index 00e2610..3097b3a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -82,6 +82,20 @@ Call sites are attributed to the **injecting module** in the store (not the defi `buildTextEdits` uses `findFunctionTokenColumns` to skip keyword-syntax occurrences (`resource_type: value`) — only `::` type separators pass through. Import-only sites use `findAllTokenColumns` since their keyword keys ARE function names. +### Who moves a file + +A module rename also moves files whose names follow the module naming convention, and who performs the move depends on whether the editor holds the file: + +- **Closed files** — the server writes the new path and deletes the old one. This keeps large renames off the wire. +- **Open files** — the client moves them, through a `rename` resource operation in the reply's `documentChanges`, ordered right after that file's own TextEdits so the edited buffer travels to the new path. The server touches neither path. Moving an open file server-side leaves the editor holding a modified buffer pointing at a deleted path, and the next save recreates the old file with the new module name — two files defining the same module. +- **Open files, client without `resourceOperations: ["rename"]`** — the module is renamed in place and the file keeps its old name. Nothing is deleted underneath a live buffer. + +`protocol.WorkspaceEdit` from `go.lsp.dev/protocol` types `documentChanges` as `[]TextDocumentEdit` and cannot carry resource operations, so `internal/lsp/workspace_edit.go` defines the wire types and `renameHandler` answers `textDocument/rename` ahead of the generated dispatcher. A client that understands `documentChanges` ignores `changes` entirely, so once one file moves, every edit in the reply goes through `documentChanges`. + +### Grouped aliases + +`alias Old.{A, B}` (and the `require`/`import` forms) names the module once, as the prefix, while the index records one reference per member — so a member's full name never appears on the line. `findGroupedAliasEdits` handles both directions: renaming the prefix rewrites the prefix, renaming a member rewrites that member inside the braces. Since every member on the line resolves to the same prefix edit, `applyEdits` drops TextEdits that overlap one already emitted for that line; the on-disk path rewrites the line as it goes and never sees the second match. + ## Key design decisions - **Tokenizer instead of tree-sitter for indexing** — a hand-rolled tokenizer + walker replaced the original regex-based parser for both file indexing and runtime `__using__` parsing. The tokenizer handles heredocs, sigils, multi-line expressions, and comments as opaque tokens, eliminating fragile line-joining heuristics. Tree-sitter is only used for scope-aware variable operations in files already opened by the editor. diff --git a/internal/lsp/rename.go b/internal/lsp/rename.go index 4a2e499..e671fcc 100644 --- a/internal/lsp/rename.go +++ b/internal/lsp/rename.go @@ -65,6 +65,43 @@ func findFunctionTokenColumns(lineText, token string) []int { return result } +// findGroupedAlias locates a grouped alias/require/import of the form +// `prefix.{A, B}` in lineText. It returns the column where prefix starts and +// the span of the text between the braces, or -1 when the line holds no such +// group for prefix. +// +// findAllTokenColumns cannot be used for this: the match would end at '{', +// whose following character is an identifier char, so the boundary check +// would reject every group. +func findGroupedAlias(lineText, prefix string) (prefixCol, groupStart, groupEnd int) { + needle := prefix + ".{" + start := 0 + for { + idx := strings.Index(lineText[start:], needle) + if idx < 0 { + return -1, -1, -1 + } + abs := start + idx + // Only the leading boundary matters — the trailing one is the brace. + if abs > 0 { + if r, _ := utf8.DecodeLastRuneInString(lineText[:abs]); r != utf8.RuneError && isRenameIdentChar(r) { + start = abs + 1 + continue + } + } + open := abs + len(needle) + end := strings.IndexByte(lineText[open:], '}') + if end < 0 { + // Multi-line group: the members continue on following lines, so + // take the rest of this one. + end = len(lineText) + } else { + end = open + end + } + return abs, open, end + } +} + // isTokenBoundary returns true when the substring [pos, pos+length) in s is // not immediately preceded or followed by an identifier character. func isTokenBoundary(s string, pos, length int) bool { diff --git a/internal/lsp/rename_test.go b/internal/lsp/rename_test.go index 9c22faa..2504e3f 100644 --- a/internal/lsp/rename_test.go +++ b/internal/lsp/rename_test.go @@ -2,11 +2,14 @@ package lsp import ( "context" + "encoding/json" "os" "path/filepath" + "sort" "strings" "testing" + "go.lsp.dev/jsonrpc2" "go.lsp.dev/protocol" "go.lsp.dev/uri" ) @@ -323,9 +326,9 @@ func TestIsValidModuleName(t *testing.T) { // === Integration helpers === -func renameAt(t *testing.T, server *Server, docURI string, line, col uint32, newName string) *protocol.WorkspaceEdit { +func renameAt(t *testing.T, server *Server, docURI string, line, col uint32, newName string) *WorkspaceEdit { t.Helper() - result, err := server.Rename(context.Background(), &protocol.RenameParams{ + result, err := server.RenameEdit(context.Background(), &protocol.RenameParams{ TextDocumentPositionParams: protocol.TextDocumentPositionParams{ TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(docURI)}, Position: protocol.Position{Line: line, Character: col}, @@ -352,12 +355,34 @@ func prepareRenameAt(t *testing.T, server *Server, docURI string, line, col uint return result } -func collectEdits(edit *protocol.WorkspaceEdit, filePath string) []protocol.TextEdit { +func collectEdits(edit *WorkspaceEdit, filePath string) []protocol.TextEdit { if edit == nil { return nil } fileURI := protocol.DocumentURI(uri.File(filePath)) - return edit.Changes[fileURI] + if edits, ok := edit.Changes[fileURI]; ok { + return edits + } + for _, change := range edit.DocumentChanges { + if tde, ok := change.(TextDocumentEdit); ok && tde.TextDocument.URI == fileURI { + return tde.Edits + } + } + return nil +} + +// renameOp returns the rename operation for filePath in the edit, if any. +func renameOp(edit *WorkspaceEdit, filePath string) *RenameFile { + if edit == nil { + return nil + } + oldURI := protocol.DocumentURI(uri.File(filePath)) + for _, change := range edit.DocumentChanges { + if rf, ok := change.(RenameFile); ok && rf.OldURI == oldURI { + return &rf + } + } + return nil } func hasEdit(edits []protocol.TextEdit, newText string) bool { @@ -378,6 +403,50 @@ func editsContainLine(edits []protocol.TextEdit, lineNum uint32) bool { return false } +// expectClientRename asserts the edit asks the client to move oldPath to +// newPath, and that the server left both paths alone: the editor owns an open +// buffer, so it must perform the move itself. +func expectClientRename(t *testing.T, edit *WorkspaceEdit, oldPath, newPath string) { + t.Helper() + op := renameOp(edit, oldPath) + if op == nil { + t.Fatalf("expected a rename operation for %s, got %+v", oldPath, edit) + } + if want := protocol.DocumentURI(uri.File(newPath)); op.NewURI != want { + t.Errorf("rename target = %s, want %s", op.NewURI, want) + } + if _, err := os.Stat(oldPath); err != nil { + t.Errorf("server removed %s — the client has it open and must move it itself", oldPath) + } + if _, err := os.Stat(newPath); err == nil { + t.Errorf("server created %s — the client performs the move", newPath) + } +} + +// bufferAfterEdits returns what content becomes once the edit's text edits for +// filePath are applied, i.e. what the editor's buffer ends up holding. +func bufferAfterEdits(t *testing.T, edit *WorkspaceEdit, filePath, content string) string { + t.Helper() + edits := collectEdits(edit, filePath) + sorted := make([]protocol.TextEdit, len(edits)) + copy(sorted, edits) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Range.Start.Line != sorted[j].Range.Start.Line { + return sorted[i].Range.Start.Line > sorted[j].Range.Start.Line + } + return sorted[i].Range.Start.Character > sorted[j].Range.Start.Character + }) + lines := strings.Split(content, "\n") + for _, e := range sorted { + l := int(e.Range.Start.Line) + if l >= len(lines) || e.Range.End.Line != e.Range.Start.Line { + t.Fatalf("unexpected edit range %+v", e.Range) + } + lines[l] = lines[l][:e.Range.Start.Character] + e.NewText + lines[l][e.Range.End.Character:] + } + return strings.Join(lines, "\n") +} + // fileContains checks whether the file at path contains the given substring. // Used to verify server-side writes for files not open in the editor. func fileContains(filePath, substr string) bool { @@ -390,7 +459,7 @@ func fileContains(filePath, substr string) bool { // hasRename returns true if the rename result (either in WorkspaceEdit or // written directly to disk) contains newText for the given file. -func hasRename(edit *protocol.WorkspaceEdit, filePath, newText string) bool { +func hasRename(edit *WorkspaceEdit, filePath, newText string) bool { if hasEdit(collectEdits(edit, filePath), newText) { return true } @@ -1490,16 +1559,12 @@ end t.Fatal("expected non-nil edit") } - // File should have been written to new path and old path removed + // The file is open, so the edit must ask the client to move it and the + // server must not touch either path. newPath := filepath.Join(server.projectRoot, "lib", "auth.ex") - if _, err := os.Stat(newPath); os.IsNotExist(err) { - t.Error("expected new file auth.ex to exist") - } - if _, err := os.Stat(oldPath); err == nil { - t.Error("expected old file accounts.ex to be removed") - } - if !fileContains(newPath, "defmodule MyApp.Auth") { - t.Errorf("expected new file to contain 'defmodule MyApp.Auth'") + expectClientRename(t, edit, oldPath, newPath) + if got := bufferAfterEdits(t, edit, oldPath, content); !strings.Contains(got, "defmodule MyApp.Auth") { + t.Errorf("expected buffer to contain 'defmodule MyApp.Auth', got:\n%s", got) } } @@ -1517,15 +1582,13 @@ end defURI := "file://" + oldPath server.docs.Set(defURI, content) - renameAt(t, server, defURI, 0, 20, "AuthTest") + edit := renameAt(t, server, defURI, 0, 20, "AuthTest") // File should be renamed preserving the .exs extension newPath := filepath.Join(server.projectRoot, "test", "auth_test.exs") - if _, err := os.Stat(newPath); os.IsNotExist(err) { - t.Error("expected file renamed to auth_test.exs (preserving .exs extension)") - } - if !fileContains(newPath, "defmodule MyApp.AuthTest") { - t.Errorf("expected 'defmodule MyApp.AuthTest' in new file") + expectClientRename(t, edit, oldPath, newPath) + if got := bufferAfterEdits(t, edit, oldPath, content); !strings.Contains(got, "defmodule MyApp.AuthTest") { + t.Errorf("expected buffer to contain 'defmodule MyApp.AuthTest', got:\n%s", got) } } @@ -1550,26 +1613,23 @@ end indexFile(t, server.store, server.projectRoot, "lib/docusign.ex", defContent) indexFile(t, server.store, server.projectRoot, "lib/web.ex", callerContent) - // Test 1: rename from the def file (open) + // Test 1: rename from the def file (open — the client moves it) t.Run("from def file", func(t *testing.T) { defURI := "file://" + oldPath server.docs.Set(defURI, defContent) - renameAt(t, server, defURI, 0, 16, "Docusigns") + edit := renameAt(t, server, defURI, 0, 16, "Docusigns") newPath := filepath.Join(server.projectRoot, "lib", "docusigns.ex") - if _, err := os.Stat(newPath); os.IsNotExist(err) { - t.Error("expected file renamed to docusigns.ex") - } - if !fileContains(newPath, "defmodule MyApp.Docusigns") { - data, _ := os.ReadFile(newPath) - t.Errorf("expected 'defmodule MyApp.Docusigns', got:\n%s", string(data)) + expectClientRename(t, edit, oldPath, newPath) + if got := bufferAfterEdits(t, edit, oldPath, defContent); !strings.Contains(got, "defmodule MyApp.Docusigns") { + t.Errorf("expected 'defmodule MyApp.Docusigns', got:\n%s", got) } }) - // Clean up test 1's renamed file and re-index with original content for test 2 - _ = os.Remove(filepath.Join(server.projectRoot, "lib", "docusigns.ex")) - _ = server.store.RemoveFile(filepath.Join(server.projectRoot, "lib", "docusigns.ex")) + // Re-index with original content for test 2, and close the def file so it + // takes the closed-file path (moved on disk by the server) + server.docs.Close("file://" + oldPath) indexFile(t, server.store, server.projectRoot, "lib/docusign.ex", defContent) // Test 2: rename from a caller file via alias (def file is closed) @@ -1831,12 +1891,10 @@ end end `) - renameAt(t, server, defURI, 0, 16, "Enterprises") + edit := renameAt(t, server, defURI, 0, 16, "Enterprises") - // Root file should be renamed - if _, err := os.Stat(filepath.Join(server.projectRoot, "lib", "enterprises.ex")); os.IsNotExist(err) { - t.Error("expected root module file renamed to enterprises.ex") - } + // Root file is open — the client renames it + expectClientRename(t, edit, defPath, filepath.Join(server.projectRoot, "lib", "enterprises.ex")) // Submodule file is closed — should be moved to new directory on disk newSubPath := filepath.Join(server.projectRoot, "lib", "enterprises", "do_something.ex") @@ -1938,16 +1996,14 @@ end t.Errorf("expected last segment range starting at col 16, got %d", r.Start.Character) } - renameAt(t, server, defURI, 0, 16, "CostCalculatorZ") + edit := renameAt(t, server, defURI, 0, 16, "CostCalculatorZ") - // Check the renamed file has the full qualified name + // The open file moves via the client, and its buffer gets the full + // qualified name newPath := filepath.Join(server.projectRoot, "lib", "cost_calculator_z.ex") - newContent, err := os.ReadFile(newPath) - if err != nil { - t.Fatalf("cannot read new file: %v", err) - } - if !strings.Contains(string(newContent), "defmodule MyApp.CostCalculatorZ do") { - t.Errorf("expected 'defmodule MyApp.CostCalculatorZ do', got:\n%s", newContent) + expectClientRename(t, edit, defPath, newPath) + if got := bufferAfterEdits(t, edit, defPath, content); !strings.Contains(got, "defmodule MyApp.CostCalculatorZ do") { + t.Errorf("expected 'defmodule MyApp.CostCalculatorZ do', got:\n%s", got) } } @@ -2776,3 +2832,365 @@ end t.Errorf("expected edit containing 'Approvals' in child file, got: %v", childEdits) } } + +// Regression: renaming a module from the file that defines it used to move +// that file on disk while the editor still held the buffer, and hand the +// editor TextEdits for the now-deleted path. Neovim applied the edits to the +// stale buffer, and the next save (`:w`, `:wa`, format-on-save) recreated the +// old file holding the new module name — two files defining the same module, +// and the project stopped compiling. +func TestRename_Module_OpenFileMovedByClientNotServer(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + content := `defmodule MyApp.Accounts do + def list_users, do: [] +end +` + oldPath := filepath.Join(server.projectRoot, "lib", "accounts.ex") + newPath := filepath.Join(server.projectRoot, "lib", "auth.ex") + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", content) + defURI := "file://" + oldPath + server.docs.Set(defURI, content) + + edit := renameAt(t, server, defURI, 0, 20, "Auth") + if edit == nil { + t.Fatal("expected non-nil edit") + } + + // The server must not touch a file the editor has open. + if _, err := os.Stat(oldPath); err != nil { + t.Error("server deleted accounts.ex while the editor had it open") + } + if _, err := os.Stat(newPath); err == nil { + t.Error("server created auth.ex; the client performs the move") + } + + // A client that applies documentChanges ignores changes entirely, so + // nothing may be left there. + if len(edit.Changes) != 0 { + t.Errorf("changes must be empty when documentChanges is used, got %v", edit.Changes) + } + + // The buffer's edits must come before its rename operation, so the edited + // buffer travels to the new path. + oldURI := protocol.DocumentURI(uri.File(oldPath)) + editIdx, renameIdx := -1, -1 + for i, change := range edit.DocumentChanges { + switch c := change.(type) { + case TextDocumentEdit: + if c.TextDocument.URI == oldURI { + editIdx = i + } + case RenameFile: + if c.OldURI == oldURI { + renameIdx = i + } + } + } + if editIdx < 0 { + t.Fatalf("expected text edits for %s, got %+v", oldPath, edit.DocumentChanges) + } + if renameIdx < 0 { + t.Fatalf("expected a rename operation for %s, got %+v", oldPath, edit.DocumentChanges) + } + if editIdx > renameIdx { + t.Error("text edits must precede the rename operation for the same file") + } + if got := bufferAfterEdits(t, edit, oldPath, content); !strings.Contains(got, "defmodule MyApp.Auth") { + t.Errorf("expected the buffer to become 'defmodule MyApp.Auth', got:\n%s", got) + } +} + +// A client that cannot apply rename operations gets the module renamed in +// place. The file keeps its old name, but nothing is deleted underneath an +// open buffer, so no save can resurrect a duplicate module. +func TestRename_Module_OpenFileLeftInPlaceWithoutRenameOps(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + server.renameFileOpsSupported = false + + content := `defmodule MyApp.Accounts do + def list_users, do: [] +end +` + oldPath := filepath.Join(server.projectRoot, "lib", "accounts.ex") + newPath := filepath.Join(server.projectRoot, "lib", "auth.ex") + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", content) + defURI := "file://" + oldPath + server.docs.Set(defURI, content) + + edit := renameAt(t, server, defURI, 0, 20, "Auth") + + if _, err := os.Stat(oldPath); err != nil { + t.Error("expected accounts.ex to stay in place") + } + if _, err := os.Stat(newPath); err == nil { + t.Error("expected no auth.ex — the file cannot move while the client holds it") + } + if len(edit.DocumentChanges) != 0 { + t.Errorf("expected no resource operations, got %+v", edit.DocumentChanges) + } + if got := bufferAfterEdits(t, edit, oldPath, content); !strings.Contains(got, "defmodule MyApp.Auth") { + t.Errorf("expected the buffer to become 'defmodule MyApp.Auth', got:\n%s", got) + } +} + +// The wire format is what the editor actually acts on, and a wrong JSON tag +// would be invisible to every other test here. +func TestWorkspaceEdit_RenameOperationJSON(t *testing.T) { + edit := &WorkspaceEdit{ + DocumentChanges: []interface{}{ + textDocumentEdit(protocol.DocumentURI("file:///p/lib/accounts.ex"), []protocol.TextEdit{{ + Range: protocol.Range{Start: protocol.Position{Line: 0, Character: 16}, End: protocol.Position{Line: 0, Character: 24}}, + NewText: "Auth", + }}), + newRenameFile("/p/lib/accounts.ex", "/p/lib/auth.ex"), + }, + } + + data, err := json.Marshal(edit) + if err != nil { + t.Fatal(err) + } + var got struct { + Changes map[string]interface{} `json:"changes"` + DocumentChanges []struct { + Kind string `json:"kind"` + OldURI string `json:"oldUri"` + NewURI string `json:"newUri"` + TextDocument *struct { + URI string `json:"uri"` + Version interface{} `json:"version"` + } `json:"textDocument"` + Edits []protocol.TextEdit `json:"edits"` + Options *struct { + Overwrite bool `json:"overwrite"` + } `json:"options"` + } `json:"documentChanges"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatal(err) + } + if got.Changes != nil { + t.Errorf("changes must be omitted, got %v", got.Changes) + } + if len(got.DocumentChanges) != 2 { + t.Fatalf("expected 2 documentChanges, got %s", data) + } + first := got.DocumentChanges[0] + if first.TextDocument == nil || first.TextDocument.URI != "file:///p/lib/accounts.ex" { + t.Errorf("first change should be a text document edit, got %s", data) + } + if first.TextDocument != nil && first.TextDocument.Version != nil { + t.Errorf("version must be null, got %v", first.TextDocument.Version) + } + if len(first.Edits) != 1 { + t.Errorf("expected the text edit to survive, got %s", data) + } + second := got.DocumentChanges[1] + if second.Kind != "rename" { + t.Errorf("kind = %q, want \"rename\"", second.Kind) + } + if second.OldURI != "file:///p/lib/accounts.ex" || second.NewURI != "file:///p/lib/auth.ex" { + t.Errorf("rename URIs = %s → %s", second.OldURI, second.NewURI) + } + if second.Options == nil || !second.Options.Overwrite { + t.Errorf("expected overwrite:true, got %s", data) + } +} + +// The rename request must be answered by our own handler: the generated +// dispatcher marshals a protocol.WorkspaceEdit, which has nowhere to put +// resource operations, so a mis-wired handler would silently drop every file +// move. +func TestRenameHandler_RepliesWithResourceOperations(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + content := `defmodule MyApp.Accounts do + def list_users, do: [] +end +` + oldPath := filepath.Join(server.projectRoot, "lib", "accounts.ex") + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", content) + defURI := "file://" + oldPath + server.docs.Set(defURI, content) + + call, err := jsonrpc2.NewCall(jsonrpc2.NewNumberID(1), protocol.MethodTextDocumentRename, &protocol.RenameParams{ + TextDocumentPositionParams: protocol.TextDocumentPositionParams{ + TextDocument: protocol.TextDocumentIdentifier{URI: protocol.DocumentURI(defURI)}, + Position: protocol.Position{Line: 0, Character: 20}, + }, + NewName: "Auth", + }) + if err != nil { + t.Fatal(err) + } + + var replied interface{} + nextCalled := false + handler := server.renameHandler(func(context.Context, jsonrpc2.Replier, jsonrpc2.Request) error { + nextCalled = true + return nil + }) + err = handler(context.Background(), func(_ context.Context, result interface{}, err error) error { + replied = result + return err + }, call) + if err != nil { + t.Fatal(err) + } + if nextCalled { + t.Error("rename must not fall through to the generated dispatcher") + } + + edit, ok := replied.(*WorkspaceEdit) + if !ok { + t.Fatalf("replied with %T, want *WorkspaceEdit", replied) + } + if renameOp(edit, oldPath) == nil { + t.Errorf("reply carries no rename operation: %+v", edit.DocumentChanges) + } +} + +// Regression: `alias Old.{A, B}` names the module once, as the prefix before +// the brace, while the index records one reference per member. Matching a +// member's full name against the line found nothing, so grouped aliases kept +// pointing at the old module and the project stopped compiling. +func TestRename_Module_GroupedAlias(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/shared_lib.ex", `defmodule SharedLib do + def start, do: :ok +end +`) + indexFile(t, server.store, server.projectRoot, "lib/shared_lib/worker.ex", `defmodule SharedLib.Worker do + def call, do: :ok +end +`) + indexFile(t, server.store, server.projectRoot, "lib/shared_lib/config.ex", `defmodule SharedLib.Config do + def get, do: :ok +end +`) + callerContent := `defmodule MyApp.Runner do + alias SharedLib.{Config, Worker} + require SharedLib.{Config, Worker} + + def run do + Config.get() + Worker.call() + end +end +` + callerPath := filepath.Join(server.projectRoot, "lib", "runner.ex") + indexFile(t, server.store, server.projectRoot, "lib/runner.ex", callerContent) + + defPath := filepath.Join(server.projectRoot, "lib", "shared_lib.ex") + defURI := "file://" + defPath + server.docs.Set(defURI, `defmodule SharedLib do + def start, do: :ok +end +`) + + renameAt(t, server, defURI, 0, 10, "CoreLib") + + // The caller is closed, so the server rewrites it on disk + got, err := os.ReadFile(callerPath) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"alias CoreLib.{Config, Worker}", "require CoreLib.{Config, Worker}"} { + if !strings.Contains(string(got), want) { + t.Errorf("expected %q, got:\n%s", want, got) + } + } + if strings.Contains(string(got), "SharedLib") { + t.Errorf("SharedLib should be gone, got:\n%s", got) + } +} + +// Renaming a member of a grouped alias rewrites the member, not the prefix. +func TestRename_Module_GroupedAliasMemberRenamed(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/shared_lib/worker.ex", `defmodule SharedLib.Worker do + def call, do: :ok +end +`) + indexFile(t, server.store, server.projectRoot, "lib/shared_lib/config.ex", `defmodule SharedLib.Config do + def get, do: :ok +end +`) + callerContent := `defmodule MyApp.Runner do + alias SharedLib.{Config, Worker} + + def run, do: Worker.call() +end +` + callerPath := filepath.Join(server.projectRoot, "lib", "runner.ex") + indexFile(t, server.store, server.projectRoot, "lib/runner.ex", callerContent) + + defPath := filepath.Join(server.projectRoot, "lib", "shared_lib", "worker.ex") + defURI := "file://" + defPath + server.docs.Set(defURI, `defmodule SharedLib.Worker do + def call, do: :ok +end +`) + + renameAt(t, server, defURI, 0, 20, "Job") + + got, err := os.ReadFile(callerPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "alias SharedLib.{Config, Job}") { + t.Errorf("expected 'alias SharedLib.{Config, Job}', got:\n%s", got) + } +} + +// Regression: every member of `alias Old.{A, B, C}` is its own indexed +// reference, but they share one edit to the prefix. TextEdits are all relative +// to the original buffer, so emitting the same span once per member made the +// editor apply the replacement repeatedly (Old → NewNewNew...). +func TestRename_Module_GroupedAliasInOpenBufferEditedOnce(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/shared_lib.ex", `defmodule SharedLib do + def start, do: :ok +end +`) + for _, sub := range []string{"Config", "Worker", "Job"} { + indexFile(t, server.store, server.projectRoot, + "lib/shared_lib/"+strings.ToLower(sub)+".ex", + "defmodule SharedLib."+sub+" do\n def call, do: :ok\nend\n") + } + + callerContent := `defmodule MyApp.Runner do + alias SharedLib.{Config, Job, Worker} + + def run, do: {Config.call(), Job.call(), Worker.call()} +end +` + callerPath := filepath.Join(server.projectRoot, "lib", "runner.ex") + indexFile(t, server.store, server.projectRoot, "lib/runner.ex", callerContent) + callerURI := "file://" + callerPath + server.docs.Set(callerURI, callerContent) + + defPath := filepath.Join(server.projectRoot, "lib", "shared_lib.ex") + defURI := "file://" + defPath + server.docs.Set(defURI, `defmodule SharedLib do + def start, do: :ok +end +`) + + edit := renameAt(t, server, defURI, 0, 10, "CoreLib") + + got := bufferAfterEdits(t, edit, callerPath, callerContent) + if !strings.Contains(got, "alias CoreLib.{Config, Job, Worker}") { + t.Errorf("expected 'alias CoreLib.{Config, Job, Worker}', got:\n%s", got) + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 93603cb..242fc8b 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -92,9 +93,10 @@ type Server struct { depsCache map[string]bool // dir → whether files in that dir are deps depsCacheMu sync.RWMutex - conn jsonrpc2.Conn // raw connection for server-initiated requests not on the Client interface - showDocumentSupported bool // client supports window/showDocument (LSP 3.16+) - snippetSupport bool // client supports snippet insert text in completions + conn jsonrpc2.Conn // raw connection for server-initiated requests not on the Client interface + showDocumentSupported bool // client supports window/showDocument (LSP 3.16+) + renameFileOpsSupported bool // client applies rename resource operations in a WorkspaceEdit + snippetSupport bool // client supports snippet insert text in completions reindexing sync.Mutex // serializes concurrent backgroundReindex calls notifiedOTPMismatch sync.Once // prevents repeated OTP mismatch warnings @@ -146,7 +148,7 @@ func Serve(in io.Reader, out io.Writer, s *store.Store, projectRoot string) erro server.client = protocol.ClientDispatcher(conn, logger) server.conn = conn - handler := protocol.ServerHandler(server, nil) + handler := server.renameHandler(protocol.ServerHandler(server, nil)) ctx := context.Background() conn.Go(ctx, handler) @@ -403,6 +405,17 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara if params.Capabilities.Window != nil && params.Capabilities.Window.ShowDocument != nil { s.showDocumentSupported = params.Capabilities.Window.ShowDocument.Support } + // Resource operations only exist inside documentChanges, so advertising + // them implies documentChanges support. Neovim, for one, lists + // resourceOperations without setting the separate documentChanges flag. + if ws := params.Capabilities.Workspace; ws != nil && ws.WorkspaceEdit != nil { + for _, op := range ws.WorkspaceEdit.ResourceOperations { + if op == "rename" { + s.renameFileOpsSupported = true + break + } + } + } if params.Capabilities.TextDocument != nil && params.Capabilities.TextDocument.Completion != nil && params.Capabilities.TextDocument.Completion.CompletionItem != nil { s.snippetSupport = params.Capabilities.TextDocument.Completion.CompletionItem.SnippetSupport @@ -4082,7 +4095,17 @@ func (s *Server) References(ctx context.Context, params *protocol.ReferenceParam return locations, nil } +// Rename implements the protocol.Server interface. It exists only to satisfy +// the generated dispatcher; Serve intercepts textDocument/rename before that +// dispatcher runs so the reply can carry resource operations, which +// protocol.WorkspaceEdit cannot express. func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*protocol.WorkspaceEdit, error) { + edit, err := s.RenameEdit(ctx, params) + return edit.toProtocol(), err +} + +// RenameEdit computes the workspace edit for a textDocument/rename request. +func (s *Server) RenameEdit(ctx context.Context, params *protocol.RenameParams) (*WorkspaceEdit, error) { docURI := string(params.TextDocument.URI) text, ok := s.docs.GetOrLoad(docURI) if !ok { @@ -4122,7 +4145,7 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr NewText: params.NewName, }) } - return &protocol.WorkspaceEdit{Changes: changes}, nil + return &WorkspaceEdit{Changes: changes}, nil } } } @@ -4159,7 +4182,7 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr }) } } - return &protocol.WorkspaceEdit{Changes: changes}, nil + return &WorkspaceEdit{Changes: changes}, nil } } @@ -4193,7 +4216,7 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr if !isValidModuleName(newModule) { return nil, fmt.Errorf("invalid module name %q: must be CamelCase segments separated by dots", params.NewName) } - return s.renameModuleEdits(ctx, fullModule, newModule, uriToPath(params.TextDocument.URI)) + return s.renameModuleEdits(fullModule, newModule) } } } @@ -4203,7 +4226,7 @@ func (s *Server) Rename(ctx context.Context, params *protocol.RenameParams) (*pr // renameFunctionEdits builds a WorkspaceEdit renaming all occurrences of // module.functionName to newName across the codebase. -func (s *Server) renameFunctionEdits(module, functionName, newName string) (*protocol.WorkspaceEdit, error) { +func (s *Server) renameFunctionEdits(module, functionName, newName string) (*WorkspaceEdit, error) { // Collect all (filePath, lineNumber) pairs — definitions + references type siteKey struct { filePath string @@ -4399,8 +4422,9 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*pro // Files not currently open in the editor are written directly to disk in // parallel goroutines. Only open buffers are included in the returned // WorkspaceEdit, keeping the response small and avoiding editor freezes. -// Files following the naming convention are also renamed/moved. -func (s *Server) renameModuleEdits(ctx context.Context, oldModule, newModule, triggerFilePath string) (*protocol.WorkspaceEdit, error) { +// Files following the naming convention are also renamed/moved: closed ones +// by the server, open ones by the client through rename operations. +func (s *Server) renameModuleEdits(oldModule, newModule string) (*WorkspaceEdit, error) { mr := s.buildModuleRename(oldModule, newModule) // Check for collisions: verify that none of the target module names @@ -4414,36 +4438,56 @@ func (s *Server) renameModuleEdits(ctx context.Context, oldModule, newModule, tr fileCache := mr.readFiles() - movedFiles, openMovedFiles, showDocumentPath := mr.moveConventionalFiles(fileCache, triggerFilePath) + movedFiles, clientRenames := mr.moveConventionalFiles(fileCache) openChanges := mr.applyEdits(fileCache, movedFiles) - mr.reindex(fileCache, movedFiles, openMovedFiles) - - // For open files that were moved: send showDocument so the editor opens - // the new path, then delete the old file in the background. - if s.showDocumentSupported && s.conn != nil { - for oldPath, newPath := range openMovedFiles { - showURI := protocol.URI(string(uri.File(newPath))) - takeFocus := newPath == showDocumentPath - go func() { - var result protocol.ShowDocumentResult - _ = protocol.Call(context.Background(), s.conn, "window/showDocument", &protocol.ShowDocumentParams{ - URI: showURI, - TakeFocus: takeFocus, - }, &result) - // Delete old file after the editor has been redirected - _ = os.Remove(oldPath) - _ = s.store.RemoveFile(oldPath) - }() + mr.reindex(fileCache, movedFiles, clientRenames) + + if len(clientRenames) == 0 { + return &WorkspaceEdit{Changes: openChanges}, nil + } + return renamesToDocumentChanges(openChanges, clientRenames), nil +} + +// renamesToDocumentChanges folds the open buffers' text edits and the file +// moves the client must perform into a single ordered documentChanges list. +// +// Each renamed file's text edits come immediately before its rename +// operation: the client edits the buffer in place and then moves it, so the +// buffer follows the file and no stale copy is left behind to be saved back +// over the rename. Text edits and moves cannot be split across `changes` and +// `documentChanges` because a client that understands documentChanges ignores +// `changes` entirely. +func renamesToDocumentChanges(openChanges map[protocol.DocumentURI][]protocol.TextEdit, clientRenames map[string]string) *WorkspaceEdit { + renamedPaths := make([]string, 0, len(clientRenames)) + for oldPath := range clientRenames { + renamedPaths = append(renamedPaths, oldPath) + } + sort.Strings(renamedPaths) + + changes := make([]interface{}, 0, len(openChanges)+len(clientRenames)) + renamedURIs := make(map[protocol.DocumentURI]bool, len(clientRenames)) + for _, oldPath := range renamedPaths { + oldURI := pathToURI(oldPath) + renamedURIs[oldURI] = true + if edits := openChanges[oldURI]; len(edits) > 0 { + changes = append(changes, textDocumentEdit(oldURI, edits)) } - } else if len(openMovedFiles) > 0 { - // Client doesn't support showDocument — still clean up old files - for oldPath := range openMovedFiles { - _ = os.Remove(oldPath) - _ = s.store.RemoveFile(oldPath) + changes = append(changes, newRenameFile(oldPath, clientRenames[oldPath])) + } + + otherURIs := make([]string, 0, len(openChanges)) + for fileURI := range openChanges { + if !renamedURIs[fileURI] { + otherURIs = append(otherURIs, string(fileURI)) } } + sort.Strings(otherURIs) + for _, u := range otherURIs { + fileURI := protocol.DocumentURI(u) + changes = append(changes, textDocumentEdit(fileURI, openChanges[fileURI])) + } - return &protocol.WorkspaceEdit{Changes: openChanges}, nil + return &WorkspaceEdit{DocumentChanges: changes} } // moduleRename holds the state for a module rename operation. @@ -4620,6 +4664,9 @@ func (mr *moduleRename) findModuleEdits(lineText string, token string) []moduleE } return results } + if results := mr.findGroupedAliasEdits(lineText, token, newToken); results != nil { + return results + } oldSuffix := token newSuffix := newToken for { @@ -4647,6 +4694,50 @@ func (mr *moduleRename) findModuleEdits(lineText string, token string) []moduleE return nil } +// findGroupedAliasEdits handles `alias Prefix.{A, B}` (and the require/import +// forms), where the module name is written once as the prefix and each member +// is indexed as its own reference — so the reference's full name never appears +// on the line. +// +// Which half moves depends on the rename: renaming the prefix rewrites the +// prefix, renaming a member rewrites that member inside the braces. Sites for +// the other members on the same line find nothing once the prefix is rewritten, +// so a group is only edited once. +func (mr *moduleRename) findGroupedAliasEdits(lineText, token, newToken string) []moduleEditResult { + dot := strings.LastIndexByte(token, '.') + if dot <= 0 { + return nil + } + prefix, member := token[:dot], token[dot+1:] + prefixCol, groupStart, groupEnd := findGroupedAlias(lineText, prefix) + if prefixCol < 0 { + return nil + } + memberCols := findAllTokenColumns(lineText[groupStart:groupEnd], member) + if len(memberCols) == 0 { + return nil + } + + newDot := strings.LastIndexByte(newToken, '.') + if newDot <= 0 { + // The member lost its namespace; a grouped alias cannot express that. + return nil + } + if newPrefix := newToken[:newDot]; newPrefix != prefix { + return []moduleEditResult{{prefixCol, len(prefix), newPrefix}} + } + + newMember := newToken[newDot+1:] + if newMember == member { + return nil + } + results := make([]moduleEditResult, 0, len(memberCols)) + for _, col := range memberCols { + results = append(results, moduleEditResult{groupStart + col, len(member), newMember}) + } + return results +} + type moduleEditResult struct { col int length int @@ -4703,14 +4794,21 @@ func (mr *moduleRename) conventionalNewPath(r store.LookupResult) (string, bool) return filepath.Join(prefix, filepath.FromSlash(newSuffix)), true } -// moveConventionalFiles moves files that follow the naming convention to their -// new paths, applying edits in the process. Open files are NOT moved on disk — -// they are left for applyEdits to handle via TextEdits so the editor buffer -// stays in sync. Returns moved files, paths that need showDocument calls -// (open files that were moved), and the path to show for the trigger file. -func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInfo, triggerFilePath string) (movedFiles map[string]string, openMovedFiles map[string]string, showDocumentPath string) { +// moveConventionalFiles moves files that follow the naming convention to +// their new paths, applying edits in the process. +// +// Files open in the editor are NOT moved here when the client can apply +// rename resource operations: the client owns the buffer, so it must move the +// file itself (see renamesToDocumentChanges). Moving it behind the client's +// back leaves the editor with a modified buffer pointing at a deleted path, +// and saving that buffer recreates the old file with the new module name. +// +// Returns the files moved on disk, the moves left to the client, the open +// files moved on disk anyway (fallback clients, which need showDocument and a +// deferred delete), and the path to show for the trigger file. +func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInfo) (movedFiles, clientRenames map[string]string) { movedFiles = make(map[string]string) - openMovedFiles = make(map[string]string) + clientRenames = make(map[string]string) for _, r := range mr.allModuleDefs { if _, ok := mr.moduleRenames[r.Module]; !ok { continue @@ -4724,26 +4822,21 @@ func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInf continue } - // Open files: write the new file to disk but DON'T delete the old one - // or mark it in movedFiles. Instead track it in openMovedFiles so that - // applyEdits still produces TextEdits for the editor buffer, and we - // send showDocument to redirect the editor to the new path. if fi.open { - updatedLines := mr.applyEditsToLines(fi.lines, mr.sitesByFile[r.FilePath]) - content := strings.Join(updatedLines, "\n") - if err := os.MkdirAll(filepath.Dir(newPath), 0755); err != nil { - log.Printf("Rename: cannot create dir for %s: %v", newPath, err) - continue - } - if err := os.WriteFile(newPath, []byte(content), 0644); err != nil { - log.Printf("Rename: cannot write %s: %v", newPath, err) + // Client applies rename operations: leave both paths untouched. + // applyEdits still emits TextEdits for the old URI, and the rename + // operation queued after them carries the edited buffer to the new + // path. + if mr.server.renameFileOpsSupported { + mr.server.debugf("Rename: %s → %s (client-applied)", r.FilePath, newPath) + clientRenames[r.FilePath] = newPath continue } - mr.server.debugf("Rename: %s → %s (open, deferred delete)", r.FilePath, newPath) - openMovedFiles[r.FilePath] = newPath - if r.FilePath == triggerFilePath && showDocumentPath == "" { - showDocumentPath = newPath - } + // Client cannot move the file and we must not do it behind its + // back: deleting a path the editor still has open leaves a buffer + // that recreates the file on the next save. Rename the contents in + // place and leave the file where it is. + log.Printf("Rename: leaving %s in place — client cannot apply rename operations and the file is open", r.FilePath) continue } @@ -4758,13 +4851,14 @@ func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInf log.Printf("Rename: cannot write %s: %v", newPath, err) continue } + if err := os.Remove(r.FilePath); err != nil { log.Printf("Rename: cannot remove %s: %v", r.FilePath, err) } mr.server.debugf("Rename: %s → %s", r.FilePath, newPath) movedFiles[r.FilePath] = newPath } - return movedFiles, openMovedFiles, showDocumentPath + return movedFiles, clientRenames } // applyEdits applies text edits to all non-moved files: open buffers get @@ -4783,12 +4877,24 @@ func (mr *moduleRename) applyEdits(fileCache map[string]moduleFileInfo, movedFil } if fi.open { fileURI := protocol.DocumentURI(uri.File(fp)) + // Each site is matched against the original line, so two sites can + // resolve to the same span — `alias Old.{A, B}` is one reference + // per member but a single edit to the shared prefix. The on-disk + // path rewrites the line as it goes and never sees the second + // match; TextEdits are all relative to the original text, so + // overlapping ones have to be dropped here or the editor applies + // the replacement twice. + claimed := make(map[int][]moduleEditResult) for _, es := range sites { if es.line-1 >= len(fi.lines) { continue } lineText := fi.lines[es.line-1] for _, e := range mr.findModuleEdits(lineText, es.token) { + if overlapsClaimed(claimed[es.line], e) { + continue + } + claimed[es.line] = append(claimed[es.line], e) openChanges[fileURI] = append(openChanges[fileURI], protocol.TextEdit{ Range: protocol.Range{ Start: protocol.Position{Line: uint32(es.line - 1), Character: uint32(e.col)}, @@ -4813,11 +4919,30 @@ func (mr *moduleRename) applyEdits(fileCache map[string]moduleFileInfo, movedFil return openChanges } +// overlapsClaimed reports whether e covers any column already taken by an +// edit on the same line. +func overlapsClaimed(claimed []moduleEditResult, e moduleEditResult) bool { + for _, c := range claimed { + if e.col < c.col+c.length && c.col < e.col+e.length { + return true + } + } + return false +} + // reindex re-parses all touched files asynchronously after the rename. -func (mr *moduleRename) reindex(fileCache map[string]moduleFileInfo, movedFiles map[string]string, openMovedFiles map[string]string) { +// +// movedFiles were moved on disk by the server, so their new paths are read +// back from disk. clientRenames have not moved yet — the client applies them +// when it receives the reply — so their new paths are indexed from the text +// the edits produce. +func (mr *moduleRename) reindex(fileCache map[string]moduleFileInfo, movedFiles, clientRenames map[string]string) { for oldPath := range movedFiles { _ = mr.server.store.RemoveFile(oldPath) } + for oldPath := range clientRenames { + _ = mr.server.store.RemoveFile(oldPath) + } var reindexPaths []string for _, newPath := range movedFiles { @@ -4839,8 +4964,8 @@ func (mr *moduleRename) reindex(fileCache map[string]moduleFileInfo, movedFiles } updatedLines := mr.applyEditsToLines(fi.lines, mr.sitesByFile[fp]) updatedText := strings.Join(updatedLines, "\n") - if newPath, moved := openMovedFiles[fp]; moved { - // Open file that was moved: reindex at the new path + if newPath, moved := clientRenames[fp]; moved { + // Open file the client is about to move: index at the new path openReindexes = append(openReindexes, textReindex{newPath, updatedText}) } else if fi.open { openReindexes = append(openReindexes, textReindex{fp, updatedText}) @@ -4849,11 +4974,15 @@ func (mr *moduleRename) reindex(fileCache map[string]moduleFileInfo, movedFiles } } - // Also reindex open moved files that had no edit sites (e.g. the file + // Also index client-renamed files that had no edit sites (e.g. the file // only contained the defmodule line which is already in allModuleDefs) - for oldPath, newPath := range openMovedFiles { - if _, hasSites := mr.sitesByFile[oldPath]; !hasSites { - reindexPaths = append(reindexPaths, newPath) + for oldPath, newPath := range clientRenames { + if _, hasSites := mr.sitesByFile[oldPath]; hasSites { + continue + } + if fi, ok := fileCache[oldPath]; ok { + updatedLines := mr.applyEditsToLines(fi.lines, nil) + openReindexes = append(openReindexes, textReindex{newPath, strings.Join(updatedLines, "\n")}) } } @@ -4880,7 +5009,7 @@ type renameSite struct { // buildTextEdits creates a WorkspaceEdit replacing all whole-token occurrences // of oldToken with newToken. Open buffers are returned in the WorkspaceEdit; // closed files are written directly to disk in parallel goroutines. -func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) *protocol.WorkspaceEdit { +func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) *WorkspaceEdit { // Group sites by file sitesByFile := make(map[string][]renameSite, len(sites)) for _, site := range sites { @@ -5012,7 +5141,7 @@ func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) * } }() - return &protocol.WorkspaceEdit{Changes: openChanges} + return &WorkspaceEdit{Changes: openChanges} } // reindexPaths re-parses and reindexes a specific set of files sequentially. diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index a9fecd8..e4e739f 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -29,6 +29,9 @@ func setupTestServer(t *testing.T) (*Server, func()) { server := NewServer(s, dir) server.snippetSupport = true + // Match real editors (Neovim, VS Code, Helix, Zed): they all apply rename + // resource operations. Fallback behaviour has its own tests. + server.renameFileOpsSupported = true // Resolve the mix binary so formatting tests work if p, err := exec.LookPath("mix"); err == nil { diff --git a/internal/lsp/workspace_edit.go b/internal/lsp/workspace_edit.go new file mode 100644 index 0000000..19aa39a --- /dev/null +++ b/internal/lsp/workspace_edit.go @@ -0,0 +1,113 @@ +package lsp + +import ( + "context" + "encoding/json" + "fmt" + + "go.lsp.dev/jsonrpc2" + "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +// WorkspaceEdit is our own workspace edit type. go.lsp.dev/protocol's +// WorkspaceEdit types documentChanges as []TextDocumentEdit, so it cannot +// express resource operations (create/rename/delete file). We need rename +// operations: when a module rename moves a file that is open in the editor, +// the editor itself must move the buffer, otherwise it is left holding a +// modified buffer pointing at a path the server deleted — saving it recreates +// the old file with the new module name and the project no longer compiles. +// +// Per the LSP spec a client that supports documentChanges must ignore +// changes entirely when documentChanges is present, so the two fields are +// mutually exclusive: emit everything through documentChanges as soon as one +// resource operation is needed. +type WorkspaceEdit struct { + Changes map[protocol.DocumentURI][]protocol.TextEdit `json:"changes,omitempty"` + DocumentChanges []interface{} `json:"documentChanges,omitempty"` +} + +// TextDocumentEdit is a documentChanges entry holding text edits for one +// document. Version is always null: we never track buffer versions, and the +// spec allows a null version to mean "apply without a version check". +type TextDocumentEdit struct { + TextDocument versionedTextDocumentIdentifier `json:"textDocument"` + Edits []protocol.TextEdit `json:"edits"` +} + +type versionedTextDocumentIdentifier struct { + URI protocol.DocumentURI `json:"uri"` + Version *int `json:"version"` +} + +// RenameFile is a documentChanges entry that moves a file. Clients apply +// documentChanges in order, so a TextDocumentEdit for OldURI placed before +// this operation is applied to the buffer first and then travels with it. +type RenameFile struct { + Kind string `json:"kind"` // always "rename" + OldURI protocol.DocumentURI `json:"oldUri"` + NewURI protocol.DocumentURI `json:"newUri"` + Options *RenameFileOptions `json:"options,omitempty"` +} + +type RenameFileOptions struct { + Overwrite bool `json:"overwrite,omitempty"` + IgnoreIfExists bool `json:"ignoreIfExists,omitempty"` +} + +// pathToURI converts a filesystem path to a document URI. +func pathToURI(path string) protocol.DocumentURI { + return protocol.DocumentURI(uri.File(path)) +} + +// newRenameFile builds a rename operation for the given paths. +func newRenameFile(oldPath, newPath string) RenameFile { + return RenameFile{ + Kind: "rename", + OldURI: pathToURI(oldPath), + NewURI: pathToURI(newPath), + Options: &RenameFileOptions{Overwrite: true}, + } +} + +// textDocumentEdit builds a documentChanges entry for a single document. +func textDocumentEdit(fileURI protocol.DocumentURI, edits []protocol.TextEdit) TextDocumentEdit { + return TextDocumentEdit{ + TextDocument: versionedTextDocumentIdentifier{URI: fileURI}, + Edits: edits, + } +} + +// toProtocol degrades a WorkspaceEdit to the protocol type, dropping resource +// operations. Only used by the protocol.Server interface shim; the production +// path replies with the full type through the handler in Serve. +func (e *WorkspaceEdit) toProtocol() *protocol.WorkspaceEdit { + if e == nil { + return nil + } + return &protocol.WorkspaceEdit{Changes: e.Changes} +} + +// renameHandler intercepts textDocument/rename so the reply can carry +// resource operations. protocol.ServerHandler marshals whatever +// Server.Rename returns, and protocol.WorkspaceEdit has no field for them, +// so the request has to be answered before it reaches that dispatcher. +func (s *Server) renameHandler(next jsonrpc2.Handler) jsonrpc2.Handler { + return func(ctx context.Context, reply jsonrpc2.Replier, req jsonrpc2.Request) error { + if req.Method() != protocol.MethodTextDocumentRename { + return next(ctx, reply, req) + } + var params protocol.RenameParams + if err := json.Unmarshal(req.Params(), ¶ms); err != nil { + return reply(ctx, nil, fmt.Errorf("%w: %v", jsonrpc2.ErrParse, err)) + } + edit, err := s.RenameEdit(ctx, ¶ms) + if err != nil { + return reply(ctx, nil, err) + } + if edit == nil { + return reply(ctx, nil, nil) + } + return reply(ctx, edit, nil) + } +} From 6cd8f4c1425b7c467a25a05256d78987aba50e92 Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Mon, 7 Sep 2026 17:09:54 -0400 Subject: [PATCH 07/17] Document attached MCP watcher and atomic renames --- CHANGELOG.md | 2 +- README.md | 2 +- docs/architecture.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f6af2..bdb92fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Ten tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, incremental reindexing, and workspace-wide rename with the same on-disk semantics as the editor rename. The headless server watches the project tree (fsnotify) so the index stays fresh without editor events. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide +- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Ten tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, incremental reindexing, and workspace-wide rename with the same on-disk semantics as the editor rename. Both headless and attached LSP+MCP modes watch the project tree (fsnotify) so the index stays fresh when agent edits bypass editor events. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide ### Fixed diff --git a/README.md b/README.md index 25625a6..d859037 100644 --- a/README.md +++ b/README.md @@ -464,7 +464,7 @@ Register it with your MCP client. For Claude Code: claude mcp add dexter -- dexter mcp ``` -Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server indexes the project on first use and keeps the index fresh by watching the project tree (fsnotify) and detecting git branch switches; a `dexter_reindex` tool forces an immediate update if a lookup ever seems stale. +Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server indexes the project on first use and keeps the index fresh by watching the project tree (fsnotify) and detecting git branch switches; a `dexter_reindex` tool forces an immediate update if a lookup ever seems stale. Attached LSP+MCP mode also watches the tree, so edits made directly by an agent are indexed even when they bypass editor notifications. Useful variants: diff --git a/docs/architecture.md b/docs/architecture.md index 276ffb1..7bc7eab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ A module rename also moves files whose names follow the module naming convention `protocol.WorkspaceEdit` from `go.lsp.dev/protocol` types `documentChanges` as `[]TextDocumentEdit` and cannot carry resource operations, so `internal/lsp/workspace_edit.go` defines the wire types and `renameHandler` answers `textDocument/rename` ahead of the generated dispatcher. A client that understands `documentChanges` ignores `changes` entirely, so once one file moves, every edit in the reply goes through `documentChanges`. -For a rename the MCP server asked for rather than an editor, `deliverEdits` plays the part the editor would: attached to a live session it forwards the whole edit as `workspace/applyEdit` — over the raw connection, since `protocol.ApplyWorkspaceEditParams` drops resource operations for the same reason — and headless it carries out the edits and moves on disk itself. Headless has no open buffers, so it never produces a client-side move; that branch is defensive. +For a rename the MCP server asked for rather than an editor, the builders keep every affected file and move in one `WorkspaceEdit`. In attached mode, `deliverEdits` forwards that complete edit as `workspace/applyEdit` — over the raw connection, since `protocol.ApplyWorkspaceEditParams` drops resource operations — and updates buffers and the index only after the editor accepts it. A rejected edit therefore leaves closed files untouched too. In headless mode, `deliverEdits` applies the same complete edit and moves on disk itself. ### Grouped aliases From 91f63af5ad884872a6c955ec3f0af695c8ce0ae3 Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Mon, 7 Sep 2026 17:24:31 -0400 Subject: [PATCH 08/17] Fix attached MCP capability handling --- cmd/main.go | 9 +++++- internal/lsp/api.go | 6 ++-- internal/lsp/api_test.go | 56 +++++++++++++++++++++++++++++++++ internal/lsp/server.go | 11 +++++-- internal/mcp/file_outline.go | 9 +++--- internal/mcp/implementations.go | 45 +++++++++++++++++++++++++- internal/mcp/mcp_test.go | 3 +- internal/mcp/tools_test.go | 33 +++++++++++++++++++ 8 files changed, 160 insertions(+), 12 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 7729c29..dcc576f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -406,7 +406,14 @@ func cmdLSP(projectRoot string, mcpListen string) { go func() { serveErrCh <- dexter_lsp.Serve(server, os.Stdin, os.Stdout) }() - <-server.Ready() + select { + case <-server.Ready(): + case err := <-serveErrCh: + if err == nil { + err = fmt.Errorf("LSP connection closed before initialization") + } + fatal(err) + } watcher, err := dexter_mcp.WatchFiles(server, s, projectRoot) if err != nil { diff --git a/internal/lsp/api.go b/internal/lsp/api.go index 96f2226..70b9cd8 100644 --- a/internal/lsp/api.go +++ b/internal/lsp/api.go @@ -31,7 +31,6 @@ func Serve(server *Server, in io.Reader, out io.Writer) error { conn := jsonrpc2.NewConn(stream) server.client = protocol.ClientDispatcher(conn, logger) server.conn = conn - close(server.ready) handler := server.renameHandler(protocol.ServerHandler(server, nil)) ctx := context.Background() @@ -41,8 +40,9 @@ func Serve(server *Server, in io.Reader, out io.Writer) error { return conn.Err() } -// Ready is closed after Serve has installed the live LSP connection. Attached -// services must wait for it before accepting requests that can apply edits. +// Ready is closed after the LSP initialize request has been handled. Attached +// services must wait for it before accepting requests so client capabilities, +// stdlib discovery, and the live connection are all available. func (s *Server) Ready() <-chan struct{} { return s.ready } diff --git a/internal/lsp/api_test.go b/internal/lsp/api_test.go index c45b1b3..401badc 100644 --- a/internal/lsp/api_test.go +++ b/internal/lsp/api_test.go @@ -56,6 +56,25 @@ func TestApplyTextEdits(t *testing.T) { } } +func TestReadyWaitsForInitialize(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + select { + case <-server.Ready(): + t.Fatal("server reported ready before LSP initialization") + default: + } + if _, err := server.Initialize(context.Background(), &protocol.InitializeParams{}); err != nil { + t.Fatal(err) + } + select { + case <-server.Ready(): + default: + t.Fatal("server did not report ready after LSP initialization") + } +} + // Without a live client, a rename requested through the exported API must // land on disk even for files marked open (the defensive fallback path). func TestRenameFunction_WritesOpenBuffers(t *testing.T) { @@ -210,6 +229,7 @@ func TestRenameModule_ForwardsFileMoveToClient(t *testing.T) { defer cleanup() fc := &fakeConn{} server.conn = fc + server.renameFileOpsSupported = true src := `defmodule MyApp.Accounts do def list_users, do: [] @@ -266,6 +286,42 @@ end } } +func TestRenameModule_LeavesConventionalFileInPlaceWithoutClientMoveSupport(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + fc := &fakeConn{} + server.conn = fc + server.renameFileOpsSupported = false + + src := `defmodule MyApp.Accounts do + def list_users, do: [] +end +` + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", src) + oldPath := filepath.Join(server.projectRoot, "lib/accounts.ex") + newPath := filepath.Join(server.projectRoot, "lib/auth.ex") + + summary, err := server.RenameModule("MyApp.Accounts", "MyApp.Auth") + if err != nil { + t.Fatal(err) + } + + for _, change := range fc.applied.DocumentChanges { + if _, ok := change.(RenameFile); ok { + t.Fatal("applyEdit included a rename operation the client does not support") + } + } + if _, err := os.Stat(oldPath); err != nil { + t.Errorf("source file should remain at its old path: %v", err) + } + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + t.Errorf("destination should not be created, stat error = %v", err) + } + if len(summary.FilesMoved) != 0 { + t.Errorf("summary reported unsupported moves: %v", summary.FilesMoved) + } +} + func TestRenameModule_RejectedApplyEditLeavesDiskUntouched(t *testing.T) { server, cleanup := setupTestServer(t) defer cleanup() diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 057d1ea..66b71b6 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -112,7 +112,8 @@ type Server struct { notifiedOTPMismatch sync.Once // prevents repeated OTP mismatch warnings backgroundWork sync.WaitGroup // tracks background reindex goroutines so the store isn't closed while they're running - ready chan struct{} // closed once Serve has installed the LSP connection + ready chan struct{} // closed once the LSP initialize request has completed + readyOnce sync.Once } func (s *Server) debugf(format string, args ...interface{}) { @@ -686,6 +687,7 @@ func (s *Server) Initialize(ctx context.Context, params *protocol.InitializePara }, } s.debugf("Initialize: capabilities: %+v", result.Capabilities) + s.readyOnce.Do(func() { close(s.ready) }) return result, nil } @@ -5059,7 +5061,12 @@ func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInf continue } if deliverAll { - clientRenames[r.FilePath] = newPath + // Headless callers encode moves in the edit and deliverEdits applies + // them on disk. Attached callers can forward them only when the live + // editor supports rename resource operations. + if mr.server.conn == nil || mr.server.renameFileOpsSupported { + clientRenames[r.FilePath] = newPath + } continue } diff --git a/internal/mcp/file_outline.go b/internal/mcp/file_outline.go index e74390f..c10c79a 100644 --- a/internal/mcp/file_outline.go +++ b/internal/mcp/file_outline.go @@ -3,7 +3,6 @@ package mcp import ( "context" "fmt" - "os" "sort" "strings" @@ -21,12 +20,14 @@ func (h *Handler) fileOutlineHandler(ctx context.Context, req *mcp.CallToolReque return nil, nil, fmt.Errorf("file must not be empty") } path := h.resolvePath(args.File) - if _, err := os.Stat(path); err != nil { + text, _, ok := h.lsp.ReadFileText(path) + if !ok { return textResult(fmt.Sprintf("File not found: %s", h.relPath(path))), nil, nil } - // Parse fresh from disk so the outline is correct even when the index is stale. - defs, _, err := parser.ParseFile(path) + // Parse fresh source so the outline is correct when either the index is + // stale or an attached editor has unsaved changes. + defs, _, err := parser.ParseText(path, text) if err != nil { return nil, nil, fmt.Errorf("parsing %s: %w", h.relPath(path), err) } diff --git a/internal/mcp/implementations.go b/internal/mcp/implementations.go index 6273eee..2b0b9e1 100644 --- a/internal/mcp/implementations.go +++ b/internal/mcp/implementations.go @@ -10,7 +10,7 @@ import ( type ImplementationsParams struct { Module string `json:"module" jsonschema:"behaviour or protocol module, fully qualified"` - Function string `json:"function,omitempty" jsonschema:"callback name; when set, locate its definition in each implementor"` + Function string `json:"function,omitempty" jsonschema:"callback or protocol function name; when set, locate its definition in each implementor"` } func (h *Handler) implementationsHandler(ctx context.Context, req *mcp.CallToolRequest, args ImplementationsParams) (*mcp.CallToolResult, any, error) { @@ -39,6 +39,49 @@ func (h *Handler) implementationsHandler(ctx context.Context, req *mcp.CallToolR if isProtocol { var b strings.Builder fmt.Fprintf(&b, "%s is a protocol (defprotocol at %s:%d).\n", module, h.relPath(modResults[decls[0]].FilePath), modResults[decls[0]].Line) + if function := strings.TrimSpace(args.Function); function != "" { + defs, err := h.store.LookupFunction(module, function) + if err != nil { + return nil, nil, fmt.Errorf("looking up protocol function: %w", err) + } + + // Functions in defprotocol and defimpl blocks share the protocol's + // module name in the index. Attribute each definition to the nearest + // preceding declaration in its file so a declaration and one or more + // implementations can safely coexist in the same file. + scopeKind := func(filePath string, line int) string { + kind, scopeLine := "", -1 + for _, r := range modResults { + if r.FilePath == filePath && r.Line <= line && r.Line > scopeLine { + kind, scopeLine = r.Kind, r.Line + } + } + return kind + } + arities := make(map[int]bool) + for _, d := range defs { + if scopeKind(d.FilePath, d.Line) == "defprotocol" { + arities[d.Arity] = true + } + } + if len(arities) == 0 { + return textResult(fmt.Sprintf("%s does not define a protocol function named %s. List its functions with dexter_module_api.", module, function)), nil, nil + } + + fmt.Fprintf(&b, "\nImplementations of protocol function %s.%s:\n", module, function) + found := 0 + for _, d := range defs { + if !arities[d.Arity] || scopeKind(d.FilePath, d.Line) != "defimpl" { + continue + } + fmt.Fprintf(&b, " %s - %s:%d\n", symbolName(module, function, d.Arity), h.relPath(d.FilePath), d.Line) + found++ + } + if found == 0 { + fmt.Fprintf(&b, " (no indexed defimpl defines %s)\n", function) + } + return textResult(b.String()), nil, nil + } if len(impls) == 0 { fmt.Fprintf(&b, "No defimpl implementations found in the index.\n") return textResult(b.String()), nil, nil diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go index 90eeff8..5ae6036 100644 --- a/internal/mcp/mcp_test.go +++ b/internal/mcp/mcp_test.go @@ -22,6 +22,7 @@ import ( type testEnv struct { t *testing.T store *store.Store + lsp *lsp.Server root string session *mcp.ClientSession } @@ -56,7 +57,7 @@ func setupTestEnv(t *testing.T) *testEnv { } t.Cleanup(func() { _ = session.Close() }) - return &testEnv{t: t, store: s, root: root, session: session} + return &testEnv{t: t, store: s, lsp: server, root: root, session: session} } // indexFile writes an Elixir source file under the project root and indexes it. diff --git a/internal/mcp/tools_test.go b/internal/mcp/tools_test.go index bdeabcc..95f7194 100644 --- a/internal/mcp/tools_test.go +++ b/internal/mcp/tools_test.go @@ -1,11 +1,15 @@ package mcp import ( + "context" "fmt" "os" "path/filepath" "strings" "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" ) const accountsSource = `defmodule MyApp.Accounts do @@ -219,6 +223,27 @@ end ) } +func TestFileOutlineTool_UsesOpenBuffer(t *testing.T) { + e := setupProject(t) + path := filepath.Join(e.root, "lib/my_app/accounts.ex") + buffer := `defmodule MyApp.Accounts do + def unsaved_function, do: :ok +end +` + if err := e.lsp.DidOpen(context.Background(), &protocol.DidOpenTextDocumentParams{ + TextDocument: protocol.TextDocumentItem{ + URI: protocol.DocumentURI(uri.File(path)), + Text: buffer, + }, + }); err != nil { + t.Fatal(err) + } + + out := e.callTool("dexter_file_outline", map[string]any{"file": "lib/my_app/accounts.ex"}) + wantContains(t, out, "def unsaved_function/0") + wantNotContains(t, out, "def fetch_user/1") +} + func TestImplementationsTool_Behaviour(t *testing.T) { e := setupProject(t) e.indexFile("lib/my_app/notifier.ex", `defmodule MyApp.Notifier do @@ -270,6 +295,14 @@ end "lib/my_app/size_impls.ex:1", "lib/my_app/size_impls.ex:5", ) + + out = e.callTool("dexter_implementations", map[string]any{"module": "MyApp.Size", "function": "size"}) + wantContains(t, out, + "Implementations of protocol function MyApp.Size.size", + "lib/my_app/size_impls.ex:2", + "lib/my_app/size_impls.ex:6", + ) + wantNotContains(t, out, "lib/my_app/size_impls.ex:1", "lib/my_app/size_impls.ex:5") } func TestCallHierarchyTool(t *testing.T) { From 2536cb7a9926509caab57816e1691b973a4cf784 Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Mon, 7 Sep 2026 17:27:44 -0400 Subject: [PATCH 09/17] Initialize LSP in attached MCP integration test --- integration_test.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/integration_test.go b/integration_test.go index 7867208..f8ab35b 100644 --- a/integration_test.go +++ b/integration_test.go @@ -3,6 +3,9 @@ package main import ( "bufio" "context" + "encoding/json" + "fmt" + "io" "os" "os/exec" "path/filepath" @@ -11,6 +14,7 @@ import ( "time" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "go.lsp.dev/uri" "github.com/remoteoss/dexter/internal/store" ) @@ -685,6 +689,10 @@ func TestIntegration_LSPWithMCPListen(t *testing.T) { if err != nil { t.Fatal(err) } + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatal(err) + } stderr, err := cmd.StderrPipe() if err != nil { t.Fatal(err) @@ -692,12 +700,31 @@ func TestIntegration_LSPWithMCPListen(t *testing.T) { if err := cmd.Start(); err != nil { t.Fatal(err) } + go func() { _, _ = io.Copy(io.Discard, stdout) }() t.Cleanup(func() { _ = stdin.Close() _ = cmd.Process.Kill() _, _ = cmd.Process.Wait() }) + // Attached MCP deliberately waits until initialize has populated the live + // client's capabilities before it starts accepting requests. + initialize, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": map[string]any{ + "rootUri": string(uri.File(root)), + "capabilities": map[string]any{}, + }, + }) + if err != nil { + t.Fatal(err) + } + if _, err := fmt.Fprintf(stdin, "Content-Length: %d\r\n\r\n%s", len(initialize), initialize); err != nil { + t.Fatal(err) + } + // Parse the bound address from stderr. addrCh := make(chan string, 1) go func() { From a5c2214b98d104dbcc765ff70bfd20bc33c887bb Mon Sep 17 00:00:00 2001 From: Jesse Herrick Date: Mon, 7 Sep 2026 17:29:51 -0400 Subject: [PATCH 10/17] Stabilize sequential module rename test --- internal/lsp/rename_test.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/lsp/rename_test.go b/internal/lsp/rename_test.go index 2504e3f..210af25 100644 --- a/internal/lsp/rename_test.go +++ b/internal/lsp/rename_test.go @@ -1628,7 +1628,9 @@ end }) // Re-index with original content for test 2, and close the def file so it - // takes the closed-file path (moved on disk by the server) + // takes the closed-file path (moved on disk by the server). Wait for the + // first rename's asynchronous index bookkeeping before restoring it. + server.backgroundWork.Wait() server.docs.Close("file://" + oldPath) indexFile(t, server.store, server.projectRoot, "lib/docusign.ex", defContent) From 70d832ee11c9ab75e607ea73e92870cd3f0da5ee Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 9 Sep 2026 09:21:12 +1000 Subject: [PATCH 11/17] Negotiate MCP workspace roots per session The headless MCP server bound its workspace to the launch directory before the session existed, indexing the wrong tree when the client started elsewhere. Without an explicit path it now obtains each session's root through MCP roots and resolves it the way the LSP resolves its own (existing .dexter index, then .git), opening the store and indexing only after resolution. Every resolved root gets one workspace, shared by sessions that resolve to it and torn down when the last one leaves, so --listen serves sessions from different projects concurrently. A roots/list_changed notification renegotiates on the session's next call. Clients that provide no usable root fall back to the launch directory; an explicit CLI path keeps today's fixed, eagerly indexed workspace, and attached mode still follows the LSP root. --- CHANGELOG.md | 2 +- README.md | 2 +- cmd/main.go | 70 +++--- internal/lsp/api.go | 9 + internal/lsp/api_test.go | 29 +++ internal/lsp/server.go | 14 +- internal/mcp/binding.go | 136 ++++++++++++ internal/mcp/mcp.go | 274 ++++++++++++++++++++--- internal/mcp/negotiation_test.go | 361 +++++++++++++++++++++++++++++++ internal/mcp/roots.go | 66 ++++++ 10 files changed, 905 insertions(+), 58 deletions(-) create mode 100644 internal/mcp/binding.go create mode 100644 internal/mcp/negotiation_test.go create mode 100644 internal/mcp/roots.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bdb92fe..97fe490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Added -- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Ten tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, incremental reindexing, and workspace-wide rename with the same on-disk semantics as the editor rename. Both headless and attached LSP+MCP modes watch the project tree (fsnotify) so the index stays fresh when agent edits bypass editor events. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide +- **Built-in MCP server** - `dexter mcp` serves the index to AI agents over the Model Context Protocol (stdio, or streamable HTTP with `--listen`), modeled on `gopls mcp`. Ten tools cover workspace overview, fuzzy symbol search, definitions with docs and specs, references (including use-chain injected call sites), module API summaries, file outlines, behaviour/protocol implementations, call hierarchy, incremental reindexing, and workspace-wide rename with the same on-disk semantics as the editor rename. Both headless and attached LSP+MCP modes watch the project tree (fsnotify) so the index stays fresh when agent edits bypass editor events. A running LSP can expose the same tools from its live session via `dexter lsp --mcp-listen=ADDR`, and `dexter mcp --instructions` prints an agent-facing usage guide. The headless server negotiates its workspace root per session through MCP roots, resolved the way the LSP resolves its own (existing `.dexter` index, then `.git`), with one workspace per resolved root in `--listen` mode; an explicit path argument overrides negotiation ### Fixed diff --git a/README.md b/README.md index d859037..3addfe0 100644 --- a/README.md +++ b/README.md @@ -464,7 +464,7 @@ Register it with your MCP client. For Claude Code: claude mcp add dexter -- dexter mcp ``` -Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server indexes the project on first use and keeps the index fresh by watching the project tree (fsnotify) and detecting git branch switches; a `dexter_reindex` tool forces an immediate update if a lookup ever seems stale. Attached LSP+MCP mode also watches the tree, so edits made directly by an agent are indexed even when they bypass editor notifications. +Any client that speaks MCP over stdio works the same way: point it at `dexter mcp`. The server obtains its workspace from the client through MCP roots and resolves it the way the LSP does (an existing `.dexter` index first, then the `.git` repository root), so it binds the project the client is working in rather than the directory it was launched from; clients that provide no roots get the launch directory, and an explicit path argument (`dexter mcp `) overrides negotiation entirely. In `--listen` mode each resolved root gets its own workspace, so sessions from different projects can share one server. The server indexes a workspace on first use and keeps the index fresh by watching the project tree (fsnotify) and detecting git branch switches; a `dexter_reindex` tool forces an immediate update if a lookup ever seems stale. Attached LSP+MCP mode also watches the tree, so edits made directly by an agent are indexed even when they bypass editor notifications. Useful variants: diff --git a/cmd/main.go b/cmd/main.go index dcc576f..e5c74fc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -135,7 +135,7 @@ func main() { if err != nil { return err } - cmdMCP(projectRoot, mcpListen) + cmdMCP(projectRoot, mcpListen, len(args) > 0) return nil }, } @@ -490,43 +490,53 @@ func openStoreForServer(projectRoot string) *store.Store { } // cmdMCP starts the headless MCP server. Logs go to stderr; stdout belongs to -// the MCP stdio transport. -func cmdMCP(projectRoot string, listen string) { +// the MCP stdio transport. With an explicit path the workspace is fixed and +// indexed before serving; without one, each session's workspace root is +// negotiated through MCP roots, with projectRoot (the launch directory) as +// the fallback for clients that provide none. +func cmdMCP(projectRoot string, listen string, explicitRoot bool) { projectRoot = findProjectRoot(projectRoot) - log.SetOutput(os.Stderr) - s := openStoreForServer(projectRoot) - defer func() { - if err := s.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close store: %v\n", err) - } - }() - server := dexter_lsp.NewServer(s, projectRoot) - if root, ok := stdlib.Resolve(s, "", projectRoot); ok { - server.SetStdlibRoot(root) - } - - // Serve only once the index reflects the current tree: an empty index is - // built from scratch, an existing one gets a fast incremental update. - server.ReindexWorkspace() - server.WatchGitHead() - - // Headless servers get no editor events, so watch the tree directly. - watcher, err := dexter_mcp.WatchFiles(server, s, projectRoot) - if err != nil { - log.Printf("Warning: file watching unavailable (%v); the index updates on branch switches and via dexter_reindex", err) - } else { + var h *dexter_mcp.Handler + if explicitRoot { + s := openStoreForServer(projectRoot) defer func() { - if err := watcher.Close(); err != nil { - log.Printf("Warning: closing file watcher: %v", err) + if err := s.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close store: %v\n", err) } }() - } - h := dexter_mcp.NewHandler(dexter_mcp.Config{LSP: server, Store: s, ProjectRoot: projectRoot}) + server := dexter_lsp.NewServer(s, projectRoot) + if root, ok := stdlib.Resolve(s, "", projectRoot); ok { + server.SetStdlibRoot(root) + } - log.Printf("Dexter MCP v%s starting (root: %s)", version.Version, projectRoot) + // Serve only once the index reflects the current tree: an empty index + // is built from scratch, an existing one gets a fast incremental + // update. + server.ReindexWorkspace() + server.WatchGitHead() + + // Headless servers get no editor events, so watch the tree directly. + watcher, err := dexter_mcp.WatchFiles(server, s, projectRoot) + if err != nil { + log.Printf("Warning: file watching unavailable (%v); the index updates on branch switches and via dexter_reindex", err) + } else { + defer func() { + if err := watcher.Close(); err != nil { + log.Printf("Warning: closing file watcher: %v", err) + } + }() + } + + h = dexter_mcp.NewHandler(dexter_mcp.Config{LSP: server, Store: s, ProjectRoot: projectRoot}) + log.Printf("Dexter MCP v%s starting (root: %s)", version.Version, projectRoot) + } else { + h = dexter_mcp.NewHandler(dexter_mcp.Config{ProjectRoot: projectRoot, NegotiateRoots: true}) + defer h.Close() + log.Printf("Dexter MCP v%s starting (workspace roots negotiated per session; fallback root: %s)", version.Version, projectRoot) + } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() diff --git a/internal/lsp/api.go b/internal/lsp/api.go index 70b9cd8..2e99671 100644 --- a/internal/lsp/api.go +++ b/internal/lsp/api.go @@ -125,6 +125,15 @@ func (s *Server) CollectReferences(module, function string) []store.ReferenceRes return out } +// StopGitHeadWatch ends the WatchGitHead goroutine and waits for it, joining +// any reindex it is mid-way through, so the store can be closed safely. The +// MCP server calls it when tearing down a workspace; an LSP session never +// does, its git-head watch runs for the life of the process. +func (s *Server) StopGitHeadWatch() { + s.gitHeadStopOnce.Do(func() { close(s.gitHeadStop) }) + s.gitHeadWG.Wait() +} + // WithReindexLock runs fn while holding the reindex lock, serializing it with // ReindexWorkspace and the background reindexes. The MCP file watcher wraps // its index writes in it so they cannot interleave with a concurrent diff --git a/internal/lsp/api_test.go b/internal/lsp/api_test.go index 401badc..a485e05 100644 --- a/internal/lsp/api_test.go +++ b/internal/lsp/api_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "go.lsp.dev/jsonrpc2" "go.lsp.dev/protocol" @@ -382,3 +383,31 @@ end t.Errorf("summary reports moves %v, want %s → %s", summary.FilesMoved, oldPath, newPath) } } + +// StopGitHeadWatch must end the watch goroutine so a HEAD change after it can +// no longer trigger a reindex against a store the caller is about to close. +func TestStopGitHeadWatch(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + headPath := filepath.Join(server.projectRoot, ".git", "HEAD") + if err := os.MkdirAll(filepath.Dir(headPath), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(headPath, []byte("ref: refs/heads/main\n"), 0644); err != nil { + t.Fatal(err) + } + + server.WatchGitHead() + done := make(chan struct{}) + go func() { + server.StopGitHeadWatch() + server.StopGitHeadWatch() // idempotent + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("StopGitHeadWatch did not return") + } +} diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 66b71b6..10ebae5 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -114,6 +114,10 @@ type Server struct { backgroundWork sync.WaitGroup // tracks background reindex goroutines so the store isn't closed while they're running ready chan struct{} // closed once the LSP initialize request has completed readyOnce sync.Once + + gitHeadStop chan struct{} // closed by StopGitHeadWatch to end the WatchGitHead goroutine + gitHeadStopOnce sync.Once + gitHeadWG sync.WaitGroup } func (s *Server) debugf(format string, args ...interface{}) { @@ -141,6 +145,7 @@ func NewServer(s *store.Store, projectRoot string) *Server { usingCache: make(map[string]*usingCacheEntry), depsCache: make(map[string]bool), ready: make(chan struct{}), + gitHeadStop: make(chan struct{}), } } @@ -485,7 +490,9 @@ func (s *Server) reindexWorkspace() (int, time.Duration) { // WatchGitHead polls .git/HEAD mtime and triggers reindex on branch switches. func (s *Server) WatchGitHead() { + s.gitHeadWG.Add(1) go func() { + defer s.gitHeadWG.Done() headPath := filepath.Join(s.projectRoot, ".git", "HEAD") var lastMtime int64 @@ -498,7 +505,12 @@ func (s *Server) WatchGitHead() { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() - for range ticker.C { + for { + select { + case <-s.gitHeadStop: + return + case <-ticker.C: + } info, err := os.Stat(headPath) if err != nil { continue diff --git a/internal/mcp/binding.go b/internal/mcp/binding.go new file mode 100644 index 0000000..c0937f8 --- /dev/null +++ b/internal/mcp/binding.go @@ -0,0 +1,136 @@ +package mcp + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/remoteoss/dexter/internal/lsp" + "github.com/remoteoss/dexter/internal/stdlib" + "github.com/remoteoss/dexter/internal/store" + "github.com/remoteoss/dexter/internal/version" +) + +// indexWaitLimit caps how long a tool call waits for a workspace's initial +// index before reporting that it is still building. A variable so tests can +// shrink it. +var indexWaitLimit = 30 * time.Second + +// binding is one workspace a negotiating server is serving: the store, the +// headless LSP server, and the file watcher for one resolved project root. +// Sessions whose roots resolve to the same project share a binding. +type binding struct { + root string + store *store.Store + lsp *lsp.Server + watcher *Watcher + + initDone chan struct{} // closed once init finishes, successfully or not + initErr error + indexed chan struct{} // closed once the initial index pass completes +} + +// init opens the workspace. It runs once, in the call that created the +// binding; everything else waits on initDone. The initial index runs in the +// background so a cold build does not stall the session's handler queue; +// tool calls gate on it through awaitIndex. +func (b *binding) init() { + defer close(b.initDone) + s, err := openStore(b.root) + if err != nil { + b.initErr = err + return + } + b.store = s + b.lsp = lsp.NewServer(s, b.root) + if root, ok := stdlib.Resolve(s, "", b.root); ok { + b.lsp.SetStdlibRoot(root) + } + b.lsp.WatchGitHead() + if w, err := WatchFiles(b.lsp, s, b.root); err != nil { + log.Printf("Warning: file watching unavailable for %s (%v); the index updates on branch switches and via dexter_reindex", b.root, err) + } else { + b.watcher = w + } + go func() { + defer close(b.indexed) + b.lsp.ReindexWorkspace() + }() +} + +// awaitIndex blocks until the workspace is ready to answer, or reports why +// it is not. Init failures surface here as retryable errors. +func (b *binding) awaitIndex(ctx context.Context) error { + select { + case <-b.initDone: + case <-ctx.Done(): + return ctx.Err() + } + if b.initErr != nil { + return b.initErr + } + select { + case <-b.indexed: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-time.After(indexWaitLimit): + return fmt.Errorf("the index for %s is still building; retry shortly", b.root) + } +} + +// close tears the workspace down: watcher first so no new index writes +// start, then the git-head watcher (joining any reindex it is running), then +// the initial index goroutine, and only then the store. +func (b *binding) close() { + <-b.initDone + if b.initErr != nil { + return + } + if b.watcher != nil { + if err := b.watcher.Close(); err != nil { + log.Printf("Warning: closing file watcher for %s: %v", b.root, err) + } + } + b.lsp.StopGitHeadWatch() + <-b.indexed + if err := b.store.Close(); err != nil { + log.Printf("Warning: closing store for %s: %v", b.root, err) + } +} + +// openStore opens the index at root with the recovery a long-running server +// needs, like cmd's openStoreForServer but returning errors instead of +// exiting: a session must survive a workspace that fails to open. A corrupt +// database or a populated index from an older format is deleted; the reopened +// empty store is then cold-built by the binding's initial index pass. +func openStore(root string) (*store.Store, error) { + s, err := store.Open(root) + if err != nil { + log.Printf("Failed to open index at %s (%v), rebuilding from scratch...", root, err) + removeIndexFiles(root) + if s, err = store.Open(root); err != nil { + return nil, fmt.Errorf("opening index at %s: %w", root, err) + } + } + if stored := s.GetIndexVersion(); stored != version.IndexVersion && !s.IsEmpty() { + log.Printf("Index version mismatch at %s (stored: %d, current: %d), rebuilding index...", root, stored, version.IndexVersion) + if err := s.Close(); err != nil { + log.Printf("Warning: closing outdated store: %v", err) + } + removeIndexFiles(root) + if s, err = store.Open(root); err != nil { + return nil, fmt.Errorf("reopening index at %s: %w", root, err) + } + } + return s, nil +} + +func removeIndexFiles(root string) { + dbPath := store.DBPath(root) + for _, p := range []string{dbPath, dbPath + "-wal", dbPath + "-shm"} { + _ = os.Remove(p) + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 02fb6a0..cf02916 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -5,10 +5,13 @@ package mcp import ( + "context" _ "embed" + "errors" "fmt" "path/filepath" "strings" + "sync" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -23,23 +26,53 @@ import ( //go:embed instructions.md var Instructions string -// Handler carries the state shared by all tool handlers. In headless mode -// (`dexter mcp`) the lsp.Server is constructed without a client connection; in -// attached mode (`dexter lsp --mcp-listen`) it is the live LSP session, so -// tools see open editor buffers and warm caches. +// Handler carries the state shared by all tool handlers. In attached mode +// (`dexter lsp --mcp-listen`) and with an explicit CLI path the workspace is +// fixed at construction; in attached mode the lsp.Server is the live LSP +// session, so tools see open editor buffers and warm caches. +// +// A negotiating handler (headless `dexter mcp` with no explicit path) has no +// fixed workspace. Each session's root is obtained through MCP roots and +// resolved the way the LSP resolves its own, and every resolved root gets one +// workspace (a binding), shared by all sessions that resolve to it. Tool +// calls run against a per-call view of the session's binding. type Handler struct { lsp *lsp.Server store *store.Store projectRoot string + + negotiate bool + fallbackRoot string // used by sessions that provide no usable root + mu sync.Mutex + bindings map[string]*binding // resolved root → workspace + sessions map[*mcp.ServerSession]*binding // session → its workspace + dirty map[*mcp.ServerSession]bool // roots changed; re-resolve on next call + watched map[*mcp.ServerSession]bool // a Wait goroutine will detach this session + closed bool } type Config struct { LSP *lsp.Server Store *store.Store ProjectRoot string + + // NegotiateRoots serves one workspace per client-provided root instead of + // the fixed LSP/Store pair, with ProjectRoot as the fallback for sessions + // that provide none. + NegotiateRoots bool } func NewHandler(cfg Config) *Handler { + if cfg.NegotiateRoots { + return &Handler{ + negotiate: true, + fallbackRoot: cfg.ProjectRoot, + bindings: make(map[string]*binding), + sessions: make(map[*mcp.ServerSession]*binding), + dirty: make(map[*mcp.ServerSession]bool), + watched: make(map[*mcp.ServerSession]bool), + } + } return &Handler{ lsp: cfg.LSP, store: cfg.Store, @@ -47,76 +80,267 @@ func NewHandler(cfg Config) *Handler { } } +var errClosed = errors.New("the MCP server is shutting down") + +// handlerFor returns the Handler a tool call should run against: the fixed +// one, or a view of the workspace bound to the call's session, waiting out +// that workspace's initial index. +func (h *Handler) handlerFor(ctx context.Context, req *mcp.CallToolRequest) (*Handler, error) { + if !h.negotiate { + return h, nil + } + b, err := h.bindingFor(ctx, req.Session) + if err != nil { + return nil, err + } + if err := b.awaitIndex(ctx); err != nil { + if b.initErr != nil { + // A workspace that failed to open is forgotten so the next call + // renegotiates from scratch instead of re-reporting a stale error. + h.forget(b) + } + return nil, err + } + return &Handler{lsp: b.lsp, store: b.store, projectRoot: b.root}, nil +} + +// bindingFor returns the session's workspace, negotiating its root first when +// the session is new or its roots changed. +func (h *Handler) bindingFor(ctx context.Context, ss *mcp.ServerSession) (*binding, error) { + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return nil, errClosed + } + b, bound := h.sessions[ss] + dirty := h.dirty[ss] + h.mu.Unlock() + if bound && !dirty { + return b, nil + } + + // Resolve outside the lock: ListRoots blocks on the client. + root, ok, err := negotiatedRoot(ctx, ss) + if err != nil { + return nil, err + } + if !ok { + root = h.fallbackRoot + } + + var created, orphan *binding + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return nil, errClosed + } + delete(h.dirty, ss) + if cur, bound := h.sessions[ss]; bound && cur.root == root { + h.mu.Unlock() + return cur, nil + } + nb, exists := h.bindings[root] + if !exists { + nb = &binding{root: root, initDone: make(chan struct{}), indexed: make(chan struct{})} + h.bindings[root] = nb + created = nb + } + if cur, bound := h.sessions[ss]; bound { + orphan = h.releaseLocked(ss, cur) + } + h.sessions[ss] = nb + if !h.watched[ss] { + h.watched[ss] = true + go func() { + _ = ss.Wait() + h.detachSession(ss) + }() + } + h.mu.Unlock() + + if orphan != nil { + go orphan.close() + } + if created != nil { + created.init() + } + return nb, nil +} + +// releaseLocked unbinds ss from b and reports b when no other session uses it +// any more, removing it from the handler; the caller closes it outside the +// lock. Callers must hold h.mu. +func (h *Handler) releaseLocked(ss *mcp.ServerSession, b *binding) (orphan *binding) { + delete(h.sessions, ss) + for _, sb := range h.sessions { + if sb == b { + return nil + } + } + if h.bindings[b.root] == b { + delete(h.bindings, b.root) + } + return b +} + +// detachSession drops everything the handler holds for a closed session, +// tearing down its workspace when no other session shares it. +func (h *Handler) detachSession(ss *mcp.ServerSession) { + h.mu.Lock() + var orphan *binding + if b, bound := h.sessions[ss]; bound { + orphan = h.releaseLocked(ss, b) + } + delete(h.dirty, ss) + delete(h.watched, ss) + h.mu.Unlock() + if orphan != nil { + orphan.close() + } +} + +// forget removes a workspace that failed to open, with every session bound to +// it, so subsequent calls renegotiate. +func (h *Handler) forget(b *binding) { + h.mu.Lock() + if h.bindings[b.root] == b { + delete(h.bindings, b.root) + } + for ss, sb := range h.sessions { + if sb == b { + delete(h.sessions, ss) + } + } + h.mu.Unlock() +} + +// onInitialized warms up a new session's workspace so the first tool call +// finds the index already building. Best-effort: failures surface on that +// first call, which renegotiates on its own context. +func (h *Handler) onInitialized(ctx context.Context, req *mcp.InitializedRequest) { + _, _ = h.bindingFor(ctx, req.Session) +} + +// onRootsChanged marks the session for renegotiation. The re-resolve happens +// on the session's next tool call, whose request context reaches the client +// reliably on every transport; if the roots still resolve to the same project +// the workspace is kept as is. +func (h *Handler) onRootsChanged(_ context.Context, req *mcp.RootsListChangedRequest) { + h.mu.Lock() + if _, bound := h.sessions[req.Session]; bound { + h.dirty[req.Session] = true + } + h.mu.Unlock() +} + +// Close tears down every workspace a negotiating handler holds. Fixed-mode +// handlers own nothing: their store and server belong to the caller. +func (h *Handler) Close() { + h.mu.Lock() + if h.closed || !h.negotiate { + h.mu.Unlock() + return + } + h.closed = true + bindings := make([]*binding, 0, len(h.bindings)) + for _, b := range h.bindings { + bindings = append(bindings, b) + } + h.bindings = nil + h.sessions = nil + h.mu.Unlock() + for _, b := range bindings { + b.close() + } +} + +// addTool registers a tool handler so each call runs against the workspace +// bound to its session (in fixed mode, always the handler itself). +func addTool[In any](srv *mcp.Server, h *Handler, t *mcp.Tool, f func(*Handler, context.Context, *mcp.CallToolRequest, In) (*mcp.CallToolResult, any, error)) { + mcp.AddTool(srv, t, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, any, error) { + hh, err := h.handlerFor(ctx, req) + if err != nil { + return nil, nil, err + } + return f(hh, ctx, req, args) + }) +} + // NewServer returns an MCP server with all dexter tools registered. func NewServer(h *Handler) *mcp.Server { + opts := &mcp.ServerOptions{Instructions: Instructions} + if h.negotiate { + opts.InitializedHandler = h.onInitialized + opts.RootsListChangedHandler = h.onRootsChanged + } srv := mcp.NewServer( &mcp.Implementation{Name: "dexter", Title: "Dexter Elixir language tools", Version: version.Version}, - &mcp.ServerOptions{Instructions: Instructions}, + opts, ) // The pointer hints distinguish explicit false from unset; clients must // treat unset pessimistically (destructive, open world). readOnly := &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(bool)} - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_workspace", Annotations: readOnly, Description: "Overview of the Elixir workspace: Mix projects, index size, stdlib status. Call once at the start of Elixir work.", - }, h.workspaceHandler) + }, (*Handler).workspaceHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_search", Annotations: readOnly, Description: "Locate Elixir modules and functions by fuzzy name match. More precise than grep for finding symbols: results are exact definitions with file:line.", - }, h.searchHandler) + }, (*Handler).searchHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_definition", Annotations: readOnly, Description: "Definition of an Elixir module or function by name: location, @doc/@spec, and source snippet, following defdelegate to the real implementation. Use instead of grep or reading files to answer where or what a symbol is.", - }, h.definitionHandler) + }, (*Handler).definitionHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_references", Annotations: readOnly, Description: "All call sites of an Elixir module or function, resolved through aliases, imports, and use-chain injection that grep cannot see. Use for any 'who calls or uses X' question.", - }, h.referencesHandler) + }, (*Handler).referencesHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_module_api", Annotations: readOnly, Description: "A module's public API in one call: moduledoc, functions with signatures and doc summaries, macros, delegates, types, callbacks, and submodules. Use before reading a module's source.", - }, h.moduleAPIHandler) + }, (*Handler).moduleAPIHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_file_outline", Annotations: readOnly, Description: "Everything an Elixir file defines: modules, functions, macros, and types with line numbers. Use instead of reading a file to map its contents; one Elixir file can define many modules.", - }, h.fileOutlineHandler) + }, (*Handler).fileOutlineHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_implementations", Annotations: readOnly, Description: "Implementations of an Elixir behaviour (@behaviour/use) or protocol (defimpl), optionally locating one callback in each implementor. Grep cannot resolve these relationships.", - }, h.implementationsHandler) + }, (*Handler).implementationsHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_call_hierarchy", Annotations: readOnly, Description: "Incoming callers and outgoing callees of an Elixir function, with file:line locations. Use to trace execution paths without reading files.", - }, h.callHierarchyHandler) + }, (*Handler).callHierarchyHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_reindex", Annotations: &mcp.ToolAnnotations{DestructiveHint: new(bool), IdempotentHint: true, OpenWorldHint: new(bool)}, Description: "Force an immediate incremental reindex. The index already updates automatically as files change; use this only when a lookup seems stale. The only tool that writes, and it writes only dexter's own index database.", - }, h.reindexHandler) + }, (*Handler).reindexHandler) - mcp.AddTool(srv, &mcp.Tool{ + addTool(srv, h, &mcp.Tool{ Name: "dexter_rename_symbol", Description: "Rename an Elixir module or function across the whole workspace, exactly like an editor rename: writes the changes to disk, moves files that follow the naming convention, and updates the index. Reports every file changed; review with git diff.", Annotations: &mcp.ToolAnnotations{OpenWorldHint: new(bool)}, - }, h.renameHandler) + }, (*Handler).renameHandler) return srv } diff --git a/internal/mcp/negotiation_test.go b/internal/mcp/negotiation_test.go new file mode 100644 index 0000000..285a9bd --- /dev/null +++ b/internal/mcp/negotiation_test.go @@ -0,0 +1,361 @@ +package mcp + +import ( + "context" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/store" +) + +// negotiationEnv is a negotiating Handler with no fixed workspace, plus +// helpers to connect clients that advertise chosen roots. +type negotiationEnv struct { + t *testing.T + h *Handler + fallback string +} + +func setupNegotiation(t *testing.T) *negotiationEnv { + t.Helper() + fallback := t.TempDir() + h := NewHandler(Config{ProjectRoot: fallback, NegotiateRoots: true}) + t.Cleanup(h.Close) + return &negotiationEnv{t: t, h: h, fallback: fallback} +} + +// connect wires a new client session to the negotiating server. Roots are +// added before connecting so they are visible from the first roots/list. +func (e *negotiationEnv) connect(opts *mcp.ClientOptions, rootURIs ...string) (*mcp.ClientSession, *mcp.Client) { + e.t.Helper() + ctx := context.Background() + serverTransport, clientTransport := mcp.NewInMemoryTransports() + ss, err := NewServer(e.h).Connect(ctx, serverTransport, nil) + if err != nil { + e.t.Fatal(err) + } + e.t.Cleanup(func() { _ = ss.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0.0.1"}, opts) + for _, u := range rootURIs { + client.AddRoots(&mcp.Root{URI: u}) + } + cs, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + e.t.Fatal(err) + } + e.t.Cleanup(func() { _ = cs.Close() }) + return cs, client +} + +// projectDir creates a project directory containing one module and returns +// its path and file URI. +func projectDir(t *testing.T, module string) (string, string) { + t.Helper() + dir := t.TempDir() + writeSource(t, dir, "lib/mod.ex", "defmodule "+module+" do\n def hello, do: :ok\nend\n") + return dir, fileURI(dir) +} + +func writeSource(t *testing.T, dir, rel, content string) { + t.Helper() + path := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func fileURI(path string) string { + return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String() +} + +func toolText(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) (string, bool) { + t.Helper() + res, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + return err.Error(), false + } + return resultText(res), !res.IsError +} + +func mustTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string]any) string { + t.Helper() + out, ok := toolText(t, cs, name, args) + if !ok { + t.Fatalf("CallTool(%s) failed: %s", name, out) + } + return out +} + +func hasIndex(root string) bool { + _, err := os.Stat(filepath.Join(root, ".dexter", "dexter.db")) + return err == nil +} + +func TestNegotiation_BindsClientRoot(t *testing.T) { + e := setupNegotiation(t) + root, uri := projectDir(t, "NegBind.Hello") + cs, _ := e.connect(nil, uri) + + out := mustTool(t, cs, "dexter_search", map[string]any{"query": "NegBind"}) + wantContains(t, out, "NegBind.Hello") + + if !hasIndex(root) { + t.Error("no index created under the negotiated root") + } + if hasIndex(e.fallback) { + t.Error("index created under the fallback root despite a negotiated root") + } + wantContains(t, mustTool(t, cs, "dexter_workspace", nil), root) +} + +func TestNegotiation_FallsBackWithoutUsableRoots(t *testing.T) { + cases := []struct { + name string + opts *mcp.ClientOptions + uris []string + }{ + // A default go-sdk client advertises roots with an empty list; this is + // what most clients look like, not an edge case. + {name: "empty roots list", opts: nil}, + {name: "roots capability off", opts: &mcp.ClientOptions{Capabilities: &mcp.ClientCapabilities{}}}, + {name: "non-file roots only", opts: nil, uris: []string{"https://example.com/project"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + e := setupNegotiation(t) + writeSource(t, e.fallback, "lib/mod.ex", "defmodule NegFall.Hello do\nend\n") + cs, _ := e.connect(tc.opts, tc.uris...) + + out := mustTool(t, cs, "dexter_search", map[string]any{"query": "NegFall"}) + wantContains(t, out, "NegFall.Hello") + if !hasIndex(e.fallback) { + t.Error("no index created under the fallback root") + } + }) + } +} + +// A root inside a repository resolves upward to the repository, exactly like +// the LSP's Initialize: .dexter/dexter.db or .git win, and a nested mix.exs +// does not stop the walk. +func TestNegotiation_ResolvesRootLikeLSP(t *testing.T) { + e := setupNegotiation(t) + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil { + t.Fatal(err) + } + writeSource(t, repo, "apps/web/mix.exs", "defmodule Web.MixProject do\nend\n") + writeSource(t, repo, "lib/top.ex", "defmodule NegRepo.Top do\nend\n") + cs, _ := e.connect(nil, fileURI(filepath.Join(repo, "apps", "web"))) + + // A module outside the advertised subdirectory is indexed, proving the + // workspace anchored on the repository root. + out := mustTool(t, cs, "dexter_search", map[string]any{"query": "NegRepo"}) + wantContains(t, out, "NegRepo.Top") + if !hasIndex(repo) { + t.Error("no index at the repository root") + } + if hasIndex(filepath.Join(repo, "apps", "web")) { + t.Error("index created at the subdirectory instead of the repository root") + } +} + +func TestNegotiation_BadRootIsRetryable(t *testing.T) { + e := setupNegotiation(t) + badURI := "file:///nonexistent/dexter-negotiation-test" + cs, client := e.connect(nil, badURI) + + out, ok := toolText(t, cs, "dexter_search", map[string]any{"query": "x"}) + if ok { + t.Fatalf("tool call succeeded against a nonexistent root: %s", out) + } + if !strings.Contains(out, "not a directory") { + t.Errorf("error does not name the problem: %s", out) + } + + // The failure is not cached: with the roots fixed, the same session works. + root, goodURI := projectDir(t, "NegRetry.Hello") + client.RemoveRoots(badURI) + client.AddRoots(&mcp.Root{URI: goodURI}) + eventually(t, "session to bind the corrected root", func() bool { + out, ok := toolText(t, cs, "dexter_search", map[string]any{"query": "NegRetry"}) + return ok && strings.Contains(out, "NegRetry.Hello") + }) + if !hasIndex(root) { + t.Error("no index created under the corrected root") + } +} + +// Sessions with different roots work concurrently against their own +// workspaces; sessions with the same root share one. +func TestNegotiation_MultipleRoots(t *testing.T) { + e := setupNegotiation(t) + rootA, uriA := projectDir(t, "NegMultiA.Mod") + rootB, uriB := projectDir(t, "NegMultiB.Mod") + csA, _ := e.connect(nil, uriA) + csB, _ := e.connect(nil, uriB) + csA2, _ := e.connect(nil, uriA) + + outA := mustTool(t, csA, "dexter_search", map[string]any{"query": "NegMulti"}) + wantContains(t, outA, "NegMultiA.Mod") + wantNotContains(t, outA, "NegMultiB.Mod") + + outB := mustTool(t, csB, "dexter_search", map[string]any{"query": "NegMulti"}) + wantContains(t, outB, "NegMultiB.Mod") + wantNotContains(t, outB, "NegMultiA.Mod") + + wantContains(t, mustTool(t, csA2, "dexter_search", map[string]any{"query": "NegMulti"}), "NegMultiA.Mod") + + if !hasIndex(rootA) || !hasIndex(rootB) { + t.Error("expected an index under each negotiated root") + } + e.h.mu.Lock() + nbindings := len(e.h.bindings) + e.h.mu.Unlock() + if nbindings != 2 { + t.Errorf("3 sessions over 2 roots hold %d workspaces, want 2", nbindings) + } +} + +func TestNegotiation_RootsChangedSwapsWorkspace(t *testing.T) { + e := setupNegotiation(t) + rootA, uriA := projectDir(t, "NegSwapA.Mod") + rootB, uriB := projectDir(t, "NegSwapB.Mod") + cs, client := e.connect(nil, uriA) + + wantContains(t, mustTool(t, cs, "dexter_search", map[string]any{"query": "NegSwapA"}), "NegSwapA.Mod") + + client.RemoveRoots(uriA) + client.AddRoots(&mcp.Root{URI: uriB}) + eventually(t, "session to move to the new root", func() bool { + out, ok := toolText(t, cs, "dexter_search", map[string]any{"query": "NegSwapB"}) + return ok && strings.Contains(out, "NegSwapB.Mod") + }) + wantContains(t, mustTool(t, cs, "dexter_workspace", nil), rootB) + + // The old workspace is torn down: its watcher no longer indexes new files + // into its store. + e.h.mu.Lock() + _, oldBound := e.h.bindings[rootA] + e.h.mu.Unlock() + if oldBound { + t.Error("old workspace still held after the swap") + } + writeSource(t, rootA, "lib/late.ex", "defmodule NegSwapA.Late do\nend\n") + time.Sleep(4 * debounceWindow) + oldStore, err := store.Open(rootA) + if err != nil { + t.Fatal(err) + } + defer oldStore.Close() + if results, err := oldStore.LookupModule("NegSwapA.Late"); err != nil || len(results) != 0 { + t.Errorf("old workspace's watcher still indexing after teardown: %v, %v", results, err) + } +} + +// A roots change that resolves to the same project keeps the workspace: no +// teardown, no rebuild. +func TestNegotiation_SameRootChangeIsNoop(t *testing.T) { + e := setupNegotiation(t) + root, uri := projectDir(t, "NegNoop.Mod") + cs, client := e.connect(nil, uri) + mustTool(t, cs, "dexter_search", map[string]any{"query": "NegNoop"}) + + e.h.mu.Lock() + before := e.h.bindings[root] + e.h.mu.Unlock() + + // Same project, different advertised directory: the first call binds it, + // so the subdirectory resolves upward via .dexter/dexter.db. + subdir := filepath.Join(root, "lib") + client.AddRoots(&mcp.Root{URI: fileURI(subdir)}) + eventually(t, "roots change notification to arrive", func() bool { + e.h.mu.Lock() + defer e.h.mu.Unlock() + for _, d := range e.h.dirty { + if d { + return true + } + } + return false + }) + mustTool(t, cs, "dexter_search", map[string]any{"query": "NegNoop"}) // renegotiates + + e.h.mu.Lock() + after := e.h.bindings[root] + e.h.mu.Unlock() + if before != after { + t.Error("workspace was rebuilt for a change that resolves to the same root") + } +} + +// A workspace root with characters that URI-encode (spaces) binds correctly. +func TestNegotiation_RootWithSpaces(t *testing.T) { + e := setupNegotiation(t) + root := filepath.Join(t.TempDir(), "my project") + writeSource(t, root, "lib/mod.ex", "defmodule NegSpace.Mod do\nend\n") + uri := fileURI(root) + if !strings.Contains(uri, "%20") { + t.Fatalf("test URI %q does not exercise percent-encoding", uri) + } + cs, _ := e.connect(nil, uri) + wantContains(t, mustTool(t, cs, "dexter_search", map[string]any{"query": "NegSpace"}), "NegSpace.Mod") + if !hasIndex(root) { + t.Error("no index created under the percent-encoded root") + } +} + +// A tool call during a long initial index reports that the workspace is still +// building instead of hanging. +func TestNegotiation_ReportsInitializing(t *testing.T) { + prev := indexWaitLimit + indexWaitLimit = 10 * time.Millisecond + defer func() { indexWaitLimit = prev }() + + e := setupNegotiation(t) + // A pre-installed workspace whose initial index never finishes. + b := &binding{root: e.fallback, initDone: make(chan struct{}), indexed: make(chan struct{})} + close(b.initDone) + e.h.mu.Lock() + e.h.bindings[e.fallback] = b + e.h.mu.Unlock() + + cs, _ := e.connect(nil) + out, ok := toolText(t, cs, "dexter_search", map[string]any{"query": "x"}) + if ok { + t.Fatalf("tool call succeeded against an unindexed workspace: %s", out) + } + if !strings.Contains(out, "still building") { + t.Errorf("error does not report the index build: %s", out) + } + close(b.indexed) // let Close tear it down without blocking + b.initErr = context.Canceled +} + +// A session disconnecting releases its workspace. +func TestNegotiation_SessionCloseReleasesWorkspace(t *testing.T) { + e := setupNegotiation(t) + root, uri := projectDir(t, "NegClose.Mod") + cs, _ := e.connect(nil, uri) + mustTool(t, cs, "dexter_search", map[string]any{"query": "NegClose"}) + + if err := cs.Close(); err != nil { + t.Fatal(err) + } + eventually(t, "workspace to be released", func() bool { + e.h.mu.Lock() + defer e.h.mu.Unlock() + _, held := e.h.bindings[root] + return !held && len(e.h.sessions) == 0 + }) +} diff --git a/internal/mcp/roots.go b/internal/mcp/roots.go new file mode 100644 index 0000000..459ac01 --- /dev/null +++ b/internal/mcp/roots.go @@ -0,0 +1,66 @@ +package mcp + +import ( + "context" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/remoteoss/dexter/internal/store" +) + +// fileURIToPath converts a file:// URI to an absolute filesystem path. +func fileURIToPath(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("invalid root URI %q: %w", raw, err) + } + if u.Host != "" && u.Host != "localhost" { + return "", fmt.Errorf("root URI %q names a remote host", raw) + } + path := filepath.FromSlash(u.Path) + if !filepath.IsAbs(path) { + return "", fmt.Errorf("root URI %q has no absolute path", raw) + } + return path, nil +} + +// negotiatedRoot resolves a session's workspace root from the MCP roots the +// client advertises. ok is false when the client offers no usable root (no +// roots capability, an empty list, or no file:// root): callers fall back to +// the launch-directory root. A transport failure or an unusable file:// root +// is an error the caller should surface and retry, not cache. +// +// A usable root resolves like the LSP's Initialize does: upward from the +// given directory to an existing index (.dexter/dexter.db) or repository +// marker (.git), so an existing index is reused and a subdirectory root +// still lands on the project. +func negotiatedRoot(ctx context.Context, ss *mcp.ServerSession) (root string, ok bool, err error) { + params := ss.InitializeParams() + if params == nil || params.Capabilities == nil || params.Capabilities.RootsV2 == nil { + return "", false, nil + } + res, err := ss.ListRoots(ctx, nil) + if err != nil { + return "", false, fmt.Errorf("listing client roots: %w", err) + } + for _, r := range res.Roots { + if !strings.HasPrefix(r.URI, "file:") { + continue + } + path, err := fileURIToPath(r.URI) + if err != nil { + return "", false, err + } + info, err := os.Stat(path) + if err != nil || !info.IsDir() { + return "", false, fmt.Errorf("client root %q is not a directory", path) + } + return store.FindProjectRoot(path), true, nil + } + return "", false, nil +} From 36790b2a2b407ea9f673fb91df431d845be1811d Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 9 Sep 2026 12:48:19 +1000 Subject: [PATCH 12/17] Log MCP workspace bind and teardown The negotiating server decides per session which directory it serves, so the stderr log records each decision (root and whether it came from client roots or the fallback) and each workspace teardown. --- internal/mcp/binding.go | 1 + internal/mcp/mcp.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/internal/mcp/binding.go b/internal/mcp/binding.go index c0937f8..271a919 100644 --- a/internal/mcp/binding.go +++ b/internal/mcp/binding.go @@ -99,6 +99,7 @@ func (b *binding) close() { if err := b.store.Close(); err != nil { log.Printf("Warning: closing store for %s: %v", b.root, err) } + log.Printf("MCP workspace closed: %s", b.root) } // openStore opens the index at root with the recovery a long-running server diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index cf02916..623393d 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -9,6 +9,7 @@ import ( _ "embed" "errors" "fmt" + "log" "path/filepath" "strings" "sync" @@ -124,8 +125,10 @@ func (h *Handler) bindingFor(ctx context.Context, ss *mcp.ServerSession) (*bindi if err != nil { return nil, err } + source := "client roots" if !ok { root = h.fallbackRoot + source = "fallback" } var created, orphan *binding @@ -149,6 +152,7 @@ func (h *Handler) bindingFor(ctx context.Context, ss *mcp.ServerSession) (*bindi orphan = h.releaseLocked(ss, cur) } h.sessions[ss] = nb + log.Printf("MCP session workspace: %s (%s)", root, source) if !h.watched[ss] { h.watched[ss] = true go func() { From 676539d0a660b2a116e113811dbb32da9c856243 Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 9 Sep 2026 13:17:48 +1000 Subject: [PATCH 13/17] Check store close error in negotiation test --- internal/mcp/negotiation_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mcp/negotiation_test.go b/internal/mcp/negotiation_test.go index 285a9bd..63c43a5 100644 --- a/internal/mcp/negotiation_test.go +++ b/internal/mcp/negotiation_test.go @@ -257,7 +257,7 @@ func TestNegotiation_RootsChangedSwapsWorkspace(t *testing.T) { if err != nil { t.Fatal(err) } - defer oldStore.Close() + defer func() { _ = oldStore.Close() }() if results, err := oldStore.LookupModule("NegSwapA.Late"); err != nil || len(results) != 0 { t.Errorf("old workspace's watcher still indexing after teardown: %v, %v", results, err) } From 4c0ae5c02609f05be4de83fa61815c5c0371712f Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 9 Sep 2026 14:01:48 +1000 Subject: [PATCH 14/17] Address review findings on root negotiation Canonicalize negotiated root paths so URI variants of one directory cannot key two workspaces onto the same database, stop the git-head watcher on explicit-root shutdown before the store closes, and skip text edits whose columns fall outside the target line instead of panicking. --- cmd/main.go | 1 + internal/lsp/api.go | 3 +++ internal/lsp/api_test.go | 8 ++++++++ internal/mcp/negotiation_test.go | 27 +++++++++++++++++++++++++++ internal/mcp/roots.go | 2 +- 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/cmd/main.go b/cmd/main.go index e5c74fc..8b28f67 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -517,6 +517,7 @@ func cmdMCP(projectRoot string, listen string, explicitRoot bool) { // update. server.ReindexWorkspace() server.WatchGitHead() + defer server.StopGitHeadWatch() // Headless servers get no editor events, so watch the tree directly. watcher, err := dexter_mcp.WatchFiles(server, s, projectRoot) diff --git a/internal/lsp/api.go b/internal/lsp/api.go index 2e99671..8c31a9a 100644 --- a/internal/lsp/api.go +++ b/internal/lsp/api.go @@ -363,6 +363,9 @@ func applyTextEdits(text string, edits []protocol.TextEdit) string { if int(start.Line) >= len(lines) || int(end.Line) >= len(lines) { continue } + if int(start.Character) > len(lines[start.Line]) || int(end.Character) > len(lines[end.Line]) { + continue + } prefix := lines[start.Line][:start.Character] suffix := lines[end.Line][end.Character:] replacement := strings.Split(prefix+e.NewText+suffix, "\n") diff --git a/internal/lsp/api_test.go b/internal/lsp/api_test.go index a485e05..5402ffc 100644 --- a/internal/lsp/api_test.go +++ b/internal/lsp/api_test.go @@ -39,6 +39,14 @@ func TestApplyTextEdits(t *testing.T) { }, want: "get_user(get_user(1))\n", }, + { + name: "out-of-range column is skipped, not a panic", + text: "short\n", + edits: []protocol.TextEdit{ + {Range: protocol.Range{Start: protocol.Position{Line: 0, Character: 40}, End: protocol.Position{Line: 0, Character: 50}}, NewText: "x"}, + }, + want: "short\n", + }, { name: "multi-line span replacement", text: "a\nold one\nold two\nb\n", diff --git a/internal/mcp/negotiation_test.go b/internal/mcp/negotiation_test.go index 63c43a5..4c4fcd8 100644 --- a/internal/mcp/negotiation_test.go +++ b/internal/mcp/negotiation_test.go @@ -96,6 +96,33 @@ func mustTool(t *testing.T, cs *mcp.ClientSession, name string, args map[string] return out } +func TestFileURIToPath(t *testing.T) { + cases := []struct { + uri string + want string // "" means an error is expected + }{ + {"file:///a/b", "/a/b"}, + {"file:///a/b/", "/a/b"}, // trailing slash must not key a second workspace + {"file://localhost/a/b", "/a/b"}, + {"file:///a/my%20project", "/a/my project"}, + {"file://otherhost/a", ""}, + {"file://a", ""}, // host form, no path + {"file:relative", ""}, + } + for _, tc := range cases { + got, err := fileURIToPath(tc.uri) + if tc.want == "" { + if err == nil { + t.Errorf("fileURIToPath(%q) = %q, want error", tc.uri, got) + } + continue + } + if err != nil || got != tc.want { + t.Errorf("fileURIToPath(%q) = %q, %v; want %q", tc.uri, got, err, tc.want) + } + } +} + func hasIndex(root string) bool { _, err := os.Stat(filepath.Join(root, ".dexter", "dexter.db")) return err == nil diff --git a/internal/mcp/roots.go b/internal/mcp/roots.go index 459ac01..fac5c0f 100644 --- a/internal/mcp/roots.go +++ b/internal/mcp/roots.go @@ -22,7 +22,7 @@ func fileURIToPath(raw string) (string, error) { if u.Host != "" && u.Host != "localhost" { return "", fmt.Errorf("root URI %q names a remote host", raw) } - path := filepath.FromSlash(u.Path) + path := filepath.Clean(filepath.FromSlash(u.Path)) if !filepath.IsAbs(path) { return "", fmt.Errorf("root URI %q has no absolute path", raw) } From f9f5b2cca688985e8ba21983150749e4b910f2a7 Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 9 Sep 2026 17:44:12 +1000 Subject: [PATCH 15/17] Serialize same-root workspace turnover and canonicalize roots An orphaned workspace closes in the background, and its close can wait out a running initial index build. Rebinding that root meanwhile opened a second store over the same database while the first was still bulk writing, violating the indexer's single-writer contract. Roots now drain: a rebind waits for the old workspace's close to finish. Resolved roots are also symlink-canonicalized, since path aliases of one directory (such as /tmp vs /private/tmp) would otherwise key two live workspaces onto one database with the same effect. --- internal/mcp/mcp.go | 113 +++++++++++++++++++++---------- internal/mcp/negotiation_test.go | 83 +++++++++++++++++++++-- internal/mcp/roots.go | 12 +++- 3 files changed, 166 insertions(+), 42 deletions(-) diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 623393d..beb3824 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -49,6 +49,7 @@ type Handler struct { sessions map[*mcp.ServerSession]*binding // session → its workspace dirty map[*mcp.ServerSession]bool // roots changed; re-resolve on next call watched map[*mcp.ServerSession]bool // a Wait goroutine will detach this session + draining map[string]chan struct{} // roots whose last workspace is still closing closed bool } @@ -67,11 +68,12 @@ func NewHandler(cfg Config) *Handler { if cfg.NegotiateRoots { return &Handler{ negotiate: true, - fallbackRoot: cfg.ProjectRoot, + fallbackRoot: canonicalRoot(cfg.ProjectRoot), bindings: make(map[string]*binding), sessions: make(map[*mcp.ServerSession]*binding), dirty: make(map[*mcp.ServerSession]bool), watched: make(map[*mcp.ServerSession]bool), + draining: make(map[string]chan struct{}), } } return &Handler{ @@ -131,44 +133,73 @@ func (h *Handler) bindingFor(ctx context.Context, ss *mcp.ServerSession) (*bindi source = "fallback" } - var created, orphan *binding - h.mu.Lock() - if h.closed { - h.mu.Unlock() - return nil, errClosed - } - delete(h.dirty, ss) - if cur, bound := h.sessions[ss]; bound && cur.root == root { + for { + var created, orphan *binding + h.mu.Lock() + if h.closed { + h.mu.Unlock() + return nil, errClosed + } + // The last workspace for this root may still be tearing down; opening + // a second store over the same database would race its final writes. + if ch, ok := h.draining[root]; ok { + h.mu.Unlock() + select { + case <-ch: + continue + case <-ctx.Done(): + return nil, ctx.Err() + } + } + delete(h.dirty, ss) + if cur, bound := h.sessions[ss]; bound && cur.root == root { + h.mu.Unlock() + return cur, nil + } + nb, exists := h.bindings[root] + if !exists { + nb = &binding{root: root, initDone: make(chan struct{}), indexed: make(chan struct{})} + h.bindings[root] = nb + created = nb + } + if cur, bound := h.sessions[ss]; bound { + orphan = h.releaseLocked(ss, cur) + } + h.sessions[ss] = nb + log.Printf("MCP session workspace: %s (%s)", root, source) + if !h.watched[ss] { + h.watched[ss] = true + go func() { + _ = ss.Wait() + h.detachSession(ss) + }() + } h.mu.Unlock() - return cur, nil - } - nb, exists := h.bindings[root] - if !exists { - nb = &binding{root: root, initDone: make(chan struct{}), indexed: make(chan struct{})} - h.bindings[root] = nb - created = nb - } - if cur, bound := h.sessions[ss]; bound { - orphan = h.releaseLocked(ss, cur) - } - h.sessions[ss] = nb - log.Printf("MCP session workspace: %s (%s)", root, source) - if !h.watched[ss] { - h.watched[ss] = true - go func() { - _ = ss.Wait() - h.detachSession(ss) - }() - } - h.mu.Unlock() - if orphan != nil { - go orphan.close() - } - if created != nil { - created.init() + if orphan != nil { + h.drainOrphan(orphan) + } + if created != nil { + created.init() + } + return nb, nil } - return nb, nil +} + +// drainOrphan closes an unbound workspace in the background, keeping its root +// marked as draining until the close finishes so no new workspace opens over +// the same database in the meantime. releaseLocked marked the root. +func (h *Handler) drainOrphan(b *binding) { + go func() { + b.close() + h.mu.Lock() + ch := h.draining[b.root] + delete(h.draining, b.root) + h.mu.Unlock() + if ch != nil { + close(ch) + } + }() } // releaseLocked unbinds ss from b and reports b when no other session uses it @@ -184,6 +215,7 @@ func (h *Handler) releaseLocked(ss *mcp.ServerSession, b *binding) (orphan *bind if h.bindings[b.root] == b { delete(h.bindings, b.root) } + h.draining[b.root] = make(chan struct{}) return b } @@ -199,7 +231,7 @@ func (h *Handler) detachSession(ss *mcp.ServerSession) { delete(h.watched, ss) h.mu.Unlock() if orphan != nil { - orphan.close() + h.drainOrphan(orphan) } } @@ -252,10 +284,17 @@ func (h *Handler) Close() { } h.bindings = nil h.sessions = nil + draining := make([]chan struct{}, 0, len(h.draining)) + for _, ch := range h.draining { + draining = append(draining, ch) + } h.mu.Unlock() for _, b := range bindings { b.close() } + for _, ch := range draining { + <-ch + } } // addTool registers a tool handler so each call runs against the workspace diff --git a/internal/mcp/negotiation_test.go b/internal/mcp/negotiation_test.go index 4c4fcd8..8819cf0 100644 --- a/internal/mcp/negotiation_test.go +++ b/internal/mcp/negotiation_test.go @@ -24,12 +24,24 @@ type negotiationEnv struct { func setupNegotiation(t *testing.T) *negotiationEnv { t.Helper() - fallback := t.TempDir() + fallback := canonTempDir(t) h := NewHandler(Config{ProjectRoot: fallback, NegotiateRoots: true}) t.Cleanup(h.Close) return &negotiationEnv{t: t, h: h, fallback: fallback} } +// canonTempDir returns a symlink-free temp dir: negotiated roots are +// canonicalized, so expectations must be built from canonical paths +// (t.TempDir itself is symlinked on macOS). +func canonTempDir(t *testing.T) string { + t.Helper() + dir, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + return dir +} + // connect wires a new client session to the negotiating server. Roots are // added before connecting so they are visible from the first roots/list. func (e *negotiationEnv) connect(opts *mcp.ClientOptions, rootURIs ...string) (*mcp.ClientSession, *mcp.Client) { @@ -58,7 +70,7 @@ func (e *negotiationEnv) connect(opts *mcp.ClientOptions, rootURIs ...string) (* // its path and file URI. func projectDir(t *testing.T, module string) (string, string) { t.Helper() - dir := t.TempDir() + dir := canonTempDir(t) writeSource(t, dir, "lib/mod.ex", "defmodule "+module+" do\n def hello, do: :ok\nend\n") return dir, fileURI(dir) } @@ -177,7 +189,7 @@ func TestNegotiation_FallsBackWithoutUsableRoots(t *testing.T) { // does not stop the walk. func TestNegotiation_ResolvesRootLikeLSP(t *testing.T) { e := setupNegotiation(t) - repo := t.TempDir() + repo := canonTempDir(t) if err := os.MkdirAll(filepath.Join(repo, ".git"), 0755); err != nil { t.Fatal(err) } @@ -329,7 +341,7 @@ func TestNegotiation_SameRootChangeIsNoop(t *testing.T) { // A workspace root with characters that URI-encode (spaces) binds correctly. func TestNegotiation_RootWithSpaces(t *testing.T) { e := setupNegotiation(t) - root := filepath.Join(t.TempDir(), "my project") + root := filepath.Join(canonTempDir(t), "my project") writeSource(t, root, "lib/mod.ex", "defmodule NegSpace.Mod do\nend\n") uri := fileURI(root) if !strings.Contains(uri, "%20") { @@ -369,6 +381,69 @@ func TestNegotiation_ReportsInitializing(t *testing.T) { b.initErr = context.Canceled } +// Symlink aliases of one directory must share a workspace: two live +// workspaces over one database would race each other's index writes. +func TestNegotiation_SymlinkedRootsShareWorkspace(t *testing.T) { + e := setupNegotiation(t) + root, uri := projectDir(t, "NegLink.Mod") + link := filepath.Join(canonTempDir(t), "link") + if err := os.Symlink(root, link); err != nil { + t.Fatal(err) + } + cs1, _ := e.connect(nil, uri) + cs2, _ := e.connect(nil, fileURI(link)) + + wantContains(t, mustTool(t, cs1, "dexter_search", map[string]any{"query": "NegLink"}), "NegLink.Mod") + wantContains(t, mustTool(t, cs2, "dexter_search", map[string]any{"query": "NegLink"}), "NegLink.Mod") + + e.h.mu.Lock() + nbindings := len(e.h.bindings) + e.h.mu.Unlock() + if nbindings != 1 { + t.Errorf("symlink alias created %d workspaces, want 1", nbindings) + } +} + +// While a root's last workspace is still tearing down, a new session for that +// root must wait it out instead of opening a second store over the same +// database mid-teardown. +func TestNegotiation_WaitsForDrainingWorkspace(t *testing.T) { + e := setupNegotiation(t) + root, uri := projectDir(t, "NegDrain.Mod") + + drain := make(chan struct{}) + e.h.mu.Lock() + e.h.draining[root] = drain + e.h.mu.Unlock() + + cs, _ := e.connect(nil, uri) + result := make(chan string, 1) + go func() { + out, _ := toolText(t, cs, "dexter_search", map[string]any{"query": "NegDrain"}) + result <- out + }() + + select { + case out := <-result: + t.Fatalf("call proceeded while the workspace was draining: %s", out) + case <-time.After(300 * time.Millisecond): + } + + e.h.mu.Lock() + delete(e.h.draining, root) + e.h.mu.Unlock() + close(drain) + + select { + case out := <-result: + if !strings.Contains(out, "NegDrain.Mod") { + t.Errorf("call after drain did not find the module: %s", out) + } + case <-time.After(10 * time.Second): + t.Fatal("call never completed after the drain finished") + } +} + // A session disconnecting releases its workspace. func TestNegotiation_SessionCloseReleasesWorkspace(t *testing.T) { e := setupNegotiation(t) diff --git a/internal/mcp/roots.go b/internal/mcp/roots.go index fac5c0f..5c1e2ea 100644 --- a/internal/mcp/roots.go +++ b/internal/mcp/roots.go @@ -60,7 +60,17 @@ func negotiatedRoot(ctx context.Context, ss *mcp.ServerSession) (root string, ok if err != nil || !info.IsDir() { return "", false, fmt.Errorf("client root %q is not a directory", path) } - return store.FindProjectRoot(path), true, nil + return store.FindProjectRoot(canonicalRoot(path)), true, nil } return "", false, nil } + +// canonicalRoot resolves symlinks so every alias of a directory keys the same +// workspace; two live workspaces over one database would race each other's +// index writes. +func canonicalRoot(path string) string { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return path +} From 6e0334629a24b68cfc6ecbc68a41e2c7e98d3ead Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 9 Sep 2026 17:52:47 +1000 Subject: [PATCH 16/17] Resolve the fallback root's symlinks before the marker walk Negotiated roots resolve symlinks before walking for project markers, but the fallback walked the launch directory's logical path first and canonicalized after, so a marker above a symlink's target was invisible and the two mechanisms could key different workspaces for one directory. --- cmd/main.go | 9 +++++++++ internal/mcp/negotiation_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/cmd/main.go b/cmd/main.go index 8b28f67..f65a58f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -495,6 +495,15 @@ func openStoreForServer(projectRoot string) *store.Store { // negotiated through MCP roots, with projectRoot (the launch directory) as // the fallback for clients that provide none. func cmdMCP(projectRoot string, listen string, explicitRoot bool) { + if !explicitRoot { + // The fallback root must key the same workspace a negotiated root + // would, and negotiated roots resolve symlinks before walking for + // project markers: a marker above a symlink's target is invisible + // from the symlink's logical parents. + if resolved, err := filepath.EvalSymlinks(projectRoot); err == nil { + projectRoot = resolved + } + } projectRoot = findProjectRoot(projectRoot) log.SetOutput(os.Stderr) diff --git a/internal/mcp/negotiation_test.go b/internal/mcp/negotiation_test.go index 8819cf0..610cf91 100644 --- a/internal/mcp/negotiation_test.go +++ b/internal/mcp/negotiation_test.go @@ -404,6 +404,30 @@ func TestNegotiation_SymlinkedRootsShareWorkspace(t *testing.T) { } } +// A symlinked fallback root and a negotiated root for the same directory must +// key one workspace, so no-roots and roots-advertising sessions share it. +func TestNegotiation_SymlinkedFallbackSharesWorkspace(t *testing.T) { + root, uri := projectDir(t, "NegFallLink.Mod") + link := filepath.Join(canonTempDir(t), "link") + if err := os.Symlink(root, link); err != nil { + t.Fatal(err) + } + e := &negotiationEnv{t: t, h: NewHandler(Config{ProjectRoot: link, NegotiateRoots: true}), fallback: root} + t.Cleanup(e.h.Close) + + noRoots, _ := e.connect(&mcp.ClientOptions{Capabilities: &mcp.ClientCapabilities{}}) + withRoots, _ := e.connect(nil, uri) + wantContains(t, mustTool(t, noRoots, "dexter_search", map[string]any{"query": "NegFallLink"}), "NegFallLink.Mod") + wantContains(t, mustTool(t, withRoots, "dexter_search", map[string]any{"query": "NegFallLink"}), "NegFallLink.Mod") + + e.h.mu.Lock() + nbindings := len(e.h.bindings) + e.h.mu.Unlock() + if nbindings != 1 { + t.Errorf("fallback and negotiated sessions hold %d workspaces, want 1 shared", nbindings) + } +} + // While a root's last workspace is still tearing down, a new session for that // root must wait it out instead of opening a second store over the same // database mid-teardown. From c4faac720efd4e47318b9d307b9fc5ea57ca4bca Mon Sep 17 00:00:00 2001 From: "shane.hull" Date: Wed, 9 Sep 2026 18:00:31 +1000 Subject: [PATCH 17/17] Resolve the fallback root exactly like a negotiated root The fallback still walked with the CLI's extra mix.exs marker while negotiated roots use the LSP's markers only, so a no-roots session and a roots session for one directory could key different workspaces in a markerless tree. Explicit paths keep the CLI resolution. --- cmd/main.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index f65a58f..a0b4144 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -495,16 +495,18 @@ func openStoreForServer(projectRoot string) *store.Store { // negotiated through MCP roots, with projectRoot (the launch directory) as // the fallback for clients that provide none. func cmdMCP(projectRoot string, listen string, explicitRoot bool) { - if !explicitRoot { - // The fallback root must key the same workspace a negotiated root - // would, and negotiated roots resolve symlinks before walking for - // project markers: a marker above a symlink's target is invisible - // from the symlink's logical parents. + if explicitRoot { + projectRoot = findProjectRoot(projectRoot) + } else { + // The fallback root must key the same workspace a negotiated root for + // the launch directory would, so it resolves identically: symlinks + // first (a marker above a symlink's target is invisible from the + // symlink's logical parents), then the LSP's marker walk. if resolved, err := filepath.EvalSymlinks(projectRoot); err == nil { projectRoot = resolved } + projectRoot = store.FindProjectRoot(projectRoot) } - projectRoot = findProjectRoot(projectRoot) log.SetOutput(os.Stderr) var h *dexter_mcp.Handler