From 47d29cb389dcdaf6e1755ef7738b55a3266d6df4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Fri, 3 Dec 2021 21:47:20 +0100 Subject: [PATCH] Require Elixir 1.13 (#737) * Bump required Elixir version to 1.13 and fix TODOs * Fix tests * Remove the deprecated URI.parse/1 * Bump Docker base image * Bump Elixir on CI --- .github/workflows/deploy.yaml | 2 +- .github/workflows/test.yaml | 2 +- Dockerfile | 4 +- lib/livebook/content_loader.ex | 24 +- lib/livebook/file_system/s3.ex | 4 +- .../intellisense/identifier_matcher.ex | 738 +----------------- lib/livebook/utils.ex | 8 +- lib/livebook_cli/server.ex | 5 +- mix.exs | 9 +- test/livebook/evaluator_test.exs | 12 +- test/livebook/intellisense_test.exs | 110 ++- test/livebook_web/live/session_live_test.exs | 10 +- 12 files changed, 91 insertions(+), 837 deletions(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 807702a3b..8f21d27ee 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -16,7 +16,7 @@ jobs: uses: erlef/setup-beam@v1 with: otp-version: '24.0' - elixir-version: '1.12.3' + elixir-version: '1.13.0' - name: Cache Mix uses: actions/cache@v2 with: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c2b803c78..f75a707eb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -15,7 +15,7 @@ jobs: uses: erlef/setup-beam@v1 with: otp-version: '24.0' - elixir-version: '1.12.3' + elixir-version: '1.13.0' - name: Cache Mix uses: actions/cache@v2 with: diff --git a/Dockerfile b/Dockerfile index 84809ce73..2fa72d874 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # Stage 1 # Builds the Livebook release -FROM hexpm/elixir:1.12.3-erlang-24.0.6-debian-bullseye-20210902-slim AS build +FROM hexpm/elixir:1.13.0-erlang-24.1.7-debian-bullseye-20210902-slim AS build RUN apt-get update && apt-get upgrade -y && \ apt-get install --no-install-recommends -y \ @@ -37,7 +37,7 @@ RUN mix do compile, release # We use the same base image, because we need Erlang, Elixir and Mix # during runtime to spawn the Livebook standalone runtimes. # Consequently the release doesn't include ERTS as we have it anyway. -FROM hexpm/elixir:1.12.3-erlang-24.0.6-debian-bullseye-20210902-slim +FROM hexpm/elixir:1.13.0-erlang-24.1.7-debian-bullseye-20210902-slim RUN apt-get update && apt-get upgrade -y && \ apt-get install --no-install-recommends -y \ diff --git a/lib/livebook/content_loader.ex b/lib/livebook/content_loader.ex index 5151e066c..993bc87ec 100644 --- a/lib/livebook/content_loader.ex +++ b/lib/livebook/content_loader.ex @@ -18,23 +18,17 @@ defmodule Livebook.ContentLoader do """ @spec rewrite_url(String.t()) :: String.t() def rewrite_url(url) do - url - |> URI.parse() - |> do_rewrite_url() - |> URI.to_string() + case URI.new(url) do + {:ok, uri} -> uri |> do_rewrite_url() |> URI.to_string() + {:error, _} -> url + end end defp do_rewrite_url(%URI{host: "github.com"} = uri) do case String.split(uri.path, "/") do ["", owner, repo, "blob", hash | file_path] -> path = Enum.join(["", owner, repo, hash | file_path], "/") - - %{ - uri - | path: path, - host: "raw.githubusercontent.com", - authority: "raw.githubusercontent.com" - } + %{uri | path: path, host: "raw.githubusercontent.com"} _ -> uri @@ -45,13 +39,7 @@ defmodule Livebook.ContentLoader do case String.split(uri.path, "/") do ["", owner, hash] -> path = Enum.join(["", owner, hash, "raw"], "/") - - %{ - uri - | path: path, - host: "gist.githubusercontent.com", - authority: "gist.githubusercontent.com" - } + %{uri | path: path, host: "gist.githubusercontent.com"} _ -> uri diff --git a/lib/livebook/file_system/s3.ex b/lib/livebook/file_system/s3.ex index 2c14f6b23..714b02c31 100644 --- a/lib/livebook/file_system/s3.ex +++ b/lib/livebook/file_system/s3.ex @@ -366,7 +366,7 @@ defimpl Livebook.FileSystem, for: Livebook.FileSystem.S3 do headers = opts[:headers] || [] body = opts[:body] - %{host: host} = URI.parse(file_system.bucket_url) + %{host: host} = URI.new!(file_system.bucket_url) url = file_system.bucket_url <> path <> "?" <> URI.encode_query(query) @@ -394,7 +394,7 @@ defimpl Livebook.FileSystem, for: Livebook.FileSystem.S3 do defp region_from_uri(uri) do # For many services the API host is of the form *.[region].[rootdomain].com - %{host: host} = URI.parse(uri) + %{host: host} = URI.new!(uri) host |> String.split(".") |> Enum.reverse() |> Enum.at(2, "auto") end diff --git a/lib/livebook/intellisense/identifier_matcher.ex b/lib/livebook/intellisense/identifier_matcher.ex index a59522baf..8b092fc60 100644 --- a/lib/livebook/intellisense/identifier_matcher.ex +++ b/lib/livebook/intellisense/identifier_matcher.ex @@ -45,7 +45,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do @spec completion_identifiers(String.t(), Code.binding(), Macro.Env.t()) :: list(identifier_item()) def completion_identifiers(hint, binding, env) do - context = cursor_context(hint) + context = Code.Fragment.cursor_context(hint) ctx = %{binding: binding, env: env, matcher: @prefix_matcher} context_to_matches(context, ctx, :completion) end @@ -63,7 +63,7 @@ defmodule Livebook.Intellisense.IdentifierMatcher do range: nil | %{from: pos_integer(), to: pos_integer()} } def locate_identifier(line, column, binding, env) do - case surround_context(line, {1, column}) do + case Code.Fragment.surround_context(line, {1, column}) do %{context: context, begin: {_, from}, end: {_, to}} -> ctx = %{binding: binding, env: env, matcher: @exact_matcher} matches = context_to_matches(context, ctx, :locate) @@ -514,738 +514,4 @@ defmodule Livebook.Intellisense.IdentifierMatcher do ctx.matcher.(name, hint), do: {:module_attribute, name, {"text/markdown", info.doc}} end - - # --- - # Source of Code.Fragment - - # TODO: no longer required on Elixir v1.13 - - # This module provides conveniences for analyzing fragments of - # textual code and extract available information whenever possible. - - # Most of the functions in this module provide a best-effort - # and may not be accurate under all circumstances. Read each - # documentation for more information. - - # This module should be considered experimental. - - @type position :: {line :: pos_integer(), column :: pos_integer()} - - @doc """ - Receives a string and returns the cursor context. - - This function receives a string with an Elixir code fragment, - representing a cursor position, and based on the string, it - provides contextual information about said position. The - return of this function can then be used to provide tips, - suggestions, and autocompletion functionality. - - This function provides a best-effort detection and may not be - accurate under all circumstances. See the "Limitations" - section below. - - Consider adding a catch-all clause when handling the return - type of this function as new cursor information may be added - in future releases. - - ## Examples - - iex> Code.Fragment.cursor_context("") - :expr - - iex> Code.Fragment.cursor_context("hello_wor") - {:local_or_var, 'hello_wor'} - - ## Return values - - * `{:alias, charlist}` - the context is an alias, potentially - a nested one, such as `Hello.Wor` or `HelloWor` - - * `{:dot, inside_dot, charlist}` - the context is a dot - where `inside_dot` is either a `{:var, charlist}`, `{:alias, charlist}`, - `{:module_attribute, charlist}`, `{:unquoted_atom, charlist}` or a `dot` - itself. If a var is given, this may either be a remote call or a map - field access. Examples are `Hello.wor`, `:hello.wor`, `hello.wor`, - `Hello.nested.wor`, `hello.nested.wor`, and `@hello.world` - - * `{:dot_arity, inside_dot, charlist}` - the context is a dot arity - where `inside_dot` is either a `{:var, charlist}`, `{:alias, charlist}`, - `{:module_attribute, charlist}`, `{:unquoted_atom, charlist}` or a `dot` - itself. If a var is given, it must be a remote arity. Examples are - `Hello.world/`, `:hello.world/`, `hello.world/2`, and `@hello.world/2` - - * `{:dot_call, inside_dot, charlist}` - the context is a dot - call. This means parentheses or space have been added after the expression. - where `inside_dot` is either a `{:var, charlist}`, `{:alias, charlist}`, - `{:module_attribute, charlist}`, `{:unquoted_atom, charlist}` or a `dot` - itself. If a var is given, it must be a remote call. Examples are - `Hello.world(`, `:hello.world(`, `Hello.world `, `hello.world(`, `hello.world `, - and `@hello.world(` - - * `:expr` - may be any expression. Autocompletion may suggest an alias, - local or var - - * `{:local_or_var, charlist}` - the context is a variable or a local - (import or local) call, such as `hello_wor` - - * `{:local_arity, charlist}` - the context is a local (import or local) - arity, such as `hello_world/` - - * `{:local_call, charlist}` - the context is a local (import or local) - call, such as `hello_world(` and `hello_world ` - - * `{:module_attribute, charlist}` - the context is a module attribute, - such as `@hello_wor` - - * `{:operator, charlist}` - the context is an operator, such as `+` or - `==`. Note textual operators, such as `when` do not appear as operators - but rather as `:local_or_var`. `@` is never an `:operator` and always a - `:module_attribute` - - * `{:operator_arity, charlist}` - the context is an operator arity, which - is an operator followed by /, such as `+/`, `not/` or `when/` - - * `{:operator_call, charlist}` - the context is an operator call, which is - an operator followed by space, such as `left + `, `not ` or `x when ` - - * `:none` - no context possible - - * `{:sigil, charlist}` - the context is a sigil. It may be either the beginning - of a sigil, such as `~` or `~s`, or an operator starting with `~`, such as - `~>` and `~>>` - - * `{:struct, charlist}` - the context is a struct, such as `%`, `%UR` or `%URI` - - * `{:unquoted_atom, charlist}` - the context is an unquoted atom. This - can be any atom or an atom representing a module - - ## Limitations - - The current algorithm only considers the last line of the input. This means - it will also show suggestions inside strings, heredocs, etc, which is - intentional as it helps with doctests, references, and more. Other functions - may be added in the future that consider the tree-structure of the code. - """ - @doc since: "1.13.0" - @spec cursor_context(List.Chars.t(), keyword()) :: - {:alias, charlist} - | {:dot, inside_dot, charlist} - | {:dot_arity, inside_dot, charlist} - | {:dot_call, inside_dot, charlist} - | :expr - | {:local_or_var, charlist} - | {:local_arity, charlist} - | {:local_call, charlist} - | {:module_attribute, charlist} - | {:operator, charlist} - | {:operator_arity, charlist} - | {:operator_call, charlist} - | :none - | {:sigil, charlist} - | {:struct, charlist} - | {:unquoted_atom, charlist} - when inside_dot: - {:alias, charlist} - | {:dot, inside_dot, charlist} - | {:module_attribute, charlist} - | {:unquoted_atom, charlist} - | {:var, charlist} - def cursor_context(fragment, opts \\ []) - - def cursor_context(binary, opts) when is_binary(binary) and is_list(opts) do - binary = - case :binary.matches(binary, "\n") do - [] -> - binary - - matches -> - {position, _} = List.last(matches) - binary_part(binary, position + 1, byte_size(binary) - position - 1) - end - - binary - |> String.to_charlist() - |> :lists.reverse() - |> codepoint_cursor_context(opts) - |> elem(0) - end - - def cursor_context(charlist, opts) when is_list(charlist) and is_list(opts) do - charlist = - case charlist |> Enum.chunk_by(&(&1 == ?\n)) |> List.last([]) do - [?\n | _] -> [] - rest -> rest - end - - charlist - |> :lists.reverse() - |> codepoint_cursor_context(opts) - |> elem(0) - end - - def cursor_context(other, opts) when is_list(opts) do - cursor_context(to_charlist(other), opts) - end - - @operators '\\<>+-*/:=|&~^%!' - @starter_punctuation ',([{;' - @non_starter_punctuation ')]}"\'.$' - @space '\t\s' - @trailing_identifier '?!' - @tilde_op_prefix '<=~' - - @non_identifier @trailing_identifier ++ - @operators ++ @starter_punctuation ++ @non_starter_punctuation ++ @space - - @textual_operators ~w(when not and or in)c - @incomplete_operators ~w(^^ ~~ ~)c - - defp codepoint_cursor_context(reverse, _opts) do - {stripped, spaces} = strip_spaces(reverse, 0) - - case stripped do - # It is empty - [] -> {:expr, 0} - # Structs - [?%, ?:, ?: | _] -> {{:struct, ''}, 1} - [?%, ?: | _] -> {{:unquoted_atom, '%'}, 2} - [?% | _] -> {{:struct, ''}, 1} - # Token/AST only operators - [?>, ?= | rest] when rest == [] or hd(rest) != ?: -> {:expr, 0} - [?>, ?- | rest] when rest == [] or hd(rest) != ?: -> {:expr, 0} - # Two-digit containers - [?<, ?< | rest] when rest == [] or hd(rest) != ?< -> {:expr, 0} - # Ambiguity around : - [?: | rest] when rest == [] or hd(rest) != ?: -> unquoted_atom_or_expr(spaces) - # Dots - [?.] -> {:none, 0} - [?. | rest] when hd(rest) not in '.:' -> dot(rest, spaces + 1, '') - # It is a local or remote call with parens - [?( | rest] -> call_to_cursor_context(strip_spaces(rest, spaces + 1)) - # A local arity definition - [?/ | rest] -> arity_to_cursor_context(strip_spaces(rest, spaces + 1)) - # Starting a new expression - [h | _] when h in @starter_punctuation -> {:expr, 0} - # It is a local or remote call without parens - rest when spaces > 0 -> call_to_cursor_context({rest, spaces}) - # It is an identifier - _ -> identifier_to_cursor_context(reverse, 0, false) - end - end - - defp strip_spaces([h | rest], count) when h in @space, do: strip_spaces(rest, count + 1) - defp strip_spaces(rest, count), do: {rest, count} - - defp unquoted_atom_or_expr(0), do: {{:unquoted_atom, ''}, 1} - defp unquoted_atom_or_expr(_), do: {:expr, 0} - - defp arity_to_cursor_context({reverse, spaces}) do - case identifier_to_cursor_context(reverse, spaces, true) do - {{:local_or_var, acc}, count} -> {{:local_arity, acc}, count} - {{:dot, base, acc}, count} -> {{:dot_arity, base, acc}, count} - {{:operator, acc}, count} -> {{:operator_arity, acc}, count} - {_, _} -> {:none, 0} - end - end - - defp call_to_cursor_context({reverse, spaces}) do - case identifier_to_cursor_context(reverse, spaces, true) do - {{:local_or_var, acc}, count} -> {{:local_call, acc}, count} - {{:dot, base, acc}, count} -> {{:dot_call, base, acc}, count} - {{:operator, acc}, count} -> {{:operator_call, acc}, count} - {_, _} -> {:none, 0} - end - end - - defp identifier_to_cursor_context([?., ?., ?: | _], n, _), do: {{:unquoted_atom, '..'}, n + 3} - defp identifier_to_cursor_context([?., ?., ?. | _], n, _), do: {{:local_or_var, '...'}, n + 3} - defp identifier_to_cursor_context([?., ?: | _], n, _), do: {{:unquoted_atom, '.'}, n + 2} - defp identifier_to_cursor_context([?., ?. | _], n, _), do: {{:operator, '..'}, n + 2} - - defp identifier_to_cursor_context(reverse, count, call_op?) do - case identifier(reverse, count) do - :none -> - {:none, 0} - - :operator -> - operator(reverse, count, [], call_op?) - - {:module_attribute, acc, count} -> - {{:module_attribute, acc}, count} - - {:sigil, acc, count} -> - {{:sigil, acc}, count} - - {:unquoted_atom, acc, count} -> - {{:unquoted_atom, acc}, count} - - {:alias, rest, acc, count} -> - case strip_spaces(rest, count) do - {'.' ++ rest, count} when rest == [] or hd(rest) != ?. -> - nested_alias(rest, count + 1, acc) - - {'%' ++ _, count} -> - {{:struct, acc}, count + 1} - - _ -> - {{:alias, acc}, count} - end - - {:identifier, _, acc, count} when call_op? and acc in @textual_operators -> - {{:operator, acc}, count} - - {:identifier, rest, acc, count} -> - case strip_spaces(rest, count) do - {'.' ++ rest, count} when rest == [] or hd(rest) != ?. -> - dot(rest, count + 1, acc) - - _ -> - {{:local_or_var, acc}, count} - end - end - end - - defp identifier([?? | rest], count), do: check_identifier(rest, count + 1, [??]) - defp identifier([?! | rest], count), do: check_identifier(rest, count + 1, [?!]) - defp identifier(rest, count), do: check_identifier(rest, count, []) - - defp check_identifier([h | t], count, acc) when h not in @non_identifier, - do: rest_identifier(t, count + 1, [h | acc]) - - defp check_identifier(_, _, _), do: :operator - - defp rest_identifier([h | rest], count, acc) when h not in @non_identifier do - rest_identifier(rest, count + 1, [h | acc]) - end - - defp rest_identifier(rest, count, [?@ | acc]) do - case tokenize_identifier(rest, count, acc) do - {:identifier, _rest, acc, count} -> {:module_attribute, acc, count} - :none when acc == [] -> {:module_attribute, '', count} - _ -> :none - end - end - - defp rest_identifier([?~ | rest], count, [letter]) - when (letter in ?A..?Z or letter in ?a..?z) and - (rest == [] or hd(rest) not in @tilde_op_prefix) do - {:sigil, [letter], count + 1} - end - - defp rest_identifier([?: | rest], count, acc) when rest == [] or hd(rest) != ?: do - case String.Tokenizer.tokenize(acc) do - {_, _, [], _, _, _} -> {:unquoted_atom, acc, count + 1} - _ -> :none - end - end - - defp rest_identifier([?? | _], _count, _acc) do - :none - end - - defp rest_identifier(rest, count, acc) do - tokenize_identifier(rest, count, acc) - end - - defp tokenize_identifier(rest, count, acc) do - case String.Tokenizer.tokenize(acc) do - # Not actually an atom cause rest is not a : - {:atom, _, _, _, _, _} -> - :none - - # Aliases must be ascii only - {:alias, _, _, _, false, _} -> - :none - - {kind, _, [], _, _, extra} -> - if ?@ in extra do - :none - else - {kind, rest, acc, count} - end - - _ -> - :none - end - end - - defp nested_alias(rest, count, acc) do - {rest, count} = strip_spaces(rest, count) - - case identifier_to_cursor_context(rest, count, true) do - {{:struct, prev}, count} -> {{:struct, prev ++ '.' ++ acc}, count} - {{:alias, prev}, count} -> {{:alias, prev ++ '.' ++ acc}, count} - _ -> {:none, 0} - end - end - - defp dot(rest, count, acc) do - {rest, count} = strip_spaces(rest, count) - - case identifier_to_cursor_context(rest, count, true) do - {{:local_or_var, var}, count} -> {{:dot, {:var, var}, acc}, count} - {{:unquoted_atom, _} = prev, count} -> {{:dot, prev, acc}, count} - {{:alias, _} = prev, count} -> {{:dot, prev, acc}, count} - {{:dot, _, _} = prev, count} -> {{:dot, prev, acc}, count} - {{:module_attribute, _} = prev, count} -> {{:dot, prev, acc}, count} - {{:struct, acc}, count} -> {{:struct, acc ++ '.'}, count} - {_, _} -> {:none, 0} - end - end - - defp operator([h | rest], count, acc, call_op?) when h in @operators do - operator(rest, count + 1, [h | acc], call_op?) - end - - defp operator(rest, count, acc, call_op?) when acc in @incomplete_operators do - {rest, dot_count} = strip_spaces(rest, count) - - cond do - call_op? -> - {:none, 0} - - match?([?. | rest] when rest == [] or hd(rest) != ?., rest) -> - dot(tl(rest), dot_count + 1, acc) - - acc == '~' -> - {{:sigil, ''}, count} - - true -> - {{:operator, acc}, count} - end - end - - # If we are opening a sigil, ignore the operator. - defp operator([letter, ?~ | rest], _count, [op], _call_op?) - when op in '<|/' and (letter in ?A..?Z or letter in ?a..?z) and - (rest == [] or hd(rest) not in @tilde_op_prefix) do - {:none, 0} - end - - defp operator(rest, count, acc, _call_op?) do - case elixir_tokenizer_tokenize(acc, 1, 1, []) do - {:ok, _, [{:atom, _, _}]} -> - {{:unquoted_atom, tl(acc)}, count} - - {:ok, _, [{_, _, op}]} -> - {rest, dot_count} = strip_spaces(rest, count) - - cond do - Code.Identifier.unary_op(op) == :error and Code.Identifier.binary_op(op) == :error -> - :none - - match?([?. | rest] when rest == [] or hd(rest) != ?., rest) -> - dot(tl(rest), dot_count + 1, acc) - - true -> - {{:operator, acc}, count} - end - - _ -> - {:none, 0} - end - end - - @doc """ - Receives a string and returns the surround context. - - This function receives a string with an Elixir code fragment - and a `position`. It returns a map containing the beginning - and ending of the expression alongside its context, or `:none` - if there is nothing with a known context. - - The difference between `cursor_context/2` and `surround_context/3` - is that the former assumes the expression in the code fragment - is incomplete. For example, `do` in `cursor_context/2` may be - a keyword or a variable or a local call, while `surround_context/3` - assumes the expression in the code fragment is complete, therefore - `do` would always be a keyword. - - The `position` contains both the `line` and `column`, both starting - with the index of 1. The column must precede the surrounding expression. - For example, the expression `foo`, will return something for the columns - 1, 2, and 3, but not 4: - - foo - ^ column 1 - - foo - ^ column 2 - - foo - ^ column 3 - - foo - ^ column 4 - - The returned map contains the column the expression starts and the - first column after the expression ends. - - Similar to `cursor_context/2`, this function also provides a best-effort - detection and may not be accurate under all circumstances. See the - "Return values" and "Limitations" section under `cursor_context/2` for - more information. - - ## Examples - - iex> Code.Fragment.surround_context("foo", {1, 1}) - %{begin: {1, 1}, context: {:local_or_var, 'foo'}, end: {1, 4}} - - ## Differences to `cursor_context/2` - - Because `surround_context/3` deals with complete code, it has some - difference to `cursor_context/2`: - - * `dot_call`/`dot_arity` and `operator_call`/`operator_arity` - are collapsed into `dot` and `operator` contexts respectively - as they are not meaningful distinction between them - - * On the other hand, this function still makes a distinction between - `local_call`/`local_arity` and `local_or_var`, since the latter can - be a local or variable - - * `@` when not followed by any identifier is returned as `{:operator, '@'}` - (in contrast to `{:module_attribute, ''}` in `cursor_context/2` - - * This function never returns empty sigils `{:sigil, ''}` or empty structs - `{:struct, ''}` as context - """ - @doc since: "1.13.0" - @spec surround_context(List.Chars.t(), position(), keyword()) :: - %{begin: position, end: position, context: context} | :none - when context: - {:alias, charlist} - | {:dot, inside_dot, charlist} - | {:local_or_var, charlist} - | {:local_arity, charlist} - | {:local_call, charlist} - | {:module_attribute, charlist} - | {:operator, charlist} - | {:unquoted_atom, charlist}, - inside_dot: - {:alias, charlist} - | {:dot, inside_dot, charlist} - | {:module_attribute, charlist} - | {:unquoted_atom, charlist} - | {:var, charlist} - def surround_context(fragment, position, options \\ []) - - def surround_context(binary, {line, column}, opts) when is_binary(binary) do - binary - |> String.split("\n") - |> Enum.at(line - 1, '') - |> String.to_charlist() - |> position_surround_context(line, column, opts) - end - - def surround_context(charlist, {line, column}, opts) when is_list(charlist) do - charlist - |> :string.split('\n', :all) - |> Enum.at(line - 1, '') - |> position_surround_context(line, column, opts) - end - - def surround_context(other, position, opts) do - surround_context(to_charlist(other), position, opts) - end - - defp position_surround_context(charlist, line, column, opts) - when is_integer(line) and line >= 1 and is_integer(column) and column >= 1 do - {reversed_pre, post} = string_reverse_at(charlist, column - 1, []) - {reversed_pre, post} = adjust_position(reversed_pre, post) - - case take_identifier(post, []) do - {_, [], _} -> - maybe_operator(reversed_pre, post, line, opts) - - {:identifier, reversed_post, rest} -> - {rest, _} = strip_spaces(rest, 0) - reversed = reversed_post ++ reversed_pre - - case codepoint_cursor_context(reversed, opts) do - {{:struct, acc}, offset} -> - build_surround({:struct, acc}, reversed, line, offset) - - {{:alias, acc}, offset} -> - build_surround({:alias, acc}, reversed, line, offset) - - {{:dot, _, [_ | _]} = dot, offset} -> - build_surround(dot, reversed, line, offset) - - {{:local_or_var, acc}, offset} when hd(rest) == ?( -> - build_surround({:local_call, acc}, reversed, line, offset) - - {{:local_or_var, acc}, offset} when hd(rest) == ?/ -> - build_surround({:local_arity, acc}, reversed, line, offset) - - {{:local_or_var, acc}, offset} when acc in @textual_operators -> - build_surround({:operator, acc}, reversed, line, offset) - - {{:local_or_var, acc}, offset} when acc not in ~w(do end after else catch rescue)c -> - build_surround({:local_or_var, acc}, reversed, line, offset) - - {{:module_attribute, ''}, offset} -> - build_surround({:operator, '@'}, reversed, line, offset) - - {{:module_attribute, acc}, offset} -> - build_surround({:module_attribute, acc}, reversed, line, offset) - - {{:sigil, acc}, offset} -> - build_surround({:sigil, acc}, reversed, line, offset) - - {{:unquoted_atom, acc}, offset} -> - build_surround({:unquoted_atom, acc}, reversed, line, offset) - - _ -> - maybe_operator(reversed_pre, post, line, opts) - end - - {:alias, reversed_post, _rest} -> - reversed = reversed_post ++ reversed_pre - - case codepoint_cursor_context(reversed, opts) do - {{:alias, acc}, offset} -> - build_surround({:alias, acc}, reversed, line, offset) - - {{:struct, acc}, offset} -> - build_surround({:struct, acc}, reversed, line, offset) - - _ -> - :none - end - end - end - - defp maybe_operator(reversed_pre, post, line, opts) do - case take_operator(post, []) do - {[], _rest} -> - :none - - {reversed_post, rest} -> - reversed = reversed_post ++ reversed_pre - - case codepoint_cursor_context(reversed, opts) do - {{:operator, acc}, offset} when acc not in @incomplete_operators -> - build_surround({:operator, acc}, reversed, line, offset) - - {{:sigil, ''}, offset} when hd(rest) in ?A..?Z or hd(rest) in ?a..?z -> - build_surround({:sigil, [hd(rest)]}, [hd(rest) | reversed], line, offset + 1) - - {{:dot, _, [_ | _]} = dot, offset} -> - build_surround(dot, reversed, line, offset) - - _ -> - :none - end - end - end - - defp build_surround(context, reversed, line, offset) do - {post, reversed_pre} = enum_reverse_at(reversed, offset, []) - pre = :lists.reverse(reversed_pre) - pre_length = :string.length(pre) + 1 - - %{ - context: context, - begin: {line, pre_length}, - end: {line, pre_length + :string.length(post)} - } - end - - defp take_identifier([h | t], acc) when h in @trailing_identifier, - do: {:identifier, [h | acc], t} - - defp take_identifier([h | t], acc) when h not in @non_identifier, - do: take_identifier(t, [h | acc]) - - defp take_identifier(rest, acc) do - with {[?. | t], _} <- strip_spaces(rest, 0), - {[h | _], _} when h in ?A..?Z <- strip_spaces(t, 0) do - take_alias(rest, acc) - else - _ -> {:identifier, acc, rest} - end - end - - defp take_alias([h | t], acc) when h not in @non_identifier, - do: take_alias(t, [h | acc]) - - defp take_alias(rest, acc) do - with {[?. | t], acc} <- move_spaces(rest, acc), - {[h | t], acc} when h in ?A..?Z <- move_spaces(t, [?. | acc]) do - take_alias(t, [h | acc]) - else - _ -> {:alias, acc, rest} - end - end - - defp take_operator([h | t], acc) when h in @operators, do: take_operator(t, [h | acc]) - defp take_operator([h | t], acc) when h == ?., do: take_operator(t, [h | acc]) - defp take_operator(rest, acc), do: {acc, rest} - - # Unquoted atom handling - defp adjust_position(reversed_pre, [?: | post]) - when hd(post) != ?: and (reversed_pre == [] or hd(reversed_pre) != ?:) do - {[?: | reversed_pre], post} - end - - defp adjust_position(reversed_pre, [?% | post]) do - adjust_position([?% | reversed_pre], post) - end - - # Dot/struct handling - defp adjust_position(reversed_pre, post) do - case move_spaces(post, reversed_pre) do - # If we are between spaces and a dot, move past the dot - {[?. | post], reversed_pre} when hd(post) != ?. and hd(reversed_pre) != ?. -> - {post, reversed_pre} = move_spaces(post, [?. | reversed_pre]) - {reversed_pre, post} - - _ -> - case strip_spaces(reversed_pre, 0) do - # If there is a dot to our left, make sure to move to the first character - {[?. | rest], _} when rest == [] or hd(rest) not in '.:' -> - {post, reversed_pre} = move_spaces(post, reversed_pre) - {reversed_pre, post} - - # If there is a % to our left, make sure to move to the first character - {[?% | _], _} -> - case move_spaces(post, reversed_pre) do - {[h | _] = post, reversed_pre} when h in ?A..?Z -> - {reversed_pre, post} - - _ -> - {reversed_pre, post} - end - - _ -> - {reversed_pre, post} - end - end - end - - defp move_spaces([h | t], acc) when h in @space, do: move_spaces(t, [h | acc]) - defp move_spaces(t, acc), do: {t, acc} - - defp string_reverse_at(charlist, 0, acc), do: {acc, charlist} - - defp string_reverse_at(charlist, n, acc) do - case :unicode_util.gc(charlist) do - [gc | cont] when is_integer(gc) -> string_reverse_at(cont, n - 1, [gc | acc]) - [gc | cont] when is_list(gc) -> string_reverse_at(cont, n - 1, :lists.reverse(gc, acc)) - [] -> {acc, []} - end - end - - defp enum_reverse_at([h | t], n, acc) when n > 0, do: enum_reverse_at(t, n - 1, [h | acc]) - defp enum_reverse_at(rest, _, acc), do: {acc, rest} - - # --- - - # See: https://github.com/elixir-lang/elixir/pull/11143/files#r676519050 - def elixir_tokenizer_tokenize(string, line, columns, opts) do - with {:ok, tokens} <- :elixir_tokenizer.tokenize(string, line, columns, opts) do - {:ok, [], tokens} - end - end end diff --git a/lib/livebook/utils.ex b/lib/livebook/utils.ex index 466a48187..3d37ccd33 100644 --- a/lib/livebook/utils.ex +++ b/lib/livebook/utils.ex @@ -167,8 +167,10 @@ defmodule Livebook.Utils do """ @spec valid_url?(String.t()) :: boolean() def valid_url?(url) do - uri = URI.parse(url) - uri.scheme != nil and uri.host != nil + case URI.new(url) do + {:ok, uri} -> uri.scheme != nil and uri.host != nil + _ -> false + end end @doc """ @@ -245,7 +247,7 @@ defmodule Livebook.Utils do @spec expand_url(String.t(), String.t()) :: String.t() def expand_url(url, relative_path) do url - |> URI.parse() + |> URI.new!() |> Map.update!(:path, fn path -> path |> Path.dirname() |> Path.join(relative_path) |> Path.expand() end) diff --git a/lib/livebook_cli/server.ex b/lib/livebook_cli/server.ex index 4ae987bdd..af247e98c 100644 --- a/lib/livebook_cli/server.ex +++ b/lib/livebook_cli/server.ex @@ -217,9 +217,8 @@ defmodule LivebookCLI.Server do defp append_path(url, path) do url - |> URI.parse() - # TODO: remove `&1 || ""` when we require Elixir 1.13 - |> Map.update!(:path, &((&1 || "") <> path)) + |> URI.new!() + |> Map.update!(:path, &(&1 <> path)) |> URI.to_string() end diff --git a/mix.exs b/mix.exs index 972ebd2db..b6bdcdc7d 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule Livebook.MixProject do [ app: :livebook, version: @version, - elixir: "~> 1.12", + elixir: "~> 1.13", name: "Livebook", description: @description, elixirc_paths: elixirc_paths(Mix.env()), @@ -87,12 +87,7 @@ defmodule Livebook.MixProject do [ "dev.setup": ["deps.get", "cmd npm install --prefix assets"], "dev.build": ["cmd npm run deploy --prefix ./assets"], - "format.all": ["format", "cmd npm run format --prefix ./assets"], - # TODO: loadconfig no longer required on Elixir v1.13 - # Currently this ensures we load configuration before - # compiling dependencies as part of `mix escript.install`. - # See https://github.com/elixir-lang/elixir/commit/a6eefb244b3a5892895a97b2dad4cce2b3c3c5ed - "escript.build": ["loadconfig", "escript.build"] + "format.all": ["format", "cmd npm run format --prefix ./assets"] ] end diff --git a/test/livebook/evaluator_test.exs b/test/livebook/evaluator_test.exs index 7d0717bd2..262e27758 100644 --- a/test/livebook/evaluator_test.exs +++ b/test/livebook/evaluator_test.exs @@ -30,8 +30,10 @@ defmodule Livebook.EvaluatorTest do Evaluator.evaluate_code(evaluator, self(), "x", :code_2) assert_receive {:evaluation_response, :code_2, - {:error, _kind, %CompileError{description: "undefined function x/0"}, - _stacktrace}, %{evaluation_time_ms: _time_ms}} + {:error, _kind, + %CompileError{ + description: "undefined function x/0 (there is no such import)" + }, _stacktrace}, %{evaluation_time_ms: _time_ms}} end) end @@ -212,8 +214,10 @@ defmodule Livebook.EvaluatorTest do Evaluator.evaluate_code(evaluator, self(), "x", :code_2, :code_1) assert_receive {:evaluation_response, :code_2, - {:error, _kind, %CompileError{description: "undefined function x/0"}, - _stacktrace}, %{evaluation_time_ms: _time_ms}} + {:error, _kind, + %CompileError{ + description: "undefined function x/0 (there is no such import)" + }, _stacktrace}, %{evaluation_time_ms: _time_ms}} end) end diff --git a/test/livebook/intellisense_test.exs b/test/livebook/intellisense_test.exs index 004eae965..b3f06274b 100644 --- a/test/livebook/intellisense_test.exs +++ b/test/livebook/intellisense_test.exs @@ -514,68 +514,66 @@ defmodule Livebook.IntellisenseTest do ] = Intellisense.get_completion_items("mod.ve", binding, env) end - # TODO: Enable on Elixir 1.13 + test "operator completion" do + {binding, env} = eval(do: nil) - # test "operator completion" do - # {binding, env} = eval(do: nil) + assert [ + %{ + label: "++/2", + kind: :function, + detail: "left ++ right", + documentation: """ + List concatenation operator. Concatenates a proper list and a term, returning a list. - # assert [ - # %{ - # label: "++/2", - # kind: :function, - # detail: "left ++ right", - # documentation: """ - # List concatenation operator. Concatenates a proper list and a term, returning a list. + ``` + @spec list() ++ term() :: + maybe_improper_list() + ```\ + """, + insert_text: "++" + }, + %{ + label: "+/1", + kind: :function, + detail: "+value", + documentation: """ + Arithmetic positive unary operator. - # ``` - # @spec list() ++ term() :: - # maybe_improper_list() - # ```\ - # """, - # insert_text: "++" - # }, - # %{ - # label: "+/1", - # kind: :function, - # detail: "+value", - # documentation: """ - # Arithmetic positive unary operator. + ``` + @spec +integer() :: integer() + @spec +float() :: float() + ```\ + """, + insert_text: "+" + }, + %{ + label: "+/2", + kind: :function, + detail: "left + right", + documentation: """ + Arithmetic addition operator. - # ``` - # @spec +integer() :: integer() - # @spec +float() :: float() - # ```\ - # """, - # insert_text: "+" - # }, - # %{ - # label: "+/2", - # kind: :function, - # detail: "left + right", - # documentation: """ - # Arithmetic addition operator. + ``` + @spec integer() + integer() :: + integer() + @spec float() + float() :: float() + @spec integer() + float() :: float() + @spec float() + integer() :: float() + ```\ + """, + insert_text: "+" + } + ] = Intellisense.get_completion_items("+", binding, env) - # ``` - # @spec integer() + integer() :: - # integer() - # @spec float() + float() :: float() - # @spec integer() + float() :: float() - # @spec float() + integer() :: float() - # ```\ - # """, - # insert_text: "+" - # } - # ] = Intellisense.get_completion_items("+", binding, env) + assert [ + %{label: "+/1"}, + %{label: "+/2"} + ] = Intellisense.get_completion_items("+/", binding, env) - # assert [ - # %{label: "+/1"}, - # %{label: "+/2"} - # ] = Intellisense.get_completion_items("+/", binding, env) - - # assert [ - # %{label: "++/2"} - # ] = Intellisense.get_completion_items("++/", binding, env) - # end + assert [ + %{label: "++/2"} + ] = Intellisense.get_completion_items("++/", binding, env) + end test "map atom key completion" do {binding, env} = diff --git a/test/livebook_web/live/session_live_test.exs b/test/livebook_web/live/session_live_test.exs index 9e23913ee..7cdef2344 100644 --- a/test/livebook_web/live/session_live_test.exs +++ b/test/livebook_web/live/session_live_test.exs @@ -668,8 +668,9 @@ defmodule LivebookWeb.SessionLiveTest do test "if the remote notebook is already imported, redirects to the session", %{conn: conn, test: test} do - index_url = "http://example.com/#{test}/index.livemd" - notebook_url = "http://example.com/#{test}/notebook.livemd" + test_path = test |> to_string() |> URI.encode_www_form() + index_url = "http://example.com/#{test_path}/index.livemd" + notebook_url = "http://example.com/#{test_path}/notebook.livemd" {:ok, index_session} = Sessions.create_session(origin: {:url, index_url}) {:ok, notebook_session} = Sessions.create_session(origin: {:url, notebook_url}) @@ -682,8 +683,9 @@ defmodule LivebookWeb.SessionLiveTest do test "renders an error message if there are already multiple session imported from the relative URL", %{conn: conn, test: test} do - index_url = "http://example.com/#{test}/index.livemd" - notebook_url = "http://example.com/#{test}/notebook.livemd" + test_path = test |> to_string() |> URI.encode_www_form() + index_url = "http://example.com/#{test_path}/index.livemd" + notebook_url = "http://example.com/#{test_path}/notebook.livemd" {:ok, index_session} = Sessions.create_session(origin: {:url, index_url}) {:ok, _notebook_session1} = Sessions.create_session(origin: {:url, notebook_url})