Current section

Files

Jump to
sourceror lib sourceror range.ex
Raw

lib/sourceror/range.ex

defmodule Sourceror.Range do
@moduledoc """
Represents a start/end position in a source file as `:line` and `:column`
keyword lists.
"""
import Sourceror.Identifier, only: [is_unary_op: 1, is_binary_op: 1]
import Sourceror.Utils.TypedStruct
typedstruct do
field :start, Sourceror.position(), enforced?: true
field :end, Sourceror.position(), enforced?: true
end
@doc """
Creates a new range struct.
## Examples
iex> Sourceror.Range.new([line: 1, column: 1], [line: 1, column: 2])
%Sourceror.Range{start: [line: 1, column: 1], end: [line: 1, column: 2]}
"""
@spec new(start_pos :: Sourceror.position(), end_pos :: Sourceror.position()) :: t()
def new(start_pos, end_pos) do
%__MODULE__{start: start_pos, end: end_pos}
end
defp split_on_newline(string) do
String.split(string, ~r/\n|\r\n|\r/)
end
@doc false
@spec get_range(Macro.t()) :: t() | nil
def get_range(quoted, opts \\ []) do
with %{} = range <- do_get_range(quoted) do
if Keyword.get(opts, :include_comments, false) do
add_comments_to_range(range, quoted)
else
range
end
end
end
defp add_comments_to_range(range, quoted) do
comments =
case quoted do
{_, meta, _} ->
meta[:leading_comments] || []
_ ->
[]
end
first_comment = List.first(comments)
last_comment = List.last(comments)
{start_line, start_column} =
if first_comment do
{first_comment.line, min(range.start[:column], first_comment.column || 1)}
else
{range.start[:line], range.start[:column]}
end
end_column =
if last_comment && last_comment.line == range.start[:line] do
comment_length = String.length(last_comment.text)
max(range.end[:column], (last_comment.column || 1) + comment_length)
else
range.end[:column]
end
new([line: start_line, column: start_column], line: range.end[:line], column: end_column)
end
@spec do_get_range(Macro.t()) :: t() | nil
defp do_get_range(quoted)
# Module aliases starting with a non-atom or special form
# e.g. __MODULE__.Nested, @module.Nested, module().Nested
defp do_get_range({:__aliases__, meta, [{_, _, _} = first_segment | rest]}) do
get_range_for_pair(first_segment, {:__aliases__, meta, rest})
end
# Module aliases
defp do_get_range({:__aliases__, meta, segments}) do
start_pos = Keyword.take(meta, [:line, :column])
last_segment_length = List.last(segments) |> to_string() |> String.length()
end_pos = meta[:last] |> Keyword.update!(:column, &(&1 + last_segment_length))
new(start_pos, end_pos)
end
# Strings
defp do_get_range({:__block__, meta, [string]}) when is_binary(string) do
lines = split_on_newline(string)
last_line = List.last(lines) || ""
end_line = meta[:line] + length(lines)
end_line =
if meta[:delimiter] in [~S/"""/, ~S/'''/] do
end_line
else
end_line - 1
end
end_column =
if meta[:delimiter] in [~S/"""/, ~S/'''/] do
delimiter_count = String.length(meta[:delimiter]) + 1
indentation = meta[:indentation] || 0
indentation + delimiter_count
else
delimiter_count =
if String.contains?(string, meta[:delimiter]) do
~r/#{meta[:delimiter]}/ |> Regex.scan(string) |> length()
else
0
end
count =
meta[:column] + String.length(last_line) + String.length(meta[:delimiter]) +
delimiter_count
if end_line == meta[:line] do
count + 1
else
count
end
end
new(Keyword.take(meta, [:line, :column]), line: end_line, column: end_column)
end
# Integers, Floats
defp do_get_range({:__block__, meta, [number]}) when is_integer(number) or is_float(number) do
start_pos = Keyword.take(meta, [:line, :column])
end_pos = [
line: meta[:line],
column: meta[:column] + String.length(meta[:token])
]
new(start_pos, end_pos)
end
# Atoms
defp do_get_range({:__block__, meta, [atom]}) when is_atom(atom) do
start_pos = Keyword.take(meta, [:line, :column])
string = Atom.to_string(atom)
delimiter = meta[:delimiter] || ""
lines = split_on_newline(string)
last_line = List.last(lines) || ""
end_line = meta[:line] + length(lines) - 1
end_column = meta[:column] + String.length(last_line) + String.length(delimiter)
end_column =
cond do
end_line == meta[:line] && meta[:delimiter] ->
# Column and first delimiter
end_column + 2
end_line == meta[:line] ->
# Just the colon
end_column + 1
end_line != meta[:line] ->
# You're beautiful as you are, Courage
end_column
end
end_pos = [line: end_line, column: end_column]
new(start_pos, end_pos)
end
# Block with no parenthesis
defp do_get_range({:__block__, meta, args} = quoted) do
if Sourceror.has_closing_line?(quoted) do
get_range_for_node_with_closing_line(quoted)
else
case args do
[{_, _, _} | _] ->
{first, rest} = List.pop_at(args, 0)
{last, _} = List.pop_at(rest, -1, first)
get_range_for_pair(first, last)
[charlist] when is_list(charlist) ->
string = List.to_string(charlist)
do_get_range({:__block__, meta, [string]})
[] ->
nil
end
end
end
# Variables
defp do_get_range({form, meta, context}) when is_atom(form) and is_atom(context) do
start_pos = Keyword.take(meta, [:line, :column])
end_pos = [
line: start_pos[:line],
column: start_pos[:column] + String.length(Atom.to_string(form))
]
new(start_pos, end_pos)
end
# 2-tuples from keyword lists
defp do_get_range({left, right}) do
get_range_for_pair(left, right)
end
# Handles arguments. Lists are always wrapped in `:__block__`, so the only case
# in which we can have a naked list is in partial keyword lists, as in `[:a, :b, c: d, e: f]`,
# or stabs like `:foo -> :bar`
defp do_get_range([first, _second | _] = list) do
get_range_for_pair(first, List.last(list))
end
defp do_get_range([first]) do
get_range(first)
end
# Stabs without right args
# a ->
defp do_get_range({:->, stab_meta, [left, {:__block__, right_meta, [nil]} = right]}) do
end_pos =
if right_meta[:column] == stab_meta[:column] do
right_meta
|> Keyword.take([:line, :column])
|> Keyword.update!(:column, &(&1 + 2))
else
get_range(right).end
end
start_pos =
case left do
[] ->
Keyword.take(stab_meta, [:line, :column])
_ ->
get_range(left).start
end
new(start_pos, end_pos)
end
# Stabs without left args
# -> b
defp do_get_range({:->, meta, [[], right]}) do
start_pos = Keyword.take(meta, [:line, :column])
with %{end: end_pos} <- get_range(right) do
new(start_pos, end_pos)
end
end
# Stabs with args
# a -> b
defp do_get_range({:->, _, [left, right]}) do
get_range_for_pair(left, right)
end
# Argument capture syntax
# &1
defp do_get_range({:&, meta, [int]}) when is_integer(int) do
start_pos = Keyword.take(meta, [:line, :column])
int_len = int |> Integer.to_string() |> String.length()
end_pos = [line: meta[:line], column: meta[:column] + int_len + 1]
new(start_pos, end_pos)
end
# Unwrapped Access syntax
defp do_get_range({:., _, [Access, :get]} = quoted) do
get_range_for_node_with_closing_line(quoted)
end
# Unwrapped qualified calls
defp do_get_range({:., meta, [left, atom]}) when is_atom(atom) do
with %{start: start_pos} <- get_range(left) do
atom_length = atom |> inspect() |> String.length()
end_pos = [line: meta[:line], column: meta[:column] + atom_length]
new(start_pos, end_pos)
end
end
# Access syntax
defp do_get_range({{:., _, [Access, :get]}, _, _} = quoted) do
get_range_for_node_with_closing_line(quoted)
end
# Qualified tuple
defp do_get_range({{:., _, [_, :{}]}, _, _} = quoted) do
get_range_for_node_with_closing_line(quoted)
end
# Interpolated atoms
defp do_get_range({{:., _, [:erlang, :binary_to_atom]}, meta, [interpolation, :utf8]}) do
interpolation =
Macro.update_meta(interpolation, &Keyword.put(&1, :delimiter, meta[:delimiter]))
get_range_for_interpolation(interpolation)
end
# Interpolated charlists
defp do_get_range({{:., _, [List, :to_charlist]}, meta, [segments]}) do
start_pos = Keyword.take(meta, [:line, :column])
end_pos = get_end_pos_for_interpolation_segments(segments, meta[:delimiter] || "'", start_pos)
new(start_pos, end_pos)
end
# Charlist interpolation node
defp do_get_range({{:., _, [Kernel, :to_string]}, meta, _}) do
start_pos = Keyword.take(meta, [:line, :column])
end_pos = Keyword.update!(meta[:closing], :column, &(&1 + 1))
new(start_pos, end_pos)
end
# Qualified call
defp do_get_range({{:., _, [_left, right]}, _meta, []} = quoted) when is_atom(right) do
get_range_for_qualified_call_without_arguments(quoted)
end
# Anonymous function call
defp do_get_range({{:., _, [_left]}, _meta, []} = quoted) do
get_range_for_qualified_call_without_arguments(quoted)
end
# Qualified call with arguments
defp do_get_range({{:., _, [_left, _]}, _meta, _args} = quoted) do
get_range_for_qualified_call_with_arguments(quoted)
end
# Qualified double call eg. Mod.unquote(foo)(bar)
defp do_get_range({{{:., _, _}, _, _}, _, _} = quoted) do
get_range_for_qualified_call_with_arguments(quoted)
end
# Anonymous function call with arguments
defp do_get_range({{:., _, [_left]}, _meta, _args} = quoted) do
get_range_for_qualified_call_with_arguments(quoted)
end
# Unary operators
defp do_get_range({op, meta, [arg]}) when is_unary_op(op) do
with %{end: end_pos} <- get_range(arg) do
start_pos = Keyword.take(meta, [:line, :column])
end_column =
if end_pos[:line] == meta[:line] do
end_pos[:column]
else
end_pos[:column] + String.length(to_string(op))
end
end_pos = [line: end_pos[:line], column: end_column]
new(start_pos, end_pos)
end
end
# Interpolation
defp do_get_range(
{:"::", meta,
[
{{:., _, [Kernel, :to_string]}, end_meta, _},
_
]}
) do
start_pos = [line: meta[:line], column: meta[:column]]
end_pos = Keyword.update!(end_meta[:closing], :column, &(&1 + 1))
new(start_pos, end_pos)
end
# Binary operators
defp do_get_range({op, meta, [left, right]}) when is_binary_op(op) do
if meta[:parens] && meta[:parens][:closing] do
start_pos = Keyword.take(meta[:parens], [:line, :column])
end_pos =
Keyword.take(meta[:parens][:closing], [:line, :column])
|> Keyword.update!(:column, &(&1 + 1))
new(start_pos, end_pos)
else
get_range_for_pair(left, right)
end
end
# Stepped ranges
defp do_get_range({:..//, _, [left, _middle, right]}) do
get_range_for_pair(left, right)
end
# Bitstrings and interpolations
defp do_get_range({:<<>>, meta, _} = quoted) do
if meta[:delimiter] do
get_range_for_interpolation(quoted)
else
get_range_for_bitstring(quoted)
end
end
# Sigils
defp do_get_range({sigil, meta, [{:<<>>, _, segments}, modifiers]} = quoted)
when is_list(modifiers) do
case Atom.to_string(sigil) do
<<"sigil_", _name>> ->
# Congratulations, it's a sigil!
start_pos = Keyword.take(meta, [:line, :column])
end_pos =
get_end_pos_for_interpolation_segments(segments, meta[:delimiter], start_pos)
|> Keyword.update!(:column, &(&1 + length(modifiers)))
end_pos =
cond do
multiline_delimiter?(meta[:delimiter]) and !has_interpolations?(segments) ->
# If it has no interpolations and is a multiline sigil, then the first
# line will be incorrectly reported because the first string in the
# segments(which is the only one) won't have a leading newline, so
# we're compensating for that here. The end column will be at the same
# indentation as the start column, plus the length of the multiline
# delimiter
[line: end_pos[:line] + 1, column: start_pos[:column] + 3]
multiline_delimiter?(meta[:delimiter]) or has_interpolations?(segments) ->
# If it's a multiline sigil or has interpolations, then the positions
# will already be correctly calculated
end_pos
true ->
# If it's a single line sigil, add the offset for the ~x
Keyword.update!(end_pos, :column, &(&1 + 2))
end
new(start_pos, end_pos)
_ ->
get_range_for_unqualified_call(quoted)
end
end
# Unqualified calls
defp do_get_range({call, _, _} = quoted) when is_atom(call) do
get_range_for_unqualified_call(quoted)
end
# Double-parenthesis unqualified calls:
# unquote(foo)()
defp do_get_range({{call, _, _}, _, _} = quoted) when is_atom(call) do
get_range_for_unqualified_call(quoted)
end
# Catch-all
defp do_get_range(_), do: nil
defp get_range_for_unqualified_call({_call, meta, args} = quoted) do
if Sourceror.has_closing_line?(quoted) do
get_range_for_node_with_closing_line(quoted)
else
with %{end: end_pos} <- get_range(List.last(args)) do
start_pos = Keyword.take(meta, [:line, :column])
new(start_pos, end_pos)
end
end
end
defp get_range_for_qualified_call_without_arguments({{:., _, call}, meta, []} = quoted) do
if Sourceror.has_closing_line?(quoted) do
get_range_for_node_with_closing_line(quoted)
else
{left, right_len} =
case call do
[left, right] -> {left, String.length(inspect(right)) - 1}
[left] -> {left, 0}
end
with %{start: start_pos} <- get_range(left) do
identifier_pos = Keyword.take(meta, [:line, :column])
parens_length =
if meta[:no_parens] do
0
else
2
end
end_pos = [
line: identifier_pos[:line],
column: identifier_pos[:column] + right_len + parens_length
]
new(start_pos, end_pos)
end
end
end
defp get_range_for_qualified_call_with_arguments({{:., _, [left | _]}, _meta, args} = quoted) do
if Sourceror.has_closing_line?(quoted) do
get_range_for_node_with_closing_line(quoted)
else
get_range_for_pair(left, List.last(args) || left)
end
end
defp get_range_for_qualified_call_with_arguments(
{{{:., _, [left | _]}, _, _}, _, args} = quoted
) do
if Sourceror.has_closing_line?(quoted) do
get_range_for_node_with_closing_line(quoted)
else
get_range_for_pair(left, List.last(args))
end
end
defp get_range_for_node_with_closing_line({:fn, meta, _} = quoted) do
start_position = Sourceror.get_start_position(quoted)
end_position =
if meta[:end_of_expression] do
Keyword.take(meta[:end_of_expression], [:line, :column])
else
quoted
|> Sourceror.get_end_position()
|> Keyword.update!(:column, &(&1 + 3))
end
new(start_position, end_position)
end
defp get_range_for_node_with_closing_line({{{:., _, [left | _]}, _, _}, _, args}) do
start_position = get_range(left).start
end_position = get_range(List.last(args) || left).end
end_position = Keyword.update!(end_position, :column, &(&1 + 1))
new(start_position, end_position)
end
defp get_range_for_node_with_closing_line({_, meta, _} = quoted) do
start_position = Sourceror.get_start_position(quoted)
end_position = Sourceror.get_end_position(quoted)
end_position =
cond do
Keyword.has_key?(meta, :end_of_expression) ->
Keyword.update!(end_position, :column, &(&1 + 1))
Keyword.has_key?(meta, :end) ->
Keyword.update!(end_position, :column, &(&1 + 3))
true ->
# If it doesn't have an end token, then it has either a ), a ] or a }
Keyword.update!(end_position, :column, &(&1 + 1))
end
new(start_position, end_position)
end
defp get_range_for_interpolation({:<<>>, meta, segments}) do
start_pos = Keyword.take(meta, [:line, :column])
end_pos =
get_end_pos_for_interpolation_segments(segments, meta[:delimiter] || "\"", start_pos)
new(start_pos, end_pos)
end
defp get_end_pos_for_interpolation_segments(segments, delimiter, start_pos) do
end_pos =
Enum.reduce(segments, start_pos, fn
string, pos when is_binary(string) ->
lines = split_on_newline(string)
length = String.length(List.last(lines) || "")
line_count = length(lines) - 1
column =
if line_count > 0 do
start_pos[:column] + length
else
pos[:column] + length
end
[
line: pos[:line] + line_count,
column: column
]
{:"::", _, [{_, meta, _}, {:binary, _, _}]}, _pos ->
meta
|> Keyword.fetch!(:closing)
|> Keyword.take([:line, :column])
# Add the closing }
|> Keyword.update!(:column, &(&1 + 1))
# interpolation in charlist
{{:., _, [Kernel, :to_string]}, meta, _}, _pos ->
meta
|> Keyword.fetch!(:closing)
|> Keyword.take([:line, :column])
# Add the closing }
|> Keyword.update!(:column, &(&1 + 1))
end)
cond do
multiline_delimiter?(delimiter) and has_interpolations?(segments) ->
[line: end_pos[:line], column: String.length(delimiter) + 1]
has_interpolations?(segments) ->
Keyword.update!(end_pos, :column, &(&1 + 1))
true ->
Keyword.update!(end_pos, :column, &(&1 + 2))
end
end
defp get_range_for_pair(left, right) do
with %{start: start_pos} <- get_range(left),
%{end: end_pos} <- get_range(right) do
new(start_pos, end_pos)
end
end
defp has_interpolations?(segments) do
Enum.any?(segments, fn segment ->
match?({:"::", _, _}, segment) or match?({{:., _, [Kernel, :to_string]}, _, _}, segment)
end)
end
defp multiline_delimiter?(delimiter) do
delimiter in ~w[""" ''']
end
defp get_range_for_bitstring(quoted) do
range = get_range_for_node_with_closing_line(quoted)
# get_range_for_node_with_closing_line/1 will add 1 to the ending column
# because it assumes it ends with ), ] or }, but bitstring closing token is
# >>, so we need to add another 1
update_in(range, [Access.key(:end), :column], &(&1 + 1))
end
end