Current section
Files
Jump to
Current section
Files
lib/ex_ast/selector.ex
defmodule ExAST.Selector.CommentMatcher do
@moduledoc """
Comment text matcher used by comment predicates.
"""
defstruct [:kind, :value, case_sensitive?: true]
@type kind :: :text | :exact | :prefix | :suffix
@type t :: %__MODULE__{kind: kind(), value: String.t(), case_sensitive?: boolean()}
end
defmodule ExAST.Selector.Predicate do
@moduledoc """
Predicate used by `ExAST.Selector.where/2`.
Build predicates with `ExAST.Selector.parent/1`, `ancestor/1`,
`has_child/1`, `has_descendant/1`, or `has/1`. Negate them with
`ExAST.Selector.not/1`.
"""
defstruct [:relation, :pattern, negated?: false]
@type relation ::
:parent
| :ancestor
| :has_child
| :has_descendant
| :any
| :all
| :follows
| :precedes
| :immediately_follows
| :immediately_precedes
| :first
| :last
| :nth
| :captures
| :piped
| :comment
| :comment_before
| :comment_after
| :comment_inside
| :comment_inline
@type t :: %__MODULE__{
relation: relation(),
pattern: ExAST.Pattern.pattern() | [t()] | pos_integer() | nil,
negated?: boolean()
}
end
defmodule ExAST.Selector do
import Kernel, except: [not: 1]
@moduledoc """
CSS-like AST selector builder.
Selectors are built from a starting pattern and relationship steps:
import ExAST.Selector
pattern("defmodule _ do ... end")
|> descendant("def _ do ... end")
|> child("IO.inspect(_)")
The final step is the selected node. Use `where/2` with predicates such as
`has_child/1`, `has_descendant/1`, `parent/1`, and `ancestor/1` to filter the
selected node without changing it.
pattern("def _ do ... end")
|> where(has_descendant("Repo.transaction(_)"))
|> where(not(has_descendant("IO.inspect(_)")))
`where/2` also accepts quoted boolean predicate expressions, so you can use
`Kernel.not/1` without excluding it from imports:
pattern("def _ do ... end")
|> where(not has_descendant("IO.inspect(_)"))
`where/2` supports capture guards using `^` to pin captured values,
allowing runtime checks on matched nodes:
pattern("Enum.take(_, count)")
|> where(match?({:-, _, [_]}, ^count))
"""
alias ExAST.Selector.CommentMatcher
alias ExAST.Selector.Predicate
defstruct steps: [], filters: []
@type relation :: :self | :child | :descendant
@type step :: {relation(), ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]}
@type t :: %__MODULE__{steps: [step()], filters: [Predicate.t()]}
@doc "Starts a selector at `pattern`."
@spec pattern(ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]) :: t()
def pattern(pattern), do: %__MODULE__{steps: [{:self, compile_pattern(pattern)}]}
@doc "Alias for `pattern/1`."
@spec selector(ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]) :: t()
def selector(pattern), do: pattern(pattern)
@doc "SQL-like alias for `pattern/1`."
@spec from(ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]) :: t()
def from(pattern), do: pattern(pattern)
@doc "Selects direct semantic children matching `pattern`."
@spec child(t(), ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]) :: t()
def child(%__MODULE__{} = selector, pattern),
do: add_step(selector, :child, compile_pattern(pattern))
@doc "Selects semantic descendants matching `pattern`."
@spec descendant(t(), ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]) :: t()
def descendant(%__MODULE__{} = selector, pattern),
do: add_step(selector, :descendant, compile_pattern(pattern))
@doc "SQL-like alias for `descendant/2`."
@spec find(t(), ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]) :: t()
def find(%__MODULE__{} = selector, pattern), do: descendant(selector, pattern)
@doc "SQL-like alias for `child/2`."
@spec find_child(t(), ExAST.Pattern.pattern() | [ExAST.Pattern.pattern()]) :: t()
def find_child(%__MODULE__{} = selector, pattern), do: child(selector, pattern)
@doc "Adds a predicate filter without changing the selected node."
defmacro where(selector, expr) do
if has_pin?(expr) do
guard_body = expand_pins(expr)
quote do
ExAST.Selector.where_predicate(
unquote(selector),
%ExAST.Selector.Predicate{
relation: :captures,
pattern: fn captures -> unquote(guard_body) end
}
)
end
else
predicate = build_predicate_from_ast(expr)
quote do
ExAST.Selector.where_predicate(unquote(selector), unquote(Macro.escape(predicate)))
end
end
end
defp has_pin?(ast) do
Macro.prewalk(ast, false, fn
{:^, _, _}, _ -> {nil, true}
node, found -> {node, found}
end)
|> elem(1)
end
defp expand_pins(ast) do
ast
|> unwrap_ampersand()
|> Macro.prewalk(fn
{:^, _, [{name, _, _}]} -> quote(do: Map.get(captures, unquote(name)))
node -> node
end)
end
defp unwrap_ampersand({:&, _, [body]}), do: body
defp unwrap_ampersand(ast), do: ast
@doc false
@spec where_predicate(t(), Predicate.t()) :: t()
def where_predicate(%__MODULE__{} = selector, %Predicate{} = predicate),
do: add_filter(selector, predicate)
@doc "Builds or applies a direct semantic parent predicate."
@spec parent(ExAST.Pattern.pattern()) :: Predicate.t()
@spec parent(t(), ExAST.Pattern.pattern()) :: t()
def parent(pattern), do: predicate(:parent, pattern)
def parent(%__MODULE__{} = selector, pattern), do: where_predicate(selector, parent(pattern))
@doc "Builds or applies a semantic ancestor predicate."
@spec ancestor(ExAST.Pattern.pattern()) :: Predicate.t()
@spec ancestor(t(), ExAST.Pattern.pattern()) :: t()
def ancestor(pattern), do: predicate(:ancestor, pattern)
def ancestor(%__MODULE__{} = selector, pattern),
do: where_predicate(selector, ancestor(pattern))
@doc "SQL-like alias for `ancestor/1` and `ancestor/2`."
@spec inside(ExAST.Pattern.pattern()) :: Predicate.t()
@spec inside(t(), ExAST.Pattern.pattern()) :: t()
def inside(pattern), do: ancestor(pattern)
def inside(%__MODULE__{} = selector, pattern), do: ancestor(selector, pattern)
@doc "Builds or applies a direct semantic child predicate."
@spec has_child(ExAST.Pattern.pattern()) :: Predicate.t()
@spec has_child(t(), ExAST.Pattern.pattern()) :: t()
def has_child(pattern), do: predicate(:has_child, pattern)
def has_child(%__MODULE__{} = selector, pattern),
do: where_predicate(selector, has_child(pattern))
@doc "Builds or applies a semantic descendant predicate."
@spec has_descendant(ExAST.Pattern.pattern()) :: Predicate.t()
@spec has_descendant(t(), ExAST.Pattern.pattern()) :: t()
def has_descendant(pattern), do: predicate(:has_descendant, pattern)
def has_descendant(%__MODULE__{} = selector, pattern),
do: where_predicate(selector, has_descendant(pattern))
@doc "Alias for `has_descendant/1` and `has_descendant/2`."
@spec has(ExAST.Pattern.pattern()) :: Predicate.t()
@spec has(t(), ExAST.Pattern.pattern()) :: t()
def has(pattern), do: has_descendant(pattern)
def has(%__MODULE__{} = selector, pattern), do: has_descendant(selector, pattern)
@doc "SQL-like alias for `has_descendant/1` and `has_descendant/2`."
@spec contains(ExAST.Pattern.pattern()) :: Predicate.t()
@spec contains(t(), ExAST.Pattern.pattern()) :: t()
def contains(pattern), do: has_descendant(pattern)
def contains(%__MODULE__{} = selector, pattern), do: has_descendant(selector, pattern)
@doc "Matches when a previous sibling matches `pattern`."
@spec follows(ExAST.Pattern.pattern()) :: Predicate.t()
def follows(pattern), do: predicate(:follows, pattern)
@doc "Matches when a following sibling matches `pattern`."
@spec precedes(ExAST.Pattern.pattern()) :: Predicate.t()
def precedes(pattern), do: predicate(:precedes, pattern)
@doc "Matches when the immediately previous sibling matches `pattern`."
@spec immediately_follows(ExAST.Pattern.pattern()) :: Predicate.t()
def immediately_follows(pattern), do: predicate(:immediately_follows, pattern)
@doc "Matches when the immediately following sibling matches `pattern`."
@spec immediately_precedes(ExAST.Pattern.pattern()) :: Predicate.t()
def immediately_precedes(pattern), do: predicate(:immediately_precedes, pattern)
@doc "Matches when the selected node is a pipe expression."
@spec piped() :: Predicate.t()
def piped, do: predicate(:piped, nil)
@doc "Matches the first semantic child in its parent."
@spec first() :: Predicate.t()
def first, do: predicate(:first, nil)
@doc "Matches the last semantic child in its parent."
@spec last() :: Predicate.t()
def last, do: predicate(:last, nil)
@doc "Matches the nth semantic child in its parent, using 1-based indexing."
@spec nth(pos_integer()) :: Predicate.t()
def nth(index) when is_integer(index) and index > 0, do: predicate(:nth, index)
@doc "Matches when any nested predicate matches."
@spec any([Predicate.t()]) :: Predicate.t()
def any(predicates) when is_list(predicates), do: predicate(:any, predicates)
@doc "Matches when all nested predicates match."
@spec all([Predicate.t()]) :: Predicate.t()
def all(predicates) when is_list(predicates), do: predicate(:all, predicates)
@doc "Matches comments associated with the selected node."
@spec comment(String.t() | Regex.t() | CommentMatcher.t()) :: Predicate.t()
def comment(matcher), do: predicate(:comment, compile_comment_matcher(matcher))
@doc "Matches comments immediately before the selected node."
@spec comment_before(String.t() | Regex.t() | CommentMatcher.t()) :: Predicate.t()
def comment_before(matcher), do: predicate(:comment_before, compile_comment_matcher(matcher))
@doc "Matches comments immediately after the selected node."
@spec comment_after(String.t() | Regex.t() | CommentMatcher.t()) :: Predicate.t()
def comment_after(matcher), do: predicate(:comment_after, compile_comment_matcher(matcher))
@doc "Matches comments inside the selected node range."
@spec comment_inside(String.t() | Regex.t() | CommentMatcher.t()) :: Predicate.t()
def comment_inside(matcher), do: predicate(:comment_inside, compile_comment_matcher(matcher))
@doc "Matches inline comments on the selected node start line."
@spec comment_inline(String.t() | Regex.t() | CommentMatcher.t()) :: Predicate.t()
def comment_inline(matcher), do: predicate(:comment_inline, compile_comment_matcher(matcher))
@doc "Builds a substring comment matcher."
@spec text(String.t(), keyword()) :: CommentMatcher.t()
def text(value, opts \\ []), do: comment_matcher(:text, value, opts)
@doc "Builds an exact comment matcher."
@spec exact(String.t(), keyword()) :: CommentMatcher.t()
def exact(value, opts \\ []), do: comment_matcher(:exact, value, opts)
@doc "Builds a comment prefix matcher."
@spec prefix(String.t(), keyword()) :: CommentMatcher.t()
def prefix(value, opts \\ []), do: comment_matcher(:prefix, value, opts)
@doc "Builds a comment suffix matcher."
@spec suffix(String.t(), keyword()) :: CommentMatcher.t()
def suffix(value, opts \\ []), do: comment_matcher(:suffix, value, opts)
@doc "Negates a predicate for use with `where/2`."
@spec not Predicate.t() :: Predicate.t()
def not (%Predicate{} = predicate), do: %{predicate | negated?: Kernel.not(predicate.negated?)}
@doc "Returns true when matching this selector requires source text."
@spec requires_source?(t()) :: boolean()
def requires_source?(%__MODULE__{} = selector), do: requires_comments?(selector)
@doc "Returns true when matching this selector depends on comments."
@spec requires_comments?(t()) :: boolean()
def requires_comments?(%__MODULE__{filters: filters}) do
Enum.any?(filters, &comment_predicate?/1)
end
@doc "Finds selector matches in source text, AST, or a Sourceror zipper."
@spec find_all(String.t() | Macro.t() | Sourceror.Zipper.t(), t(), keyword()) :: [map()]
def find_all(input, %__MODULE__{} = selector, opts \\ []) do
ExAST.Patcher.find_all(input, selector, opts)
end
@doc "Returns true when the selector matches at least once."
@spec match?(String.t() | Macro.t() | Sourceror.Zipper.t(), t(), keyword()) :: boolean()
def match?(input, %__MODULE__{} = selector, opts \\ []) do
input
|> find_all(selector, Keyword.put(opts, :limit, 1))
|> Kernel.!=([])
end
defp add_step(%__MODULE__{steps: steps} = selector, relation, pattern) do
%{selector | steps: steps ++ [{relation, pattern}]}
end
defp build_predicate_from_ast({:not, _, [expr]}),
do: not build_predicate_from_ast(unwrap_block(expr))
defp build_predicate_from_ast({:or, _, [left, right]}),
do: any([build_predicate_from_ast(left), build_predicate_from_ast(right)])
defp build_predicate_from_ast({:and, _, [left, right]}),
do: all([build_predicate_from_ast(left), build_predicate_from_ast(right)])
defp build_predicate_from_ast({:any, _, [predicates]}),
do: any(Enum.map(list_ast_to_list(predicates), &build_predicate_from_ast/1))
defp build_predicate_from_ast({:all, _, [predicates]}),
do: all(Enum.map(list_ast_to_list(predicates), &build_predicate_from_ast/1))
defp build_predicate_from_ast({name, _, []}) when name in [:first, :last, :piped] do
apply(__MODULE__, name, [])
end
defp build_predicate_from_ast({:nth, _, [index]}) when is_integer(index), do: nth(index)
defp build_predicate_from_ast({name, _, [pattern]})
when name in [
:parent,
:ancestor,
:inside,
:has_child,
:has_descendant,
:has,
:contains,
:follows,
:precedes,
:immediately_follows,
:immediately_precedes
] do
apply(__MODULE__, name, [pattern])
end
defp build_predicate_from_ast({name, _, [matcher]})
when name in [:comment, :comment_before, :comment_after, :comment_inside, :comment_inline] do
apply(__MODULE__, name, [build_comment_matcher_from_ast(matcher)])
end
defp build_predicate_from_ast(%Predicate{} = predicate), do: predicate
defp build_predicate_from_ast(expr) do
raise ArgumentError,
"unsupported selector predicate expression: #{Macro.to_string(expr)}"
end
defp list_ast_to_list(list) when is_list(list), do: list
defp list_ast_to_list(ast) do
raise ArgumentError, "expected predicate list, got: #{Macro.to_string(ast)}"
end
defp unwrap_block({:__block__, _, [expr]}), do: expr
defp unwrap_block(expr), do: expr
defp add_filter(%__MODULE__{filters: filters} = selector, %Predicate{} = predicate) do
%{selector | filters: filters ++ [predicate]}
end
defp comment_predicate?(%Predicate{relation: relation})
when relation in [
:comment,
:comment_before,
:comment_after,
:comment_inside,
:comment_inline
],
do: true
defp comment_predicate?(%Predicate{relation: relation, pattern: predicates})
when relation in [:any, :all] and is_list(predicates) do
Enum.any?(predicates, &comment_predicate?/1)
end
defp comment_predicate?(_predicate), do: false
defp predicate(relation, patterns) when relation in [:any, :all] do
%Predicate{relation: relation, pattern: patterns}
end
defp predicate(relation, nil) when relation in [:first, :last] do
%Predicate{relation: relation, pattern: nil}
end
defp predicate(:nth = relation, index) when is_integer(index) do
%Predicate{relation: relation, pattern: index}
end
defp predicate(relation, %CommentMatcher{} = matcher)
when relation in [
:comment,
:comment_before,
:comment_after,
:comment_inside,
:comment_inline
] do
%Predicate{relation: relation, pattern: matcher}
end
defp predicate(relation, %Regex{} = matcher)
when relation in [
:comment,
:comment_before,
:comment_after,
:comment_inside,
:comment_inline
] do
%Predicate{relation: relation, pattern: matcher}
end
defp predicate(relation, pattern) do
%Predicate{relation: relation, pattern: compile_pattern(pattern)}
end
defp build_comment_matcher_from_ast({name, _, args})
when name in [:text, :exact, :prefix, :suffix] do
{args, _binding} = Code.eval_quoted(args)
apply(__MODULE__, name, args)
end
defp build_comment_matcher_from_ast(ast) do
{matcher, _binding} = Code.eval_quoted(ast)
matcher
end
defp compile_comment_matcher(%CommentMatcher{} = matcher), do: matcher
defp compile_comment_matcher(%Regex{} = regex), do: regex
defp compile_comment_matcher(value) when is_binary(value), do: text(value)
defp comment_matcher(kind, value, opts) when is_binary(value) do
%CommentMatcher{kind: kind, value: value, case_sensitive?: Keyword.get(opts, :case, true)}
end
defp compile_pattern(pattern) when is_binary(pattern), do: Code.string_to_quoted!(pattern)
defp compile_pattern(patterns) when is_list(patterns) do
{:__ex_ast_any_patterns__, Enum.map(patterns, &compile_pattern/1)}
end
defp compile_pattern(pattern), do: pattern
end