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
44 changes: 44 additions & 0 deletions internal/lsp/hover.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ func (s *Server) hoverFromFile(function string, result store.LookupResult) (*pro
} else {
doc, spec = extractDocAbove(lines, defIdx)
signature = extractSignature(lines, defIdx)

// Fallback: if the def has no doc (e.g. inline def injected by a
// __using__ macro), look for a @callback with the same name in the
// same file and extract its doc/spec instead.
if doc == "" && spec == "" {
if cbIdx := findCallbackLine(lines, function); cbIdx >= 0 {
doc, spec = extractDocAbove(lines, cbIdx)
if spec == "" {
spec = extractMultiLineAttr(lines, cbIdx)
}
}
}
}

content := formatHoverContent(doc, spec, signature)
Expand Down Expand Up @@ -214,6 +226,38 @@ func extractModuledoc(lines []string, moduleIdx int) string {
return ""
}

// findCallbackLine scans for a @callback or @macrocallback line that defines
// the given function name and returns its 0-based line index, or -1.
func findCallbackLine(lines []string, function string) int {
cbPrefix := "@callback " + function
mcbPrefix := "@macrocallback " + function
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, cbPrefix) && (len(trimmed) == len(cbPrefix) || trimmed[len(cbPrefix)] == '(' || trimmed[len(cbPrefix)] == ' ') {
return i
}
if strings.HasPrefix(trimmed, mcbPrefix) && (len(trimmed) == len(mcbPrefix) || trimmed[len(mcbPrefix)] == '(' || trimmed[len(mcbPrefix)] == ' ') {
return i
}
}
return -1
}

// extractMultiLineAttr collects a potentially multi-line module attribute
// starting at idx (e.g. @callback, @spec). Continuation lines are collected
// until an empty line, another attribute, or a definition is encountered.
func extractMultiLineAttr(lines []string, idx int) string {
collected := []string{lines[idx]}
for i := idx + 1; i < len(lines); i++ {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" || strings.HasPrefix(trimmed, "@") || strings.HasPrefix(trimmed, "def") {
break
}
collected = append(collected, lines[i])
}
return strings.TrimSpace(strings.Join(collected, "\n"))
}

func extractQuotedString(s string) string {
if len(s) < 2 || s[0] != '"' {
return ""
Expand Down
130 changes: 130 additions & 0 deletions internal/lsp/hover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1026,3 +1026,133 @@ end`
t.Errorf("expected submodule in hover, got %q", hover.Contents.Value)
}
}

func TestHover_QualifiedCallViaUseChain(t *testing.T) {
server, cleanup := setupTestServer(t)
defer cleanup()

// SharedLib.Storage defines __using__ that imports SharedLib.Queryable
storageSrc := `defmodule SharedLib.Storage do
defmacro __using__(_opts) do
quote do
import SharedLib.Queryable
end
end
end`
queryableSrc := `defmodule SharedLib.Queryable do
@doc "Fetches all records matching the query."
def all(queryable), do: queryable
end`

// MyApp.Repo uses SharedLib.Storage, so all/1 is injected
repoSrc := `defmodule MyApp.Repo do
use SharedLib.Storage
end`

callerSrc := `defmodule MyApp.Accounts do
alias MyApp.Repo

def list do
Repo.all(User)
end
end`

indexFile(t, server.store, server.projectRoot, "lib/storage.ex", storageSrc)
storageURI := "file://" + filepath.Join(server.projectRoot, "lib/storage.ex")
server.docs.Set(storageURI, storageSrc)

indexFile(t, server.store, server.projectRoot, "lib/queryable.ex", queryableSrc)

indexFile(t, server.store, server.projectRoot, "lib/repo.ex", repoSrc)
repoURI := "file://" + filepath.Join(server.projectRoot, "lib/repo.ex")
server.docs.Set(repoURI, repoSrc)

callerURI := "file://" + filepath.Join(server.projectRoot, "lib/accounts.ex")
server.docs.Set(callerURI, callerSrc)

// line 4 (0-indexed): ` Repo.all(User)` — col 9 is on `all`
hover := hoverAt(t, server, callerURI, 4, 9)
if hover == nil {
t.Fatal("expected hover result for Repo.all resolved via use-chain, got nil")
}
if !strings.Contains(hover.Contents.Value, "Fetches all records") {
t.Errorf("expected doc from use-chain source, got %q", hover.Contents.Value)
}
}

func TestHover_QualifiedCallViaUseChain_CallbackDoc(t *testing.T) {
server, cleanup := setupTestServer(t)
defer cleanup()

// SharedLib.Storage defines @callback with @doc and injects bare def via __using__.
// The @callback for get spans multiple lines to test multi-line extraction.
storageSrc := `defmodule SharedLib.Storage do
@doc """
Fetches all records from the data store.
"""
@callback all(queryable :: term) :: [term]

@doc """
Fetches a single record by its ID.
"""
@callback get(queryable :: term, id :: term) ::
term | nil

defmacro __using__(_opts) do
quote do
@behaviour SharedLib.Storage

def all(queryable), do: queryable
def get(queryable, id), do: nil
end
end
end`

repoSrc := `defmodule MyApp.Repo do
use SharedLib.Storage
end`

callerSrc := `defmodule MyApp.Accounts do
alias MyApp.Repo

def list do
Repo.all(User)
end

def find(id) do
Repo.get(User, id)
end
end`

indexFile(t, server.store, server.projectRoot, "lib/storage.ex", storageSrc)
storageURI := "file://" + filepath.Join(server.projectRoot, "lib/storage.ex")
server.docs.Set(storageURI, storageSrc)

indexFile(t, server.store, server.projectRoot, "lib/repo.ex", repoSrc)
repoURI := "file://" + filepath.Join(server.projectRoot, "lib/repo.ex")
server.docs.Set(repoURI, repoSrc)

callerURI := "file://" + filepath.Join(server.projectRoot, "lib/accounts.ex")
server.docs.Set(callerURI, callerSrc)

// line 4 (0-indexed): ` Repo.all(User)` — col 9 is on `all`
hover := hoverAt(t, server, callerURI, 4, 9)
if hover == nil {
t.Fatal("expected hover result for Repo.all via callback doc, got nil")
}
if !strings.Contains(hover.Contents.Value, "Fetches all records") {
t.Errorf("expected callback doc for all, got %q", hover.Contents.Value)
}

// line 8 (0-indexed): ` Repo.get(User, id)` — col 9 is on `get`
hover = hoverAt(t, server, callerURI, 8, 9)
if hover == nil {
t.Fatal("expected hover result for Repo.get via callback doc, got nil")
}
if !strings.Contains(hover.Contents.Value, "Fetches a single record") {
t.Errorf("expected callback doc for get, got %q", hover.Contents.Value)
}
if !strings.Contains(hover.Contents.Value, "term | nil") {
t.Errorf("expected full multi-line callback spec for get, got %q", hover.Contents.Value)
}
}
6 changes: 6 additions & 0 deletions internal/lsp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2696,6 +2696,12 @@ func (s *Server) Hover(ctx context.Context, params *protocol.HoverParams) (*prot
if err == nil && len(results) > 0 {
return s.hoverFromFile(functionName, results[0])
}

// Not directly defined — the function may have been injected by a
// `use` macro in fullModule's source (e.g. Ecto.Repo injects `all`).
if results := s.lookupThroughUseOf(fullModule, functionName); len(results) > 0 {
return s.hoverFromFile(functionName, results[0])
}
}

results, err := s.store.LookupModule(fullModule)
Expand Down
Loading