diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 72c78de..885e393 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -12,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" "sync" @@ -579,6 +580,12 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara fullModule := s.resolveBareFunctionModule(uriToPath(protocol.DocumentURI(docURI)), text, lines, lineNum, functionName, aliases) s.debugf("Definition: resolved bare %q -> %q", functionName, fullModule) if fullModule == "" { + if currentModule != "" { + if results := s.lookupMacroGeneratedDelegate(currentModule, functionName, uriToPath(protocol.DocumentURI(docURI)), text); len(results) > 0 { + s.debugf("Definition: found %d result(s) via macro-generated delegate in %s for %s", len(results), currentModule, functionName) + return storeResultsToLocations(filterOutTypes(results)), nil + } + } s.debugf("Definition: could not resolve bare function %q", functionName) return nil, nil } @@ -606,6 +613,11 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara return storeResultsToLocations(filterOutTypes(results)), nil } + if results := s.lookupMacroGeneratedDelegate(fullModule, functionName, "", ""); len(results) > 0 { + s.debugf("Definition: found %d result(s) via macro-generated delegate in %s for %s", len(results), fullModule, functionName) + return storeResultsToLocations(filterOutTypes(results)), nil + } + // fullModule may not directly define the function — try its use chain // (e.g. `import MyApp.Factory` where MyApp.Factory uses ExMachina). if results := s.lookupThroughUseOf(fullModule, functionName); len(results) > 0 { @@ -640,6 +652,10 @@ func (s *Server) Definition(ctx context.Context, params *protocol.DefinitionPara s.debugf("Definition: found %d result(s) in store for %s.%s", len(results), fullModule, functionName) return storeResultsToLocations(filterOutTypes(results)), nil } + if results := s.lookupMacroGeneratedDelegate(fullModule, functionName, "", ""); len(results) > 0 { + s.debugf("Definition: found %d result(s) via macro-generated delegate in %s for %s", len(results), fullModule, functionName) + return storeResultsToLocations(filterOutTypes(results)), nil + } // Not directly defined — the function may have been injected by a // `use` macro in fullModule's source (e.g. Oban.Worker injects `new`). if results := s.lookupThroughUseOf(fullModule, functionName); len(results) > 0 { @@ -680,6 +696,12 @@ func storeResultsToLocations(results []store.LookupResult) []protocol.Location { var typeKinds = map[string]bool{"type": true, "typep": true, "opaque": true} +var ( + macroDelegateToRe = regexp.MustCompile(`to:\s*([A-Za-z0-9_.]+)`) + macroDelegateAsRe = regexp.MustCompile(`as:\s*:?([a-z_][a-z0-9_?!]*)`) + macroDelegateBoundaryRe = regexp.MustCompile(`^\s*(defdelegate|defp?|defmacrop?|defguardp?|alias|import|use|end)\b`) +) + func filterOutTypes(results []store.LookupResult) []store.LookupResult { var nonTypes []store.LookupResult for _, r := range results { @@ -693,6 +715,228 @@ func filterOutTypes(results []store.LookupResult) []store.LookupResult { return results } +// lookupMacroGeneratedDelegate resolves functionName in moduleName when it is +// generated by a local/imported macro call that emits defdelegate. +func (s *Server) lookupMacroGeneratedDelegate(moduleName, functionName, sourcePath, sourceText string) []store.LookupResult { + if moduleName == "" || functionName == "" { + return nil + } + + filePath := sourcePath + text := sourceText + if text == "" { + if filePath == "" { + moduleDefs, err := s.store.LookupModule(moduleName) + if err != nil || len(moduleDefs) == 0 { + return nil + } + filePath = moduleDefs[0].FilePath + } + fileText, _, ok := s.readFileText(filePath) + if !ok { + return nil + } + text = fileText + } + + lines := strings.Split(text, "\n") + wrapperCache := make(map[string]bool) + + // Compute aliases once for the whole file — macro calls like + // `action :run, to: Runner` are at module top-level where all + // aliases are in scope, so unscoped extraction is safe and avoids + // O(n²) re-scanning per candidate line. + aliases := ExtractAliases(text) + s.mergeAliasesFromUse(text, aliases) + + for lineIdx, line := range lines { + rest := strings.TrimLeft(parser.StripCommentsAndStrings(line), " \t") + if rest == "" { + continue + } + + macroName, ok := scanMacroCallWithFunctionArg(rest, functionName) + if !ok { + continue + } + + // Stay within the target module when index data is available. + if filePath != "" { + if enclosing := s.store.LookupEnclosingModule(filePath, lineIdx+1); enclosing != "" && enclosing != moduleName { + continue + } + } + + wrapped := false + if macroBodyContainsDefdelegate(text, macroName) { + wrapped = true + } else { + macroModule := s.resolveBareFunctionModule(filePath, text, lines, lineIdx, macroName, aliases) + if macroModule == "" { + continue + } + cacheKey := macroModule + ":" + macroName + if v, hit := wrapperCache[cacheKey]; hit { + wrapped = v + } else { + wrapped = s.macroModuleDefinesDelegateWrapper(macroModule, macroName) + wrapperCache[cacheKey] = wrapped + } + } + if !wrapped { + continue + } + + delegateTo, delegateAs := extractDelegateTargetFromMacroCall(lines, lineIdx, aliases, moduleName) + if delegateTo == "" { + continue + } + + targetFunc := functionName + if delegateAs != "" { + targetFunc = delegateAs + } + if results, err := s.store.LookupFollowDelegate(delegateTo, targetFunc); err == nil && len(results) > 0 { + return results + } + } + + return nil +} + +func scanMacroCallWithFunctionArg(rest, functionName string) (string, bool) { + macroName := parser.ScanFuncName(rest) + if macroName == "" || parser.IsElixirKeyword(macroName) { + return "", false + } + + after := strings.TrimLeft(rest[len(macroName):], " \t") + if len(after) == 0 { + return "", false + } + if after[0] == '(' { + after = strings.TrimLeft(after[1:], " \t") + } + if len(after) == 0 { + return "", false + } + if after[0] == ':' { + after = after[1:] + } + + argName := parser.ScanFuncName(after) + if argName == "" || argName != functionName { + return "", false + } + if len(after) > len(argName) { + switch after[len(argName)] { + case ' ', '\t', ',', ')', '\n', '\r': + default: + return "", false + } + } + + return macroName, true +} + +func extractDelegateTargetFromMacroCall(lines []string, startIdx int, aliases map[string]string, currentModule string) (delegateTo, delegateAs string) { + maxIdx := startIdx + 5 + if maxIdx >= len(lines) { + maxIdx = len(lines) - 1 + } + + var block strings.Builder + for i := startIdx; i <= maxIdx; i++ { + trimmed := strings.TrimRight(parser.StripCommentsAndStrings(lines[i]), " \t\r") + if i > startIdx && macroDelegateBoundaryRe.MatchString(trimmed) { + break + } + block.WriteString(" ") + block.WriteString(trimmed) + } + + match := macroDelegateToRe.FindStringSubmatch(block.String()) + if match == nil { + return "", "" + } + + delegateTo = parser.ResolveModuleRef(match[1], aliases, currentModule) + if match[1] == "__MODULE__" { + delegateTo = currentModule + } + if m := macroDelegateAsRe.FindStringSubmatch(block.String()); m != nil { + delegateAs = m[1] + } + return delegateTo, delegateAs +} + +func (s *Server) macroModuleDefinesDelegateWrapper(moduleName, macroName string) bool { + moduleDefs, err := s.store.LookupModule(moduleName) + if err != nil || len(moduleDefs) == 0 { + return false + } + fileText, _, ok := s.readFileText(moduleDefs[0].FilePath) + if !ok { + return false + } + return macroBodyContainsDefdelegate(fileText, macroName) +} + +func macroBodyContainsDefdelegate(text, macroName string) bool { + lines := strings.Split(text, "\n") + for i := 0; i < len(lines); i++ { + trimmed := strings.TrimSpace(parser.StripCommentsAndStrings(lines[i])) + if trimmed == "" { + continue + } + + rest := "" + switch { + case strings.HasPrefix(trimmed, "defmacro "): + rest = strings.TrimLeft(trimmed[len("defmacro "):], " \t") + case strings.HasPrefix(trimmed, "defmacrop "): + rest = strings.TrimLeft(trimmed[len("defmacrop "):], " \t") + default: + continue + } + + name := parser.ScanFuncName(rest) + if name != macroName { + continue + } + if len(rest) > len(name) { + switch rest[len(name)] { + case ' ', '\t', '(', ',', '\n', '\r': + default: + continue + } + } + + depth := 0 + started := false + for j := i; j < len(lines); j++ { + bodyLine := strings.TrimSpace(parser.StripCommentsAndStrings(lines[j])) + if bodyLine == "" { + continue + } + if parser.OpensBlock(bodyLine) { + depth++ + started = true + } + if started && strings.Contains(bodyLine, "defdelegate") { + return true + } + if parser.IsEnd(bodyLine) { + depth-- + if started && depth <= 0 { + break + } + } + } + } + return false +} + func lineRange(line int) protocol.Range { return protocol.Range{ Start: protocol.Position{Line: uint32(line), Character: 0}, diff --git a/internal/lsp/server_test.go b/internal/lsp/server_test.go index 89ccfb3..ff367e0 100644 --- a/internal/lsp/server_test.go +++ b/internal/lsp/server_test.go @@ -122,6 +122,66 @@ end } } +func TestDefinition_FollowDelegates_WrappedMacroViaUseChain(t *testing.T) { + server, cleanup := setupTestServer(t) + defer cleanup() + + indexFile(t, server.store, server.projectRoot, "lib/custom_macros.ex", `defmodule MyApp.CustomMacros do + defmacro action(name, opts) do + quote do + defdelegate unquote(name)(), unquote(opts) + end + end +end +`) + indexFile(t, server.store, server.projectRoot, "lib/actions.ex", `defmodule MyApp.Actions do + defmacro __using__(_opts) do + quote do + import MyApp.CustomMacros + end + end +end +`) + indexFile(t, server.store, server.projectRoot, "lib/facade.ex", `defmodule MyApp.Facade do + use MyApp.Actions + alias MyApp.Workers.Runner + + action :run, to: Runner, as: :call +end +`) + indexFile(t, server.store, server.projectRoot, "lib/workers/runner.ex", `defmodule MyApp.Workers.Runner do + def call() do + :ok + end +end +`) + + callerSrc := `defmodule MyApp.Caller do + def run do + MyApp.Facade.run() + end +end +` + callerURI := "file://" + filepath.Join(server.projectRoot, "lib/caller.ex") + server.docs.Set(callerURI, callerSrc) + + locs := definitionAt(t, server, callerURI, 2, 18) + if len(locs) == 0 { + t.Fatal("expected go-to-definition to follow wrapped defdelegate via use-chain-imported macro") + } + + foundRunner := false + for _, loc := range locs { + if strings.HasSuffix(string(loc.URI), "lib/workers/runner.ex") { + foundRunner = true + break + } + } + if !foundRunner { + t.Fatalf("expected definition in lib/workers/runner.ex, got %v", locs) + } +} + // waitFor polls condition every 10ms until it returns true or one second elapses. func waitFor(t *testing.T, condition func() bool) { t.Helper()