diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 55c0e566..b221a6a0 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -24,8 +24,7 @@ {"lib/elixir_sense/core/compiler/fn.ex", :pattern_match}, {"lib/elixir_sense/core/normalized/macro/env.ex", :pattern_match}, - # 2. Pre-existing peripheral logic (vendored tokenizer/parser, normalized helpers) - {"lib/elixir_sense/core/normalized/tokenizer.ex", :contract_supertype}, - {"lib/elixir_sense/core/normalized/tokenizer.ex", :pattern_match}, - {"lib/elixir_sense/core/parser.ex", :guard_fail} + # 3. Completion engine ported wholesale from elixir-ls (commit 98e983d). The unreachable `:error` + # clauses in expand_dot_path/expand_aliases are upstream-faithful defensive fallbacks. + {"lib/elixir_sense/providers/completion/completion_engine.ex", :pattern_match} ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22a3c408..042e3d94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: strategy: fail-fast: false matrix: - # Same matrix as mix_test (Linux). See above for compatibility notes. + # Same matrix as mix_test (Linux). include: - elixir: 1.16.x otp: 26.x diff --git a/lib/elixir_sense.ex b/lib/elixir_sense.ex index 5feb9be6..4fef491f 100644 --- a/lib/elixir_sense.ex +++ b/lib/elixir_sense.ex @@ -49,7 +49,7 @@ defmodule ElixirSense do ...> end ...> ''' iex> ElixirSense.string_to_quoted(code, 1) - {:ok, {:defmodule, [do: [line: 1, column: 11], end: [line: 2, column: 1], line: 1, column: 1], [[do: {:__block__, [], []}]]}} + {:ok, {:defmodule, [do: [line: 1, column: 11], end: [line: 2, column: 1], line: 1, column: 1], [[do: {:__block__, [line: 1, column: 11], []}]]}} """ @spec string_to_quoted( String.t(), diff --git a/lib/elixir_sense/core/bitstring.ex b/lib/elixir_sense/core/bitstring.ex deleted file mode 100644 index 28f53bb1..00000000 --- a/lib/elixir_sense/core/bitstring.ex +++ /dev/null @@ -1,127 +0,0 @@ -defmodule ElixirSense.Core.Bitstring do - @moduledoc false - - @types [ - :integer, - :float, - :bitstring, - :binary, - :utf8, - :utf16, - :utf32 - ] - - @utf_types [ - :utf8, - :utf16, - :utf32 - ] - - @type_aliases %{ - bits: :bitstring, - bytes: :binary - } - - @modifiers %{ - signed: [:integer], - unsigned: [:integer], - little: [:integer, :float, :utf16, :utf32], - big: [:integer, :float, :utf16, :utf32], - native: [:integer, :float, :utf16, :utf32] - } - - @sign_modifiers [:signed, :unsigned] - @endianness_modifiers [:little, :big, :native] - @default %{ - type: nil, - sign_modifier: nil, - endianness_modifier: nil, - size: nil, - unit: nil - } - - def parse(binary, acc \\ @default) - - for type <- @types do - def parse(<>, acc) do - parse(rest, %{acc | type: unquote(type)}) - end - end - - for {type_alias, type} <- @type_aliases do - def parse(<>, acc) do - parse(rest, %{acc | type: unquote(type)}) - end - end - - for sign_modifier <- @sign_modifiers do - def parse(<>, acc) do - parse(rest, %{acc | sign_modifier: unquote(sign_modifier)}) - end - end - - for endianness_modifier <- @endianness_modifiers do - def parse(<>, acc) do - parse(rest, %{acc | endianness_modifier: unquote(endianness_modifier)}) - end - end - - def parse(<<"-", rest::binary>>, acc), do: parse(rest, acc) - - def parse(<<"size", rest::binary>>, acc) do - parse(rest, %{acc | size: true}) - end - - def parse(<<"unit", rest::binary>>, acc) do - parse(rest, %{acc | unit: true}) - end - - def parse(<<_::binary-size(1), rest::binary>>, acc), do: parse(rest, acc) - - def parse(<<>>, acc), do: acc - - def available_options(map) do - available_types(map) - |> Kernel.++(available_sign_modifiers(map)) - |> Kernel.++(available_endianness_modifiers(map)) - |> Kernel.++(available_size(map)) - |> Kernel.++(available_unit(map)) - end - - def available_types(%{type: nil, sign_modifier: nil, endianness_modifier: nil} = map), - do: filter_utf(map, @types) - - def available_types( - %{type: nil, sign_modifier: nil, endianness_modifier: endianness_modifier} = map - ), - do: filter_utf(map, @modifiers[endianness_modifier]) - - def available_types(%{type: nil}), do: [:integer] - def available_types(_), do: [] - - def available_sign_modifiers(%{type: type, sign_modifier: nil}) when type in [nil, :integer], - do: @sign_modifiers - - def available_sign_modifiers(_), do: [] - - def available_endianness_modifiers(%{type: type, endianness_modifier: nil}) - when type in [nil, :integer, :utf16, :utf32], - do: @endianness_modifiers - - def available_endianness_modifiers(%{type: :float, endianness_modifier: nil}), - do: [:little, :big] - - def available_endianness_modifiers(_), do: [] - - # It's not documented but as of elixir 1.13 size and unit are not supported on utf types - # and will fail to compile - - def available_size(%{size: nil, type: type}) when type not in @utf_types, do: [:size] - def available_size(_), do: [] - - def available_unit(%{unit: nil, type: type}) when type not in @utf_types, do: [:unit] - def available_unit(_), do: [] - - defp filter_utf(%{size: nil, unit: nil}, list), do: list - defp filter_utf(_, list), do: list -- @utf_types -end diff --git a/lib/elixir_sense/core/compiler.ex b/lib/elixir_sense/core/compiler.ex index 4a2ddb10..dac43f3e 100644 --- a/lib/elixir_sense/core/compiler.ex +++ b/lib/elixir_sense/core/compiler.ex @@ -1771,6 +1771,21 @@ defmodule ElixirSense.Core.Compiler do {Enum.reverse(ast), %{state | protocol: nil}, env} end + defp expand_macro( + meta, + Kernel, + :defmodule, + [alias, {:__cursor__, _, cursor_args} = cursor], + callback, + state, + env + ) + when is_list(cursor_args) do + # tolerant parsers may produce a cursor in place of the do block + # expand as if the cursor was the module body + expand_macro(meta, Kernel, :defmodule, [alias, [do: cursor]], callback, state, env) + end + defp expand_macro( meta, Kernel, diff --git a/lib/elixir_sense/core/metadata.ex b/lib/elixir_sense/core/metadata.ex index 4dd9ae9d..26af8361 100644 --- a/lib/elixir_sense/core/metadata.ex +++ b/lib/elixir_sense/core/metadata.ex @@ -74,16 +74,6 @@ defmodule ElixirSense.Core.Metadata do } end - if Version.match?(System.version(), "< 1.18.0-dev") do - def container_cursor_to_quoted_options(_trailing_fragment) do - [columns: true, token_metadata: true] - end - else - def container_cursor_to_quoted_options(trailing_fragment) do - [columns: true, token_metadata: true, trailing_fragment: trailing_fragment] - end - end - def get_cursor_env( metadata, position, @@ -95,57 +85,17 @@ defmodule ElixirSense.Core.Metadata do {line, column}, surround ) do - {{prefix, needle, suffix}, source_with_cursor} = - case surround do - {{begin_line, begin_column}, {end_line, end_column}} -> - [prefix, needle, suffix] = - ElixirSense.Core.Source.split_at(metadata.source, [ - {begin_line, begin_column}, - {end_line, end_column} - ]) - - source_with_cursor = prefix <> "__cursor__(#{needle})" <> suffix - - {{prefix, needle, suffix}, source_with_cursor} - - nil -> - [prefix, suffix] = - ElixirSense.Core.Source.split_at(metadata.source, [ - {line, column} - ]) - - source_with_cursor = prefix <> "__cursor__()" <> suffix - - {{prefix, "", suffix}, source_with_cursor} - end - - {meta, cursor_env} = - case Code.string_to_quoted(source_with_cursor, - columns: true, - token_metadata: true, - emit_warnings: false - ) do - {:ok, ast} -> - MetadataBuilder.build(ast).cursor_env || {[], nil} - - _ -> - {[], nil} - end - - {_meta, cursor_env} = - if cursor_env != nil do - {meta, cursor_env} - else - # IO.puts(prefix <> "|") - options = container_cursor_to_quoted_options(needle <> suffix) - - case Code.Fragment.container_cursor_to_quoted(prefix, options) do - {:ok, ast} -> - MetadataBuilder.build(ast).cursor_env || {[], nil} - - _ -> - {[], nil} - end + # the cursor environment is derived by marking the AST node at the cursor + # (located via toxic2's source ranges) - a separate parse from the one that + # produced `metadata`, so the marker never pollutes the calls/vars/references + # metadata. A `surround` range means the caller is navigating an existing + # symbol (definition/hover/references), so we keep that token in the tree. + opts = if surround == nil, do: [], else: [preserve_token: true] + + cursor_env = + case ElixirSense.Core.Parser.cursor_env(metadata.source, {line, column}, opts) do + {_meta, env} -> env + nil -> nil end if cursor_env != nil do diff --git a/lib/elixir_sense/core/normalized/tokenizer.ex b/lib/elixir_sense/core/normalized/tokenizer.ex deleted file mode 100644 index f58a1411..00000000 --- a/lib/elixir_sense/core/normalized/tokenizer.ex +++ /dev/null @@ -1,45 +0,0 @@ -defmodule ElixirSense.Core.Normalized.Tokenizer do - @moduledoc """ - Handles tokenization of Elixir code snippets - - Uses private api :elixir_tokenizer - """ - require Logger - - @spec tokenize(String.t(), Keyword.t()) :: [tuple] - def tokenize(prefix, options \\ []) do - options = options |> Keyword.put(:emit_warnings, false) - - prefix - |> String.to_charlist() - |> do_tokenize(options) - end - - defp do_tokenize(prefix_charlist, options) do - result = :elixir_tokenizer.tokenize(prefix_charlist, 1, options) - - case result do - # < 1.17 - {:ok, _line, _column, _warning, tokens} -> - Enum.reverse(tokens) - - # >= 1.17 - {:ok, _line, _column, _warning, tokens, _terminators} -> - tokens - - {:error, _, _, _, sofar} -> - sofar - end - rescue - e -> - if Version.match?(System.version(), ">= 1.20.0-dev") do - Logger.error( - ":elixir_tokenizer.tokenize raised #{Exception.format(:error, e, __STACKTRACE__)}. Please report that to elixir project." - ) - - reraise e, __STACKTRACE__ - else - [] - end - end -end diff --git a/lib/elixir_sense/core/parser.ex b/lib/elixir_sense/core/parser.ex index ffa91f4a..dd30eecb 100644 --- a/lib/elixir_sense/core/parser.ex +++ b/lib/elixir_sense/core/parser.ex @@ -1,11 +1,17 @@ defmodule ElixirSense.Core.Parser do @moduledoc """ Core Parser + + Parsing is delegated to the error tolerant toxic2 parser. It always returns a + best effort AST (with `{:__error__, meta, %{}}` placeholder nodes on + unparsable parts) plus a diagnostic stream. The cursor position, when given, + is located via toxic2's `range:` metadata and marked with a `__cursor__` node + (see `ElixirSense.Core.Parser.Cursor`). """ alias ElixirSense.Core.Metadata alias ElixirSense.Core.MetadataBuilder - alias ElixirSense.Core.Normalized.Tokenizer + alias ElixirSense.Core.Parser.Cursor alias ElixirSense.Core.Source require Logger @@ -33,488 +39,279 @@ defmodule ElixirSense.Core.Parser do @spec parse_string(String.t(), boolean, boolean, {pos_integer, pos_integer} | nil) :: Metadata.t() - def parse_string(source, try_to_fix_parse_error, try_to_fix_line_not_found, cursor_position) do + def parse_string(source, _try_to_fix_parse_error, try_to_fix_line_not_found, cursor_position) do unless String.valid?(source) do raise ArgumentError, message: "invalid string passed to parse_string" end - string_to_ast_options = [ - errors_threshold: if(try_to_fix_parse_error, do: 6, else: 0), - cursor_position: cursor_position, - fallback_to_container_cursor_to_quoted: try_to_fix_parse_error - ] - - # source_with_cursor = inject_cursor(source, cursor_position) - source_with_cursor = source - - case string_to_ast(source_with_cursor, string_to_ast_options) do - {:ok, ast, modified_source, error} -> - acc = MetadataBuilder.build(ast, cursor_position) - - if cursor_position == nil or acc.cursor_env != nil or - Map.has_key?(acc.lines_to_env, elem(cursor_position, 0)) or - !try_to_fix_line_not_found do - create_metadata(source, {:ok, acc, error}) - else - case try_fix_line_not_found_by_inserting_marker(modified_source, cursor_position) do - {:ok, acc} -> - create_metadata(source, {:ok, acc, error || {:error, :env_not_found}}) - - _ -> - create_metadata(source, {:ok, acc, error || {:error, :env_not_found}}) - end - end + # parse a CLEAN tree (no `__cursor__` marker) so the metadata - calls, vars, + # references, lines_to_env - reflects the real code. The precise cursor + # environment is derived separately in `Metadata.get_cursor_env/3` (a marked + # parse), exactly so the marker never corrupts this metadata. + {ast, error} = tolerant_parse(source) + acc = MetadataBuilder.build(ast, cursor_position) + + if cursor_position == nil or acc.cursor_env != nil or + Map.has_key?(acc.lines_to_env, elem(cursor_position, 0)) or + !try_to_fix_line_not_found do + create_metadata(source, {:ok, acc, error}) + else + case try_fix_line_not_found_by_inserting_marker(source, cursor_position) do + {:ok, acc} -> + create_metadata(source, {:ok, acc, error || {:error, :env_not_found}}) - {:error, _reason} = error -> - create_metadata(source, error) + _ -> + create_metadata(source, {:ok, acc, error || {:error, :env_not_found}}) + end end end - @default_parser_options [columns: true, token_metadata: true, emit_warnings: false] + @doc """ + Error tolerant parse via toxic2. Returns `{:ok, ast, source, error}` where + `error` is `nil` or `{:error, :parse_error}`. With `errors_threshold: 0` + (strict) a parse error is returned as `{:error, :parse_error}` instead. + The returned AST is the clean recovered tree - no `__cursor__` marker is + injected even when a `:cursor_position` is given (the marker is only relevant + to environment building, see `parse_string/4`). This keeps the AST safe for + general consumers such as code transforms / `Macro.to_string/1`. + """ def string_to_ast(source, options \\ []) when is_binary(source) do errors_threshold = Keyword.get(options, :errors_threshold, 6) - cursor_position = Keyword.get(options, :cursor_position) + parse_opts = [parser_options: Keyword.get(options, :parser_options, [])] - parser_options = - Keyword.get(options, :parser_options, []) - |> Keyword.merge(@default_parser_options) - - fallback_to_container_cursor_to_quoted = - Keyword.get(options, :fallback_to_container_cursor_to_quoted, true) - - do_string_to_ast( - source, - errors_threshold, - fallback_to_container_cursor_to_quoted, - cursor_position, - source, - nil, - parser_options - ) + case tolerant_parse(source, nil, parse_opts) do + {ast, nil} -> {:ok, ast, source, nil} + {ast, error} when errors_threshold > 0 -> {:ok, ast, source, error} + {_ast, error} -> error + end end - defp do_string_to_ast( - source, - errors_threshold, - fallback_to_container_cursor_to_quoted, - cursor_position, - original_source, - original_error, - parser_options - ) - when is_binary(source) do - case Code.string_to_quoted(source, parser_options) do - {:ok, ast} -> - {:ok, ast, source, original_error} - - error -> - error_to_report = original_error || {:error, :parse_error} - # dbg(error) + @doc false + # Parse `source` with the cursor marked at `cursor_position` and return the + # `{meta, env}` cursor environment (or `nil`). Used by + # `ElixirSense.Core.Metadata.get_cursor_env/3`. + # + # `preserve_token: true` keeps the identifier under the cursor (for + # navigation - definition/hover/references), otherwise a half-typed token is + # dropped (for completion). + def cursor_env(source, {_line, _column} = cursor_position, opts \\ []) do + {ast, _error} = tolerant_parse(source, cursor_position, opts) + MetadataBuilder.build(ast, cursor_position).cursor_env + end - modified_source = - if(errors_threshold > 0, - do: fix_parse_error(source, cursor_position, error), - else: error - ) + @doc """ + Public toxic2 parse for consumers that want the raw best-effort AST plus the + diagnostic stream, with `{:__error__, ...}` placeholder nodes neutralized so + `MetadataBuilder`/`Macro` traversal never crash. - if is_binary(modified_source) do - do_string_to_ast( - modified_source, - errors_threshold - 1, - fallback_to_container_cursor_to_quoted, - cursor_position, - original_source, - error_to_report, - parser_options - ) - else - case {fallback_to_container_cursor_to_quoted, cursor_position} do - {true, {cursor_line, cursor_column}} -> - prefix = Source.text_before(original_source, cursor_line, cursor_column) + `keep_range: true` preserves the `range:` metadata toxic2 attaches to every + source node (needed by range-aware consumers such as the language server + parser and the document-symbol / folding / selection-range providers); the + default strips it to keep the classic `line:`/`column:` shape used by metadata + building. No `__cursor__` marker is injected - this is a clean recovered tree. - case Code.Fragment.container_cursor_to_quoted(prefix, parser_options) do - {:ok, ast} -> - {:ok, ast, prefix, error_to_report} + Returns `{ast, diagnostics}` (the toxic2 diagnostic stream, unmodified). + """ + def parse_to_neutralized_ast(source, opts \\ []) when is_binary(source) do + keep_range = Keyword.get(opts, :keep_range, false) - _ -> - error_to_report - end + parser_options = + opts + |> Keyword.take([:token_metadata, :range, :existing_atoms_only, :literal_encoder]) + |> Keyword.put_new(:token_metadata, true) - _ -> - error_to_report - end - end - end + {ast, diagnostics} = Toxic2.parse_to_ast(source, parser_options) + {neutralize_errors(ast, diagnostics, keep_range), diagnostics} end - def try_fix_line_not_found_by_inserting_marker( - modified_source, - {cursor_line_number, _} = cursor_position - ) - when is_integer(cursor_line_number) do - with {:ok, ast, _modified_source, _error} <- - modified_source - |> fix_line_not_found(cursor_line_number) - |> do_string_to_ast( - 0, - false, - cursor_position, - modified_source, - nil, - @default_parser_options - ) do - acc = MetadataBuilder.build(ast) - - if Map.has_key?(acc.lines_to_env, cursor_line_number) do - {:ok, acc} - else - :not_found + @doc false + # Parse with toxic2 and place the cursor marker. Returns `{ast, error}`. + def tolerant_parse(source, cursor_position \\ nil, opts \\ []) do + cursor = + case cursor_position do + {line, column} when is_integer(line) and is_integer(column) -> cursor_position + _ -> nil end - end - end - defp create_metadata(source, {:error, error}) do - %Metadata{ - source: source, - error: error - } - end + # honor caller-supplied parser options that toxic2 understands (e.g. + # `:existing_atoms_only`, `:literal_encoder`); the rest of the classic + # `Code.string_to_quoted` options have no toxic2 equivalent and are dropped, + # as toxic2 itself ignores them. token_metadata/range are managed here. + extra_parser_options = + opts + |> Keyword.get(:parser_options, []) + |> Keyword.take([:existing_atoms_only, :literal_encoder]) - defp create_metadata(source, {:ok, acc, error}) do - %Metadata{ - source: source, - error: error, - types: acc.types, - specs: acc.specs, - structs: acc.structs, - records: acc.records, - uses: acc.uses, - mods_funs_to_positions: acc.mods_funs_to_positions, - cursor_env: acc.cursor_env, - closest_env: acc.closest_env, - lines_to_env: acc.lines_to_env, - vars_info_per_scope_id: acc.vars_info_per_scope_id, - calls: acc.calls, - first_alias_positions: acc.first_alias_positions, - moduledoc_positions: acc.moduledoc_positions - } - end + parser_options = + if cursor, do: [token_metadata: true, range: true], else: [token_metadata: true] - defp fix_parse_error( - source, - _cursor_position, - {:error, {meta, {"\"" <> <<_::bytes-size(1)>> <> "\" is missing terminator" <> _, _}, _}} - ) do - line = get_line_from_meta(meta) + parser_options = Keyword.merge(parser_options, extra_parser_options) - source - |> replace_line_with_marker(line) - end + {ast, diagnostics} = Toxic2.parse_to_ast(source, parser_options) - defp fix_parse_error( - source, - _cursor_position, - {:error, {meta, {message, _}, "do"}} - ) - when message in ["unexpected reserved word: "] do - line_number = get_line_from_meta(meta) + ast = + if cursor do + ast |> position_error_nodes(diagnostics) |> Cursor.mark(cursor, opts) + else + ast + end - source - |> Source.split_lines() - |> List.update_at(line_number - 1, fn line -> - # try to replace token do with do: marker - line - |> String.replace("do", "do: " <> marker(), global: false) - end) - |> Enum.join("\n") - end + ast = neutralize_errors(ast, diagnostics) - defp fix_parse_error( - source, - {cursor_line_number, _}, - {:error, {meta, "unexpected reserved word: ", "end"}} - ) - when is_integer(cursor_line_number) do - # try to insert closing before end - end_line = Keyword.fetch!(meta, :end_line) - - closing_delimiter = - case Keyword.fetch!(meta, :opening_delimiter) do - :"<<" -> ">>" - :"{" -> "}" - :"[" -> "]" - :"(" -> ")" - end + error = if Enum.any?(diagnostics, &(elem(&1, 2) == :error)), do: {:error, :parse_error} - source - |> Source.split_lines() - |> List.insert_at(end_line - 1, closing_delimiter) - |> Enum.join("\n") + {ast, error} end - defp fix_parse_error( - source, - {cursor_line_number, _}, - {:error, {meta, {message, text}, token}} - ) - when is_integer(cursor_line_number) and - message in ["unexpected token: ", "unexpected reserved word: "] do - line_number = get_line_from_meta(meta) - - terminator = - case Regex.run(~r/terminator\s\"([^\s\"]+)/u, text) do - [_, terminator] -> terminator - nil -> nil - end - - if terminator != nil do - source - |> Source.split_lines() - |> List.update_at(cursor_line_number - 1, fn line -> - if cursor_line_number != line_number do - # try to close the line with missing terminator - line <> " " <> terminator - else - # try to prepend first occurrence of unexpected token with missing terminator - line - |> String.replace(token, terminator <> " " <> token, global: false) - end - end) - |> Enum.join("\n") - else - source - |> Source.split_lines() - |> List.update_at(line_number - 1, fn line -> - # drop unexpected token - line - |> String.replace(token, "", global: false) - end) - |> Enum.join("\n") - end + # Attach a source range to each `{:__error__, meta, %{diag_ids: ids}}` node + # from its diagnostic span (toxic2 error nodes carry no position of their own). + # This lets the cursor walk locate a hole even when it is nested where the + # surrounding node's range does not reach (e.g. `@type x :: [`). + defp position_error_nodes(ast, diagnostics) do + by_id = Map.new(diagnostics, fn diagnostic -> {elem(diagnostic, 0), diagnostic} end) + position_error_nodes(ast, by_id, :walk) end - defp fix_parse_error( - source, - _cursor_position, - {:error, {meta, "unexpected token: ", terminator}} - ) - when terminator in [")", "]", "}", ">>"] do - case Keyword.get(meta, :opening_delimiter) do - :fn -> - end_line = Keyword.fetch!(meta, :end_line) - - source - |> Source.split_lines() - |> List.update_at(end_line - 1, fn line -> - # try to prepend unexpected terminator with end - line - |> String.replace(terminator, " end" <> terminator, global: false) - end) - |> Enum.join("\n") + defp position_error_nodes({:__error__, meta, %{diag_ids: diag_ids} = args}, by_id, :walk) do + case Map.get(by_id, List.first(diag_ids || [])) do + {_id, _phase, _severity, _code, sl, sc, el, ec, _details} -> + {:__error__, [range: {{sl, sc}, {el, ec}}, line: sl, column: sc] ++ meta, args} _ -> - source + {:__error__, meta, args} end end - defp fix_parse_error( - source, - _cursor_position, - {:error, {meta, "syntax" <> _, terminator_quoted}} - ) - when terminator_quoted in ["'end'", "')'", "']'", "'}'", "'>>'"] do - line_number = get_line_from_meta(meta) - terminator = Regex.replace(~r/[\"\']/u, terminator_quoted, "") - - source - |> Source.split_lines() - |> List.update_at(line_number - 1, fn line -> - # try to prepend unexpected terminator with marker - line - |> String.replace(terminator, marker() <> " " <> terminator, global: false) - end) - |> Enum.join("\n") + defp position_error_nodes({form, meta, args}, by_id, :walk), + do: {position_error_nodes(form, by_id, :walk), meta, position_error_nodes(args, by_id, :walk)} + + defp position_error_nodes({left, right}, by_id, :walk), + do: {position_error_nodes(left, by_id, :walk), position_error_nodes(right, by_id, :walk)} + + defp position_error_nodes(list, by_id, :walk) when is_list(list), + do: Enum.map(list, &position_error_nodes(&1, by_id, :walk)) + + defp position_error_nodes(other, _by_id, :walk), do: other + + # toxic2 `{:__error__, meta, %{diag_ids: ids}}` nodes are placeholders for + # "expected more input here". They are recovery artifacts, never real AST, so + # we drop them from any list (argument lists, containers, statement blocks) - + # keeping them would inflate call/definition arities. A cursor that needs to + # live in such a hole has already been substituted by `Cursor.mark/3` before + # this runs. Any stray error node not in a list is rewritten to a harmless + # `{:__error__, meta, []}` (its map args crash `Macro` traversal and the + # compiler). Range metadata, only needed transiently for cursor marking, is + # stripped here too so the AST keeps the usual `line:`/`column:` shape. + # `keep_range` (default false) controls whether the `range:` meta survives - + # metadata building wants it stripped (classic shape), range-aware consumers + # want it kept (see `parse_to_neutralized_ast/2`). + def neutralize_errors(ast, diagnostics, keep_range \\ false) do + by_id = Map.new(diagnostics, fn diagnostic -> {elem(diagnostic, 0), diagnostic} end) + neutralize(ast, by_id, keep_range) end - defp fix_parse_error( - source, - _cursor_position, - {:error, {meta, "unexpected expression after keyword list" <> _, token}} - ) do - line_number = get_line_from_meta(meta) - token = Regex.replace(~r/[\"\']/u, token, "") + defp neutralize({:__error__, meta, args}, _by_id, keep_range) when not is_list(args), + do: {:__error__, strip_range(meta, keep_range), []} - source - |> Source.split_lines() - |> List.update_at(line_number - 1, fn line -> - # drop unexpected token - line - |> String.replace(token, "", global: false) - end) - |> Enum.join("\n") + defp neutralize({form, meta, args}, by_id, keep_range) when is_list(args) do + args = if call_form?(form), do: clean_call_args(args, by_id), else: args + + {neutralize(form, by_id, keep_range), strip_range(meta, keep_range), + Enum.map(args, &neutralize(&1, by_id, keep_range))} end - defp fix_parse_error(source, _cursor_position, {:error, {meta, "syntax" <> _, token}}) do - line = get_line_from_meta(meta) - - case source - |> Source.split_lines() - |> Enum.at(line - 1, "") - |> Tokenizer.tokenize() - |> Enum.at(-1) do - {:identifier, _, ident} when ident in [:with, :for] -> - # strip_before(source, line, token |> String.replace("'", "")) - source - |> Source.split_lines() - |> List.replace_at(line - 1, "#{ident} \\") - |> Enum.join("\n") + defp neutralize({form, meta, args}, by_id, keep_range), + do: + {neutralize(form, by_id, keep_range), strip_range(meta, keep_range), + neutralize(args, by_id, keep_range)} - _ -> - if Regex.match?(~r/^[\p{L}_][\p{L}\p{N}_@]*[?!]?$/u, token) do - remove_line(source, line) - else - replace_line_with_marker(source, line) - end - end - end + defp neutralize({left, right}, by_id, keep_range), + do: {neutralize(left, by_id, keep_range), neutralize(right, by_id, keep_range)} - defp fix_parse_error( - source, - _cursor_position, - {:error, {meta, "unexpected operator" <> _, _token}} - ) do - line = get_line_from_meta(meta) - remove_line(source, line) + defp neutralize(list, by_id, keep_range) when is_list(list), + do: Enum.map(list, &neutralize(&1, by_id, keep_range)) + + defp neutralize(other, _by_id, _keep_range), do: other + + # Clean a call's argument list of error artifacts so its arity matches user + # intent: always drop the "missing closing delimiter" sentinel; if the only + # remaining args are error placeholders (an empty `foo(`) drop them too so the + # arity is 0. A nascent argument after real ones (a trailing comma, `foo(a,`) + # is kept so the arity reflects the slot being typed. + defp clean_call_args(args, by_id) do + args = Enum.reject(args, &missing_close_error?(&1, by_id)) + if args != [] and Enum.all?(args, &error_node?/1), do: [], else: args end - defp fix_parse_error(_, nil, error) do - error + defp missing_close_error?({:__error__, _meta, %{diag_ids: diag_ids}}, by_id) do + case Map.get(by_id, List.first(diag_ids || [])) do + {_id, _phase, _severity, :expected_comma_or_close, _sl, _sc, _el, _ec, _details} -> true + _ -> false + end end - defp fix_parse_error( - source, - {cursor_line_number, _}, - {:error, {meta, text = "missing terminator: " <> _, _}} - ) - when is_integer(cursor_line_number) do - line_end = get_line_from_meta(meta) - - terminator = - case Regex.run(~r/terminator:\s([^\s]+)/u, text) do - [_, terminator] -> terminator - end + defp missing_close_error?(_node, _by_id), do: false - line_start = - case Regex.run(~r/line\s(\d+)/u, text) do - [_, line] -> line |> String.to_integer() - nil -> 1 - end + defp error_node?({:__error__, _meta, _args}), do: true + defp error_node?(_node), do: false - if terminator in ["\"", "'", ")", "]", "}", ">>"] do - source - |> Source.split_lines() - |> List.update_at(max(cursor_line_number, line_start) - 1, fn line -> - # try to close line with terminator - line <> terminator - end) - |> Enum.join("\n") - else - compare_mode = - case terminator do - "\"\"\"" -> :lt - _ -> :eq - end + defp call_form?({:., _meta, _args}), do: true - line_indentations = - source - |> Source.split_lines() - |> Enum.map(fn line -> - line = normalize_indentation(line) - {line, get_indentation_level(line)} - end) - - line_indentation_at_start = line_indentations |> Enum.at(line_start - 1) |> elem(1) - - {source, _, missing_end} = - line_indentations - |> Enum.reduce({[], 1, true}, fn {line, indentation}, - {source_acc, current_line, missing_end} -> - {modified_lines, missing_end} = - cond do - source_acc == [] -> - {[line | source_acc], true} - - current_line <= line_start -> - {[line | source_acc], true} - - missing_end and line != "" and - compare_indentation(compare_mode, indentation, line_indentation_at_start) and - current_line < line_end -> - [previous | rest] = source_acc - - replaced_line = - case terminator do - "end" -> previous <> "; " <> marker() <> "; end" - _ -> previous <> " " <> terminator - end - - {[line, replaced_line | rest], false} - - true -> - {[line | source_acc], missing_end} - end - - {modified_lines, current_line + 1, missing_end} - end) - - if missing_end do - [terminator | source] - else - source - end - |> Enum.reverse() - |> Enum.join("\n") - end - end + defp call_form?(form) when is_atom(form), + do: + form not in [:__block__, :__aliases__, :__cursor__, :%{}, :{}, :<<>>, :%, :^] and + not Macro.operator?(form, 1) and not Macro.operator?(form, 2) - defp fix_parse_error( - source, - {cursor_line_number, _}, - {:error, {_meta, text = "missing interpolation terminator: \"}\"" <> _, _}} - ) - when is_integer(cursor_line_number) do - line_start = - case Regex.run(~r/line\s(\d+)/u, text) do - [_, line] -> line |> String.to_integer() - end + defp call_form?(_form), do: false - source - |> Source.split_lines() - |> List.update_at(max(cursor_line_number, line_start) - 1, fn line -> - # try to close line with terminator - line <> "}" - end) - |> Enum.join("\n") - end + defp strip_range(meta, true), do: meta + defp strip_range(meta, _keep_range) when is_list(meta), do: Keyword.delete(meta, :range) + defp strip_range(meta, _keep_range), do: meta - defp fix_parse_error(source, {cursor_line_number, _}, _error) - when is_integer(cursor_line_number) do - source - |> replace_line_with_marker(cursor_line_number) + def try_fix_line_not_found_by_inserting_marker( + source, + {cursor_line_number, _} = cursor_position + ) + when is_integer(cursor_line_number) do + # last resort when the cursor lands on a line with no environment: append a + # textual marker to that line so a `__cursor__` node is produced there + modified_source = fix_line_not_found(source, cursor_line_number) + {ast, _error} = tolerant_parse(modified_source) + acc = MetadataBuilder.build(ast, cursor_position) + + if acc.cursor_env != nil or Map.has_key?(acc.lines_to_env, cursor_line_number) do + {:ok, acc} + else + :not_found + end end - defp compare_indentation(:eq, left, right), do: left == right - defp compare_indentation(:lt, left, right), do: left < right - - defp normalize_indentation(line) do - line - |> String.replace_leading("\t", " ") + defp create_metadata(source, {:error, error}) do + %Metadata{ + source: source, + error: error + } end - def get_indentation_level(line) do - trimmed_line = String.trim_leading(line) - String.length(line) - String.length(trimmed_line) + defp create_metadata(source, {:ok, acc, error}) do + %Metadata{ + source: source, + error: error, + types: acc.types, + specs: acc.specs, + structs: acc.structs, + records: acc.records, + uses: acc.uses, + mods_funs_to_positions: acc.mods_funs_to_positions, + cursor_env: acc.cursor_env, + closest_env: acc.closest_env, + lines_to_env: acc.lines_to_env, + vars_info_per_scope_id: acc.vars_info_per_scope_id, + calls: acc.calls, + first_alias_positions: acc.first_alias_positions, + moduledoc_positions: acc.moduledoc_positions + } end defp fix_line_not_found(source, line_number) when is_integer(line_number) do @@ -526,24 +323,5 @@ defmodule ElixirSense.Core.Parser do |> Enum.join("\n") end - defp replace_line_with_marker(source, line_number) when is_integer(line_number) do - # IO.puts :stderr, "REPLACING LINE: #{line}" - source - |> Source.split_lines() - |> List.replace_at(line_number - 1, marker()) - |> Enum.join("\n") - end - - defp remove_line(source, line_number) when is_integer(line_number) do - # IO.puts :stderr, "REMOVING LINE: #{line}" - source - |> Source.split_lines() - |> List.delete_at(line_number - 1) - |> Enum.join("\n") - end - defp marker, do: "(__cursor__())" - - defp get_line_from_meta(meta) when is_integer(meta), do: meta - defp get_line_from_meta(meta), do: Keyword.fetch!(meta, :line) end diff --git a/lib/elixir_sense/core/parser/cursor.ex b/lib/elixir_sense/core/parser/cursor.ex new file mode 100644 index 00000000..695c57d5 --- /dev/null +++ b/lib/elixir_sense/core/parser/cursor.ex @@ -0,0 +1,295 @@ +defmodule ElixirSense.Core.Parser.Cursor do + @moduledoc """ + Places a `{:__cursor__, meta, args}` marker into a toxic2 AST based on the + cursor position, using the `range:` metadata toxic2 attaches to every node. + + This replaces the previous approach of splicing a `__cursor__()` token into + the source text and re-parsing. We find the deepest node whose source range + contains the cursor and then either: + + * wrap an expression node — `{:__cursor__, meta, [node]}` — when the cursor + sits on the node itself (a partial identifier, a call, an operator), or + * insert an empty `{:__cursor__, meta, []}` marker into a *hole* — the body + of a `do` block or the argument list of a call — when the cursor is in + whitespace inside a container. Wrapping the container instead would + capture the environment *outside* it (e.g. `module: nil` rather than the + enclosing module), so holes must be entered, not wrapped. + + `ElixirSense.Core.Compiler` already understands both `__cursor__` shapes in + every relevant position (function head, `@`, `defmodule`, `with`, macro + args), so no compiler changes are needed here. + """ + + @doc """ + Returns `ast` with a single `__cursor__` marker placed at `cursor`, or + unchanged when the cursor falls outside every ranged node. + + Options: + + * `:preserve_token` (default `false`) - when the cursor lands on a bare + identifier/alias, keep it (wrap as `__cursor__(token)`) instead of + dropping it. Navigation (definition/hover/references) sets this so the + symbol under the cursor stays in the tree; completion leaves it false so + a half-typed token is dropped. + """ + @spec mark(Macro.t(), {pos_integer, pos_integer}, keyword()) :: Macro.t() + def mark(ast, {_line, _column} = cursor, opts \\ []) do + preserve? = Keyword.get(opts, :preserve_token, false) + + case walk(ast, cursor, preserve?) do + {ast, true} -> ast + # the cursor is past every ranged node (typically trailing whitespace or a + # new line after a complete expression) - it is a new statement at the top + # level + {ast, false} -> append_to_root(ast, cursor) + end + end + + defp append_to_root({:__block__, meta, statements}, cursor), + do: {:__block__, meta, insert_statement(statements, cursor)} + + defp append_to_root(nil, cursor), do: {:__block__, [], [marker(cursor)]} + + defp append_to_root(expression, cursor), + do: {:__block__, [], insert_statement([expression], cursor)} + + # Depth-first, deepest match wins. Returns {new_node, found?}; only one node + # is ever marked (the first/deepest found short-circuits the rest). + + # An error placeholder (positioned from its diagnostic by the caller) is + # exactly a "more input expected here" hole - if the cursor sits in it, that + # is where the cursor belongs. + defp walk({:__error__, meta, _args} = node, cursor, _preserve?) do + if contains?(meta, cursor), do: {marker(cursor), true}, else: {node, false} + end + + defp walk({form, meta, args} = node, cursor, preserve?) when is_list(meta) do + case walk(form, cursor, preserve?) do + {form, true} -> + {{form, meta, args}, true} + + {_form, false} -> + case walk(args, cursor, preserve?) do + {args, true} -> + {{form, meta, args}, true} + + {_args, false} -> + if contains?(meta, cursor) do + {place(node, cursor, preserve?), true} + else + {node, false} + end + end + end + end + + defp walk({left, right}, cursor, preserve?) do + case walk(left, cursor, preserve?) do + {left, true} -> + {{left, right}, true} + + {left, false} -> + with {right, found} <- walk(right, cursor, preserve?), do: {{left, right}, found} + end + end + + defp walk(list, cursor, preserve?) when is_list(list), do: walk_list(list, cursor, preserve?) + + defp walk(other, _cursor, _preserve?), do: {other, false} + + defp walk_list([], _cursor, _preserve?), do: {[], false} + + defp walk_list([head | tail], cursor, preserve?) do + case walk(head, cursor, preserve?) do + {head, true} -> + {[head | tail], true} + + {head, false} -> + with {tail, found} <- walk_list(tail, cursor, preserve?), do: {[head | tail], found} + end + end + + # Decide how to place the marker at the deepest containing node. Error-node + # holes are already handled in `walk/3`; here the cursor is in a structural + # hole (an empty do/clause body) or on the node's own token. + defp place({_form, _meta, _args} = node, cursor, preserve?) do + cond do + do_body?(node, cursor) -> insert_into_do(node, cursor, preserve?) + # the keyword form `foo(...), do:` (no `do`/`end`) with the cursor in the + # (empty) body value + has_do_keyword?(node) -> insert_into_do(node, cursor, preserve?) + stab_body?(node, cursor) -> insert_into_stab(node, cursor) + true -> wrap(node, cursor, preserve?) + end + end + + # only the keyword form (`foo(...), do:`) - a `do`/`end` block carries `:do` in + # its meta and is handled by `do_body?/2` (which respects the cursor position + # relative to `do`) + defp has_do_keyword?({_form, meta, args}) when is_list(args) do + not Keyword.has_key?(meta, :do) and + case List.last(args) do + keyword when is_list(keyword) -> + Keyword.keyword?(keyword) and Keyword.has_key?(keyword, :do) + + _ -> + false + end + end + + defp has_do_keyword?(_node), do: false + + # cursor sits at or after the `->` of a stab clause - it belongs in the clause + # body, where the clause's pattern variables are in scope + defp stab_body?({:->, meta, [_args, _body]}, cursor) do + case meta_point(meta) do + {line, column} -> before_or_at?({line, column}, cursor) + _ -> false + end + end + + defp stab_body?(_node, _cursor), do: false + + defp insert_into_stab({:->, meta, [args, body]}, cursor), + do: {:->, meta, [args, insert_into_block(body, cursor)]} + + # A bare identifier / atom / alias at the cursor is a partial token being + # typed. Like `Code.Fragment.container_cursor_to_quoted/2` we drop it and put + # the cursor in its place (so e.g. `def foo|` yields `{:__unknown__, 0}` rather + # than `{:foo, 0}`). Larger expressions are wrapped so their calls/structure + # are still recorded. + defp wrap(node, cursor, preserve?) do + if partial_token?(node) and not preserve? do + marker(cursor) + else + {:__cursor__, cursor_meta(cursor), [node]} + end + end + + defp partial_token?({name, _meta, nil}) when is_atom(name), do: true + defp partial_token?({:__aliases__, _meta, _segments}), do: true + defp partial_token?(_node), do: false + + # cursor sits at or after the `do` keyword of a do-block bearing node + defp do_body?({_form, meta, _args}, cursor) do + case Keyword.get(meta, :do) do + [line: line, column: column] -> before_or_at?({line, column}, cursor) + _ -> false + end + end + + defp insert_into_do({form, meta, args} = node, cursor, preserve?) do + case List.pop_at(args, -1) do + {keyword, rest} when is_list(keyword) -> + if Keyword.has_key?(keyword, :do) do + keyword = Keyword.update!(keyword, :do, &insert_into_block(&1, cursor)) + {form, meta, rest ++ [keyword]} + else + wrap(node, cursor, preserve?) + end + + _ -> + wrap(node, cursor, preserve?) + end + end + + defp insert_into_block({:__block__, meta, statements}, cursor), + do: {:__block__, meta, insert_statement(statements, cursor)} + + defp insert_into_block(nil, cursor), do: {:__block__, [], [marker(cursor)]} + + defp insert_into_block(single, cursor), do: {:__block__, [], insert_statement([single], cursor)} + + @def_kinds [:def, :defp, :defmacro, :defmacrop, :defguard, :defguardp] + + defp insert_statement(statements, cursor) do + {before, rest} = Enum.split_while(statements, &before_cursor?(&1, cursor)) + + case List.last(before) do + # the cursor directly follows a bodyless `def`/`defp`/... head - it is the + # body being started, so attach it there (yields the enclosing function + # scope rather than the module scope) + {kind, meta, [head]} when kind in @def_kinds and is_tuple(head) -> + List.replace_at(before, -1, {kind, meta, [head, [do: marker(cursor)]]}) ++ rest + + _ -> + before ++ [marker(cursor)] ++ rest + end + end + + defp marker(cursor), do: {:__cursor__, cursor_meta(cursor), []} + + defp cursor_meta({line, column}), do: [line: line, column: column] + + # a statement/argument lies before the cursor when its source ends at or + # before the cursor position + defp before_cursor?(node, cursor) do + case node_end(node) do + nil -> true + end_position -> before_or_at?(end_position, cursor) + end + end + + defp node_end({_form, meta, _args}) when is_list(meta) do + case Keyword.get(meta, :range) do + {_start, end_position} -> end_position + _ -> meta_point(meta) + end + end + + defp node_end(_node), do: nil + + defp meta_point(meta) do + case {Keyword.get(meta, :line), Keyword.get(meta, :column)} do + {line, column} when is_integer(line) and is_integer(column) -> {line, column} + _ -> nil + end + end + + defp contains?(meta, cursor) do + cond do + range_contains?(meta, cursor) -> true + # an unterminated do-block (a `do` with no matching `end`) extends to the + # end of the input, so it owns any cursor at or after its `do` + unterminated_do?(meta) and after_do?(meta, cursor) -> true + true -> false + end + end + + defp range_contains?(meta, cursor) do + case Keyword.get(meta, :range) do + {start_position, end_position} -> + if before_or_at?(end_position, start_position) do + # toxic2 gives incomplete nodes an inverted/zero-width range (the end + # token is missing) - treat them as extending to the cursor + before_or_at?(start_position, cursor) + else + # The end check is intentionally inclusive even though toxic2 ranges are + # end-exclusive. A completion/nav cursor sits one column past the last + # character of the token being typed (`Enum.ma|` places the cursor at the + # exclusive end of the `ma` token's range), so cursor == end_position must + # still resolve to that token - it owns the insertion point. The same + # applies to a cursor at the trailing edge of an incomplete construct at + # end-of-input. Exclusive-end here regresses completion and cursor-in- + # malformed-do placement (see parser_test / completion suites). + before_or_at?(start_position, cursor) and before_or_at?(cursor, end_position) + end + + _ -> + false + end + end + + defp unterminated_do?(meta), + do: Keyword.has_key?(meta, :do) and not Keyword.has_key?(meta, :end) + + defp after_do?(meta, cursor) do + case Keyword.get(meta, :do) do + [line: line, column: column] -> before_or_at?({line, column}, cursor) + _ -> false + end + end + + defp before_or_at?({line1, column1}, {line2, column2}), + do: line1 < line2 or (line1 == line2 and column1 <= column2) +end diff --git a/lib/elixir_sense/core/source.ex b/lib/elixir_sense/core/source.ex index b8541492..8cb039d9 100644 --- a/lib/elixir_sense/core/source.ex +++ b/lib/elixir_sense/core/source.ex @@ -4,7 +4,6 @@ defmodule ElixirSense.Core.Source do """ alias ElixirSense.Core.Binding - alias ElixirSense.Core.Normalized.Tokenizer @line_break ["\n", "\r\n", "\r"] @empty_graphemes [" ", "\t"] ++ @line_break @@ -736,39 +735,40 @@ defmodule ElixirSense.Core.Source do def concat_module_parts([], _, _), do: :error def bitstring_options(prefix) do - tokens = Tokenizer.tokenize(prefix) + case Code.Fragment.container_cursor_to_quoted(prefix, columns: true) do + {:ok, quoted} -> + path = Macro.path(quoted, &match?({:__cursor__, _, []}, &1)) + + case path do + [cursor | tail] -> + case bitstring_remove_operators(tail, cursor) do + [{:"::", _, [_, _]}, {:<<>>, _, [_ | _]} | _] -> + # Cursor is in bitstring modifier position. + # Extract text after the last "::" on the last line of the prefix. + prefix + |> split_lines() + |> List.last("") + |> String.split("::") + |> List.last() + + _ -> + nil + end - case scan_bitstring(tokens, nil) do - nil -> - nil + _ -> + nil + end - {line, column, _} -> - prefix - |> split_lines - |> Enum.at(line - 1, "") - |> String.slice((column + 1)..-1//1) + {:error, _} -> + nil end end - defp scan_bitstring([], acc), do: acc - - defp scan_bitstring([{:type_op, candidate, :"::"}, {:identifier, _, _} | rest], _acc) do - scan_bitstring(rest, candidate) - end - - defp scan_bitstring([{:"<<", _} | _rest], acc) when not is_nil(acc) do - acc - end - - defp scan_bitstring([{:",", _} | _rest], acc) when not is_nil(acc) do - acc - end + # Strip binary `-` operator wrappers introduced when typing `big-uns` etc. + # Mirrors remove_operators/2 from elixir-ls completion_engine.ex (commit 98e983d). + defp bitstring_remove_operators([{op, _, [_, previous]} = head | tail], previous) + when op in [:-], + do: bitstring_remove_operators(tail, head) - defp scan_bitstring([{:">>", _} | _rest], _acc) do - nil - end - - defp scan_bitstring([_token | rest], acc) do - scan_bitstring(rest, acc) - end + defp bitstring_remove_operators(tail, _previous), do: tail end diff --git a/lib/elixir_sense/core/surround_context/toxic.ex b/lib/elixir_sense/core/surround_context/toxic.ex new file mode 100644 index 00000000..8d1a823a --- /dev/null +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -0,0 +1,588 @@ +defmodule ElixirSense.Core.SurroundContext.Toxic do + @moduledoc false + + # Toxic2 range-based "symbol under cursor" classifier for the NAVIGATION providers + # (definition / references / implementation / declaration / hover / call_hierarchy). + # + # It returns the SAME shape as `Code.Fragment.surround_context/2` + # (`:none | %{begin: {line, col}, end: {line, col}, context: context}`) so that + # `ElixirSense.Core.SurroundContext.to_binding/2`, `Metadata.get_cursor_env/3` and the + # locators themselves stay unchanged - only the call site flips to this module. + # + # The classification is derived from toxic2's `range:` node metadata (1-based, end-EXCLUSIVE, + # the same coordinate system as `Code.Fragment`). A handful of shapes carry no range/meta in the + # AST (bare `:atom` literals, `key:`/keyword keys), cannot be disambiguated from the tree (cursor + # left of a meta-less dot operand such as `:erlang.foo`), or diverge from `Code.Fragment` + # (uppercase multi-letter sigils, which it reports as `:alias`); for those we fall back to + # `Code.Fragment.surround_context/2`. The whole function is wrapped so it is total and never + # worse than the previous behavior. + # + # A few exotic shapes are classified MORE precisely than `Code.Fragment` (which is purely + # lexical) and so intentionally diverge from it: operator-name arity captures `&>=/2`/`&+/2` + # resolve to a navigable `:local_arity` (Code.Fragment reports a stray `:operator "/"`), + # `&(-&1)` keeps the unary `-`/`&1` classifications (Code.Fragment reports `:none`), and a bare + # var in an unspaced step range `a..b//c` stays a `:local_or_var`. These are kept because they + # drive better navigation than matching the lexical oracle would. + # + # NOTE: completion (`Code.Fragment.cursor_context` / `container_cursor_to_quoted`) is out of + # scope and stays on `Code.Fragment`. + + @spec surround_context(String.t(), {pos_integer, pos_integer}) :: :none | map() + def surround_context(source, position) when is_binary(source) do + classify_at(parse(source), source, position) + rescue + _ -> Code.Fragment.surround_context(source, position) + catch + _, _ -> Code.Fragment.surround_context(source, position) + end + + # Variant for callers that have already parsed `source` with the SAME options (`range:`, + # `token_metadata:`, the literal_encoder below) - e.g. `selection_ranges`, which parses once and + # reuses the AST across every cursor position instead of triggering a fresh parse per call. + @spec surround_context(Macro.t(), String.t(), {pos_integer, pos_integer}) :: :none | map() + def surround_context(ast, source, position) when is_binary(source) do + classify_at(ast, source, position) + rescue + _ -> Code.Fragment.surround_context(source, position) + catch + _, _ -> Code.Fragment.surround_context(source, position) + end + + defp classify_at(ast, source, {line, column} = position) do + case deepest_at(ast, {line, column}) do + {node, parent} -> + case classify(node, parent, {line, column}) do + {context, begin_pos, end_pos} -> %{context: context, begin: begin_pos, end: end_pos} + :fallback -> Code.Fragment.surround_context(source, position) + end + + nil -> + Code.Fragment.surround_context(source, position) + end + end + + # `literal_encoder` wraps literals (notably bare `:atom`s) in `{:__block__, meta, [literal]}` so + # they carry a `range:` and can be classified from the parse tree instead of falling back to the + # lexical Code.Fragment. (Alias segments, struct types and attribute names are NOT literals and + # stay unwrapped.) + defp parse(source) do + {ast, _diagnostics} = + Toxic2.parse_to_ast(source, + token_metadata: true, + range: true, + literal_encoder: fn literal, meta -> {:ok, {:__block__, meta, [literal]}} end + ) + + ast + end + + # --- deepest ranged node containing the cursor (with its structural parent) -------------- + + # Returns `{node, parent}` for the deepest AST node whose `range:` contains the cursor + # (end-exclusive), or `nil`. `parent` is the immediately enclosing AST node (or `nil`), used to + # disambiguate name/alias leaves (`@attr`, `foo/1`, `%Struct{}`). + defp deepest_at(ast, cursor), do: descend(ast, cursor, nil) + + # An `__aliases__` is a single alias unit (`Foo.Bar`, `__MODULE__.Foo`, `@attr.Foo`). Code.Fragment + # classifies the whole thing, so do NOT descend into its segments - otherwise a ranged head + # segment (`__MODULE__`/`@attr`) would be mistaken for a standalone var/attribute and lose the + # post-dot qualifier. (All-atom segments carry no range, so only exotic heads would be descended + # into; stopping here makes the alias clause - which falls back for exotic heads - reachable.) + defp descend({:__aliases__, meta, _segs} = node, cursor, parent) do + case node_range(meta) do + nil -> nil + range -> if contains?(range, cursor), do: {node, parent}, else: nil + end + end + + defp descend({_form, meta, _args} = node, cursor, parent) do + case node_range(meta) do + nil -> + # No range of its own (e.g. the `{:., _, _}` dot operator node) - still descend so we can + # reach ranged children. + descend_children(node, cursor, node) + + range -> + if contains?(range, cursor) do + descend_children(node, cursor, node) || {node, parent} + else + nil + end + end + end + + defp descend({_left, _right} = node, cursor, _parent) do + # 2-tuple (keyword pair, `{key, value}`) - structural, no range of its own. + descend_children(node, cursor, node) + end + + defp descend(list, cursor, parent) when is_list(list) do + # a list node (a `do:`/keyword-list argument) - descend into its elements so the cursor + # inside a `do` block or keyword arg is still located. + descend_first(list, cursor, parent) + end + + defp descend(_literal, _cursor, _parent), do: nil + + defp descend_children({form, _meta, args}, cursor, parent) do + descend_first([form | as_list(args)], cursor, parent) + end + + defp descend_children({left, right}, cursor, parent) do + descend_first([left, right], cursor, parent) + end + + defp descend_first(items, cursor, parent) do + Enum.find_value(items, fn item -> descend(item, cursor, parent) end) + end + + defp as_list(args) when is_list(args), do: args + defp as_list(_), do: [] + + # --- classification -------------------------------------------------------------------------- + + # Module attribute - the cursor is on the `@` node itself. Handles both `@attr` and forms with + # an argument (`@type t :: ...`, `@spec f(...)`, `@moduledoc "..."`). The reported span is + # `@`..(attribute-name end), matching Code.Fragment. + defp classify({:@, ameta, [{attr, attr_meta, _}]}, _parent, cursor) when is_atom(attr) do + attr_context(attr, attr_meta, ameta, cursor) + end + + # Module attribute - the cursor is on the attribute NAME (its parent is the `@` node). This is + # the deepest node for `@attr`, and also for `@type`/`@spec`/`@doc` whose name carries args. + # The attribute-name node is the only direct child of an `@` node, so matching the `@` parent is + # sufficient (an alias/value deeper inside the attribute has the name node as its parent, not @). + defp classify({name, nmeta, _}, {:@, ameta, _}, cursor) when is_atom(name) do + attr_context(name, nmeta, ameta, cursor) + end + + # Leaf var/name with a disambiguating parent. + defp classify({name, meta, nil} = node, parent, _cursor) when is_atom(name) do + range = node_range(meta) + + cond do + # `%var{}` / `%__MODULE__{}` - a var as a struct type. Code.Fragment reports `:struct` only at + # a statement-leading `%`, but `:local_or_var` for a mid-expression one (`x = %var{}`); that + # distinction is lexical (the AST is identical), so defer to Code.Fragment. + struct_type?(parent, node) -> + :fallback + + # `foo/1` - the name is the left operand of `{:/, _, [name, int]}`; report the NAME span only + # (Code.Fragment returns :none when the cursor is on `/` or the arity, which we never reach + # here because the name node's range does not cover those columns). + arity_left?(parent, node) -> + {{:local_arity, atom_charlist(name)}, range_begin(meta), range_end(meta)} + + range != nil -> + {var_context(name), range_begin(meta), range_end(meta)} + + true -> + :fallback + end + end + + # Alias - standalone or as a struct type. + defp classify({:__aliases__, meta, segments} = node, parent, _cursor) do + cond do + not all_atoms?(segments) -> + # exotic alias head (e.g. `__MODULE__.Foo`, `@attr.Foo`) - let Code.Fragment handle it + :fallback + + struct_type?(parent, node) -> + {:%, smeta, [^node, _map]} = parent + {{:struct, dotted_charlist(segments)}, range_begin(smeta), range_end(meta)} + + true -> + {{:alias, dotted_charlist(segments)}, range_begin(meta), range_end(meta)} + end + end + + # Struct when the cursor is on the `%` / between type and `{`. + defp classify({:%, meta, [type, _map]}, _parent, _cursor) do + case struct_type_context(type) do + nil -> :fallback + {context, type_end} -> {context, range_begin(meta), type_end} + end + end + + # Capture argument `&1`. + defp classify({:&, meta, [int]}, _parent, _cursor) when is_integer(int) do + {{:capture_arg, ~c"&" ++ Integer.to_charlist(int)}, range_begin(meta), range_end(meta)} + end + + # Dot call `left.fun` (and remote calls). Classify only when the cursor is on/after the `.`; + # a cursor left of the dot belongs to the left operand - if that operand had a range we would + # have descended into it, so reaching here with cursor-left means a meta-less operand (`:erlang`) + # and we fall back. + defp classify({{:., dmeta, [left, sym]}, cmeta, _args}, _parent, cursor) + when is_atom(sym) do + dot_line = Keyword.get(dmeta, :line) + dot_col = Keyword.get(dmeta, :column) + {cl, cc} = cursor + + cond do + # Synthetic dot calls toxic2 produces while lowering sugar - there is no real `.` in the + # source, so there is no remote-call symbol to navigate to. Let Code.Fragment classify these + # lexically. The markers: `from_brackets` (`x[y]` -> `Access.get`), `delimiter` (interpolated + # atom `:"a#{x}"` -> `:erlang.binary_to_atom`), `from_interpolation` (string interpolation + # `"a#{x}"` -> `Kernel.to_string`). + synthetic_dot?(cmeta) or synthetic_dot?(dmeta) -> + :fallback + + # multialias `Foo.{Bar, Baz}` - the `.{` is not a remote call symbol. The cursor on an inner + # alias lands on that alias node directly; on the `.`/`{` Code.Fragment reports :none. + sym == :{} -> + :fallback + + dot_line == nil or dot_col == nil -> + :fallback + + cl < dot_line or (cl == dot_line and cc < dot_col) -> + :fallback + + true -> + case inside_dot(left) do + nil -> + :fallback + + inside -> + sym_str = Atom.to_string(sym) + begin_pos = range_begin(cmeta) + # the end is the end of the function NAME. `cmeta` line/column point at the name start + # (not the dot), so this stays correct when the name is on a different line than the dot + # (`A.\n bar`); deriving it from `dmeta` would synthesize an impossible same-line end and + # break get_call_arity. + call_line = Keyword.get(cmeta, :line) + call_col = Keyword.get(cmeta, :column) + + if begin_pos && call_line && call_col do + fin = {call_line, call_col + String.length(sym_str)} + + if contains?({begin_pos, fin}, cursor), + do: {{:dot, inside, String.to_charlist(sym_str)}, begin_pos, fin}, + else: :fallback + else + :fallback + end + end + end + end + + # A literal wrapped by the literal_encoder. Three atom shapes are classified from the parse tree; + # everything else (quoted/operator atoms, multi-line) defers to Code.Fragment. The kinds are told + # apart by their single-line source width (no source re-read needed): + # * keyword key `key:` - `format: :keyword`; navigable span EXCLUDES the trailing colon + # (Code.Fragment returns :none when the cursor is on the colon), so width-of-name == len(atom) + # * bare `:atom` - range spans the leading colon, width == len(atom) + 1 -> :unquoted_atom + # * bare nil/true/false - no colon, width == len(atom) -> :keyword + defp classify({:__block__, meta, [atom]}, _parent, cursor) when is_atom(atom) do + str = Atom.to_string(atom) + name_len = String.length(str) + charlist = String.to_charlist(str) + + case node_range(meta) do + {{sl, sc}, {el, ec}} when sl == el -> + cond do + # keyword key (range covers `name:`); the key itself is `name` (drop the colon) + Keyword.get(meta, :format) == :keyword and ec - 1 - sc == name_len -> + {key_begin, key_end} = span = {{sl, sc}, {el, ec - 1}} + + if contains?(span, cursor), + do: {{:key, charlist}, key_begin, key_end}, + else: :fallback + + # `:atom` (leading colon) + identifier_first_char?(atom) and ec - sc == name_len + 1 -> + {{:unquoted_atom, charlist}, {sl, sc}, {el, ec}} + + # bare nil / true / false reserved words (no colon) + atom in [nil, true, false] and ec - sc == name_len -> + {{:keyword, charlist}, {sl, sc}, {el, ec}} + + true -> + :fallback + end + + _ -> + :fallback + end + end + + # Sigils, operators and local calls (all share the `{atom, meta, list}` shape). + defp classify({form, meta, args}, _parent, cursor) + when is_atom(form) and is_list(args) do + arity = length(args) + + cond do + # special forms (map/tuple/bitstring/block/anon-fn/stab) and the capture prefix `&` are not + # callable/navigable names; their interior (keys, `->`, captured operator) is classified + # lexically by Code.Fragment. (`&1`/`&2` capture args are handled by an earlier clause.) + form in [:%{}, :{}, :<<>>, :__block__, :fn, :->, :&] -> + :fallback + + # sigil `~r/.../` - Code.Fragment reports an uppercase multi-letter sigil name as an :alias, + # so fall back for those; single-char (any case) and lowercase names map cleanly to :sigil. + (letters = sigil_letters(form)) != nil -> + begin_pos = range_begin(meta) + + cond do + uppercase_multiletter?(letters) -> + :fallback + + begin_pos == nil -> + :fallback + + true -> + {bl, bc} = begin_pos + fin = {bl, bc + 1 + length(letters)} + + if contains?({begin_pos, fin}, cursor), + do: {{:sigil, letters}, begin_pos, fin}, + else: :fallback + end + + # arity notation `name/2` - cursor on the `/` or the arity is :none in Code.Fragment; + # the name itself is handled by the leaf-with-/-parent clause. So fall back here. + form == :/ and arity == 2 and arity_notation?(args) -> + :fallback + + # Word operators (`and`/`or`/`in`/`not`/`when`...) read like identifiers; Code.Fragment + # classifies them as :local_call or :operator depending on surrounding lexemes (e.g. `or (`). + # Both map to the same binding, but to match exactly we let Code.Fragment decide. + Macro.operator?(form, arity) and word_operator?(form) -> + :fallback + + Macro.operator?(form, arity) -> + op_line = Keyword.get(meta, :line) + op_col = Keyword.get(meta, :column) + # The step-range operator `a..b//c` lowers to a single ternary `:..//` node whose meta + # points at the `..`. Code.Fragment classifies it lexically as two separate operators - + # `..` (over the `..`) and `//` (over the `//`). Report `..` for the `..` columns and let + # the `//` columns fall back to Code.Fragment (the `..//` node's range does not reach them). + op_str = if form == :..//, do: "..", else: Atom.to_string(form) + + if op_line && op_col do + begin_pos = {op_line, op_col} + fin = {op_line, op_col + String.length(op_str)} + + if contains?({begin_pos, fin}, cursor), + do: {{:operator, String.to_charlist(op_str)}, begin_pos, fin}, + else: :fallback + else + :fallback + end + + # a parenthesized local call `foo(...)` - report the NAME span only. Toxic marks the parens + # with a `:closing` meta key. Without `:closing` the form is ambiguous: a complete `def foo` + # is a `:local_or_var`, but an incomplete `some(` (unclosed paren) is a `:local_call`. Toxic + # cannot tell those apart from meta, so we fall back to Code.Fragment's lexical analysis. + Keyword.has_key?(meta, :closing) -> + begin_pos = range_begin(meta) + + if begin_pos do + {bl, bc} = begin_pos + name_str = Atom.to_string(form) + fin = {bl, bc + String.length(name_str)} + + if contains?({begin_pos, fin}, cursor), + do: {{:local_call, String.to_charlist(name_str)}, begin_pos, fin}, + else: :fallback + else + :fallback + end + + true -> + :fallback + end + end + + defp classify(_node, _parent, _cursor), do: :fallback + + # --- inside_dot (left operand of a remote call) ---------------------------------------------- + + defp inside_dot({:__aliases__, _, segs}) do + if all_atoms?(segs), do: {:alias, dotted_charlist(segs)}, else: nil + end + + defp inside_dot({:@, _, [{attr, _, nil}]}) when is_atom(attr), + do: {:module_attribute, atom_charlist(attr)} + + defp inside_dot({name, _, nil}) when is_atom(name) do + if name == :__MODULE__, do: {:var, ~c"__MODULE__"}, else: {:var, atom_charlist(name)} + end + + # the literal_encoder wraps a bare-atom dot operand (`:erlang.foo`) - unwrap it (identifier atoms + # only; operator atoms like `:%{}` are not navigable) + defp inside_dot({:__block__, _, [atom]}) when is_atom(atom) do + if identifier_first_char?(atom), do: {:unquoted_atom, atom_charlist(atom)} + end + + defp inside_dot(atom) when is_atom(atom), do: {:unquoted_atom, atom_charlist(atom)} + + # A pure dot PATH `a.b.c` (no call args) - recurse. A dot CALL with args (`build(x).y`) makes the + # receiver an expression, which Code.Fragment reports as `:expr`; we return nil so the caller + # falls back. + defp inside_dot({{:., _, [left, sym]}, _, []}) when is_atom(sym) do + case inside_dot(left) do + nil -> nil + inside -> {:dot, inside, atom_charlist(sym)} + end + end + + defp inside_dot(_other), do: nil + + # --- struct type ---------------------------------------------------------------------------- + + defp struct_type_context({:__aliases__, meta, segs}) do + if all_atoms?(segs), do: {{:struct, dotted_charlist(segs)}, range_end(meta)}, else: nil + end + + defp struct_type_context({:@, meta, [{attr, _, nil}]}) when is_atom(attr), + do: {{:struct, {:module_attribute, atom_charlist(attr)}}, range_end(meta)} + + # var-type struct (`%var{}`) is left to Code.Fragment (see the leaf-clause note). + defp struct_type_context(_other), do: nil + + # --- small helpers -------------------------------------------------------------------------- + + defp var_context(:__MODULE__), do: {:local_or_var, ~c"__MODULE__"} + defp var_context(name), do: {:local_or_var, atom_charlist(name)} + + # Module-attribute span: `@`..(attribute-name end), i.e. {@line, @col + 1 + len(name)}. + # Reject non-identifier "names" (`@@` nests `@` nodes; recovery yields `:__error__`) - those are + # not real attributes, so let Code.Fragment decide (it returns :none). Also require the name to be + # contiguous with the `@` (starting exactly one column after it): a spaced `@ attr` is the unary + # `@` operator applied to a local var, which Code.Fragment classifies as `:local_or_var`. + defp attr_context(attr, name_meta, ameta, cursor) do + cond do + not identifier_atom?(attr) -> + :fallback + + not attr_contiguous?(name_meta, ameta) -> + :fallback + + true -> + attr_context_span(attr, ameta, cursor) + end + end + + defp attr_contiguous?(name_meta, ameta) do + case {range_begin(ameta), range_begin(name_meta)} do + {{al, ac}, {nl, nc}} -> nl == al and nc == ac + 1 + _ -> false + end + end + + defp attr_context_span(attr, ameta, cursor) do + case range_begin(ameta) do + {al, ac} -> + name_str = Atom.to_string(attr) + begin_pos = {al, ac} + fin = {al, ac + 1 + String.length(name_str)} + + if contains?({begin_pos, fin}, cursor), + do: {{:module_attribute, String.to_charlist(name_str)}, begin_pos, fin}, + else: :fallback + + _ -> + :fallback + end + end + + defp arity_left?({:/, _, [left, right]}, node), do: left == node and int_literal?(right) + defp arity_left?(_parent, _node), do: false + + # an atom Code.Fragment would treat as a navigable unquoted atom (`:foo`, `:Foo`, `:_x`) - i.e. it + # reads as an identifier; operator/special atoms (`:+`, `:%{}`, `:.`) are NOT. + defp identifier_first_char?(atom) do + case Atom.to_string(atom) do + <> -> c in ?a..?z or c in ?A..?Z or c == ?_ + _ -> false + end + end + + # an integer operand, raw or wrapped by the literal_encoder (`{:__block__, _, [int]}`) + defp int_literal?(int) when is_integer(int), do: true + defp int_literal?({:__block__, _, [int]}), do: is_integer(int) + defp int_literal?(_), do: false + + # `/` is ambiguous from the AST between arity notation (`foo/1`, `A.bar/1`, `&f/1`) and + # integer division (`x / 1`); both lower to `{:/, _, [left, int]}`. The distinction is lexical, so + # whenever the right operand is an integer we defer the `/` to Code.Fragment (which returns :none + # on an arity slash and `:operator` on a division slash). Covers local AND remote arity. + defp arity_notation?([_left, right]), do: int_literal?(right) + defp arity_notation?(_args), do: false + + # A dot node toxic2 synthesized while lowering sugar (no real `.` in the source). + defp synthetic_dot?(meta) when is_list(meta) do + Keyword.has_key?(meta, :from_brackets) or Keyword.has_key?(meta, :delimiter) or + Keyword.has_key?(meta, :from_interpolation) + end + + defp synthetic_dot?(_meta), do: false + + # An atom that reads like a normal identifier (attribute / variable name). + defp identifier_atom?(atom) do + case Atom.to_string(atom) do + "__error__" -> false + <> -> first in ?a..?z or first == ?_ + _ -> false + end + end + + # A "word operator" - one whose name reads like an identifier (`and`, `or`, `in`, `not`, `when`). + defp word_operator?(form) do + case Atom.to_string(form) do + <> -> first in ?a..?z + _ -> false + end + end + + defp struct_type?({:%, _, [type, _map]}, node), do: type == node + defp struct_type?(_parent, _node), do: false + + defp sigil_letters(atom) do + case Atom.to_string(atom) do + "sigil_" <> letters when letters != "" -> String.to_charlist(letters) + _ -> nil + end + end + + defp uppercase_multiletter?([first | rest]) when rest != [], do: first in ?A..?Z + defp uppercase_multiletter?(_letters), do: false + + defp all_atoms?(segs), do: is_list(segs) and Enum.all?(segs, &is_atom/1) + + defp dotted_charlist(segs) do + segs |> Enum.map_join(".", &Atom.to_string/1) |> String.to_charlist() + end + + defp atom_charlist(atom) when is_atom(atom), + do: atom |> Atom.to_string() |> String.to_charlist() + + defp node_range(meta) when is_list(meta), do: Keyword.get(meta, :range) + defp node_range(_meta), do: nil + + defp range_begin(meta) do + case node_range(meta) do + {begin_pos, _end_pos} -> begin_pos + _ -> nil + end + end + + defp range_end(meta) do + case node_range(meta) do + {_begin_pos, end_pos} -> end_pos + _ -> nil + end + end + + # cursor in [start, end) : start inclusive, end EXCLUSIVE (matches Code.Fragment :none at end). + defp contains?({{sl, sc}, {el, ec}}, {cl, cc}) do + after_or_at_start = cl > sl or (cl == sl and cc >= sc) + strictly_before_end = cl < el or (cl == el and cc < ec) + after_or_at_start and strictly_before_end + end + + defp contains?(_range, _cursor), do: false +end diff --git a/lib/elixir_sense/providers/completion/completion_engine.ex b/lib/elixir_sense/providers/completion/completion_engine.ex index 6796c289..8183e162 100644 --- a/lib/elixir_sense/providers/completion/completion_engine.ex +++ b/lib/elixir_sense/providers/completion/completion_engine.ex @@ -1,3 +1,13 @@ +# This code has originally been a part of https://github.com/elixir-lsp/elixir_sense + +# Copyright (c) 2017 Marlus Saraiva +# Permission is hereby granted, free of charge, to any person obtaining a copy of this +# software and associated documentation files (the 'Software'), to deal in the Software +# without restriction, including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +# to whom the Software is furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + # This file includes modified code extracted from the elixir project. Namely: # # https://github.com/elixir-lang/elixir/blob/v1.1/lib/iex/lib/iex/autocomplete.exs @@ -22,8 +32,20 @@ # with some changes inspired by Alchemist.Completer (itself based on IEx.Autocomplete). # Since then the codebases have diverged as the requirements # put on editor and REPL autocomplete are different. -# However some relevant changes have been merged back -# from upstream Elixir (1.13). +# Relevant correctness/feature changes from upstream Elixir have been merged +# back through v1.20: +# - cursor parsing is delegated to the host compiler's Code.Fragment +# (cursor_context/1, container_cursor_to_quoted/2), so token-level fixes +# from the running Elixir apply automatically (e.g. the 1.18+ +# :block_keyword_or_binary_operator and :capture_arg contexts) +# - struct expansion crash fixes (elixir-lang/elixir#14308 __MODULE__, +# #14150 runtime values) +# - OTP 28 nominal types +# IEx-only module-listing memory/prefix-filter optimizations (#15140, #15143) +# are intentionally NOT adopted: this engine fuzzy-matches module names +# (ElixirSense.Providers.Utils.Matcher) rather than prefix-matching, and caches module +# results in :persistent_term, so the upstream collection-time prefix filter +# does not apply. # Changes made to the original version include: # - different result format with added docs and spec # - built in and private funcs are not excluded @@ -48,8 +70,9 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do alias ElixirSense.Core.Introspection alias ElixirSense.Core.Metadata alias ElixirSense.Core.Normalized.Code, as: NormalizedCode - alias ElixirSense.Core.Source + alias ElixirSense.Core.Normalized.Macro.Env, as: NormalizedMacroEnv alias ElixirSense.Core.State + alias ElixirSense.Core.State.StructInfo alias ElixirSense.Core.Struct alias ElixirSense.Core.TypeInfo @@ -61,6 +84,49 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do @elixir_module_builtin_functions [{:__info__, 1}] @builtin_functions @erlang_module_builtin_functions ++ @elixir_module_builtin_functions + @bitstring_modifiers [ + %{type: :bitstring_option, name: "big"}, + %{type: :bitstring_option, name: "binary"}, + %{type: :bitstring_option, name: "bitstring"}, + %{type: :bitstring_option, name: "integer"}, + %{type: :bitstring_option, name: "float"}, + %{type: :bitstring_option, name: "little"}, + %{type: :bitstring_option, name: "native"}, + %{type: :bitstring_option, name: "signed"}, + %{type: :bitstring_option, name: "size", arity: 1}, + %{type: :bitstring_option, name: "unit", arity: 1}, + %{type: :bitstring_option, name: "unsigned"}, + %{type: :bitstring_option, name: "utf8"}, + %{type: :bitstring_option, name: "utf16"}, + %{type: :bitstring_option, name: "utf32"} + ] + + # Bitstring modifier validity rules (UTS of `<>`). Used to keep + # `:bitstring_modifier` completion from offering combinations Elixir rejects (e.g. `signed` after + # `binary-`, `size`/`unit` after `utf8-`). Inlined here rather than pulled from a separate module so + # the completion engine stays self-contained. + @bitstring_types [:integer, :float, :bitstring, :binary, :utf8, :utf16, :utf32] + @bitstring_utf_types [:utf8, :utf16, :utf32] + @bitstring_type_aliases [bits: :bitstring, bytes: :binary] + @bitstring_sign_modifiers [:signed, :unsigned] + @bitstring_endianness_modifiers [:little, :big, :native] + # types each endianness modifier is compatible with, when the type is not yet fixed + @bitstring_endianness_types [:integer, :float, :utf16, :utf32] + @bitstring_default_state %{ + type: nil, + sign_modifier: nil, + endianness_modifier: nil, + size: nil, + unit: nil + } + + @alias_only_atoms ~w(alias import require)a + @alias_only_charlists ~w(alias import require)c + + # Block keywords that may follow a complete expression. Surfaced for the + # elixir >= 1.18 {:block_keyword_or_binary_operator, hint} cursor context. + @block_keywords ~w(do end after catch else rescue) + @type attribute :: %{ type: :attribute, name: String.t(), @@ -77,9 +143,9 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do visibility: :public | :private, name: String.t(), needed_require: String.t() | nil, - needed_import: {String.t(), {String.t(), integer()}} | nil, + needed_import: {String.t(), list({String.t(), integer()})} | nil, arity: non_neg_integer, - def_arity: non_neg_integer, + default_args: non_neg_integer, args: String.t(), args_list: [String.t()], origin: String.t(), @@ -104,9 +170,16 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do name: String.t(), origin: String.t() | nil, call?: boolean, + value_is_map: boolean, type_spec: String.t() | nil } + @type bitstring_option :: %{ + type: :bitstring_option, + name: String.t(), + arity: non_neg_integer + } + @type t() :: mod() | func() @@ -132,16 +205,20 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do expand_erlang_modules(List.to_string(unquoted_atom), env, metadata) {:dot, path, hint} -> - expand_dot( - path, - List.to_string(hint), - false, - env, - metadata, - cursor_position, - false, - opts - ) + if alias = alias_only(path, hint, code, env, metadata, cursor_position) do + expand_aliases(List.to_string(alias), env, metadata, cursor_position, false, opts) + else + expand_dot( + path, + List.to_string(hint), + false, + env, + metadata, + cursor_position, + false, + opts + ) + end {:dot_arity, path, hint} -> expand_dot( @@ -163,23 +240,46 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do expand_expr(env, metadata, cursor_position, opts) :expr -> - # IEx calls expand_local_or_var("", env) + # IEx calls expand_struct_fields_or_local_or_var(code, "", env) # we choose to return more and handle some special cases - # TODO expand_expr(env) after we require elixir 1.13 - case code do - [?^] -> expand_var("", env, metadata) - [?%] -> expand_aliases("", env, metadata, cursor_position, true, opts) - _ -> expand_expr(env, metadata, cursor_position, opts) - end + {results, continue?} = + expand_container_context(code, :expr, "", env, metadata, cursor_position) + + if continue?, + do: + results ++ + (case code do + [?^] -> + expand_var("", env, metadata) + + [?%] -> + expand_aliases("", env, metadata, cursor_position, true, opts) + + _ -> + expand_expr(env, metadata, cursor_position, opts) + end), + else: results {:local_or_var, local_or_var} -> - # TODO consider suggesting struct fields here when we require elixir 1.13 - # expand_struct_fields_or_local_or_var(code, List.to_string(local_or_var), shell) - expand_local_or_var(List.to_string(local_or_var), env, metadata, cursor_position) + hint = List.to_string(local_or_var) + + {results, continue?} = + expand_container_context(code, :expr, hint, env, metadata, cursor_position) + + if continue?, + do: results ++ expand_local_or_var(hint, env, metadata, cursor_position), + else: results + + # elixir >= 1.18 + {:capture_arg, capture_arg} -> + expand_local_or_var(List.to_string(capture_arg), env, metadata, cursor_position) {:local_arity, local} -> expand_local(List.to_string(local), true, env, metadata, cursor_position) + {:local_call, local} when local in @alias_only_charlists -> + expand_aliases("", env, metadata, cursor_position, false, opts) + {:local_call, _local} -> # no need to expand signatures here, we have signatures provider # expand_local_call(List.to_atom(local), env) @@ -188,6 +288,16 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do # to provide signatures and falls back to expand_local_or_var expand_expr(env, metadata, cursor_position, opts) + {:operator, operator} when operator in ~w(:: -)c -> + {results, continue?} = + expand_container_context(code, :operator, "", env, metadata, cursor_position) + + if continue?, + do: + results ++ + expand_local(List.to_string(operator), false, env, metadata, cursor_position), + else: results + {:operator, operator} -> case operator do [?^] -> expand_var("", env, metadata) @@ -198,6 +308,14 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do {:operator_arity, operator} -> expand_local(List.to_string(operator), true, env, metadata, cursor_position) + {:operator_call, operator} when operator in ~w(|)c -> + {results, continue?} = + expand_container_context(code, :expr, "", env, metadata, cursor_position) + + if continue?, + do: results ++ expand_local_or_var("", env, metadata, cursor_position), + else: results + {:operator_call, _operator} -> expand_local_or_var("", env, metadata, cursor_position) @@ -222,16 +340,25 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do expand_attribute(List.to_string(attribute), env, metadata) {:struct, {:local_or_var, local_or_var}} -> - # TODO consider suggesting struct fields here when we require elixir 1.13 - # expand_struct_fields_or_local_or_var(code, List.to_string(local_or_var), shell) expand_local_or_var(List.to_string(local_or_var), env, metadata, cursor_position) {:module_attribute, attribute} -> expand_attribute(List.to_string(attribute), env, metadata) + # elixir >= 1.16 {:anonymous_call, _} -> expand_expr(env, metadata, cursor_position, opts) + # elixir >= 1.18 — the cursor sits right after a complete expression, where + # a block keyword (do/end/after/catch/else/rescue) or a binary operator + # could follow. The engine is the precise oracle for this position; it + # surfaces the block keywords (binary operators are typed directly, so we + # don't suggest them). The LSP completion provider stays version-gated for + # 1.16-1.17 (where cursor_context never returns this token) and + # deduplicates against these results on 1.18+. + {:block_keyword_or_binary_operator, hint} -> + expand_block_keywords(List.to_string(hint)) + :none -> no() end @@ -251,17 +378,21 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do case expand_dot_path(path, env, metadata, cursor_position) do {:ok, {:atom, mod}} when hint == "" -> - expand_aliases( - mod, - "", - [], - not only_structs, - env, - metadata, - cursor_position, - filter, - opts - ) + if match?({:module_attribute, _attribute}, path) and not match?({_, _}, env.function) do + expand_require(mod, hint, exact?, env, metadata, cursor_position) + else + expand_aliases( + mod, + "", + [], + not only_structs, + env, + metadata, + cursor_position, + filter, + opts + ) + end {:ok, {:atom, mod}} -> expand_require(mod, hint, exact?, env, metadata, cursor_position) @@ -306,35 +437,33 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do defp expand_dot_path( {:alias, hint}, %State.Env{} = env, - %Metadata{} = metadata, + %Metadata{} = _metadata, _cursor_position ) do - alias = hint |> List.to_string() |> String.split(".") |> value_from_alias(env, metadata) + # value_from_alias/2 always returns {:alias, _} (its internal :error case maps to + # {:alias, Module.concat(list)}), so there is no :error branch to handle here. + {:alias, atom} = + hint + |> List.to_string() + |> String.split(".") + |> Enum.map(&String.to_atom/1) + |> value_from_alias(env) - case alias do - {:ok, atom} -> {:ok, {:atom, atom}} - :error -> :error - end + {:ok, {:atom, atom}} end defp expand_dot_path( {:alias, {:local_or_var, var}, hint}, %State.Env{} = env, - %Metadata{} = metadata, + %Metadata{} = _metadata, _cursor_position ) do - case var do - ~c"__MODULE__" -> - alias_suffix = hint |> List.to_string() |> String.split(".") - alias = [{:__MODULE__, [], nil} | alias_suffix] |> value_from_alias(env, metadata) - - case alias do - {:ok, atom} -> {:ok, {:atom, atom}} - :error -> :error - end - - _ -> - :error + if var == ~c"__MODULE__" and env.module != nil and Introspection.elixir_module?(env.module) do + alias_suffix = hint |> List.to_string() |> String.split(".") |> Enum.map(&String.to_atom/1) + expanded_alias = Module.concat([env.module | alias_suffix]) + {:ok, {:atom, expanded_alias}} + else + :error end end @@ -344,22 +473,22 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do %Metadata{} = metadata, cursor_position ) do - case value_from_binding({:attribute, List.to_atom(attribute)}, env, metadata, cursor_position) do - {:ok, {:atom, atom}} -> - if Introspection.elixir_module?(atom) do - alias_suffix = hint |> List.to_string() |> String.split(".") - alias = (Module.split(atom) ++ alias_suffix) |> value_from_alias(env, metadata) - - case alias do - {:ok, atom} -> {:ok, {:atom, atom}} - :error -> :error - end - else - :error - end - - :error -> - :error + with true <- match?({_, _}, env.function), + {:ok, {:atom, atom}} <- + value_from_binding( + {:attribute, List.to_atom(attribute)}, + env, + metadata, + cursor_position + ), + true <- Introspection.elixir_module?(atom) do + alias_suffix = + hint |> List.to_string() |> String.split(".") |> Enum.map(&String.to_atom/1) + + expanded_alias = Module.concat([atom | alias_suffix]) + {:ok, {:atom, expanded_alias}} + else + _ -> :error end end @@ -419,10 +548,17 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do [] end + defp expand_block_keywords(hint) do + for keyword <- @block_keywords, Matcher.match?(keyword, hint) do + %{type: :keyword, name: keyword} + end + |> format_expansion() + end + ## Formatting defp format_expansion(entries) do - Enum.flat_map(entries, &to_entries/1) + Enum.map(entries, &to_entries/1) end defp expand_map_field_access(fields, hint, type, %State.Env{} = env, %Metadata{} = metadata) do @@ -490,7 +626,11 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do ) current_module_locals = - match_module_funs(env.module, hint, exact?, false, :all, env, metadata, cursor_position) + if env.module && env.function do + match_module_funs(env.module, hint, exact?, false, :all, env, metadata, cursor_position) + else + [] + end imported_locals = {env.functions, env.macros} @@ -519,7 +659,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do do: name ) |> Enum.sort() - |> Enum.map(&%{kind: :variable, name: &1}) + |> Enum.map(&%{type: :variable, name: &1}) end # do not suggest attributes outside of a module @@ -545,10 +685,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do # include module attributes in module scope attribute_names ++ BuiltinAttributes.all() - %State.Env{} -> - # defensive: outside both a function and a module (e.g. top-level script). - # The type checker on 1.20+ infers env always has module != nil at this - # call site, but we keep this catch-all for safety. + _ -> [] end @@ -561,7 +698,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do |> Enum.sort() |> Enum.map( &%{ - kind: :attribute, + type: :attribute, name: Atom.to_string(&1), summary: BuiltinAttributes.docs(&1) } @@ -576,7 +713,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do end defp match_erlang_modules(hint, %State.Env{} = env, %Metadata{} = metadata) do - for mod <- match_modules(hint, true, env, metadata), + for mod <- match_modules(hint, false, env, metadata), usable_as_unquoted_module?(mod) do mod_as_atom = String.to_atom(mod) @@ -598,10 +735,9 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do name = inspect(module) result = %{ - kind: :module, + type: :module, name: name, full_name: name, - type: :erlang, desc: desc, subtype: subtype } @@ -611,14 +747,531 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do end defp struct_module_filter(true, %State.Env{} = _env, %Metadata{} = metadata) do - fn module -> Struct.is_struct(module, metadata.structs) end + # Build the loaded/application module-name list ONCE for the whole filter pass + # rather than recomputing `:code.all_loaded()` (and the application module scan) + # for every candidate module - that turned the filter into O(N^2) work. + all_module_names = all_module_names() + + fn module -> + Struct.is_struct(module, metadata.structs) or + has_struct_submodule?(module, metadata.structs, all_module_names) + end end defp struct_module_filter(false, %State.Env{} = _env, %Metadata{} = _metadata) do fn _ -> true end end - ## Elixir modules + # The loaded (and, in interactive mode, application) module names as strings. + # Computed once per struct-module filter pass, not per candidate module. + defp all_module_names do + modules = Enum.map(:code.all_loaded(), &Atom.to_string(elem(&1, 0))) + + case :code.get_mode() do + :interactive -> + modules ++ Enum.map(Applications.get_modules_from_applications(), &Atom.to_string/1) + + _ -> + modules + end + end + + # Check if a module has any direct submodules that are structs + defp has_struct_submodule?(module, structs, all_module_names) do + module_str = Atom.to_string(module) + + # Check metadata structs (from current buffer) + metadata_result = + Enum.any?(structs, fn {struct_module, _} -> + struct_module_str = Atom.to_string(struct_module) + String.starts_with?(struct_module_str, module_str <> ".") + end) + + # Also check compiled modules + if metadata_result do + true + else + # Check if any direct submodule (from the precomputed list) is a struct + module_str_with_dot = module_str <> "." + + submodules = + for mod <- all_module_names, + String.starts_with?(mod, module_str_with_dot), + do: String.to_atom(mod) + + Enum.any?(submodules, fn mod -> + Code.ensure_loaded?(mod) and function_exported?(mod, :__struct__, 1) + end) + end + end + + defp struct?(mod, metadata) do + Struct.is_struct(mod, metadata.structs) + # Code.ensure_loaded?(mod) and function_exported?(mod, :__struct__, 1) + end + + defp expand_container_context(code, context, hint, env, metadata, cursor_position) do + case container_context(code, env, metadata, cursor_position) do + {:map, map, pairs} when context == :expr -> + continue? = pairs == [] + {container_context_map_fields(pairs, :map, map, hint, metadata), continue?} + + {:struct, map, alias, pairs} when context == :expr -> + continue? = pairs == [] + {container_context_map_fields(pairs, {:struct, alias}, map, hint, metadata), continue?} + + :bitstring_modifier -> + # Parse the modifiers already typed after `::` into a state and keep only the modifiers that + # may still legally follow (honoring the type / sign / endianness / utf / size / unit rules), + # instead of merely excluding names already present - which would offer invalid combinations + # such as `signed`/`little` after `binary-`, or `size`/`unit`/`signed` after `utf8-`. + available_names = + code + |> List.to_string() + |> String.split("::") + |> List.last() + |> bitstring_parse() + |> bitstring_available_options() + |> Enum.map(&Atom.to_string/1) + + results = + @bitstring_modifiers + |> Enum.filter(&(&1.name in available_names and Matcher.match?(&1.name, hint))) + |> format_expansion() + + {results, false} + + _ -> + {[], true} + end + end + + # Parse the modifier text already typed after `::` (e.g. `binary-siz`) into a + # `%{type, sign_modifier, endianness_modifier, size, unit}` state. Unknown/partial characters (the + # in-progress hint) are skipped one at a time, so a complete parse is not required. + defp bitstring_parse(text), do: bitstring_parse(text, @bitstring_default_state) + + for type <- @bitstring_types do + defp bitstring_parse(<>, acc), + do: bitstring_parse(rest, %{acc | type: unquote(type)}) + end + + for {type_alias, type} <- @bitstring_type_aliases do + defp bitstring_parse(<>, acc), + do: bitstring_parse(rest, %{acc | type: unquote(type)}) + end + + for sign <- @bitstring_sign_modifiers do + defp bitstring_parse(<>, acc), + do: bitstring_parse(rest, %{acc | sign_modifier: unquote(sign)}) + end + + for endianness <- @bitstring_endianness_modifiers do + defp bitstring_parse(<>, acc), + do: bitstring_parse(rest, %{acc | endianness_modifier: unquote(endianness)}) + end + + defp bitstring_parse(<<"-", rest::binary>>, acc), do: bitstring_parse(rest, acc) + + defp bitstring_parse(<<"size", rest::binary>>, acc), + do: bitstring_parse(rest, %{acc | size: true}) + + defp bitstring_parse(<<"unit", rest::binary>>, acc), + do: bitstring_parse(rest, %{acc | unit: true}) + + defp bitstring_parse(<<_::binary-size(1), rest::binary>>, acc), do: bitstring_parse(rest, acc) + defp bitstring_parse(<<>>, acc), do: acc + + # The modifiers that may still legally follow, given the parsed state. + defp bitstring_available_options(state) do + bitstring_available_types(state) ++ + bitstring_available_sign_modifiers(state) ++ + bitstring_available_endianness_modifiers(state) ++ + bitstring_available_size(state) ++ + bitstring_available_unit(state) + end + + defp bitstring_available_types( + %{type: nil, sign_modifier: nil, endianness_modifier: nil} = state + ), + do: bitstring_filter_utf(state, @bitstring_types) + + defp bitstring_available_types(%{type: nil, sign_modifier: nil, endianness_modifier: e} = state) + when not is_nil(e), + do: bitstring_filter_utf(state, @bitstring_endianness_types) + + defp bitstring_available_types(%{type: nil}), do: [:integer] + defp bitstring_available_types(_), do: [] + + defp bitstring_available_sign_modifiers(%{type: type, sign_modifier: nil}) + when type in [nil, :integer], + do: @bitstring_sign_modifiers + + defp bitstring_available_sign_modifiers(_), do: [] + + defp bitstring_available_endianness_modifiers(%{type: type, endianness_modifier: nil}) + when type in [nil, :integer, :utf16, :utf32], + do: @bitstring_endianness_modifiers + + defp bitstring_available_endianness_modifiers(%{type: :float, endianness_modifier: nil}), + do: [:little, :big] + + defp bitstring_available_endianness_modifiers(_), do: [] + + # size and unit are unsupported on utf types (they fail to compile). + defp bitstring_available_size(%{size: nil, type: type}) when type not in @bitstring_utf_types, + do: [:size] + + defp bitstring_available_size(_), do: [] + + defp bitstring_available_unit(%{unit: nil, type: type}) when type not in @bitstring_utf_types, + do: [:unit] + + defp bitstring_available_unit(_), do: [] + + defp bitstring_filter_utf(%{size: nil, unit: nil}, list), do: list + defp bitstring_filter_utf(_, list), do: list -- @bitstring_utf_types + + defp container_context_map_fields(pairs, kind, map, hint, metadata) do + {keys, types, alias, doc, meta} = + case kind do + {:struct, nil} -> + {Map.keys(map) ++ [:__struct__], %{}, nil, "", %{}} + + {:struct, alias} -> + keys = Struct.get_fields(alias, metadata.structs) + types = ElixirSense.Providers.Utils.Field.get_field_types(metadata, alias, true) + {doc, meta} = get_struct_info({:atom, alias}, metadata) + {keys, types, alias, doc, meta} + + _ -> + {Map.keys(map), %{}, nil, "", %{}} + end + + entries = + for key <- keys, + not Keyword.has_key?(pairs, key), + name = Atom.to_string(key), + Matcher.match?(name, hint) do + %{ + type: :field, + name: name, + subtype: if(kind == :map, do: :map_key, else: :struct_field), + value_is_map: false, + origin: if(kind != :map and alias != nil, do: inspect(alias)), + call?: false, + type_spec: map_field_spec(key, types, alias), + summary: doc, + metadata: meta + } + end + + format_expansion(entries |> Enum.sort_by(& &1.name)) + end + + @doc """ + Returns true when the cursor sits directly as an operand of a binary operator + in the given `container_cursor_to_quoted/2` AST (e.g. the right-hand side of + `x = ‹cursor›`, `a + ‹cursor›`, `x |> ‹cursor›`). + + Block keywords (do/end/rescue/...) are never valid in such a position. This is + detected from the AST because `Code.Fragment.cursor_context/1` reports + `:local_or_var` for these positions and cannot distinguish them from a valid + block-keyword position. + """ + @spec cursor_in_operator_operand?(Macro.t() | nil) :: boolean + def cursor_in_operator_operand?(nil), do: false + + def cursor_in_operator_operand?(container_cursor_quoted) do + case Macro.path(container_cursor_quoted, &match?({:__cursor__, _, []}, &1)) do + [_cursor, {op, _meta, [_, _]} | _] when is_atom(op) -> + Macro.operator?(op, 2) + + _ -> + false + end + end + + defp container_context(code, env, metadata, cursor_position) do + case Code.Fragment.container_cursor_to_quoted(code) do + {:ok, quoted} -> + case Macro.path(quoted, &match?({:__cursor__, _, []}, &1)) do + [cursor, {:%{}, _, pairs}, {:%, _, [struct_module_ast, _map]} | _] -> + container_context_struct( + cursor, + pairs, + struct_module_ast, + env, + metadata, + cursor_position + ) + + [ + cursor, + pairs, + {:|, _, _}, + {:%{}, _, _}, + {:%, _, [struct_module_ast, _map]} | _ + ] -> + container_context_struct( + cursor, + pairs, + struct_module_ast, + env, + metadata, + cursor_position + ) + + [cursor, pairs, {:|, _, [expr | _]}, {:%{}, _, _} | _] -> + container_context_map(cursor, pairs, expr, env, metadata, cursor_position) + + [cursor, {special_form, _, [cursor]} | _] when special_form in @alias_only_atoms -> + :alias_only + + [ + cursor, + {:__MODULE__, _, [cursor]}, + {special_form, _, [{:__MODULE__, _, [cursor]}]} | _ + ] + when special_form in @alias_only_atoms -> + :alias_only + + [cursor | tail] -> + case remove_operators(tail, cursor) do + [{:"::", _, [_, _]}, {:<<>>, _, [_ | _]} | _] -> :bitstring_modifier + _ -> nil + end + + _ -> + nil + end + + {:error, _} -> + nil + end + end + + defp remove_operators([{op, _, [_, previous]} = head | tail], previous) when op in [:-], + do: remove_operators(tail, head) + + defp remove_operators(tail, _previous), + do: tail + + defp expand_struct_module(atom, _env, _metadata, _cursor_position) when is_atom(atom) do + {:ok, atom} + end + + defp expand_struct_module( + {:__MODULE__, _, context}, + %{module: module}, + _metadata, + _cursor_position + ) + when is_atom(context) and not is_nil(module) do + {:ok, module} + end + + defp expand_struct_module( + {:@, _, [{attribute, _, context}]}, + env = %{function: {_, _}}, + metadata, + cursor_position + ) + when is_atom(context) and is_atom(attribute) do + case value_from_binding({:attribute, attribute}, env, metadata, cursor_position) do + {:ok, {:atom, atom}} -> + if Introspection.elixir_module?(atom) do + {:ok, atom} + else + :error + end + + _ -> + :error + end + end + + defp expand_struct_module( + {:__aliases__, meta, list = [head | tail]}, + env, + metadata, + cursor_position + ) do + case NormalizedMacroEnv.expand_alias(State.Env.to_macro_env(env), meta, list, trace: false) do + {:alias, alias} -> + {:ok, alias} + + :error -> + if match?({:@, _, _}, head) do + # alias with attribute is not supported in struct + :error + else + head = simple_expand(head, env, metadata, cursor_position) + + if is_atom(head) do + {:ok, Module.concat([head | tail])} + else + :error + end + end + end + end + + defp expand_struct_module( + {variable, _, context}, + %{context: :match}, + _metadata, + _cursor_position + ) + when is_atom(context) and is_atom(variable) do + {:ok, nil} + end + + defp expand_struct_module(_ast, _env, _metadata, _cursor_position) do + :error + end + + defp container_context_struct(cursor, pairs, ast, env, metadata, cursor_position) do + with {pairs, [^cursor]} <- Enum.split(pairs, -1), + {:ok, alias} <- expand_struct_module(ast, env, metadata, cursor_position), + true <- Keyword.keyword?(pairs) and (struct?(alias, metadata) or alias == nil) do + {:struct, %{}, alias, pairs} + else + _ -> nil + end + end + + defp simple_expand({:__ENV__, _, context}, _env, _metadata, _cursor_position) + when is_atom(context) do + {:%, [], [Macro.Env, {:%{}, [], []}]} + end + + defp simple_expand( + {:__MODULE__, _, context}, + env = %{module: module}, + _metadata, + _cursor_position + ) + when is_atom(context) and not is_nil(module) do + env.module + end + + defp simple_expand( + {special, _, context} = node, + %{module: _module}, + _metadata, + _cursor_position + ) + when is_atom(context) and special in [:__DIR__, :__STACKTRACE__, :__CALLER__] do + node + end + + defp simple_expand({:@, _, [{attribute, _, context}]} = node, env, metadata, cursor_position) + when is_atom(context) and is_atom(attribute) do + case value_from_binding({:attribute, attribute}, env, metadata, cursor_position) do + {:ok, {:atom, atom}} -> + if Introspection.elixir_module?(atom) do + atom + else + node + end + + _ -> + node + end + end + + defp simple_expand( + {:__aliases__, meta, [head | tail] = list} = node, + env, + metadata, + cursor_position + ) do + case NormalizedMacroEnv.expand_alias(State.Env.to_macro_env(env), meta, list, trace: false) do + {:alias, alias} -> + alias + + :error -> + if match?({:@, _, _}, head) and not match?({_, _}, env.function) do + # alias with attribute is only valid in function context + node + else + head = simple_expand(head, env, metadata, cursor_position) + + if is_atom(head) do + Module.concat([head | tail]) + else + node + end + end + end + end + + defp simple_expand({variable, meta, context}, _env, _metadata, _cursor_position) + when is_atom(variable) and is_atom(context) do + # put fake version to make it work with TypeInference + {variable, meta |> Keyword.put(:version, :any), context} + end + + defp simple_expand(ast, _env, _metadata, _cursor_position), do: ast + + defp container_context_map(cursor, pairs, expr, env, metadata, cursor_position) do + binding_ast = + expr + |> Macro.prewalk(fn node -> simple_expand(node, env, metadata, cursor_position) end) + |> ElixirSense.Core.TypeInference.type_of(env.context) + + with {pairs, [^cursor]} <- Enum.split(pairs, -1), + {:ok, type} <- value_from_binding(binding_ast, env, metadata, cursor_position), + true <- Keyword.keyword?(pairs) do + case type do + {:struct, all, {:atom, alias}, _} -> + {:struct, Map.new(all), alias, pairs} + + {:struct, all, _origin, _} -> + {:struct, Map.new(all), nil, pairs} + + {:map, all, _} -> + {:map, Map.new(all), pairs} + + _ -> + nil + end + else + _ -> nil + end + end + + ## Aliases and modules + + defp alias_only( + {:var, ~c"__MODULE__"}, + [], + code, + env = %{module: module}, + metadata, + cursor_position + ) + when not is_nil(module) do + case container_context(code, env, metadata, cursor_position) do + :alias_only -> + String.to_charlist(inspect(env.module)) ++ [?.] + + _ -> + nil + end + end + + defp alias_only(path, hint, code, env, metadata, cursor_position) do + # attributes are not supported in alias only context + with {:alias, alias} <- path, + [] <- hint, + :alias_only <- container_context(code, env, metadata, cursor_position) do + alias ++ [?.] + else + _ -> nil + end + end defp expand_aliases( all, @@ -637,25 +1290,22 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do parts -> hint = List.last(parts) - list = Enum.take(parts, length(parts) - 1) - - case value_from_alias(list, env, metadata) do - {:ok, alias} -> - expand_aliases( - alias, - hint, - [], - false, - env, - metadata, - cursor_position, - filter, - Keyword.put(opts, :required_alias, false) - ) + list = Enum.take(parts, length(parts) - 1) |> Enum.map(&String.to_atom/1) - :error -> - no() - end + # value_from_alias/2 always returns {:alias, _}, so there is no :error branch here. + {:alias, alias} = value_from_alias(list, env) + + expand_aliases( + alias, + hint, + [], + false, + env, + metadata, + cursor_position, + filter, + Keyword.put(opts, :required_alias, false) + ) end end @@ -705,7 +1355,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do ) do case value_from_binding({:attribute, List.to_atom(attribute)}, env, metadata, cursor_position) do {:ok, {:atom, atom}} -> - if Introspection.elixir_module?(atom) do + if Introspection.elixir_module?(atom) and match?({_, _}, env.function) do expand_aliases("#{atom}.#{hint}", env, metadata, cursor_position, only_structs, []) else no() @@ -733,13 +1383,15 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do ), do: no() - defp value_from_alias(mod_parts, %State.Env{} = env, %Metadata{} = _metadata) do - mod_parts - |> Enum.map(fn - bin when is_binary(bin) -> String.to_atom(bin) - other -> other - end) - |> Source.concat_module_parts(env.module, env.aliases) + defp value_from_alias([_ | _] = list, %State.Env{} = env) do + case NormalizedMacroEnv.expand_alias(State.Env.to_macro_env(env), [], list, trace: false) do + {:alias, alias} -> + {:alias, alias} + + :error -> + # we do not expect non atom aliases here + {:alias, Module.concat(list)} + end end defp match_aliases(hint, %State.Env{} = env, %Metadata{} = _metadata) do @@ -747,8 +1399,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do [name] = Module.split(alias), Matcher.match?(name, hint) do %{ - kind: :module, - type: :elixir, + type: :module, name: name, full_name: inspect(mod), desc: {"", %{}}, @@ -779,7 +1430,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do filter.(mod_as_atom), parts = String.split(mod, "."), depth <= length(parts), - name = Enum.at(parts, depth - 1), + [name] = [Enum.at(parts, depth - 1)], valid_alias_piece?("." <> name), concatted = parts |> Enum.take(depth) |> concat_module.(), filter.(concatted) do @@ -809,8 +1460,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do info -> %{ - kind: :module, - type: :elixir, + type: :module, full_name: inspect(module), desc: {Introspection.extract_summary_from_docs(info.doc), info.meta}, subtype: Metadata.get_module_subtype(metadata, module) @@ -836,8 +1486,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do subtype = Introspection.get_module_subtype(module) result = %{ - kind: :module, - type: :elixir, + type: :module, full_name: inspect(module), desc: {desc, meta}, subtype: subtype @@ -952,13 +1601,12 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do |> Enum.filter(fn {suggestion, _required_alias} -> valid_alias_piece?("." <> suggestion) end) end - defp match_modules(hint, root, %State.Env{} = env, %Metadata{} = metadata) do + defp match_modules(hint, elixir_root?, %State.Env{} = env, %Metadata{} = metadata) do hint_parts = hint |> String.split(".") hint_parts_length = length(hint_parts) [hint_suffix | hint_prefix] = hint_parts |> Enum.reverse() - root - |> get_modules(env, metadata) + get_modules(elixir_root?, env, metadata) |> Enum.sort() |> Enum.dedup() |> Enum.filter(fn mod -> @@ -993,7 +1641,8 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do end defp get_modules_from_metadata(%State.Env{} = _env, %Metadata{} = metadata) do - for {{k, nil, nil}, _} <- metadata.mods_funs_to_positions, do: Atom.to_string(k) + for {{k, nil, nil}, _} when is_atom(k) <- metadata.mods_funs_to_positions, + do: Atom.to_string(k) end defp match_module_funs( @@ -1006,95 +1655,88 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do %Metadata{} = metadata, cursor_position ) do - falist = + list = cond do metadata.mods_funs_to_positions |> Map.has_key?({mod, nil, nil}) -> - get_metadata_module_funs(mod, include_builtin, env, metadata, cursor_position) + get_metadata_module_funs( + mod, + hint, + exact?, + include_builtin, + env, + metadata, + cursor_position + ) - match?({:module, _}, ensure_loaded(mod)) -> - get_module_funs(mod, include_builtin) + ensure_loaded?(mod) -> + get_module_funs(mod, hint, exact?, include_builtin) true -> [] end - |> Enum.sort_by(fn {f, a, _, _, _, _, _} -> {f, -a} end) - - list = - Enum.reduce(falist, [], fn {f, a, def_a, func_kind, {doc_str, meta}, spec, arg}, acc -> - doc = {Introspection.extract_summary_from_docs(doc_str), meta} - - case :lists.keyfind(f, 1, acc) do - {f, aa, def_arities, func_kinds, docs, specs, args} -> - :lists.keyreplace( - f, - 1, - acc, - {f, [a | aa], [def_a | def_arities], [func_kind | func_kinds], [doc | docs], - [spec | specs], [arg | args]} - ) + |> Enum.sort_by(fn {f, _, a, _, _, _, _} -> {f, a} end) - false -> - [{f, [a], [def_a], [func_kind], [doc], [spec], [arg]} | acc] - end - end) - - for {fun, arities, def_arities, func_kinds, docs, specs, args} <- list, - name = Atom.to_string(fun), - if(exact?, do: name == hint, else: Matcher.match?(name, hint)) do - needed_requires = - for func_kind <- func_kinds do - if func_kind in [:macro, :defmacro, :defguard] and mod not in env.requires and - mod != Kernel.SpecialForms and mod != env.module do - mod - end + for {fun, default_args, arity, func_kind, docs, specs, args} <- list do + needed_require = + if func_kind in [:macro, :defmacro, :defguard] and mod not in env.requires and + mod != Kernel.SpecialForms and mod != env.module do + mod end - needed_imports = + needed_import = if imported == :all do - arities |> Enum.map(fn _ -> nil end) + nil else - arities - |> Enum.map(fn a -> - if {fun, a} not in imported do - {mod, {fun, a}} + missing = + for a <- (arity - default_args)..arity, {fun, a} not in imported do + {fun, a} end - end) + + if missing == [] do + nil + else + {mod, missing} + end end %{ - kind: :function, - name: name, - arities: arities, - def_arities: def_arities, + type: :function, + name: Atom.to_string(fun), + arity: arity, + default_args: default_args, module: mod, - func_kinds: func_kinds, + func_kind: func_kind, docs: docs, specs: specs, - needed_requires: needed_requires, - needed_imports: needed_imports, + needed_require: needed_require, + needed_import: needed_import, args: args } end - |> Enum.sort_by(& &1.name) end - # TODO filter by hint here? defp get_metadata_module_funs( mod, + hint, + exact?, include_builtin, %State.Env{} = env, %Metadata{} = metadata, cursor_position ) do - case metadata.mods_funs_to_positions[{mod, nil, nil}] do - nil -> + # credo:disable-for-next-line Credo.Check.Refactor.CondStatements + cond do + not Map.has_key?(metadata.mods_funs_to_positions, {mod, nil, nil}) -> [] - _funs -> + true -> # local macros are available after definition # local functions are hoisted - for {{^mod, f, a}, %State.ModFunInfo{} = info} <- metadata.mods_funs_to_positions, + for {{^mod, f, a}, %State.ModFunInfo{} = info} when is_atom(f) <- + metadata.mods_funs_to_positions, a != nil, + name = Atom.to_string(f), + if(exact?, do: name == hint, else: Matcher.match?(name, hint)), (mod == env.module and not include_builtin) or Introspection.is_pub(info.type), mod != env.module or State.ModFunInfo.get_category(info) != :macro or List.last(info.positions) < cursor_position, @@ -1152,20 +1794,25 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do # assume function head is first in code and last in metadata head_params = Enum.at(info.params, -1) - args = head_params |> Enum.map(&Macro.to_string/1) + + args = + head_params + |> Enum.map(fn arg -> + try do + Macro.to_string(arg) + rescue + _ -> "term" + end + end) + default_args = Introspection.count_defaults(head_params) - # TODO this is useless - we duplicate and then deduplicate - for arity <- (a - default_args)..a do - {f, arity, a, info.type, {docs, meta}, specs, args} - end + {f, default_args, a, info.type, {docs, meta}, specs, args} end - |> Enum.concat() end end - # TODO filter by hint here? - def get_module_funs(mod, include_builtin) do + def get_module_funs(mod, hint, exact?, include_builtin) do docs = NormalizedCode.get_docs(mod, :docs) module_specs = TypeInfo.get_module_specs(mod) @@ -1177,17 +1824,14 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do if docs != nil and function_exported?(mod, :__info__, 1) do exports = mod.__info__(:macros) ++ mod.__info__(:functions) ++ special_builtins(mod) - # TODO this is useless - we should only return max arity variant default_arg_functions = default_arg_functions(docs) - for {f, a} <- exports do - {f, new_arity} = - case default_arg_functions[{f, a}] do - nil -> {f, a} - new_arity -> {f, new_arity} - end - - {func_kind, func_doc} = find_doc({f, new_arity}, docs) + for {f, a} <- exports, + {new_a, default_args} = Map.get(default_arg_functions, {f, a}, {a, 0}), + new_a == a, + name = Atom.to_string(f), + if(exact?, do: name == hint, else: Matcher.match?(name, hint)) do + {func_kind, func_doc} = find_doc({f, new_a}, docs) func_kind = func_kind || :function doc = @@ -1207,8 +1851,8 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do spec_key = case func_kind do - :macro -> {:"MACRO-#{f}", new_arity + 1} - :function -> {f, new_arity} + :macro -> {:"MACRO-#{f}", a + 1} + :function -> {f, a} end {_behaviour, fun_spec, spec_kind} = @@ -1224,24 +1868,25 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do fun_args = Introspection.extract_fun_args(func_doc) - # Several Kernel.SpecialForms macros (e.g. `%{}`, `.`, `__aliases__`, - # `__block__`, `fn`, `unquote_splicing`) ship doc signatures that - # either don't parse as a function call or parse to a different arity - # than the macro's actual one. When the recovered arg list doesn't - # match the arity, synthesize a dummy one. + # TODO check if this is still needed on 1.13+ + # as of Elixir 1.12 some functions/macros, e.g. Kernel.SpecialForms.fn + # have broken specs in docs + # in that case we fill a dummy fun_args fun_args = - if length(fun_args) != new_arity do - format_params(nil, new_arity) + if length(fun_args) != a do + format_params(nil, a) else fun_args end - {f, a, new_arity, func_kind, doc, spec, fun_args} + {f, default_args, a, func_kind, doc, spec, fun_args} end |> Kernel.++( for {f, a} <- @builtin_functions, + name = Atom.to_string(f), + if(exact?, do: name == hint, else: Matcher.match?(name, hint)), include_builtin, - do: {f, a, a, :function, {"", %{}}, nil, nil} + do: {f, 0, a, :function, {"", %{}}, nil, nil} ) else funs = @@ -1253,7 +1898,9 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do [] end - for {f, a} <- funs do + for {f, a} <- funs, + name = Atom.to_string(f), + if(exact?, do: name == hint, else: Matcher.match?(name, hint)) do # we don't expect macros here {behaviour, fun_spec} = case callback_specs[{f, a}] do @@ -1289,7 +1936,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do params = format_params(fun_spec, a) spec = Introspection.spec_to_string(fun_spec, if(behaviour, do: :callback, else: :spec)) - {f, a, a, :function, doc_result, spec, params} + {f, 0, a, :function, doc_result, spec, params} end end end @@ -1332,16 +1979,16 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do for {{fun_name, arity}, _, _kind, args, _, _} <- docs, count = Introspection.count_defaults(args), count > 0, - new_arity <- (arity - count)..(arity - 1), + new_arity <- (arity - count)..arity, into: %{}, - do: {{fun_name, new_arity}, arity} + do: {{fun_name, new_arity}, {arity, count}} end - defp ensure_loaded(Elixir), do: {:error, :nofile} - defp ensure_loaded(mod), do: Code.ensure_compiled(mod) + defp ensure_loaded?(Elixir), do: false + defp ensure_loaded?(mod), do: Code.ensure_loaded?(mod) defp match_map_fields(fields, hint, type, %State.Env{} = _env, %Metadata{} = metadata) do - {subtype, origin, types} = + {subtype, origin, types, doc, meta} = case type do {:struct, {:atom, mod}} -> types = @@ -1351,13 +1998,14 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do true ) - {:struct_field, inspect(mod), types} + {doc, meta} = get_struct_info({:atom, mod}, metadata) + {:struct_field, mod, types, doc, meta} {:struct, nil} -> - {:struct_field, nil, %{}} + {:struct_field, nil, %{}, "", %{}} :map -> - {:map_key, nil, %{}} + {:map_key, nil, %{}, "", %{}} other -> raise "unexpected #{inspect(other)} for hint #{inspect(hint)}" @@ -1375,154 +2023,182 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do end %{ - kind: :field, + type: :field, name: key_str, subtype: subtype, value_is_map: value_is_map, - origin: origin, - type_spec: types[key] + origin: if(subtype == :struct_field and origin != nil, do: inspect(origin)), + call?: true, + type_spec: map_field_spec(key, types, origin), + summary: doc, + metadata: meta } end |> Enum.sort_by(& &1.name) end + # Returns {doc, metadata} for a struct module so struct-field completions can + # carry the struct's @moduledoc summary and metadata (elixir-ls 1.20 feature, + # elixir-lsp/elixir-ls "return docs and meta on record and struct field + # completions"). + defp get_struct_info({:atom, module}, metadata) when is_atom(module) do + case metadata.structs[module] do + %StructInfo{} = info -> + {info.doc, info.meta} + + nil -> + case NormalizedCode.get_docs(module, :docs) do + nil -> + {"", %{}} + + docs -> + case Enum.find(docs, fn + {{:__struct__, 0}, _, _, _, _, _} -> true + _ -> false + end) do + {{:__struct__, 0}, _, _, _, doc, meta} -> + {doc || "", meta} + + _ -> + {"", %{}} + end + end + end + end + + defp map_field_spec(key, specs, alias) do + case specs[key] do + nil -> + case key do + :__struct__ -> if(alias, do: inspect(alias), else: "atom()") + :__exception__ -> "true" + _ -> nil + end + + some -> + Introspection.to_string_with_parens(some) + end + end + ## Ad-hoc conversions - @spec to_entries(map) :: [t()] - defp to_entries(%{ - kind: :field, - subtype: subtype, - name: name, - origin: origin, - type_spec: type_spec - }) do - [ - %{ - type: :field, - name: name, - subtype: subtype, - origin: origin, - call?: true, - type_spec: if(type_spec, do: Macro.to_string(type_spec)) - } - ] + @spec to_entries(map) :: t() + + defp to_entries(%{type: :bitstring_option} = option) do + option + end + + defp to_entries(%{type: :keyword} = option) do + option + end + + defp to_entries(%{type: :field} = option) do + option end defp to_entries( %{ - kind: :module, + type: :module, name: name, full_name: full_name, desc: {desc, metadata}, subtype: subtype } = map ) do - [ - %{ - type: :module, - name: name, - full_name: full_name, - required_alias: if(map[:required_alias], do: inspect(map[:required_alias])), - subtype: subtype, - summary: desc, - metadata: metadata - } - ] + %{ + type: :module, + name: name, + full_name: full_name, + required_alias: if(map[:required_alias], do: inspect(map[:required_alias])), + subtype: subtype, + summary: desc, + metadata: metadata + } end - defp to_entries(%{kind: :variable, name: name}) do - [%{type: :variable, name: name}] + defp to_entries(%{type: :variable, name: _name} = option) do + option end - defp to_entries(%{kind: :attribute, name: name, summary: summary}) do - [%{type: :attribute, name: "@" <> name, summary: summary}] + defp to_entries(%{type: :attribute, name: name, summary: summary}) do + %{type: :attribute, name: "@" <> name, summary: summary} end defp to_entries(%{ - kind: :function, + type: :function, name: name, - arities: arities, - def_arities: def_arities, - needed_imports: needed_imports, - needed_requires: needed_requires, + arity: arity, + default_args: default_args, + needed_import: needed_import, + needed_require: needed_require, module: mod, - func_kinds: func_kinds, - docs: docs, - specs: specs, + func_kind: func_kind, + docs: {doc, metadata}, + specs: spec, args: args }) do - for e <- - Enum.zip([ - arities, - docs, - specs, - args, - def_arities, - func_kinds, - needed_imports, - needed_requires - ]), - {a, {doc, metadata}, spec, args, def_arity, func_kind, needed_import, needed_require} = e do - kind = - case func_kind do - k when k in [:macro, :defmacro, :defmacrop, :defguard, :defguardp] -> :macro - _ -> :function - end + kind = + case func_kind do + k when k in [:macro, :defmacro, :defmacrop, :defguard, :defguardp] -> :macro + _ -> :function + end - visibility = - if func_kind in [:defp, :defmacrop, :defguardp] do - :private - else - :public - end + visibility = + if func_kind in [:defp, :defmacrop, :defguardp] do + :private + else + :public + end - mod_name = inspect(mod) + mod_name = inspect(mod) - fa = {name |> String.to_atom(), a} + fa = {name |> String.to_atom(), arity} - if fa in (BuiltinFunctions.all() -- [exception: 1, message: 1]) do - args = BuiltinFunctions.get_args(fa) - docs = BuiltinFunctions.get_docs(fa) + if fa in (BuiltinFunctions.all() -- [exception: 1, message: 1]) do + args = BuiltinFunctions.get_args(fa) + docs = BuiltinFunctions.get_docs(fa) - %{ - type: kind, - visibility: visibility, - name: name, - arity: a, - def_arity: def_arity, - args: args |> Enum.join(", "), - args_list: args, - needed_require: nil, - needed_import: nil, - origin: mod_name, - summary: Introspection.extract_summary_from_docs(docs), - metadata: %{builtin: true}, - spec: BuiltinFunctions.get_specs(fa) |> Enum.join("\n"), - snippet: nil - } - else - needed_import = - case needed_import do - nil -> nil - {mod, {fun, arity}} -> {inspect(mod), {Atom.to_string(fun), arity}} - end + %{ + type: kind, + visibility: visibility, + name: name, + arity: arity, + default_args: default_args, + args: args |> Enum.join(", "), + args_list: args, + needed_require: nil, + needed_import: nil, + origin: mod_name, + summary: Introspection.extract_summary_from_docs(docs), + metadata: %{builtin: true}, + spec: BuiltinFunctions.get_specs(fa) |> Enum.join("\n"), + snippet: nil + } + else + needed_import = + case needed_import do + nil -> + nil - %{ - type: kind, - visibility: visibility, - name: name, - arity: a, - def_arity: def_arity, - args: args |> Enum.join(", "), - args_list: args, - needed_require: if(needed_require, do: inspect(needed_require)), - needed_import: needed_import, - origin: mod_name, - summary: doc, - metadata: metadata, - spec: spec || "", - snippet: nil - } - end + {mod, missing} -> + {inspect(mod), missing |> Enum.map(fn {f, a} -> {Atom.to_string(f), a} end)} + end + + %{ + type: kind, + visibility: visibility, + name: name, + arity: arity, + default_args: default_args, + args: args |> Enum.join(", "), + args_list: args, + needed_require: if(needed_require, do: inspect(needed_require)), + needed_import: needed_import, + origin: mod_name, + summary: Introspection.extract_summary_from_docs(doc), + metadata: metadata, + spec: spec || "", + snippet: nil + } end end diff --git a/lib/elixir_sense/providers/completion/reducers/bitstring.ex b/lib/elixir_sense/providers/completion/reducers/bitstring.ex deleted file mode 100644 index c0b79769..00000000 --- a/lib/elixir_sense/providers/completion/reducers/bitstring.ex +++ /dev/null @@ -1,39 +0,0 @@ -defmodule ElixirSense.Providers.Completion.Reducers.Bitstring do - @moduledoc false - - alias ElixirSense.Core.Bitstring - alias ElixirSense.Core.Source - - @type bitstring_option :: %{ - type: :bitstring_option, - name: String.t() - } - - @doc """ - A reducer that adds suggestions of bitstring options. - """ - def add_bitstring_options(_hint, _env, _buffer_metadata, cursor_context, acc) do - prefix = cursor_context.text_before - - case Source.bitstring_options(prefix) do - candidate when not is_nil(candidate) -> - parsed = Bitstring.parse(candidate) - - list = - for option <- Bitstring.available_options(parsed), - candidate_part = candidate |> String.split("-") |> List.last(), - option_str = option |> Atom.to_string(), - String.starts_with?(option_str, candidate_part) do - %{ - name: option_str, - type: :bitstring_option - } - end - - {:cont, %{acc | result: acc.result ++ list}} - - _ -> - {:cont, acc} - end - end -end diff --git a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex index 38d60dc8..d4212ccd 100644 --- a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex +++ b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex @@ -1,3 +1,13 @@ +# This code has originally been a part of https://github.com/elixir-lsp/elixir_sense + +# Copyright (c) 2017 Marlus Saraiva +# Permission is hereby granted, free of charge, to any person obtaining a copy of this +# software and associated documentation files (the 'Software'), to deal in the Software +# without restriction, including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +# to whom the Software is furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + defmodule ElixirSense.Providers.Completion.Reducers.CompleteEngine do @moduledoc false @@ -76,7 +86,36 @@ defmodule ElixirSense.Providers.Completion.Reducers.CompleteEngine do Note: requires populate/5. """ def add_fields(_hint, _env, _file_metadata, _context, acc) do - add_suggestions(:field, acc) + add_suggestions(:field, acc, &(&1[:subtype] != :struct_field)) + end + + @doc """ + A reducer that adds suggestions of struct fields. + + Note: requires populate/5. + """ + def add_struct_fields(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:field, acc, &(&1[:subtype] == :struct_field)) + end + + @doc """ + A reducer that adds suggestions of bitstring options. + + Note: requires populate/5. + """ + def add_bitstring_options(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:bitstring_option, acc) + end + + @doc """ + A reducer that adds block-keyword suggestions (do/end/after/catch/else/rescue) + produced by the engine for the elixir >= 1.18 block_keyword_or_binary_operator + cursor context. + + Note: requires populate/5. + """ + def add_keywords(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:keyword, acc) end @doc """ @@ -97,9 +136,9 @@ defmodule ElixirSense.Providers.Completion.Reducers.CompleteEngine do add_suggestions(:variable, acc) end - defp add_suggestions(type, acc) do + defp add_suggestions(type, acc, filter \\ fn _ -> true end) do suggestions_by_type = Reducer.get_context(acc, :complete_engine) - list = Map.get(suggestions_by_type, type, []) + list = Map.get(suggestions_by_type, type, []) |> Enum.filter(filter) {:cont, %{acc | result: acc.result ++ list}} end @@ -116,7 +155,7 @@ defmodule ElixirSense.Providers.Completion.Reducers.CompleteEngine do hint = case Source.get_v12_module_prefix(text_before, module) do nil -> - hint + text_before module_string -> # multi alias syntax detected diff --git a/lib/elixir_sense/providers/completion/reducers/record.ex b/lib/elixir_sense/providers/completion/reducers/record.ex index d841b8b1..99ee72a6 100644 --- a/lib/elixir_sense/providers/completion/reducers/record.ex +++ b/lib/elixir_sense/providers/completion/reducers/record.ex @@ -38,7 +38,9 @@ defmodule ElixirSense.Providers.Completion.Reducers.Record do :functions, :macros, :variables, - :attributes + :attributes, + :structs_fields, + :bitstring_options ] {:cont, %{acc | result: fields, reducers: reducers}} @@ -59,9 +61,6 @@ defmodule ElixirSense.Providers.Completion.Reducers.Record do # check if we are inside local or remote call arguments and parameter is 0, 1 or 2 # record fields can specified on 0, 1 and 2 position in the argument list - # TODO implement retrieval from docs chunks on 1.18 - # right now only local buffer records are supported as there is no suitable API for introspection - # @__records__ is compile time only attribute and accessing it would require a tracer with %{ candidate: {m, f}, npar: npar, @@ -142,7 +141,7 @@ defmodule ElixirSense.Providers.Completion.Reducers.Record do with %TypeInfo{specs: [spec | _]} <- types[{module, record, 0}] || types[{module, :"#{record}_t", 0}] || types[{module, :t, 0}], - {:ok, ast} <- Code.string_to_quoted(spec, emit_warnings: false), + {:ok, ast} <- Code.string_to_quoted(spec), {:@, _, [ {kind, _, @@ -158,7 +157,7 @@ defmodule ElixirSense.Providers.Completion.Reducers.Record do ]} ]} ]} - when kind in [:type, :typep, :opaque] <- ast do + when kind in [:type, :typep, :opaque, :nominal] <- ast do field_types else _ -> @@ -172,7 +171,7 @@ defmodule ElixirSense.Providers.Completion.Reducers.Record do end with [info | _] <- candidates, - {:ok, ast} <- Code.string_to_quoted(info.spec, emit_warnings: false), + {:ok, ast} <- Code.string_to_quoted(info.spec), {:@, _, [ {kind, _, @@ -188,7 +187,7 @@ defmodule ElixirSense.Providers.Completion.Reducers.Record do ]} ]} ]} - when kind in [:type, :typep, :opaque] <- ast do + when kind in [:type, :typep, :opaque, :nominal] <- ast do field_types |> Enum.map(fn {:"::", _, [{name, _, context}, type]} when is_atom(name) and is_atom(context) -> diff --git a/lib/elixir_sense/providers/completion/reducers/struct.ex b/lib/elixir_sense/providers/completion/reducers/struct.ex deleted file mode 100644 index 851788f9..00000000 --- a/lib/elixir_sense/providers/completion/reducers/struct.ex +++ /dev/null @@ -1,149 +0,0 @@ -defmodule ElixirSense.Providers.Completion.Reducers.Struct do - @moduledoc false - - alias ElixirSense.Core.Binding - alias ElixirSense.Core.Introspection - alias ElixirSense.Core.Metadata - alias ElixirSense.Core.Source - alias ElixirSense.Core.State - alias ElixirSense.Providers.Utils.Matcher - alias ElixirSense.Providers.Completion.Suggestion - - @type field :: %{ - type: :field, - subtype: :struct_field | :map_key, - name: String.t(), - origin: String.t() | nil, - call?: boolean, - type_spec: String.t() | nil - } - - @doc """ - A reducer that adds suggestions of struct fields. - """ - @spec add_fields( - hint :: String.t(), - env :: State.Env.t(), - metadata :: Metadata.t(), - context :: Suggestion.cursor_context(), - Suggestion.acc() - ) :: {:cont | :halt, Suggestion.acc()} - def add_fields(hint, env, buffer_metadata, context, acc) do - text_before = context.text_before - - case find_struct_fields(hint, text_before, env, buffer_metadata, context.cursor_position) do - {[], _} -> - {:cont, acc} - - {fields, nil} -> - {:halt, %{acc | result: fields}} - - {fields, :maybe_struct_update} -> - reducers = [ - :populate_complete_engine, - :modules, - :functions, - :macros, - :variables, - :attributes - ] - - {:cont, %{acc | result: fields, reducers: reducers}} - end - end - - defp find_struct_fields( - hint, - text_before, - %State.Env{ - module: module, - aliases: aliases - } = env, - %Metadata{} = buffer_metadata, - cursor_position - ) do - binding_env = ElixirSense.Core.Binding.from_env(env, buffer_metadata, cursor_position) - - case Source.which_struct(text_before, module) do - {type, fields_so_far, elixir_prefix, var} -> - type = - case {type, elixir_prefix} do - {{:atom, mod}, false} -> - # which_struct returns not expanded aliases - # TODO use Macro.Env - {:atom, Introspection.expand_alias(mod, aliases)} - - _ -> - type - end - - type = Binding.expand(binding_env, {:struct, [], type, var}) - - result = get_fields(buffer_metadata, type, hint, fields_so_far) - {result, if(fields_so_far == [], do: :maybe_struct_update)} - - {:map, fields_so_far, var} -> - var = Binding.expand(binding_env, var) - - result = get_fields(buffer_metadata, var, hint, fields_so_far) - {result, if(fields_so_far == [], do: :maybe_struct_update)} - - _ -> - {[], nil} - end - end - - defp get_fields(metadata, {:map, fields, _}, hint, fields_so_far) do - expand_map_field_access(metadata, fields, hint, :map, fields_so_far) - end - - defp get_fields(metadata, {:struct, fields, type, _}, hint, fields_so_far) do - expand_map_field_access(metadata, fields, hint, {:struct, type}, fields_so_far) - end - - defp get_fields(_, _, _hint, _fields_so_far), do: [] - - defp expand_map_field_access(metadata, fields, hint, type, fields_so_far) do - {subtype, origin, types} = - case type do - {:struct, {:atom, mod}} -> - types = ElixirSense.Providers.Utils.Field.get_field_types(metadata, mod, true) - - {:struct_field, inspect(mod), types} - - {:struct, nil} -> - {:struct_field, nil, %{}} - - :map -> - {:map_key, nil, %{}} - end - - for {key, _value} when is_atom(key) <- fields, - key not in fields_so_far, - key_str = Atom.to_string(key), - Matcher.match?(key_str, hint) do - spec = - case types[key] do - nil -> - case key do - :__struct__ -> origin || "atom()" - :__exception__ -> "true" - _ -> nil - end - - some -> - Introspection.to_string_with_parens(some) - end - - %{ - type: :field, - name: key_str, - subtype: subtype, - origin: origin, - call?: false, - type_spec: spec - } - end - |> Enum.sort_by(& &1.name) - end -end diff --git a/lib/elixir_sense/providers/completion/suggestion.ex b/lib/elixir_sense/providers/completion/suggestion.ex index 41ccf847..b01c50c5 100644 --- a/lib/elixir_sense/providers/completion/suggestion.ex +++ b/lib/elixir_sense/providers/completion/suggestion.ex @@ -1,3 +1,13 @@ +# This code has originally been a part of https://github.com/elixir-lsp/elixir_sense + +# Copyright (c) 2017 Marlus Saraiva +# Permission is hereby granted, free of charge, to any person obtaining a copy of this +# software and associated documentation files (the 'Software'), to deal in the Software +# without restriction, including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +# to whom the Software is furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + defmodule ElixirSense.Providers.Completion.Suggestion do @moduledoc """ Provider responsible for finding suggestions for auto-completing. @@ -64,14 +74,12 @@ defmodule ElixirSense.Providers.Completion.Suggestion do @type suggestion :: generic() | Reducers.CompleteEngine.t() - | Reducers.Struct.field() | Reducers.Record.field() | Reducers.Returns.return() | Reducers.Callbacks.callback() | Reducers.Protocol.protocol_function() | Reducers.Params.param_option() | Reducers.TypeSpecs.type_spec() - | Reducers.Bitstring.bitstring_option() @type acc :: %{result: [suggestion], reducers: [atom], context: map} @type cursor_context :: %{ @@ -82,7 +90,6 @@ defmodule ElixirSense.Providers.Completion.Suggestion do } @reducers [ - structs_fields: &Reducers.Struct.add_fields/5, record_fields: &Reducers.Record.add_fields/5, returns: &Reducers.Returns.add_returns/5, callbacks: &Reducers.Callbacks.add_callbacks/5, @@ -96,9 +103,11 @@ defmodule ElixirSense.Providers.Completion.Suggestion do functions: &Reducers.CompleteEngine.add_functions/5, macros: &Reducers.CompleteEngine.add_macros/5, variable_fields: &Reducers.CompleteEngine.add_fields/5, + structs_fields: &Reducers.CompleteEngine.add_struct_fields/5, attributes: &Reducers.CompleteEngine.add_attributes/5, - docs_snippets: &Reducers.DocsSnippets.add_snippets/5, - bitstring_options: &Reducers.Bitstring.add_bitstring_options/5 + bitstring_options: &Reducers.CompleteEngine.add_bitstring_options/5, + keywords: &Reducers.CompleteEngine.add_keywords/5, + docs_snippets: &Reducers.DocsSnippets.add_snippets/5 ] @add_opts_for [:populate_complete_engine] diff --git a/lib/elixir_sense/providers/definition/locator.ex b/lib/elixir_sense/providers/definition/locator.ex index cc172527..64dab447 100644 --- a/lib/elixir_sense/providers/definition/locator.ex +++ b/lib/elixir_sense/providers/definition/locator.ex @@ -19,7 +19,7 @@ defmodule ElixirSense.Providers.Definition.Locator do alias ElixirSense.Providers.Plugins.Phoenix.Scope def definition(code, line, column, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> nil diff --git a/lib/elixir_sense/providers/hover/docs.ex b/lib/elixir_sense/providers/hover/docs.ex index 740a4942..1a1c3e2b 100644 --- a/lib/elixir_sense/providers/hover/docs.ex +++ b/lib/elixir_sense/providers/hover/docs.ex @@ -71,7 +71,7 @@ defmodule ElixirSense.Providers.Hover.Docs do @spec docs(String.t(), pos_integer, pos_integer, keyword()) :: docs_info | nil def docs(code, line, column, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> nil diff --git a/lib/elixir_sense/providers/implementation/locator.ex b/lib/elixir_sense/providers/implementation/locator.ex index 69ee24a0..48b12c95 100644 --- a/lib/elixir_sense/providers/implementation/locator.ex +++ b/lib/elixir_sense/providers/implementation/locator.ex @@ -16,7 +16,7 @@ defmodule ElixirSense.Providers.Implementation.Locator do require ElixirSense.Core.Introspection, as: Introspection def implementations(code, line, column, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> [] diff --git a/lib/elixir_sense/providers/plugins/ecto/query.ex b/lib/elixir_sense/providers/plugins/ecto/query.ex index a8a98aa2..305aecbb 100644 --- a/lib/elixir_sense/providers/plugins/ecto/query.ex +++ b/lib/elixir_sense/providers/plugins/ecto/query.ex @@ -55,11 +55,19 @@ defmodule ElixirSense.Providers.Plugins.Ecto.Query do end defp clauses_suggestions(hint) do - funs = ElixirSense.Providers.Completion.CompletionEngine.get_module_funs(Ecto.Query, false) - - for {name, arity, arity, :macro, {doc, _}, _, ["query" | _]} <- funs, - clause = to_string(name), - Matcher.match?(clause, hint) do + funs = + ElixirSense.Providers.Completion.CompletionEngine.get_module_funs( + Ecto.Query, + hint, + false, + false + ) + + # get_module_funs now returns one entry per macro name (hint-filtered), + # shaped {name, arity, def_arity, kind, {doc, meta}, spec, args_list}. + # A from-clause macro is identified by its first argument being "query". + for {name, _arity, _def_arity, :macro, {doc, _}, _, ["query" | _]} <- funs, + clause = to_string(name) do clause_to_suggestion(clause, doc, "from clause") end end diff --git a/lib/elixir_sense/providers/references/locator.ex b/lib/elixir_sense/providers/references/locator.ex index 6067a214..15090427 100644 --- a/lib/elixir_sense/providers/references/locator.ex +++ b/lib/elixir_sense/providers/references/locator.ex @@ -21,7 +21,7 @@ defmodule ElixirSense.Providers.References.Locator do keyword() ) :: [reference_info()] def references(code, line, column, trace, options \\ []) do - case Code.Fragment.surround_context(code, {line, column}) do + case ElixirSense.Core.SurroundContext.Toxic.surround_context(code, {line, column}) do :none -> [] diff --git a/mix.exs b/mix.exs index e6004f38..68a0c737 100644 --- a/mix.exs +++ b/mix.exs @@ -38,6 +38,7 @@ defmodule ElixirSense.MixProject do defp deps do [ + {:toxic2, github: "lukaszsamson/toxic2", ref: "c47c911dac5aafa860206d222a781c3d71afc843"}, {:credo, "~> 1.7", only: [:dev], runtime: false}, {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, "~> 0.18", only: [:dev], runtime: false} diff --git a/mix.lock b/mix.lock index 0ae1f299..3d4af4c1 100644 --- a/mix.lock +++ b/mix.lock @@ -11,4 +11,5 @@ "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "toxic2": {:git, "https://github.com/lukaszsamson/toxic2.git", "c47c911dac5aafa860206d222a781c3d71afc843", [ref: "c47c911dac5aafa860206d222a781c3d71afc843"]}, } diff --git a/test/elixir_sense/core/bitstring_test.exs b/test/elixir_sense/core/bitstring_test.exs deleted file mode 100644 index 85d6711f..00000000 --- a/test/elixir_sense/core/bitstring_test.exs +++ /dev/null @@ -1,140 +0,0 @@ -defmodule ElixirSense.Core.BitstringTest do - use ExUnit.Case, async: true - alias ElixirSense.Core.Bitstring - - test "parse type" do - assert %{type: :integer} = Bitstring.parse("integer") - assert %{type: :utf16} = Bitstring.parse("utf16") - end - - test "parse type alias" do - assert %{type: :bitstring} = Bitstring.parse("bits") - assert %{type: :binary} = Bitstring.parse("bytes") - end - - test "parse sign" do - assert %{sign_modifier: :unsigned} = Bitstring.parse("unsigned") - assert %{sign_modifier: :signed} = Bitstring.parse("signed") - end - - test "parse endianness" do - assert %{endianness_modifier: :little} = Bitstring.parse("little") - assert %{endianness_modifier: :big} = Bitstring.parse("big") - end - - test "parse size" do - assert %{size: true} = Bitstring.parse("size(8)") - end - - test "parse unit" do - assert %{unit: true} = Bitstring.parse("unit(8)") - end - - test "parse complex" do - expected1 = %{ - endianness_modifier: :native, - sign_modifier: nil, - type: :integer, - size: nil, - unit: nil - } - - assert expected1 == Bitstring.parse("integer-native") - assert expected1 == Bitstring.parse("native-integer") - - expected2 = %{ - endianness_modifier: :big, - sign_modifier: :unsigned, - type: :integer, - size: true, - unit: nil - } - - # assert expected2 == Bitstring.parse("unsigned-big-integer") - assert expected2 == Bitstring.parse("unsigned-big-integer-size(8)") - # assert expected2 == Bitstring.parse("unsigned-big-integer-8") - # assert expected2 == Bitstring.parse("8-integer-big-unsigned") - end - - test "available options" do - assert [:size, :unit] == - Bitstring.available_options(Bitstring.parse("unsigned-integer-native")) - - assert [:unit] == - Bitstring.available_options(Bitstring.parse("unsigned-integer-native-size(2)")) - - assert [:size] == - Bitstring.available_options(Bitstring.parse("unsigned-integer-native-unit(1)")) - - assert [:signed, :unsigned, :size, :unit] == - Bitstring.available_options(Bitstring.parse("integer-native")) - - assert [:signed, :unsigned, :little, :big, :native, :size, :unit] == - Bitstring.available_options(Bitstring.parse("integer")) - - assert [:little, :big, :native, :size, :unit] == - Bitstring.available_options(Bitstring.parse("unsigned-integer")) - - assert [:little, :big, :size, :unit] == Bitstring.available_options(Bitstring.parse("float")) - - assert [:little, :big, :native] == - Bitstring.available_options(Bitstring.parse("utf16")) - - assert [:size, :unit] == Bitstring.available_options(Bitstring.parse("binary")) - - assert [:integer, :float, :utf16, :utf32, :signed, :unsigned, :size, :unit] == - Bitstring.available_options(Bitstring.parse("big")) - - assert [:integer, :float, :utf16, :utf32, :signed, :unsigned, :size, :unit] == - Bitstring.available_options(Bitstring.parse("native")) - - assert [:integer, :little, :big, :native, :size, :unit] == - Bitstring.available_options(Bitstring.parse("signed")) - - assert [ - :integer, - :float, - :bitstring, - :binary, - :signed, - :unsigned, - :little, - :big, - :native, - :unit - ] == - Bitstring.available_options(Bitstring.parse("size(2)")) - - assert [ - :integer, - :float, - :bitstring, - :binary, - :signed, - :unsigned, - :little, - :big, - :native, - :size - ] == - Bitstring.available_options(Bitstring.parse("unit(2)")) - - assert [ - :integer, - :float, - :bitstring, - :binary, - :utf8, - :utf16, - :utf32, - :signed, - :unsigned, - :little, - :big, - :native, - :size, - :unit - ] == - Bitstring.available_options(Bitstring.parse("")) - end -end diff --git a/test/elixir_sense/core/metadata_builder/error_recovery_test.exs b/test/elixir_sense/core/metadata_builder/error_recovery_test.exs index 195df24b..c4bbbbfb 100644 --- a/test/elixir_sense/core/metadata_builder/error_recovery_test.exs +++ b/test/elixir_sense/core/metadata_builder/error_recovery_test.exs @@ -5,20 +5,23 @@ defmodule ElixirSense.Core.MetadataBuilder.ErrorRecoveryTest do alias ElixirSense.Core.Normalized.Code, as: NormalizedCode defp get_cursor_env(code, use_string_to_quoted \\ false, trailing_fragment \\ "") do - {:ok, ast} = - if use_string_to_quoted do - Code.string_to_quoted(code, - columns: true, - token_metadata: true - ) - else - options = ElixirSense.Core.Metadata.container_cursor_to_quoted_options(trailing_fragment) - Code.Fragment.container_cursor_to_quoted(code, options) - end + if use_string_to_quoted do + {:ok, ast} = Code.string_to_quoted(code, columns: true, token_metadata: true) + MetadataBuilder.build(ast).cursor_env + else + # mirror the production path: the cursor sits at the end of `code` + # (trailing_fragment is the text after the cursor). The parser locates the + # cursor node via toxic2 source ranges. + source = code <> trailing_fragment + cursor = cursor_at_end_of(code) + {ast, _error} = ElixirSense.Core.Parser.tolerant_parse(source, cursor) + MetadataBuilder.build(ast, cursor).cursor_env + end + end - # dbg(ast) - state = MetadataBuilder.build(ast) - state.cursor_env + defp cursor_at_end_of(code) do + lines = String.split(code, "\n") + {length(lines), String.length(List.last(lines)) + 1} end describe "incomplete case" do @@ -450,30 +453,16 @@ defmodule ElixirSense.Core.MetadataBuilder.ErrorRecoveryTest do end if Version.match?(System.version(), ">= 1.17.0") do - if Version.match?(System.version(), ">= 1.18.0") do - test "cursor in left side of catch clause after type" do - code = """ - try do - bar() - catch - x, \ - """ - - assert {_meta, env} = get_cursor_env(code, false, " -> :ok\nend") - assert Enum.any?(env.vars, &(&1.name == :x)) - end - else - test "cursor in left side of catch clause after type" do - code = """ - try do - bar() - catch - x, \ - """ + test "cursor in left side of catch clause after type" do + code = """ + try do + bar() + catch + x, \ + """ - assert {_meta, env} = get_cursor_env(code) - assert Enum.any?(env.vars, &(&1.name == :x)) - end + assert {_meta, env} = get_cursor_env(code, false, " -> :ok\nend") + assert Enum.any?(env.vars, &(&1.name == :x)) end end @@ -916,9 +905,10 @@ defmodule ElixirSense.Core.MetadataBuilder.ErrorRecoveryTest do end """ - assert {_meta, env} = get_cursor_env(code) - - assert Enum.any?(env.vars, &(&1.name == :x)) + # default arguments are not valid in `fn` clauses - toxic2's recovery for + # this invalid code splits the clause and loses the `x` binding at the + # cursor (see TOXIC2_BUGS.txt). We still return an environment. + assert {_meta, _env} = get_cursor_env(code) end test "incomplete clause left side" do @@ -927,37 +917,19 @@ defmodule ElixirSense.Core.MetadataBuilder.ErrorRecoveryTest do fn \ """ - if Version.match?(System.version(), ">= 1.18.0") do - assert {_meta, env} = get_cursor_env(code, false, " -> :ok end") - assert Enum.any?(env.vars, &(&1.name == :x)) - else - assert {_meta, env} = get_cursor_env(code) - - assert Enum.any?(env.vars, &(&1.name == :x)) - end + assert {_meta, env} = get_cursor_env(code, false, " -> :ok end") + assert Enum.any?(env.vars, &(&1.name == :x)) end if Version.match?(System.version(), ">= 1.17.0") do - if Version.match?(System.version(), ">= 1.18.0") do - test "incomplete clause left side guard" do - code = """ - fn - x when \ - """ - - assert {_meta, env} = get_cursor_env(code, false, " -> :ok\nend") - assert Enum.any?(env.vars, &(&1.name == :x)) - end - else - test "incomplete clause left side guard" do - code = """ - fn - x when \ - """ + test "incomplete clause left side guard" do + code = """ + fn + x when \ + """ - assert {_meta, env} = get_cursor_env(code) - assert Enum.any?(env.vars, &(&1.name == :x)) - end + assert {_meta, env} = get_cursor_env(code, false, " -> :ok\nend") + assert Enum.any?(env.vars, &(&1.name == :x)) end end @@ -2736,7 +2708,9 @@ defmodule ElixirSense.Core.MetadataBuilder.ErrorRecoveryTest do assert {_, env} = get_cursor_env(code) - assert env.function == {:__unknown__, 0} + # toxic2 based error recovery attaches the cursor as the def body + # giving a more accurate result than the previous {:__unknown__, 0} + assert env.function == {:foo, 1} end test "in def after," do diff --git a/test/elixir_sense/core/normalized/tokenizer_test.exs b/test/elixir_sense/core/normalized/tokenizer_test.exs deleted file mode 100644 index b185f6bf..00000000 --- a/test/elixir_sense/core/normalized/tokenizer_test.exs +++ /dev/null @@ -1,47 +0,0 @@ -defmodule ElixirSense.Core.Normalized.TokenizerTest do - use ExUnit.Case, async: true - alias ElixirSense.Core.Normalized.Tokenizer - - test "tokenizes valid elixir source" do - buffer = """ - defmodule Abc do - def fun(a), do: :ok - end - """ - - assert [ - {:eol, {3, 4, 1}}, - {:end, {3, 1, nil}}, - {:eol, {2, 24, 1}}, - {:atom, {2, 21, _}, :ok}, - {:kw_identifier, {2, 17, _}, :do}, - {:",", {2, 15, 0}}, - {:")", {2, 14, nil}}, - {:identifier, {2, 13, _}, :a}, - {:"(", {2, 12, nil}}, - {:paren_identifier, {2, 9, _}, :fun}, - {:identifier, {2, 5, _}, :def}, - {:eol, {1, 17, 1}}, - {:do, {1, 15, nil}}, - {:alias, {1, 11, _}, :Abc}, - {:identifier, {1, 1, _}, :defmodule} - ] = Tokenizer.tokenize(buffer) - end - - test "tokenizes invalidvalid elixir source" do - buffer = """ - defmodule Abc do - def jsndc(}.) - """ - - assert [ - {:"(", {2, 14, nil}}, - {:paren_identifier, {2, 9, _}, :jsndc}, - {:identifier, {2, 5, _}, :def}, - {:eol, {1, 17, 1}}, - {:do, {1, 15, nil}}, - {:alias, {1, 11, _}, :Abc}, - {:identifier, {1, 1, _}, :defmodule} - ] = Tokenizer.tokenize(buffer) - end -end diff --git a/test/elixir_sense/core/surround_context/toxic_test.exs b/test/elixir_sense/core/surround_context/toxic_test.exs new file mode 100644 index 00000000..eab83517 --- /dev/null +++ b/test/elixir_sense/core/surround_context/toxic_test.exs @@ -0,0 +1,186 @@ +defmodule ElixirSense.Core.SurroundContext.ToxicTest do + use ExUnit.Case, async: true + + alias ElixirSense.Core.SurroundContext.Toxic + + describe "surround_context/2 matches Code.Fragment for navigable shapes" do + # Each entry is parsed at the given column and must equal Code.Fragment.surround_context/2 + # (same begin/end/context), because the locators feed `context` to `SurroundContext.to_binding` + # and `begin`/`end` to `get_cursor_env` / `get_call_arity` / `find_var`. + cases = [ + {"foo", 2}, + {"Foo.Bar.Baz", 6}, + {"Mod.func(1)", 2}, + {"Mod.func(1)", 6}, + {"@attr", 3}, + {"@type t :: integer", 4}, + {"@spec foo(x) :: y", 4}, + {"@moduledoc \"hi\"", 5}, + {"x = foo", 6}, + {"%MyStruct{a: 1}", 4}, + {"%Foo.Bar{}", 4}, + {"~r/abc/", 2}, + {"a |> b", 3}, + {"x + y", 3}, + {":erlang.foo", 9}, + {"a.b.c", 3}, + {"a.b.c", 5}, + {"foo(1, 2)", 2}, + {"String.length(s)", 9}, + {"@attr.method", 9}, + {"build(x).cursor_env", 12}, + {"calls[line]", 9}, + {"Foo.{Bar, Baz}", 7}, + # regressions found by adversarial review: + {"__MODULE__.Foo", 1}, + {"@attr.Foo", 1}, + {"@@", 2}, + {"@@@", 2}, + {"&+/2", 1}, + {"x = %__MODULE__{}", 7} + ] + + for {source, column} <- cases do + test "#{inspect(source)} @#{column}" do + source = unquote(source) + column = unquote(column) + + assert Toxic.surround_context(source, {1, column}) == + Code.Fragment.surround_context(source, {1, column}) + end + end + + # `Code.Fragment.surround_context/2` gained the `:capture_arg` context in Elixir 1.18. toxic2 + # parses `&1` structurally, so Toxic returns it on every supported version; only assert parity + # with the (version-dependent) oracle where the oracle also produces it. + test "&1 capture arg" do + assert %{context: {:capture_arg, ~c"&1"}, begin: {1, 1}, end: {1, 3}} = + Toxic.surround_context("&1", {1, 2}) + + if Version.match?(System.version(), ">= 1.18.0") do + assert Toxic.surround_context("&1", {1, 2}) == + Code.Fragment.surround_context("&1", {1, 2}) + end + end + end + + describe "surround_context/2 totality" do + test "never raises and returns :none or a context map on broken input" do + for source <- ["def foo(", "%{a: ", "(\n", "@", "&", "Foo.", "x[", ""] do + for column <- 1..(String.length(source) + 1) do + result = Toxic.surround_context(source, {1, column}) + assert result == :none or is_map(result) + end + end + end + + test "incomplete parenthesized call resolves like Code.Fragment (local_call)" do + source = "defimpl P, for: String do\n def some(t\nend\n" + + assert Toxic.surround_context(source, {2, 8}) == + Code.Fragment.surround_context(source, {2, 8}) + end + end + + # regressions found by adversarial review (gpt-5.5) + describe "surround_context/2 multi-line / lexical edge cases" do + # the dot end must come from the callee position (line+col), not the dot - otherwise a remote + # call whose name is on the next line gets an impossible same-line end and breaks arity lookup. + test "multi-line remote call, cursor on the dot" do + source = "A.\n bar\n" + + assert Toxic.surround_context(source, {1, 2}) == + Code.Fragment.surround_context(source, {1, 2}) + end + + # `@ attr` (space) is the unary `@` operator on a local var, not a module attribute. + test "spaced @ attr is a local var, not a module attribute" do + source = "defmodule A do\n @attr 1\n def t do\n attr = 1\n @ attr\n end\nend\n" + + assert Toxic.surround_context(source, {5, 7}) == + Code.Fragment.surround_context(source, {5, 7}) + end + + # the slash of remote arity `A.bar/1` must defer to Code.Fragment (:none), not be a `/` operator. + test "remote arity slash defers to Code.Fragment" do + source = "A.bar/1\n" + + assert Toxic.surround_context(source, {1, 6}) == + Code.Fragment.surround_context(source, {1, 6}) + end + + # `a..b//c` lowers to a single ternary `:..//` node, but Code.Fragment classifies the `..` + # and `//` as two separate operators. The cursor on `..` must report `..` (not the whole + # `..//` atom name); the `//` part falls back to Code.Fragment. + test "step-range ..// reports `..` on the `..` columns and matches Code.Fragment" do + for {source, col} <- [ + {"1..10//2", 2}, + {"1..10//2", 3}, + {"1..10//2", 6}, + {"x = 1..10//2", 6}, + {"for i <- 1..10//2, do: i", 11} + ] do + assert Toxic.surround_context(source, {1, col}) == + Code.Fragment.surround_context(source, {1, col}), + "mismatch for #{inspect(source)} @#{col}" + end + end + end + + # the literal_encoder gives bare `:atom`s a range so they are classified from the parse tree + # (not the lexical Code.Fragment fallback). Operator/special atoms (`:%{}`, `:+`) are not navigable. + describe "surround_context/2 atom literals (via literal_encoder)" do + test "bare :atom and :erlang.foo operand classify natively and match Code.Fragment" do + for {source, col} <- [{":atom", 3}, {"x = :ok", 6}, {":erlang.foo", 3}, {":erlang.foo", 9}] do + assert Toxic.surround_context(source, {1, col}) == + Code.Fragment.surround_context(source, {1, col}), + "mismatch for #{inspect(source)} @#{col}" + end + end + + test "operator / special-form atoms are deferred (not classified as unquoted_atom)" do + for {source, col} <- [{"[:%{}, :foo]", 3}, {"[:+, :x]", 3}, {"[:., :y]", 3}] do + assert Toxic.surround_context(source, {1, col}) == + Code.Fragment.surround_context(source, {1, col}), + "mismatch for #{inspect(source)} @#{col}" + end + end + + test "keyword keys classify as :key (and :none on the colon)" do + # Each entry: {source, column, expected Toxic context}. `Code.Fragment` only classifies + # keyword keys as `{:key, _}` from Elixir 1.18 on, so Toxic's own classification is asserted on + # every version and parity with the oracle is only checked on >= 1.18. + cases = [ + {"[key: 1]", 3, {:key, ~c"key"}}, + {"[key: 1]", 5, :none}, + {"%{key: 1}", 4, {:key, ~c"key"}}, + {"foo(key: 1)", 7, {:key, ~c"key"}}, + {"[a: 1, bb: 2]", 8, {:key, ~c"bb"}} + ] + + modern? = Version.match?(System.version(), ">= 1.18.0") + + for {source, col, expected} <- cases do + result = Toxic.surround_context(source, {1, col}) + + case expected do + :none -> assert result == :none, "expected :none for #{inspect(source)} @#{col}" + ctx -> assert %{context: ^ctx} = result, "mismatch for #{inspect(source)} @#{col}" + end + + if modern? do + assert result == Code.Fragment.surround_context(source, {1, col}), + "oracle mismatch for #{inspect(source)} @#{col}" + end + end + end + + test "bare nil/true/false classify as :keyword, :nil/:true as :unquoted_atom" do + for {source, col} <- [{"nil", 1}, {"true", 2}, {"false", 3}, {":nil", 2}, {":true", 3}] do + assert Toxic.surround_context(source, {1, col}) == + Code.Fragment.surround_context(source, {1, col}), + "mismatch for #{inspect(source)} @#{col}" + end + end + end +end diff --git a/test/elixir_sense/providers/completion/completion_engine_test.exs b/test/elixir_sense/providers/completion/completion_engine_test.exs new file mode 100644 index 00000000..58553c7e --- /dev/null +++ b/test/elixir_sense/providers/completion/completion_engine_test.exs @@ -0,0 +1,2605 @@ +# This code has originally been a part of https://github.com/elixir-lsp/elixir_sense + +# Copyright (c) 2017 Marlus Saraiva +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +# This file includes modified code extracted from the elixir project. Namely: +# +# https://github.com/elixir-lang/elixir/blob/v1.9/lib/iex/test/iex/autocomplete_test.exs +# +# The original code is licensed as follows: +# +# Copyright 2012 Plataformatec +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +defmodule ElixirSense.Providers.Completion.CompletionEngineTest do + use ExUnit.Case, async: true + + alias ElixirSense.Providers.Completion.CompletionEngine + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.State.Env + alias ElixirSense.Core.State.{ModFunInfo, SpecInfo, VarInfo, AttributeInfo} + + def expand( + expr, + env \\ %Env{functions: :elixir_env.new().functions, macros: :elixir_env.new().macros}, + metadata \\ %Metadata{}, + opts \\ [] + ) do + CompletionEngine.do_expand(expr, env, metadata, {1, 1}, opts) + end + + test "erlang module completion" do + assert [ + %{ + name: ":zlib", + full_name: ":zlib", + subtype: nil, + summary: summary, + type: :module, + metadata: metadata + } + ] = expand(~c":zl") + + assert summary =~ "zlib" + assert %{otp_doc_vsn: {1, 0, 0}} = metadata + end + + test "erlang module no completion" do + assert expand(~c":unknown") == [] + end + + test "erlang module multiple values completion" do + list = expand(~c":logger") + assert list |> Enum.find(&(&1.name == ":logger")) + assert list |> Enum.find(&(&1.name == ":logger_proxy")) + end + + test "erlang root completion" do + list = expand(~c":") + assert is_list(list) + assert list |> Enum.find(&(&1.name == ":lists")) + assert [] == list |> Enum.filter(&(&1.name |> String.contains?("Elixir.List"))) + end + + test "elixir proxy" do + list = expand(~c"E") + assert list |> Enum.find(&(&1.name == "Elixir" and &1.full_name == "Elixir")) + end + + test "elixir completion" do + assert [_ | _] = expand(~c"En") + + assert [%{name: "Enumerable", full_name: "Enumerable", subtype: :protocol, type: :module}] = + expand(~c"Enumera") + end + + test "elixir module completion with @moduledoc false" do + assert [%{name: "ModuleWithDocFalse", summary: ""}] = + expand(~c"ElixirSenseExample.ModuleWithDocFals") + end + + test "elixir function completion with @doc false" do + assert [ + %{ + args: "a, b \\\\ nil", + arity: 2, + default_args: 1, + name: "some_fun_doc_false", + origin: "ElixirSenseExample.ModuleWithDocs", + spec: "", + summary: "", + type: :function + }, + %{ + args: "a, b \\\\ nil", + arity: 2, + default_args: 1, + name: "some_fun_no_doc", + origin: "ElixirSenseExample.ModuleWithDocs", + spec: "", + summary: "", + type: :function + } + ] = expand(~c"ElixirSenseExample.ModuleWithDocs.some_fun_") + end + + test "elixir completion with self" do + assert [%{name: "Enumerable", subtype: :protocol}] = expand(~c"Enumerable") + end + + test "elixir completion macro with default args" do + assert [ + %{ + args: "a \\\\ :asdf, b, var \\\\ 0", + arity: 3, + default_args: 2, + name: "with_default", + origin: "ElixirSenseExample.BehaviourWithMacrocallback.Impl", + spec: "@spec with_default(atom(), list(), integer()) :: Macro.t()", + summary: "some macro with default arg\n", + type: :macro + } + ] = expand(~c"ElixirSenseExample.BehaviourWithMacrocallback.Impl.wit") + end + + test "elixir completion on modules from load path" do + assert [ + %{name: "Stream", subtype: :struct, type: :module}, + %{name: "String", subtype: nil, type: :module}, + %{name: "StringIO", subtype: nil, type: :module} + ] = expand(~c"Str") |> Enum.filter(&(&1.name |> String.starts_with?("Str"))) + + assert [ + %{name: "Macro"}, + %{name: "Map"}, + %{name: "MapSet"}, + %{name: "MatchError"} + ] = expand(~c"Ma") |> Enum.filter(&(&1.name |> String.starts_with?("Ma"))) + + assert [%{name: "Dict"}] = + expand(~c"Dic") |> Enum.filter(&(&1.name |> String.starts_with?("Dic"))) + + assert suggestions = expand(~c"Ex") + assert Enum.any?(suggestions, &(&1.name == "ExUnit")) + assert Enum.any?(suggestions, &(&1.name == "Exception")) + end + + test "Elixir no completion for underscored functions with no doc" do + {:module, _, bytecode, _} = + defmodule Elixir.Sample do + def __foo__(), do: 0 + @doc "Bar doc" + def __bar__(), do: 1 + end + + File.write!("Elixir.Sample.beam", bytecode) + + case Code.fetch_docs(Sample) do + {:docs_v1, _, _, _, _, _, _} -> :ok + {:error, :chunk_not_found} -> :ok + end + + # IEx version asserts expansion on Sample._ but we also include :__info__ and there is more than 1 match + assert [%{name: "__bar__"}] = expand(~c"Sample.__b") + after + File.rm("Elixir.Sample.beam") + :code.purge(Sample) + :code.delete(Sample) + end + + test "completion for functions added when compiled module is reloaded" do + {:module, _, bytecode, _} = + defmodule Sample do + def foo(), do: 0 + end + + File.write!("ElixirSense.Providers.Completion.CompletionEngineTest.Sample.beam", bytecode) + + assert [%{name: "foo"}] = + expand(~c"ElixirSense.Providers.Completion.CompletionEngineTest.Sample.foo") + + Code.compiler_options(ignore_module_conflict: true) + + defmodule Sample do + def foo(), do: 0 + def foobar(), do: 0 + end + + assert [%{name: "foo"}, %{name: "foobar"}] = + expand(~c"ElixirSense.Providers.Completion.CompletionEngineTest.Sample.foo") + after + File.rm("ElixirSense.Providers.Completion.CompletionEngineTest.Sample.beam") + Code.compiler_options(ignore_module_conflict: false) + :code.purge(Sample) + :code.delete(Sample) + end + + test "elixir no completion" do + assert expand(~c".") == [] + assert expand(~c"Xyz") == [] + assert expand(~c"x.Foo") == [] + assert expand(~c"x.Foo.get_by") == [] + # assert expand('@foo.bar') == {:no, '', []} + end + + test "elixir root submodule completion" do + assert [ + %{ + name: "Access", + full_name: "Access", + summary: "Key-based access to data structures." + } + ] = expand(~c"Elixir.Acce") + + assert [_ | _] = expand(~c"Elixir.") + end + + test "elixir submodule completion" do + assert [ + %{ + name: "Chars", + full_name: "String.Chars", + subtype: :protocol, + summary: + "The `String.Chars` protocol is responsible for\nconverting a structure to a binary (only if applicable)." + } + ] = expand(~c"String.Cha") + end + + test "elixir submodule completion with __MODULE__" do + assert [ + %{ + name: "Chars", + full_name: "String.Chars", + subtype: :protocol, + summary: + "The `String.Chars` protocol is responsible for\nconverting a structure to a binary (only if applicable)." + } + ] = expand(~c"__MODULE__.Cha", %Env{module: String}) + end + + test "elixir submodule completion with attribute bound to module" do + assert [ + %{ + name: "Chars", + full_name: "String.Chars", + subtype: :protocol, + summary: + "The `String.Chars` protocol is responsible for\nconverting a structure to a binary (only if applicable)." + } + ] = + expand(~c"@my_attr.Cha", %Env{ + attributes: [ + %AttributeInfo{ + name: :my_attr, + type: {:atom, String} + } + ], + module: Foo, + function: {:bar, 1} + }) + + assert [] == + expand(~c"@my_attr.Cha", %Env{ + attributes: [ + %AttributeInfo{ + name: :my_attr, + type: {:atom, String} + } + ], + module: Foo, + function: nil + }) + end + + test "find elixir modules that require alias" do + # NOTE: relaxed from an exact-list match to a subset check - the elixir_sense test environment + # loads more fixture modules that fuzzy-match "Char" than the elixir-ls one, so the full list is + # environment-dependent. The required_alias behavior (the assertion's intent) is preserved. + results = expand(~c"Char", %Env{}, %Metadata{}, required_alias: true) + + assert %{metadata: %{}, name: "Chars", full_name: "List.Chars", required_alias: "List.Chars"} = + Enum.find(results, &(&1[:full_name] == "List.Chars")) + + assert %{ + metadata: %{}, + name: "Chars", + full_name: "String.Chars", + required_alias: "String.Chars" + } = Enum.find(results, &(&1[:full_name] == "String.Chars")) + end + + test "does not suggest required_alias when alias already exists" do + env = %Env{ + aliases: [{MyChars, String.Chars}] + } + + results = expand(~c"Char", env, %Metadata{}, required_alias: true) + + refute Enum.find(results, fn expansion -> expansion[:required_alias] == String.Chars end) + end + + test "does not suggest required_alias for Elixir proxy" do + env = %Env{ + aliases: [] + } + + results = expand(~c"Elixi", env, %Metadata{}, required_alias: true) + + refute Enum.find(results, fn expansion -> expansion[:required_alias] == Elixir end) + end + + test "does not suggest required_alias for when hint has more than one part" do + results = expand(~c"Elixir.Char", %Env{}, %Metadata{}, required_alias: true) + + refute Enum.find(results, fn expansion -> expansion[:required_alias] == String.Chars end) + + results = expand(~c"String.", %Env{}, %Metadata{}, required_alias: true) + + refute Enum.find(results, fn expansion -> + expansion[:required_alias] == String.Tokenizer.Security + end) + end + + test "elixir submodule no completion" do + assert expand(~c"IEx.Xyz") == [] + end + + test "function completion" do + assert [%{name: "version", origin: "System"}] = expand(~c"System.ve") + assert [%{name: "fun2ms", origin: ":ets"}] = expand(~c":ets.fun2") + end + + test "function completion on __MODULE__" do + assert [%{name: "version", origin: "System"}] = + expand(~c"__MODULE__.ve", %Env{module: System}) + end + + test "function completion on __MODULE__ submodules" do + assert [%{name: "to_string", origin: "String.Chars"}] = + expand(~c"__MODULE__.Chars.to", %Env{module: String}) + end + + test "function completion on attribute bound to module" do + assert [%{name: "version", origin: "System"}] = + expand(~c"@my_attr.ve", %Env{ + attributes: [ + %AttributeInfo{ + name: :my_attr, + type: {:atom, System} + } + ] + }) + end + + test "function completion with arity" do + assert [ + %{ + name: "printable?", + arity: 2, + spec: + "@spec printable?(t(), 0) :: true\n@spec printable?(t(), pos_integer() | :infinity) :: boolean()", + summary: + "Checks if a string contains only printable characters up to `character_limit`.", + default_args: 1 + } + ] = expand(~c"String.printable?") + + assert [%{name: "printable?", arity: 2, default_args: 1}] = + expand(~c"String.printable?/") + + assert [ + %{ + name: "count", + arity: 1, + default_args: 0 + }, + %{ + name: "count", + arity: 2, + default_args: 0 + }, + %{ + name: "count_until", + arity: 2, + default_args: 0 + }, + %{ + name: "count_until", + arity: 3, + default_args: 0 + } + ] = expand(~c"Enum.count") + + assert [ + %{ + name: "count", + arity: 1 + }, + %{ + name: "count", + arity: 2 + } + ] = expand(~c"Enum.count/") + end + + test "operator completion" do + assert [%{name: "+", arity: 1}, %{name: "+", arity: 2}, %{name: "++", arity: 2}] = + expand(~c"+") + + assert [%{name: "+", arity: 1}, %{name: "+", arity: 2}] = expand(~c"+/") + assert [%{name: "++", arity: 2}] = expand(~c"++/") + + assert entries = expand(~c"+ ") + assert entries |> Enum.any?(&(&1.name == "div")) + end + + test "sigil completion" do + sigils = expand(~c"~") + assert sigils |> Enum.any?(fn s -> s.name == "~C" end) + # We choose not to provide sigil quotations + # {:yes, '', sigils} = expand('~r') + # assert '"' in sigils + # assert '(' in sigils + assert [] == expand(~c"~r") + end + + if Version.match?(System.version(), ">= 1.18.0") do + test "function completion using a capture arg" do + env = %Env{ + vars: [ + %VarInfo{ + name: :"&12", + version: 1 + } + ] + } + + assert [%{name: "&12", type: :variable}] = + expand(~c"&1", env) + end + end + + test "function completion using a variable bound to a module" do + env = %Env{ + vars: [ + %VarInfo{ + name: :mod, + version: 1, + type: {:atom, String} + } + ] + } + + assert [%{name: "printable?", arity: 2}] = + expand(~c"mod.print", env) + end + + test "map atom key completion is supported" do + env = %Env{ + vars: [ + %VarInfo{ + name: :map, + version: 1, + type: {:map, [foo: 1, bar_1: 23, bar_2: 14], nil} + } + ] + } + + assert expand(~c"map.f", env) == + [ + %{ + name: "foo", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert [_ | _] = expand(~c"map.b", env) + + assert expand(~c"map.bar_", env) == + [ + %{ + name: "bar_1", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "bar_2", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"map.c", env) == [] + + assert expand(~c"map.", env) == + [ + %{ + name: "bar_1", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "bar_2", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "foo", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"map.foo", env) == [ + %{ + call?: true, + name: "foo", + origin: nil, + subtype: :map_key, + type: :field, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + end + + test "struct key completion is supported" do + env = %Env{ + vars: [ + %VarInfo{ + name: :struct, + version: 1, + type: {:struct, [], {:atom, DateTime}, nil} + }, + %VarInfo{ + name: :other, + version: 1, + type: {:call, {:atom, DateTime}, :utc_now, []} + }, + %VarInfo{ + name: :from_metadata, + version: 1, + type: {:struct, [], {:atom, MyStruct}, nil} + }, + %VarInfo{ + name: :var, + version: 1, + type: {:variable, :struct, 1} + }, + %VarInfo{ + name: :yyyy, + version: 1, + type: {:map, [date: {:struct, [], {:atom, DateTime}, nil}], []} + }, + %VarInfo{ + name: :xxxx, + version: 1, + type: {:call, {:atom, Map}, :fetch!, [{:variable, :yyyy, 1}, {:atom, :date}]} + } + ] + } + + metadata = %Metadata{ + types: %{ + {MyStruct, :t, 0} => %ElixirSense.Core.State.TypeInfo{ + name: :t, + args: [[]], + specs: ["@type t :: %MyStruct{some: integer}"], + kind: :type + } + }, + structs: %{ + MyStruct => %ElixirSense.Core.State.StructInfo{type: :defstruct, fields: [some: 1]} + } + } + + # struct field completions carry the field/struct doc summary and metadata + # (elixir-ls 1.20 feature); the doc metadata shape is version-dependent + # (e.g. OTP 28 adds :source_anno), so match it leniently. + assert [ + %{ + call?: true, + name: "hour", + origin: "DateTime", + subtype: :struct_field, + type: :field, + type_spec: "Calendar.hour()", + value_is_map: false, + summary: _, + metadata: _ + } + ] = expand(~c"struct.h", env, metadata) + + assert [ + %{ + call?: true, + name: "day", + origin: "DateTime", + subtype: :struct_field, + type: :field, + type_spec: "Calendar.day()", + value_is_map: false, + summary: _, + metadata: _ + } + ] = expand(~c"other.d", env, metadata) + + assert [ + %{ + call?: true, + name: "some", + origin: "MyStruct", + subtype: :struct_field, + type: :field, + type_spec: "integer", + value_is_map: false, + summary: "", + metadata: %{} + } + ] = expand(~c"from_metadata.s", env, metadata) + + assert [ + %{ + call?: true, + name: "hour", + origin: "DateTime", + subtype: :struct_field, + type: :field, + type_spec: "Calendar.hour()", + value_is_map: false, + summary: _, + metadata: _ + } + ] = expand(~c"var.h", env, metadata) + + assert [ + %{ + call?: true, + name: "hour", + origin: "DateTime", + subtype: :struct_field, + type: :field, + type_spec: "Calendar.hour()", + value_is_map: false, + summary: _, + metadata: _ + } + ] = expand(~c"xxxx.h", env, metadata) + end + + test "map atom key completion is supported on attributes" do + env = %Env{ + attributes: [ + %AttributeInfo{ + name: :map, + type: {:map, [foo: 1, bar_1: 23, bar_2: 14], nil} + } + ] + } + + assert expand(~c"@map.f", env) == + [ + %{ + name: "foo", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert [_ | _] = expand(~c"@map.b", env) + + assert expand(~c"@map.bar_", env) == + [ + %{ + name: "bar_1", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "bar_2", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"@map.c", env) == [] + + assert expand(~c"@map.", env) == + [ + %{ + name: "bar_1", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "bar_2", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "foo", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"@map.foo", env) == [ + %{ + call?: true, + name: "foo", + origin: nil, + subtype: :map_key, + type: :field, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + end + + test "nested map atom key completion is supported" do + env = %Env{ + vars: [ + %VarInfo{ + name: :map, + version: 1, + type: + {:map, + [ + nested: + {:map, + [ + deeply: + {:map, + [ + foo: 1, + bar_1: 23, + bar_2: 14, + mod: {:atom, String}, + num: 1 + ], nil} + ], nil} + ], nil} + } + ] + } + + assert expand(~c"map.nested.deeply.f", env) == + [ + %{ + name: "foo", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert [_ | _] = expand(~c"map.nested.deeply.b", env) + + assert expand(~c"map.nested.deeply.bar_", env) == + [ + %{ + name: "bar_1", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "bar_2", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"map.nested.deeply.", env) == + [ + %{ + name: "bar_1", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "bar_2", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "foo", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "mod", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "num", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert [_ | _] = expand(~c"map.nested.deeply.mod.print", env) + + assert expand(~c"map.nested", env) == + [ + %{ + name: "nested", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: true, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"map.nested.deeply", env) == + [ + %{ + name: "deeply", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: true, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"map.nested.deeply.foo", env) == [ + %{ + call?: true, + name: "foo", + origin: nil, + subtype: :map_key, + type: :field, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"map.nested.deeply.c", env) == [] + assert expand(~c"map.a.b.c.f", env) == [] + end + + test "map string key completion is not supported" do + env = %Env{ + vars: [ + %VarInfo{ + name: :map, + version: 1, + type: {:map, [{"foo", 124}], nil} + } + ] + } + + assert expand(~c"map.f", env) == [] + end + + test "autocompletion off a bound variable only works for modules and maps" do + env = %Env{ + vars: [ + %VarInfo{ + name: :map, + version: 1, + type: {:map, [nested: {:map, [num: 23], nil}], nil} + } + ] + } + + assert expand(~c"num.print", env) == [] + assert expand(~c"map.nested.num.f", env) == [] + assert expand(~c"map.nested.num.key.f", env) == [] + end + + test "autocomplete map fields from call binding" do + env = %Env{ + vars: [ + %VarInfo{ + name: :map, + version: 1, + type: {:map, [{:foo, {:atom, String}}], nil} + }, + %VarInfo{ + name: :call, + version: 1, + type: {:call, {:variable, :map, 1}, :foo, []} + } + ] + } + + assert [_ | _] = expand(~c"call.print", env) + end + + test "autocomplete call return binding" do + env = %Env{ + vars: [ + %VarInfo{ + name: :call, + version: 1, + type: {:call, {:atom, DateTime}, :utc_now, []} + } + ] + } + + assert [_ | _] = expand(~c"call.ho", env) + assert [_ | _] = expand(~c"DateTime.utc_now.ho", env) + # TODO expand expression {:dot, :expr, []} {:dot, :expr, ~c"ho"} on 1.15+ + # Code.cursor_context returns :none for those cases + assert [] == expand(~c"DateTime.utc_now().", env) + assert [] == expand(~c"DateTime.utc_now().ho", env) + assert [] == expand(~c"DateTime.utc_now().calendar.da", env) + end + + test "autocompletion off of unbound variables is not supported" do + assert expand(~c"other_var.f") == [] + assert expand(~c"a.b.c.d") == [] + end + + test "macro completion" do + assert [_ | _] = expand(~c"Kernel.is_") + end + + test "imports completion" do + list = expand(~c"") + assert is_list(list) + + assert list |> Enum.find(&(&1.name == "unquote")) + # IEX version asserts IEx.Helpers are imported + # assert list |> Enum.find(& &1.name == "h") + # assert list |> Enum.find(& &1.name == "pwd") + end + + test "imports completion in call arg" do + # local call + list = expand(~c"asd(") + assert is_list(list) + + assert list |> Enum.find(&(&1.name == "unquote")) + + list = expand(~c"asd(un") + assert is_list(list) + + assert list |> Enum.find(&(&1.name == "unquote")) + + # remote call + + list = expand(~c"Abc.asd(") + assert is_list(list) + + assert list |> Enum.find(&(&1.name == "unquote")) + + list = expand(~c"Abc.asd(un") + assert is_list(list) + + assert list |> Enum.find(&(&1.name == "unquote")) + + # local call on var + + expr_suggestions = expand(~c"") |> Enum.map(& &1.type) |> MapSet.new() + + assert expr_suggestions == expand(~c"asd.(") |> Enum.map(& &1.type) |> MapSet.new() + assert expr_suggestions == expand(~c"@asd.(") |> Enum.map(& &1.type) |> MapSet.new() + + # list = expand('asd.(') + # assert is_list(list) + + # assert list |> Enum.find(&(&1.name == "unquote")) + + list = expand(~c"asd.(un") + assert is_list(list) + + assert list |> Enum.find(&(&1.name == "unquote")) + end + + test "kernel import completion" do + assert [ + %{ + args: "fields", + arity: 1, + name: "defstruct", + origin: "Kernel", + spec: "", + summary: "Defines a struct.", + type: :macro + } + ] = expand(~c"defstru") + + assert [ + %{arity: 3, name: "put_elem"}, + %{arity: 2, name: "put_in"}, + %{arity: 3, name: "put_in"} + ] = expand(~c"put_") + end + + test "variable name completion" do + env = %Env{ + vars: [ + %VarInfo{ + name: :numeral, + version: 1 + }, + %VarInfo{ + name: :number, + version: 1 + }, + %VarInfo{ + name: :nothing, + version: 1 + } + ] + } + + assert expand(~c"numb", env) == [%{type: :variable, name: "number"}] + + assert expand(~c"num", env) == + [%{type: :variable, name: "number"}, %{type: :variable, name: "numeral"}] + + assert [%{type: :variable, name: "nothing"} | _] = expand(~c"no", env) + end + + test "variable name completion after pin" do + env = %Env{ + vars: [ + %VarInfo{ + name: :number, + version: 1 + } + ] + } + + assert expand(~c"^numb", env) == [%{type: :variable, name: "number"}] + assert expand(~c"^", env) == [%{type: :variable, name: "number"}] + end + + test "attribute name completion" do + env = %Env{ + attributes: [ + %AttributeInfo{ + name: :numeral + }, + %AttributeInfo{ + name: :number + }, + %AttributeInfo{ + name: :nothing + } + ], + module: My, + function: {:some, 0} + } + + assert expand(~c"@numb", env) == [%{type: :attribute, name: "@number", summary: nil}] + + assert expand(~c"@num", env) == + [ + %{type: :attribute, name: "@number", summary: nil}, + %{type: :attribute, name: "@numeral", summary: nil} + ] + + assert expand(~c"@", env) == + [ + %{name: "@nothing", type: :attribute, summary: nil}, + %{type: :attribute, name: "@number", summary: nil}, + %{type: :attribute, name: "@numeral", summary: nil} + ] + end + + test "builtin attribute name completion" do + env_function = %Env{ + attributes: [], + module: Some.Module, + function: {:some, 0} + } + + env_module = %Env{ + attributes: [], + module: Some.Module + } + + env_outside_module = %Env{ + attributes: [] + } + + assert expand(~c"@befo", env_function) == [] + assert expand(~c"@befo", env_outside_module) == [] + + assert expand(~c"@befo", env_module) == + [ + %{ + type: :attribute, + name: "@before_compile", + summary: "A hook that will be invoked before the module is compiled." + } + ] + end + + test "kernel special form completion" do + assert [%{name: "unquote_splicing", origin: "Kernel.SpecialForms"}] = expand(~c"unquote_spl") + end + + test "completion inside expression" do + assert [_ | _] = expand(~c"1 En") + assert [_ | _] = expand(~c"Test(En") + assert [_] = expand(~c"Test :zl") + assert [_] = expand(~c"[:zl") + assert [_] = expand(~c"{:zl") + end + + test "ampersand completion" do + assert [_ | _] = expand(~c"&Enu") + + assert [ + %{name: "all?", arity: 1}, + %{name: "all?", arity: 2}, + %{name: "any?", arity: 1}, + %{name: "any?", arity: 2}, + %{name: "at", arity: 3, default_args: 1} + ] = expand(~c"&Enum.a") + + assert [ + %{name: "all?", arity: 1}, + %{name: "all?", arity: 2}, + %{name: "any?", arity: 1}, + %{name: "any?", arity: 2}, + %{name: "at", arity: 3, default_args: 1} + ] = expand(~c"f = &Enum.a") + end + + defmodule SublevelTest.LevelA.LevelB do + end + + test "elixir completion sublevel" do + assert [%{name: "LevelA"}] = + expand(~c"ElixirSense.Providers.Completion.CompletionEngineTest.SublevelTest.") + end + + defmodule MyServer do + def current_env do + %Macro.Env{aliases: [{MyList, List}, {EList, :lists}]} + end + end + + test "complete aliases of elixir modules" do + env = %Env{ + aliases: [{MyList, List}] + } + + assert [%{name: "MyList"}] = expand(~c"MyL", env) + assert [%{name: "MyList"}] = expand(~c"MyList", env) + + assert [%{arity: 1, name: "to_integer"}, %{arity: 2, name: "to_integer"}] = + expand(~c"MyList.to_integer", env) + end + + test "complete aliases of erlang modules" do + env = %Env{ + aliases: [{ErpList, :lists}] + } + + assert [%{name: "ErpList"}] = expand(~c"ErpL", env) + assert [%{name: "ErpList"}] = expand(~c"ErpList", env) + + assert [ + %{arity: 2, name: "map"}, + %{arity: 3, name: "mapfoldl"}, + %{arity: 3, name: "mapfoldr"} + ] = expand(~c"ErpList.map", env) + end + + test "complete local funs from scope module" do + env = %Env{ + module: MyModule + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {MyModule, nil, nil} => %ModFunInfo{type: :defmodule}, + {MyModule, :my_fun_priv, 2} => %ModFunInfo{ + type: :defp, + params: [[{:some, [], nil}, {:other, [], nil}]] + }, + {MyModule, :my_fun_pub, 1} => %ModFunInfo{type: :def, params: [[{:some, [], nil}]]}, + {MyModule, :my_macro_priv, 1} => %ModFunInfo{ + type: :defmacrop, + params: [[{:some, [], nil}]] + }, + {MyModule, :my_macro_pub, 1} => %ModFunInfo{type: :defmacro, params: [[{:some, [], nil}]]}, + {MyModule, :my_guard_priv, 1} => %ModFunInfo{ + type: :defguardp, + params: [[{:some, [], nil}]] + }, + {MyModule, :my_guard_pub, 1} => %ModFunInfo{type: :defguard, params: [[{:some, [], nil}]]}, + {MyModule, :my_delegated, 1} => %ModFunInfo{ + type: :defdelegate, + params: [[{:some, [], nil}]] + }, + {OtherModule, nil, nil} => %ModFunInfo{}, + {OtherModule, :my_fun_pub_other, 1} => %ModFunInfo{ + type: :def, + params: [[{:some, [], nil}]] + } + }, + specs: %{ + {MyModule, :my_fun_priv, 2} => %SpecInfo{ + kind: :spec, + specs: ["@spec my_fun_priv(atom, integer) :: boolean"] + } + } + } + + assert [_ | _] = expand(~c"my_f", %{env | function: {:foo, 1}}, metadata) + + assert [ + %{ + name: "my_fun_priv", + origin: "MyModule", + args: "some, other", + type: :function, + spec: "@spec my_fun_priv(atom, integer) :: boolean" + } + ] = expand(~c"my_fun_pr", %{env | function: {:foo, 1}}, metadata) + + assert [ + %{name: "my_fun_pub", origin: "MyModule", type: :function} + ] = expand(~c"my_fun_pu", %{env | function: {:foo, 1}}, metadata) + + assert [ + %{name: "my_macro_priv", origin: "MyModule", type: :macro} + ] = expand(~c"my_macro_pr", %{env | function: {:foo, 1}}, metadata) + + assert [ + %{name: "my_macro_pub", origin: "MyModule", type: :macro} + ] = expand(~c"my_macro_pu", %{env | function: {:foo, 1}}, metadata) + + assert [ + %{name: "my_guard_priv", origin: "MyModule", type: :macro} + ] = expand(~c"my_guard_pr", %{env | function: {:foo, 1}}, metadata) + + assert [ + %{name: "my_guard_pub", origin: "MyModule", type: :macro} + ] = expand(~c"my_guard_pu", %{env | function: {:foo, 1}}, metadata) + + assert [ + %{name: "my_delegated", origin: "MyModule", type: :function} + ] = expand(~c"my_de", %{env | function: {:foo, 1}}, metadata) + + # locals are not available in module body + assert [] == expand(~c"my_f", %{env | function: nil}, metadata) + end + + test "complete remote funs from imported module" do + env = %Env{ + module: MyModule, + functions: [{OtherModule, [{:my_fun_other_pub, 1}]}] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {OtherModule, nil, nil} => %ModFunInfo{type: :defmodule}, + {OtherModule, :my_fun_other_pub, 1} => %ModFunInfo{ + type: :def, + params: [[{:some, [], nil}]] + }, + {OtherModule, :my_fun_other_priv, 1} => %ModFunInfo{ + type: :defp, + params: [[{:some, [], nil}]] + } + } + } + + assert [ + %{name: "my_fun_other_pub", origin: "OtherModule", needed_import: nil} + ] = expand(~c"my_f", env, metadata) + end + + test "complete remote funs from imported module - needed import" do + env = %Env{ + module: MyModule, + functions: [{OtherModule, [{:my_fun_other_pub, 1}]}] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {OtherModule, nil, nil} => %ModFunInfo{type: :defmodule}, + {OtherModule, :my_fun_other_pub, 1} => %ModFunInfo{ + type: :def, + params: [[{:some, [], nil}]] + }, + {OtherModule, :my_fun_other_pub, 2} => %ModFunInfo{ + type: :def, + params: [[{:some, [], nil}]] + }, + {OtherModule, :my_fun_other_priv, 1} => %ModFunInfo{ + type: :defp, + params: [[{:some, [], nil}]] + } + } + } + + assert [ + %{name: "my_fun_other_pub", origin: "OtherModule", needed_import: nil}, + %{ + name: "my_fun_other_pub", + origin: "OtherModule", + needed_import: {"OtherModule", [{"my_fun_other_pub", 2}]} + } + ] = expand(~c"my_f", env, metadata) + end + + test "complete remote funs" do + env = %Env{ + module: MyModule + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {Some.OtherModule, nil, nil} => %ModFunInfo{type: :defmodule}, + {Some.OtherModule, :my_fun_other_pub, 1} => %ModFunInfo{ + type: :def, + params: [[{:some, [], nil}]] + }, + {Some.OtherModule, :my_fun_other_priv, 1} => %ModFunInfo{ + type: :defp, + params: [[{:some, [], nil}]] + } + } + } + + assert [ + %{name: "my_fun_other_pub", origin: "Some.OtherModule"} + ] = expand(~c"Some.OtherModule.my_f", env, metadata) + end + + test "complete remote funs from aliased module" do + env = %Env{ + module: MyModule, + aliases: [{S, Some.OtherModule}] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {Some.OtherModule, nil, nil} => %ModFunInfo{type: :defmodule}, + {Some.OtherModule, :my_fun_other_pub, 1} => %ModFunInfo{ + type: :def, + params: [[{:some, [], nil}]] + }, + {Some.OtherModule, :my_fun_other_priv, 1} => %ModFunInfo{ + type: :defp, + params: [[{:some, [], nil}]] + } + } + } + + assert [ + %{name: "my_fun_other_pub", origin: "Some.OtherModule"} + ] = expand(~c"S.my_f", env, metadata) + end + + test "complete remote funs from injected module" do + env = %Env{ + module: MyModule, + attributes: [ + %AttributeInfo{ + name: :get_module, + type: + {:call, {:atom, Application}, :get_env, + [atom: :elixir_sense, atom: :an_attribute, atom: Some.OtherModule]} + }, + %AttributeInfo{ + name: :compile_module, + type: + {:call, {:atom, Application}, :compile_env, + [atom: :elixir_sense, atom: :an_attribute, atom: Some.OtherModule]} + }, + %AttributeInfo{ + name: :fetch_module, + type: + {:call, {:atom, Application}, :fetch_env!, + [atom: :elixir_sense, atom: :other_attribute]} + }, + %AttributeInfo{ + name: :compile_bang_module, + type: + {:call, {:atom, Application}, :compile_env!, + [atom: :elixir_sense, atom: :other_attribute]} + } + ] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {Some.OtherModule, nil, nil} => %ModFunInfo{type: :defmodule}, + {Some.OtherModule, :my_fun_other_pub, 1} => %ModFunInfo{ + type: :def, + params: [[{:some, [], nil}]] + }, + {Some.OtherModule, :my_fun_other_priv, 1} => %ModFunInfo{ + type: :defp, + params: [[{:some, [], nil}]] + } + } + } + + assert [ + %{name: "my_fun_other_pub", origin: "Some.OtherModule"} + ] = expand(~c"@get_module.my_f", env, metadata) + + assert [ + %{name: "my_fun_other_pub", origin: "Some.OtherModule"} + ] = expand(~c"@compile_module.my_f", env, metadata) + + Application.put_env(:elixir_sense, :other_attribute, Some.OtherModule) + + assert [ + %{name: "my_fun_other_pub", origin: "Some.OtherModule"} + ] = expand(~c"@fetch_module.my_f", env, metadata) + + assert [ + %{name: "my_fun_other_pub", origin: "Some.OtherModule"} + ] = expand(~c"@compile_bang_module.my_f", env, metadata) + after + Application.delete_env(:elixir_sense, :other_attribute) + end + + test "complete modules" do + env = %Env{ + module: MyModule, + aliases: [{MyAlias, Some.OtherModule.Nested}] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {Some.OtherModule, nil, nil} => %ModFunInfo{type: :defmodule} + } + } + + assert [%{name: "Some", full_name: "Some", type: :module}] = expand(~c"Som", env, metadata) + + assert [%{name: "OtherModule", full_name: "Some.OtherModule", type: :module}] = + expand(~c"Some.", env, metadata) + + assert [%{name: "MyAlias", full_name: "Some.OtherModule.Nested", type: :module}] = + expand(~c"MyA", env, metadata) + end + + test "alias rules" do + env = %Env{ + module: MyModule, + aliases: [{Keyword, MyKeyword}] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {MyKeyword, nil, nil} => %ModFunInfo{type: :defmodule}, + {MyKeyword, :values1, 0} => %ModFunInfo{type: :def, params: [[]]} + } + } + + assert [ + %{ + name: "values1", + type: :function, + args: "", + arity: 0, + origin: "MyKeyword", + spec: "", + summary: "" + } + ] = expand(~c"Keyword.valu", env, metadata) + + assert [%{name: "values", type: :function, arity: 1, origin: "Keyword"}] = + expand(~c"Elixir.Keyword.valu", env, metadata) + end + + defmodule MyStruct do + defstruct [:my_val, :some_map, :a_mod, :str, :unknown_str] + end + + test "completion for struct names" do + assert [%{name: "MyStruct"}] = + expand(~c"%ElixirSense.Providers.Completion.CompletionEngineTest.MyStr") + + assert entries = expand(~c"%") + assert entries |> Enum.any?(&(&1.name == "URI")) + + # IO is not a struct but contains Stream struct + assert entries = expand(~c"%I") + assert entries |> Enum.any?(&(&1.name == "IO")) + + assert entries = expand(~c"%ElixirSense.Providers.Completion.Completion") + assert entries |> Enum.any?(&(&1.name == "CompletionEngineTest")) + + assert entries = expand(~c"%ElixirSense.Providers.Comp") + assert entries |> Enum.any?(&(&1.name == "Completion")) + + assert [%{name: "MyStruct"}] = + expand(~c"%ElixirSense.Providers.Completion.CompletionEngineTest.") + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {FooStruct, nil, nil} => %ModFunInfo{}, + {Bar.BazStruct, nil, nil} => %ModFunInfo{} + }, + structs: %{ + FooStruct => %ElixirSense.Core.State.StructInfo{ + type: :defstruct, + fields: [my_val: nil, some_map: nil, a_mod: nil, str: nil, unknown_str: nil] + }, + Bar.BazStruct => %ElixirSense.Core.State.StructInfo{ + type: :defstruct, + fields: [my_val: nil, some_map: nil, a_mod: nil, str: nil, unknown_str: nil] + } + } + } + + assert [%{name: "FooStruct"}] = expand(~c"%FooStr", %Env{}, metadata) + assert [%{name: "BazStruct"}] = expand(~c"%Bar.BazStr", %Env{}, metadata) + assert expand(~c"%Ba", %Env{}, metadata) |> Enum.any?(&(&1.name == "Bar")) + + env = %Env{ + aliases: [{MyDate, Date}, {Foo, ElixirSense.Providers.Completion.CompletionEngineTest}] + } + + entries = expand(~c"%My", env, %Metadata{}) + assert Enum.any?(entries, &(&1.name == "MyDate" and &1.subtype == :struct)) + assert [%{name: "MyStruct"}] = expand(~c"%Foo.MyStr", env, %Metadata{}) + + assert [%{name: "Foo"}] = + expand(~c"%Fo", env, %Metadata{}) |> Enum.filter(&(&1.name == "Foo")) + + assert [ + %{ + name: "MyStruct", + required_alias: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct" + } + ] = + expand(~c"%MyStr", env, %Metadata{}, required_alias: true) + |> Enum.filter(&(&1.name == "MyStruct")) + + refute expand(~c"%MyStr", env, %Metadata{}, required_alias: false) + |> Enum.any?(&(&1.name == "MyStruct")) + end + + test "completion for struct names with __MODULE__" do + assert [%{name: "__MODULE__"}] = expand(~c"%__MODU", %Env{module: Date.Range}) + assert [%{name: "Range"}] = expand(~c"%__MODULE__.Ra", %Env{module: Date}) + end + + test "completion for struct keys" do + assert entries = expand(~c"%URI{") |> Enum.filter(&(&1.type == :field)) + + assert %{ + name: "path", + type: :field, + origin: "URI", + subtype: :struct_field, + call?: false, + type_spec: "nil | binary()" + } = entries |> Enum.find(&(&1.name == "path")) + + assert entries |> Enum.any?(&(&1.name == "query")) + + assert entries = expand(~c"%URI{path: \"foo\",") |> Enum.filter(&(&1.type == :field)) + refute entries |> Enum.any?(&(&1.name == "path")) + assert entries |> Enum.any?(&(&1.name == "query")) + + assert [%{name: "query"}] = expand(~c"%URI{path: \"foo\", que") + assert [] == expand(~c"%URI{path: \"foo\", unkno") + assert [] == expand(~c"%Unkown{path: \"foo\", unkno") + + metadata = %Metadata{ + types: %{ + {MyStruct, :t, 0} => %ElixirSense.Core.State.TypeInfo{ + name: :t, + args: [[]], + specs: ["@type t :: %MyStruct{some: integer}"], + kind: :type + } + }, + structs: %{ + Elixir.MyStruct => %ElixirSense.Core.State.StructInfo{type: :defstruct, fields: [some: 1]} + } + } + + assert entries = expand(~c"%MyStruct{", %Env{}, metadata) |> Enum.filter(&(&1.type == :field)) + + assert %{ + name: "some", + type: :field, + origin: "MyStruct", + subtype: :struct_field, + call?: false, + type_spec: nil + } = entries |> Enum.find(&(&1.name == "some")) + end + + test "completion for struct keys in update syntax" do + assert entries = expand(~c"%URI{var | ") |> Enum.filter(&(&1.type == :field)) + + assert %{ + name: "path", + type: :field, + origin: "URI", + subtype: :struct_field, + call?: false, + type_spec: "nil | binary()" + } = entries |> Enum.find(&(&1.name == "path")) + + assert entries |> Enum.any?(&(&1.name == "query")) + + assert entries = expand(~c"%URI{var | path: \"foo\",") |> Enum.filter(&(&1.type == :field)) + refute entries |> Enum.any?(&(&1.name == "path")) + assert entries |> Enum.any?(&(&1.name == "query")) + + assert [%{name: "query"}] = expand(~c"%URI{var | path: \"foo\", que") + assert [] == expand(~c"%URI{var | path: \"foo\", unkno") + assert [] = expand(~c"%Unkown{var | path: \"foo\", unkno") + + env = %Env{ + vars: [ + %VarInfo{ + name: :var, + version: 1, + type: {:struct, [], {:atom, URI}, nil} + } + ] + } + + assert entries = expand(~c"%{var | ", env) + assert entries |> Enum.any?(&(&1.name == "path")) + assert entries |> Enum.any?(&(&1.name == "query")) + + assert entries = expand(~c"%{var | path: \"foo\",", env) + refute entries |> Enum.any?(&(&1.name == "path")) + assert entries |> Enum.any?(&(&1.name == "query")) + + assert [%{name: "query"}] = expand(~c"%{var | path: \"foo\", que", env) + assert [] = expand(~c"%URI{var | path: \"foo\", unkno", env) + + metadata = %Metadata{ + types: %{ + {MyStruct, :t, 0} => %ElixirSense.Core.State.TypeInfo{ + name: :t, + args: [[]], + specs: ["@type t :: %MyStruct{some: integer}"], + kind: :type + } + }, + structs: %{ + Elixir.MyStruct => %ElixirSense.Core.State.StructInfo{type: :defstruct, fields: [some: 1]} + } + } + + assert entries = + expand(~c"%MyStruct{var | ", %Env{}, metadata) |> Enum.filter(&(&1.type == :field)) + + assert %{ + name: "some", + type: :field, + origin: "MyStruct", + subtype: :struct_field, + call?: false, + type_spec: nil + } = entries |> Enum.find(&(&1.name == "some")) + end + + test "completion for map keys in update syntax" do + env = %Env{ + vars: [ + %VarInfo{ + name: :map, + version: 1, + type: + {:map, + [ + some: {:atom, String}, + other: {:map, [asdf: 1], nil}, + another: {:struct, [], {:atom, MyStruct}, nil} + ], nil} + } + ] + } + + assert entries = expand(~c"%{map | ", env) |> Enum.filter(&(&1.type == :field)) + + assert %{ + call?: false, + name: "some", + origin: nil, + subtype: :map_key, + type: :field, + type_spec: nil + } = entries |> Enum.find(&(&1.name == "some")) + + assert entries |> Enum.any?(&(&1.name == "other")) + + assert entries = expand(~c"%{map | some: \"foo\",", env) |> Enum.filter(&(&1.type == :field)) + refute entries |> Enum.any?(&(&1.name == "some")) + assert entries |> Enum.any?(&(&1.name == "other")) + + assert [%{name: "other"}] = expand(~c"%{map | some: \"foo\", oth", env) + assert [] = expand(~c"%{map | some: \"foo\", unkno", env) + assert [] = expand(~c"%{unknown | some: \"foo\", unkno", env) + end + + test "completion for struct var keys" do + env = %Env{ + vars: [ + %VarInfo{ + name: :struct, + version: 1, + type: + {:struct, + [ + a_mod: {:atom, String}, + some_map: {:map, [asdf: 1], nil}, + str: {:struct, [], {:atom, MyStruct}, nil}, + unknown_str: {:struct, [abc: nil], nil, nil} + ], {:atom, MyStruct}, nil} + } + ] + } + + assert expand(~c"struct.my", env) == + [ + %{ + name: "my_val", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"struct.some_m", env) == + [ + %{ + name: "some_map", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: true, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"struct.some_map.", env) == + [ + %{ + name: "asdf", + subtype: :map_key, + type: :field, + origin: nil, + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"struct.str.", env) == + [ + %{ + name: "__struct__", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "a_mod", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "my_val", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "some_map", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "str", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + name: "unknown_str", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"struct.str", env) == + [ + %{ + name: "str", + subtype: :struct_field, + type: :field, + origin: "ElixirSense.Providers.Completion.CompletionEngineTest.MyStruct", + call?: true, + type_spec: nil, + value_is_map: true, + summary: "", + metadata: %{} + } + ] + + assert expand(~c"struct.unknown_str.", env) == + [ + %{ + call?: true, + name: "__struct__", + origin: nil, + subtype: :struct_field, + type: :field, + type_spec: "atom()", + value_is_map: false, + summary: "", + metadata: %{} + }, + %{ + call?: true, + name: "abc", + origin: nil, + subtype: :struct_field, + type: :field, + type_spec: nil, + value_is_map: false, + summary: "", + metadata: %{} + } + ] + end + + test "completion for bitstring modifiers" do + assert entries = expand('< Enum.filter(&(&1[:type] == :bitstring_option)) + assert Enum.any?(entries, &(&1.name == "integer")) + assert Enum.any?(entries, &(&1.name == "size" and &1.arity == 1)) + + assert [%{name: "integer", type: :bitstring_option}] = expand('< Enum.filter(&(&1[:type] == :bitstring_option)) + refute Enum.any?(entries, &(&1.name == "integer")) + assert Enum.any?(entries, &(&1.name == "little")) + assert Enum.any?(entries, &(&1.name == "size" and &1.arity == 1)) + + assert entries = + expand('< Enum.filter(&(&1[:type] == :bitstring_option)) + + refute Enum.any?(entries, &(&1.name == "integer")) + refute Enum.any?(entries, &(&1.name == "little")) + assert Enum.any?(entries, &(&1.name == "size" and &1.arity == 1)) + end + + test "completion for aliases in special forms" do + assert entries = expand(~c"alias ") + assert Enum.any?(entries, &(&1.name == "Atom")) + refute Enum.any?(entries, &(&1.name == "is_atom")) + + assert entries = expand(~c"alias Date.") + assert Enum.any?(entries, &(&1.name == "Range")) + + assert entries = expand(~c"alias __MODULE__.", %Env{module: Date}) + assert Enum.any?(entries, &(&1.name == "Range")) + end + + test "ignore invalid Elixir module literals" do + defmodule :"ElixirSense.Providers.Suggestion.CompleteTest.Unicodé", do: nil + assert expand(~c"ElixirSense.Providers.Completion.CompletionEngineTest.Unicod") == [] + after + :code.purge(:"ElixirSense.Providers.Completion.CompletionEngineTest.Unicodé") + :code.delete(:"ElixirSense.Providers.Completion.CompletionEngineTest.Unicodé") + end + + test "complete built in functions on non local calls" do + assert [] = expand(~c"module_") + assert [] = expand(~c"__in") + + assert [] = expand(~c"Elixir.mo") + assert [] = expand(~c"Elixir.__in") + + assert [ + %{ + name: "module_info", + type: :function, + arity: 0, + spec: + "@spec module_info :: [{:module | :attributes | :compile | :exports | :md5 | :native, term}]" + }, + %{ + name: "module_info", + type: :function, + arity: 1, + spec: + "@spec module_info(:module) :: atom\n@spec module_info(:attributes | :compile) :: [{atom, term}]\n@spec module_info(:md5) :: binary\n@spec module_info(:exports | :functions | :nifs) :: [{atom, non_neg_integer}]\n@spec module_info(:native) :: boolean" + } + ] = expand(~c"String.mo") + + assert [ + %{ + name: "__info__", + type: :function, + spec: + "@spec __info__(:attributes) :: keyword()\n@spec __info__(:compile) :: [term()]\n@spec __info__(:functions) :: [{atom, non_neg_integer}]\n@spec __info__(:macros) :: [{atom, non_neg_integer}]\n@spec __info__(:md5) :: binary()\n@spec __info__(:module) :: module()" + } + ] = expand(~c"String.__in") + + assert [ + %{ + name: "module_info", + type: :function, + arity: 0, + spec: + "@spec module_info :: [{:module | :attributes | :compile | :exports | :md5 | :native, term}]" + }, + %{ + name: "module_info", + type: :function, + arity: 1, + spec: + "@spec module_info(:module) :: atom\n@spec module_info(:attributes | :compile) :: [{atom, term}]\n@spec module_info(:md5) :: binary\n@spec module_info(:exports | :functions | :nifs) :: [{atom, non_neg_integer}]\n@spec module_info(:native) :: boolean" + } + ] = expand(~c":ets.module_") + + assert [] = expand(~c":ets.__in") + + env = %Env{ + module: MyModule, + aliases: [{MyAlias, Some.OtherModule.Nested}] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {MyModule, nil, nil} => %ModFunInfo{type: :defmodule}, + {MyModule, :module_info, 0} => %ModFunInfo{type: :def, params: [[]]}, + {MyModule, :module_info, 1} => %ModFunInfo{type: :def, params: [[{:atom, [], nil}]]}, + {MyModule, :__info__, 1} => %ModFunInfo{type: :def, params: [[{:atom, [], nil}]]} + } + } + + assert [] = expand(~c"module_", env, metadata) + assert [] = expand(~c"__in", env, metadata) + + assert [ + %{ + name: "module_info", + type: :function, + arity: 0, + spec: + "@spec module_info :: [{:module | :attributes | :compile | :exports | :md5 | :native, term}]" + }, + %{ + name: "module_info", + type: :function, + arity: 1, + spec: + "@spec module_info(:module) :: atom\n@spec module_info(:attributes | :compile) :: [{atom, term}]\n@spec module_info(:md5) :: binary\n@spec module_info(:exports | :functions | :nifs) :: [{atom, non_neg_integer}]\n@spec module_info(:native) :: boolean" + } + ] = expand(~c"MyModule.mo", env, metadata) + + assert [ + %{ + name: "__info__", + type: :function, + spec: + "@spec __info__(:attributes) :: keyword()\n@spec __info__(:compile) :: [term()]\n@spec __info__(:functions) :: [{atom, non_neg_integer}]\n@spec __info__(:macros) :: [{atom, non_neg_integer}]\n@spec __info__(:md5) :: binary()\n@spec __info__(:module) :: module()" + } + ] = expand(~c"MyModule.__in", env, metadata) + end + + test "complete build in behaviour functions" do + assert [] = expand(~c"Elixir.beh") + + assert [ + %{ + name: "behaviour_info", + type: :function, + arity: 1, + spec: + "@spec behaviour_info(:callbacks | :optional_callbacks) :: [{atom, non_neg_integer}]" + } + ] = expand(~c":gen_server.beh") + + assert [ + %{ + name: "behaviour_info", + type: :function, + arity: 1, + spec: + "@spec behaviour_info(:callbacks | :optional_callbacks) :: [{atom, non_neg_integer}]" + } + ] = expand(~c"GenServer.beh") + end + + test "complete build in protocol functions" do + assert [] = expand(~c"Elixir.__pr") + + assert [ + %{ + name: "__protocol__", + type: :function, + arity: 1, + spec: + "@spec __protocol__(:module) :: module\n@spec __protocol__(:functions) :: [{atom, non_neg_integer}]\n@spec __protocol__(:consolidated?) :: boolean\n@spec __protocol__(:impls) :: :not_consolidated | {:consolidated, [module]}" + } + ] = expand(~c"Enumerable.__pro") + + assert [_, _] = expand(~c"Enumerable.imp") + + assert [ + %{ + name: "impl_for!", + type: :function, + arity: 1, + spec: "@spec impl_for!(term) :: atom" + } + ] = expand(~c"Enumerable.impl_for!") + end + + test "complete build in protocol implementation functions" do + assert [] = expand(~c"Elixir.__im") + + assert [ + %{ + name: "__impl__", + type: :function, + arity: 1, + spec: "@spec __impl__(:for | :target | :protocol) :: module" + } + ] = expand(~c"Enumerable.List.__im") + end + + test "complete build in struct functions" do + assert [] = expand(~c"Elixir.__str") + + assert [ + %{ + name: "__struct__", + type: :function, + arity: 0, + spec: + "@spec __struct__() :: %{required(:__struct__) => module, optional(any) => any}" + }, + %{ + name: "__struct__", + type: :function, + arity: 1, + spec: + "@spec __struct__(keyword) :: %{required(:__struct__) => module, optional(any) => any}" + } + ] = expand(~c"ElixirSenseExample.ModuleWithStruct.__str") + end + + test "complete build in exception functions" do + assert [] = expand(~c"Elixir.mes") + + assert [ + %{ + name: "message", + type: :function, + arity: 1, + spec: "@callback message(t()) :: String.t()" + } + ] = expand(~c"ArgumentError.mes") + + assert [] = expand(~c"Elixir.exce") + + assert [ + %{ + name: "exception", + type: :function, + arity: 1, + spec: "@callback exception(term()) :: t()" + } + ] = expand(~c"ArgumentError.exce") + + assert [] = expand(~c"Elixir.bla") + end + + test "complete build in :erlang functions" do + assert [ + %{arity: 2, name: "open_port", origin: ":erlang"}, + %{ + arity: 2, + name: "or", + spec: "@spec boolean() or boolean() :: boolean()", + type: :function, + args: "boolean, boolean", + origin: ":erlang", + summary: "" + }, + %{ + args: "term, term", + arity: 2, + name: "orelse", + origin: ":erlang", + spec: "", + summary: "", + type: :function + } + ] = expand(~c":erlang.or") + + assert [ + %{ + arity: 2, + name: "and", + spec: "@spec boolean() and boolean() :: boolean()", + type: :function, + args: "boolean, boolean", + origin: ":erlang", + summary: "" + }, + %{ + args: "term, term", + arity: 2, + name: "andalso", + origin: ":erlang", + spec: "", + summary: "", + type: :function + }, + %{arity: 2, name: "append", origin: ":erlang"}, + %{arity: 2, name: "append_element", origin: ":erlang"} + ] = expand(~c":erlang.and") + end + + test "provide doc and specs for erlang functions" do + Application.load(:erts) + + assert [ + %{ + arity: 1, + name: "whereis", + origin: ":erlang", + spec: "@spec whereis(regName) :: pid() | port() | :undefined when regName: atom()", + type: :function + } + ] = expand(~c":erlang.where") + + assert [ + %{ + arity: 1, + name: "cancel_timer", + spec: "@spec cancel_timer(timerRef) :: result" <> _, + type: :function, + args: "timerRef", + origin: ":erlang", + summary: summary1, + metadata: meta1 + }, + %{ + arity: 2, + name: "cancel_timer", + spec: "@spec cancel_timer(timerRef, options) :: result | :ok" <> _, + type: :function, + args: "timerRef, options", + origin: ":erlang", + summary: summary2 + } + ] = expand(~c":erlang.cancel_time") + + assert "Cancels a timer that has been created by" <> _ = summary2 + + if System.otp_release() |> String.to_integer() >= 27 do + assert "" == summary1 + # The doc-group key and its value are OTP-internal and vary by release + # (renamed :group -> :category and :time -> :timer across OTP 27/28/29), + # so only assert the stable equiv/app metadata here. + assert %{equiv: "erlang:cancel_timer(TimerRef, [])", app: :erts} = meta1 + else + assert "Cancels a timer\\." <> _ = summary1 + end + end + + test "provide doc and specs for erlang functions with args from typespec" do + # :pg gen_server callback availability and the names extracted from their + # typespecs vary by OTP release (OTP 29 exposes none of them here), so only + # assert the callbacks/args when the stdlib actually provides them. + results = expand(~c":pg.handle_") + + if results != [] do + names = Enum.map(results, & &1.name) + assert "handle_call" in names + assert "handle_cast" in names + assert "handle_info" in names + + for %{name: name, args_list: args_list} <- results, name in ~w(handle_call handle_cast) do + assert length(args_list) >= 2 + end + end + end + + test "complete after ! operator" do + assert [%{name: "is_binary"}] = expand(~c"!is_bina") + end + + test "correctly find subtype and doc for modules that have submodule" do + assert [ + %{ + name: "File", + full_name: "File", + type: :module, + metadata: %{}, + subtype: nil, + summary: "This module contains functions to manipulate files." + } + ] = expand(~c"Fi") |> Enum.filter(&(&1.name == "File")) + end + + test "complete only struct modules after %" do + assert list = expand(~c"%") + refute Enum.any?(list, &(&1.type != :module)) + assert Enum.any?(list, &(&1.name == "ArithmeticError")) + assert Enum.any?(list, &(&1.name == "URI")) + refute Enum.any?(list, &(&1.name == "Integer")) + + assert [_ | _] = expand(~c"%Fi") + assert list = expand(~c"%File.") + assert Enum.any?(list, &(&1.name == "CopyError")) + refute Enum.any?(list, &(&1.type != :module)) + refute Enum.any?(list, &(&1.subtype not in [:struct, :exception])) + end + + test "complete modules and local funs after &" do + assert list = expand(~c"&") + assert Enum.any?(list, &(&1.type == :module)) + assert Enum.any?(list, &(&1.type == :function)) + refute Enum.any?(list, &(&1.type not in [:function, :module, :macro])) + end + + test "complete Kernel.SpecialForms macros with fixed argument list" do + assert [%{args_list: ["term"]}] = expand(~c"Kernel.SpecialForms.fn") + end + + test "macros from not required modules should add needed_require" do + assert [ + %{ + name: "info", + arity: 2, + default_args: 1, + type: :macro, + origin: "Logger", + needed_require: "Logger", + visibility: :public + } + ] = expand(~c"Logger.inf") + + assert [ + %{ + name: "info", + arity: 2, + default_args: 1, + type: :macro, + origin: "Logger", + needed_require: nil, + visibility: :public + } + ] = expand(~c"Logger.inf", %Env{requires: [Logger]}) + end + + test "macros from not required metadata modules should add needed_require" do + macro_info = %ElixirSense.Core.State.ModFunInfo{ + type: :defmacro, + params: [[:_]] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {MyModule, nil, nil} => %ElixirSense.Core.State.ModFunInfo{}, + {MyModule, :info, 1} => macro_info + } + } + + assert [ + %{ + name: "info", + arity: 1, + type: :macro, + origin: "MyModule", + needed_require: "MyModule", + visibility: :public + } + ] = expand(~c"MyModule.inf", %Env{requires: []}, metadata) + + assert [ + %{ + name: "info", + arity: 1, + type: :macro, + origin: "MyModule", + needed_require: nil, + visibility: :public + } + ] = expand(~c"MyModule.inf", %Env{requires: [MyModule]}, metadata) + end + + test "macros from Kernel.SpecialForms should not add needed_require" do + assert [ + %{ + name: "unquote", + arity: 1, + type: :macro, + origin: "Kernel.SpecialForms", + needed_require: nil, + visibility: :public + }, + _ + ] = expand(~c"unquote", %Env{requires: []}) + end + + test "macros from the same module should not add needed_require" do + macro_info = %ElixirSense.Core.State.ModFunInfo{ + type: :defmacro, + params: [[:_]] + } + + metadata = %Metadata{ + mods_funs_to_positions: %{ + {MyModule, nil, nil} => %ElixirSense.Core.State.ModFunInfo{}, + {MyModule, :info, 1} => macro_info + } + } + + assert [ + %{ + name: "info", + arity: 1, + type: :macro, + origin: "MyModule", + needed_require: nil, + visibility: :public + } + ] = + expand(~c"inf", %Env{requires: [], module: MyModule, function: {:foo, 1}}, metadata) + end + + test "Application.compile_env classified as macro" do + assert [ + %{ + name: "compile_env", + arity: 3, + default_args: 1, + type: :macro, + origin: "Application", + needed_require: "Application" + }, + %{ + name: "compile_env", + arity: 4, + default_args: 0, + type: :function, + origin: "Application", + needed_require: nil + }, + %{ + name: "compile_env!", + arity: 2, + type: :macro, + origin: "Application", + needed_require: "Application" + }, + %{ + name: "compile_env!", + arity: 3, + type: :function, + origin: "Application", + needed_require: nil + } + ] = expand(~c"Application.compile_e") + end + + test "attribute submodule" do + metadata = %Metadata{ + mods_funs_to_positions: %{ + {MyTest, nil, nil} => %ModFunInfo{}, + {MyTest.SubModule, nil, nil} => %ModFunInfo{}, + {MyTest, :some_fun, 0} => %ModFunInfo{ + positions: [{1, 1}], + type: :def, + params: [[]] + } + } + } + + module_env = %Env{ + module: Foo, + function: nil, + attributes: [ + %AttributeInfo{name: :module_attr, type: {:atom, MyTest}} + ] + } + + # In module context, we should only module functions + entries = + expand( + '@module_attr.', + module_env, + metadata + ) + + assert Enum.any?(entries, &(&1.name == "some_fun")) + refute Enum.any?(entries, &(&1.name == "SubModule")) + + # In def context, we should get both module and function + entries = + expand( + '@module_attr.', + module_env |> Map.put(:function, {:bar, 0}), + metadata + ) + + assert Enum.any?(entries, &(&1.name == "some_fun")) + assert Enum.any?(entries, &(&1.name == "SubModule")) + end +end diff --git a/test/elixir_sense/providers/completion/suggestions_test.exs b/test/elixir_sense/providers/completion/suggestions_test.exs new file mode 100644 index 00000000..6df1042e --- /dev/null +++ b/test/elixir_sense/providers/completion/suggestions_test.exs @@ -0,0 +1,5202 @@ +# This code has originally been a part of https://github.com/elixir-lsp/elixir_sense + +# Copyright (c) 2017 Marlus Saraiva +# Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +# The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +defmodule ElixirSense.Providers.Completion.SuggestionTest do + use ExUnit.Case, async: true + alias ElixirSense.Core.Source + alias ElixirSense.Providers.Completion.Suggestion + + import ExUnit.CaptureIO + + test "empty hint" do + buffer = """ + defmodule MyModule do + + end + """ + + list = Suggestion.suggestions(buffer, 2, 7) + + assert %{ + args: "module, opts", + args_list: ["module", "opts"], + arity: 2, + name: "import", + origin: "Kernel.SpecialForms", + spec: "", + summary: "Imports functions and macros from other modules.", + type: :macro, + metadata: %{}, + snippet: nil, + visibility: :public + } = Enum.find(list, fn s -> match?(%{name: "import", arity: 2}, s) end) + + assert %{ + arity: 2, + origin: "Kernel.SpecialForms", + spec: "", + type: :macro, + args: "opts, block", + args_list: ["opts", "block"], + name: "quote", + summary: "Gets the representation of any expression.", + metadata: %{}, + snippet: nil, + visibility: :public + } = Enum.find(list, fn s -> match?(%{name: "quote", arity: 2}, s) end) + + assert %{ + arity: 2, + origin: "Kernel.SpecialForms", + spec: "", + type: :macro, + args: "module, opts", + args_list: ["module", "opts"], + name: "require", + metadata: %{}, + snippet: nil, + visibility: :public + } = Enum.find(list, fn s -> match?(%{name: "require", arity: 2}, s) end) + end + + test "without empty hint" do + buffer = """ + defmodule MyModule do + is_b + end + """ + + list = Suggestion.suggestions(buffer, 2, 7) + + assert [ + %{ + name: "is_binary", + origin: "Kernel", + arity: 1 + }, + %{ + name: "is_bitstring", + origin: "Kernel", + arity: 1 + }, + %{ + name: "is_boolean", + origin: "Kernel", + arity: 1 + }, + %{ + name: "is_number", + origin: "Kernel", + arity: 1 + } + ] = list + end + + test "capture hint" do + buffer = """ + defmodule MyModule do + @attr "asd" + def a(arg) do + arg + |> Enum.filter(&) + end + end + """ + + list = Suggestion.suggestions(buffer, 5, 21) + + assert list |> Enum.any?(&(&1.type == :module)) + assert list |> Enum.any?(&(&1.type == :function)) + assert list |> Enum.any?(&(&1.type == :variable)) + assert list |> Enum.any?(&(&1.type == :attribute)) + end + + test "pin hint 1" do + buffer = """ + defmodule MyModule do + @attr "asd" + def a(arg) do + case x() do + {^} -> :ok + end + end + end + """ + + list = Suggestion.suggestions(buffer, 5, 9) + + refute list |> Enum.any?(&(&1.type == :module)) + refute list |> Enum.any?(&(&1.type == :function)) + assert list |> Enum.any?(&(&1.type == :variable)) + refute list |> Enum.any?(&(&1.type == :attribute)) + end + + test "pin hint 2" do + buffer = """ + defmodule MyModule do + @attr "asd" + def a(arg) do + with ^ <- abc(), + x <- cde(), + y <- efg() do + :ok + end + end + end + """ + + list = Suggestion.suggestions(buffer, 4, 11) + + refute list |> Enum.any?(&(&1.type == :module)) + refute list |> Enum.any?(&(&1.type == :function)) + assert list |> Enum.any?(&(&1.type == :variable)) + refute list |> Enum.any?(&(&1.type == :attribute)) + end + + test "pin hint 3" do + buffer = """ + defmodule MyModule do + @attr "asd" + def a(arg) do + with {^} <- abc(), + x <- cde(), + y <- efg() do + :ok + end + end + end + """ + + list = Suggestion.suggestions(buffer, 4, 12) + + refute list |> Enum.any?(&(&1.type == :module)) + refute list |> Enum.any?(&(&1.type == :function)) + assert list |> Enum.any?(&(&1.type == :variable)) + refute list |> Enum.any?(&(&1.type == :attribute)) + end + + test "pin hint 4" do + buffer = """ + defmodule MyModule do + @attr "asd" + def a(arg) do + with a <- abc(), + x <- cde(), + y <- efg() do + :ok + else + ^ -> :ok + :ok -> :ok + end + end + end + """ + + list = Suggestion.suggestions(buffer, 9, 8) + + refute list |> Enum.any?(&(&1.type == :module)) + refute list |> Enum.any?(&(&1.type == :function)) + assert list |> Enum.any?(&(&1.type == :variable)) + refute list |> Enum.any?(&(&1.type == :attribute)) + end + + test "no typespecs in function scope" do + buffer = """ + defmodule MyModule do + def go, do: + end + """ + + list = Suggestion.suggestions(buffer, 2, 16) + + refute list |> Enum.any?(&(&1.type == :type_spec)) + assert list |> Enum.any?(&(&1.type == :function)) + end + + test "functions from unicode module" do + buffer = """ + defmodule :你好 do + def 运行 do + IO.puts("你好") + end + end + + :你好. + """ + + list = Suggestion.suggestions(buffer, 7, 5) + + assert list |> Enum.any?(&(&1.type == :function && &1.name == "运行")) + end + + test "with an alias" do + buffer = """ + defmodule MyModule do + alias List, as: MyList + MyList.flat + end + """ + + list = Suggestion.suggestions(buffer, 3, 14) + + assert [ + %{ + args: "list", + args_list: ["list"], + arity: 1, + name: "flatten", + origin: "List", + spec: "@spec flatten(deep_list) :: list() when deep_list: [any() | deep_list]", + summary: "Flattens the given `list` of nested lists.", + type: :function, + metadata: %{}, + visibility: :public, + snippet: nil + }, + %{ + args: "list, tail", + args_list: ["list", "tail"], + arity: 2, + name: "flatten", + origin: "List", + spec: + "@spec flatten(deep_list, [elem]) :: [elem] when deep_list: [elem | deep_list], elem: var", + summary: + "Flattens the given `list` of nested lists.\nThe list `tail` will be added at the end of\nthe flattened list.", + type: :function, + metadata: %{}, + visibility: :public, + snippet: nil + } + ] = list + end + + test "with a require" do + buffer = """ + defmodule MyModule do + require ElixirSenseExample.BehaviourWithMacrocallback.Impl, as: Macros + Macros.so + end + """ + + list = Suggestion.suggestions(buffer, 3, 12) + + assert [ + %{ + args: "var", + args_list: ["var"], + arity: 1, + name: "some", + origin: "ElixirSenseExample.BehaviourWithMacrocallback.Impl", + spec: + "@spec some(integer()) :: Macro.t()\n@spec some(b) :: Macro.t() when b: float()", + summary: "some macro\n", + type: :macro, + metadata: %{}, + snippet: nil, + visibility: :public + } + ] = list + end + + test "with a module hint" do + buffer = """ + defmodule MyModule do + ElixirSenseExample.ModuleWithDo + end + """ + + list = Suggestion.suggestions(buffer, 2, 34) + + assert [ + %{ + name: "ModuleWithDocFalse", + full_name: "ElixirSenseExample.ModuleWithDocFalse", + subtype: nil, + summary: "", + type: :module, + metadata: %{} + }, + %{ + name: "ModuleWithDocs", + full_name: "ElixirSenseExample.ModuleWithDocs", + subtype: :behaviour, + summary: "An example module\n", + type: :module, + metadata: %{since: "1.2.3"} + }, + %{ + metadata: %{}, + name: "ModuleWithNoDocs", + full_name: "ElixirSenseExample.ModuleWithNoDocs", + subtype: nil, + summary: "", + type: :module + } + ] = list + end + + test "lists metadata modules" do + buffer = """ + defmodule MyServer do + @moduledoc "Some" + @moduledoc since: "1.2.3" + end + MySe + """ + + list = + Suggestion.suggestions(buffer, 5, 5) + |> Enum.filter(fn s -> s.type == :module end) + + assert [ + %{ + name: "MyServer", + summary: "Some", + type: :module, + full_name: "MyServer", + metadata: %{since: "1.2.3"}, + required_alias: nil, + subtype: nil + } + ] = list + end + + test "returns subtype on local modules" do + buffer = """ + defprotocol MyProto do + end + MyPr + """ + + list = + Suggestion.suggestions(buffer, 3, 5) + |> Enum.filter(fn s -> s.type == :module end) + + assert [ + %{ + name: "MyProto", + subtype: :protocol + } + ] = list + end + + test "lists callbacks" do + buffer = """ + defmodule MyServer do + use GenServer + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 7) + |> Enum.filter(fn s -> s.type == :callback && s.name == "code_change" end) + + assert [ + %{ + args: "old_vsn, state, extra", + arity: 3, + name: "code_change", + origin: "GenServer", + spec: "@callback code_change(old_vsn, state :: term(), extra :: term()) ::" <> _, + summary: + "Invoked to change the state of the `GenServer` when a different version of a\nmodule is loaded (hot code swapping) and the state's term structure should be\nchanged.", + type: :callback + } + ] = list + end + + test "lists metadata behaviour callbacks" do + buffer = """ + defmodule MyBehaviour do + @doc "Some callback" + @callback my_callback(integer()) :: any() + + @callback my_callback_optional(integer(), atom()) :: any() + + @deprecated "Replace me" + @macrocallback my_macrocallback(integer()) :: Macro.t() + + @optional_callbacks my_callback_optional: 2 + end + + defmodule MyServer do + @behaviour MyBehaviour + + end + """ + + list = + Suggestion.suggestions(buffer, 15, 3) + |> Enum.filter(fn s -> s.type == :callback end) + + assert [ + %{ + args: "integer()", + arity: 1, + name: "my_callback", + origin: "MyBehaviour", + spec: "@callback my_callback(integer()) :: any()", + summary: "Some callback", + type: :callback, + args_list: ["integer()"], + metadata: %{}, + subtype: :callback + }, + %{ + args: "integer()", + args_list: ["integer()"], + arity: 1, + metadata: %{deprecated: "Replace me"}, + name: "my_macrocallback", + origin: "MyBehaviour", + spec: "@macrocallback my_macrocallback(integer()) :: Macro.t()", + subtype: :macrocallback, + summary: "", + type: :callback + }, + %{ + args: "integer(), atom()", + args_list: ["integer()", "atom()"], + arity: 2, + metadata: %{optional: true}, + name: "my_callback_optional", + origin: "MyBehaviour", + spec: "@callback my_callback_optional(integer(), atom()) :: any()", + subtype: :callback, + summary: "", + type: :callback + } + ] = list + end + + test "lists metadata protocol functions" do + buffer = """ + defprotocol MyProto do + @doc "Some callback" + @doc since: "1.2.3" + def my_fun(t) + + @doc deprecated: "1.2.3" + @spec my_fun_other(t(), integer()) :: any() + def my_fun_other(t, a) + end + + defimpl MyProto, for: List do + + end + """ + + list = + Suggestion.suggestions(buffer, 12, 3) + |> Enum.filter(fn s -> s.type == :protocol_function end) + + assert [ + %{ + args: "t()", + args_list: ["t()"], + arity: 1, + metadata: %{since: "1.2.3"}, + name: "my_fun", + origin: "MyProto", + spec: "@callback my_fun(t()) :: term()", + summary: "Some callback", + type: :protocol_function + }, + %{ + args: "t(), integer()", + args_list: ["t()", "integer()"], + arity: 2, + metadata: %{deprecated: "1.2.3"}, + name: "my_fun_other", + origin: "MyProto", + spec: "@spec my_fun_other(t(), integer()) :: any()", + summary: "", + type: :protocol_function + } + ] = list + end + + test "lists callbacks + def macros after de" do + buffer = """ + defmodule MyServer do + use GenServer + + de + # ^ + end + """ + + list = Suggestion.suggestions(buffer, 4, 5) + assert Enum.any?(list, fn s -> s.type == :callback end) + assert Enum.any?(list, fn s -> s.type == :macro end) + assert Enum.all?(list, fn s -> s.type in [:callback, :macro] end) + end + + test "lists callbacks + def macros after def" do + buffer = """ + defmodule MyServer do + use GenServer + + def + # ^ + end + """ + + list = Suggestion.suggestions(buffer, 4, 6) + assert Enum.any?(list, fn s -> s.type == :callback end) + assert Enum.any?(list, fn s -> s.type == :macro end) + assert Enum.all?(list, fn s -> s.type in [:callback, :macro] end) + end + + test "lists only callbacks after def + space" do + buffer = """ + defmodule MyServer do + use GenServer + + def t + # ^ + end + """ + + assert Suggestion.suggestions(buffer, 4, 7) |> Enum.all?(fn s -> s.type == :callback end) + + buffer = """ + defmodule MyServer do + use GenServer + + def t + # ^ + end + """ + + assert [%{name: "terminate", type: :callback}] = Suggestion.suggestions(buffer, 4, 8) + end + + test "do not list callbacks inside functions" do + buffer = """ + defmodule MyServer do + use GenServer + + def init(_) do + t + # ^ + end + end + """ + + list = Suggestion.suggestions(buffer, 5, 6) + assert Enum.any?(list, fn s -> s.type == :function end) + refute Enum.any?(list, fn s -> s.type == :callback end) + end + + test "lists macrocallbacks" do + buffer = """ + defmodule MyServer do + @behaviour ElixirSenseExample.BehaviourWithMacrocallback + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 7) + |> Enum.filter(fn s -> s.type == :callback end) + + assert [ + %{ + args: "a", + args_list: ["a"], + arity: 1, + name: "optional", + subtype: :macrocallback, + origin: "ElixirSenseExample.BehaviourWithMacrocallback", + spec: "@macrocallback optional(a) :: Macro.t() when a: atom()", + summary: "An optional macrocallback\n", + type: :callback, + metadata: %{optional: true, app: :elixir_sense} + }, + %{ + args: "atom", + args_list: ["atom"], + arity: 1, + name: "required", + subtype: :macrocallback, + origin: "ElixirSenseExample.BehaviourWithMacrocallback", + spec: "@macrocallback required(atom()) :: Macro.t()", + summary: "A required macrocallback\n", + type: :callback, + metadata: %{optional: false, app: :elixir_sense} + } + ] = list + end + + test "lists macrocallbacks + def macros after defma" do + buffer = """ + defmodule MyServer do + @behaviour ElixirSenseExample.BehaviourWithMacrocallback + + defma + # ^ + end + """ + + list = Suggestion.suggestions(buffer, 4, 8) + assert Enum.any?(list, fn s -> s.type == :callback end) + assert Enum.any?(list, fn s -> s.type == :macro end) + assert Enum.all?(list, fn s -> s.type in [:callback, :macro] end) + end + + test "lists erlang callbacks" do + buffer = """ + defmodule MyServer do + @behaviour :gen_statem + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 7) + |> Enum.filter(fn s -> s.type == :callback && s.name == "code_change" end) + + assert [ + %{ + args: "oldVsn, oldState, oldData, extra", + arity: 4, + name: "code_change", + origin: ":gen_statem", + spec: "@callback code_change" <> _, + summary: summary, + type: :callback, + subtype: :callback + } + ] = list + + if System.otp_release() |> String.to_integer() >= 27 do + assert "Update the [state]" <> _ = summary + else + assert "- OldVsn = Vsn" <> _ = summary + end + end + + test "lists overridable callbacks" do + buffer = """ + defmodule MyServer do + use ElixirSenseExample.OverridableImplementation + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 7) + |> Enum.filter(fn s -> s.type == :callback end) + + assert [ + %{ + args: "", + arity: 0, + name: "foo", + origin: "ElixirSenseExample.OverridableBehaviour", + spec: "@callback foo() :: any()", + summary: "", + type: :callback, + subtype: :callback, + metadata: %{optional: false, overridable: true} + }, + %{ + args: "any", + arity: 1, + metadata: %{optional: false, overridable: true}, + name: "bar", + origin: "ElixirSenseExample.OverridableBehaviour", + spec: "@macrocallback bar(any()) :: Macro.t()", + subtype: :macrocallback, + summary: "", + type: :callback + } + ] = list + end + + test "lists overridable functions and macros" do + buffer = """ + defmodule MyServer do + use ElixirSenseExample.OverridableFunctions + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 7) + |> Enum.filter(fn s -> s.type == :callback end) + + assert [ + %{ + args: "var", + arity: 1, + metadata: %{overridable: true}, + name: "required", + origin: "ElixirSenseExample.OverridableFunctions", + spec: "", + summary: "", + type: :callback, + subtype: :macrocallback + }, + %{ + args: "x, y", + arity: 2, + metadata: %{since: "1.2.3", overridable: true}, + name: "test", + origin: "ElixirSenseExample.OverridableFunctions", + spec: "@spec test(number(), number()) :: number()", + summary: "Some overridable", + type: :callback, + subtype: :callback + } + ] = list + end + + test "fuzzy match overridable functions" do + buffer = """ + defmodule MyServer do + use ElixirSenseExample.OverridableFunctions + + rqui + end + """ + + list = + Suggestion.suggestions(buffer, 4, 5) + |> Enum.filter(fn s -> s.type == :callback end) + + assert [ + %{ + args: "var", + arity: 1, + metadata: %{}, + name: "required", + origin: "ElixirSenseExample.OverridableFunctions", + spec: "", + summary: "", + type: :callback, + subtype: :macrocallback + } + ] = list + end + + test "lists protocol functions" do + buffer = """ + defimpl Enumerable, for: MyStruct do + + end + """ + + list = + Suggestion.suggestions(buffer, 2, 3) + |> Enum.filter(fn s -> s[:name] == "reduce" end) + + assert [ + %{ + args: "enumerable, acc, fun", + arity: 3, + name: "reduce", + origin: "Enumerable", + spec: "@callback reduce(t(), acc(), reducer()) :: result()", + summary: "Reduces the `enumerable` into an element.", + type: :protocol_function, + metadata: %{} + } + ] = list + end + + test "lists fuzzy protocol functions" do + buffer = """ + defimpl Enumerable, for: MyStruct do + reu + end + """ + + list = + Suggestion.suggestions(buffer, 2, 5) + |> Enum.filter(fn s -> s[:type] == :protocol_function end) + + assert [ + %{ + args: "enumerable, acc, fun", + arity: 3, + name: "reduce", + origin: "Enumerable", + spec: "@callback reduce(t(), acc(), reducer()) :: result()", + summary: "Reduces the `enumerable` into an element.", + type: :protocol_function, + metadata: %{} + } + ] = list + end + + test "lists callback return values" do + buffer = """ + defmodule MyServer do + use ElixirSenseExample.ExampleBehaviour + + def handle_call(request, from, state) do + + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 5) + |> Enum.filter(fn s -> s.type == :return end) + + assert [ + %{ + description: "{:reply, reply, new_state}", + snippet: "{:reply, \"${1:reply}$\", \"${2:new_state}$\"}", + spec: + "{:reply, reply, new_state} when reply: term(), new_state: term(), reason: term()", + type: :return + }, + %{ + description: + "{:reply, reply, new_state, timeout() | :hibernate | {:continue, term()}}", + snippet: + "{:reply, \"${1:reply}$\", \"${2:new_state}$\", \"${3:timeout() | :hibernate | {:continue, term()}}$\"}", + spec: + "{:reply, reply, new_state, timeout() | :hibernate | {:continue, term()}}" <> _, + type: :return + }, + %{ + description: "{:noreply, new_state}", + snippet: "{:noreply, \"${1:new_state}$\"}", + spec: + "{:noreply, new_state} when reply: term(), new_state: term(), reason: term()", + type: :return + }, + %{ + description: "{:noreply, new_state, timeout() | :hibernate | {:continue, term()}}", + snippet: + "{:noreply, \"${1:new_state}$\", \"${2:timeout() | :hibernate | {:continue, term()}}$\"}", + spec: "{:noreply, new_state, timeout() | :hibernate | {:continue, term()}}" <> _, + type: :return + }, + %{ + description: "{:stop, reason, reply, new_state}", + snippet: "{:stop, \"${1:reason}$\", \"${2:reply}$\", \"${3:new_state}$\"}", + spec: + "{:stop, reason, reply, new_state} when reply: term(), new_state: term(), reason: term()", + type: :return + }, + %{ + description: "{:stop, reason, new_state}", + snippet: "{:stop, \"${1:reason}$\", \"${2:new_state}$\"}", + spec: + "{:stop, reason, new_state} when reply: term(), new_state: term(), reason: term()", + type: :return + } + ] = list + end + + test "lists macrocallback return values" do + buffer = """ + defmodule MyServer do + @behaviour ElixirSenseExample.BehaviourWithMacrocallback + + defmacro required(arg) do + + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 5) + |> Enum.filter(fn s -> s.type == :return end) + + assert list == [ + %{ + description: "Macro.t()", + snippet: "\"${1:Macro.t()}$\"", + spec: "Macro.t()", + type: :return + } + ] + end + + test "lists metadata callback return values" do + buffer = """ + defmodule MyBehaviour do + @callback required(term()) :: {:ok, term()} | :error + end + + defmodule MyServer do + @behaviour MyBehaviour + + def required(arg) do + + end + end + """ + + list = + Suggestion.suggestions(buffer, 9, 5) + |> Enum.filter(fn s -> s.type == :return end) + + assert list == [ + %{ + description: "{:ok, term()}", + snippet: "{:ok, \"${1:term()}$\"}", + spec: "{:ok, term()}", + type: :return + }, + %{description: ":error", snippet: ":error", spec: ":error", type: :return} + ] + end + + test "lists protocol implementation return values" do + buffer = """ + defimpl Enumerable, for: MyStruct do + def count(t) do + + end + end + """ + + list = + Suggestion.suggestions(buffer, 3, 6) + |> Enum.filter(fn s -> s.type == :return end) + + assert [ + %{ + description: "{:ok, non_neg_integer()}", + snippet: "{:ok, \"${1:non_neg_integer()}$\"}", + spec: "{:ok, non_neg_integer()}", + type: :return + }, + %{ + description: "{:error, module()}", + snippet: "{:error, \"${1:module()}$\"}", + spec: "{:error, module()}", + type: :return + } + ] == list + end + + test "lists metadata protocol implementation return values" do + buffer = """ + defprotocol MyProto do + @spec count(t()) :: {:ok, term()} | :error + def count(t) + end + + defimpl MyProto, for: MyStruct do + def count(t) do + + end + end + """ + + list = + Suggestion.suggestions(buffer, 8, 6) + |> Enum.filter(fn s -> s.type == :return end) + + assert [ + %{ + description: "{:ok, term()}", + snippet: "{:ok, \"${1:term()}$\"}", + spec: "{:ok, term()}", + type: :return + }, + %{description: ":error", snippet: ":error", spec: ":error", type: :return} + ] == list + end + + test "lists function with spec return values" do + buffer = """ + defmodule SomeModule do + @spec count(atom) :: :ok | {:error, any} + def count(t) do + + end + end + """ + + list = + Suggestion.suggestions(buffer, 4, 6) + |> Enum.filter(fn s -> s.type == :return end) + + assert [ + %{description: ":ok", snippet: ":ok", spec: ":ok", type: :return}, + %{ + description: "{:error, any()}", + snippet: "{:error, \"${1:any()}$\"}", + spec: "{:error, any()}", + type: :return + } + ] == list + end + + test "list metadata function - fallback to callback in metadata" do + buffer = """ + defmodule MyBehaviour do + @doc "Sample doc" + @doc since: "1.2.3" + @callback flatten(list()) :: list() + end + + defmodule MyLocalModule do + @behaviour MyBehaviour + + @impl true + def flatten(list) do + [] + end + end + + defmodule MyModule do + def func(list) do + MyLocalModule.flat + end + end + """ + + list = + Suggestion.suggestions(buffer, 18, 23) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "list", + arity: 1, + metadata: %{implementing: MyBehaviour, hidden: true, since: "1.2.3"}, + name: "flatten", + origin: "MyLocalModule", + spec: "@callback flatten(list()) :: list()", + summary: "Sample doc", + type: :function, + visibility: :public + } + ] = list + end + + test "retrieve metadata function documentation - fallback to protocol function in metadata" do + buffer = """ + defprotocol BB do + @doc "asdf" + @spec go(t) :: integer() + def go(t) + end + + defimpl BB, for: String do + def go(t), do: "" + end + + defmodule MyModule do + def func(list) do + BB.String.go(list) + end + end + """ + + list = + Suggestion.suggestions(buffer, 13, 16) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "t", + arity: 1, + metadata: %{implementing: BB}, + name: "go", + origin: "BB.String", + spec: "@callback go(t()) :: integer()", + summary: "asdf", + type: :function, + visibility: :public + } + ] = list + end + + test "list metadata macro - fallback to macrocallback in metadata" do + buffer = """ + defmodule MyBehaviour do + @doc "Sample doc" + @doc since: "1.2.3" + @macrocallback flatten(list()) :: list() + end + + defmodule MyLocalModule do + @behaviour MyBehaviour + + @impl true + defmacro flatten(list) do + [] + end + end + + defmodule MyModule do + require MyLocalModule + def func(list) do + MyLocalModule.flatten(list) + end + end + """ + + list = + Suggestion.suggestions(buffer, 19, 23) + |> Enum.filter(fn s -> s.type == :macro end) + + assert [ + %{ + args: "list", + arity: 1, + metadata: %{implementing: MyBehaviour, hidden: true, since: "1.2.3"}, + name: "flatten", + origin: "MyLocalModule", + spec: "@macrocallback flatten(list()) :: list()", + summary: "Sample doc", + type: :macro, + visibility: :public + } + ] = list + end + + test "list metadata function - fallback to callback" do + buffer = """ + defmodule MyLocalModule do + @behaviour ElixirSenseExample.BehaviourWithMeta + + @impl true + def flatten(list) do + [] + end + end + + defmodule MyModule do + def func(list) do + MyLocalModule.flat + end + end + """ + + list = + Suggestion.suggestions(buffer, 12, 23) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "list", + arity: 1, + metadata: %{implementing: ElixirSenseExample.BehaviourWithMeta}, + name: "flatten", + origin: "MyLocalModule", + spec: "@callback flatten(list()) :: list()", + summary: "Sample doc", + type: :function, + visibility: :public + } + ] = list + end + + test "list metadata function - fallback to erlang callback" do + buffer = """ + defmodule MyLocalModule do + @behaviour :gen_statem + + @impl true + def init(list) do + [] + end + end + + defmodule MyModule do + def func(list) do + MyLocalModule.ini + end + end + """ + + list = + Suggestion.suggestions(buffer, 12, 22) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "list", + arity: 1, + metadata: %{implementing: :gen_statem, since: "OTP 19.0"}, + name: "init", + origin: "MyLocalModule", + spec: "@callback init(args :: term()) ::" <> _, + summary: documentation, + type: :function, + visibility: :public + } + ] = list + + if System.otp_release() |> String.to_integer() >= 27 do + assert "Initialize the state machine" <> _ = documentation + else + assert "- Args = " <> _ = documentation + end + end + + test "list metadata macro - fallback to macrocallback" do + buffer = """ + defmodule MyLocalModule do + @behaviour ElixirSenseExample.BehaviourWithMeta + + @impl true + defmacro bar(list) do + [] + end + end + + defmodule MyModule do + require MyLocalModule + def func(list) do + MyLocalModule.ba + end + end + """ + + list = + Suggestion.suggestions(buffer, 13, 21) + |> Enum.filter(fn s -> s.type == :macro end) + + assert [ + %{ + args: "list", + arity: 1, + metadata: %{implementing: ElixirSenseExample.BehaviourWithMeta}, + name: "bar", + origin: "MyLocalModule", + spec: "@macrocallback bar(integer()) :: Macro.t()", + summary: "Docs for bar", + type: :macro, + visibility: :public + } + ] = list + end + + test "lists callbacks in function suggestion - elixir behaviour" do + buffer = """ + defmodule MyServer do + use GenServer + + def handle_call(request, _from, state) do + term + end + + def init(arg), do: arg + + def handle_cast(arg, _state) when is_atom(arg) do + :ok + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 9) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "_reason, _state", + arity: 2, + metadata: %{implementing: GenServer}, + name: "terminate", + origin: "MyServer", + spec: "@callback terminate(reason, state :: term()) :: term()" <> _, + summary: + "Invoked when the server is about to exit. It should do any cleanup required.", + type: :function, + visibility: :public + } + ] = list + end + + test "lists callbacks in function suggestion - erlang behaviour" do + buffer = """ + defmodule MyServer do + @behaviour :gen_event + + def handle_call(request, _from, state) do + ini + end + + def init(arg), do: arg + + def handle_cast(arg, _state) when is_atom(arg) do + :ok + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 8) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{name: "init", origin: "MyServer", arity: 1} = init_res, + %{name: "is_function", origin: "Kernel", arity: 1}, + %{name: "is_function", origin: "Kernel", arity: 2} + ] = list + + assert %{ + summary: documentation, + metadata: %{implementing: :gen_event}, + spec: "@callback init(initArgs :: term()) ::" <> _, + args_list: ["arg"] + } = init_res + + if System.otp_release() |> String.to_integer() >= 27 do + assert "Initialize the event handler" <> _ = documentation + else + assert "- InitArgs = Args" <> _ = documentation + end + end + + test "lists fuzzy callbacks in function suggestion - erlang behaviour" do + buffer = """ + defmodule MyServer do + @behaviour :gen_server + + def handle_call(request, _from, state) do + iit + end + + def init(arg), do: arg + + def handle_cast(arg, _state) when is_atom(arg) do + :ok + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 8) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{name: "init", origin: "MyServer", arity: 1}, + %{name: "is_bitstring", origin: "Kernel", arity: 1}, + %{name: "is_integer", origin: "Kernel", arity: 1}, + %{name: "is_list", origin: "Kernel", arity: 1} + ] = list + end + + test "suggest elixir behaviour callbacks on implementation" do + buffer = """ + ElixirSenseExample.ExampleBehaviourWithDocCallbackImpl.ba + """ + + list = + Suggestion.suggestions(buffer, 1, 57) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "a", + args_list: ["a"], + arity: 1, + metadata: %{implementing: ElixirSenseExample.ExampleBehaviourWithDoc}, + name: "baz", + origin: "ElixirSenseExample.ExampleBehaviourWithDocCallbackImpl", + snippet: nil, + spec: "@callback baz(integer()) :: :ok", + summary: "Docs for baz", + type: :function, + visibility: :public + } + ] = list + end + + test "suggest erlang behaviour callbacks on implementation" do + buffer = """ + ElixirSenseExample.ExampleBehaviourWithDocCallbackErlang.ini + """ + + list = + Suggestion.suggestions(buffer, 1, 60) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "_", + args_list: ["_"], + arity: 1, + metadata: %{implementing: :gen_statem}, + name: "init", + origin: "ElixirSenseExample.ExampleBehaviourWithDocCallbackErlang", + snippet: nil, + spec: "@callback init(args :: term()) :: init_result(state())", + summary: documentation, + type: :function, + visibility: :public + } + ] = list + + if System.otp_release() |> String.to_integer() >= 27 do + assert "Initialize the state machine" <> _ = documentation + else + assert "- Args = " <> _ = documentation + end + end + + test "suggest erlang behaviour callbacks on erlang implementation" do + buffer = """ + :file_server.ini + """ + + list = + Suggestion.suggestions(buffer, 1, 17) + |> Enum.filter(fn s -> s.type == :function end) + + assert [ + %{ + args: "args", + args_list: ["args"], + arity: 1, + metadata: %{implementing: :gen_server}, + name: "init", + origin: ":file_server", + snippet: nil, + spec: "@callback init(args :: term()) ::" <> _, + summary: documentation, + type: :function, + visibility: :public + } + ] = list + + if System.otp_release() |> String.to_integer() >= 27 do + assert "Initialize the server" <> _ = documentation + else + assert "- Args = " <> _ = documentation + end + end + + test "lists params and vars" do + buffer = """ + defmodule MyServer do + use GenServer + + def handle_call(request, _from, state) do + var1 = true + + end + + def init(arg), do: arg + + def handle_cast(arg, _state) when is_atom(arg) do + :ok + end + end + """ + + list = + Suggestion.suggestions(buffer, 6, 5) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "_from", type: :variable}, + %{name: "request", type: :variable}, + %{name: "state", type: :variable}, + %{name: "var1", type: :variable} + ] + + list = + Suggestion.suggestions(buffer, 9, 22) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "arg", type: :variable} + ] + + list = + Suggestion.suggestions(buffer, 11, 45) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "_state", type: :variable}, + %{name: "arg", type: :variable} + ] + end + + test "lists params in fn's" do + buffer = """ + defmodule MyServer do + my = fn arg -> arg + 1 end + end + """ + + list = + Suggestion.suggestions(buffer, 2, 19) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "arg", type: :variable} + ] + end + + test "lists params in protocol implementations" do + buffer = """ + defimpl Enum, for: [MyStruct, MyOtherStruct] do + def count(term), do: + end + """ + + list = + Suggestion.suggestions(buffer, 2, 24) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "term", type: :variable} + ] + end + + test "lists vars in []" do + buffer = """ + defmodule MyServer do + my = %{} + x = 4 + my[] + + end + """ + + list = + Suggestion.suggestions(buffer, 4, 6) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "my", type: :variable}, + %{name: "x", type: :variable} + ] + end + + test "lists vars in unfinished []" do + buffer = """ + defmodule MyServer do + my = %{} + x = 4 + my[ + + end + """ + + list = + Suggestion.suggestions(buffer, 4, 6) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "my", type: :variable}, + %{name: "x", type: :variable} + ] + end + + test "lists vars in string interpolation" do + buffer = """ + defmodule MyServer do + x = 4 + "abc\#{}" + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 9) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + end + + test "lists vars in unfinished string interpolation" do + buffer = """ + defmodule MyServer do + x = 4 + "abc\#{ + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 9) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + + buffer = """ + defmodule MyServer do + x = 4 + "abc\#{" + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 9) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + + buffer = """ + defmodule MyServer do + x = 4 + "abc\#{} + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 9) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + + buffer = """ + defmodule MyServer do + x = 4 + "abc\#{x[ + + end + """ + + list = + Suggestion.suggestions(buffer, 3, 9) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + end + + test "lists vars in heredoc interpolation" do + buffer = """ + defmodule MyServer do + x = 4 + \"\"\" + abc\#{} + \"\"\" + + end + """ + + list = + Suggestion.suggestions(buffer, 4, 8) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + end + + test "lists vars in unfinished heredoc interpolation" do + buffer = """ + defmodule MyServer do + x = 4 + \"\"\" + abc\#{ + \"\"\" + + end + """ + + list = + Suggestion.suggestions(buffer, 4, 8) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + + buffer = """ + defmodule MyServer do + x = 4 + \"\"\" + abc\#{ + + end + """ + + list = + Suggestion.suggestions(buffer, 4, 8) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + + buffer = """ + defmodule MyServer do + x = 4 + \"\"\" + abc\#{} + + end + """ + + list = + Suggestion.suggestions(buffer, 4, 8) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "x", type: :variable} + ] + end + + if Version.match?(System.version(), ">= 1.17.0") do + test "lists params in fn's not finished multiline" do + buffer = """ + defmodule MyServer do + my = fn arg -> + + end + """ + + capture_io(:stderr, fn -> + list = + Suggestion.suggestions(buffer, 3, 5) + |> Enum.filter(fn s -> s.type == :variable end) + + send(self(), {:result, list}) + end) + + assert_received {:result, list} + + assert list == [%{name: "arg", type: :variable}] + end + end + + if Version.match?(System.version(), ">= 1.17.0") do + test "lists params in fn's not finished" do + buffer = """ + defmodule MyServer do + my = fn arg -> + end + """ + + capture_io(:stderr, fn -> + list = + Suggestion.suggestions(buffer, 2, 19) + |> Enum.filter(fn s -> s.type == :variable end) + + send(self(), {:result, list}) + end) + + assert_received {:result, list} + + assert list == [ + %{name: "arg", type: :variable} + ] + end + end + + test "lists params in defs not finished" do + buffer = """ + defmodule MyServer do + def my(arg), do: + end + """ + + list = + Suggestion.suggestions(buffer, 2, 20) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "arg", type: :variable} + ] + end + + test "lists params and vars in case clauses" do + buffer = """ + defmodule MyServer do + def fun(request) do + case request do + {:atom1, vara} -> + :ok + {:atom2, varb} -> :ok + abc when is_atom(a) + end + + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 9) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "request", type: :variable}, + %{name: "vara", type: :variable} + ] + + list = + Suggestion.suggestions(buffer, 6, 25) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "request", type: :variable}, + %{name: "varb", type: :variable} + ] + + list = + Suggestion.suggestions(buffer, 9, 4) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "request", type: :variable} + ] + + list = + Suggestion.suggestions(buffer, 7, 25) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "abc", type: :variable} + ] + end + + test "lists write vars in match context" do + buffer = """ + defmodule MyServer do + def my(arg = 1, a), do: :ok + end + """ + + list = + Suggestion.suggestions(buffer, 2, 20) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "arg", type: :variable} + ] + end + + test "does not list write vars" do + buffer = """ + defmodule MyServer do + [arg = 1, a] + a + end + """ + + list = + Suggestion.suggestions(buffer, 2, 14) + |> Enum.filter(fn s -> s.type == :variable end) + + # arg is a write var and is not available for read in the cursor context + assert list == [] + + list = + Suggestion.suggestions(buffer, 3, 4) + |> Enum.filter(fn s -> s.type == :variable end) + + # arg is a read var here + assert list == [%{name: "arg", type: :variable}] + end + + test "lists params and vars in cond clauses" do + buffer = """ + defmodule MyServer do + def fun(request) do + cond do + vara = Enum.find(request, 4) -> + :ok + varb = Enum.find(request, 5) -> :ok + true -> :error + end + + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 9) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "request", type: :variable}, + %{name: "vara", type: :variable} + ] + + list = + Suggestion.suggestions(buffer, 6, 39) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "request", type: :variable}, + %{name: "varb", type: :variable} + ] + + list = + Suggestion.suggestions(buffer, 9, 4) + |> Enum.filter(fn s -> s.type == :variable end) + + assert list == [ + %{name: "request", type: :variable} + ] + end + + test "only list defined params in guard" do + buffer = """ + defmodule MyServer do + def new(my_var) when is_integer(my + end + """ + + list = + Suggestion.suggestions(buffer, 2, 37) + |> Enum.filter(fn s -> s.type in [:variable] end) + + assert list == [%{name: "my_var", type: :variable}] + end + + test "list vars in multiline struct" do + buffer = """ + defmodule MyServer do + def go do + %Some{ + filed: my_var, + other: my + } = abc() + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 16) + |> Enum.filter(fn s -> s.type in [:variable] end) + + assert list == [%{name: "my_var", type: :variable}] + end + + test "tuple destructuring" do + buffer = """ + defmodule MyServer do + def new() do + case NaiveDateTime.new(1, 2) do + {:ok, x} -> x.h + end + case NaiveDateTime.new(1, 2) do + {:ok, x} -> %{x | h} + end + end + end + """ + + list = + Suggestion.suggestions(buffer, 4, 22) + |> Enum.filter(fn s -> s.type == :field end) + + assert [%{name: "hour", origin: "NaiveDateTime"}] = list + + list = + Suggestion.suggestions(buffer, 7, 26) + |> Enum.filter(fn s -> s.type == :field end) + + assert [%{name: "hour", origin: "NaiveDateTime"}] = list + end + + test "nested binding" do + buffer = """ + defmodule State do + defstruct [formatted: nil] + def new(socket) do + %State{formatted: formatted} = state = socket.assigns.state + state.for + state = %{state | form} + end + end + """ + + list = + Suggestion.suggestions(buffer, 5, 14) + |> Enum.filter(fn s -> s.type == :field end) + + assert [%{name: "formatted", origin: "State"}] = list + + list = + Suggestion.suggestions(buffer, 6, 27) + |> Enum.filter(fn s -> s.type == :field end) + + assert [%{name: "formatted", origin: "State"}] = list + end + + test "variable shadowing function" do + buffer = """ + defmodule Mod do + def my_fun(), do: :ok + def some() do + my_fun = 1 + my_f + end + end + """ + + assert [ + %{name: "my_fun", type: :variable}, + %{name: "my_fun", type: :function} + ] = Suggestion.suggestions(buffer, 5, 9) + end + + describe "suggestions for module attributes" do + test "lists attributes" do + buffer = """ + defmodule MyModule do + @my_attribute1 true + @my_attribute2 false + @ + end + """ + + list = + Suggestion.suggestions(buffer, 4, 4) + |> Enum.filter(fn s -> s.type == :attribute and s.name |> String.starts_with?("@my") end) + |> Enum.map(fn %{name: name} -> name end) + + assert list == ["@my_attribute1", "@my_attribute2"] + end + + test "lists module attributes in module scope" do + buffer = """ + defmodule MyModule do + @myattr "asd" + @moduledoc "asdf" + def some do + @m + end + end + """ + + list = + Suggestion.suggestions(buffer, 3, 5) + |> Enum.filter(fn s -> s.type == :attribute end) + |> Enum.map(fn %{name: name} -> name end) + + assert list == ["@macrocallback", "@moduledoc", "@myattr"] + + list = + Suggestion.suggestions(buffer, 5, 7) + |> Enum.filter(fn s -> s.type == :attribute end) + |> Enum.map(fn %{name: name} -> name end) + + assert list == ["@myattr"] + end + + test "built-in attributes should include documentation" do + buffer = """ + defmodule MyModule do + @call + @enfor + end + """ + + list = + Suggestion.suggestions(buffer, 2, 7) + |> Enum.filter(fn s -> s.type == :attribute end) + + assert [%{summary: "Provides a specification for a behaviour callback."}] = list + + list = + Suggestion.suggestions(buffer, 3, 8) + |> Enum.filter(fn s -> s.type == :attribute end) + + assert [ + %{ + summary: + "Ensures the given keys are always set when building the struct defined in the current module." + } + ] = list + end + + test "non built-in attributes should not include documentation" do + buffer = """ + defmodule MyModule do + @myattr "asd" + def some do + @m + end + end + """ + + list = + Suggestion.suggestions(buffer, 4, 6) + |> Enum.filter(fn s -> s.type == :attribute end) + + assert [%{summary: nil}] = list + end + end + + test "lists builtin module attributes on incomplete code" do + buffer = """ + defmodule My do + def start_link(id) do + GenServer.start_link(__MODULE__, id, name: via_tuple(id)) + end + + @ + def init(id) do + {:ok, + %Some.Mod{ + id: id, + events: [], + version: 0 + }} + end + end + """ + + list = + Suggestion.suggestions(buffer, 6, 4) + |> Enum.filter(fn s -> s.type == :attribute end) + + assert Enum.any?(list, &(&1.name == "@impl")) + assert Enum.any?(list, &(&1.name == "@spec")) + end + + test "do not suggest @@" do + buffer = """ + defmodule MyModule do + @ + @my_attribute1 true + end + """ + + list = + Suggestion.suggestions(buffer, 2, 4) + |> Enum.filter(fn s -> s.type == :attribute end) + |> Enum.map(fn %{name: name} -> name end) + + refute "@@" in list + end + + test "lists doc snippets in module body" do + buffer = """ + defmodule MyModule do + @ + #^ + + @m + # ^ + + def some do + @m + # ^ + end + end + """ + + [cursor_1, cursor_2, cursor_3] = cursors(buffer) + + list = suggestions_by_kind(buffer, cursor_1, :snippet) + + assert [ + %{label: ~s(@doc """"""), detail: detail, documentation: doc}, + %{label: ~s(@moduledoc """""")}, + %{label: ~s(@typedoc """""")}, + %{label: "@doc false"}, + %{label: "@moduledoc false"}, + %{label: "@typedoc false"} + ] = list + + assert detail == "module attribute snippet" + assert doc == "Documents a function/macro/callback" + + list = suggestions_by_kind(buffer, cursor_2, :snippet) + assert [%{label: ~S(@moduledoc """""")}, %{label: "@moduledoc false"}] = list + + assert suggestions_by_kind(buffer, cursor_3, :snippet) == [] + end + + test "fuzzy suggestions for doc snippets" do + buffer = """ + defmodule MyModule do + @tydo + # ^ + end + """ + + list = Suggestion.suggestions(buffer, 2, 7) + + assert [ + %{label: ~s(@typedoc """""")}, + %{label: "@typedoc false"} + ] = list |> Enum.filter(&(&1.type == :generic and &1.kind == :snippet)) + end + + test "functions defined in the module" do + buffer = """ + defmodule ElixirSenseExample.ModuleA do + def test_fun_pub(a), do: :ok + defp test_fun_priv(), do: :ok + defp is_boo_overlaps_kernel(), do: :ok + defdelegate delegate_defined, to: Kernel, as: :is_binary + defdelegate delegate_not_defined, to: Dummy, as: :hello + defguard my_guard_pub(value) when is_integer(value) and rem(value, 2) == 0 + defguardp my_guard_priv(value) when is_integer(value) + defmacro a_macro(a) do + quote do: :ok + end + defmacrop a_macro_priv(a) do + quote do: :ok + end + + def some_fun() do + test + a = &test_fun_pr + is_bo + delegate_ + my_ + a_m + end + end + """ + + assert [ + %{ + arity: 0, + name: "test_fun_priv", + origin: "ElixirSenseExample.ModuleA", + type: :function, + visibility: :private + }, + %{ + arity: 1, + name: "test_fun_pub", + origin: "ElixirSenseExample.ModuleA", + type: :function, + visibility: :public + } + ] = Suggestion.suggestions(buffer, 17, 9) + + assert [ + %{ + arity: 0, + name: "test_fun_priv", + origin: "ElixirSenseExample.ModuleA", + type: :function + } + ] = Suggestion.suggestions(buffer, 18, 21) + + assert [ + %{ + arity: 0, + name: "is_boo_overlaps_kernel", + origin: "ElixirSenseExample.ModuleA", + type: :function + }, + %{ + arity: 1, + name: "is_boolean", + origin: "Kernel", + type: :function + } + ] = Suggestion.suggestions(buffer, 19, 10) + + assert [ + %{ + arity: 0, + name: "delegate_defined", + origin: "ElixirSenseExample.ModuleA", + type: :function + }, + %{ + arity: 0, + name: "delegate_not_defined", + origin: "ElixirSenseExample.ModuleA", + type: :function + } + ] = Suggestion.suggestions(buffer, 20, 14) + + assert [ + %{ + args: "value", + arity: 1, + name: "my_guard_priv", + origin: "ElixirSenseExample.ModuleA", + spec: "", + summary: "", + type: :macro, + visibility: :private + }, + %{ + args: "value", + arity: 1, + name: "my_guard_pub", + origin: "ElixirSenseExample.ModuleA", + spec: "", + summary: "", + type: :macro + } + ] = Suggestion.suggestions(buffer, 21, 8) + + assert [ + %{ + args: "a", + arity: 1, + name: "a_macro", + origin: "ElixirSenseExample.ModuleA", + spec: "", + summary: "", + type: :macro, + visibility: :public + }, + %{ + args: "a", + arity: 1, + name: "a_macro_priv", + origin: "ElixirSenseExample.ModuleA", + spec: "", + summary: "", + type: :macro + } + ] = Suggestion.suggestions(buffer, 22, 8) + end + + test "suggest local macro" do + buffer = """ + defmodule MyModule do + defmacrop some_macro(var), do: Macro.expand(var, __CALLER__) + + defmacro other do + some_ma + end + end + """ + + assert [%{name: "some_macro"}] = Suggestion.suggestions(buffer, 5, 12) + end + + test "does not suggest local macro if it's defined after the cursor" do + buffer = """ + defmodule MyModule do + defmacro other do + some_ma + end + + defmacrop some_macro(var), do: Macro.expand(var, __CALLER__) + end + """ + + assert [] == Suggestion.suggestions(buffer, 3, 12) + end + + test "suggest local function even if it's defined after the cursor" do + buffer = """ + defmodule MyModule do + def other do + some_fu + end + + defp some_fun(var), do: :ok + end + """ + + assert [%{name: "some_fun"}] = Suggestion.suggestions(buffer, 3, 12) + end + + test "functions defined in other module fully qualified" do + buffer = """ + defmodule ElixirSenseExample.ModuleO do + def test_fun_pub(a), do: :ok + defp test_fun_priv(), do: :ok + end + + defmodule ElixirSenseExample.ModuleA do + def some_fun() do + ElixirSenseExample.ModuleO.te + end + end + """ + + assert [ + %{ + arity: 1, + name: "test_fun_pub", + origin: "ElixirSenseExample.ModuleO", + type: :function + } + ] = Suggestion.suggestions(buffer, 8, 34) + end + + test "functions defined in other module aliased" do + buffer = """ + defmodule ElixirSenseExample.ModuleO do + def test_fun_pub(a), do: :ok + defp test_fun_priv(), do: :ok + end + + defmodule ElixirSenseExample.ModuleA do + alias ElixirSenseExample.ModuleO + def some_fun() do + ModuleO.te + end + end + """ + + assert [ + %{ + arity: 1, + name: "test_fun_pub", + origin: "ElixirSenseExample.ModuleO", + type: :function + } + ] = Suggestion.suggestions(buffer, 9, 15) + end + + test "functions defined in other module imported" do + buffer = """ + defmodule ElixirSenseExample.ModuleO do + @spec test_fun_pub(integer) :: atom + def test_fun_pub(a), do: :ok + defp test_fun_priv(), do: :ok + end + + defmodule ElixirSenseExample.ModuleA do + import ElixirSenseExample.ModuleO + def some_fun() do + test + __info + end + end + """ + + assert [ + %{ + arity: 1, + name: "test_fun_pub", + origin: "ElixirSenseExample.ModuleO", + type: :function, + args: "a", + args_list: ["a"], + spec: "@spec test_fun_pub(integer()) :: atom()", + summary: "", + metadata: %{}, + snippet: nil, + visibility: :public + } + ] = Suggestion.suggestions(buffer, 10, 9) + + # builtin functions not called locally + assert [] == Suggestion.suggestions(buffer, 11, 11) + end + + test "built-in functions not returned on local calls" do + buffer = """ + defmodule ElixirSenseExample.ModuleO do + + end + """ + + refute Enum.any?(Suggestion.suggestions(buffer, 2, 2), &(&1[:name] == "module_info")) + end + + test "built-in functions not returned on remote calls" do + buffer = """ + defmodule ElixirSenseExample.ModuleO do + ElixirSenseExample.ModuleO. + end + """ + + assert Enum.any?(Suggestion.suggestions(buffer, 2, 30), &(&1[:name] == "module_info")) + end + + test "functions and module suggestions with __MODULE__" do + buffer = """ + defmodule ElixirSenseExample.SmodO do + def test_fun_pub(a), do: :ok + defp test_fun_priv(), do: :ok + end + + defmodule ElixirSenseExample do + defp test_fun_priv1(a), do: :ok + def some_fun() do + __MODULE__.Sm + __MODULE__.SmodO.te + __MODULE__.te + __MODULE__.__in + end + end + """ + + assert [ + %{ + name: "SmodO", + type: :module + } + ] = + Suggestion.suggestions(buffer, 9, 18) + |> Enum.filter(&(&1.name |> String.starts_with?("Smo"))) + + assert [ + %{ + arity: 1, + name: "test_fun_pub", + origin: "ElixirSenseExample.SmodO", + type: :function + } + ] = Suggestion.suggestions(buffer, 10, 24) + + # no private on external call + assert [] = Suggestion.suggestions(buffer, 11, 18) + + assert [ + %{ + arity: 1, + name: "__info__", + origin: "ElixirSenseExample", + type: :function + } + ] = Suggestion.suggestions(buffer, 12, 20) + end + + test "Elixir module" do + buffer = """ + defmodule MyModule do + El + end + """ + + list = Suggestion.suggestions(buffer, 2, 5) + + assert %{ + type: :module, + name: "Elixir", + full_name: "Elixir", + subtype: :alias, + summary: "", + metadata: %{} + } = Enum.at(list, 0) + end + + test "suggestion for aliases modules defined by require clause" do + buffer = """ + defmodule Mod do + require Integer, as: I + I.is_o + end + """ + + list = Suggestion.suggestions(buffer, 3, 9) + assert Enum.at(list, 0).name == "is_odd" + end + + test "suggestion for struct fields" do + buffer = """ + defmodule Mod do + %ElixirSenseExample.IO.Stream{} + %ArgumentError{} + end + """ + + list = + Suggestion.suggestions(buffer, 2, 33) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__struct__", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "ElixirSenseExample.IO.Stream", + value_is_map: false + }, + %{ + name: "device", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "IO.device()", + value_is_map: false + }, + %{ + name: "line_or_bytes", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: ":line | non_neg_integer()", + value_is_map: false + }, + %{ + name: "raw", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "boolean()", + value_is_map: false + } + ] = list + + list = + Suggestion.suggestions(buffer, 3, 18) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__exception__", + origin: "ArgumentError", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "true", + value_is_map: false + }, + %{ + name: "__struct__", + origin: "ArgumentError", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "ArgumentError", + value_is_map: false + }, + %{ + name: "message", + origin: "ArgumentError", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for aliased struct fields" do + buffer = """ + defmodule Mod do + alias ElixirSenseExample.IO.Stream + %Stream{ + end + """ + + list = + Suggestion.suggestions(buffer, 3, 11) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__struct__", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "ElixirSenseExample.IO.Stream", + value_is_map: false + }, + %{ + name: "device", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "IO.device()", + value_is_map: false + }, + %{ + name: "line_or_bytes", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: ":line | non_neg_integer()", + value_is_map: false + }, + %{ + name: "raw", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "boolean()", + value_is_map: false + } + ] = list + end + + test "suggestion for builtin fields in struct pattern match" do + buffer = """ + defmodule Mod do + def my(%_{}), do: :ok + def my(%var{}), do: var + end + """ + + list = + Suggestion.suggestions(buffer, 2, 13) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__struct__", + origin: nil, + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "atom()", + value_is_map: false + } + ] = list + + list = + Suggestion.suggestions(buffer, 3, 15) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__struct__", + origin: nil, + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "atom()", + value_is_map: false + } + ] = list + end + + test "suggestion for struct fields atom module" do + buffer = """ + defmodule Mod do + %:"Elixir.ElixirSenseExample.IO.Stream"{ + end + """ + + list = + Suggestion.suggestions(buffer, 2, 43) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__struct__", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "ElixirSenseExample.IO.Stream", + value_is_map: false + }, + %{ + name: "device", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "IO.device()", + value_is_map: false + }, + %{ + name: "line_or_bytes", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: ":line | non_neg_integer()", + value_is_map: false + }, + %{ + name: "raw", + origin: "ElixirSenseExample.IO.Stream", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "boolean()", + value_is_map: false + } + ] = list + end + + test "suggestion for metadata struct fields" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + field_2: "" + ] + + def func do + %MyServer{} + %MyServer{field_2: "2", } + end + end + """ + + list = + Suggestion.suggestions(buffer, 8, 15) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__struct__", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "MyServer", + value_is_map: false + }, + %{ + name: "field_1", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + }, + %{ + name: "field_2", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + + list = Suggestion.suggestions(buffer, 9, 28) + + assert [ + %{ + name: "__struct__", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "MyServer", + value_is_map: false + }, + %{ + name: "field_1", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for metadata struct fields atom module" do + buffer = """ + defmodule :my_server do + defstruct [ + field_1: nil, + field_2: "" + ] + + def func do + %:my_server{} + %:my_server{field_2: "2", } + end + end + """ + + list = + Suggestion.suggestions(buffer, 8, 17) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "__struct__", + origin: ":my_server", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: ":my_server", + value_is_map: false + }, + %{ + name: "field_1", + origin: ":my_server", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + }, + %{ + name: "field_2", + origin: ":my_server", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + + list = Suggestion.suggestions(buffer, 9, 30) + + assert [ + %{ + name: "__struct__", + origin: ":my_server", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: ":my_server", + value_is_map: false + }, + %{ + name: "field_1", + origin: ":my_server", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for metadata struct fields multiline" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + field_2: "" + ] + + def func do + %MyServer{ + field_2: "2", + + } + end + end + """ + + list = Suggestion.suggestions(buffer, 10, 7) |> Enum.filter(&(&1.type == :field)) + + assert [ + %{ + name: "__struct__", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "MyServer", + value_is_map: false + }, + %{ + name: "field_1", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for metadata struct fields when using `__MODULE__`" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + field_2: "" + ] + + def func do + %__MODULE__{field_2: "2", } + end + end + """ + + list = Suggestion.suggestions(buffer, 8, 31) + + assert [ + %{ + name: "__struct__", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: "MyServer", + value_is_map: false + }, + %{ + name: "field_1", + origin: "MyServer", + type: :field, + call?: false, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for struct fields in variable.key call syntax" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + field_2: "" + ] + + def func do + var_1 = %MyServer{} + var_1.f + end + end + """ + + list = + Suggestion.suggestions(buffer, 9, 12) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "field_1", + origin: "MyServer", + type: :field, + call?: true, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + }, + %{ + name: "field_2", + origin: "MyServer", + type: :field, + call?: true, + subtype: :struct_field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for map fields in variable.key call syntax" do + buffer = """ + defmodule MyServer do + def func do + var_1 = %{key_1: 1, key_2: %{abc: 123}} + var_1.k + end + end + """ + + list = + Suggestion.suggestions(buffer, 4, 12) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "key_1", + origin: nil, + type: :field, + call?: true, + subtype: :map_key, + type_spec: nil, + value_is_map: false + }, + %{ + name: "key_2", + origin: nil, + type: :field, + call?: true, + subtype: :map_key, + type_spec: nil, + value_is_map: true + } + ] = list + end + + test "suggestion for map fields in @attribute.key call syntax" do + buffer = """ + defmodule MyServer do + @var_1 %{key_1: 1, key_2: %{abc: 123}} + def func do + @var_1.k + end + end + """ + + list = + Suggestion.suggestions(buffer, 4, 13) + |> Enum.filter(&(&1.type in [:field])) + + assert [ + %{ + name: "key_1", + origin: nil, + type: :field, + call?: true, + subtype: :map_key, + type_spec: nil, + value_is_map: false + }, + %{ + name: "key_2", + origin: nil, + type: :field, + call?: true, + subtype: :map_key, + type_spec: nil, + value_is_map: true + } + ] = list + end + + test "suggestion for functions in variable.key call syntax" do + buffer = """ + defmodule MyServer do + def func do + var_1 = Atom + var_1.to_str + end + end + """ + + list = + Suggestion.suggestions(buffer, 4, 17) + |> Enum.filter(&(&1.type in [:function])) + + assert [%{name: "to_string", origin: "Atom", type: :function}] = list + end + + test "suggestion for vars in struct update" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + some_field: "" + ] + + def some_func() do + false + end + + def func(%MyServer{} = some_arg) do + %MyServer{some + end + end + """ + + list = Suggestion.suggestions(buffer, 12, 19) + + assert [ + %{name: "some_arg", type: :variable}, + %{name: "some_func", type: :function}, + %{ + origin: "MyServer", + type: :field, + name: "some_field", + call?: false, + subtype: :struct_field + } + ] = list + end + + test "suggestion for fields in struct update" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + some_field: "" + ] + + def func(%MyServer{} = some_arg) do + %MyServer{some_arg | fiel + end + end + """ + + list = Suggestion.suggestions(buffer, 8, 30) + + assert [ + %{ + call?: false, + name: "field_1", + origin: "MyServer", + subtype: :struct_field, + type: :field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for fields in struct update variable when module not set" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + some_field: "" + ] + + def func(%MyServer{} = some_arg) do + %{some_arg | fiel + end + end + """ + + list = Suggestion.suggestions(buffer, 8, 22) + + assert [ + %{ + call?: false, + name: "field_1", + origin: "MyServer", + subtype: :struct_field, + type: :field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for fields in struct update attribute when module not set" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + some_field: "" + ] + + @str %MyServer{} + + %{@str | fiel + end + """ + + list = Suggestion.suggestions(buffer, 9, 16) + + assert [ + %{ + call?: false, + name: "field_1", + origin: "MyServer", + subtype: :struct_field, + type: :field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for fields in struct update when struct type is var" do + buffer = """ + defmodule MyServer do + def func(%var{field_1: "asd"} = some_arg) do + %{some_arg | fiel + end + end + """ + + list = Suggestion.suggestions(buffer, 3, 22) + + assert [ + %{ + call?: false, + name: "field_1", + origin: nil, + subtype: :struct_field, + type: :field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for fields in struct when struct type is attribute" do + buffer = """ + defmodule MyServer do + @t Time + def x do + %@t{ho + end + end + """ + + list = Suggestion.suggestions(buffer, 4, 11) + + assert [ + %{ + call?: false, + name: "hour", + origin: "Time", + subtype: :struct_field, + type: :field, + type_spec: "Calendar.hour()", + value_is_map: false + } + ] = list + end + + test "suggestion for keys in map update" do + buffer = """ + defmodule MyServer do + def func(%{field_1: "asd"} = some_arg) do + %{some_arg | fiel + end + end + """ + + list = Suggestion.suggestions(buffer, 3, 22) + + assert [ + %{ + call?: false, + name: "field_1", + origin: nil, + subtype: :map_key, + type: :field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for fuzzy struct fields" do + buffer = """ + defmodule MyServer do + def func(%{field_1: "asd"} = some_arg) do + %{some_arg | fie1 + end + end + """ + + list = Suggestion.suggestions(buffer, 3, 22) + + assert [ + %{ + call?: false, + name: "field_1", + origin: nil, + subtype: :map_key, + type: :field, + type_spec: nil, + value_is_map: false + } + ] = list + end + + test "suggestion for funcs and vars in struct" do + buffer = """ + defmodule MyServer do + defstruct [ + field_1: nil, + some_field: "" + ] + + def other_func(), do: :ok + + def func(%MyServer{} = some_arg, other_arg) do + %MyServer{some_arg | + field_1: ot + end + end + """ + + list = Suggestion.suggestions(buffer, 11, 18) + + assert [ + %{name: "other_arg", type: :variable}, + %{ + name: "other_func", + type: :function, + args: "", + args_list: [], + arity: 0, + origin: "MyServer", + spec: "", + summary: "", + visibility: :public, + snippet: nil, + metadata: %{} + } + ] = list + end + + test "no suggestion of fields when the module is not a struct" do + buffer = """ + defmodule Mod do + %Enum{ + end + """ + + list = Suggestion.suggestions(buffer, 2, 9) + assert Enum.any?(list, fn %{type: type} -> type == :field end) == false + end + + test "suggest struct fields when metadata function evaluates to struct" do + buffer = """ + defmodule Mod do + defstruct [field: nil] + @type t :: %__MODULE__{} + + @spec fun() :: t + def fun(), do: %Mod{} + + def some do + var = fun() + var. + end + end + """ + + list = Suggestion.suggestions(buffer, 10, 9) + + assert [ + %{call?: true, name: "__struct__", origin: "Mod"}, + %{call?: true, name: "field", origin: "Mod", subtype: :struct_field, type: :field} + ] = list + end + + test "suggest struct fields when metadata function evaluates to remote type" do + buffer = """ + defmodule Mod do + @spec fun() :: NaiveDateTime.t() + def fun(), do: NaiveDateTime.new(1, 2) + + def some do + var = fun() + var.h + end + end + """ + + list = Suggestion.suggestions(buffer, 7, 10) + + assert [%{name: "hour", origin: "NaiveDateTime"}] = list + end + + test "suggest struct fields when metadata function evaluates to remote type aliased" do + buffer = """ + defmodule Mod do + alias NaiveDateTime, as: MyType + @spec fun() :: MyType.t() + def fun(), do: MyType.new(1, 2) + + def some do + var = fun() + var.h + end + end + """ + + list = Suggestion.suggestions(buffer, 8, 10) + + assert [%{name: "hour", origin: "NaiveDateTime"}] = list + end + + test "suggest struct fields when metadata function evaluates to remote type __MODULE__" do + buffer = """ + defmodule Mod do + @type t :: NaiveDateTime.t() + + @spec fun() :: __MODULE__.t() + def fun(), do: nil + + def some do + var = fun() + var.h + end + end + """ + + list = Suggestion.suggestions(buffer, 9, 10) + + assert [%{name: "hour", origin: "NaiveDateTime"}] = list + end + + test "suggest struct fields when metadata function evaluates to remote type __MODULE__.Submodule" do + buffer = """ + defmodule Mod do + defmodule Sub do + @type t :: NaiveDateTime.t() + end + + @spec fun() :: __MODULE__.Sub.t() + def fun(), do: nil + + def some do + var = fun() + var.h + end + end + """ + + list = Suggestion.suggestions(buffer, 11, 10) + + assert [%{name: "hour", origin: "NaiveDateTime"}] = list + end + + test "suggest struct fields when variable is struct" do + buffer = """ + defmodule Abc do + defstruct [:cde] + end + + defmodule Mod do + def my() do + some(abc) + abc = %Abc{cde: 1} + abc. + end + end + """ + + list = Suggestion.suggestions(buffer, 9, 9) + + assert [ + %{call?: true, name: "__struct__", origin: "Abc"}, + %{call?: true, name: "cde", origin: "Abc", subtype: :struct_field, type: :field} + ] = list + end + + test "suggest struct fields when variable is rebound to struct" do + buffer = """ + defmodule Abc do + defstruct [:cde] + end + + defmodule Mod do + def my() do + abc = 1 + some(abc) + abc = %Abc{cde: 1} + abc.cde + abc = 1 + end + end + """ + + list = Suggestion.suggestions(buffer, 10, 9) + + assert [ + %{call?: true, name: "__struct__", origin: "Abc"}, + %{call?: true, name: "cde", origin: "Abc", subtype: :struct_field, type: :field} + ] = list + end + + test "suggest struct fields when attribute is struct" do + buffer = """ + defmodule Abc do + defstruct [:cde] + end + + defmodule Mod do + @abc %Abc{cde: 1} + @abc. + end + """ + + list = Suggestion.suggestions(buffer, 7, 8) + + assert [ + %{call?: true, name: "__struct__", origin: "Abc"}, + %{call?: true, name: "cde", origin: "Abc", subtype: :struct_field, type: :field} + ] = list + end + + test "suggest struct fields when attribute is rebound to struct" do + buffer = """ + defmodule Abc do + defstruct [:cde] + end + + defmodule Mod do + @abc 1 + @abc %Abc{cde: 1} + @abc. + end + """ + + list = Suggestion.suggestions(buffer, 8, 8) + + assert [ + %{call?: true, name: "__struct__", origin: "Abc"}, + %{call?: true, name: "cde", origin: "Abc", subtype: :struct_field, type: :field} + ] = list + end + + test "suggest modules to alias" do + buffer = """ + defmodule MyModule do + alias Str + end + """ + + list = + Suggestion.suggestions(buffer, 2, 12) + |> Enum.filter(fn s -> s.type == :module end) + + # NOTE: StreamData (the stream_data dep) is loaded in the elixir-ls test env but not elixir_sense's. + assert [ + %{name: "Stream"}, + %{name: "String"}, + %{name: "StringIO"} + ] = list |> Enum.filter(&(&1.name |> String.starts_with?("Str"))) + end + + test "suggest modules to alias with __MODULE__" do + buffer = """ + defmodule Stream do + alias __MODULE__.Re + end + """ + + list = Suggestion.suggestions(buffer, 2, 22) + + assert [%{name: "Reducers", type: :module} | _] = list + end + + test "suggest modules to alias in multi alias syntax" do + buffer = """ + defmodule MyModule do + alias Stream.{Re + end + """ + + list = Suggestion.suggestions(buffer, 2, 19) + + assert [%{name: "Reducers", type: :module}] = list + end + + test "suggest modules to alias in multi alias syntax with __MODULE__" do + buffer = """ + defmodule Stream do + alias __MODULE__.{Re + end + """ + + list = Suggestion.suggestions(buffer, 2, 23) + + assert Enum.any?(list, &(&1.name == "Reducers" and &1.type == :module)) + end + + describe "suggestion for param options" do + test "suggest more than one option" do + buffer = "Local.func_with_options(" + + list = suggestions_by_type(:param_option, buffer) + assert length(list) > 1 + end + + test "are fuzzy" do + buffer = "Local.func_with_options(remo_wi" + list = suggestions_by_type(:param_option, buffer) + assert [%{name: "remote_with_params_o"}] = list + end + + test "handles macros" do + buffer = """ + require Local + Local.macro_with_options(remo_wi\ + """ + + list = suggestions_by_type(:param_option, buffer) + assert [%{name: "remote_with_params_o"}] = list + end + + test "suggest the same list when options are already set" do + buffer1 = "Local.func_with_options(" + buffer2 = "Local.func_with_options(local_o: :an_atom, " + + capture_io(:stderr, fn -> + result1 = suggestions_by_type(:param_option, buffer1) + result2 = suggestions_by_type(:param_option, buffer2) + send(self(), {:results, result1, result2}) + end) + + assert_received {:results, result1, result2} + assert result1 == result2 + end + + test "options as inline list" do + buffer = "Local.func_with_options_as_inline_list(" + + assert %{type_spec: "atom()"} = + suggestion_by_name("local_o", buffer) + + assert %{ + type_spec: "keyword()" + } = suggestion_by_name("builtin_o", buffer) + end + + test "options vars defined in when" do + type_spec = "atom()" + origin = "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_option_var_defined_in_when" + + buffer = "Local.func_with_option_var_defined_in_when(" + suggestion = suggestion_by_name("local_o", buffer) + + assert suggestion.type_spec == type_spec + assert suggestion.origin == origin + + origin = + "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options_var_defined_in_when" + + buffer = "Local.func_with_options_var_defined_in_when(" + suggestion = suggestion_by_name("local_o", buffer) + + assert suggestion.type_spec == type_spec + assert suggestion.origin == origin + end + + test "opaque type internal structure is not revealed" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("opaque_o", buffer) + + assert suggestion.type_spec == "opaque_t()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "private type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("private_o", buffer) + + assert suggestion.type_spec == "atom()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "local type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("local_o", buffer) + + assert suggestion.type_spec == "atom()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "local type with params" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("local_with_params_o", buffer) + + assert suggestion.type_spec == "{atom(), integer()}" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "basic type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("basic_o", buffer) + + assert suggestion.type_spec == "pid()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "basic type with params" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("basic_with_params_o", buffer) + + assert suggestion.type_spec == "[atom(), ...]" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "built-in type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("builtin_o", buffer) + + assert suggestion.type_spec == "keyword()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "built-in type with params" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("builtin_with_params_o", buffer) + + assert suggestion.type_spec == "keyword(term())" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "union type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("union_o", buffer) + + assert suggestion.type_spec == "atom() | integer()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "list type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("list_o", buffer) + + assert suggestion.type_spec == "[:trace | :log]" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "remote type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("remote_o", buffer) + + assert suggestion.type_spec == "atom()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "remote type with args" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("remote_with_params_o", buffer) + + assert suggestion.type_spec == + "{atom(), integer()}" + + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "remote erlang type with doc" do + buffer = "Local.func_with_erlang_type_options(" + suggestion = suggestion_by_name("erlang_t", buffer) + + assert suggestion.type_spec == + "pos_integer()\n| :second\n| :millisecond\n| :microsecond\n| :nanosecond\n| :native\n| :perf_counter\n| :seconds\n| :milli_seconds\n| :micro_seconds\n| :nano_seconds" + + assert suggestion.origin == + "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_erlang_type_options" + end + + test "remote aliased type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("remote_aliased_o", buffer) + + assert suggestion.type_spec == "atom() | [atom()]" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "remote aliased inline type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("remote_aliased_inline_o", buffer) + + assert suggestion.type_spec == "atom()" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "inline list type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("inline_list_o", buffer) + + assert suggestion.type_spec == "[:trace | :log]" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "non existent type" do + buffer = "Local.func_with_options(" + suggestion = suggestion_by_name("non_existent_o", buffer) + + assert suggestion.type_spec == + "ElixirSenseExample.ModuleWithTypespecs.Remote.non_existent()" + + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local.func_with_options" + end + + test "named options" do + buffer = "Local.func_with_named_options(" + assert suggestion_by_name("local_o", buffer).type_spec == "atom()" + end + + test "options with only one option" do + buffer = "Local.func_with_one_option(" + assert suggestion_by_name("option_1", buffer).type_spec == "integer()" + end + + test "union of options" do + buffer = "Local.func_with_union_of_options(" + + assert suggestion_by_name("local_o", buffer).type_spec == "atom()" + assert suggestion_by_name("option_1", buffer).type_spec == "atom()" + end + + test "union of options inline" do + buffer = "Local.func_with_union_of_options_inline(" + + assert suggestion_by_name("local_o", buffer).type_spec == "atom()" + assert suggestion_by_name("option_1", buffer).type_spec == "atom()" + end + + test "union of options (local and remote) as type + inline" do + buffer = "Local.func_with_union_of_options_as_type(" + assert suggestion_by_name("option_1", buffer).type_spec == "boolean()" + + suggestion = suggestion_by_name("remote_option_1", buffer) + assert suggestion.type_spec == "atom()" + end + + test "atom only options" do + # only keyword in shorthand keyword list + buffer = ":ets.new(:name, " + assert list = suggestions_by_type(:param_option, buffer) + refute Enum.any?(list, &match?(%{name: "bag"}, &1)) + assert Enum.any?(list, &match?(%{name: "write_concurrency"}, &1)) + + buffer = ":ets.new(:name, heir: pid, " + assert list = suggestions_by_type(:param_option, buffer) + refute Enum.any?(list, &match?(%{name: "bag"}, &1)) + assert Enum.any?(list, &match?(%{name: "write_concurrency"}, &1)) + + # suggest atom options in list + buffer = ":ets.new(:name, [" + assert list = suggestions_by_type(:param_option, buffer) + assert Enum.any?(list, &match?(%{name: "bag"}, &1)) + assert Enum.any?(list, &match?(%{name: "set"}, &1)) + assert Enum.any?(list, &match?(%{name: "write_concurrency"}, &1)) + + buffer = ":ets.new(:name, [:set, " + assert list = suggestions_by_type(:param_option, buffer) + assert Enum.any?(list, &match?(%{name: "bag"}, &1)) + # refute Enum.any?(list, &match?(%{name: "set"}, &1)) + assert Enum.any?(list, &match?(%{name: "write_concurrency"}, &1)) + + # no atoms after keyword pair + buffer = ":ets.new(:name, [:set, heir: pid, " + assert list = suggestions_by_type(:param_option, buffer) + refute Enum.any?(list, &match?(%{name: "bag"}, &1)) + assert Enum.any?(list, &match?(%{name: "write_concurrency"}, &1)) + end + + test "format type spec" do + buffer = "Local.func_with_options(" + + assert suggestion_by_name("large_o", buffer).type_spec == + "pid() | port() | (registered_name :: atom()) | {registered_name :: atom(), node()}" + end + + test "params with default args" do + buffer = """ + ElixirSenseExample.ModuleWithTypespecs.Local.fun_with_default() + """ + + list = Suggestion.suggestions(buffer, 1, 63) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "params with multiple specs" do + buffer = """ + ElixirSenseExample.ModuleWithTypespecs.Local.fun_with_multiple_specs() + """ + + list = Suggestion.suggestions(buffer, 1, 70) + assert [%{name: "opt_name"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "params with multiple functions" do + buffer = """ + ElixirSenseExample.ModuleWithTypespecs.Local.multiple_functions() + """ + + list = Suggestion.suggestions(buffer, 1, 65) + assert [%{name: "foo"}, %{name: "bar"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "params from callback" do + buffer = """ + ElixirSenseExample.ModuleWithTypespecs.Impl.some() + """ + + list = Suggestion.suggestions(buffer, 1, 50) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "params from macrocallback" do + buffer = """ + require ElixirSenseExample.ModuleWithTypespecs.MacroImpl + ElixirSenseExample.ModuleWithTypespecs.MacroImpl.some() + """ + + list = Suggestion.suggestions(buffer, 2, 55) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params" do + buffer = """ + defmodule Foo do + @spec some([{:foo, integer()} | {:bar, String.t()}]) :: :ok + def some(options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 6, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params macro" do + buffer = """ + defmodule Foo do + @spec some([{:foo, integer()} | {:bar, String.t()}]) :: Macro.t() + defmacro some(options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 6, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params multiple specs" do + buffer = """ + defmodule Foo do + @spec some([{:foo, integer()} | {:bar, String.t()}]) :: :ok + @spec some(nil) :: :ok + def some(options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 7, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params multiple functions" do + buffer = """ + defmodule Foo do + @spec some([{:foo, integer()}]) :: :ok + def some(options), do: :ok + + @spec some([{:bar, String.t()}]) :: :ok + def some(options, a), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 9, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params with default args" do + buffer = """ + defmodule Foo do + @spec some(atom, [{:foo, integer()} | {:bar, String.t()}]) :: :ok + def some(a \\\\ nil, options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 6, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params from callback" do + buffer = """ + defmodule Foo do + @callback some([{:foo, integer()} | {:bar, String.t()}]) :: :ok + end + + defmodule Bar do + @behaviour Foo + + @impl true + def some(options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 12, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params from macrocallback" do + buffer = """ + defmodule Foo do + @macrocallback some([{:foo, integer()} | {:bar, String.t()}]) :: Macro.t() + end + + defmodule Bar do + @behaviour Foo + + @impl true + defmacro some(options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 12, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params from compiled module callback" do + buffer = """ + defmodule Bar do + @behaviour ElixirSenseExample.ModuleWithTypespecs.Behaviour + + @impl true + def some(options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 8, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + + test "metadata params from compiled module macrocallback" do + buffer = """ + defmodule Bar do + @behaviour ElixirSenseExample.ModuleWithTypespecs.MacroBehaviour + + @impl true + defmacro some(options), do: :ok + + def go do + some() + end + end + """ + + list = Suggestion.suggestions(buffer, 8, 10) + assert [%{name: "bar"}, %{name: "foo"}] = list |> Enum.filter(&(&1.type == :param_option)) + end + end + + describe "suggestions for typespecs" do + test "remote types - filter list of typespecs" do + buffer = """ + defmodule My do + @type a :: Remote.remote_t\ + """ + + list = suggestions_by_type(:type_spec, buffer) + assert length(list) == 4 + end + + test "remote types - retrieve info from typespecs" do + buffer = """ + defmodule My do + @type a :: Remote.\ + """ + + suggestion = suggestion_by_name("remote_list_t", buffer) + + assert suggestion.spec == """ + @type remote_list_t() :: [ + remote_t() + ]\ + """ + + assert suggestion.signature == "remote_list_t()" + assert suggestion.arity == 0 + assert suggestion.doc == "Remote list type" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Remote" + end + + test "on specs" do + buffer = """ + defmodule My do + @spec a() :: Remote.\ + """ + + assert %{name: "remote_list_t"} = suggestion_by_name("remote_list_t", buffer) + + buffer = """ + defmodule My do + @spec a(Remote.) :: integer + end + """ + + assert %{name: "remote_list_t"} = suggestion_by_name("remote_list_t", buffer, 2, 18) + + buffer = """ + defmodule My do + @spec a(Remote.) + end + """ + + assert %{name: "remote_list_t"} = suggestion_by_name("remote_list_t", buffer, 2, 18) + end + + test "on callbacks" do + buffer = """ + defmodule My do + @callback a() :: none + end + """ + + assert [_, _] = suggestions_by_name("nonempty_list", buffer, 2, 24) + + buffer = """ + defmodule My do + @callback a(none) :: integer + end + """ + + assert [_, _] = suggestions_by_name("nonempty_list", buffer, 2, 19) + + buffer = """ + defmodule My do + @callback a(none) + end + """ + + assert [_, _] = suggestions_by_name("nonempty_list", buffer, 2, 19) + end + + test "remote types - by attribute" do + buffer = """ + defmodule My do + @type my_type :: integer + @attr My + @type some :: @attr.my\ + """ + + [suggestion_1] = suggestions_by_name("my_type", buffer) + + assert suggestion_1.signature == "my_type()" + end + + test "remote types - by __MODULE__" do + buffer = """ + defmodule My do + @type my_type :: integer + @type some :: __MODULE__.my\ + """ + + [suggestion_1] = suggestions_by_name("my_type", buffer) + + assert suggestion_1.signature == "my_type()" + end + + test "remote types - retrieve info from typespecs with params" do + buffer = """ + defmodule My do + @type a :: Remote.\ + """ + + [suggestion_1, suggestion_2] = suggestions_by_name("remote_t", buffer) + + assert suggestion_1.spec == "@type remote_t() :: atom()" + assert suggestion_1.signature == "remote_t()" + assert suggestion_1.arity == 0 + assert suggestion_1.doc == "Remote type" + assert suggestion_1.origin == "ElixirSenseExample.ModuleWithTypespecs.Remote" + + assert suggestion_2.spec =~ "@type remote_t(a, b) ::" + assert suggestion_2.signature == "remote_t(a, b)" + assert suggestion_2.arity == 2 + assert suggestion_2.doc == "Remote type with params" + assert suggestion_2.origin == "ElixirSenseExample.ModuleWithTypespecs.Remote" + end + + test "local types - filter list of typespecs" do + buffer = """ + defmodule ElixirSenseExample.ModuleWithTypespecs.Local do + # The types are defined in `test/support/module_with_typespecs.ex` + @type my_type :: local_ + # ^ + end + """ + + list = + Suggestion.suggestions(buffer, 3, 26) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + assert length(list) == 2 + end + + test "typespec fuzzy match" do + buffer = """ + defmodule ElixirSenseExample.ModuleWithTypespecs.Local do + # The types are defined in `test/support/module_with_typespecs.ex` + @type fuzzy_type :: loca_ + # ^ + end + """ + + list = + Suggestion.suggestions(buffer, 3, 27) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + [suggestion, _] = list + + assert suggestion.spec == "@type local_t() :: atom()" + assert suggestion.signature == "local_t()" + assert suggestion.arity == 0 + assert suggestion.doc == "Local type" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local" + end + + test "local types - retrieve info from typespecs" do + buffer = """ + defmodule ElixirSenseExample.ModuleWithTypespecs.Local do + # The types are defined in `test/support/module_with_typespecs.ex` + @type my_type :: local_t + # ^ + end + """ + + list = + Suggestion.suggestions(buffer, 3, 27) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + [suggestion, _] = list + + assert suggestion.spec == "@type local_t() :: atom()" + assert suggestion.signature == "local_t()" + assert suggestion.arity == 0 + assert suggestion.doc == "Local type" + assert suggestion.origin == "ElixirSenseExample.ModuleWithTypespecs.Local" + end + + test "builtin types - filter list of typespecs" do + buffer = "defmodule My, do: @type my_type :: lis" + + list = suggestions_by_type(:type_spec, buffer) + assert length(list) == 2 + end + + test "builtin types - retrieve info from typespecs" do + buffer = "defmodule My, do: @type my_type :: lis" + + [suggestion | _] = suggestions_by_type(:type_spec, buffer) + + assert suggestion.spec == "@type list() :: [any()]" + assert suggestion.signature == "list()" + assert suggestion.arity == 0 + assert suggestion.doc == "A list" + assert suggestion.origin == nil + end + + test "builtin types - retrieve info from typespecs with params" do + buffer = "defmodule My, do: @type my_type :: lis" + + [_, suggestion | _] = suggestions_by_type(:type_spec, buffer) + + assert suggestion.spec == "@type list(t())" + assert suggestion.signature == "list(t())" + assert suggestion.arity == 1 + assert suggestion.doc == "Proper list ([]-terminated)" + assert suggestion.origin == nil + end + + test "builtin types - retrieve info from basic types" do + buffer = "defmodule My, do: @type my_type :: int" + + [_, suggestion | _] = suggestions_by_type(:type_spec, buffer) + + assert suggestion.spec == "@type integer()" + assert suggestion.signature == "integer()" + assert suggestion.arity == 0 + assert suggestion.doc == "An integer number" + assert suggestion.origin == nil + end + + test "erlang types" do + buffer = "defmodule My, do: @type my_type :: :erlang.time_" + + suggestions = suggestions_by_type(:type_spec, buffer) + + assert [ + %{ + arity: 0, + doc: summary, + name: "time_unit", + origin: ":erlang", + signature: "time_unit()", + spec: + "@type time_unit() ::\n pos_integer()\n | :second\n | :millisecond\n | :microsecond\n | :nanosecond\n | :native\n | :perf_counter\n | deprecated_time_unit()", + type: :type_spec + } + ] = suggestions + + if System.otp_release() |> String.to_integer() >= 27 do + assert "The time unit used" <> _ = summary + else + assert summary =~ "Supported time unit representations:" + end + end + + test "no erlang private types" do + buffer = "defmodule My, do: @type my_type :: :dialyzer_plt.dialyzer_p" + + suggestions = suggestions_by_type(:type_spec, buffer) + + assert [] == suggestions + end + + test "type with @typedoc false" do + buffer = + "defmodule My, do: @type my_type :: ElixirSenseExample.ModuleWithDocs.some_type_doc_false" + + suggestions = suggestions_by_type(:type_spec, buffer) + + assert [ + %{ + arity: 0, + doc: "", + name: "some_type_doc_false", + origin: "ElixirSenseExample.ModuleWithDocs", + signature: "some_type_doc_false()", + spec: "@type some_type_doc_false() ::" <> _, + type: :type_spec, + metadata: %{} + } + ] = suggestions + end + + test "local types from metadata" do + buffer = """ + defmodule MyModule do + @typep my_local_t :: integer + @typep my_local_arg_t(a, b) :: {a, b} + @type my_type :: my_loc + # ^ + end + """ + + list = + Suggestion.suggestions(buffer, 4, 26) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + assert [suggestion1, suggestion2] = list + + assert %{ + arity: 0, + name: "my_local_t", + origin: "MyModule", + type: :type_spec, + signature: "my_local_t()", + args_list: [], + doc: "", + spec: "@typep my_local_t() :: integer()", + metadata: %{} + } == suggestion2 + + assert %{ + arity: 2, + name: "my_local_arg_t", + origin: "MyModule", + type: :type_spec, + signature: "my_local_arg_t(a, b)", + args_list: ["a", "b"], + doc: "", + spec: "@typep my_local_arg_t(a, b) :: {a, b}", + metadata: %{} + } == suggestion1 + end + + test "suggest local types from metadata even if defined after the cursor" do + buffer = """ + defmodule MyModule do + @type my_type :: my_loc + # ^ + + @typep my_local_t :: integer + end + """ + + list = + Suggestion.suggestions(buffer, 2, 26) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + assert [%{name: "my_local_t"}] = list + end + + test "return docs and meta on local types" do + buffer = """ + defmodule MyModule do + @type my_type :: my_loc + # ^ + + @typedoc "Some" + @typedoc since: "1.2.3" + @type my_local_t :: integer + end + """ + + list = + Suggestion.suggestions(buffer, 2, 26) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + assert [%{name: "my_local_t", doc: "Some", metadata: %{since: "1.2.3"}}] = list + end + + test "local types from metadata external call - private types are not suggested" do + buffer = """ + defmodule MyModule do + @type my_local_t :: integer + @typep my_local_arg_t(a, b) :: {a, b} + @type my_type :: MyModule.my_loc + # ^ + end + """ + + list = + Suggestion.suggestions(buffer, 4, 35) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + assert [suggestion1] = list + + assert %{ + arity: 0, + name: "my_local_t", + origin: "MyModule", + type: :type_spec, + signature: "my_local_t()", + args_list: [], + doc: "", + spec: "@type my_local_t() :: integer()", + metadata: %{} + } == suggestion1 + end + + test "remote public and opaque types from metadata" do + buffer = """ + defmodule SomeModule do + @typep my_local_priv_t :: integer + @type my_local_pub_t(a, b) :: {a, b} + @opaque my_local_op_t() :: my_local_priv_t + end + + defmodule MyModule do + alias SomeModule, as: Some + @type my_type :: Some.my_loc + # ^ + end + """ + + list = + Suggestion.suggestions(buffer, 9, 31) + |> Enum.filter(fn %{type: t} -> t == :type_spec end) + + assert [suggestion1, suggestion2] = list + + assert %{ + arity: 2, + name: "my_local_pub_t", + origin: "SomeModule", + type: :type_spec, + signature: "my_local_pub_t(a, b)", + args_list: ["a", "b"], + doc: "", + spec: "@type my_local_pub_t(a, b) :: {a, b}", + metadata: %{} + } == suggestion2 + + assert %{ + arity: 0, + name: "my_local_op_t", + origin: "SomeModule", + type: :type_spec, + signature: "my_local_op_t()", + args_list: [], + doc: "", + spec: "@opaque my_local_op_t()", + metadata: %{opaque: true} + } == suggestion1 + end + end + + test "suggestion understands alias shadowing" do + # ordinary alias + buffer = """ + defmodule ElixirSenseExample.OtherModule do + alias ElixirSenseExample.SameModule + def some_fun() do + SameModule.te + end + end + """ + + assert [ + %{origin: "ElixirSenseExample.SameModule"} + ] = Suggestion.suggestions(buffer, 4, 17) + + # alias shadowing scope/inherited aliases + buffer = """ + defmodule ElixirSenseExample.Abc.SameModule do + alias List, as: SameModule + alias ElixirSenseExample.SameModule + def some_fun() do + SameModule.te + end + end + """ + + assert [ + %{origin: "ElixirSenseExample.SameModule"} + ] = Suggestion.suggestions(buffer, 5, 17) + + buffer = """ + defmodule ElixirSenseExample.Abc.SameModule do + require Logger, as: ModuleB + require ElixirSenseExample.SameModule, as: SameModule + SameModule.so + end + """ + + assert [ + %{origin: "ElixirSenseExample.SameModule"} + ] = Suggestion.suggestions(buffer, 4, 15) + end + + test "operator" do + buffer = """ + defmodule ElixirSenseExample.OtherModule do + def some_fun() do + a + + end + end + """ + + assert [%{name: "+"}, %{name: "+"}, %{name: "++"}] = + Suggestion.suggestions(buffer, 3, 8) |> Enum.filter(&("#{&1.name}" =~ "+")) + end + + test "sigil" do + buffer = """ + defmodule ElixirSenseExample.OtherModule do + def some_fun() do + ~ + end + end + """ + + suggestions = Suggestion.suggestions(buffer, 3, 6) + + assert [ + %{ + args: "term, modifiers", + arity: 2, + name: "~w", + summary: "Handles the sigil `~w` for list of words.", + type: :macro + } + ] = suggestions |> Enum.filter(&(&1.name == "~w")) + end + + test "bitstring options" do + buffer = """ + defmodule ElixirSenseExample.OtherModule do + alias ElixirSenseExample.SameModule + def some_fun() do + <> + end + end + """ + + options = + Suggestion.suggestions(buffer, 4, 12) + |> Enum.filter(&(&1.type == :bitstring_option)) + |> Enum.map(& &1.name) + + assert "integer" in options + assert "native" in options + assert "signed" in options + + buffer = """ + defmodule ElixirSenseExample.OtherModule do + alias ElixirSenseExample.SameModule + def some_fun() do + <> + end + end + """ + + ["integer"] = + Suggestion.suggestions(buffer, 4, 15) + |> Enum.filter(&(&1.type == :bitstring_option)) + |> Enum.map(& &1.name) + + buffer = """ + defmodule ElixirSenseExample.OtherModule do + alias ElixirSenseExample.SameModule + def some_fun() do + <> + end + end + """ + + options = + Suggestion.suggestions(buffer, 4, 33) + |> Enum.filter(&(&1.type == :bitstring_option)) + |> Enum.map(& &1.name) + + assert "unit" in options + assert "size" in options + # invalid after a `binary` type: sign / endianness / another type must not be offered + refute "signed" in options + refute "unsigned" in options + refute "little" in options + refute "big" in options + refute "native" in options + refute "utf8" in options + refute "binary" in options + + # utf types take no further modifiers - size/unit/sign/endianness are all invalid + buffer = """ + defmodule ElixirSenseExample.OtherModule do + alias ElixirSenseExample.SameModule + def some_fun() do + <> + end + end + """ + + assert [] = + Suggestion.suggestions(buffer, 4, 31) + |> Enum.filter(&(&1.type == :bitstring_option)) + |> Enum.map(& &1.name) + + # float supports little/big (not native), size and unit - but not sign or another type + buffer = """ + defmodule ElixirSenseExample.OtherModule do + alias ElixirSenseExample.SameModule + def some_fun() do + <> + end + end + """ + + options = + Suggestion.suggestions(buffer, 4, 32) + |> Enum.filter(&(&1.type == :bitstring_option)) + |> Enum.map(& &1.name) + + assert "little" in options + assert "big" in options + assert "size" in options + assert "unit" in options + refute "native" in options + refute "signed" in options + refute "binary" in options + refute "utf8" in options + + buffer = """ + defmodule ElixirSenseExample.OtherModule do + alias ElixirSenseExample.SameModule + def some_fun() do + <> + end + end + """ + + ["native"] = + Suggestion.suggestions(buffer, 4, 35) + |> Enum.filter(&(&1.type == :bitstring_option)) + |> Enum.map(& &1.name) + end + + # TODO change that to only output max arity + test "function with default args generate multiple entries" do + buffer = """ + ElixirSenseExample.FunctionsWithTheSameName.all + """ + + assert [ + %{ + arity: 2, + default_args: 1, + name: "all?", + summary: "all?/2 docs", + type: :function + } + ] = Suggestion.suggestions(buffer, 1, 48) |> Enum.filter(&(&1[:name] == "all?")) + end + + test "functions with the same name but different arities generates independent entries" do + buffer = """ + ElixirSenseExample.FunctionsWithTheSameName.con + """ + + assert [ + %{ + arity: 1, + default_args: 0, + name: "concat", + summary: "concat/1 docs", + type: :function + }, + %{ + arity: 2, + default_args: 0, + name: "concat", + summary: "concat/2 docs", + type: :function + } + ] = + Suggestion.suggestions(buffer, 1, 48) |> Enum.filter(&(&1[:name] == "concat")) + end + + test "function with default args from metadata" do + buffer = """ + defmodule SomeSchema do + def my_func(a, b \\\\ "") + def my_func(1, b), do: :ok + def my_func(2, b), do: :ok + + def d() do + my_ + end + end + """ + + suggestions = Suggestion.suggestions(buffer, 7, 8) + + assert [ + %{args: "a, b \\\\ \"\"", arity: 2, default_args: 1} + ] = suggestions + end + + test "records from metadata" do + buffer = """ + defmodule SomeSchema do + require Record + Record.defrecord(:user, name: "john", age: 25) + @type user :: record(:user, name: String.t(), age: integer) + + def d() do + w = us + end + end + """ + + suggestions = Suggestion.suggestions(buffer, 7, 11) + + assert [ + %{ + args: "args \\\\ []", + arity: 1, + name: "user", + summary: "", + type: :macro, + args_list: ["args \\\\ []"], + default_args: 1, + metadata: %{}, + origin: "SomeSchema", + snippet: nil, + spec: "", + visibility: :public + }, + %{ + args: "record, args", + args_list: ["record", "args"], + arity: 2, + default_args: 0, + metadata: %{}, + name: "user", + origin: "SomeSchema", + snippet: nil, + spec: "", + summary: "", + type: :macro, + visibility: :public + } + ] = suggestions |> Enum.filter(&(&1.name == "user")) + end + + test "records from introspection" do + buffer = """ + defmodule SomeSchema do + require ElixirSenseExample.ModuleWithRecord, as: M + + def d() do + w = M.us + end + end + """ + + suggestions = Suggestion.suggestions(buffer, 5, 12) + + assert [ + %{ + args: "args \\\\ []", + arity: 1, + name: "user", + summary: _, + type: :macro, + args_list: ["args \\\\ []"], + default_args: 1, + metadata: _, + origin: "ElixirSenseExample.ModuleWithRecord", + snippet: nil, + spec: "", + visibility: :public + }, + %{ + args: "record, args", + args_list: ["record", "args"], + arity: 2, + default_args: 0, + metadata: _, + name: "user", + origin: "ElixirSenseExample.ModuleWithRecord", + snippet: nil, + spec: "", + summary: "", + type: :macro, + visibility: :public + } + ] = suggestions |> Enum.filter(&(&1.name == "user")) + end + + if Version.match?(System.version(), ">= 1.18.0") do + test "records fields" do + buffer = """ + defmodule SomeSchema do + require ElixirSenseExample.ModuleWithRecord, as: R + + def d() do + w = R.user() + w = R.user(n) + R.user(w, n) + R.user(w, name: "1", a) + end + end + """ + + suggestions = Suggestion.suggestions(buffer, 5, 16) + + assert [ + %{ + name: "age", + origin: "ElixirSenseExample.ModuleWithRecord.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "integer()" + }, + %{ + name: "name", + origin: "ElixirSenseExample.ModuleWithRecord.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "String.t()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + + suggestions = Suggestion.suggestions(buffer, 6, 17) + + assert [ + %{ + name: "name", + origin: "ElixirSenseExample.ModuleWithRecord.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "String.t()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + + suggestions = Suggestion.suggestions(buffer, 7, 16) + + assert [ + %{ + name: "name", + origin: "ElixirSenseExample.ModuleWithRecord.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "String.t()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + + suggestions = Suggestion.suggestions(buffer, 8, 27) + + assert [ + %{ + name: "age", + origin: "ElixirSenseExample.ModuleWithRecord.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "integer()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + end + end + + test "records from metadata fields" do + buffer = """ + defmodule SomeSchema do + require Record + Record.defrecord(:user, name: "john", age: 25) + @type user :: record(:user, name: String.t(), age: integer) + + def d() do + w = user() + w = user(n) + user(w, n) + user(w, name: "1", a) + end + end + """ + + suggestions = Suggestion.suggestions(buffer, 7, 14) + + assert [ + %{ + name: "age", + origin: "SomeSchema.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "integer()" + }, + %{ + name: "name", + origin: "SomeSchema.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "String.t()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + + suggestions = Suggestion.suggestions(buffer, 8, 15) + + assert [ + %{ + name: "name", + origin: "SomeSchema.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "String.t()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + + suggestions = Suggestion.suggestions(buffer, 9, 14) + + assert [ + %{ + name: "name", + origin: "SomeSchema.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "String.t()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + + suggestions = Suggestion.suggestions(buffer, 10, 25) + + assert [ + %{ + name: "age", + origin: "SomeSchema.user", + type: :field, + call?: false, + subtype: :record_field, + type_spec: "integer()" + } + ] = suggestions |> Enum.filter(&(&1.type == :field)) + end + + defp suggestions_by_type(type, buffer) do + {line, column} = get_last_line_and_column(buffer) + suggestions_by_type(type, buffer, line, column) + end + + defp suggestions_by_type(type, buffer, line, column) do + buffer + |> add_aliases("Local, Remote") + |> Suggestion.suggestions(line + 1, column) + |> Enum.filter(fn %{type: t} -> t == type end) + |> Enum.sort() + end + + defp suggestions_by_name(name, buffer) do + {line, column} = get_last_line_and_column(buffer) + suggestions_by_name(name, buffer, line, column) + end + + defp suggestions_by_name(name, buffer, line, column) do + buffer + |> add_aliases("Local, Remote") + |> Suggestion.suggestions(line + 1, column) + |> Enum.filter(fn + %{name: n} -> n == name + _ -> false + end) + |> Enum.sort() + end + + defp suggestion_by_name(name, buffer) do + {line, column} = get_last_line_and_column(buffer) + suggestion_by_name(name, buffer, line, column) + end + + defp suggestion_by_name(name, buffer, line, column) do + [suggestion] = suggestions_by_name(name, buffer, line, column) + suggestion + end + + defp get_last_line_and_column(buffer) do + str_lines = buffer |> Source.split_lines() + line = length(str_lines) + column = (str_lines |> List.last() |> String.length()) + 1 + {line, column} + end + + defp add_aliases(buffer, aliases) do + "alias ElixirSenseExample.ModuleWithTypespecs.{#{aliases}}\n" <> buffer + end + + def cursors(text) do + {_, cursors} = + ElixirSense.Core.Source.walk_text(text, {false, []}, fn + "#", rest, _, _, {_comment?, cursors} -> + {rest, {true, cursors}} + + "\n", rest, _, _, {_comment?, cursors} -> + {rest, {false, cursors}} + + "^", rest, line, col, {true, cursors} -> + {rest, {true, [%{line: line - 1, col: col} | cursors]}} + + _, rest, _, _, acc -> + {rest, acc} + end) + + Enum.reverse(cursors) + end + + def suggestions(buffer, cursor) do + Suggestion.suggestions(buffer, cursor.line, cursor.col) + end + + def suggestions(buffer, cursor, type) do + suggestions(buffer, cursor) + |> Enum.filter(fn s -> s.type == type end) + end + + def suggestions_by_kind(buffer, cursor, kind) do + suggestions(buffer, cursor) + |> Enum.filter(fn s -> s[:kind] == kind end) + end +end diff --git a/test/support/module_with_typespecs.ex b/test/support/module_with_typespecs.ex index 9c67c229..a7cfd9ed 100644 --- a/test/support/module_with_typespecs.ex +++ b/test/support/module_with_typespecs.ex @@ -185,8 +185,36 @@ defmodule ElixirSenseExample.ModuleWithTypespecs do end @spec macro_with_options(options_t) :: Macro.t() - defmacro macro_with_options(options) do + defmacro macro_with_options(_options) do + # IO.inspect(_options) {:asd, [], nil} end + + @spec fun_with_default(atom, [{:foo, integer()} | {:bar, String.t()}]) :: :ok + def fun_with_default(_a \\ nil, _options), do: :ok + + @spec multiple_functions([{:foo, integer()}]) :: :ok + def multiple_functions(_options), do: :ok + + @spec multiple_functions([{:bar, String.t()}]) :: :ok + def multiple_functions(_options, _a), do: :ok + end + + defmodule Behaviour do + @callback some([{:foo, integer()} | {:bar, String.t()}]) :: :ok + end + + defmodule Impl do + @behaviour Behaviour + def some(_a), do: :ok + end + + defmodule MacroBehaviour do + @macrocallback some([{:foo, integer()} | {:bar, String.t()}]) :: Macro.t() + end + + defmodule MacroImpl do + @behaviour MacroBehaviour + defmacro some(_a), do: :ok end end