diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c7b299..97fe490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [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`. 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 - **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 diff --git a/README.md b/README.md index c3f5726..3addfe0 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, incremental reindexing, and workspace-wide rename. + +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 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: + +```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 7cf9d44..a0b4144 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,15 +1,21 @@ package main import ( + "context" "fmt" "io/fs" "log" + "net" + "net/http" "os" + "os/signal" "path/filepath" + "syscall" "time" "github.com/remoteoss/dexter/internal/indexer" 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" @@ -98,6 +104,7 @@ func main() { }, } + var lspMCPListen string lspCmd := &cobra.Command{ Use: "lsp [path]", Short: "Start the LSP server (stdio)", @@ -107,10 +114,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, len(args) > 0) + 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", @@ -120,7 +150,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) @@ -347,9 +377,81 @@ 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) + if mcpListen == "" { + if err := dexter_lsp.Serve(server, os.Stdin, os.Stdout); err != nil { + fatal(err) + } + return + } + + // 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. + serveErrCh := make(chan error, 1) + go func() { + serveErrCh <- dexter_lsp.Serve(server, os.Stdin, os.Stdout) + }() + 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 { + log.Printf("Warning: file watching unavailable (%v); the index updates through LSP events, branch switches, and dexter_reindex", err) + } else { + defer func() { + if err := watcher.Close(); err != nil { + log.Printf("Warning: closing file watcher: %v", err) + } + }() + } + + 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 := <-serveErrCh + 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++ { @@ -384,16 +486,93 @@ func cmdLSP(projectRoot string) { fatal(openErr) } } - defer func() { - if err := s.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close store: %v\n", err) - } - }() + return s +} +// cmdMCP starts the headless MCP server. Logs go to stderr; stdout belongs to +// 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) { + 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) + } log.SetOutput(os.Stderr) - log.Printf("Dexter LSP v%s starting (root: %s)", version.Version, projectRoot) - if err := dexter_lsp.Serve(os.Stdin, os.Stdout, s, projectRoot); err != nil { + var h *dexter_mcp.Handler + if explicitRoot { + 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() + defer server.StopGitHeadWatch() + + // 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() + + 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_mcp.RunStdio(ctx, h); err != nil && ctx.Err() == nil { fatal(err) } } diff --git a/docs/architecture.md b/docs/architecture.md index 9e52672..64dc71a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,6 +10,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 @@ -114,6 +115,8 @@ 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, 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 `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. diff --git a/go.mod b/go.mod index c2da4ee..c5ae6fd 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ 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 github.com/tree-sitter/go-tree-sitter v0.25.0 github.com/tree-sitter/tree-sitter-elixir v0.3.5 @@ -16,13 +18,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..c8a4d7c 100644 --- a/go.sum +++ b/go.sum @@ -6,9 +6,15 @@ 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= -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 +24,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 +33,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 +70,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 +100,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 +109,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 +118,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..f8ab35b 100644 --- a/integration_test.go +++ b/integration_test.go @@ -1,11 +1,20 @@ package main import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" "os" "os/exec" "path/filepath" "strings" "testing" + "time" + + sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "go.lsp.dev/uri" "github.com/remoteoss/dexter/internal/store" ) @@ -564,3 +573,216 @@ 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", "dexter_rename_symbol"} { + 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", "dexter_rename_symbol"} { + 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) + } + stdout, err := cmd.StdoutPipe() + 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) + } + 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() { + 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) + } + + // Agent edits do not necessarily produce editor LSP notifications. Attached + // MCP mode must still observe them through its filesystem watcher. + createdPath := filepath.Join(root, "lib", "my_app", "created_by_agent.ex") + if err := os.WriteFile(createdPath, []byte("defmodule MyApp.CreatedByAgent do\n def run, do: :ok\nend\n"), 0o644); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for { + res, err = session.CallTool(ctx, &sdkmcp.CallToolParams{Name: "dexter_definition", Arguments: map[string]any{"module": "MyApp.CreatedByAgent"}}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(mcpToolText(t, res), "lib/my_app/created_by_agent.ex") { + break + } + if time.Now().After(deadline) { + t.Fatal("attached MCP watcher did not index an agent-created file") + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/internal/lsp/api.go b/internal/lsp/api.go new file mode 100644 index 0000000..8c31a9a --- /dev/null +++ b/internal/lsp/api.go @@ -0,0 +1,381 @@ +package lsp + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "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/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 := server.renameHandler(protocol.ServerHandler(server, nil)) + ctx := context.Background() + + conn.Go(ctx, handler) + <-conn.Done() + return conn.Err() +} + +// 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 +} + +// 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 +} + +// 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 +// workspace reindex's walk-and-prune. +func (s *Server) WithReindexLock(fn func()) { + s.reindexing.Lock() + defer s.reindexing.Unlock() + s.indexWrites.RLock() + defer s.indexWrites.RUnlock() + if s.indexUnavailable { + return + } + 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 +// 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) + } + 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, true) + if err != nil { + return RenameSummary{}, err + } + 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 +} + +// 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(oldModule, newModule, true) + if err != nil { + return RenameSummary{}, err + } + if err := s.deliverEdits(edit); err != nil { + return RenameSummary{}, err + } + s.backgroundWork.Wait() + return RenameSummary{FilesChanged: files, FilesMoved: moved}, nil +} + +// deliverEdits carries out a WorkspaceEdit on behalf of a caller that is not +// an editor. With a live LSP client (attached mode) the whole edit is +// forwarded as a workspace/applyEdit request: the editor owns the open +// buffers, and for a file it has open it owns the move too, so it applies +// everything and syncs back via didChange exactly as an editor-initiated +// rename would. Without a client there is no editor to ask, so the edit is +// carried out on disk; headless servers have no open buffers and never move a +// file through the client, so that path is a defensive no-op in practice. +func (s *Server) deliverEdits(edit *WorkspaceEdit) error { + if edit.empty() { + return nil + } + if s.conn != nil { + prepared, err := s.prepareDeliveredEdit(edit) + if err != nil { + return err + } + applied, err := s.applyEdit(context.Background(), "dexter rename", edit) + if err != nil { + return err + } + if !applied { + return fmt.Errorf("editor did not apply the rename edits for open files") + } + s.recordDeliveredEdit(prepared) + return nil + } + + edits := edit.textEditsByPath() + renames := edit.fileRenames() + for path, fileEdits := range edits { + 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, fileEdits)), 0644); err != nil { + return err + } + } + // Renames come after the edits, so an edited file is moved with its new + // contents — the same order the client applies documentChanges in. + for from, to := range renames { + if err := os.MkdirAll(filepath.Dir(to), 0755); err != nil { + return err + } + if err := os.Rename(from, to); err != nil { + return err + } + _ = s.store.RemoveFile(from) + } + + paths := make([]string, 0, len(edits)+len(renames)) + for path := range edits { + if to, moved := renames[path]; moved { + paths = append(paths, to) + } else { + paths = append(paths, path) + } + } + for from, to := range renames { + if _, edited := edits[from]; !edited { + paths = append(paths, to) + } + } + if len(paths) > 0 { + s.reindexPaths(paths) + } + return nil +} + +type deliveredFile struct { + oldPath string + newPath string + text string + open bool +} + +// prepareDeliveredEdit snapshots the source text without mutating disk or the +// document store. That keeps an editor-rejected workspace edit fully atomic. +func (s *Server) prepareDeliveredEdit(edit *WorkspaceEdit) ([]deliveredFile, error) { + edits := edit.textEditsByPath() + renames := edit.fileRenames() + paths := make(map[string]struct{}, len(edits)+len(renames)) + for path := range edits { + paths[path] = struct{}{} + } + for path := range renames { + paths[path] = struct{}{} + } + + prepared := make([]deliveredFile, 0, len(paths)) + for path := range paths { + text, open, ok := s.ReadFileText(path) + if !ok { + return nil, fmt.Errorf("reading %s to prepare rename edits", path) + } + if fileEdits := edits[path]; len(fileEdits) > 0 { + text = applyTextEdits(text, fileEdits) + } + newPath := path + if renamed, ok := renames[path]; ok { + newPath = renamed + } + prepared = append(prepared, deliveredFile{oldPath: path, newPath: newPath, text: text, open: open}) + } + return prepared, nil +} + +// recordDeliveredEdit makes MCP reads and index queries reflect an accepted +// editor edit immediately, without waiting for subsequent didChange events. +func (s *Server) recordDeliveredEdit(files []deliveredFile) { + s.indexWrites.RLock() + defer s.indexWrites.RUnlock() + if s.indexUnavailable { + return + } + for _, file := range files { + if file.oldPath != file.newPath { + _ = s.store.RemoveFile(file.oldPath) + } + defs, refs, err := parser.ParseText(file.newPath, file.text) + if err == nil { + _ = s.store.IndexFileWithRefs(file.newPath, defs, refs) + } + if file.open { + if file.oldPath != file.newPath { + s.docs.Close(string(uri.File(file.oldPath))) + } + s.docs.Set(string(uri.File(file.newPath)), file.text) + } + } +} + +// 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 + } + 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") + lines = append(lines[:start.Line], append(replacement, lines[end.Line+1:]...)...) + } + return strings.Join(lines, "\n") +} + +func (s *Server) reindexPaths(paths []string) { + for _, path := range paths { + s.indexOneFile(path) + } +} diff --git a/internal/lsp/api_test.go b/internal/lsp/api_test.go new file mode 100644 index 0000000..5402ffc --- /dev/null +++ b/internal/lsp/api_test.go @@ -0,0 +1,421 @@ +package lsp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "go.lsp.dev/jsonrpc2" + "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: "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", + 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) + } + }) + } +} + +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) { + 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) + } +} + +// fakeConn records workspace/applyEdit requests. The edit is captured as the +// JSON that actually goes over the wire, because that is the only place the +// resource operations survive — protocol.Client.ApplyEdit's typed params +// would drop them. +type fakeConn struct { + jsonrpc2.Conn + applied *WorkspaceEdit + raw map[string]interface{} + reject bool +} + +func (f *fakeConn) Call(_ context.Context, method string, params, result interface{}) (jsonrpc2.ID, error) { + if method != protocol.MethodWorkspaceApplyEdit { + return jsonrpc2.ID{}, nil + } + p, ok := params.(*applyWorkspaceEditParams) + if !ok { + return jsonrpc2.ID{}, fmt.Errorf("applyEdit params were %T", params) + } + f.applied = p.Edit + data, err := json.Marshal(p) + if err != nil { + return jsonrpc2.ID{}, err + } + if err := json.Unmarshal(data, &f.raw); err != nil { + return jsonrpc2.ID{}, err + } + if res, ok := result.(*protocol.ApplyWorkspaceEditResponse); ok { + res.Applied = !f.reject + } + return jsonrpc2.ID{}, 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 := &fakeConn{} + server.conn = 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) != 2 { + t.Errorf("ApplyEdit carried %d files, want both open and closed files", 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") + } +} + +// 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.conn = &fakeConn{reject: true} + + definitionSrc := `defmodule MyApp.Accounts do + def fetch_user(id), do: id +end + ` + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", definitionSrc) + definitionPath := filepath.Join(server.projectRoot, "lib/accounts.ex") + 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") + } + data, err := os.ReadFile(definitionPath) + if err != nil { + t.Fatal(err) + } + if string(data) != definitionSrc { + t.Errorf("rejected rename changed a closed file:\n%s", data) + } +} + +// An agent renaming a module whose file the editor has open: the move belongs +// to the editor, so it has to travel in the applyEdit request as a rename +// resource operation. protocol.ApplyWorkspaceEditParams cannot carry one, so a +// deliverEdits that reaches for the typed client would silently move nothing. +func TestRenameModule_ForwardsFileMoveToClient(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + fc := &fakeConn{} + server.conn = fc + server.renameFileOpsSupported = true + + 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") + server.docs.Set(string(uri.File(oldPath)), src) + + summary, err := server.RenameModule("MyApp.Accounts", "MyApp.Auth") + if err != nil { + t.Fatal(err) + } + + if fc.applied == nil { + t.Fatal("no workspace/applyEdit request reached the editor") + } + var renamed *RenameFile + for _, change := range fc.applied.DocumentChanges { + if rf, ok := change.(RenameFile); ok { + renamed = &rf + } + } + if renamed == nil { + t.Fatalf("applyEdit carried no rename operation: %+v", fc.applied.DocumentChanges) + } + if want := protocol.DocumentURI(uri.File(newPath)); renamed.NewURI != want { + t.Errorf("rename target = %s, want %s", renamed.NewURI, want) + } + + // The operation has to survive marshaling — that is what the editor reads. + edit, _ := fc.raw["edit"].(map[string]interface{}) + changes, _ := edit["documentChanges"].([]interface{}) + foundKind := false + for _, c := range changes { + if m, ok := c.(map[string]interface{}); ok && m["kind"] == "rename" { + foundKind = true + } + } + if !foundKind { + t.Errorf("no rename operation in the marshaled request: %v", fc.raw) + } + + // The editor performs the move, so dexter must have left both paths alone. + if _, err := os.Stat(oldPath); err != nil { + t.Error("dexter removed the file the editor has open") + } + if _, err := os.Stat(newPath); err == nil { + t.Error("dexter created the destination; the editor performs the move") + } + if summary.FilesMoved[oldPath] != newPath { + t.Errorf("summary reports moves %v, want %s → %s", summary.FilesMoved, oldPath, newPath) + } +} + +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() + server.conn = &fakeConn{reject: true} + + 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") + + if _, err := server.RenameModule("MyApp.Accounts", "MyApp.Auth"); err == nil { + t.Fatal("rename reported success despite the editor rejecting the edit") + } + data, err := os.ReadFile(oldPath) + if err != nil { + t.Fatalf("source file was moved or removed: %v", err) + } + if string(data) != src { + t.Errorf("rejected rename changed the source file:\n%s", data) + } + if _, err := os.Stat(newPath); !os.IsNotExist(err) { + t.Errorf("rejected rename created destination %s", newPath) + } +} + +// Headless, no editor: nothing is open, so dexter carries out the whole edit +// itself and the summary still reports the move. +func TestRenameModule_HeadlessMovesFilesItself(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/accounts.ex", `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") + + summary, err := server.RenameModule("MyApp.Accounts", "MyApp.Auth") + if err != nil { + t.Fatal(err) + } + + if _, err := os.Stat(oldPath); err == nil { + t.Error("expected accounts.ex to be gone") + } + data, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("expected auth.ex on disk: %v", err) + } + if !strings.Contains(string(data), "defmodule MyApp.Auth") { + t.Errorf("expected 'defmodule MyApp.Auth', got:\n%s", data) + } + if summary.FilesMoved[oldPath] != newPath { + 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/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 1ed5428..ff9cdfb 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -25,7 +25,6 @@ import ( "go.lsp.dev/jsonrpc2" "go.lsp.dev/protocol" "go.lsp.dev/uri" - "go.uber.org/zap" "github.com/remoteoss/dexter/internal/indexer" "github.com/remoteoss/dexter/internal/parser" @@ -115,6 +114,12 @@ 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 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 +146,8 @@ func NewServer(s *store.Store, projectRoot string) *Server { erlangRuntimeCache: make(map[string]*erlangRuntimeCache), usingCache: make(map[string]*usingCacheEntry), depsCache: make(map[string]bool), + ready: make(chan struct{}), + gitHeadStop: make(chan struct{}), } } @@ -151,24 +158,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 := server.renameHandler(protocol.ServerHandler(server, nil)) - ctx := context.Background() - - conn.Go(ctx, handler) - <-conn.Done() - return conn.Err() -} - // warmUsingCache parses every module's defmacro __using__ body ahead of the // first request that needs one. // @@ -351,147 +340,161 @@ func (s *Server) backgroundReindex() { return } defer s.reindexing.Unlock() + s.reindexWorkspace() + }() +} - start := time.Now() - reindexed := 0 - coldStart := s.store.IsEmpty() - fullBuilt := false - - if coldStart { - 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 blocks until it completes. +func (s *Server) ReindexWorkspace() (int, time.Duration) { + s.reindexing.Lock() + defer s.reindexing.Unlock() + return s.reindexWorkspace() +} - stats, ran, err := s.fullBuild() - switch { - case errors.Is(err, indexer.ErrUnindexed): - // The SQL indexes did not come back after the bulk load committed - // or rolled back, so every query is a full table scan. Falling back - // to the incremental walk would be far worse than doing nothing: - // each per-file write issues a DELETE by file_id against definitions - // and refs tables, once per file on disk. FullBuild leaves the - // index version unset, so the next editor start rebuilds from - // scratch through cmdInit — in a process with no live readers, - // where deleting the database is safe. - log.Printf("Error: SQL indexes could not be restored after the bulk build: %v", err) - s.showError("Dexter: the index could not be completed. Run `dexter init --force` in your project root and restart your editor. If it happens again, please report it.") - // Collapse any committed data in the WAL rather than leaving it at - // its high-water mark for the rest of the process. - if err := s.store.Checkpoint(); err != nil { - log.Printf("Warning: WAL checkpoint: %v", err) - } - return - case err != nil: - // The incremental walk below needs nothing to be true of the - // database, so it is the safe thing to fall back to. It is - // slower, not wrong. - log.Printf("Warning: full index build failed, falling back to incremental: %v", err) - case !ran: - // Something wrote to the index between the check above and the - // build's lock. Nothing was built, and the incremental path - // below covers whatever is there. - log.Printf("Index was no longer empty at build time, using incremental reindex") - default: - fullBuilt = true - reindexed = stats.Files +func (s *Server) reindexWorkspace() (int, time.Duration) { + start := time.Now() + reindexed := 0 + coldStart := s.store.IsEmpty() + fullBuilt := false + + if coldStart { + 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) } } - // Re-read rather than reusing coldStart. A full build, a failed build - // and a concurrent write all change the answer, and reading a stale - // true here would skip the mtime short-circuit for every file. - isEmpty := s.store.IsEmpty() + stats, ran, err := s.fullBuild() + switch { + case errors.Is(err, indexer.ErrUnindexed): + // The SQL indexes did not come back after the bulk load committed + // or rolled back, so every query is a full table scan. Falling back + // to the incremental walk would be far worse than doing nothing: + // each per-file write issues a DELETE by file_id against definitions + // and refs tables, once per file on disk. FullBuild leaves the + // index version unset, so the next editor start rebuilds from + // scratch through cmdInit — in a process with no live readers, + // where deleting the database is safe. + log.Printf("Error: SQL indexes could not be restored after the bulk build: %v", err) + s.showError("Dexter: the index could not be completed. Run `dexter init --force` in your project root and restart your editor. If it happens again, please report it.") + // Collapse any committed data in the WAL rather than leaving it at + // its high-water mark for the rest of the process. + if err := s.store.Checkpoint(); err != nil { + log.Printf("Warning: WAL checkpoint: %v", err) + } + return reindexed, time.Since(start).Round(time.Millisecond) + case err != nil: + // The incremental walk below needs nothing to be true of the + // database, so it is the safe thing to fall back to. It is + // slower, not wrong. + log.Printf("Warning: full index build failed, falling back to incremental: %v", err) + case !ran: + // Something wrote to the index between the check above and the + // build's lock. Nothing was built, and the incremental path + // below covers whatever is there. + log.Printf("Index was no longer empty at build time, using incremental reindex") + default: + fullBuilt = true + reindexed = stats.Files + } + } - seen := make(map[string]struct{}) - walkAndIndex := func(root string, indexRefs bool) { - _ = parser.WalkElixirFiles(root, func(path string, d fs.DirEntry) error { - seen[path] = struct{}{} + // Re-read rather than reusing coldStart. A full build, a failed build + // and a concurrent write all change the answer, and reading a stale + // true here would skip the mtime short-circuit for every file. + isEmpty := s.store.IsEmpty() - 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++ - return nil - }) - } + } - // A full build already indexed every file on disk from the traversal - // this walk would repeat, so skipping it saves a second traversal and a - // stored-mtime query per file. The prune lives in the same branch and - // so cannot run without the walk that fills `seen`. - if !fullBuilt { - // The walk writes, so it takes indexWrites for reading, the same as - // every other single-file write. That is what keeps it from - // overlapping a cold build. - s.indexWrites.RLock() - if s.indexUnavailable { - s.indexWrites.RUnlock() - return + defs, refs, err := parser.ParseFile(path) + if err != nil { + return nil + } + if !indexRefs { + refs = nil } - // Index stdlib first (definitions only). - if s.stdlibRoot != "" { - walkAndIndex(s.stdlibRoot, false) + if err := s.store.IndexFileWithRefs(path, defs, refs); err != nil { + log.Printf("Warning: reindex %s: %v", path, err) } + reindexed++ + return nil + }) + } - walkAndIndex(s.projectRoot, true) + // A full build already indexed every file on disk from the traversal + // this walk would repeat, so skipping it saves a second traversal and a + // stored-mtime query per file. The prune lives in the same branch and + // so cannot run without the walk that fills `seen`. + if !fullBuilt { + // The walk writes, so it takes indexWrites for reading, the same as + // every other single-file write. That is what keeps it from + // overlapping a cold build. + s.indexWrites.RLock() + if s.indexUnavailable { s.indexWrites.RUnlock() - - s.pruneMissingFiles(seen) + return reindexed, time.Since(start).Round(time.Millisecond) } - - // 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) + // Index stdlib first (definitions only). + if s.stdlibRoot != "" { + walkAndIndex(s.stdlibRoot, false) } - // The index is in place now, so fill the __using__ cache before a user - // asks for it. See warmUsingCache. - s.warmUsingCache() + walkAndIndex(s.projectRoot, true) + s.indexWrites.RUnlock() - elapsed := time.Since(start).Round(time.Millisecond) - log.Printf("Background reindex: %d files updated (%s)", reindexed, elapsed) + s.pruneMissingFiles(seen) + } - if coldStart && 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) - } + // 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) + } + + // The index is in place now, so fill the __using__ cache before a user + // asks for it. See warmUsingCache. + s.warmUsingCache() + + elapsed := time.Since(start).Round(time.Millisecond) + log.Printf("Background reindex: %d files updated (%s)", reindexed, elapsed) + + if coldStart && 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() { + s.gitHeadWG.Add(1) go func() { + defer s.gitHeadWG.Done() headPath := filepath.Join(s.projectRoot, ".git", "HEAD") var lastMtime int64 @@ -504,7 +507,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 @@ -513,7 +521,7 @@ func (s *Server) watchGitHead() { if currentMtime != lastMtime { lastMtime = currentMtime log.Printf("Git HEAD changed, reindexing...") - s.backgroundReindex() + s.ReindexWorkspace() } } }() @@ -632,7 +640,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 { @@ -693,6 +701,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 } @@ -4578,7 +4587,8 @@ func (s *Server) RenameEdit(ctx context.Context, params *protocol.RenameParams) 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, false) + return edit, err } } else if moduleRef != "" { fullModule := resolveModule(moduleRef, aliases) @@ -4592,7 +4602,8 @@ func (s *Server) RenameEdit(ctx context.Context, params *protocol.RenameParams) if !isValidModuleName(newModule) { return nil, fmt.Errorf("invalid module name %q: must be CamelCase segments separated by dots", params.NewName) } - return s.renameModuleEdits(fullModule, newModule) + edit, _, _, err := s.renameModuleEdits(fullModule, newModule, false) + return edit, err } } } @@ -4602,7 +4613,7 @@ func (s *Server) RenameEdit(ctx context.Context, params *protocol.RenameParams) // renameFunctionEdits builds a WorkspaceEdit renaming all occurrences of // module.functionName to newName across the codebase. -func (s *Server) renameFunctionEdits(module, functionName, newName string) (*WorkspaceEdit, error) { +func (s *Server) renameFunctionEdits(module, functionName, newName string, deliverAll bool) (*WorkspaceEdit, []string, error) { // Collect all (filePath, lineNumber) pairs — definitions + references type siteKey struct { filePath string @@ -4631,7 +4642,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*Wor // 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) @@ -4640,7 +4651,7 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*Wor // 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" { @@ -4726,7 +4737,11 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*Wor } } - edit := s.buildTextEdits(sites, functionName, newName) + edit := s.buildTextEdits(sites, functionName, newName, deliverAll) + 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. @@ -4764,9 +4779,10 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*Wor if !changed { continue } + changedFiles[del.FilePath] = true fileURI := protocol.DocumentURI(uri.File(del.FilePath)) - if open { + if open || deliverAll { if edit.Changes == nil { edit.Changes = make(map[protocol.DocumentURI][]protocol.TextEdit) } @@ -4789,7 +4805,12 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*Wor } } - return edit, nil + files := make([]string, 0, len(changedFiles)) + for filePath := range changedFiles { + files = append(files, filePath) + } + sort.Strings(files) + return edit, files, nil } // renameModuleEdits builds a WorkspaceEdit renaming oldModule to newModule, @@ -4800,28 +4821,42 @@ func (s *Server) renameFunctionEdits(module, functionName, newName string) (*Wor // WorkspaceEdit, keeping the response small and avoiding editor freezes. // 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) { +func (s *Server) renameModuleEdits(oldModule, newModule string, deliverAll bool) (*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() fileCache := mr.readFiles() - movedFiles, clientRenames := mr.moveConventionalFiles(fileCache) - openChanges := mr.applyEdits(fileCache, movedFiles) - mr.reindex(fileCache, movedFiles, clientRenames) + movedFiles, clientRenames := mr.moveConventionalFiles(fileCache, deliverAll) + openChanges := mr.applyEdits(fileCache, movedFiles, deliverAll) + if !deliverAll { + mr.reindex(fileCache, movedFiles, clientRenames) + } + moved := make(map[string]string, len(movedFiles)+len(clientRenames)) + for from, to := range movedFiles { + moved[from] = to + } + for from, to := range clientRenames { + moved[from] = to + } + files := make([]string, 0, len(mr.sitesByFile)) + for filePath := range mr.sitesByFile { + files = append(files, filePath) + } + sort.Strings(files) if len(clientRenames) == 0 { - return &WorkspaceEdit{Changes: openChanges}, nil + return &WorkspaceEdit{Changes: openChanges}, moved, files, nil } - return renamesToDocumentChanges(openChanges, clientRenames), nil + return renamesToDocumentChanges(openChanges, clientRenames), moved, files, nil } // renamesToDocumentChanges folds the open buffers' text edits and the file @@ -5182,7 +5217,7 @@ func (mr *moduleRename) conventionalNewPath(r store.LookupResult) (string, bool) // 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) { +func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInfo, deliverAll bool) (movedFiles, clientRenames map[string]string) { movedFiles = make(map[string]string) clientRenames = make(map[string]string) for _, r := range mr.allModuleDefs { @@ -5197,6 +5232,15 @@ func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInf if !hasContent { continue } + if deliverAll { + // 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 + } if fi.open { // Client applies rename operations: leave both paths untouched. @@ -5239,7 +5283,7 @@ func (mr *moduleRename) moveConventionalFiles(fileCache map[string]moduleFileInf // applyEdits applies text edits to all non-moved files: open buffers get // TextEdits in the WorkspaceEdit, closed files are written directly to disk. -func (mr *moduleRename) applyEdits(fileCache map[string]moduleFileInfo, movedFiles map[string]string) map[protocol.DocumentURI][]protocol.TextEdit { +func (mr *moduleRename) applyEdits(fileCache map[string]moduleFileInfo, movedFiles map[string]string, deliverAll bool) map[protocol.DocumentURI][]protocol.TextEdit { openChanges := make(map[protocol.DocumentURI][]protocol.TextEdit) var wg sync.WaitGroup @@ -5251,7 +5295,7 @@ func (mr *moduleRename) applyEdits(fileCache map[string]moduleFileInfo, movedFil if !ok { continue } - if fi.open { + if fi.open || deliverAll { 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 @@ -5376,7 +5420,7 @@ type textReindex 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) *WorkspaceEdit { +func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string, deliverAll bool) *WorkspaceEdit { // Group sites by file sitesByFile := make(map[string][]renameSite, len(sites)) for _, site := range sites { @@ -5451,7 +5495,7 @@ func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) * // Compute edits once for both TextEdits and reindexing updatedLines := applyTokenEdits(fi.lines, fileSites) - if fi.open { + if fi.open || deliverAll { // Open buffer: build TextEdits for the editor AND capture updated // text for reindexing (computed once, used for both purposes). fileURI := protocol.DocumentURI(uri.File(fp)) @@ -5476,7 +5520,9 @@ func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) * }) } } - openReindexes = append(openReindexes, textReindex{fp, strings.Join(updatedLines, "\n")}) + if !deliverAll { + openReindexes = append(openReindexes, textReindex{fp, strings.Join(updatedLines, "\n")}) + } } else { // Closed file: write to disk in parallel wg.Add(1) @@ -5491,7 +5537,9 @@ func (s *Server) buildTextEdits(sites []renameSite, oldToken, newToken string) * } wg.Wait() - s.reindexAfterRename(nil, reindexPaths, openReindexes) + if !deliverAll { + s.reindexAfterRename(nil, reindexPaths, openReindexes) + } return &WorkspaceEdit{Changes: openChanges} } @@ -5590,6 +5638,11 @@ func (s *Server) readFileText(filePath string) (text string, open bool, ok bool) return "", false, false } +// ReadFileText returns a file's current text, preferring an editor-owned buffer. +func (s *Server) ReadFileText(filePath string) (text string, open bool, ok bool) { + return s.readFileText(filePath) +} + // getFileLine 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 @@ -5622,6 +5675,11 @@ func (s *Server) getFileLine(filePath string, lineNum int) (string, bool) { return "", false } +// FileLine returns one 1-based line, preferring an editor-owned buffer. +func (s *Server) FileLine(filePath string, lineNum int) (string, bool) { + return s.getFileLine(filePath, lineNum) +} + // findBareCallRefs scans definition files for bare intra-module calls to // functionName (not indexed in the store) and returns them as ReferenceResults. func (s *Server) findBareCallRefs(module, functionName string) []store.ReferenceResult { diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 8fabad0..ad42ef3 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -6348,7 +6348,7 @@ end`) } }() - server.buildTextEdits([]renameSite{{filePath: path, line: 2}}, "old_name", "new_name") + server.buildTextEdits([]renameSite{{filePath: path, line: 2}}, "old_name", "new_name", false) done := make(chan struct{}) go func() { server.backgroundWork.Wait() diff --git a/internal/lsp/workspace_edit.go b/internal/lsp/workspace_edit.go index 19aa39a..bc16081 100644 --- a/internal/lsp/workspace_edit.go +++ b/internal/lsp/workspace_edit.go @@ -111,3 +111,56 @@ func (s *Server) renameHandler(next jsonrpc2.Handler) jsonrpc2.Handler { return reply(ctx, edit, nil) } } + +// applyWorkspaceEditParams mirrors protocol.ApplyWorkspaceEditParams with our +// own edit type, for the same reason WorkspaceEdit itself exists: the protocol +// package's edit has nowhere to put resource operations. +type applyWorkspaceEditParams struct { + Label string `json:"label,omitempty"` + Edit *WorkspaceEdit `json:"edit"` +} + +// applyEdit asks the editor to apply edit, reporting whether it did. It goes +// out over the raw connection rather than protocol.Client.ApplyEdit, whose +// typed params would drop every file move. +func (s *Server) applyEdit(ctx context.Context, label string, edit *WorkspaceEdit) (bool, error) { + var result protocol.ApplyWorkspaceEditResponse + params := &applyWorkspaceEditParams{Label: label, Edit: edit} + if err := protocol.Call(ctx, s.conn, protocol.MethodWorkspaceApplyEdit, params, &result); err != nil { + return false, err + } + return result.Applied, nil +} + +// empty reports whether the edit asks for nothing at all. +func (e *WorkspaceEdit) empty() bool { + return e == nil || (len(e.Changes) == 0 && len(e.DocumentChanges) == 0) +} + +// textEditsByPath returns the edit's text edits keyed by file path, from +// whichever of the two fields carries them. +func (e *WorkspaceEdit) textEditsByPath() map[string][]protocol.TextEdit { + out := make(map[string][]protocol.TextEdit, len(e.Changes)+len(e.DocumentChanges)) + for docURI, edits := range e.Changes { + path := uriToPath(docURI) + out[path] = append(out[path], edits...) + } + for _, change := range e.DocumentChanges { + if tde, ok := change.(TextDocumentEdit); ok { + path := uriToPath(tde.TextDocument.URI) + out[path] = append(out[path], tde.Edits...) + } + } + return out +} + +// fileRenames returns the moves the edit asks for, old path → new path. +func (e *WorkspaceEdit) fileRenames() map[string]string { + out := make(map[string]string, len(e.DocumentChanges)) + for _, change := range e.DocumentChanges { + if rf, ok := change.(RenameFile); ok { + out[uriToPath(rf.OldURI)] = uriToPath(rf.NewURI) + } + } + return out +} diff --git a/internal/mcp/binding.go b/internal/mcp/binding.go new file mode 100644 index 0000000..271a919 --- /dev/null +++ b/internal/mcp/binding.go @@ -0,0 +1,137 @@ +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) + } + log.Printf("MCP workspace closed: %s", b.root) +} + +// 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/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..c10c79a --- /dev/null +++ b/internal/mcp/file_outline.go @@ -0,0 +1,110 @@ +package mcp + +import ( + "context" + "fmt" + "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) + text, _, ok := h.lsp.ReadFileText(path) + if !ok { + return textResult(fmt.Sprintf("File not found: %s", h.relPath(path))), nil, nil + } + + // 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) + } + 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..2b0b9e1 --- /dev/null +++ b/internal/mcp/implementations.go @@ -0,0 +1,163 @@ +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 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) { + 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 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 + } + 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..f1fd749 --- /dev/null +++ b/internal/mcp/instructions.md @@ -0,0 +1,29 @@ +# 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` +- 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` +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, +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..beb3824 --- /dev/null +++ b/internal/mcp/mcp.go @@ -0,0 +1,433 @@ +// 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 ( + "context" + _ "embed" + "errors" + "fmt" + "log" + "path/filepath" + "strings" + "sync" + + "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 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 + draining map[string]chan struct{} // roots whose last workspace is still closing + 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: 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{ + lsp: cfg.LSP, + store: cfg.Store, + projectRoot: cfg.ProjectRoot, + } +} + +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 + } + source := "client roots" + if !ok { + root = h.fallbackRoot + source = "fallback" + } + + 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() + + if orphan != nil { + h.drainOrphan(orphan) + } + if created != nil { + created.init() + } + 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 +// 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) + } + h.draining[b.root] = make(chan struct{}) + 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 { + h.drainOrphan(orphan) + } +} + +// 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 + 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 +// 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}, + 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)} + + 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.", + }, (*Handler).workspaceHandler) + + 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.", + }, (*Handler).searchHandler) + + 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.", + }, (*Handler).definitionHandler) + + 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.", + }, (*Handler).referencesHandler) + + 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.", + }, (*Handler).moduleAPIHandler) + + 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.", + }, (*Handler).fileOutlineHandler) + + 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.", + }, (*Handler).implementationsHandler) + + 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.", + }, (*Handler).callHierarchyHandler) + + 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.", + }, (*Handler).reindexHandler) + + 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)}, + }, (*Handler).renameHandler) + + 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..5ae6036 --- /dev/null +++ b/internal/mcp/mcp_test.go @@ -0,0 +1,186 @@ +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 + lsp *lsp.Server + 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, lsp: server, 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_rename_symbol", + "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" && 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/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/negotiation_test.go b/internal/mcp/negotiation_test.go new file mode 100644 index 0000000..610cf91 --- /dev/null +++ b/internal/mcp/negotiation_test.go @@ -0,0 +1,487 @@ +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 := 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) { + 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 := canonTempDir(t) + 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 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 +} + +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 := canonTempDir(t) + 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 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) + } +} + +// 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(canonTempDir(t), "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 +} + +// 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) + } +} + +// 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. +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) + 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/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/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") + } +} diff --git a/internal/mcp/roots.go b/internal/mcp/roots.go new file mode 100644 index 0000000..5c1e2ea --- /dev/null +++ b/internal/mcp/roots.go @@ -0,0 +1,76 @@ +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.Clean(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(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 +} 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..95f7194 --- /dev/null +++ b/internal/mcp/tools_test.go @@ -0,0 +1,340 @@ +package mcp + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "go.lsp.dev/protocol" + "go.lsp.dev/uri" +) + +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 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 + @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", + ) + + 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) { + 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/watch.go b/internal/mcp/watch.go new file mode 100644 index 0000000..ebff311 --- /dev/null +++ b/internal/mcp/watch.go @@ -0,0 +1,192 @@ +package mcp + +import ( + "errors" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + + "github.com/remoteoss/dexter/internal/lsp" + "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 MCP queries in sync with filesystem changes. It runs in both +// headless and attached mode because agent edits need not pass through the +// editor's LSP file-change notifications. +type Watcher struct { + fsw *fsnotify.Watcher + server *lsp.Server + store *store.Store + root string + wg sync.WaitGroup +} + +// WatchFiles watches projectRoot recursively and incrementally reindexes +// 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, server: server, store: s, root: projectRoot} + 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) { + log.Printf("Warning: file watcher overflowed, reindexing workspace") + w.server.ReindexWorkspace() + continue + } + log.Printf("Warning: file watcher: %v", err) + case <-timerC: + timer = nil + timerC = nil + batch := pending + pending = make(map[string]struct{}) + w.server.WithReindexLock(func() { 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..cdc9367 --- /dev/null +++ b/internal/mcp/watch_test.go @@ -0,0 +1,189 @@ +package mcp + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/remoteoss/dexter/internal/lsp" + "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) { + 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) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + + server := lsp.NewServer(s, root) + w, err := WatchFiles(server, s, root) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := w.Close(); err != nil { + t.Errorf("closing watcher: %v", err) + } + }) + return s, root, server +} + +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") + } +} + +// 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)) + } +} diff --git a/internal/mcp/workspace.go b/internal/mcp/workspace.go new file mode 100644 index 0000000..9e41e74 --- /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 as files change and on git branch switches; dexter_reindex forces an immediate update.\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 54eb29d..e9bf039 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -947,6 +947,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 d.module, d.function, d.arity, d.kind, f.path, d.line, d.params FROM definitions d JOIN files f ON f.id = d.file_id WHERE d.module = ? AND d.kind IN ('callback', 'macrocallback') GROUP BY d.function, d.arity ORDER BY d.function, d.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 45c6be2..65f94aa 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1704,3 +1704,70 @@ func TestSetBulkPragmas_AppliesWhenExclusive(t *testing.T) { t.Errorf("journal_mode = %q, want memory", mode) } } + +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: msg +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("callbacks = %d, want 3: %+v", len(results), results) + } + kinds := make(map[string]string, len(results)) + for _, result := range results { + kinds[result.Function] = result.Kind + } + if kinds["deliver"] != "callback" || kinds["render"] != "macrocallback" { + t.Errorf("callback kinds = %v", kinds) + } +} + +func TestStats(t *testing.T) { + s, dir := setupTestStore(t) + defer func() { _ = s.Close() }() + + stats, err := s.Stats() + if err != nil { + t.Fatal(err) + } + if stats.Files != 0 || stats.Definitions != 0 || stats.References != 0 { + t.Errorf("empty store stats = %+v, want zeros", stats) + } + + path := writeElixirFile(t, dir, "lib/worker.ex", `defmodule SharedLib.Worker do + def run, do: MyApp.Accounts.fetch_user(1) +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) + } + stats, err = s.Stats() + if err != nil { + t.Fatal(err) + } + if stats.Files != 1 || stats.Definitions < 2 || stats.References < 1 { + t.Errorf("populated store stats = %+v", stats) + } +}