Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 244 additions & 0 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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},
Expand Down
60 changes: 60 additions & 0 deletions internal/lsp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading