Skip to content

Add expand_imports to resolve bare and except imports#5

Open
rodrigues wants to merge 2 commits into
elixir-vibe:masterfrom
rodrigues:expand_imports
Open

Add expand_imports to resolve bare and except imports#5
rodrigues wants to merge 2 commits into
elixir-vibe:masterfrom
rodrigues:expand_imports

Conversation

@rodrigues

@rodrigues rodrigues commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Expands #4 to enable bare import resolution, if you think this path makes sense for ex_ast.


The bare-import fix left import Enum matching nothing: with no :only list there's no way to tell which local calls came from the import. This adds an opt-in expand_imports: true that loads the module and uses its real exports as the membership list, so a bare import Enum makes map(a, b) match Enum.map(_, _).

find_all(source, "Enum.map(_, _)")                       # 0
find_all(source, "Enum.map(_, _)", expand_imports: true) # 1

It also covers except:, which has the same problem — import Enum, except: [map: 2] only makes sense once we know the full export list, so it resolves to the exports minus the excluded names.

Functions the module defines itself shadow the import and are dropped, so a local def map/2 is never rewritten to Enum.map. When the module isn't loadable we fall back to no expansion.

Off by default; the extra export walk only runs for files that have a bare or except: import, so the common path is unaffected.

@rodrigues rodrigues marked this pull request as ready for review June 29, 2026 11:04
@rodrigues rodrigues force-pushed the expand_imports branch 2 times, most recently from 621eeb6 to 6d0bda7 Compare June 29, 2026 11:30

@dannote dannote left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is a useful direction, and keeping it opt-in is the right default. I found a few correctness issues around import scope and only: :functions / only: :macros semantics that I think need changes before merge.

Comment thread lib/ex_ast/pattern.ex
end)

aliases
if Keyword.get(opts, :expand_imports, false) do

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This currently makes expand_imports file-global: imports are collected once for the whole AST and then used while matching every node in the file.

I validated this false positive:

defmodule A do
  import Enum
  def run(list), do: map(list, & &1)
end

defmodule B do
  def run(list), do: map(list, & &1)
end

With expand_imports: true, Enum.map(_, _) matches both calls. The import from A leaks into sibling module B. I think this needs lexical/module scoping before the option is safe.

Comment thread lib/ex_ast/pattern.ex Outdated
imports = Map.get(aliases, {__MODULE__, :imports}, [])

if Enum.any?(imports, &resolvable?/1) do
local = local_definitions(ast)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

local_definitions(ast) is also file-global, so local shadowing can leak across sibling modules.

I validated this false negative:

defmodule A do
  def map(a, b), do: {a, b}
end

defmodule B do
  import Enum
  def run(list), do: map(list, & &1)
end

With expand_imports: true, Enum.map(_, _) returns no matches because A.map/2 suppresses expansion in B. Shadowing should be scoped to the module/function context where the call appears.

Comment thread lib/ex_ast/pattern.ex Outdated
case opt_value(opts, :only) do
nil -> :all
nil -> import_except(opts)
only_ast -> parse_only_list(only_ast)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only: :functions and only: :macros collapse to :all, and expand_imports then resolves :all to functions + macros. That changes Elixir import semantics.

I validated both directions:

import ExAST.Query, only: :functions
where(query, expr)

incorrectly matches ExAST.Query.where(_, _), even though where/2 is a macro.

import ExAST.Query, only: :macros
from(pattern)

incorrectly matches ExAST.Query.from(_), even though from/1 is a function. We probably need to preserve whether the unresolved import is functions-only, macros-only, or both.

The bare-import fix left `import Enum` matching nothing: with no `:only`
list there's no way to tell which local calls came from the import. This
adds an opt-in `expand_imports: true` that loads the module and uses its
real exports as the membership list, so a bare `import Enum` makes
`map(a, b)` match `Enum.map(_, _)`.

    find_all(source, "Enum.map(_, _)")                       # 0
    find_all(source, "Enum.map(_, _)", expand_imports: true) # 1

It also covers `except:`, which has the same problem — `import Enum,
except: [map: 2]` only makes sense once we know the full export list, so
it resolves to the exports minus the excluded names.

Functions the module defines itself shadow the import and are dropped, so
a local `def map/2` is never rewritten to `Enum.map`. When the module
isn't loadable we fall back to no expansion.

Off by default; the extra export walk only runs for files that have a bare
or `except:` import, so the common path is unaffected.
expand_imports collected imports and locals once for the whole file, so
an import in one module leaked into its siblings and a local def in one
module shadowed the import everywhere.

    defmodule A do
      import Enum
      def run(l), do: map(l, & &1)
    end

    defmodule B do
      def run(l), do: map(l, & &1)
    end

    find_all(src, "Enum.map(_, _)", expand_imports: true) # was 2, now 1

Imports now carry the module they were declared in, and matching scopes
them to the node's module path (a nested module still inherits an outer
import). Local shadowing moved to match time, keyed by the call site's
module, so a `def map/2` in one module no longer suppresses the import in
another — and a local in a nested module shadows an outer import only
within that module.

only: :functions / :macros also collapsed to :all, which then resolved to
functions + macros. They now keep their kind, so only: :functions never
matches a macro and vice versa.
@rodrigues

Copy link
Copy Markdown
Contributor Author

@dannote thanks for the review! Brought the edge cases into the tests, and made a few changes.


  • Sibling leak: imports now carry the module they're declared in, and matching scopes them to the node's module path. A's import no longer reaches B, and a nested module still inherits an outer import.
  • Local shadowing: locals are collected per module and resolved at match time against the call site, not file-wide. A.map/2 no longer suppresses the import in B. I also added a nested case — a local def shadows an outer import only inside its own module.
  • only: :functions / :macros: these were collapsing to :all and resolving to both. They keep their kind now and resolve against just __info__(:functions) or __info__(:macros), so only: :functions won't match where/2 and only: :macros won't match from/1.

@dannote

dannote commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks, the behavior issues look fixed now. Remaining blocker is CI: Credo flags ExAST.Pattern.imported_module/3 as nested too deep. Once that’s green, I’ll approve.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants