Skip to content

Commit

Permalink
Add additional identification guards to Sourceror.Identifier (#108)
Browse files Browse the repository at this point in the history
* Add `is_call/1`, `is_unqualified_call/1`, and `is_qualified_call/1`

* Add `is_identifier/1`

* Add `is_atomic_literal/1`

* Fix formatting for CI and add Elixir 1.15/OTP 26

* Fix specs
  • Loading branch information
zachallaun authored Sep 16, 2023
1 parent eaf0cc6 commit b0ec3cc
Show file tree
Hide file tree
Showing 5 changed files with 301 additions and 80 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ jobs:
run_dialyzer: true
- elixir: '1.14.3'
otp: '25.2'
- elixir: '1.15.5'
otp: '26'
steps:
- uses: actions/checkout@v2
- name: Set up Elixir
Expand Down
308 changes: 257 additions & 51 deletions lib/sourceror/identifier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,63 +3,85 @@ defmodule Sourceror.Identifier do
Functions to identify an classify forms and quoted expressions.
"""

# this is used below to handle operators that were added in later
# versions of Elixir
concat_if = fn ops, version, new_ops ->
if Version.match?(System.version(), version) do
ops ++ new_ops
else
ops
end
end

@unary_ops [:&, :!, :^, :not, :+, :-, :~~~, :@]
binary_ops = [
:<-,
:\\,
:when,
:"::",
:|,
:=,
:||,
:|||,
:or,
:&&,
:&&&,
:and,
:==,
:!=,
:=~,
:===,
:!==,
:<,
:<=,
:>=,
:>,
:|>,
:<<<,
:>>>,
:<~,
:~>,
:<<~,
:~>>,
:<~>,
:<|>,
:in,
:^^^,
:"//",
:++,
:--,
:..,
:<>,
:+,
:-,
:*,
:/,
:.
]

@binary_ops (if Version.match?(System.version(), "~> 1.12") do
binary_ops ++ Enum.map(~w[+++ ---], &String.to_atom/1)
else
binary_ops
end)

@binary_ops [
:<-,
:\\,
:when,
:"::",
:|,
:=,
:||,
:|||,
:or,
:&&,
:&&&,
:and,
:==,
:!=,
:=~,
:===,
:!==,
:<,
:<=,
:>=,
:>,
:|>,
:<<<,
:>>>,
:<~,
:~>,
:<<~,
:~>>,
:<~>,
:<|>,
:in,
:^^^,
:"//",
:++,
:--,
:..,
:<>,
:+,
:-,
:*,
:/,
:.
]
|> concat_if.("~> 1.12", ~w[+++ ---]a)
|> concat_if.("~> 1.13", ~w[**]a)

@pipeline_operators [:|>, :~>>, :<<~, :~>, :<~, :<~>, :<|>]

@non_call_forms [:__block__, :__aliases__]

defguardp __is_atomic_literal__(quoted)
when is_number(quoted) or is_atom(quoted) or is_binary(quoted)

# {:__block__, [], [atomic_literal]}
defguardp __is_atomic_literal_block__(quoted)
when is_tuple(quoted) and
tuple_size(quoted) == 3 and
elem(quoted, 0) == :__block__ and
tl(elem(quoted, 2)) == [] and
__is_atomic_literal__(hd(elem(quoted, 2)))

@doc """
Checks if the given identifier is an unary op.
## Examples
iex> is_unary_op(:+)
true
"""
Expand All @@ -68,7 +90,9 @@ defmodule Sourceror.Identifier do

@doc """
Checks if the given identifier is a binary op.
## Examples
iex> is_binary_op(:+)
true
"""
Expand All @@ -77,15 +101,197 @@ defmodule Sourceror.Identifier do

@doc """
Checks if the given identifier is a pipeline operator.
## Examples
iex> is_pipeline_op(:|>)
true
"""
@spec is_pipeline_op(Macro.t()) :: Macro.t()
defguard is_pipeline_op(op) when is_atom(op) and op in @pipeline_operators

@doc """
Checks if the given quoted form is a call.
Calls are any form of the shape `{form, metadata, args}` where args is
a list, with the exception of blocks and aliases, which are identified
by the forms `:__block__` and `:__aliases__`.
## Examples
iex> "node()" |> Sourceror.parse_string!() |> is_call()
true
iex> "Kernel.node()" |> Sourceror.parse_string!() |> is_call()
true
iex> "%{}" |> Sourceror.parse_string!() |> is_call()
true
iex> "@attr" |> Sourceror.parse_string!() |> is_call()
true
iex> "node" |> Sourceror.parse_string!() |> is_call()
false
iex> "1" |> Sourceror.parse_string!() |> is_call()
false
iex> "(1; 2)" |> Sourceror.parse_string!() |> is_call()
false
iex> "Macro.Env" |> Sourceror.parse_string!() |> is_call()
false
"""
@spec is_call(Macro.t()) :: Macro.t()
defguard is_call(quoted)
when is_tuple(quoted) and
tuple_size(quoted) == 3 and
is_list(elem(quoted, 2)) and
elem(quoted, 0) not in @non_call_forms

@doc """
Checks if the given quoted form is an unqualified call.
All unqualified calls would also return `true` if passed to `is_call/1`,
but they have the shape `{atom, metadata, args}`.
## Examples
iex> "node()" |> Sourceror.parse_string!() |> is_unqualified_call()
true
iex> "%{}" |> Sourceror.parse_string!() |> is_unqualified_call()
true
iex> "@attr" |> Sourceror.parse_string!() |> is_unqualified_call()
true
iex> "node" |> Sourceror.parse_string!() |> is_unqualified_call()
false
iex> "1" |> Sourceror.parse_string!() |> is_unqualified_call()
false
iex> "(1; 2)" |> Sourceror.parse_string!() |> is_unqualified_call()
false
iex> "Macro.Env" |> Sourceror.parse_string!() |> is_unqualified_call()
false
"""
@spec is_unqualified_call(Macro.t()) :: Macro.t()
defguard is_unqualified_call(quoted)
when is_call(quoted) and is_atom(elem(quoted, 0))

@doc """
Checks if the given quoted form is a qualified call.
All unqualified calls would also return `true` if passed to `is_call/1`,
but they have the shape `{{:., dot_metadata, dot_args}, metadata, args}`.
## Examples
iex> "Kernel.node()" |> Sourceror.parse_string!() |> is_qualified_call()
true
iex> "__MODULE__.node()" |> Sourceror.parse_string!() |> is_qualified_call()
true
iex> "foo.()" |> Sourceror.parse_string!() |> is_qualified_call()
true
iex> "foo.bar()" |> Sourceror.parse_string!() |> is_qualified_call()
true
iex> "node()" |> Sourceror.parse_string!() |> is_qualified_call()
false
iex> "%{}" |> Sourceror.parse_string!() |> is_qualified_call()
false
iex> "@attr" |> Sourceror.parse_string!() |> is_qualified_call()
false
iex> "1" |> Sourceror.parse_string!() |> is_qualified_call()
false
iex> "(1; 2)" |> Sourceror.parse_string!() |> is_qualified_call()
false
iex> "Macro.Env" |> Sourceror.parse_string!() |> is_qualified_call()
false
"""
@spec is_qualified_call(Macro.t()) :: Macro.t()
defguard is_qualified_call(quoted)
when is_call(quoted) and
is_call(elem(quoted, 0)) and
elem(elem(quoted, 0), 0) == :.

@doc """
Checks if the given quoted form is an identifier, such as a variable.
## Examples
iex> "node" |> Sourceror.parse_string!() |> is_identifier()
true
iex> "node()" |> Sourceror.parse_string!() |> is_identifier()
false
iex> "1" |> Sourceror.parse_string!() |> is_identifier()
false
"""
@spec is_identifier(Macro.t()) :: Macro.t()
defguard is_identifier(quoted)
when is_tuple(quoted) and
tuple_size(quoted) == 3 and
is_atom(elem(quoted, 0)) and
is_atom(elem(quoted, 2))

@doc """
Checks if the given quoted form is an atomic literal in the AST.
This set includes numbers, atoms, and strings, but not collections like
tuples, lists, or maps.
This guard returns `true` for literals that are the only elements inside
of a `:__block__`, such as `{:__block__, [], [:literal]}`.
## Examples
iex> is_atomic_literal(1)
true
iex> is_atomic_literal(1.0)
true
iex> is_atomic_literal(:foo)
true
iex> is_atomic_literal("foo")
true
iex> is_atomic_literal({:__block__, [], [1]})
true
iex> is_atomic_literal({:__block__, [], [1, 2]})
false
iex> is_atomic_literal({:__block__, [], [{:node, [], nil}]})
false
iex> is_atomic_literal('foo')
false
"""
@spec is_atomic_literal(Macro.t()) :: Macro.t()
defguard is_atomic_literal(quoted)
when __is_atomic_literal__(quoted) or __is_atomic_literal_block__(quoted)

@doc """
Checks if the given atom is a valid module alias.
## Examples
iex> valid_alias?(Foo)
true
iex> valid_alias?(:foo)
Expand All @@ -95,7 +301,7 @@ defmodule Sourceror.Identifier do
valid_alias?(to_charlist(atom))
end

def valid_alias?('Elixir' ++ rest), do: valid_alias_piece?(rest)
def valid_alias?(~c"Elixir" ++ rest), do: valid_alias_piece?(rest)
def valid_alias?(_other), do: false

defp valid_alias_piece?([?., char | rest]) when char >= ?A and char <= ?Z,
Expand Down
5 changes: 0 additions & 5 deletions test/corpus/term/alias.ex
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,3 @@ name.Mod.Child
## dot on special identifier

__MODULE__.Child

(source
(dot
(identifier)
(alias)))
Loading

0 comments on commit b0ec3cc

Please sign in to comment.