mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-09-04 20:14:57 +08:00
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
This commit is contained in:
parent
ac6b423e79
commit
47d29cb389
12 changed files with 91 additions and 837 deletions
2
.github/workflows/deploy.yaml
vendored
2
.github/workflows/deploy.yaml
vendored
|
@ -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:
|
||||
|
|
2
.github/workflows/test.yaml
vendored
2
.github/workflows/test.yaml
vendored
|
@ -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:
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
9
mix.exs
9
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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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} =
|
||||
|
|
|
@ -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})
|
||||
|
|
Loading…
Add table
Reference in a new issue