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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ Use these terms and facts to retrieve candidates, then verify with

## Limitations

- Alias/import expansion is syntax-aware, not full semantic macro expansion
- Alias/import expansion is syntax-aware, not full semantic macro expansion. A bare `import Mod` is not expanded by default; pass `expand_imports: true` (or `--expand-imports`) to resolve it from `Mod`'s real exports
- Function-name placeholders are intentionally limited: definitions use `name`, `fun`, or `function`; call heads use `fun` or `function`
- Multi-node patterns require contiguous statements
- Replacement formatting uses `Macro.to_string/1`; pass `format: true` or run `mix format` after
Expand Down
4 changes: 4 additions & 0 deletions guides/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Search files for AST pattern matches. PATH can be a file, directory, or glob.
| `--count` | Print match count only |
| `--limit N` | Stop after N matches |
| `--allow-broad` | Allow patterns like `_` that match everything |
| `--expand-imports` | Resolve `import Mod` to `Mod`'s real exports, scoped per module, so `map(a, b)` matches `Mod.map(_, _)`. Requires `Mod` to be loadable |
| `--format json` / `--json` | Print structured JSON output |
| `--inside PATTERN` | Only match inside ancestors matching pattern |
| `--not-inside PATTERN` | Reject matches inside ancestors matching pattern |
Expand Down Expand Up @@ -65,6 +66,9 @@ mix ex_ast.search --inside 'defp _ do _ end' 'Repo.get!(_, _)'

# Count matches
mix ex_ast.search --count 'dbg(_)' lib/

# Resolve a bare `import Enum` to its real exports
mix ex_ast.search 'Enum.map(_, _)' lib/ --expand-imports
```

## Replace
Expand Down
56 changes: 46 additions & 10 deletions lib/ex_ast/patcher.ex
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ defmodule ExAST.Patcher do

* `:inside` — only match nodes nested within an ancestor matching this pattern
* `:not_inside` — reject nodes nested within an ancestor matching this pattern
* `:expand_imports` — when `true`, resolve `import Mod` (including `except:`
and `only: :functions` / `:macros`) to `Mod`'s real exports, scoped to the
enclosing module, so `map(a, b)` matches `Mod.map(_, _)`. Defaults to
`false`; requires `Mod` to be loadable.
"""
@spec find_all(String.t() | Zipper.t() | Macro.t(), Pattern.pattern() | Selector.t(), keyword()) ::
[
Expand Down Expand Up @@ -209,7 +213,7 @@ defmodule ExAST.Patcher do
defp do_find_all(ast, pattern, opts, comments, source_lines)

defp do_find_all(ast, %Selector{} = selector, opts, comments, source_lines) do
alias_env = Pattern.collect_aliases(ast)
alias_env = Pattern.collect_aliases(ast, opts)

ast
|> collect_selector_matches(selector, alias_env)
Expand All @@ -219,7 +223,7 @@ defmodule ExAST.Patcher do
end

defp do_find_all(ast, pattern, opts, _comments, source_lines) do
alias_env = Pattern.collect_aliases(ast)
alias_env = Pattern.collect_aliases(ast, opts)

matches =
if Pattern.multi_node?(pattern) do
Expand All @@ -234,7 +238,7 @@ defmodule ExAST.Patcher do
end

defp do_find_many(ast, patterns, opts, comments, source_lines) do
alias_env = Pattern.collect_aliases(ast)
alias_env = Pattern.collect_aliases(ast, opts)
{compiled, fallback} = split_many_patterns(patterns)

compiled_matches =
Expand Down Expand Up @@ -301,10 +305,12 @@ defmodule ExAST.Patcher do
end

defp collect_match(zipper, node, compiled_pattern, signature, alias_env, acc) do
case Pattern.match_compiled(node, compiled_pattern, alias_env) do
{env, scoped_ancestors} = scope_env(alias_env, zipper)

case Pattern.match_compiled(node, compiled_pattern, env) do
{:ok, captures} ->
range = safe_range(node)
ancestors = collect_ancestors(zipper)
ancestors = scoped_ancestors || collect_ancestors(zipper)
match = %{node: node, range: range, captures: captures, ancestors: ancestors}

zipper
Expand Down Expand Up @@ -356,8 +362,15 @@ defmodule ExAST.Patcher do
acc
) do
node = Zipper.node(zipper)
normalized_node = Pattern.normalize_node(node, alias_env)
ancestors = if map_size(blocked) == 0, do: nil, else: collect_ancestors(zipper)
{env, scoped_ancestors} = scope_env(alias_env, zipper)
normalized_node = Pattern.normalize_node(node, env)

ancestors =
cond do
scoped_ancestors -> scoped_ancestors
map_size(blocked) == 0 -> nil
true -> collect_ancestors(zipper)
end

context = %{
ancestors: ancestors,
Expand Down Expand Up @@ -470,7 +483,7 @@ defmodule ExAST.Patcher do
Macro.prewalk(ast, [], fn node, acc ->
case extract_block_children(node) do
nil -> {node, acc}
children -> {node, find_in_block(children, pattern_asts, ast, alias_env) ++ acc}
children -> {node, find_in_block(node, children, pattern_asts, ast, alias_env) ++ acc}
end
end)

Expand All @@ -493,8 +506,10 @@ defmodule ExAST.Patcher do

defp extract_block_children(_), do: nil

defp find_in_block(children, pattern_asts, root_ast, alias_env) do
Pattern.match_sequences(children, pattern_asts, alias_env)
defp find_in_block(container, children, pattern_asts, root_ast, alias_env) do
env = scope_block_env(alias_env, container, root_ast)

Pattern.match_sequences(children, pattern_asts, env)
|> Enum.map(fn {captures, range} ->
matched_nodes = Enum.slice(children, range)
first = List.first(matched_nodes)
Expand Down Expand Up @@ -968,6 +983,27 @@ defmodule ExAST.Patcher do
end
end

# Scope imports to the matched node's module so they don't leak into siblings.
# Returns the (maybe scoped) env plus the ancestors it computed, for reuse.
defp scope_env(alias_env, zipper) do
if Pattern.imports?(alias_env) do
ancestors = collect_ancestors(zipper)
{Pattern.scope_alias_env(alias_env, Pattern.module_path(ancestors)), ancestors}
else
{alias_env, nil}
end
end

# Same, for sequence matching, where the module comes from the block container.
defp scope_block_env(alias_env, container, root_ast) do
if Pattern.imports?(alias_env) do
ancestors = [container | collect_ancestors_for_node(container, root_ast)]
Pattern.scope_alias_env(alias_env, Pattern.module_path(ancestors))
else
alias_env
end
end

defp apply_where(matches, opts, alias_env, source_lines) do
inside = Keyword.get(opts, :inside)
not_inside = Keyword.get(opts, :not_inside)
Expand Down
Loading
Loading