From c5c448066c8fe45047b7de2a266389896070b141 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 16:00:08 +0200 Subject: [PATCH 01/17] Replace custom error recovery with the toxic2 tolerant parser - ElixirSense.Core.Parser parses everything via Toxic2 (no Code.string_to_quoted fast path); error recovery and cursor location now come from toxic2 - Range-based cursor marking (ElixirSense.Core.Parser.Cursor) using toxic2 source ranges, replacing the __cursor__() text-injection heuristics - get_cursor_env derives the cursor env from a separate marked parse - Drop the bespoke fix_parse_error loop and container_cursor fallbacks Co-Authored-By: Claude Fable 5 --- .dialyzer_ignore.exs | 3 +- lib/elixir_sense.ex | 2 +- lib/elixir_sense/core/compiler.ex | 15 + lib/elixir_sense/core/metadata.ex | 72 +- lib/elixir_sense/core/parser.ex | 621 +++++------------- lib/elixir_sense/core/parser/cursor.ex | 287 ++++++++ mix.exs | 1 + .../metadata_builder/error_recovery_test.exs | 40 +- 8 files changed, 515 insertions(+), 526 deletions(-) create mode 100644 lib/elixir_sense/core/parser/cursor.ex diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 55c0e566..ec4992fe 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -26,6 +26,5 @@ # 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} + {"lib/elixir_sense/core/normalized/tokenizer.ex", :pattern_match} ] 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/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/parser.ex b/lib/elixir_sense/core/parser.ex index ffa91f4a..bc1043bd 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,144 +39,204 @@ 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 + # 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}}) - case string_to_ast(source_with_cursor, string_to_ast_options) do - {:ok, ast, modified_source, error} -> - acc = MetadataBuilder.build(ast, cursor_position) + _ -> + create_metadata(source, {:ok, acc, error || {:error, :env_not_found}}) + end + end + end - 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}}) + @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. - _ -> - create_metadata(source, {:ok, acc, error || {:error, :env_not_found}}) - end - end + 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) - {:error, _reason} = error -> - create_metadata(source, error) + case tolerant_parse(source) do + {ast, nil} -> {:ok, ast, source, nil} + {ast, error} when errors_threshold > 0 -> {:ok, ast, source, error} + {_ast, error} -> error end end - @default_parser_options [columns: true, token_metadata: true, emit_warnings: false] + @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 - 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) + @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 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 - ) + if cursor, do: [token_metadata: true, range: true], else: [token_metadata: true] + + {ast, diagnostics} = Toxic2.parse_to_ast(source, parser_options) + + ast = + if cursor do + ast |> position_error_nodes(diagnostics) |> Cursor.mark(cursor, opts) + else + ast + end + + ast = neutralize_errors(ast, diagnostics) + + error = if Enum.any?(diagnostics, &(elem(&1, 2) == :error)), do: {:error, :parse_error} + + {ast, error} 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} + # 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 - error -> - error_to_report = original_error || {:error, :parse_error} - # dbg(error) + 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} - modified_source = - if(errors_threshold > 0, - do: fix_parse_error(source, cursor_position, error), - else: error - ) + _ -> + {:__error__, meta, args} + end + end - 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) + 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. + defp neutralize_errors(ast, diagnostics) do + by_id = Map.new(diagnostics, fn diagnostic -> {elem(diagnostic, 0), diagnostic} end) + neutralize(ast, by_id) + end - case Code.Fragment.container_cursor_to_quoted(prefix, parser_options) do - {:ok, ast} -> - {:ok, ast, prefix, error_to_report} + defp neutralize({:__error__, meta, args}, _by_id) when not is_list(args), + do: {:__error__, strip_range(meta), []} - _ -> - error_to_report - end + defp neutralize({form, meta, args}, by_id) when is_list(args) do + args = if call_form?(form), do: clean_call_args(args, by_id), else: args + {neutralize(form, by_id), strip_range(meta), Enum.map(args, &neutralize(&1, by_id))} + end - _ -> - error_to_report - end - end + defp neutralize({form, meta, args}, by_id), + do: {neutralize(form, by_id), strip_range(meta), neutralize(args, by_id)} + + defp neutralize({left, right}, by_id), do: {neutralize(left, by_id), neutralize(right, by_id)} + + defp neutralize(list, by_id) when is_list(list), do: Enum.map(list, &neutralize(&1, by_id)) + + defp neutralize(other, _by_id), 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 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 missing_close_error?(_node, _by_id), do: false + + defp error_node?({:__error__, _meta, _args}), do: true + defp error_node?(_node), do: false + + defp call_form?({:., _meta, _args}), do: true + + 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 call_form?(_form), do: false + + defp strip_range(meta) when is_list(meta), do: Keyword.delete(meta, :range) + defp strip_range(meta), do: meta + def try_fix_line_not_found_by_inserting_marker( - modified_source, + 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 - end + # 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 @@ -201,322 +267,6 @@ defmodule ElixirSense.Core.Parser do } end - defp fix_parse_error( - source, - _cursor_position, - {:error, {meta, {"\"" <> <<_::bytes-size(1)>> <> "\" is missing terminator" <> _, _}, _}} - ) do - line = get_line_from_meta(meta) - - source - |> replace_line_with_marker(line) - end - - 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) - - 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 - - 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 - - source - |> Source.split_lines() - |> List.insert_at(end_line - 1, closing_delimiter) - |> Enum.join("\n") - 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 - 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") - - _ -> - source - 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") - 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, "") - - 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 - - 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") - - _ -> - 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 fix_parse_error( - source, - _cursor_position, - {:error, {meta, "unexpected operator" <> _, _token}} - ) do - line = get_line_from_meta(meta) - remove_line(source, line) - end - - defp fix_parse_error(_, nil, error) do - error - 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 - - line_start = - case Regex.run(~r/line\s(\d+)/u, text) do - [_, line] -> line |> String.to_integer() - nil -> 1 - end - - 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 - - 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 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 - - 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 fix_parse_error(source, {cursor_line_number, _}, _error) - when is_integer(cursor_line_number) do - source - |> replace_line_with_marker(cursor_line_number) - 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", " ") - end - - def get_indentation_level(line) do - trimmed_line = String.trim_leading(line) - String.length(line) - String.length(trimmed_line) - end - defp fix_line_not_found(source, line_number) when is_integer(line_number) do source |> Source.split_lines() @@ -526,24 +276,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..f856c669 --- /dev/null +++ b/lib/elixir_sense/core/parser/cursor.ex @@ -0,0 +1,287 @@ +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 + 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/mix.exs b/mix.exs index e6004f38..288b9116 100644 --- a/mix.exs +++ b/mix.exs @@ -38,6 +38,7 @@ defmodule ElixirSense.MixProject do defp deps do [ + {:toxic2, path: "../../toxic2"}, {: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/test/elixir_sense/core/metadata_builder/error_recovery_test.exs b/test/elixir_sense/core/metadata_builder/error_recovery_test.exs index 195df24b..5213f01b 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 @@ -916,9 +919,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 @@ -2736,7 +2740,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 From 6622efaf1ed8c1e7e29344bc968bd7eae14aec4a Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 13 Jun 2026 23:36:55 +0200 Subject: [PATCH 02/17] Add public parse_to_neutralized_ast/2 with keep_range option Exposes ElixirSense.Core.Parser.parse_to_neutralized_ast/2 - the toxic2 best-effort AST plus diagnostic stream, with __error__ placeholder nodes neutralized. keep_range: true preserves toxic2 range: metadata for range-aware consumers (the LS parser and document-symbol / folding / selection-range providers); the default strips it for the classic line:/column: shape used by metadata building. neutralize_errors/3 now threads keep_range through every clause. Co-Authored-By: Claude Fable 5 --- lib/elixir_sense/core/parser.ex | 61 ++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 13 deletions(-) diff --git a/lib/elixir_sense/core/parser.ex b/lib/elixir_sense/core/parser.ex index bc1043bd..643e8064 100644 --- a/lib/elixir_sense/core/parser.ex +++ b/lib/elixir_sense/core/parser.ex @@ -99,6 +99,31 @@ defmodule ElixirSense.Core.Parser do MetadataBuilder.build(ast, cursor_position).cursor_env end + @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. + + `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. + + 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) + + parser_options = + opts + |> Keyword.take([:token_metadata, :range, :existing_atoms_only, :literal_encoder]) + |> Keyword.put_new(:token_metadata, true) + + {ast, diagnostics} = Toxic2.parse_to_ast(source, parser_options) + {neutralize_errors(ast, diagnostics, keep_range), diagnostics} + end + @doc false # Parse with toxic2 and place the cursor marker. Returns `{ast, error}`. def tolerant_parse(source, cursor_position \\ nil, opts \\ []) do @@ -166,27 +191,36 @@ defmodule ElixirSense.Core.Parser do # `{:__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. - defp neutralize_errors(ast, diagnostics) do + # `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) + neutralize(ast, by_id, keep_range) end - defp neutralize({:__error__, meta, args}, _by_id) when not is_list(args), - do: {:__error__, strip_range(meta), []} + defp neutralize({:__error__, meta, args}, _by_id, keep_range) when not is_list(args), + do: {:__error__, strip_range(meta, keep_range), []} - defp neutralize({form, meta, args}, by_id) when is_list(args) do + 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), strip_range(meta), Enum.map(args, &neutralize(&1, by_id))} + + {neutralize(form, by_id, keep_range), strip_range(meta, keep_range), + Enum.map(args, &neutralize(&1, by_id, keep_range))} end - defp neutralize({form, meta, args}, by_id), - do: {neutralize(form, by_id), strip_range(meta), neutralize(args, by_id)} + 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)} - defp neutralize({left, right}, by_id), do: {neutralize(left, by_id), neutralize(right, by_id)} + defp neutralize({left, right}, by_id, keep_range), + do: {neutralize(left, by_id, keep_range), neutralize(right, by_id, keep_range)} - defp neutralize(list, by_id) when is_list(list), do: Enum.map(list, &neutralize(&1, by_id)) + 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), do: other + 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 @@ -219,8 +253,9 @@ defmodule ElixirSense.Core.Parser do defp call_form?(_form), do: false - defp strip_range(meta) when is_list(meta), do: Keyword.delete(meta, :range) - defp strip_range(meta), do: meta + 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 def try_fix_line_not_found_by_inserting_marker( source, From 726938b9d0323decf74f927ef1291871f6cdb4d2 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 00:03:21 +0200 Subject: [PATCH 03/17] Add SurroundContext.Toxic seam; route nav locators through it (stage 0) Introduces ElixirSense.Core.SurroundContext.Toxic.surround_context/2, the seam for the toxic2 range-based symbol-under-cursor classifier. It returns the same shape as Code.Fragment.surround_context/2 so to_binding/get_cursor_env/locators are unchanged. Stage 0 delegates entirely to Code.Fragment; classification is added incrementally per symbol-kind with a fallback. Navigation locators (definition/references/ implementation/hover) now call the seam. Completion stays on Code.Fragment. Co-Authored-By: Claude Fable 5 --- .../core/surround_context/toxic.ex | 27 +++++++++++++++++++ .../providers/definition/locator.ex | 2 +- lib/elixir_sense/providers/hover/docs.ex | 2 +- .../providers/implementation/locator.ex | 2 +- .../providers/references/locator.ex | 2 +- 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 lib/elixir_sense/core/surround_context/toxic.ex 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..3adc2c0c --- /dev/null +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -0,0 +1,27 @@ +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. A handful of shapes + # carry no range/meta in the AST (bare `:atom` literals, `key:`/keyword keys) or cannot be + # disambiguated from the tree (cursor left of a meta-less dot operand); for those we fall back + # to `Code.Fragment.surround_context/2`, which is purely lexical and exactly what it is good at. + # This keeps the function total and never worse than the previous behavior. + # + # 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, {_line, _column} = position) when is_binary(source) do + # Stage 0: delegate entirely to Code.Fragment. Classification is added incrementally + # (one symbol-kind group at a time), each guarded behind the toxic path with a fallback here. + Code.Fragment.surround_context(source, position) + end +end 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/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 -> [] From e1dd5212e6cf460cab4ffcfed5574728f448695a Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 02:12:07 +0200 Subject: [PATCH 04/17] SurroundContext.Toxic: classify symbol under cursor from toxic2 ranges Implements the toxic2 range-based symbol-under-cursor classifier (item 2), replacing the Code.Fragment.surround_context delegation from stage 0. It walks to the deepest ranged node containing the cursor and classifies it into the same context shapes Code.Fragment emits, so SurroundContext.to_binding and the locators are unchanged. Handled via toxic: aliases, remote dot calls + all inside_dot forms, bare vars and __MODULE__, module attributes (incl. @type/@spec/@doc with args), alias/@attr structs, sigils, capture args (&1), symbolic operators, local arity (foo/1), parenthesized local calls. Falls back to Code.Fragment (so the function is total and never worse than before) for: bare :atom literals and keyword keys (no range meta), interpolated-atom / bracket-access / string-interpolation synthetic dots (from_brackets/delimiter/from_interpolation), word operators, exotic alias heads (__MODULE__.Foo / @attr.Foo), var-type structs (statement- position dependent), multialias Foo.{...}, invalid @@ names, and the & capture prefix. Containment is end-exclusive to match Code.Fragment's :none boundary. Validated by a differential test over ~90k cursor positions in real source files (matches Code.Fragment except ~8 benign positions on rare/invalid syntax) plus a unit test of the navigable shapes and the regressions found in adversarial review (__MODULE__.Foo, @attr.Foo, @@, &+/2 all now match Code.Fragment). Co-Authored-By: Claude Fable 5 --- .../core/surround_context/toxic.ex | 454 +++++++++++++++++- .../core/surround_context/toxic_test.exs | 72 +++ 2 files changed, 517 insertions(+), 9 deletions(-) create mode 100644 test/elixir_sense/core/surround_context/toxic_test.exs diff --git a/lib/elixir_sense/core/surround_context/toxic.ex b/lib/elixir_sense/core/surround_context/toxic.ex index 3adc2c0c..0e18160b 100644 --- a/lib/elixir_sense/core/surround_context/toxic.ex +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -9,19 +9,455 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do # `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. A handful of shapes - # carry no range/meta in the AST (bare `:atom` literals, `key:`/keyword keys) or cannot be - # disambiguated from the tree (cursor left of a meta-less dot operand); for those we fall back - # to `Code.Fragment.surround_context/2`, which is purely lexical and exactly what it is good at. - # This keeps the function total and never worse than the previous behavior. + # 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. # # 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, {_line, _column} = position) when is_binary(source) do - # Stage 0: delegate entirely to Code.Fragment. Classification is added incrementally - # (one symbol-kind group at a time), each guarded behind the toxic path with a fallback here. - Code.Fragment.surround_context(source, position) + def surround_context(source, {line, column} = position) when is_binary(source) do + {ast, _diagnostics} = Toxic2.parse_to_ast(source, token_metadata: true, range: true) + + 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 + rescue + _ -> Code.Fragment.surround_context(source, position) + catch + _, _ -> Code.Fragment.surround_context(source, position) + 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, _, _}]}, _parent, cursor) when is_atom(attr) do + attr_context(attr, 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, _, _}, {:@, ameta, _}, cursor) when is_atom(name) do + attr_context(name, 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) + fin = {dot_line, dot_col + 1 + String.length(sym_str)} + + if contains?({begin_pos, fin}, cursor) do + {{:dot, inside, String.to_charlist(sym_str)}, begin_pos, fin} + else + :fallback + end + end + 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) + op_str = 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 + + 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). + defp attr_context(attr, ameta, cursor) do + cond do + not identifier_atom?(attr) -> + :fallback + + true -> + attr_context_span(attr, ameta, cursor) + 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 is_integer(right) + defp arity_left?(_parent, _node), do: false + + defp arity_notation?([{name, _, nil}, int]) when is_atom(name) and is_integer(int), do: true + 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/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..f9816320 --- /dev/null +++ b/test/elixir_sense/core/surround_context/toxic_test.exs @@ -0,0 +1,72 @@ +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}, + {"&1", 2}, + {"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 + 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 +end From f4d0030fe9640c5d18bef5ed093c68b870ddd4e8 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 08:48:57 +0200 Subject: [PATCH 05/17] Source.bitstring_options: detect bitstring context via AST, drop Normalized.Tokenizer Ports the AST-based bitstring-modifier detection from elixir-ls completion-engine rework (98e983d) into Source.bitstring_options/1: parse the prefix with Code.Fragment.container_cursor_to_quoted and match the cursor's Macro.path against {:"::", _, [_,_]} inside {:<<>>, _, [_|_]} (stripping - operator wrappers), instead of scanning Elixir tokens. Same return contract (the modifiers string after the last ::, or nil) so the bitstring completion reducer is unchanged. Removes the last ElixirSense.Core.Normalized.Tokenizer user; deletes the module, its test, and its now-stale .dialyzer_ignore entries. Edge cases verified against the old behavior (adversarial review): < nil and a newline before the modifier are both functionally equivalent/better. Co-Authored-By: Claude Fable 5 --- .dialyzer_ignore.exs | 6 +- lib/elixir_sense/core/normalized/tokenizer.ex | 45 -------------- lib/elixir_sense/core/source.ex | 60 +++++++++---------- .../core/normalized/tokenizer_test.exs | 47 --------------- 4 files changed, 31 insertions(+), 127 deletions(-) delete mode 100644 lib/elixir_sense/core/normalized/tokenizer.ex delete mode 100644 test/elixir_sense/core/normalized/tokenizer_test.exs diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index ec4992fe..36232203 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -22,9 +22,5 @@ {"lib/elixir_sense/core/compiler.ex", :pattern_match}, {"lib/elixir_sense/core/compiler/clauses.ex", :pattern_match}, {"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/normalized/macro/env.ex", :pattern_match} ] 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/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/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 From b3cf3304f77b7191ca7d78a6569ea9fe087393d9 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 10:25:37 +0200 Subject: [PATCH 06/17] Port elixir-ls completion subsystem (commit 98e983d) wholesale Brings the completion engine to parity with elixir-ls's port-complete-merges rework (wholesale onto Elixir 1.20), namespace-translated (ElixirLS.Utils / ElixirLS.LanguageServer.* -> ElixirSense.*): - completion_engine.ex: rewritten - cursor parsing delegated to Code.Fragment, @bitstring_modifiers embedded, struct/map-field + bitstring-modifier detection done via container_context (AST), upstream correctness fixes through 1.20. - reducers: bitstring.ex and struct.ex deleted (folded into CompleteEngine add_bitstring_options/add_struct_fields + add_keywords); complete_engine.ex and record.ex updated; suggestion.ex @reducers list updated. - plugins/ecto/query.ex: only the clauses_suggestions hunk applied (the rest of the file had diverged - extract_bindings is an elixir_sense improvement - so a surgical apply rather than a file overwrite). - tests: complete_test.exs and suggestions_test.exs ported as test/elixir_sense/providers/completion/{completion_engine,suggestions}_test.exs (292 tests). A few env-specific assertions adapted to elixir_sense's test env (app name :elixir_sense, no stream_data dep, struct-completion namespaces). - test/support/module_with_typespecs.ex synced (the ported tests need the Local/Behaviour/Impl/MacroBehaviour fixtures elixir-ls had added). - .dialyzer_ignore.exs: ignore the upstream-faithful unreachable :error clauses. Co-Authored-By: Claude Fable 5 --- .dialyzer_ignore.exs | 6 +- .../providers/completion/completion_engine.ex | 1178 +++- .../completion/reducers/bitstring.ex | 39 - .../completion/reducers/complete_engine.ex | 37 +- .../providers/completion/reducers/record.ex | 15 +- .../providers/completion/reducers/struct.ex | 149 - .../providers/completion/suggestion.ex | 15 +- .../providers/plugins/ecto/query.ex | 18 +- .../completion/completion_engine_test.exs | 2605 +++++++++ .../providers/completion/suggestions_test.exs | 5155 +++++++++++++++++ test/support/module_with_typespecs.ex | 30 +- 11 files changed, 8730 insertions(+), 517 deletions(-) delete mode 100644 lib/elixir_sense/providers/completion/reducers/bitstring.ex delete mode 100644 lib/elixir_sense/providers/completion/reducers/struct.ex create mode 100644 test/elixir_sense/providers/completion/completion_engine_test.exs create mode 100644 test/elixir_sense/providers/completion/suggestions_test.exs diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index 36232203..b221a6a0 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -22,5 +22,9 @@ {"lib/elixir_sense/core/compiler.ex", :pattern_match}, {"lib/elixir_sense/core/compiler/clauses.ex", :pattern_match}, {"lib/elixir_sense/core/compiler/fn.ex", :pattern_match}, - {"lib/elixir_sense/core/normalized/macro/env.ex", :pattern_match} + {"lib/elixir_sense/core/normalized/macro/env.ex", :pattern_match}, + + # 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/lib/elixir_sense/providers/completion/completion_engine.ex b/lib/elixir_sense/providers/completion/completion_engine.ex index 6796c289..1365938a 100644 --- a/lib/elixir_sense/providers/completion/completion_engine.ex +++ b/lib/elixir_sense/providers/completion/completion_engine.ex @@ -1,3 +1,9 @@ +# 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 +28,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 +66,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 +80,30 @@ 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"} + ] + + @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 +120,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 +147,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 +182,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 +217,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 +265,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 +285,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 +317,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 +355,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) @@ -309,10 +417,15 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do %Metadata{} = metadata, _cursor_position ) do - alias = hint |> List.to_string() |> String.split(".") |> value_from_alias(env, metadata) - - case alias do - {:ok, atom} -> {:ok, {:atom, atom}} + result = + hint + |> List.to_string() + |> String.split(".") + |> Enum.map(&String.to_atom/1) + |> value_from_alias(env) + + case result do + {:alias, atom} -> {:ok, {:atom, atom}} :error -> :error end end @@ -323,18 +436,12 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do %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 +451,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 +526,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 +604,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 +637,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 +663,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 +676,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 +691,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 +713,10 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do name = inspect(module) result = %{ - kind: :module, + type: :module, name: name, full_name: name, - type: :erlang, + type: :module, desc: desc, subtype: subtype } @@ -611,14 +726,436 @@ 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 + fn module -> + Struct.is_struct(module, metadata.structs) or + has_struct_submodule?(module, metadata.structs) + end end defp struct_module_filter(false, %State.Env{} = _env, %Metadata{} = _metadata) do fn _ -> true end end - ## Elixir modules + # Check if a module has any direct submodules that are structs + defp has_struct_submodule?(module, structs) 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 + # Get all modules and check if any direct submodule is a struct + module_str_with_dot = module_str <> "." + + # Get all loaded modules + modules = Enum.map(:code.all_loaded(), &Atom.to_string(elem(&1, 0))) + + # Add modules from applications if in interactive mode + modules = + case :code.get_mode() do + :interactive -> + modules ++ + Enum.map(Applications.get_modules_from_applications(), &Atom.to_string/1) + + _ -> + modules + end + + # Find submodules + submodules = + for mod <- modules, + String.starts_with?(mod, module_str_with_dot), + do: String.to_atom(mod) + + # Check if any submodule is a struct + 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 -> + existing = + code + |> List.to_string() + |> String.split("::") + |> List.last() + |> String.split("-") + + results = + @bitstring_modifiers + |> Enum.filter(&(Matcher.match?(&1.name, hint) and &1.name not in existing)) + |> format_expansion() + + {results, false} + + _ -> + {[], true} + end + end + + 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}, + env = %{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}, + env = %{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, + env = %{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,10 +1174,10 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do parts -> hint = List.last(parts) - list = Enum.take(parts, length(parts) - 1) + list = Enum.take(parts, length(parts) - 1) |> Enum.map(&String.to_atom/1) - case value_from_alias(list, env, metadata) do - {:ok, alias} -> + case value_from_alias(list, env) do + {:alias, alias} -> expand_aliases( alias, hint, @@ -705,7 +1242,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 +1270,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 = [head | _], %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 +1286,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 +1317,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 +1347,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 +1373,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 +1488,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 +1528,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 +1542,87 @@ 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]} - ) - - false -> - [{f, [a], [def_a], [func_kind], [doc], [spec], [arg]} | acc] - end - end) + |> Enum.sort_by(fn {f, _, a, _, _, _, _} -> {f, a} 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 -> + 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 +1680,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 +1710,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 +1737,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 +1754,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 +1784,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 +1822,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 +1865,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 +1884,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 +1909,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..2b3da34d 100644 --- a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex +++ b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex @@ -1,3 +1,9 @@ +# 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 @@ -79,6 +85,35 @@ defmodule ElixirSense.Providers.Completion.Reducers.CompleteEngine do add_suggestions(:field, acc) end + @doc """ + A reducer that adds suggestions of variable fields. + + Note: requires populate/5. + """ + def add_struct_fields(_hint, _env, _file_metadata, _context, acc) do + add_suggestions(:struct_field, acc) + 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 """ A reducer that adds suggestions of existing module attributes. @@ -116,7 +151,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..bebd685a 100644 --- a/lib/elixir_sense/providers/completion/suggestion.ex +++ b/lib/elixir_sense/providers/completion/suggestion.ex @@ -1,3 +1,9 @@ +# 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 +70,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 +86,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 +99,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/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/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..3a606e57 --- /dev/null +++ b/test/elixir_sense/providers/completion/suggestions_test.exs @@ -0,0 +1,5155 @@ +# 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 + + 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 From 5ed9a6621cef3f5aa0b7b92bd9df03450856e0e3 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 12:03:38 +0200 Subject: [PATCH 07/17] SurroundContext.Toxic: fix 3 classification bugs (gpt-5.5 adversarial review) - Multi-line remote call: derive the dot end from the callee meta (line+column), not the dot token, so `A.\n bar` with the cursor on the dot gets the correct cross-line end instead of an impossible same-line one (which broke get_call_arity -> wrong overload). - Spaced `@ attr`: require the attribute name to be contiguous with `@` (one column after); otherwise it is the unary `@` operator on a local var, which Code.Fragment reports as :local_or_var (was wrongly classified as a module attribute). - Remote arity slash `A.bar/1`: defer any `/` slash to Code.Fragment (arity vs division is lexical), so the slash no longer resolves to Kernel.//2. Co-Authored-By: Claude Fable 5 --- .../core/surround_context/toxic.ex | 47 ++++++++++++++----- .../core/surround_context/toxic_test.exs | 28 +++++++++++ 2 files changed, 64 insertions(+), 11 deletions(-) diff --git a/lib/elixir_sense/core/surround_context/toxic.ex b/lib/elixir_sense/core/surround_context/toxic.ex index 0e18160b..c603c7f1 100644 --- a/lib/elixir_sense/core/surround_context/toxic.ex +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -108,16 +108,16 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do # 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, _, _}]}, _parent, cursor) when is_atom(attr) do - attr_context(attr, ameta, cursor) + 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, _, _}, {:@, ameta, _}, cursor) when is_atom(name) do - attr_context(name, ameta, cursor) + 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. @@ -212,10 +212,19 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do inside -> sym_str = Atom.to_string(sym) begin_pos = range_begin(cmeta) - fin = {dot_line, dot_col + 1 + String.length(sym_str)} - - if contains?({begin_pos, fin}, cursor) do - {{:dot, inside, String.to_charlist(sym_str)}, begin_pos, fin} + # 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 @@ -355,17 +364,29 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do # 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). - defp attr_context(attr, ameta, cursor) do + # 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} -> @@ -385,7 +406,11 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do defp arity_left?({:/, _, [left, right]}, node), do: left == node and is_integer(right) defp arity_left?(_parent, _node), do: false - defp arity_notation?([{name, _, nil}, int]) when is_atom(name) and is_integer(int), do: true + # `/` 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, int]) when is_integer(int), do: true defp arity_notation?(_args), do: false # A dot node toxic2 synthesized while lowering sugar (no real `.` in the source). diff --git a/test/elixir_sense/core/surround_context/toxic_test.exs b/test/elixir_sense/core/surround_context/toxic_test.exs index f9816320..ec77f873 100644 --- a/test/elixir_sense/core/surround_context/toxic_test.exs +++ b/test/elixir_sense/core/surround_context/toxic_test.exs @@ -69,4 +69,32 @@ defmodule ElixirSense.Core.SurroundContext.ToxicTest do 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 + end end From 0675b4f60d01e9080430c762d3a2a415ddcb6ee7 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 14:47:27 +0200 Subject: [PATCH 08/17] SurroundContext.Toxic: classify atoms from the parse tree via literal_encoder Parse with a literal_encoder that wraps literals in {:__block__, meta, [literal]} so bare :atom literals carry a range and can be classified from the (authoritative on valid code) toxic2 parse instead of the lexical Code.Fragment fallback. Shrinks the fallback surface: bare :atom, x = :ok, and the :erlang.foo dot-left operand (which previously had no range and fell back) now classify natively. - New clause for {:__block__, meta, [atom]}: only identifier atoms with a colon-led range (width == len+1) -> :unquoted_atom; keyword keys (format: :keyword), nil/true/false, and operator/special atoms (:%{}, :+, :.) still defer to Code.Fragment (the :key/:keyword and :none/:operator distinctions are lexical). - Unwrap the now-wrapped atom in inside_dot and the wrapped integer in the arity checks (foo/1, A.bar/1). Differential vs Code.Fragment over real files is unchanged (same handful of exotic residuals); nav + completion suites green. Co-Authored-By: Claude Fable 5 --- .../core/surround_context/toxic.ex | 51 +++++++++++++++++-- .../core/surround_context/toxic_test.exs | 20 ++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/lib/elixir_sense/core/surround_context/toxic.ex b/lib/elixir_sense/core/surround_context/toxic.ex index c603c7f1..7d03b4c7 100644 --- a/lib/elixir_sense/core/surround_context/toxic.ex +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -22,7 +22,16 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do @spec surround_context(String.t(), {pos_integer, pos_integer}) :: :none | map() def surround_context(source, {line, column} = position) when is_binary(source) do - {ast, _diagnostics} = Toxic2.parse_to_ast(source, token_metadata: true, range: true) + # `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.) + {ast, _diagnostics} = + Toxic2.parse_to_ast(source, + token_metadata: true, + range: true, + literal_encoder: fn literal, meta -> {:ok, {:__block__, meta, [literal]}} end + ) case deepest_at(ast, {line, column}) do {node, parent} -> @@ -232,6 +241,22 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do end end + # A literal wrapped by the literal_encoder. A bare `:atom` (`{:unquoted_atom, ...}`) is the only + # one we classify here; keyword keys (`format: :keyword`) and the keyword-literals nil/true/false + # are deferred to Code.Fragment (the `:key`/`:keyword` distinction and `:nil` vs `nil` are lexical). + # `:atom`'s range spans the leading colon, so its width is `len(atom) + 1`; nil/true/false have no + # colon (width == len), which is how we tell them apart without re-reading the source. + defp classify({:__block__, meta, [atom]}, _parent, _cursor) when is_atom(atom) do + with false <- Keyword.has_key?(meta, :format), + true <- identifier_first_char?(atom), + {{sl, sc}, {el, ec}} <- node_range(meta), + true <- sl == el and ec - sc == String.length(Atom.to_string(atom)) + 1 do + {{:unquoted_atom, atom_charlist(atom)}, {sl, sc}, {el, ec}} + else + _ -> :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 @@ -331,6 +356,12 @@ defmodule ElixirSense.Core.SurroundContext.Toxic 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 @@ -403,14 +434,28 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do end end - defp arity_left?({:/, _, [left, right]}, node), do: left == node and is_integer(right) + 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, int]) when is_integer(int), do: true + 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). diff --git a/test/elixir_sense/core/surround_context/toxic_test.exs b/test/elixir_sense/core/surround_context/toxic_test.exs index ec77f873..d244145c 100644 --- a/test/elixir_sense/core/surround_context/toxic_test.exs +++ b/test/elixir_sense/core/surround_context/toxic_test.exs @@ -97,4 +97,24 @@ defmodule ElixirSense.Core.SurroundContext.ToxicTest do Code.Fragment.surround_context(source, {1, 6}) 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 + end end From b5b40c2af1111996fdd2b1666312e59f998aff4a Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 15:03:32 +0200 Subject: [PATCH 09/17] surround_context/toxic: classify keyword keys and nil/true/false via literal_encoder Push more literal classification onto the parse tree. The literal_encoder already gives bare :atoms a range; extend the wrapped-literal classify clause to also handle: - keyword keys (format: :keyword) -> {:key, charlist}, with :none when the cursor is on the trailing colon (key span excludes the colon) - bare nil/true/false (no leading colon) -> {:keyword, charlist} This shrinks the Code.Fragment lexical fallback surface further; the differential against Code.Fragment stays at the same 7 exotic residuals. Co-Authored-By: Claude Fable 5 --- .../core/surround_context/toxic.ex | 48 ++++++++++++++----- .../core/surround_context/toxic_test.exs | 22 +++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/lib/elixir_sense/core/surround_context/toxic.ex b/lib/elixir_sense/core/surround_context/toxic.ex index 7d03b4c7..71d2a35b 100644 --- a/lib/elixir_sense/core/surround_context/toxic.ex +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -241,19 +241,41 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do end end - # A literal wrapped by the literal_encoder. A bare `:atom` (`{:unquoted_atom, ...}`) is the only - # one we classify here; keyword keys (`format: :keyword`) and the keyword-literals nil/true/false - # are deferred to Code.Fragment (the `:key`/`:keyword` distinction and `:nil` vs `nil` are lexical). - # `:atom`'s range spans the leading colon, so its width is `len(atom) + 1`; nil/true/false have no - # colon (width == len), which is how we tell them apart without re-reading the source. - defp classify({:__block__, meta, [atom]}, _parent, _cursor) when is_atom(atom) do - with false <- Keyword.has_key?(meta, :format), - true <- identifier_first_char?(atom), - {{sl, sc}, {el, ec}} <- node_range(meta), - true <- sl == el and ec - sc == String.length(Atom.to_string(atom)) + 1 do - {{:unquoted_atom, atom_charlist(atom)}, {sl, sc}, {el, ec}} - else - _ -> :fallback + # 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 + name_len = String.length(Atom.to_string(atom)) + + 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 -> + span = {{sl, sc}, {el, ec - 1}} + + if contains?(span, cursor), + do: {{:key, atom_charlist(atom)}, {sl, sc}, {el, ec - 1}}, + else: :fallback + + # `:atom` (leading colon) + identifier_first_char?(atom) and ec - sc == name_len + 1 -> + {{:unquoted_atom, atom_charlist(atom)}, {sl, sc}, {el, ec}} + + # bare nil / true / false reserved words (no colon) + atom in [nil, true, false] and ec - sc == name_len -> + {{:keyword, atom_charlist(atom)}, {sl, sc}, {el, ec}} + + true -> + :fallback + end + + _ -> + :fallback end end diff --git a/test/elixir_sense/core/surround_context/toxic_test.exs b/test/elixir_sense/core/surround_context/toxic_test.exs index d244145c..8eba8c50 100644 --- a/test/elixir_sense/core/surround_context/toxic_test.exs +++ b/test/elixir_sense/core/surround_context/toxic_test.exs @@ -116,5 +116,27 @@ defmodule ElixirSense.Core.SurroundContext.ToxicTest do "mismatch for #{inspect(source)} @#{col}" end end + + test "keyword keys classify as :key (and :none on the colon)" do + for {source, col} <- [ + {"[key: 1]", 3}, + {"[key: 1]", 5}, + {"%{key: 1}", 4}, + {"foo(key: 1)", 7}, + {"[a: 1, bb: 2]", 8} + ] do + assert Toxic.surround_context(source, {1, col}) == + Code.Fragment.surround_context(source, {1, col}), + "mismatch for #{inspect(source)} @#{col}" + 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 From 1a3bea5c19afde3300686bc0d750f5e92e419654 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 17:38:47 +0200 Subject: [PATCH 10/17] surround_context/toxic: classify step-range `..//` as `..` to match Code.Fragment A step range `a..b//c` lowers to a single ternary `:..//` node, but Code.Fragment classifies the `..` and `//` lexically as two separate operators. The cursor on the `..` was reporting the whole `..//` atom name; report `..` over the two `..` columns instead and let the `//` columns fall back to Code.Fragment (the `..//` node range does not reach them). This was the most common differential residual (every step range in real code). After this, a 6-file real-source scan drops to 2 residuals, both cases where toxic is *more* precise than the lexical oracle (`&(-&1)` -> `:capture_arg`/`:operator` where Code.Fragment returns `:none`); those are documented as intentional divergences and kept because they drive better navigation. Co-Authored-By: Claude Fable 5 --- lib/elixir_sense/core/surround_context/toxic.ex | 13 ++++++++++++- .../core/surround_context/toxic_test.exs | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lib/elixir_sense/core/surround_context/toxic.ex b/lib/elixir_sense/core/surround_context/toxic.ex index 71d2a35b..15b86c41 100644 --- a/lib/elixir_sense/core/surround_context/toxic.ex +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -17,6 +17,13 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do # `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`. @@ -326,7 +333,11 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do Macro.operator?(form, arity) -> op_line = Keyword.get(meta, :line) op_col = Keyword.get(meta, :column) - op_str = Atom.to_string(form) + # 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} diff --git a/test/elixir_sense/core/surround_context/toxic_test.exs b/test/elixir_sense/core/surround_context/toxic_test.exs index 8eba8c50..1f823395 100644 --- a/test/elixir_sense/core/surround_context/toxic_test.exs +++ b/test/elixir_sense/core/surround_context/toxic_test.exs @@ -96,6 +96,23 @@ defmodule ElixirSense.Core.SurroundContext.ToxicTest do 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 From a4545521ad385bfbe0f73bbd3c1021cad7595c21 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 18:21:45 +0200 Subject: [PATCH 11/17] surround_context/toxic: add AST-accepting arity; tidy literal classify /simplify cleanup: - Add surround_context/3 taking an already-parsed AST, so callers that parse the source themselves (selection_ranges) reuse that AST instead of triggering a fresh toxic2 parse per cursor position. Both arities share a private classify_at/3; the parse opts move to a private parse/1. - classify({:__block__, ...}): compute Atom.to_string once (reused for length and charlist) and reuse the keyword-key span binding instead of rebuilding the {{sl,sc},{el,ec-1}} tuple. Co-Authored-By: Claude Fable 5 --- .../core/surround_context/toxic.ex | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/lib/elixir_sense/core/surround_context/toxic.ex b/lib/elixir_sense/core/surround_context/toxic.ex index 15b86c41..8d1a823a 100644 --- a/lib/elixir_sense/core/surround_context/toxic.ex +++ b/lib/elixir_sense/core/surround_context/toxic.ex @@ -28,18 +28,27 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do # scope and stays on `Code.Fragment`. @spec surround_context(String.t(), {pos_integer, pos_integer}) :: :none | map() - def surround_context(source, {line, column} = position) when is_binary(source) do - # `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.) - {ast, _diagnostics} = - Toxic2.parse_to_ast(source, - token_metadata: true, - range: true, - literal_encoder: fn literal, meta -> {:ok, {:__block__, meta, [literal]}} end - ) + 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 @@ -50,10 +59,21 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do nil -> Code.Fragment.surround_context(source, position) end - rescue - _ -> Code.Fragment.surround_context(source, position) - catch - _, _ -> Code.Fragment.surround_context(source, position) + 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) -------------- @@ -256,26 +276,28 @@ defmodule ElixirSense.Core.SurroundContext.Toxic do # * 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 - name_len = String.length(Atom.to_string(atom)) + 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 -> - span = {{sl, sc}, {el, ec - 1}} + {key_begin, key_end} = span = {{sl, sc}, {el, ec - 1}} if contains?(span, cursor), - do: {{:key, atom_charlist(atom)}, {sl, sc}, {el, ec - 1}}, + do: {{:key, charlist}, key_begin, key_end}, else: :fallback # `:atom` (leading colon) identifier_first_char?(atom) and ec - sc == name_len + 1 -> - {{:unquoted_atom, atom_charlist(atom)}, {sl, sc}, {el, ec}} + {{: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, atom_charlist(atom)}, {sl, sc}, {el, ec}} + {{:keyword, charlist}, {sl, sc}, {el, ec}} true -> :fallback From b928399b3677f8c19b5afedf8a259c725202802f Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 18:45:15 +0200 Subject: [PATCH 12/17] deps: use published toxic2 git dep; require Elixir ~> 1.19 Switch the toxic2 parser dependency from a local path dep to the published git repo (lukaszsamson/toxic2) so a clean checkout / CI can resolve it. toxic2 requires Elixir ~> 1.19, which transitively raises this branch's minimum: bump mix.exs to ~> 1.19 and drop the 1.16/1.17/1.18 jobs from the CI matrix (Linux + Windows) accordingly. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 26 ++++---------------------- mix.exs | 5 +++-- mix.lock | 1 + 3 files changed, 8 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22a3c408..6e881268 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,18 +18,8 @@ jobs: # We test on OTP 27, 28, 29 (the latest three releases), falling back # to the latest OTP each Elixir version supports. include: - # 1.16 supports OTP 24-26 — latest is 26. - - elixir: 1.16.x - otp: 26.x - tests_may_fail: false - # 1.17 supports OTP 25-27 — latest is 27. - - elixir: 1.17.x - otp: 27.x - tests_may_fail: false - # 1.18 supports OTP 25-27 — latest is 27. - - elixir: 1.18.x - otp: 27.x - tests_may_fail: false + # NOTE: the toxic2 parser dependency requires Elixir ~> 1.19, so this branch + # drops the 1.16/1.17/1.18 jobs the rest of elixir_sense still supported. # 1.19 supports OTP 26-28. - elixir: 1.19.x otp: 27.x @@ -69,17 +59,9 @@ jobs: strategy: fail-fast: false matrix: - # Same matrix as mix_test (Linux). See above for compatibility notes. + # Same matrix as mix_test (Linux). See above for compatibility notes + # (toxic2 requires Elixir ~> 1.19, so 1.16/1.17/1.18 are dropped on this branch). include: - - elixir: 1.16.x - otp: 26.x - tests_may_fail: false - - elixir: 1.17.x - otp: 27.x - tests_may_fail: false - - elixir: 1.18.x - otp: 27.x - tests_may_fail: false - elixir: 1.19.x otp: 27.x tests_may_fail: false diff --git a/mix.exs b/mix.exs index 288b9116..e64e6a5a 100644 --- a/mix.exs +++ b/mix.exs @@ -6,7 +6,8 @@ defmodule ElixirSense.MixProject do [ app: :elixir_sense, version: "2.0.0", - elixir: "~> 1.16", + # toxic2 (the parser dependency) requires ~> 1.19 + elixir: "~> 1.19", elixirc_paths: elixirc_paths(Mix.env()), build_embedded: Mix.env() == :prod, start_permanent: Mix.env() == :prod, @@ -38,7 +39,7 @@ defmodule ElixirSense.MixProject do defp deps do [ - {:toxic2, path: "../../toxic2"}, + {:toxic2, github: "lukaszsamson/toxic2", ref: "6fde2f89acf94e9231e28245bf0c61f4fd4e0422"}, {: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..cca50257 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", "6fde2f89acf94e9231e28245bf0c61f4fd4e0422", [ref: "6fde2f89acf94e9231e28245bf0c61f4fd4e0422"]}, } From a2b0e592b0518aa8423577cb03f811a78100eac6 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sun, 14 Jun 2026 19:04:12 +0200 Subject: [PATCH 13/17] completion_engine: fix compiler/credo warnings from the wholesale port The 98e983d port carried warnings that the static-analysis gate (compile --warnings-as-errors + credo --strict) on Elixir 1.19/1.20 rejects: - prefix unused clause args with _ (metadata/env/module/cursor_position/name/head) - drop the duplicate :type key in the erlang module result map - value_from_alias/2 always returns {:alias, _}, so remove the two dead :error branches the 1.20 type checker proves unreachable (matches elixir-ls) - wrap the single-line MIT license header (was one 434-char line) - mark the deliberate single-condition cond with a credo disable No behavior change; full suite (1668) + dialyzer still green. Co-Authored-By: Claude Fable 5 --- .../providers/completion/completion_engine.ex | 64 +++++++++---------- .../completion/reducers/complete_engine.ex | 6 +- .../providers/completion/suggestion.ex | 6 +- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/lib/elixir_sense/providers/completion/completion_engine.ex b/lib/elixir_sense/providers/completion/completion_engine.ex index 1365938a..a20a34e8 100644 --- a/lib/elixir_sense/providers/completion/completion_engine.ex +++ b/lib/elixir_sense/providers/completion/completion_engine.ex @@ -1,7 +1,11 @@ # 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: +# 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: @@ -414,26 +418,25 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do defp expand_dot_path( {:alias, hint}, %State.Env{} = env, - %Metadata{} = metadata, + %Metadata{} = _metadata, _cursor_position ) do - result = + # 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 result do - {:alias, 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 if var == ~c"__MODULE__" and env.module != nil and Introspection.elixir_module?(env.module) do @@ -716,7 +719,6 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do type: :module, name: name, full_name: name, - type: :module, desc: desc, subtype: subtype } @@ -947,7 +949,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do defp expand_struct_module( {:__MODULE__, _, context}, - env = %{module: module}, + %{module: module}, _metadata, _cursor_position ) @@ -1003,7 +1005,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do defp expand_struct_module( {variable, _, context}, - env = %{context: :match}, + %{context: :match}, _metadata, _cursor_position ) @@ -1025,7 +1027,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do end end - defp simple_expand({:__ENV__, _, context}, env, _metadata, _cursor_position) + defp simple_expand({:__ENV__, _, context}, _env, _metadata, _cursor_position) when is_atom(context) do {:%, [], [Macro.Env, {:%{}, [], []}]} end @@ -1042,7 +1044,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do defp simple_expand( {special, _, context} = node, - env = %{module: module}, + %{module: _module}, _metadata, _cursor_position ) @@ -1091,7 +1093,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do end end - defp simple_expand({variable, meta, context}, env, metadata, cursor_position) + 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} @@ -1176,23 +1178,20 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do hint = List.last(parts) list = Enum.take(parts, length(parts) - 1) |> Enum.map(&String.to_atom/1) - case value_from_alias(list, env) do - {:alias, alias} -> - expand_aliases( - alias, - hint, - [], - false, - env, - metadata, - cursor_position, - filter, - Keyword.put(opts, :required_alias, false) - ) + # value_from_alias/2 always returns {:alias, _}, so there is no :error branch here. + {:alias, alias} = value_from_alias(list, env) - :error -> - no() - end + expand_aliases( + alias, + hint, + [], + false, + env, + metadata, + cursor_position, + filter, + Keyword.put(opts, :required_alias, false) + ) end end @@ -1270,7 +1269,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do ), do: no() - defp value_from_alias(list = [head | _], %State.Env{} = env) do + 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} @@ -1611,6 +1610,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do %Metadata{} = metadata, cursor_position ) do + # credo:disable-for-next-line Credo.Check.Refactor.CondStatements cond do not Map.has_key?(metadata.mods_funs_to_positions, {mod, nil, nil}) -> [] @@ -2001,7 +2001,7 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do } end - defp to_entries(%{type: :variable, name: name} = option) do + defp to_entries(%{type: :variable, name: _name} = option) do option end diff --git a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex index 2b3da34d..46f8a21c 100644 --- a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex +++ b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex @@ -1,7 +1,11 @@ # 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: +# 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 diff --git a/lib/elixir_sense/providers/completion/suggestion.ex b/lib/elixir_sense/providers/completion/suggestion.ex index bebd685a..b01c50c5 100644 --- a/lib/elixir_sense/providers/completion/suggestion.ex +++ b/lib/elixir_sense/providers/completion/suggestion.ex @@ -1,7 +1,11 @@ # 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: +# 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 From 38a29d55448b1ea5ece5eaac14b0d06efd67b245 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 4 Jul 2026 10:02:35 +0200 Subject: [PATCH 14/17] Address PR review: parser_options, struct-field grouping, perf, dead code - parser: thread caller `parser_options` (`:existing_atoms_only`, `:literal_encoder`) from the public `string_to_quoted/5` API through `string_to_ast` into the toxic2 parse instead of silently dropping them. - complete_engine: `add_struct_fields` / `add_fields` now filter the `:field` suggestion group by `:subtype` (struct fields are emitted as `type: :field, subtype: :struct_field`, so looking up a `:struct_field` type found nothing and dropped them when a chain enabled `:structs_fields` without `:variable_fields`). Matches the elixir-ls copy, which already had this. - completion_engine: compute the loaded/application module-name list once per struct-module filter pass rather than per candidate (`has_struct_submodule?` was O(N^2)). - cursor: document why range containment is intentionally end-inclusive (a completion/nav cursor sits at the exclusive end of the token being typed). - Remove the now-dead `ElixirSense.Core.Bitstring` module + test: the bitstring modifier completion uses the inlined `@bitstring_modifiers` list (elixir-ls #1264), so nothing referenced it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232 --- lib/elixir_sense/core/bitstring.ex | 127 ---------------- lib/elixir_sense/core/parser.ex | 14 +- lib/elixir_sense/core/parser/cursor.ex | 8 + .../providers/completion/completion_engine.ex | 43 +++--- .../completion/reducers/complete_engine.ex | 10 +- test/elixir_sense/core/bitstring_test.exs | 140 ------------------ 6 files changed, 49 insertions(+), 293 deletions(-) delete mode 100644 lib/elixir_sense/core/bitstring.ex delete mode 100644 test/elixir_sense/core/bitstring_test.exs 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/parser.ex b/lib/elixir_sense/core/parser.ex index 643e8064..dd30eecb 100644 --- a/lib/elixir_sense/core/parser.ex +++ b/lib/elixir_sense/core/parser.ex @@ -78,8 +78,9 @@ defmodule ElixirSense.Core.Parser do """ def string_to_ast(source, options \\ []) when is_binary(source) do errors_threshold = Keyword.get(options, :errors_threshold, 6) + parse_opts = [parser_options: Keyword.get(options, :parser_options, [])] - case tolerant_parse(source) do + 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 @@ -133,9 +134,20 @@ defmodule ElixirSense.Core.Parser do _ -> nil 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]) + parser_options = if cursor, do: [token_metadata: true, range: true], else: [token_metadata: true] + parser_options = Keyword.merge(parser_options, extra_parser_options) + {ast, diagnostics} = Toxic2.parse_to_ast(source, parser_options) ast = diff --git a/lib/elixir_sense/core/parser/cursor.ex b/lib/elixir_sense/core/parser/cursor.ex index f856c669..695c57d5 100644 --- a/lib/elixir_sense/core/parser/cursor.ex +++ b/lib/elixir_sense/core/parser/cursor.ex @@ -264,6 +264,14 @@ defmodule ElixirSense.Core.Parser.Cursor do # 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 diff --git a/lib/elixir_sense/providers/completion/completion_engine.ex b/lib/elixir_sense/providers/completion/completion_engine.ex index a20a34e8..2e48a9d7 100644 --- a/lib/elixir_sense/providers/completion/completion_engine.ex +++ b/lib/elixir_sense/providers/completion/completion_engine.ex @@ -728,9 +728,14 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do end defp struct_module_filter(true, %State.Env{} = _env, %Metadata{} = metadata) do + # 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) + has_struct_submodule?(module, metadata.structs, all_module_names) end end @@ -738,8 +743,22 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do fn _ -> true end end + # 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) do + defp has_struct_submodule?(module, structs, all_module_names) do module_str = Atom.to_string(module) # Check metadata structs (from current buffer) @@ -753,30 +772,14 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do if metadata_result do true else - # Get all modules and check if any direct submodule is a struct + # Check if any direct submodule (from the precomputed list) is a struct module_str_with_dot = module_str <> "." - # Get all loaded modules - modules = Enum.map(:code.all_loaded(), &Atom.to_string(elem(&1, 0))) - - # Add modules from applications if in interactive mode - modules = - case :code.get_mode() do - :interactive -> - modules ++ - Enum.map(Applications.get_modules_from_applications(), &Atom.to_string/1) - - _ -> - modules - end - - # Find submodules submodules = - for mod <- modules, + for mod <- all_module_names, String.starts_with?(mod, module_str_with_dot), do: String.to_atom(mod) - # Check if any submodule is a struct Enum.any?(submodules, fn mod -> Code.ensure_loaded?(mod) and function_exported?(mod, :__struct__, 1) end) diff --git a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex index 46f8a21c..d4212ccd 100644 --- a/lib/elixir_sense/providers/completion/reducers/complete_engine.ex +++ b/lib/elixir_sense/providers/completion/reducers/complete_engine.ex @@ -86,16 +86,16 @@ 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 variable fields. + 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(:struct_field, acc) + add_suggestions(:field, acc, &(&1[:subtype] == :struct_field)) end @doc """ @@ -136,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 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 From 99570b9b749dbdde5120e22963c95298365511a0 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 4 Jul 2026 10:02:45 +0200 Subject: [PATCH 15/17] Restore Elixir 1.16-1.18 support Reverses the version-floor drop that came from toxic2's `~> 1.19` pin. toxic2 now compiles on 1.16-1.20 (lukaszsamson/toxic2 elixir-1.16-support branch, which fixes a compile-time regex `#Reference` escape that broke 1.16/1.17), so elixir_sense can support the same range again. - Lower `elixir:` back to `~> 1.16`. - Bump the toxic2 ref to c47c911 (the 1.16-compatible build) and mix.lock. - Restore the 1.16/1.17/1.18 jobs in the Linux + Windows test matrices. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232 --- .github/workflows/ci.yml | 26 ++++++++++++++++++++++---- mix.exs | 5 ++--- mix.lock | 2 +- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e881268..042e3d94 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,18 @@ jobs: # We test on OTP 27, 28, 29 (the latest three releases), falling back # to the latest OTP each Elixir version supports. include: - # NOTE: the toxic2 parser dependency requires Elixir ~> 1.19, so this branch - # drops the 1.16/1.17/1.18 jobs the rest of elixir_sense still supported. + # 1.16 supports OTP 24-26 — latest is 26. + - elixir: 1.16.x + otp: 26.x + tests_may_fail: false + # 1.17 supports OTP 25-27 — latest is 27. + - elixir: 1.17.x + otp: 27.x + tests_may_fail: false + # 1.18 supports OTP 25-27 — latest is 27. + - elixir: 1.18.x + otp: 27.x + tests_may_fail: false # 1.19 supports OTP 26-28. - elixir: 1.19.x otp: 27.x @@ -59,9 +69,17 @@ jobs: strategy: fail-fast: false matrix: - # Same matrix as mix_test (Linux). See above for compatibility notes - # (toxic2 requires Elixir ~> 1.19, so 1.16/1.17/1.18 are dropped on this branch). + # Same matrix as mix_test (Linux). include: + - elixir: 1.16.x + otp: 26.x + tests_may_fail: false + - elixir: 1.17.x + otp: 27.x + tests_may_fail: false + - elixir: 1.18.x + otp: 27.x + tests_may_fail: false - elixir: 1.19.x otp: 27.x tests_may_fail: false diff --git a/mix.exs b/mix.exs index e64e6a5a..68a0c737 100644 --- a/mix.exs +++ b/mix.exs @@ -6,8 +6,7 @@ defmodule ElixirSense.MixProject do [ app: :elixir_sense, version: "2.0.0", - # toxic2 (the parser dependency) requires ~> 1.19 - elixir: "~> 1.19", + elixir: "~> 1.16", elixirc_paths: elixirc_paths(Mix.env()), build_embedded: Mix.env() == :prod, start_permanent: Mix.env() == :prod, @@ -39,7 +38,7 @@ defmodule ElixirSense.MixProject do defp deps do [ - {:toxic2, github: "lukaszsamson/toxic2", ref: "6fde2f89acf94e9231e28245bf0c61f4fd4e0422"}, + {: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 cca50257..3d4af4c1 100644 --- a/mix.lock +++ b/mix.lock @@ -11,5 +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", "6fde2f89acf94e9231e28245bf0c61f4fd4e0422", [ref: "6fde2f89acf94e9231e28245bf0c61f4fd4e0422"]}, + "toxic2": {:git, "https://github.com/lukaszsamson/toxic2.git", "c47c911dac5aafa860206d222a781c3d71afc843", [ref: "c47c911dac5aafa860206d222a781c3d71afc843"]}, } From f7bbb7110effcf2b88ec5ec75ea19d8b696155dc Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 4 Jul 2026 10:13:31 +0200 Subject: [PATCH 16/17] test: make version-sensitive parser tests pass on Elixir 1.16/1.17 Restoring the 1.16-1.18 CI matrix surfaced tests that had never run against the toxic2 parser on < 1.18 (CI had been 1.19+): - surround_context/2: `Code.Fragment` only classifies `&1` capture args and keyword keys as `:capture_arg` / `{:key, _}` from Elixir 1.18 on. Toxic parses them structurally on every version, so assert Toxic's own (correct) result always and only compare against the version-dependent oracle on >= 1.18. - error_recovery: the incomplete fn/catch/guard-clause cursor-env tests used a trailing-fragment completion (the production path - the editor always has text after the cursor) on >= 1.18 but a raw no-suffix parse on < 1.18 that was never validated and yields a nil cursor env on 1.16/1.17. Collapse both to the suffix-based production path, which works uniformly across versions. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232 --- .../metadata_builder/error_recovery_test.exs | 68 +++++-------------- .../core/surround_context/toxic_test.exs | 49 ++++++++++--- 2 files changed, 56 insertions(+), 61 deletions(-) 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 5213f01b..c4bbbbfb 100644 --- a/test/elixir_sense/core/metadata_builder/error_recovery_test.exs +++ b/test/elixir_sense/core/metadata_builder/error_recovery_test.exs @@ -453,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 @@ -931,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 diff --git a/test/elixir_sense/core/surround_context/toxic_test.exs b/test/elixir_sense/core/surround_context/toxic_test.exs index 1f823395..eab83517 100644 --- a/test/elixir_sense/core/surround_context/toxic_test.exs +++ b/test/elixir_sense/core/surround_context/toxic_test.exs @@ -25,7 +25,6 @@ defmodule ElixirSense.Core.SurroundContext.ToxicTest do {":erlang.foo", 9}, {"a.b.c", 3}, {"a.b.c", 5}, - {"&1", 2}, {"foo(1, 2)", 2}, {"String.length(s)", 9}, {"@attr.method", 9}, @@ -50,6 +49,19 @@ defmodule ElixirSense.Core.SurroundContext.ToxicTest do 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 @@ -135,16 +147,31 @@ defmodule ElixirSense.Core.SurroundContext.ToxicTest do end test "keyword keys classify as :key (and :none on the colon)" do - for {source, col} <- [ - {"[key: 1]", 3}, - {"[key: 1]", 5}, - {"%{key: 1}", 4}, - {"foo(key: 1)", 7}, - {"[a: 1, bb: 2]", 8} - ] do - assert Toxic.surround_context(source, {1, col}) == - Code.Fragment.surround_context(source, {1, col}), - "mismatch for #{inspect(source)} @#{col}" + # 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 From 89eda3fc4df9cea0d70c528f3e7e240a70c860f8 Mon Sep 17 00:00:00 2001 From: Lukasz Samson Date: Sat, 4 Jul 2026 12:38:04 +0200 Subject: [PATCH 17/17] completion: restore state-based bitstring modifier filtering The `:bitstring_modifier` completion branch only split the text after `::` and dropped already-typed names, so it offered combinations Elixir rejects - e.g. `signed`/`little`/`native`/`utf8` after `binary-`, `size`/`unit`/`signed` after `utf8-`, `signed`/`binary` after `float-`. Restore the type/sign/endianness/utf/size/unit validity rules by parsing the modifiers already typed after `::` into a state and keeping only the modifiers that may still legally follow. The logic is inlined as private helpers in the completion engine (self-contained; the old `ElixirSense.Core.Bitstring` module stays removed). The flat `@bitstring_modifiers` list is still the display source (names + size/unit arity), now filtered by the parsed state. Tests assert the invalid options are absent (not just that valid ones are present) for `binary-`, `utf8-` and `float-`. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XRd15BX6wBpZ8ThaNSo232 --- .../providers/completion/completion_engine.ex | 117 +++++++++++++++++- .../providers/completion/suggestions_test.exs | 47 +++++++ 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/lib/elixir_sense/providers/completion/completion_engine.ex b/lib/elixir_sense/providers/completion/completion_engine.ex index 2e48a9d7..8183e162 100644 --- a/lib/elixir_sense/providers/completion/completion_engine.ex +++ b/lib/elixir_sense/providers/completion/completion_engine.ex @@ -101,6 +101,25 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do %{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 @@ -802,16 +821,22 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do {container_context_map_fields(pairs, {:struct, alias}, map, hint, metadata), continue?} :bitstring_modifier -> - existing = + # 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() - |> String.split("-") + |> bitstring_parse() + |> bitstring_available_options() + |> Enum.map(&Atom.to_string/1) results = @bitstring_modifiers - |> Enum.filter(&(Matcher.match?(&1.name, hint) and &1.name not in existing)) + |> Enum.filter(&(&1.name in available_names and Matcher.match?(&1.name, hint))) |> format_expansion() {results, false} @@ -821,6 +846,92 @@ defmodule ElixirSense.Providers.Completion.CompletionEngine do 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 diff --git a/test/elixir_sense/providers/completion/suggestions_test.exs b/test/elixir_sense/providers/completion/suggestions_test.exs index 3a606e57..6df1042e 100644 --- a/test/elixir_sense/providers/completion/suggestions_test.exs +++ b/test/elixir_sense/providers/completion/suggestions_test.exs @@ -4747,6 +4747,53 @@ defmodule ElixirSense.Providers.Completion.SuggestionTest do 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