diff --git a/apps/language_server/lib/language_server/parser.ex b/apps/language_server/lib/language_server/parser.ex index 1ba935b34..268ae1554 100644 --- a/apps/language_server/lib/language_server/parser.ex +++ b/apps/language_server/lib/language_server/parser.ex @@ -217,6 +217,7 @@ defmodule ElixirLS.LanguageServer.Parser do nil -> # file got closed, no need to do anything state + file -> version = file.source_file.version diff --git a/apps/language_server/lib/language_server/providers/completion/generic_reducer.ex b/apps/language_server/lib/language_server/providers/completion/generic_reducer.ex index 2eed16422..858046a61 100644 --- a/apps/language_server/lib/language_server/providers/completion/generic_reducer.ex +++ b/apps/language_server/lib/language_server/providers/completion/generic_reducer.ex @@ -12,7 +12,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion.GenericReducer do require Logger # TODO change/move this - alias ElixirSense.Plugins.Util + alias ElixirLS.LanguageServer.Plugins.Util @type func_call :: {module, fun :: atom, arg :: non_neg_integer, any} @type suggestion :: ElixirLS.LanguageServer.Providers.Completion.Suggestion.generic() diff --git a/apps/language_server/lib/language_server/providers/completion/reducers/docs_snippets.ex b/apps/language_server/lib/language_server/providers/completion/reducers/docs_snippets.ex index f6260e32a..4de1f872c 100644 --- a/apps/language_server/lib/language_server/providers/completion/reducers/docs_snippets.ex +++ b/apps/language_server/lib/language_server/providers/completion/reducers/docs_snippets.ex @@ -7,8 +7,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion.Reducers.DocsSnippets do @moduledoc false - # TODO change/move this - alias ElixirSense.Plugins.Util + alias ElixirLS.LanguageServer.Plugins.Util alias ElixirLS.Utils.Matcher # Format: diff --git a/apps/language_server/lib/language_server/providers/completion/suggestion.ex b/apps/language_server/lib/language_server/providers/completion/suggestion.ex index 3a3c53063..ef265ef4e 100644 --- a/apps/language_server/lib/language_server/providers/completion/suggestion.ex +++ b/apps/language_server/lib/language_server/providers/completion/suggestion.ex @@ -48,7 +48,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion.Suggestion do """ alias ElixirSense.Core.Metadata - alias ElixirSense.Core.ModuleStore + alias ElixirLS.LanguageServer.Plugins.ModuleStore alias ElixirSense.Core.State alias ElixirSense.Core.Parser alias ElixirSense.Core.Source diff --git a/apps/language_server/lib/language_server/providers/definition/locator.ex b/apps/language_server/lib/language_server/providers/definition/locator.ex index 8d7440e26..dbd57eb56 100644 --- a/apps/language_server/lib/language_server/providers/definition/locator.ex +++ b/apps/language_server/lib/language_server/providers/definition/locator.ex @@ -23,8 +23,8 @@ defmodule ElixirLS.LanguageServer.Providers.Definition.Locator do alias ElixirSense.Core.SurroundContext alias ElixirLS.LanguageServer.Location alias ElixirSense.Core.Parser - # TODO change/move this - alias ElixirSense.Plugins.Phoenix.Scope + + alias ElixirLS.LanguageServer.Plugins.Phoenix.Scope alias ElixirSense.Core.Normalized.Code, as: NormalizedCode def definition(code, line, column, options \\ []) do diff --git a/apps/language_server/lib/language_server/providers/plugins/ecto.ex b/apps/language_server/lib/language_server/providers/plugins/ecto.ex new file mode 100644 index 000000000..8923c6adc --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/ecto.ex @@ -0,0 +1,136 @@ +defmodule ElixirLS.LanguageServer.Plugins.Ecto do + @moduledoc false + + alias ElixirLS.LanguageServer.Plugins.ModuleStore + alias ElixirSense.Core.Source + alias ElixirLS.LanguageServer.Plugins.Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.Schema + alias ElixirLS.LanguageServer.Plugins.Ecto.Types + + @behaviour ElixirLS.LanguageServer.Plugin + use ElixirLS.LanguageServer.Providers.Completion.GenericReducer + + @schema_funcs [:field, :belongs_to, :has_one, :has_many, :many_to_many] + + @impl true + def setup(context) do + ModuleStore.ensure_compiled(context, Ecto.UUID) + end + + @impl true + def suggestions(hint, {Ecto.Migration, :add, 1, _info}, _chain, opts) do + builtin_types = Types.find_builtin_types(hint, opts.cursor_context) + builtin_types = Enum.reject(builtin_types, &String.starts_with?(&1.label, "{")) + + {:override, builtin_types} + end + + def suggestions(hint, {Ecto.Schema, :field, 1, _info}, _chain, opts) do + builtin_types = Types.find_builtin_types(hint, opts.cursor_context) + custom_types = Types.find_custom_types(hint, opts.module_store) + + {:override, builtin_types ++ custom_types} + end + + def suggestions(hint, {Ecto.Schema, func, 1, _info}, _chain, opts) + when func in @schema_funcs do + {:override, Schema.find_schemas(hint, opts.module_store)} + end + + def suggestions(hint, {Ecto.Schema, func, 2, %{option: option}}, _, _) + when func in @schema_funcs and option != nil do + {:override, Schema.find_option_values(hint, option, func)} + end + + def suggestions(_hint, {Ecto.Schema, func, 2, %{cursor_at_option: false}}, _, _) + when func in @schema_funcs do + :ignore + end + + def suggestions(hint, {Ecto.Schema, func, 2, _info}, _, _) + when func in @schema_funcs do + {:override, Schema.find_options(hint, func)} + end + + def suggestions(hint, {Ecto.Query, :from, 0, _info}, _, opts) do + text_before = opts.cursor_context.text_before + + if after_in?(hint, text_before) do + {:add, Schema.find_schemas(hint, opts.module_store)} + else + :ignore + end + end + + def suggestions( + hint, + _, + [{nil, :assoc, 1, assoc_info} | rest], + opts + ) do + text_before = opts.cursor_context.text_before + env = opts.env + meta = opts.buffer_metadata + + with %{pos: {{line, col}, _}} <- assoc_info, + from_info when not is_nil(from_info) <- + Enum.find_value(rest, fn + {Ecto.Query, :from, 1, from_info} -> from_info + _ -> nil + end), + assoc_code <- Source.text_after(text_before, line, col), + [_, var] <- + Regex.run(~r/^assoc\(\s*([_\p{Ll}\p{Lo}][\p{L}\p{N}_]*[?!]?)\s*,/u, assoc_code), + %{^var => %{type: type}} <- Query.extract_bindings(text_before, from_info, env, meta), + true <- function_exported?(type, :__schema__, 1) do + {:override, Query.find_assoc_suggestions(type, hint)} + else + _ -> + :ignore + end + end + + def suggestions(hint, _func_call, chain, opts) do + case Enum.find(chain, &match?({Ecto.Query, :from, 1, _}, &1)) do + {_, _, _, %{cursor_at_option: false} = info} -> + text_before = opts.cursor_context.text_before + env = opts.env + buffer_metadata = opts.buffer_metadata + + schemas = + if after_in?(hint, text_before), + do: Schema.find_schemas(hint, opts.module_store), + else: [] + + bindings = Query.extract_bindings(text_before, info, env, buffer_metadata) + {:add, schemas ++ Query.bindings_suggestions(hint, bindings)} + + {_, _, _, _} -> + {:override, Query.find_options(hint)} + + _ -> + :ignore + end + end + + # Adds customized snippet for `Ecto.Schema.schema/2` + @impl true + def decorate(%{origin: "Ecto.Schema", name: "schema", arity: 2} = item) do + snippet = """ + schema "$1" do + $0 + end + """ + + Map.put(item, :snippet, snippet) + end + + # Fallback + def decorate(item) do + item + end + + defp after_in?(hint, text_before) do + Regex.match?(~r/\s+in\s+#{Regex.escape(hint)}$/u, text_before) + end +end diff --git a/apps/language_server/lib/language_server/providers/plugins/ecto/query.ex b/apps/language_server/lib/language_server/providers/plugins/ecto/query.ex new file mode 100644 index 000000000..69b1e22d4 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/ecto/query.ex @@ -0,0 +1,261 @@ +defmodule ElixirLS.LanguageServer.Plugins.Ecto.Query do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Source + alias ElixirLS.LanguageServer.Plugins.Util + alias ElixirLS.Utils.Matcher + + # We'll keep these values hard-coded until Ecto provides the same information + # using docs' metadata. + + @joins [ + :join, + :inner_join, + :cross_join, + :left_join, + :right_join, + :full_join, + :inner_lateral_join, + :left_lateral_join + ] + + @from_join_opts [ + as: "A named binding for the from/join.", + prefix: "The prefix to be used for the from/join when issuing a database query.", + hints: "A string or a list of strings to be used as database hints." + ] + + @join_opts [on: "A query expression or keyword list to filter the join."] + + @var_r "[_\p{Ll}\p{Lo}][\p{L}\p{N}_]*[?!]?" + # elixir alias must be ASCII, no need to support unicode here + @mod_r "[A-Z][a-zA-Z0-9_\.]*" + @binding_r "(#{@var_r}) in (#{@mod_r}|assoc\\(\\s*#{@var_r},\\s*\\:#{@var_r}\\s*\\))" + + def find_assoc_suggestions(type, hint) do + for assoc <- type.__schema__(:associations), + assoc_str = inspect(assoc), + Matcher.match?(assoc_str, hint) do + assoc_mod = type.__schema__(:association, assoc).related + {doc, _} = Introspection.get_module_docs_summary(assoc_mod) + + %{ + type: :generic, + kind: :field, + label: assoc_str, + detail: "(Ecto association) #{inspect(assoc_mod)}", + documentation: doc + } + end + end + + def find_options(hint) do + clauses_suggestions(hint) ++ joins_suggestions(hint) ++ join_opts_suggestions(hint) + end + + defp clauses_suggestions(hint) do + funs = ElixirLS.Utils.CompletionEngine.get_module_funs(Ecto.Query, false) + + for {name, arity, arity, :macro, {doc, _}, _, ["query" | _]} <- funs, + clause = to_string(name), + Matcher.match?(clause, hint) do + clause_to_suggestion(clause, doc, "from clause") + end + end + + defp joins_suggestions(hint) do + for name <- @joins -- [:join], + clause = to_string(name), + Matcher.match?(clause, hint) do + join_kind = String.replace(clause, "_", " ") + doc = "A #{join_kind} query expression." + clause_to_suggestion(clause, doc, "from clause") + end + end + + defp join_opts_suggestions(hint) do + for {name, doc} <- @join_opts ++ @from_join_opts, + clause = to_string(name), + Matcher.match?(clause, hint) do + type = if Keyword.has_key?(@join_opts, name), do: "join", else: "from/join" + clause_to_suggestion(clause, doc, "#{type} option") + end + end + + defp find_fields(type, hint) do + with {:module, _} <- Code.ensure_compiled(type), + true <- function_exported?(type, :__schema__, 1) do + for field <- Enum.sort(type.__schema__(:fields)), + name = to_string(field), + Matcher.match?(name, hint) do + %{name: field, type: type.__schema__(:type, field)} + end + else + _ -> + [] + end + end + + defp find_field_relations(field, type) do + associations = type.__schema__(:associations) + + for assoc_name <- associations, + assoc = type.__schema__(:association, assoc_name), + assoc.owner == type, + assoc.owner_key == field.name do + assoc + end + end + + def bindings_suggestions(hint, bindings) do + case String.split(hint, ".") do + [var, field_hint] -> + type = bindings[var][:type] + + type + |> find_fields(field_hint) + |> Enum.map(fn f -> field_to_suggestion(f, type) end) + + _ -> + for {name, %{type: type}} <- bindings, + Matcher.match?(name, hint) do + binding_to_suggestion(name, type) + end + end + end + + defp clause_to_suggestion(option, doc, detail) do + doc_str = + doc + |> doc_sections() + |> Enum.filter(fn {k, _v} -> k in [:summary, "Keywords examples", "Keywords example"] end) + |> Enum.map_join("\n\n", fn + {:summary, text} -> + text + + {_, text} -> + [first | _] = String.split(text, "\n\n") + if first == "", do: "", else: "### Example\n\n#{first}" + end) + + %{ + type: :generic, + kind: :property, + label: option, + insert_text: "#{option}: ", + detail: "(#{detail}) Ecto.Query", + documentation: doc_str + } + end + + defp binding_to_suggestion(binding, type) do + {doc, _} = Introspection.get_module_docs_summary(type) + + %{ + type: :generic, + kind: :variable, + label: binding, + detail: "(query binding) #{inspect(type)}", + documentation: doc + } + end + + defp field_to_suggestion(field, origin) do + type_str = inspect(field.type) + associations = find_field_relations(field, origin) + + relations = + Enum.map_join(associations, ", ", fn + %{related: related, related_key: related_key} -> + "`#{inspect(related)} (#{inspect(related_key)})`" + + %{related: related} -> + # Ecto.Association.ManyToMany does not define :related_key + "`#{inspect(related)}`" + end) + + related_info = if relations == "", do: "", else: "* **Related:** #{relations}" + + doc = """ + The `#{inspect(field.name)}` field of `#{inspect(origin)}`. + + * **Type:** `#{type_str}` + #{related_info} + """ + + %{ + type: :generic, + kind: :field, + label: to_string(field.name), + detail: "Ecto field", + documentation: doc + } + end + + defp infer_type({:__aliases__, _, mods}, _vars, env, buffer_metadata) do + mod = Module.concat(mods) + {actual_mod, _, _, _} = Util.actual_mod_fun({mod, nil}, false, env, buffer_metadata) + actual_mod + end + + defp infer_type({:assoc, _, [{var, _, _}, assoc]}, vars, _env, _buffer_metadata) do + var_type = vars[to_string(var)][:type] + + if var_type && function_exported?(var_type, :__schema__, 2) do + var_type.__schema__(:association, assoc).related + end + end + + defp infer_type(_, _vars, _env, _buffer_metadata) do + nil + end + + def extract_bindings(prefix, %{pos: {{line, col}, _}} = func_info, env, buffer_metadata) do + func_code = Source.text_after(prefix, line, col) + + from_matches = Regex.scan(~r/^.+\(?\s*(#{@binding_r})/u, func_code) + + # TODO this code is broken + # depends on join positions that we are unable to get from AST + # line and col was previously assigned to each option in Source.which_func + join_matches = + for join when join in @joins <- func_info.options_so_far, + code = Source.text_after(prefix, line, col), + match <- Regex.scan(~r/^#{Regex.escape(join)}\:\s*(#{@binding_r})/u, code) do + match + end + + matches = from_matches ++ join_matches + + Enum.reduce(matches, %{}, fn [_, _, var, expr], bindings -> + case Code.string_to_quoted(expr) do + {:ok, expr_ast} -> + type = infer_type(expr_ast, bindings, env, buffer_metadata) + Map.put(bindings, var, %{type: type}) + + _ -> + bindings + end + end) + end + + def extract_bindings(_prefix, _func_info, _env, _buffer_metadata) do + %{} + end + + defp doc_sections(doc) do + [summary_and_detail | rest] = String.split(doc, "##") + summary_and_detail_parts = Source.split_lines(summary_and_detail, parts: 2) + summary = summary_and_detail_parts |> Enum.at(0, "") |> String.trim() + detail = summary_and_detail_parts |> Enum.at(1, "") |> String.trim() + + sections = + Enum.map(rest, fn text -> + [title, body] = Source.split_lines(text, parts: 2) + {String.trim(title), String.trim(body, "\n")} + end) + + [{:summary, summary}, {:detail, detail}] ++ sections + end +end diff --git a/apps/language_server/lib/language_server/providers/plugins/ecto/schema.ex b/apps/language_server/lib/language_server/providers/plugins/ecto/schema.ex new file mode 100644 index 000000000..bfe21c57a --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/ecto/schema.ex @@ -0,0 +1,457 @@ +defmodule ElixirLS.LanguageServer.Plugins.Ecto.Schema do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirLS.LanguageServer.Plugins.Option + alias ElixirLS.LanguageServer.Plugins.Util + alias ElixirLS.Utils.Matcher + + # We'll keep these values hard-coded until Ecto provides the same information + # using docs' metadata. + + @on_replace_values [ + raise: """ + (default) - do not allow removing association or embedded + data via parent changesets + """, + mark_as_invalid: """ + If attempting to remove the association or + embedded data via parent changeset - an error will be added to the parent + changeset, and it will be marked as invalid + """, + nilify: """ + Sets owner reference column to `nil` (available only for + associations). Use this on a `belongs_to` column to allow the association + to be cleared out so that it can be set to a new value. Will set `action` + on associated changesets to `:replace` + """, + update: """ + Updates the association, available only for `has_one` and `belongs_to`. + This option will update all the fields given to the changeset including the id + for the association + """, + delete: """ + Removes the association or related data from the database. + This option has to be used carefully (see below). Will set `action` on associated + changesets to `:replace` + """ + ] + + @on_delete_values [ + nothing: "(default) - do nothing to the associated records when the parent record is deleted", + nilify_all: "Sets the key in the associated table to `nil`", + delete_all: "Deletes the associated records when the parent record is deleted" + ] + + @options %{ + field: [ + %{ + name: :default, + doc: """ + Sets the default value on the schema and the struct. + The default value is calculated at compilation time, so don't use + expressions like `DateTime.utc_now` or `Ecto.UUID.generate` as + they would then be the same for all records. + """ + }, + %{ + name: :source, + doc: """ + Defines the name that is to be used in database for this field. + This is useful when attaching to an existing database. The value should be + an atom. + """ + }, + %{ + name: :autogenerate, + doc: """ + a `{module, function, args}` tuple for a function + to call to generate the field value before insertion if value is not set. + A shorthand value of `true` is equivalent to `{type, :autogenerate, []}`. + """ + }, + %{ + name: :read_after_writes, + doc: """ + When true, the field is always read back + from the database after insert and updates. + For relational databases, this means the RETURNING option of those + statements is used. For this reason, MySQL does not support this + option and will raise an error if a schema is inserted/updated with + read after writes fields. + """ + }, + %{ + name: :virtual, + doc: """ + When true, the field is not persisted to the database. + Notice virtual fields do not support `:autogenerate` nor + `:read_after_writes`. + """ + }, + %{ + name: :primary_key, + doc: """ + When true, the field is used as part of the + composite primary key. + """ + }, + %{ + name: :load_in_query, + doc: """ + When false, the field will not be loaded when + selecting the whole struct in a query, such as `from p in Post, select: p`. + Defaults to `true`. + """ + } + ], + belongs_to: [ + %{ + name: :foreign_key, + doc: """ + Sets the foreign key field name, defaults to the name + of the association suffixed by `_id`. For example, `belongs_to :company` + will define foreign key of `:company_id`. The associated `has_one` or `has_many` + field in the other schema should also have its `:foreign_key` option set + with the same value. + """ + }, + %{ + name: :references, + doc: """ + Sets the key on the other schema to be used for the + association, defaults to: `:id` + """ + }, + %{ + name: :define_field, + doc: """ + When false, does not automatically define a `:foreign_key` + field, implying the user is defining the field manually elsewhere + """ + }, + %{ + name: :type, + doc: """ + Sets the type of automatically defined `:foreign_key`. + Defaults to: `:integer` and can be set per schema via `@foreign_key_type` + """ + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, `:nilify`, `:update`, or `:delete`. + See `Ecto.Changeset`'s section on related data for more info. + """, + values: @on_replace_values + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :primary_key, + doc: """ + If the underlying belongs_to field is a primary key + """ + }, + %{ + name: :source, + doc: """ + Defines the name that is to be used in database for this field + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3`. + """ + } + ], + has_one: [ + %{ + name: :foreign_key, + doc: """ + Sets the foreign key, this should map to a field on the + other schema, defaults to the underscored name of the current module + suffixed by `_id` + """ + }, + %{ + name: :references, + doc: """ + Sets the key on the current schema to be used for the + association, defaults to the primary key on the schema + """ + }, + %{ + name: :through, + doc: """ + If this association must be defined in terms of existing + associations. Read the section in `has_many/3` for more information + """ + }, + %{ + name: :on_delete, + doc: """ + The action taken on associations when parent record + is deleted. May be `:nothing` (default), `:nilify_all` and `:delete_all`. + Using this option is DISCOURAGED for most relational databases. Instead, + in your migration, set `references(:parent_id, on_delete: :delete_all)`. + Opposite to the migration option, this option cannot guarantee integrity + and it is only triggered for `c:Ecto.Repo.delete/2` (and not on + `c:Ecto.Repo.delete_all/2`) and it never cascades. If posts has many comments, + which has many tags, and you delete a post, only comments will be deleted. + If your database does not support references, cascading can be manually + implemented by using `Ecto.Multi` or `Ecto.Changeset.prepare_changes/2` + """, + values: @on_delete_values + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, `:nilify`, `:update`, or + `:delete`. See `Ecto.Changeset`'s section on related data for more info. + """, + values: @on_replace_values + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3`. It does not apply to `:through` associations. + """ + } + ], + has_many: [ + %{ + name: :foreign_key, + doc: """ + Sets the foreign key, this should map to a field on the + other schema, defaults to the underscored name of the current module + suffixed by `_id`. + """ + }, + %{ + name: :references, + doc: """ + Sets the key on the current schema to be used for the + association, defaults to the primary key on the schema. + """ + }, + %{ + name: :through, + doc: """ + If this association must be defined in terms of existing + associations. Read the section in `has_many/3` for more information. + """ + }, + %{ + name: :on_delete, + doc: """ + The action taken on associations when parent record + is deleted. May be `:nothing` (default), `:nilify_all` and `:delete_all`. + Using this option is DISCOURAGED for most relational databases. Instead, + in your migration, set `references(:parent_id, on_delete: :delete_all)`. + Opposite to the migration option, this option cannot guarantee integrity + and it is only triggered for `c:Ecto.Repo.delete/2` (and not on + `c:Ecto.Repo.delete_all/2`) and it never cascades. If posts has many comments, + which has many tags, and you delete a post, only comments will be deleted. + If your database does not support references, cascading can be manually + implemented by using `Ecto.Multi` or `Ecto.Changeset.prepare_changes/2`. + """, + values: @on_delete_values + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, `:nilify`, `:update`, or + `:delete`. See `Ecto.Changeset`'s section on related data for more info. + """, + values: @on_replace_values + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3`. It does not apply to `:through` associations. + """ + } + ], + many_to_many: [ + %{ + name: :join_through, + doc: """ + Specifies the source of the associated data. + It may be a string, like "posts_tags", representing the + underlying storage table or an atom, like `MyApp.PostTag`, + representing a schema. This option is required. + """ + }, + %{ + name: :join_keys, + doc: """ + Specifies how the schemas are associated. It + expects a keyword list with two entries, the first being how + the join table should reach the current schema and the second + how the join table should reach the associated schema. In the + example above, it defaults to: `[post_id: :id, tag_id: :id]`. + The keys are inflected from the schema names. + """ + }, + %{ + name: :on_delete, + doc: """ + The action taken on associations when the parent record + is deleted. May be `:nothing` (default) or `:delete_all`. + Using this option is DISCOURAGED for most relational databases. Instead, + in your migration, set `references(:parent_id, on_delete: :delete_all)`. + Opposite to the migration option, this option cannot guarantee integrity + and it is only triggered for `c:Ecto.Repo.delete/2` (and not on + `c:Ecto.Repo.delete_all/2`). This option can only remove data from the + join source, never the associated records, and it never cascades. + """, + values: Keyword.take(@on_delete_values, [:nothing, :delete_all]) + }, + %{ + name: :on_replace, + doc: """ + The action taken on associations when the record is + replaced when casting or manipulating parent changeset. May be + `:raise` (default), `:mark_as_invalid`, or `:delete`. + `:delete` will only remove data from the join source, never the + associated records. See `Ecto.Changeset`'s section on related data + for more info. + """, + values: Keyword.take(@on_replace_values, [:raise, :mark_as_invalid, :delete]) + }, + %{ + name: :defaults, + doc: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """ + }, + %{ + name: :join_defaults, + doc: """ + The same as `:defaults` but it applies to the join schema + instead. This option will raise if it is given and the `:join_through` value + is not a schema. + """ + }, + %{ + name: :unique, + doc: """ + When true, checks if the associated entries are unique + whenever the association is cast or changed via the parent record. + For instance, it would verify that a given tag cannot be attached to + the same post more than once. This exists mostly as a quick check + for user feedback, as it does not guarantee uniqueness at the database + level. Therefore, you should also set a unique index in the database + join table, such as: `create unique_index(:posts_tags, [:post_id, :tag_id])` + """ + }, + %{ + name: :where, + doc: """ + A filter for the association. See "Filtering associations" + in `has_many/3` + """ + }, + %{ + name: :join_where, + doc: """ + A filter for the join table. See "Filtering associations" + in `has_many/3` + """ + } + ] + } + + def find_options(hint, fun) do + @options[fun] |> Option.find(hint, fun) + end + + def find_option_values(hint, option, fun) do + for {value, doc} <- Enum.find(@options[fun], &(&1.name == option))[:values] || [], + value_str = inspect(value), + Matcher.match?(value_str, hint) do + %{ + type: :generic, + kind: :enum_member, + label: value_str, + insert_text: Util.trim_leading_for_insertion(hint, value_str), + detail: "#{inspect(option)} value", + documentation: doc + } + end + end + + def find_schemas(hint, module_store) do + for module <- module_store.list, + function_exported?(module, :__schema__, 1), + mod_str = inspect(module), + Util.match_module?(mod_str, hint) do + {doc, _} = Introspection.get_module_docs_summary(module) + + %{ + type: :generic, + kind: :class, + label: mod_str, + insert_text: Util.trim_leading_for_insertion(hint, mod_str), + detail: "Ecto schema", + documentation: doc + } + end + |> Enum.sort_by(& &1.label) + end +end diff --git a/apps/language_server/lib/language_server/providers/plugins/ecto/types.ex b/apps/language_server/lib/language_server/providers/plugins/ecto/types.ex new file mode 100644 index 000000000..7a04e0b4d --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/ecto/types.ex @@ -0,0 +1,108 @@ +defmodule ElixirLS.LanguageServer.Plugins.Ecto.Types do + @moduledoc false + + alias ElixirSense.Core.Introspection + alias ElixirLS.LanguageServer.Plugins.Util + alias ElixirLS.Utils.Matcher + + # We'll keep these values hard-coded until Ecto provides the same information + # using docs' metadata. + + @ecto_types [ + {":string", "string UTF-8 encoded", "\"hello\"", nil}, + {":boolean", "boolean", "true, false", nil}, + {":integer", "integer", "1, 2, 3", nil}, + {":float", "float", "1.0, 2.0, 3.0", nil}, + {":decimal", "Decimal", nil, nil}, + {":id", "integer", "1, 2, 3", nil}, + {":date", "Date", nil, nil}, + {":time", "Time", nil, nil}, + {":time_usec", "Time", nil, nil}, + {":naive_datetime", "NaiveDateTime", nil, nil}, + {":naive_datetime_usec", "NaiveDateTime", nil, nil}, + {":utc_datetime", "DateTime", nil, nil}, + {":utc_datetime_usec", "DateTime", nil, nil}, + {"{:array, inner_type}", "list", "[value, value, ...]", "{:array, ${1:inner_type}}"}, + {":map", "map", nil, nil}, + {"{:map, inner_type}", "map", nil, "{:map, ${1:inner_type}}"}, + {":binary_id", "binary", "<>", nil}, + {":binary", "binary", "<>", nil} + ] + + def find_builtin_types(hint, cursor_context) do + text_before = cursor_context.text_before + text_after = cursor_context.text_after + + actual_hint = + if String.ends_with?(text_before, "{" <> hint) do + "{" <> hint + else + hint + end + + for {name, _, _, _} = type <- @ecto_types, + Matcher.match?(name, actual_hint) do + buitin_type_to_suggestion(type, actual_hint, text_after) + end + end + + def find_custom_types(hint, module_store) do + for module <- Map.get(module_store.by_behaviour, Ecto.Type, []), + type_str = inspect(module), + Util.match_module?(type_str, hint) do + custom_type_to_suggestion(module, hint) + end + end + + defp buitin_type_to_suggestion({type, elixir_type, literal_syntax, snippet}, hint, text_after) do + [_, hint_prefix] = Regex.run(~r/(.*?)[\w0-9\._!\?\->]*$/u, hint) + + insert_text = String.replace_prefix(type, hint_prefix, "") + snippet = snippet && String.replace_prefix(snippet, hint_prefix, "") + + {insert_text, snippet} = + if String.starts_with?(text_after, "}") do + snippet = snippet && String.replace_suffix(snippet, "}", "") + insert_text = String.replace_suffix(insert_text, "}", "") + {insert_text, snippet} + else + {insert_text, snippet} + end + + literal_syntax_info = + if literal_syntax, do: "* **Literal syntax:** `#{literal_syntax}`", else: "" + + doc = """ + Built-in Ecto type. + + * **Elixir type:** `#{elixir_type}` + #{literal_syntax_info}\ + """ + + %{ + type: :generic, + kind: :type_parameter, + label: type, + insert_text: insert_text, + snippet: snippet, + detail: "Ecto type", + documentation: doc, + priority: 0 + } + end + + defp custom_type_to_suggestion(type, hint) do + type_str = inspect(type) + {doc, _} = Introspection.get_module_docs_summary(type) + + %{ + type: :generic, + kind: :type_parameter, + label: type_str, + insert_text: Util.trim_leading_for_insertion(hint, type_str), + detail: "Ecto custom type", + documentation: doc, + priority: 1 + } + end +end diff --git a/apps/language_server/lib/language_server/providers/plugins/module_store.ex b/apps/language_server/lib/language_server/providers/plugins/module_store.ex new file mode 100644 index 000000000..c36761ced --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/module_store.ex @@ -0,0 +1,88 @@ +defmodule ElixirLS.LanguageServer.Plugins.ModuleStore do + @moduledoc """ + Caches the module list and a list of modules keyed by the behaviour they implement. + """ + defstruct by_behaviour: %{}, list: [], plugins: [] + + @type t :: %__MODULE__{ + by_behaviour: %{optional(atom) => module}, + list: list(module), + plugins: list(module) + } + + alias ElixirSense.Core.Applications + + def ensure_compiled(context, module_or_modules) do + modules = List.wrap(module_or_modules) + Enum.each(modules, &Code.ensure_compiled/1) + + Map.update!(context, :module_store, &build(modules, &1)) + end + + def build(list \\ all_loaded(), module_store \\ %__MODULE__{}) do + Enum.reduce(list, module_store, fn module, module_store -> + try do + module_store = %{module_store | list: [module | module_store.list]} + + module_store = + if is_plugin?(module) do + %{module_store | plugins: [module | module_store.plugins]} + else + module_store + end + + module.module_info(:attributes) + |> Enum.flat_map(fn + {:behaviour, behaviours} when is_list(behaviours) -> + behaviours + + _ -> + [] + end) + |> Enum.reduce(module_store, &add_behaviour(module, &1, &2)) + rescue + _ -> + module_store + end + end) + end + + defp is_plugin?(module) do + module.module_info(:attributes) + |> Enum.any?(fn + {:behaviour, behaviours} when is_list(behaviours) -> + ElixirSense.Plugin in behaviours or ElixirLS.LanguageServer.Plugin in behaviours + + {:is_elixir_sense_plugin, true} -> + true + + {:is_elixir_ls_plugin, true} -> + true + + _ -> + false + end) + end + + defp all_loaded do + Applications.get_modules_from_applications() + |> Enum.filter(fn module -> + try do + _ = Code.ensure_compiled(module) + function_exported?(module, :module_info, 0) + rescue + _ -> + false + end + end) + end + + defp add_behaviour(adopter, behaviour, module_store) do + new_by_behaviour = + module_store.by_behaviour + |> Map.put_new_lazy(behaviour, fn -> MapSet.new() end) + |> Map.update!(behaviour, &MapSet.put(&1, adopter)) + + %{module_store | by_behaviour: new_by_behaviour} + end +end diff --git a/apps/language_server/lib/language_server/providers/plugins/option.ex b/apps/language_server/lib/language_server/providers/plugins/option.ex new file mode 100644 index 000000000..120e10ff4 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/option.ex @@ -0,0 +1,38 @@ +defmodule ElixirLS.LanguageServer.Plugins.Option do + @moduledoc false + + alias ElixirLS.LanguageServer.Plugins.Util + alias ElixirLS.Utils.Matcher + + def find(options, hint, fun) do + for option <- options, match_hint?(option, hint) do + to_suggestion(option, fun) + end + |> Enum.sort_by(& &1.label) + end + + def to_suggestion(option, fun) do + command = + if option[:values] not in [nil, []] do + Util.command(:trigger_suggest) + end + + %{ + type: :generic, + kind: :property, + label: to_string(option.name), + insert_text: "#{option.name}: ", + snippet: option[:snippet], + detail: "#{fun} option", + documentation: option[:doc], + command: command + } + end + + def match_hint?(option, hint) do + option + |> Map.fetch!(:name) + |> to_string() + |> Matcher.match?(hint) + end +end diff --git a/apps/language_server/lib/language_server/providers/plugins/phoenix.ex b/apps/language_server/lib/language_server/providers/plugins/phoenix.ex new file mode 100644 index 000000000..eb44bcb78 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/phoenix.ex @@ -0,0 +1,105 @@ +defmodule ElixirLS.LanguageServer.Plugins.Phoenix do + @moduledoc false + + @behaviour ElixirLS.LanguageServer.Plugin + + use ElixirLS.LanguageServer.Providers.Completion.GenericReducer + + alias ElixirSense.Core.Source + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirLS.LanguageServer.Plugins.ModuleStore + alias ElixirLS.LanguageServer.Plugins.Phoenix.Scope + alias ElixirLS.LanguageServer.Plugins.Util + alias ElixirLS.Utils.Matcher + + @phoenix_route_funcs ~w( + get put patch trace + delete head options + forward connect post + )a + + @impl true + def setup(context) do + ModuleStore.ensure_compiled(context, Phoenix.Router) + end + + if Version.match?(System.version(), ">= 1.14.0") do + @impl true + def suggestions(hint, {Phoenix.Router, func, 1, _info}, _list, opts) + when func in @phoenix_route_funcs do + binding = Binding.from_env(opts.env, opts.buffer_metadata) + {_, scope_alias} = Scope.within_scope(opts.cursor_context.text_before, binding) + + case find_controllers(opts.module_store, opts.env, hint, scope_alias) do + [] -> :ignore + controllers -> {:override, controllers} + end + end + + def suggestions( + hint, + {Phoenix.Router, func, 2, %{params: [_path, module]}}, + _list, + opts + ) + when func in @phoenix_route_funcs do + binding_env = Binding.from_env(opts.env, opts.buffer_metadata) + {_, scope_alias} = Scope.within_scope(opts.cursor_context.text_before) + {module, _} = Source.get_mod([module], binding_env) + + module = Module.concat(scope_alias, module) + + suggestions = + for {export, {2, :function}} when export not in ~w(action call)a <- + Introspection.get_exports(module), + name = inspect(export), + Matcher.match?(name, hint) do + %{ + type: :generic, + kind: :function, + label: name, + insert_text: Util.trim_leading_for_insertion(hint, name), + detail: "Phoenix action" + } + end + + {:override, suggestions} + end + end + + @impl true + def suggestions(_hint, _func_call, _list, _opts) do + :ignore + end + + defp find_controllers(module_store, env, hint, scope_alias) do + [prefix | _] = + env.module + |> inspect() + |> String.split(".") + + for module <- module_store.list, + mod_str = inspect(module), + Util.match_module?(mod_str, prefix), + mod_str =~ "Controller", + Util.match_module?(mod_str, hint) do + {doc, _} = Introspection.get_module_docs_summary(module) + + %{ + type: :generic, + kind: :class, + label: mod_str, + insert_text: skip_scope_alias(scope_alias, mod_str), + detail: "Phoenix controller", + documentation: doc + } + end + |> Enum.sort_by(& &1.label) + end + + defp skip_scope_alias(nil, insert_text), do: insert_text + + defp skip_scope_alias(scope_alias, insert_text), + do: String.replace_prefix(insert_text, "#{inspect(scope_alias)}.", "") +end diff --git a/apps/language_server/lib/language_server/providers/plugins/phoenix/scope.ex b/apps/language_server/lib/language_server/providers/plugins/phoenix/scope.ex new file mode 100644 index 000000000..97abe5ec1 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/phoenix/scope.ex @@ -0,0 +1,116 @@ +defmodule ElixirLS.LanguageServer.Plugins.Phoenix.Scope do + @moduledoc false + + alias ElixirSense.Core.Source + alias ElixirSense.Core.Binding + + def within_scope(buffer, binding_env \\ %Binding{}) do + with {:ok, ast} <- Code.Fragment.container_cursor_to_quoted(buffer), + {true, scopes_ast} <- get_scopes(ast), + scopes_ast = Enum.reverse(scopes_ast), + scope_alias <- get_scope_alias(scopes_ast, binding_env) do + {true, scope_alias} + else + _ -> {false, nil} + end + end + + defp get_scopes(ast) do + path = Macro.path(ast, &match?({:__cursor__, _, _}, &1)) + + scopes = + path + |> Enum.filter(&match?({:scope, _, _}, &1)) + |> Enum.map(fn {:scope, meta, params} -> + params = Enum.reject(params, &match?([{:do, _} | _], &1)) + {:scope, meta, params} + end) + + case scopes do + [] -> {false, nil} + scopes -> {true, scopes} + end + end + + # scope path: "/", alias: ExampleWeb do ... end + defp get_scope_alias_from_ast_node({:scope, _, [scope_params]}, binding_env, module) + when is_list(scope_params) do + scope_alias = Keyword.get(scope_params, :alias) + concat_module(scope_alias, binding_env, module) + end + + # scope "/", alias: ExampleWeb do ... end + defp get_scope_alias_from_ast_node( + {:scope, _, [_scope_path, scope_params]}, + binding_env, + module + ) + when is_list(scope_params) do + scope_alias = Keyword.get(scope_params, :alias) + concat_module(scope_alias, binding_env, module) + end + + defp get_scope_alias_from_ast_node( + {:scope, _, [_scope_path, scope_alias]}, + binding_env, + module + ) do + concat_module(scope_alias, binding_env, module) + end + + # scope "/", ExampleWeb, host: "api." do ... end + defp get_scope_alias_from_ast_node( + {:scope, _, [_scope_path, scope_alias, scope_params]}, + binding_env, + module + ) + when is_list(scope_params) do + concat_module(scope_alias, binding_env, module) + end + + defp get_scope_alias_from_ast_node( + _ast, + _binding_env, + module + ), + do: module + + # no alias - propagate parent + defp concat_module(nil, _binding_env, module), do: module + # alias: false resets all nested aliases + defp concat_module(false, _binding_env, _module), do: nil + + defp concat_module(scope_alias, binding_env, module) do + scope_alias = get_mod(scope_alias, binding_env) + Module.concat([module, scope_alias]) + end + + defp get_scope_alias(scopes_ast, binding_env, module \\ nil) + # recurse + defp get_scope_alias([], _binding_env, module), do: module + + defp get_scope_alias([head | tail], binding_env, module) do + scope_alias = get_scope_alias_from_ast_node(head, binding_env, module) + get_scope_alias(tail, binding_env, scope_alias) + end + + defp get_mod({:__aliases__, _, [scope_alias]}, binding_env) do + get_mod(scope_alias, binding_env) + end + + defp get_mod({name, _, nil}, binding_env) when is_atom(name) do + case Binding.expand(binding_env, {:variable, name}) do + {:atom, atom} -> + atom + + _ -> + nil + end + end + + defp get_mod(scope_alias, binding_env) do + with {mod, _} <- Source.get_mod([scope_alias], binding_env) do + mod + end + end +end diff --git a/apps/language_server/lib/language_server/providers/plugins/plugin.ex b/apps/language_server/lib/language_server/providers/plugins/plugin.ex new file mode 100644 index 000000000..8ee3ae065 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/plugin.ex @@ -0,0 +1,27 @@ +defmodule ElixirLS.LanguageServer.Plugin do + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.State + @type suggestion :: ElixirLS.LanguageServer.Providers.Suggestion.generic() + + @type context :: term + @type acc :: %{context: context(), result: list(suggestion())} + @type cursor_context :: %{ + text_before: String.t(), + text_after: String.t(), + at_module_body?: boolean + } + + @callback reduce( + hint :: String, + env :: State.Env.t(), + buffer_metadata :: Metadata.t(), + cursor_context, + acc + ) :: {:cont, acc} | {:halt, acc} + + @callback setup(context()) :: context() + + @callback decorate(suggestion) :: suggestion + + @optional_callbacks decorate: 1, reduce: 5, setup: 1 +end diff --git a/apps/language_server/lib/language_server/providers/plugins/util.ex b/apps/language_server/lib/language_server/providers/plugins/util.ex new file mode 100644 index 000000000..06ddf0ac8 --- /dev/null +++ b/apps/language_server/lib/language_server/providers/plugins/util.ex @@ -0,0 +1,97 @@ +defmodule ElixirLS.LanguageServer.Plugins.Util do + @moduledoc false + + alias ElixirSense.Core.Binding + alias ElixirSense.Core.Introspection + alias ElixirSense.Core.Metadata + alias ElixirSense.Core.Source + alias ElixirSense.Core.State + alias ElixirLS.Utils.Matcher + + def match_module?(mod_str, hint) do + hint = String.downcase(hint) + mod_full = String.downcase(mod_str) + mod_last = mod_full |> String.split(".") |> List.last() + Enum.any?([mod_last, mod_full], &Matcher.match?(&1, hint)) + end + + def trim_leading_for_insertion(hint, value) do + [_, hint_prefix] = Regex.run(~r/(.*?)[\w0-9\._!\?\->]*$/u, hint) + insert_text = String.replace_prefix(value, hint_prefix, "") + + case String.split(hint, ".") do + [] -> + insert_text + + hint_parts -> + parts = String.split(insert_text, ".") + {_, insert_parts} = Enum.split(parts, length(hint_parts) - 1) + Enum.join(insert_parts, ".") + end + end + + # TODO this is vscode specific. Remove? + def command(:trigger_suggest) do + %{ + "title" => "Trigger Parameter Hint", + "command" => "editor.action.triggerSuggest" + } + end + + def actual_mod_fun({mod, fun}, elixir_prefix, env, buffer_metadata) do + %State.Env{ + imports: imports, + requires: requires, + aliases: aliases, + module: module, + scope: scope + } = env + + %Metadata{mods_funs_to_positions: mods_funs, types: metadata_types} = buffer_metadata + + Introspection.actual_mod_fun( + {mod, fun}, + imports, + requires, + if(elixir_prefix, do: [], else: aliases), + module, + scope, + mods_funs, + metadata_types, + # we don't expect local macros here, no need to pass position + {1, 1} + ) + end + + def partial_func_call(code, %State.Env{} = env, %Metadata{} = buffer_metadata) do + binding_env = Binding.from_env(env, buffer_metadata) + + func_info = Source.which_func(code, binding_env) + + with %{candidate: {mod, fun}, npar: npar} <- func_info, + mod_fun <- actual_mod_fun({mod, fun}, func_info.elixir_prefix, env, buffer_metadata), + {actual_mod, actual_fun, _, _} <- mod_fun do + {actual_mod, actual_fun, npar, func_info} + else + _ -> + :none + end + end + + def func_call_chain(code, env, buffer_metadata) do + func_call_chain(code, env, buffer_metadata, []) + end + + # TODO reimplement this on elixir 1.14 with + # Code.Fragment.container_cursor_to_quoted and Macro.path + defp func_call_chain(code, env, buffer_metadata, chain) do + case partial_func_call(code, env, buffer_metadata) do + :none -> + Enum.reverse(chain) + + {_mod, _fun, _npar, %{pos: {{line, col}, _}}} = func_call -> + code_before = Source.text_before(code, line, col) + func_call_chain(code_before, env, buffer_metadata, [func_call | chain]) + end + end +end diff --git a/apps/language_server/test/providers/plugins/ecto_test.exs b/apps/language_server/test/providers/plugins/ecto_test.exs new file mode 100644 index 000000000..7d7e2fae2 --- /dev/null +++ b/apps/language_server/test/providers/plugins/ecto_test.exs @@ -0,0 +1,923 @@ +defmodule ElixirLS.LanguageServer.Plugins.EctoTest do + use ExUnit.Case + import ExUnit.CaptureIO + + 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 + ElixirLS.LanguageServer.Providers.Completion.Suggestion.suggestions( + buffer, + cursor.line, + cursor.col + ) + end + + describe "decorate" do + test "update snippet for Ecto.Schema.schema/2" do + buffer = """ + import Ecto.Schema + sche + # ^ + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [%{name: "schema", arity: 2, snippet: snippet}] = result + + assert snippet == """ + schema "$1" do + $0 + end + """ + end + end + + describe "suggesting ecto types" do + # test "suggestion info for bult-in types" do + # buffer = """ + # import Ecto.Schema + # field name, {: + # # ^ + # """ + + # [cursor] = cursors(buffer) + + # result = suggestions(buffer, cursor) + + # assert [ + # %{ + # detail: "Ecto type", + # label: "{:array, inner_type}", + # insert_text: "array, inner_type}", + # kind: :type_parameter, + # documentation: doc, + # type: :generic + # }, + # %{detail: "Ecto type", label: "{:map, inner_type}"} + # ] = result + + # assert doc == """ + # Built-in Ecto type. + + # * **Elixir type:** `list` + # * **Literal syntax:** `[value, value, ...]`\ + # """ + # end + + test "suggestion info for custom types" do + buffer = """ + import Ecto.Schema + field name, Ecto.U + # ^ + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [ + %{ + detail: "Ecto custom type", + label: "Ecto.UUID", + kind: :type_parameter, + insert_text: "UUID", + documentation: doc, + type: :generic + } + ] = result + + assert doc == """ + Fake Ecto.UUID + """ + end + + test "insert_text includes leading `:` if it's not present" do + buffer = """ + import Ecto.Schema + field name, + # ^ + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [%{insert_text: ":string"} | _] = result + end + + test "insert_text does not include leading `:` if it's already present" do + buffer = """ + import Ecto.Schema + field name, :f + # ^ + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [%{insert_text: "float"}] = result + end + + # TODO + # test "insert_text/snippet include trailing `}` if it's not present" do + # buffer = """ + # import Ecto.Schema + # field name, {:arr + # # ^ + # """ + + # [cursor] = cursors(buffer) + + # result = suggestions(buffer, cursor) + + # assert [%{insert_text: "array, inner_type}", snippet: "array, ${1:inner_type}}"}] = result + # end + + # test "insert_text/snippet do not include trailing `}` if it's already present" do + # buffer = """ + # import Ecto.Schema + # field name, {:arr} + # # ^ + # """ + + # [cursor] = cursors(buffer) + + # result = suggestions(buffer, cursor) + + # assert [%{insert_text: "array, inner_type", snippet: "array, ${1:inner_type}"}] = result + # end + end + + describe "suggesting ecto schemas" do + setup do + Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment) + Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post) + Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User) + Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Tag) + :ok + end + + test "suggest all available schemas" do + buffer = """ + import Ecto.Schema + has_many :posts, + # ^ + """ + + [cursor] = cursors(buffer) + + [ + %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment"}, + %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post"}, + %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Tag"}, + %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User"} + ] = suggestions(buffer, cursor) + end + + test "match the hint ignoring the case against the full module name or just the last part" do + buffer = """ + import Ecto.Schema + has_many :posts, po + # ^ + """ + + [cursor] = cursors(buffer) + + [%{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post"}] = + suggestions(buffer, cursor) + + buffer = """ + import Ecto.Schema + has_many :posts, ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Po + # ^ + """ + + [cursor] = cursors(buffer) + + [%{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post"}] = + suggestions(buffer, cursor) + end + + test "suggestion info" do + buffer = """ + import Ecto.Schema + has_many :posts, Po + # ^ + """ + + [cursor] = cursors(buffer) + [suggestion] = suggestions(buffer, cursor) + + assert suggestion == %{ + detail: "Ecto schema", + documentation: "Fake Post schema.\n", + kind: :class, + label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post", + insert_text: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post", + type: :generic + } + end + end + + describe "suggestions for Ecto.Query.from/2" do + test "list clauses (any Ecto.Query macro that has `query` as first argument" do + buffer = """ + import Ecto.Query + + from( + u in User, + where: is_nil(u.id), + s + # ^ + ) + """ + + [cursor] = cursors(buffer) + + capture_io(:stderr, fn -> + result = suggestions(buffer, cursor) + send(self(), {:result, result}) + end) + + assert_received {:result, result} + + detail = "(from clause) Ecto.Query" + + assert [ + %{ + documentation: doc1, + label: "select", + detail: ^detail, + kind: :property, + insert_text: "select: " + }, + %{documentation: doc2, label: "select_merge", detail: ^detail} + ] = result + + assert doc1 == """ + A select query expression. + + ### Example + + from(c in City, select: c) # returns the schema as a struct + from(c in City, select: {c.name, c.population}) + from(c in City, select: [c.name, c.county])\ + """ + + assert doc2 =~ "Mergeable select query expression." + end + + test "list different available join types" do + buffer = """ + import Ecto.Query + + from( + u in User, + where: is_nil(u.id), + l + # ^ + ) + """ + + [cursor] = cursors(buffer) + + capture_io(:stderr, fn -> + result = suggestions(buffer, cursor) + send(self(), {:result, result}) + end) + + assert_received {:result, result} + + detail = "(from clause) Ecto.Query" + + assert [ + %{documentation: doc1, label: "left_join", detail: ^detail, kind: :property}, + %{documentation: doc2, label: "left_lateral_join", detail: ^detail} + ] = result + + assert doc1 == "A left join query expression." + assert doc2 =~ "A left lateral join query expression." + end + + test "join options" do + buffer = """ + import Ecto.Query + + from( + u in User, + where: is_nil(u.id), + prefix: "pre", + # ^ + o + # ^ + ) + """ + + [cursor_1, cursor_2] = cursors(buffer) + + capture_io(:stderr, fn -> + results = {suggestions(buffer, cursor_1), suggestions(buffer, cursor_2)} + send(self(), {:results, results}) + end) + + assert_received {:results, {result_1, result_2}} + + assert [%{documentation: doc, label: "prefix", detail: detail, kind: kind}] = result_1 + assert kind == :property + assert detail == "(from/join option) Ecto.Query" + assert doc == "The prefix to be used for the from/join when issuing a database query." + + assert [%{documentation: doc, label: "on", detail: detail, kind: kind}] = result_2 + assert kind == :property + assert detail == "(join option) Ecto.Query" + assert doc == "A query expression or keyword list to filter the join." + end + + # TODO + # test "list available bindings" do + # buffer = """ + # import Ecto.Query + # alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User, as: User + + # def query() do + # from( + # u in User, + # join: m1 in Mod1, + # join: m2 in Mod2, + # left_join: a1 in assoc(u, :assoc1), + # inner_join: a2 in assoc(u, :assoc2), + # where: a2 in subquery(from(s in Sub, limit: 1)), + # where: u.id == m + # # ^ ^ + # end + # """ + + # [cursor_1, cursor_2] = cursors(buffer) + + # assert [ + # %{label: "a1"}, + # %{label: "a2"}, + # %{label: "m1"}, + # %{label: "m2"}, + # %{label: "u", kind: :variable, detail: detail, documentation: doc} + # ] = suggestions(buffer, cursor_1, :generic) + + # assert detail == "(query binding) ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User" + # assert doc == "Fake User schema." + + # assert [%{label: "m1"}, %{label: "m2"}] = suggestions(buffer, cursor_2, :generic) + # end + + # test "list binding's fields" do + # buffer = """ + # import Ecto.Query + # alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + # alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment + + # def query() do + # from( + # p in Post, + # join: u in assoc(p, :user), + # left_join: c in Comment, + # select: {u.id, c.id, p.t, p.u} + # # ^ ^ ^ ^ + # ) + # end + # """ + + # [cursor_1, cursor_2, cursor_3, cursor_4] = cursors(buffer) + + # assert [ + # %{label: "email", detail: "Ecto field", kind: :field}, + # %{label: "id"}, + # %{label: "name"} + # ] = suggestions(buffer, cursor_1) + + # assert [%{label: "content"}, %{label: "date"}] = suggestions(buffer, cursor_2) + + # assert [%{label: "text"}, %{label: "title"}] = suggestions(buffer, cursor_3) + + # assert [%{label: "user_id", documentation: doc}] = suggestions(buffer, cursor_4) + + # assert doc == """ + # The `:user_id` field of `ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post`. + + # * **Type:** `:id` + # * **Related:** `ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User (:id)` + # """ + # end + + # test "list binding's fields even without any hint after `.`" do + # buffer = """ + # import Ecto.Query + # alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + # def query() do + # from( + # p in Post, + # join: c in assoc(p, :comments), + # select: {p., c.id} + # # ^ + # ) + # end + # """ + + # [cursor] = cursors(buffer) + + # assert [ + # %{label: "date"}, + # %{label: "id"}, + # %{label: "text"}, + # %{label: "title"}, + # %{label: "user_id"} + # ] = suggestions(buffer, cursor) + # end + + test "list associations from assoc/2" do + buffer = """ + import Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + def query() do + from( + p in Post, + join: c in assoc(p, + # ^ + ) + end + """ + + [cursor] = cursors(buffer) + + assert [ + %{ + label: ":user", + detail: detail, + documentation: doc, + kind: :field, + type: :generic + }, + %{label: ":comments"}, + %{label: ":tags"} + ] = suggestions(buffer, cursor) + + assert doc == "Fake User schema." + assert detail == "(Ecto association) ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User" + end + + # test "list available schemas after `in`" do + # Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment) + # Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post) + # Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User) + # Code.ensure_loaded(ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Tag) + + # buffer = """ + # import Ecto.Query + # alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + # alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment + + # def query() do + # from p in Post, join: c in Comment + # # ^ ^ + # end + # """ + + # [cursor_1, cursor_2] = cursors(buffer) + + # assert [ + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment"}, + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post"}, + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Tag"}, + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User"} + # | _ + # ] = suggestions(buffer, cursor_1) + + # assert [ + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment"}, + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post"}, + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Tag"}, + # %{label: "ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User"} + # | _ + # ] = suggestions(buffer, cursor_2) + # end + + test "list bindings and binding fields inside nested functions" do + buffer = """ + import Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + def query() do + from( + p in Post, + where: is_nil(p.t + # ^ ^ + ) + end + """ + + [cursor_1, cursor_2] = cursors(buffer) + + assert [%{label: "p"} | _] = suggestions(buffer, cursor_1) + assert [%{label: "text"}, %{label: "title"}] = suggestions(buffer, cursor_2) + end + + test "list bindings and binding fields using full module name" do + buffer = """ + import Ecto.Query + + def query() do + from p in ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post, + where: p.t + # ^ ^ + end + """ + + [cursor_1, cursor_2] = cursors(buffer) + + assert [%{label: "p"} | _] = suggestions(buffer, cursor_1) + assert [%{label: "text"}, %{label: "title"}] = suggestions(buffer, cursor_2) + end + + test "from/2 without parens" do + buffer = """ + import Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + def query() do + from p in Post, se + # ^ + end + """ + + [cursor] = cursors(buffer) + + assert [%{label: "select"}, %{label: "select_merge"}] = suggestions(buffer, cursor) + + buffer = """ + import Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + def query() do + from p in Post, where: p.id + # ^ + end + """ + + [cursor] = cursors(buffer) + + assert [%{label: "id"}] = suggestions(buffer, cursor) + + buffer = """ + import Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + def query() do + from p in Post, + join: u in User, + se + # ^ + end + """ + + [cursor] = cursors(buffer) + + assert [%{label: "select"}, %{label: "select_merge"}] = suggestions(buffer, cursor) + + buffer = """ + import Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + def query() do + from p in Post, + join: u in User, + + # ^ + end + """ + + [cursor] = cursors(buffer) + + assert [%{detail: "(from clause) Ecto.Query"} | _] = suggestions(buffer, cursor) + end + + test "succeeds when using schema with many_to_many assoc" do + buffer = """ + import Ecto.Query + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post + + def query() do + from p in Post, se + # ^ + end + """ + + [cursor] = cursors(buffer) + + assert [%{label: "select"}, %{label: "select_merge"}] = suggestions(buffer, cursor) + end + end + + describe "suggestions for Ecto.Schema.field/3" do + test "at arg 1, suggest built-in and custom ecto types" do + buffer = """ + import Ecto.Schema + field name, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.any?(result, &(&1.detail in ["Ecto type", "Ecto custom type"])) + end + + test "at arg 2, suggest field options" do + buffer = """ + import Ecto.Schema + field :name, :string, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.map(result, & &1.label) == [ + "autogenerate", + "default", + "load_in_query", + "primary_key", + "read_after_writes", + "source", + "virtual" + ] + end + + test "at arg 2, suggest fuzzy field options" do + buffer = """ + import Ecto.Schema + field :name, :string, deau + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + assert Enum.map(result, & &1.label) == ["default"] + + buffer = """ + import Ecto.Schema + field :name, :string, pri_ke + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + assert Enum.map(result, & &1.label) == ["primary_key"] + end + end + + describe "suggestions for Ecto.Migration.add/3" do + test "at arg 1, suggest built-in ecto types" do + buffer = """ + import Ecto.Migration + add :name, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.all?(result, &(&1.detail == "Ecto type")) + end + end + + describe "suggestions for Ecto.Schema.has_many/3" do + test "at arg 1, suggest only ecto schemas" do + buffer = """ + import Ecto.Schema + has_many :posts, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.all?(result, &(&1.detail == "Ecto schema")) + end + + test "at arg 2, suggest has_many options" do + buffer = """ + import Ecto.Schema + has_many :posts, Post, + # ^ + """ + + [cursor] = cursors(buffer) + [first_suggestion | _] = result = suggestions(buffer, cursor) + + assert Enum.map(result, & &1.label) == [ + "defaults", + "foreign_key", + "on_delete", + "on_replace", + "references", + "through", + "where" + ] + + assert first_suggestion == %{ + detail: "has_many option", + documentation: """ + Default values to use when building the association. + It may be a keyword list of options that override the association schema + or a `{module, function, args}` that receive the struct and the owner as + arguments. For example, if you set `Post.has_many :comments, defaults: [public: true]`, + then when using `Ecto.build_assoc(post, :comments)` that comment will have + `comment.public == true`. Alternatively, you can set it to + `Post.has_many :comments, defaults: {__MODULE__, :update_comment, []}` + and `Post.update_comment(comment, post)` will be invoked. + """, + insert_text: "defaults: ", + kind: :property, + label: "defaults", + snippet: nil, + command: nil, + type: :generic + } + end + + test "at arg 2, on option :on_replace, suggest possible values" do + buffer = """ + import Ecto.Schema + + has_many :posts, Post, on_replace: # + # ^ + """ + + [cursor] = cursors(buffer) + [_first_suggestion | _] = result = suggestions(buffer, cursor) + + assert Enum.map(result, & &1.label) == [ + ":raise", + ":mark_as_invalid", + ":nilify", + ":update", + ":delete" + ] + + buffer = """ + import Ecto.Schema + + has_many :posts, Post, on_replace: :rais # + # ^ + """ + + [cursor] = cursors(buffer) + + assert [ + %{ + detail: ":on_replace value", + insert_text: "raise", + kind: :enum_member, + label: ":raise", + type: :generic, + documentation: """ + (default) - do not allow removing association or embedded + data via parent changesets + """ + } + ] == suggestions(buffer, cursor) + end + end + + describe "suggestions for Ecto.Schema.has_one/3" do + test "at arg 1, suggest only ecto schemas" do + buffer = """ + import Ecto.Schema + has_one :post, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.all?(result, &(&1.detail == "Ecto schema")) + end + + test "at arg 2, suggest has_one options" do + buffer = """ + import Ecto.Schema + has_one :post, Post, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.map(result, & &1.label) == [ + "defaults", + "foreign_key", + "on_delete", + "on_replace", + "references", + "through", + "where" + ] + end + end + + describe "suggestions for Ecto.Schema.belongs_to/3" do + test "at arg 1, suggest only ecto schemas" do + buffer = """ + import Ecto.Schema + belongs_to :post, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.all?(result, &(&1.detail == "Ecto schema")) + end + + test "at arg 2, suggest belongs_to options" do + buffer = """ + import Ecto.Schema + belongs_to :post, Post, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.map(result, & &1.label) == [ + "defaults", + "define_field", + "foreign_key", + "on_replace", + "primary_key", + "references", + "source", + "type", + "where" + ] + end + end + + describe "suggestions for Ecto.Schema.many_to_many/3" do + test "at arg 1, suggest only ecto schemas" do + buffer = """ + import Ecto.Schema + many_to_many :post, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.all?(result, &(&1.detail == "Ecto schema")) + end + + test "at arg 2, suggest many_to_many options" do + buffer = """ + import Ecto.Schema + many_to_many :post, Post, + # ^ + """ + + [cursor] = cursors(buffer) + result = suggestions(buffer, cursor) + + assert Enum.map(result, & &1.label) == [ + "defaults", + "join_defaults", + "join_keys", + "join_through", + "join_where", + "on_delete", + "on_replace", + "unique", + "where" + ] + end + end +end diff --git a/apps/language_server/test/providers/plugins/phoenix/scope_test.exs b/apps/language_server/test/providers/plugins/phoenix/scope_test.exs new file mode 100644 index 000000000..e78a58a5d --- /dev/null +++ b/apps/language_server/test/providers/plugins/phoenix/scope_test.exs @@ -0,0 +1,116 @@ +defmodule ElixirLS.LanguageServer.Plugins.Phoenix.ScopeTest do + use ExUnit.Case + alias ElixirSense.Core.Binding + alias ElixirLS.LanguageServer.Plugins.Phoenix.Scope + + @moduletag requires_elixir_1_14: true + describe "within_scope/1" do + test "returns true and nil alias" do + buffer = """ + scope "/" do + get "/", + """ + + assert {true, nil} = Scope.within_scope(buffer) + end + + test "returns true and alias when passing alias as option" do + buffer = """ + scope "/", alias: ExampleWeb do + get "/", + """ + + assert {true, ExampleWeb} = Scope.within_scope(buffer) + end + + test "returns true and alias when passing alias as second parameter" do + buffer = """ + scope "/", ExampleWeb do + get "/", + """ + + assert {true, ExampleWeb} = Scope.within_scope(buffer) + end + + test "returns true and alias when nested within other scopes" do + _define_existing_atom = ExampleWeb.Admin + _define_existing_atom = Admin + + buffer = """ + scope "/", ExampleWeb do + scope "/admin", Admin do + get "/", + """ + + assert {true, ExampleWeb.Admin} = Scope.within_scope(buffer) + end + + test "can expand module attributes" do + buffer = """ + defmodule ExampleWeb.Router do + import Phoenix.Router + @web_prefix ExampleWweb + + scope "/", @web_prefix do + get "/", + """ + + binding = %Binding{ + structs: %{}, + variables: [], + attributes: [ + %ElixirSense.Core.State.AttributeInfo{ + name: :web_prefix, + positions: [{4, 5}], + type: {:atom, ExampleWeb} + } + ], + current_module: ExampleWeb.Router, + imports: [{Kernel, []}, {Phoenix.Router, []}], + specs: %{}, + types: %{}, + mods_funs: %{} + } + + assert {true, ExampleWeb} = Scope.within_scope(buffer, binding) + end + + test "can expand variables" do + buffer = """ + defmodule ExampleWeb.Router do + import Phoenix.Router + web_prefix = ExampleWweb + + scope "/", web_prefix do + get "/", + """ + + binding = %Binding{ + structs: %{}, + variables: [ + %ElixirSense.Core.State.VarInfo{ + name: :web_prefix, + positions: [{5, 5}], + scope_id: 2, + is_definition: true, + type: {:atom, ExampleWeb} + } + ], + attributes: [], + current_module: ExampleWeb.Router, + imports: [{Kernel, []}, {Phoenix.Router, []}], + specs: %{}, + types: %{}, + mods_funs: %{} + } + + assert {true, ExampleWeb} = Scope.within_scope(buffer, binding) + end + + test "returns false" do + buffer = "get \"\\\" ," + + assert {false, nil} = Scope.within_scope(buffer) + end + end +end diff --git a/apps/language_server/test/providers/plugins/phoenix_test.exs b/apps/language_server/test/providers/plugins/phoenix_test.exs new file mode 100644 index 000000000..4a8d513fa --- /dev/null +++ b/apps/language_server/test/providers/plugins/phoenix_test.exs @@ -0,0 +1,156 @@ +defmodule ElixirLS.LanguageServer.Plugins.PhoenixTest do + use ExUnit.Case + + 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 + ElixirLS.LanguageServer.Providers.Completion.Suggestion.suggestions( + buffer, + cursor.line, + cursor.col + ) + end + + @moduletag requires_elixir_1_14: true + describe "suggestions/4" do + test "overrides with controllers for phoenix_route_funcs, when in the second parameter" do + buffer = """ + defmodule ExampleWeb.Router do + import Phoenix.Router + + get "/", P + # ^ + end + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [ + %{ + type: :generic, + kind: :class, + label: "ExampleWeb.PageController", + insert_text: "ExampleWeb.PageController", + detail: "Phoenix controller" + } + ] = result + end + + test "do not prepend alias defined within Phoenix `scope` functions" do + _define_existing_atom = ExampleWeb + + buffer = """ + defmodule ExampleWeb.Router do + import Phoenix.Router + + scope "/", ExampleWeb do + get "/", P + # ^ + end + end + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [ + %{ + type: :generic, + kind: :class, + label: "ExampleWeb.PageController", + insert_text: "PageController", + detail: "Phoenix controller" + } + ] = result + end + + test "overrides with action suggestions for phoenix_route_funcs, when in the third parameter" do + buffer = """ + defmodule ExampleWeb.Router do + import Phoenix.Router + + get "/", ExampleWeb.PageController, : + # ^ + end + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [ + %{ + detail: "Phoenix action", + insert_text: "home", + kind: :function, + label: ":home", + type: :generic + } + ] = result + end + + test "overrides with action suggestions even when inside scope" do + buffer = """ + defmodule ExampleWeb.Router do + import Phoenix.Router + + scope "/", ExampleWeb do + get "/", PageController, : + # ^ + end + end + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + assert [ + %{ + detail: "Phoenix action", + insert_text: "home", + kind: :function, + label: ":home", + type: :generic + } + ] = result + end + + test "ignores for non-phoenix_route_funcs" do + buffer = """ + defmodule ExampleWeb.Router do + import Phoenix.Router + + something_else "/", P + # ^ + end + """ + + [cursor] = cursors(buffer) + + result = suggestions(buffer, cursor) + + refute Enum.find(result, &(&1[:detail] == "Phoenix controller")) + end + end +end diff --git a/apps/language_server/test/support/plugins/ecto/fake_schemas.ex b/apps/language_server/test/support/plugins/ecto/fake_schemas.ex index 006f15ae9..d52c3ffed 100644 --- a/apps/language_server/test/support/plugins/ecto/fake_schemas.ex +++ b/apps/language_server/test/support/plugins/ecto/fake_schemas.ex @@ -1,4 +1,4 @@ -defmodule ElixirSense.Plugins.Ecto.FakeSchemas.User do +defmodule ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User do @moduledoc """ Fake User schema. @@ -18,12 +18,12 @@ defmodule ElixirSense.Plugins.Ecto.FakeSchemas.User do do: %{related: FakeAssoc2, owner: __MODULE__, owner_key: :assoc2_id} end -defmodule ElixirSense.Plugins.Ecto.FakeSchemas.Comment do +defmodule ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment do @moduledoc """ Fake Comment schema. """ - alias ElixirSense.Plugins.Ecto.FakeSchemas.Post + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post def __schema__(:fields), do: [:content, :date] def __schema__(:associations), do: [:post] @@ -34,13 +34,13 @@ defmodule ElixirSense.Plugins.Ecto.FakeSchemas.Comment do do: %{related: Post, owner: __MODULE__, owner_key: :post_id} end -defmodule ElixirSense.Plugins.Ecto.FakeSchemas.Post do +defmodule ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post do @moduledoc """ Fake Post schema. """ - alias ElixirSense.Plugins.Ecto.FakeSchemas.User - alias ElixirSense.Plugins.Ecto.FakeSchemas.Comment + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.User + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Comment def __schema__(:fields), do: [:id, :title, :text, :date, :user_id] def __schema__(:associations), do: [:user, :comments, :tags] @@ -60,12 +60,12 @@ defmodule ElixirSense.Plugins.Ecto.FakeSchemas.Post do do: %{related: Tag, owner: __MODULE__, owner_key: :id} end -defmodule ElixirSense.Plugins.Ecto.FakeSchemas.Tag do +defmodule ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Tag do @moduledoc """ Fake Tag schema. """ - alias ElixirSense.Plugins.Ecto.FakeSchemas.Post + alias ElixirLS.LanguageServer.Plugins.Ecto.FakeSchemas.Post def __schema__(:fields), do: [:id, :name] def __schema__(:associations), do: [:posts]