diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc6451f..6478df6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,30 @@ Improvements: - Add autocompletion of struct fields on a binding when we know for sure what type of struct it is. (thanks [Łukasz Samson](https://github.com/lukaszsamson)) [#202](https://github.com/elixir-lsp/elixir-ls/pull/202) - For details see the [Code Completion section of the readme](https://github.com/elixir-lsp/elixir-ls/tree/a2a1f38bf0f47e074ec5d50636d669fae03a3d5e#code-completion) +- Add all core elixir apps to the Dialyzer PLT. (thanks [Eric Entin](https://github.com/ericentin)) [#225](https://github.com/elixir-lsp/elixir-ls/pull/225) +- Change "did not receive workspace/didChangeConfiguration" log level from warning to info (thanks [Jason Axelson](https://github.com/axelson)) [#222](https://github.com/elixir-lsp/elixir-ls/pull/222) +- Automatically create a `.gitignore` file inside the `.elixir-ls` dir so that users do not need to manually add it to their gitignore (thanks [Thanabodee Charoenpiriyakij](https://github.com/wingyplus)) [#232](https://github.com/elixir-lsp/elixir-ls/pull/232) Bug Fixes: - Dialyzer: Get beam file for preloaded modules. (thanks [Łukasz Samson](https://github.com/lukaszsamson)) [#218](https://github.com/elixir-lsp/elixir-ls/pull/218) - Warn when using the debugger on Elixir 1.10.0-1.10.2. (thanks [Jason Axelson](https://github.com/axelson)) [#221](https://github.com/elixir-lsp/elixir-ls/pull/221) +- Don't return snippets to clients that don't declare `snippetSupport` for function completions (thanks [Jeffrey Xiao](https://github.com/jeffrey-xiao)) [#223](https://github.com/elixir-lsp/elixir-ls/pull/223) VSCode: - Add basic support for `.html.leex` files for Phoenix LiveView (thanks [oskarkook](https://github.com/oskarkook)) [#82](https://github.com/elixir-lsp/vscode-elixir-ls/pull/82) +- Add filetype and watcher for `.html.leex` files for Phoenix LiveView (thanks [Byron Hambly](https://github.com/delta1)) [#83](https://github.com/elixir-lsp/vscode-elixir-ls/pull/83) + +VSCode potentially breaking changes: +- Change language id to be lowercase kebab-case in accordance with [VSCode guidelines](https://code.visualstudio.com/docs/languages/identifiers#_new-identifier-guidelines). This also fixes an issue displaying the elixir logo for html.eex files. (thanks [Matt Furden](https://github.com/zolrath)) [#87](https://github.com/elixir-lsp/vscode-elixir-ls/pull/87) + - This changes the language id's `EEx`->`eex` and `HTML (EEx)`->`html-eex` + - If you have customized your emmet configuration configuration then you need to update it: + - Open VSCode and hit `Ctrl+Shift+P` or `Cmd+Shift+P` and type `"Preference: Open Settings (JSON)"` + - Add or edit your `emmet.includedLanguages` to include the new Language Id: +```json +"emmet.includeLanguages": { + "html-eex": "html" +} +``` ### v0.3.3: 15 Apr 2020 diff --git a/README.md b/README.md index 2f0b3b52..979b770c 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ You may want to install Elixir and Erlang from source, using the [kiex](https:// | Neovim | [coc.nvim](https://github.com/neoclide/coc.nvim) | Does not support debugger | | Emacs | [lsp-mode](https://github.com/emacs-lsp/lsp-mode) | Supports debugger via [dap-mode](https://github.com/yyoncho/dap-mode) | | Emacs | [eglot](https://github.com/joaotavora/eglot) | | +| Kate | [built-in LSP Client plugin](https://kate-editor.org/post/2020/2020-01-01-kate-lsp-client-status/) | Does not support debugger | Feel free to create and publish your own client packages and add them to this list! diff --git a/apps/elixir_ls_utils/mix.exs b/apps/elixir_ls_utils/mix.exs index 45d4d7c0..79d14413 100644 --- a/apps/elixir_ls_utils/mix.exs +++ b/apps/elixir_ls_utils/mix.exs @@ -26,7 +26,7 @@ defmodule ElixirLS.Utils.Mixfile do defp deps do [ - {:jason, "~> 1.1"}, + {:jason, "~> 1.2"}, {:mix_task_archive_deps, github: "JakeBecker/mix_task_archive_deps"} ] end diff --git a/apps/language_server/lib/language_server/build.ex b/apps/language_server/lib/language_server/build.ex index ae9ebdf3..d7c2dfc2 100644 --- a/apps/language_server/lib/language_server/build.ex +++ b/apps/language_server/lib/language_server/build.ex @@ -1,5 +1,5 @@ defmodule ElixirLS.LanguageServer.Build do - alias ElixirLS.LanguageServer.{Server, JsonRpc, SourceFile} + alias ElixirLS.LanguageServer.{Server, JsonRpc, SourceFile, Diagnostics} def build(parent, root_path, opts) do if Path.absname(File.cwd!()) != Path.absname(root_path) do @@ -13,8 +13,7 @@ defmodule ElixirLS.LanguageServer.Build do IO.puts("Compiling with Mix env #{Mix.env()}") prev_deps = cached_deps() - # FIXME: Private API - Mix.Dep.clear_cached() + :ok = Mix.Project.clear_deps_cache() case reload_project() do {:ok, mixfile_diagnostics} -> @@ -29,6 +28,7 @@ defmodule ElixirLS.LanguageServer.Build do load_all_modules() end + diagnostics = Diagnostics.normalize(diagnostics, root_path) Server.build_finished(parent, {status, mixfile_diagnostics ++ diagnostics}) {:error, mixfile_diagnostics} -> diff --git a/apps/language_server/lib/language_server/diagnostics.ex b/apps/language_server/lib/language_server/diagnostics.ex new file mode 100644 index 00000000..4a10c3be --- /dev/null +++ b/apps/language_server/lib/language_server/diagnostics.ex @@ -0,0 +1,151 @@ +defmodule ElixirLS.LanguageServer.Diagnostics do + def normalize(diagnostics, root_path) do + for diagnostic <- diagnostics do + {type, file, line, description, stacktrace} = + extract_message_info(diagnostic.message, root_path) + + diagnostic + |> update_message(type, description, stacktrace) + |> maybe_update_file(file) + |> maybe_update_position(line, stacktrace) + end + end + + defp extract_message_info(list, root_path) when is_list(list) do + list + |> Enum.join() + |> extract_message_info(root_path) + end + + defp extract_message_info(diagnostic_message, root_path) do + {reversed_stacktrace, reversed_description} = + diagnostic_message + |> String.trim_trailing() + |> String.split("\n") + |> Enum.reverse() + |> Enum.split_while(&is_stack?/1) + + message = reversed_description |> Enum.reverse() |> Enum.join("\n") |> String.trim() + stacktrace = reversed_stacktrace |> Enum.map(&String.trim/1) |> Enum.reverse() + + {type, message_without_type} = split_type_and_message(message) + {file, line, description} = split_file_and_description(message_without_type, root_path) + + {type, file, line, description, stacktrace} + end + + defp update_message(diagnostic, type, description, stacktrace) do + description = + if type do + "(#{type}) #{description}" + else + description + end + + message = + if stacktrace != [] do + stacktrace = + stacktrace + |> Enum.map(&" │ #{&1}") + |> Enum.join("\n") + |> String.trim_trailing() + + description <> "\n\n" <> "Stacktrace:\n" <> stacktrace + else + description + end + + Map.put(diagnostic, :message, message) + end + + defp maybe_update_file(diagnostic, path) do + if path do + Map.put(diagnostic, :file, path) + else + diagnostic + end + end + + defp maybe_update_position(diagnostic, line, stacktrace) do + cond do + line -> + %{diagnostic | position: line} + + diagnostic.position -> + diagnostic + + true -> + line = extract_line_from_stacktrace(diagnostic.file, stacktrace) + %{diagnostic | position: line} + end + end + + defp split_type_and_message(message) do + case Regex.run(~r/^\*\* \(([\w\.]+?)?\) (.*)/s, message) do + [_, type, rest] -> + {type, rest} + + _ -> + {nil, message} + end + end + + defp split_file_and_description(message, root_path) do + with [_, file, line, description] <- Regex.run(~r/^(.*?):(\d+): (.*)/s, message), + {:ok, path} <- file_path(file, root_path) do + {path, String.to_integer(line), description} + else + _ -> + {nil, nil, message} + end + end + + defp file_path(nil, _root_path) do + {:error, :file_not_found} + end + + defp file_path(file, root_path) do + path = Path.join([root_path, file]) + + if File.exists?(path) do + {:ok, path} + else + file_path_in_umbrella(file, root_path) + end + end + + defp file_path_in_umbrella(file, root_path) do + case [root_path, "apps", "*", file] |> Path.join() |> Path.wildcard() do + [] -> + {:error, :file_not_found} + + [path] -> + {:ok, path} + + _ -> + {:error, :more_than_one_file_found} + end + end + + defp is_stack?(" " <> str) do + Regex.match?(~r/.*\.(ex|erl):\d+: /, str) || + Regex.match?(~r/.*expanding macro: /, str) + end + + defp is_stack?(_) do + false + end + + defp extract_line_from_stacktrace(file, stacktrace) do + Enum.find_value(stacktrace, fn stack_item -> + with [_, _, file_relative, line] <- + Regex.run(~r/(\(.+?\)\s+)?(.*\.ex):(\d+): /, stack_item), + true <- String.ends_with?(file, file_relative) do + String.to_integer(line) + else + _ -> + nil + end + end) + end +end diff --git a/apps/language_server/lib/language_server/dialyzer.ex b/apps/language_server/lib/language_server/dialyzer.ex index a70b3fb2..efa31124 100644 --- a/apps/language_server/lib/language_server/dialyzer.ex +++ b/apps/language_server/lib/language_server/dialyzer.ex @@ -374,7 +374,7 @@ defmodule ElixirLS.LanguageServer.Dialyzer do {active_plt, new_mod_deps, raw_warnings} = Analyzer.analyze(active_plt, files_to_analyze) - mod_deps = Map.merge(mod_deps, new_mod_deps) + mod_deps = update_mod_deps(mod_deps, new_mod_deps, removed_modules) warnings = add_warnings(warnings, raw_warnings) md5 = @@ -382,6 +382,8 @@ defmodule ElixirLS.LanguageServer.Dialyzer do {file, hash} end + md5 = remove_files(md5, removed_files) + {active_plt, mod_deps, md5, warnings} end) @@ -393,6 +395,17 @@ defmodule ElixirLS.LanguageServer.Dialyzer do analysis_finished(parent, :ok, active_plt, mod_deps, md5, warnings, timestamp, build_ref) end + defp update_mod_deps(mod_deps, new_mod_deps, removed_modules) do + mod_deps + |> Map.merge(new_mod_deps) + |> Map.drop(removed_modules) + |> Map.new(fn {mod, deps} -> {mod, deps -- removed_modules} end) + end + + defp remove_files(md5, removed_files) do + Map.drop(md5, removed_files) + end + defp add_warnings(warnings, raw_warnings) do new_warnings = for {_, {file, line, m_or_mfa}, _} = warning <- raw_warnings, diff --git a/apps/language_server/lib/language_server/dialyzer/manifest.ex b/apps/language_server/lib/language_server/dialyzer/manifest.ex index ade00796..e4c7daab 100644 --- a/apps/language_server/lib/language_server/dialyzer/manifest.ex +++ b/apps/language_server/lib/language_server/dialyzer/manifest.ex @@ -114,6 +114,8 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Manifest do Path.join([Mix.Utils.mix_home(), "elixir-ls-#{otp_vsn()}_elixir-#{System.version()}"]) end + @elixir_apps [:elixir, :eex, :ex_unit, :iex, :logger, :mix] + defp build_elixir_plt() do JsonRpc.show_message( :info, @@ -121,12 +123,14 @@ defmodule ElixirLS.LanguageServer.Dialyzer.Manifest do ) files = - Path.join([Application.app_dir(:elixir), "**/*.beam"]) - |> Path.wildcard() - |> Enum.map(&pathname_to_module/1) - |> expand_references() - |> Enum.map(&Utils.get_beam_file/1) - |> Enum.filter(&is_list/1) + Enum.flat_map(@elixir_apps, fn app -> + Path.join([Application.app_dir(app), "**/*.beam"]) + |> Path.wildcard() + |> Enum.map(&pathname_to_module/1) + |> expand_references() + |> Enum.map(&Utils.get_beam_file/1) + |> Enum.filter(&is_list/1) + end) File.mkdir_p!(Path.dirname(elixir_plt_path())) diff --git a/apps/language_server/lib/language_server/providers/completion.ex b/apps/language_server/lib/language_server/providers/completion.ex index df0a1fc3..e2bdaf17 100644 --- a/apps/language_server/lib/language_server/providers/completion.ex +++ b/apps/language_server/lib/language_server/providers/completion.ex @@ -135,7 +135,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do items = ElixirSense.suggestions(text, line + 1, character + 1) - |> Enum.map(&from_completion_item(&1, context)) + |> Enum.map(&from_completion_item(&1, context, options)) |> Enum.concat(module_attr_snippets(context)) |> Enum.concat(keyword_completions(context)) @@ -151,12 +151,16 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do ## Helpers - defp from_completion_item(%{type: :attribute, name: name}, %{ - prefix: prefix, - def_before: nil, - capture_before?: false, - pipe_before?: false - }) do + defp from_completion_item( + %{type: :attribute, name: name}, + %{ + prefix: prefix, + def_before: nil, + capture_before?: false, + pipe_before?: false + }, + _options + ) do name_only = String.trim_leading(name, "@") insert_text = if String.starts_with?(prefix, "@"), do: name_only, else: name @@ -175,11 +179,15 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end end - defp from_completion_item(%{type: :variable, name: name}, %{ - def_before: nil, - pipe_before?: false, - capture_before?: false - }) do + defp from_completion_item( + %{type: :variable, name: name}, + %{ + def_before: nil, + pipe_before?: false, + capture_before?: false + }, + _options + ) do %__MODULE__{ label: to_string(name), kind: :variable, @@ -192,7 +200,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do defp from_completion_item( %{type: :return, description: description, spec: spec, snippet: snippet}, - %{def_before: nil, capture_before?: false, pipe_before?: false} + %{def_before: nil, capture_before?: false, pipe_before?: false}, + _options ) do snippet = Regex.replace(Regex.recompile!(~r/"\$\{(.*)\}\$"/U), snippet, "${\\1}") @@ -212,7 +221,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do %{ def_before: nil, prefix: prefix - } + }, + _options ) do capitalized? = String.first(name) == String.upcase(String.first(name)) @@ -252,7 +262,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do origin: origin, metadata: metadata }, - context + context, + options ) do if (context[:def_before] == :def && String.starts_with?(spec, "@macrocallback")) || (context[:def_before] == :defmacro && String.starts_with?(spec, "@callback")) do @@ -267,7 +278,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end end - full_snippet = "#{def_str}#{snippet(name, args, arity)} do\n\t$0\nend" + insert_text = def_snippet(def_str, name, args, arity, options) label = "#{def_str}#{function_label(name, args, arity)}" %__MODULE__{ @@ -275,7 +286,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do kind: :interface, detail: "#{origin} callback", documentation: summary, - insert_text: full_snippet, + insert_text: insert_text, priority: 2, filter_text: name, tags: metadata_to_tags(metadata) @@ -294,11 +305,12 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do origin: origin, metadata: metadata }, - context + context, + options ) do def_str = if(context[:def_before] == nil, do: "def ") - full_snippet = "#{def_str}#{snippet(name, args, arity)} do\n\t$0\nend" + insert_text = def_snippet(def_str, name, args, arity, options) label = "#{def_str}#{function_label(name, args, arity)}" %__MODULE__{ @@ -306,7 +318,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do kind: :interface, detail: "#{origin} protocol function", documentation: summary, - insert_text: full_snippet, + insert_text: insert_text, priority: 2, filter_text: name, tags: metadata_to_tags(metadata) @@ -315,7 +327,8 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do defp from_completion_item( %{type: :field, subtype: subtype, name: name, origin: origin, call?: call?}, - _context + _context, + _options ) do detail = case {subtype, origin} do @@ -334,7 +347,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do } end - defp from_completion_item(%{type: :param_option} = suggestion, _context) do + defp from_completion_item(%{type: :param_option} = suggestion, _context, _options) do %{name: name, origin: _origin, doc: doc, type_spec: type_spec, expanded_spec: expanded_spec} = suggestion @@ -356,7 +369,11 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do } end - defp from_completion_item(%{type: :type_spec, metadata: metadata} = suggestion, _context) do + defp from_completion_item( + %{type: :type_spec, metadata: metadata} = suggestion, + _context, + _options + ) do %{name: name, arity: arity, origin: _origin, doc: doc, signature: signature, spec: spec} = suggestion @@ -387,9 +404,10 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do defp from_completion_item( %{name: name, origin: origin} = item, - %{def_before: nil} = context + %{def_before: nil} = context, + options ) do - completion = function_completion(item, context) + completion = function_completion(item, context, options) completion = if origin == "Kernel" || origin == "Kernel.SpecialForms" do @@ -405,7 +423,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end end - defp from_completion_item(_suggestion, _context) do + defp from_completion_item(_suggestion, _context, _options) do nil end @@ -417,30 +435,43 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end end - defp snippet(name, args, arity, opts \\ []) do - if Keyword.get(opts, :capture_before?) && arity <= 1 do - Enum.join([name, "/", arity]) + defp def_snippet(def_str, name, args, arity, opts) do + if Keyword.get(opts, :snippets_supported, false) do + "#{def_str}#{function_snippet(name, args, arity)} do\n\t$0\nend" else - args_list = - if args && args != "" do - split_args(args) - else - for i <- Enum.slice(0..arity, 1..-1), do: "arg#{i}" - end + "#{def_str}#{name}" + end + end - args_list = - if Keyword.get(opts, :pipe_before?) do - Enum.slice(args_list, 1..-1) - else - args_list - end + defp function_snippet(name, args, arity, opts \\ []) do + cond do + Keyword.get(opts, :capture_before?) && arity <= 1 -> + Enum.join([name, "/", arity]) - tabstops = - args_list - |> Enum.with_index() - |> Enum.map(fn {arg, i} -> "${#{i + 1}:#{arg}}" end) + not Keyword.get(opts, :snippets_supported, false) -> + name - Enum.join([name, "(", Enum.join(tabstops, ", "), ")"]) + true -> + args_list = + if args && args != "" do + split_args(args) + else + for i <- Enum.slice(0..arity, 1..-1), do: "arg#{i}" + end + + args_list = + if Keyword.get(opts, :pipe_before?) do + Enum.slice(args_list, 1..-1) + else + args_list + end + + tabstops = + args_list + |> Enum.with_index() + |> Enum.map(fn {arg, i} -> "${#{i + 1}:#{arg}}" end) + + Enum.join([name, "(", Enum.join(tabstops, ", "), ")"]) end end @@ -534,7 +565,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end) end - defp function_completion(info, context) do + defp function_completion(info, context, options) do %{ type: type, args: args, @@ -555,7 +586,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do text_after_cursor: text_after_cursor } = context - {label, snippet} = + {label, insert_text} = cond do match?("sigil_" <> _, name) -> "sigil_" <> sigil_name = name @@ -568,16 +599,19 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do true -> label = function_label(name, args, arity) - snippet = - snippet( + insert_text = + function_snippet( name, args, arity, - pipe_before?: pipe_before?, - capture_before?: capture_before? + Keyword.merge( + options, + pipe_before?: pipe_before?, + capture_before?: capture_before? + ) ) - {label, snippet} + {label, insert_text} end detail = @@ -597,7 +631,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do kind: :function, detail: detail, documentation: summary, - insert_text: snippet, + insert_text: insert_text, priority: 7, tags: metadata_to_tags(metadata) } @@ -670,7 +704,7 @@ defmodule ElixirLS.LanguageServer.Providers.Completion do end defp snippet?(item) do - item.kind == :snippet || String.match?(item.insert_text, ~r/\$\d/) + item.kind == :snippet || String.match?(item.insert_text, ~r/\${?\d/) end # As defined by CompletionItemTag in https://microsoft.github.io/language-server-protocol/specifications/specification-current/ diff --git a/apps/language_server/lib/language_server/providers/formatting.ex b/apps/language_server/lib/language_server/providers/formatting.ex index 4534cf59..43dfa9c7 100644 --- a/apps/language_server/lib/language_server/providers/formatting.ex +++ b/apps/language_server/lib/language_server/providers/formatting.ex @@ -79,7 +79,9 @@ defmodule ElixirLS.LanguageServer.Providers.Formatting do if char == "\n" do {line + 1, 0} else - {line, col + 1} + # LSP contentChanges positions are based on UTF-16 string representation + # https://microsoft.github.io/language-server-protocol/specification#textDocuments + {line, col + byte_size(:unicode.characters_to_binary(char, :utf8, :utf16)) / 2} end end) end diff --git a/apps/language_server/lib/language_server/server.ex b/apps/language_server/lib/language_server/server.ex index 593b8818..734ada13 100644 --- a/apps/language_server/lib/language_server/server.ex +++ b/apps/language_server/lib/language_server/server.ex @@ -144,7 +144,9 @@ defmodule ElixirLS.LanguageServer.Server do JsonRpc.register_capability_request("workspace/didChangeWatchedFiles", %{ "watchers" => [ %{"globPattern" => "**/*.ex"}, - %{"globPattern" => "**/*.exs"} + %{"globPattern" => "**/*.exs"}, + %{"globPattern" => "**/*.eex"}, + %{"globPattern" => "**/*.leex"} ] }) @@ -157,7 +159,7 @@ defmodule ElixirLS.LanguageServer.Server do case state do %{settings: nil} -> JsonRpc.show_message( - :warning, + :info, "Did not receive workspace/didChangeConfiguration notification after 5 seconds. " <> "Using default settings." ) @@ -730,6 +732,7 @@ defmodule ElixirLS.LanguageServer.Server do |> set_project_dir(project_dir) |> set_dialyzer_enabled(enable_dialyzer) + state = create_gitignore(state) trigger_build(%{state | settings: settings}) end @@ -796,4 +799,24 @@ defmodule ElixirLS.LanguageServer.Server do defp set_project_dir(state, _) do state end + + defp create_gitignore(%{project_dir: project_dir} = state) do + with gitignore_path <- Path.join([project_dir, ".elixir_ls", ".gitignore"]), + false <- File.exists?(gitignore_path), + :ok <- gitignore_path |> Path.dirname() |> File.mkdir_p(), + :ok <- File.write(gitignore_path, "*", [:write]) do + state + else + true -> + state + + {:error, err} -> + JsonRpc.log_message( + :warning, + "Cannot create .elixir_ls/.gitignore, cause: #{Atom.to_string(err)}" + ) + + state + end + end end diff --git a/apps/language_server/lib/language_server/source_file.ex b/apps/language_server/lib/language_server/source_file.ex index da2162f4..ace92dea 100644 --- a/apps/language_server/lib/language_server/source_file.ex +++ b/apps/language_server/lib/language_server/source_file.ex @@ -83,7 +83,14 @@ defmodule ElixirLS.LanguageServer.SourceFile do [[line, ?\n] | acc] idx == start_line -> - [String.slice(line, 0, start_character) | acc] + # LSP contentChanges positions are based on UTF-16 string representation + # https://microsoft.github.io/language-server-protocol/specification#textDocuments + beginning_utf8 = + :unicode.characters_to_binary(line, :utf8, :utf16) + |> binary_part(0, start_character * 2) + |> :unicode.characters_to_binary(:utf16, :utf8) + + [beginning_utf8 | acc] idx > start_line -> acc @@ -99,7 +106,18 @@ defmodule ElixirLS.LanguageServer.SourceFile do acc idx == end_line -> - [[String.slice(line, end_character..-1), ?\n] | acc] + # LSP contentChanges positions are based on UTF-16 string representation + # https://microsoft.github.io/language-server-protocol/specification#textDocuments + ending_utf8 = + :unicode.characters_to_binary(line, :utf8, :utf16) + |> (&binary_part( + &1, + end_character * 2, + byte_size(&1) - end_character * 2 + )).() + |> :unicode.characters_to_binary(:utf16, :utf8) + + [[ending_utf8, ?\n] | acc] idx > end_line -> [[line, ?\n] | acc] diff --git a/apps/language_server/test/diagnostics_test.exs b/apps/language_server/test/diagnostics_test.exs new file mode 100644 index 00000000..d8aac1c2 --- /dev/null +++ b/apps/language_server/test/diagnostics_test.exs @@ -0,0 +1,147 @@ +defmodule ElixirLS.LanguageServer.DiagnosticsTest do + alias ElixirLS.LanguageServer.Diagnostics + use ExUnit.Case + + describe "normalize/2" do + test "extract the stacktrace from the message and format it" do + root_path = Path.join(__DIR__, "fixtures/build_errors") + file = Path.join(root_path, "lib/has_error.ex") + position = 2 + + message = """ + ** (CompileError) some message + + Hint: Some hint + (elixir 1.10.1) lib/macro.ex:304: Macro.pipe/3 + (stdlib 3.7.1) lists.erl:1263: :lists.foldl/3 + (elixir 1.10.1) expanding macro: Kernel.|>/2 + expanding macro: SomeModule.sigil_L/2 + lib/my_app/my_module.ex:10: MyApp.MyModule.render/1 + """ + + [diagnostic | _] = + [build_diagnostic(message, file, position)] + |> Diagnostics.normalize(root_path) + + assert diagnostic.message == """ + (CompileError) some message + + Hint: Some hint + + Stacktrace: + │ (elixir 1.10.1) lib/macro.ex:304: Macro.pipe/3 + │ (stdlib 3.7.1) lists.erl:1263: :lists.foldl/3 + │ (elixir 1.10.1) expanding macro: Kernel.|>/2 + │ expanding macro: SomeModule.sigil_L/2 + │ lib/my_app/my_module.ex:10: MyApp.MyModule.render/1\ + """ + end + + test "update file and position if file is present in the message" do + root_path = Path.join(__DIR__, "fixtures/build_errors") + file = Path.join(root_path, "lib/has_error.ex") + position = 2 + + message = """ + ** (CompileError) lib/has_error.ex:3: some message + lib/my_app/my_module.ex:10: MyApp.MyModule.render/1 + """ + + [diagnostic | _] = + [build_diagnostic(message, file, position)] + |> Diagnostics.normalize(root_path) + + assert diagnostic.message == """ + (CompileError) some message + + Stacktrace: + │ lib/my_app/my_module.ex:10: MyApp.MyModule.render/1\ + """ + + assert diagnostic.position == 3 + end + + test "update file and position if file is present in the message (umbrella)" do + root_path = Path.join(__DIR__, "fixtures/umbrella") + file = Path.join(root_path, "lib/file_to_be_replaced.ex") + position = 3 + + message = """ + ** (CompileError) lib/app2.ex:5: some message + (elixir 1.10.1) lib/macro.ex:304: Macro.pipe/3 + lib/my_app/my_module.ex:10: MyApp.MyModule.render/1 + """ + + [diagnostic | _] = + [build_diagnostic(message, file, position)] + |> Diagnostics.normalize(root_path) + + assert diagnostic.message =~ "(CompileError) some message" + assert diagnostic.file =~ "umbrella/apps/app2/lib/app2.ex" + assert diagnostic.position == 5 + end + + test "don't update file nor position if file in message does not exist" do + root_path = Path.join(__DIR__, "fixtures/build_errors_on_external_resource") + file = Path.join(root_path, "lib/has_error.ex") + position = 2 + + message = """ + ** (CompileError) lib/non_existing.ex:3: some message + lib/my_app/my_module.ex:10: MyApp.MyModule.render/1 + """ + + [diagnostic | _] = + [build_diagnostic(message, file, position)] + |> Diagnostics.normalize(root_path) + + assert diagnostic.message == """ + (CompileError) lib/non_existing.ex:3: some message + + Stacktrace: + │ lib/my_app/my_module.ex:10: MyApp.MyModule.render/1\ + """ + + assert diagnostic.position == 2 + end + + test "if position is nil, try to retrieve it info from the stacktrace" do + root_path = Path.join(__DIR__, "fixtures/build_errors") + file = Path.join(root_path, "lib/demo_web/router.ex") + position = nil + + message = """ + ** (FunctionClauseError) no function clause matching in Phoenix.Router.Scope.pipeline/2 + + The following arguments were given to Phoenix.Router.Scope.pipeline/2: + + # 1 + DemoWeb.Router + + # 2 + "api" + + (phoenix 1.5.1) lib/phoenix/router/scope.ex:66: Phoenix.Router.Scope.pipeline/2 + lib/demo_web/router.ex:13: (module) + (stdlib 3.7.1) erl_eval.erl:680: :erl_eval.do_apply/6 + """ + + [diagnostic | _] = + [build_diagnostic(message, file, position)] + |> Diagnostics.normalize(root_path) + + assert diagnostic.position == 13 + end + + defp build_diagnostic(message, file, position) do + %Mix.Task.Compiler.Diagnostic{ + compiler_name: "Elixir", + details: nil, + file: file, + message: message, + position: position, + severity: :error + } + end + end +end diff --git a/apps/language_server/test/fixtures/build_errors_on_external_resource/lib/has_error.ex b/apps/language_server/test/fixtures/build_errors_on_external_resource/lib/has_error.ex new file mode 100644 index 00000000..fbd93fc9 --- /dev/null +++ b/apps/language_server/test/fixtures/build_errors_on_external_resource/lib/has_error.ex @@ -0,0 +1,3 @@ +defmodule ElixirLS.LanguageServer.Fixtures.BuildErrorsOnExternalResource.HasError do + EEx.compile_file("lib/template.eex", line: 1) +end diff --git a/apps/language_server/test/fixtures/build_errors_on_external_resource/lib/template.eex b/apps/language_server/test/fixtures/build_errors_on_external_resource/lib/template.eex new file mode 100644 index 00000000..e1b53702 --- /dev/null +++ b/apps/language_server/test/fixtures/build_errors_on_external_resource/lib/template.eex @@ -0,0 +1,3 @@ +line 1 +<%= , %> +line 3 \ No newline at end of file diff --git a/apps/language_server/test/fixtures/build_errors_on_external_resource/mix.exs b/apps/language_server/test/fixtures/build_errors_on_external_resource/mix.exs new file mode 100644 index 00000000..4e4d39da --- /dev/null +++ b/apps/language_server/test/fixtures/build_errors_on_external_resource/mix.exs @@ -0,0 +1,11 @@ +defmodule ElixirLS.LanguageServer.Fixtures.BuildErrorsOnExternalResource.Mixfile do + use Mix.Project + + def project do + [app: :els_build_errors_test, version: "0.1.0"] + end + + def application do + [] + end +end diff --git a/apps/language_server/test/providers/completion_test.exs b/apps/language_server/test/providers/completion_test.exs index 95c8e515..d5172b05 100644 --- a/apps/language_server/test/providers/completion_test.exs +++ b/apps/language_server/test/providers/completion_test.exs @@ -87,6 +87,35 @@ defmodule ElixirLS.LanguageServer.Providers.CompletionTest do assert length(items) == 0 end + test "function with snippets not supported does not have argument placeholders" do + text = """ + defmodule MyModule do + def add(a, b), do: a + b + + def dummy_function() do + ad + # ^ + end + end + """ + + {line, char} = {4, 6} + TestUtils.assert_has_cursor_char(text, line, char) + + {:ok, %{"items" => [item]}} = Completion.completion(text, line, char, @supports) + assert item["insertText"] == "add(${1:a}, ${2:b})" + + {:ok, %{"items" => [item]}} = + Completion.completion( + text, + line, + char, + @supports |> Keyword.put(:snippets_supported, false) + ) + + assert item["insertText"] == "add" + end + test "provides completions for protocol functions" do text = """ defimpl ElixirLS.LanguageServer.Fixtures.ExampleProtocol, for: MyModule do diff --git a/apps/language_server/test/providers/formatting_test.exs b/apps/language_server/test/providers/formatting_test.exs index 956efaf1..2e10450b 100644 --- a/apps/language_server/test/providers/formatting_test.exs +++ b/apps/language_server/test/providers/formatting_test.exs @@ -67,4 +67,109 @@ defmodule ElixirLS.LanguageServer.Providers.FormattingTest do assert {:error, :internal_error, msg} = Formatting.format(source_file, uri, project_dir) assert String.contains?(msg, "Unable to format") end + + test "Proper utf-16 format: emoji 😀" do + uri = "file://project/file.ex" + + text = """ + IO.puts "😀" + """ + + source_file = %ElixirLS.LanguageServer.SourceFile{ + text: text, + version: 1, + dirty?: true + } + + project_dir = "/project" + + assert {:ok, changes} = Formatting.format(source_file, uri, project_dir) + + assert changes == [ + %{ + "newText" => ")", + "range" => %{ + "end" => %{"character" => 12, "line" => 0}, + "start" => %{"character" => 12, "line" => 0} + } + }, + %{ + "newText" => "(", + "range" => %{ + "end" => %{"character" => 8, "line" => 0}, + "start" => %{"character" => 7, "line" => 0} + } + } + ] + end + + test "Proper utf-16 format: emoji 🏳️‍🌈" do + uri = "file://project/file.ex" + + text = """ + IO.puts "🏳️‍🌈" + """ + + source_file = %ElixirLS.LanguageServer.SourceFile{ + text: text, + version: 1, + dirty?: true + } + + project_dir = "/project" + + assert {:ok, changes} = Formatting.format(source_file, uri, project_dir) + + assert changes == [ + %{ + "newText" => ")", + "range" => %{ + "end" => %{"character" => 16, "line" => 0}, + "start" => %{"character" => 16, "line" => 0} + } + }, + %{ + "newText" => "(", + "range" => %{ + "end" => %{"character" => 8, "line" => 0}, + "start" => %{"character" => 7, "line" => 0} + } + } + ] + end + + test "Proper utf-16 format: zalgo" do + uri = "file://project/file.ex" + + text = """ + IO.puts "ẕ̸͇̞̲͇͕̹̙̄͆̇͂̏̊͒̒̈́́̕͘͠͝à̵̢̛̟̞͚̟͖̻̹̮̘͚̻͍̇͂̂̅́̎̉͗́́̃̒l̴̻̳͉̖̗͖̰̠̗̃̈́̓̓̍̅͝͝͝g̷̢͚̠̜̿̊́̋͗̔ȍ̶̹̙̅̽̌̒͌͋̓̈́͑̏͑͊͛͘ ̸̨͙̦̫̪͓̠̺̫̖͙̫̏͂̒̽́̿̂̊́͂͋͜͠͝͝ṭ̴̜͎̮͉̙͍͔̜̾͋͒̓̏̉̄͘͠͝ͅę̷̡̭̹̰̺̩̠͓͌̃̕͜͝ͅͅx̵̧͍̦͈͍̝͖͙̘͎̥͕̾̾̍̀̿̔̄̑̈͝t̸̛͇̀̕" + """ + + source_file = %ElixirLS.LanguageServer.SourceFile{ + text: text, + version: 1, + dirty?: true + } + + project_dir = "/project" + + assert {:ok, changes} = Formatting.format(source_file, uri, project_dir) + + assert changes == [ + %{ + "newText" => ")", + "range" => %{ + "end" => %{"character" => 213, "line" => 0}, + "start" => %{"character" => 213, "line" => 0} + } + }, + %{ + "newText" => "(", + "range" => %{ + "end" => %{"character" => 8, "line" => 0}, + "start" => %{"character" => 7, "line" => 0} + } + } + ] + end end diff --git a/apps/language_server/test/server_test.exs b/apps/language_server/test/server_test.exs index b91f96fe..719c78d5 100644 --- a/apps/language_server/test/server_test.exs +++ b/apps/language_server/test/server_test.exs @@ -259,7 +259,7 @@ defmodule ElixirLS.LanguageServer.ServerTest do "diagnostics" => [ %{ "message" => - "** (CompileError) lib/has_error.ex:4: undefined function does_not_exist" <> + "(CompileError) undefined function does_not_exist" <> _, "range" => %{"end" => %{"line" => 3}, "start" => %{"line" => 3}}, "severity" => 1 @@ -269,6 +269,27 @@ defmodule ElixirLS.LanguageServer.ServerTest do end) end + test "reports build diagnostics on external resources", %{server: server} do + in_fixture(__DIR__, "build_errors_on_external_resource", fn -> + error_file = SourceFile.path_to_uri("lib/template.eex") + + initialize(server) + + assert_receive notification("textDocument/publishDiagnostics", %{ + "uri" => ^error_file, + "diagnostics" => [ + %{ + "message" => + "(SyntaxError) syntax error before: ','" <> + _, + "range" => %{"end" => %{"line" => 1}, "start" => %{"line" => 1}}, + "severity" => 1 + } + ] + }) + end) + end + test "reports error if no mixfile", %{server: server} do in_fixture(__DIR__, "no_mixfile", fn -> mixfile_uri = SourceFile.path_to_uri("mix.exs") diff --git a/mix.lock b/mix.lock index 9af1d9e4..1745dd85 100644 --- a/mix.lock +++ b/mix.lock @@ -6,7 +6,7 @@ "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "forms": {:hex, :forms, "0.0.1", "45f3b10b6f859f95f2c2c1a1de244d63855296d55ed8e93eb0dd116b3e86c4a6", [:rebar3], [], "hexpm", "530f63ed8ed5a171f744fc75bd69cb2e36496899d19dbef48101b4636b795868"}, "getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, "mix_task_archive_deps": {:git, "https://github.com/JakeBecker/mix_task_archive_deps.git", "50301a4314e3cc1104f77a8208d5b66ee382970b", []}, "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, }