Skip to content

Multi-line prompting #14522

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions lib/iex/lib/iex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -396,10 +396,8 @@ defmodule IEx do
The supported options are:
* `:auto_reload`
* `:alive_continuation_prompt`
* `:alive_prompt`
* `:colors`
* `:continuation_prompt`
* `:default_prompt`
* `:dot_iex`
* `:history_size`
Expand Down Expand Up @@ -485,14 +483,8 @@ defmodule IEx do
* `:default_prompt` - used when `Node.alive?/0` returns `false`
* `:continuation_prompt` - used when `Node.alive?/0` returns `false`
and more input is expected
* `:alive_prompt` - used when `Node.alive?/0` returns `true`
* `:alive_continuation_prompt` - used when `Node.alive?/0` returns
`true` and more input is expected
The following values in the prompt string will be replaced appropriately:
* `%counter` - the index of the history
Expand All @@ -506,11 +498,17 @@ defmodule IEx do
The parser is a "mfargs", which is a tuple with three elements:
the module name, the function name, and extra arguments to
be appended. The parser receives at least three arguments, the
current input as a string, the parsing options as a keyword list,
and the buffer as a string. It must return `{:ok, expr, buffer}`
or `{:incomplete, buffer}`.
current input as a charlist, the parsing options as a keyword list,
and the state. The initial state is an empty charlist. It must
return `{:ok, expr, state}` or `{:incomplete, state}`.
If the parser raises, the state is reset to an empty charlist.
If the parser raises, the buffer is reset to an empty string.
> In earlier Elixir versions, the parser would receive the input
> and the initial buffer as strings. However, this behaviour
> changed when Erlang/OTP introduced multiline editing. If you
> support earlier Elixir versions, you can normalize the inputs
> by calling `to_charlist/1`.
## `.iex`
Expand Down
4 changes: 4 additions & 0 deletions lib/iex/lib/iex/app.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ defmodule IEx.App do
use Application

def start(_type, _args) do
with :default <- Application.get_env(:stdlib, :shell_multiline_prompt, :default) do
Application.put_env(:stdlib, :shell_multiline_prompt, {IEx.Config, :prompt})
end

children = [IEx.Config, IEx.Broker, IEx.Pry]
Supervisor.start_link(children, strategy: :one_for_one, name: IEx.Supervisor)
end
Expand Down
38 changes: 25 additions & 13 deletions lib/iex/lib/iex/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,37 @@ defmodule IEx.Config do
:inspect,
:history_size,
:default_prompt,
:continuation_prompt,
:alive_prompt,
:alive_continuation_prompt,
:width,
:parser,
:dot_iex,
:auto_reload
]

# Generate a continuation prompt based on IEx prompt.
# This is set as global configuration on app start.
def prompt(prompt) do
case Enum.split_while(prompt, &(&1 != ?()) do
# It is not the default Elixir shell, so we use the default prompt
{_, []} ->
List.duplicate(?\s, max(0, prompt_width(prompt) - 3)) ++ ~c".. "

{left, right} ->
List.duplicate(?., prompt_width(left)) ++ right
end
end

# TODO: Remove this when we require Erlang/OTP 27+
@compile {:no_warn_undefined, :prim_tty}
@compile {:no_warn_undefined, :shell}
defp prompt_width(prompt) do
if function_exported?(:prim_tty, :npwcwidthstring, 1) do
:prim_tty.npwcwidthstring(prompt)
else
:shell.prompt_width(prompt)
end
end

# Read API

def configuration() do
Expand Down Expand Up @@ -53,20 +75,12 @@ defmodule IEx.Config do
Application.fetch_env!(:iex, :default_prompt)
end

def continuation_prompt() do
Application.get_env(:iex, :continuation_prompt, default_prompt())
end

def alive_prompt() do
Application.fetch_env!(:iex, :alive_prompt)
end

def alive_continuation_prompt() do
Application.get_env(:iex, :alive_continuation_prompt, alive_prompt())
end

def parser() do
Application.get_env(:iex, :parser, {IEx.Evaluator, :parse, []})
Application.fetch_env!(:iex, :parser)
end

def color(color) do
Expand Down Expand Up @@ -202,9 +216,7 @@ defmodule IEx.Config do
defp validate_option({:inspect, new}) when is_list(new), do: :ok
defp validate_option({:history_size, new}) when is_integer(new), do: :ok
defp validate_option({:default_prompt, new}) when is_binary(new), do: :ok
defp validate_option({:continuation_prompt, new}) when is_binary(new), do: :ok
defp validate_option({:alive_prompt, new}) when is_binary(new), do: :ok
defp validate_option({:alive_continuation_prompt, new}) when is_binary(new), do: :ok
defp validate_option({:width, new}) when is_integer(new), do: :ok
defp validate_option({:parser, tuple}) when tuple_size(tuple) == 3, do: :ok
defp validate_option({:dot_iex, path}) when is_binary(path), do: :ok
Expand Down
70 changes: 23 additions & 47 deletions lib/iex/lib/iex/evaluator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,26 +55,21 @@ defmodule IEx.Evaluator do
end
end

# If parsing fails, this might be a TokenMissingError which we treat in
# a special way (to allow for continuation of an expression on the next
# line in IEx).
#
# The first two clauses provide support for the break-trigger allowing to
# break out from a pending incomplete expression. See
# https://github.com/elixir-lang/elixir/issues/1089 for discussion.
@break_trigger "#iex:break\n"
@break_trigger ~c"#iex:break\n"

@op_tokens [:or_op, :and_op, :comp_op, :rel_op, :arrow_op, :in_op] ++
[:three_op, :concat_op, :mult_op]

@doc false
def parse(input, opts, parser_state)
@doc """
Default parsing implementation with support for pipes and #iex:break.
def parse(input, opts, ""), do: parse(input, opts, {"", :other})
If parsing fails, this might be a TokenMissingError which we treat in
a special way (to allow for continuation of an expression on the next
line in IEx).
"""
def parse(input, opts, parser_state)

def parse(@break_trigger, _opts, {"", _} = parser_state) do
{:incomplete, parser_state}
end
def parse(input, opts, []), do: parse(input, opts, {[], :other})

def parse(@break_trigger, opts, _parser_state) do
:elixir_errors.parse_error(
Expand All @@ -87,14 +82,13 @@ defmodule IEx.Evaluator do
end

def parse(input, opts, {buffer, last_op}) do
input = buffer <> input
input = buffer ++ input
file = Keyword.get(opts, :file, "nofile")
line = Keyword.get(opts, :line, 1)
column = Keyword.get(opts, :column, 1)
charlist = String.to_charlist(input)

result =
with {:ok, tokens} <- :elixir.string_to_tokens(charlist, line, column, file, opts),
with {:ok, tokens} <- :elixir.string_to_tokens(input, line, column, file, opts),
{:ok, adjusted_tokens} <- adjust_operator(tokens, line, column, file, opts, last_op),
{:ok, forms} <- :elixir.tokens_to_quoted(adjusted_tokens, file, opts) do
last_op =
Expand All @@ -108,7 +102,7 @@ defmodule IEx.Evaluator do

case result do
{:ok, forms, last_op} ->
{:ok, forms, {"", last_op}}
{:ok, forms, {[], last_op}}

{:error, {_, _, ""}} ->
{:incomplete, {input, last_op}}
Expand All @@ -119,7 +113,7 @@ defmodule IEx.Evaluator do
file,
error,
token,
{charlist, line, column, 0}
{input, line, column, 0}
)
end
end
Expand Down Expand Up @@ -189,9 +183,9 @@ defmodule IEx.Evaluator do

defp loop(%{server: server, ref: ref} = state) do
receive do
{:eval, ^server, code, counter, parser_state} ->
{status, parser_state, state} = parse_eval_inspect(code, counter, parser_state, state)
send(server, {:evaled, self(), status, parser_state})
{:eval, ^server, code, counter} ->
{status, state} = safe_eval_and_inspect(code, counter, state)
send(server, {:evaled, self(), status})
loop(state)

{:fields_from_env, ^server, ref, receiver, fields} ->
Expand Down Expand Up @@ -296,32 +290,19 @@ defmodule IEx.Evaluator do
end
end

defp parse_eval_inspect(code, counter, parser_state, state) do
try do
{parser_module, parser_fun, args} = IEx.Config.parser()
args = [code, [line: counter, file: "iex"], parser_state | args]
eval_and_inspect_parsed(apply(parser_module, parser_fun, args), counter, state)
catch
kind, error ->
print_error(kind, error, __STACKTRACE__)
{:error, "", state}
end
end

defp eval_and_inspect_parsed({:ok, forms, parser_state}, counter, state) do
defp safe_eval_and_inspect(forms, counter, state) do
put_history(state)
put_whereami(state)
state = eval_and_inspect(forms, counter, state)
{:ok, parser_state, state}
{:ok, eval_and_inspect(forms, counter, state)}
catch
kind, error ->
print_error(kind, error, __STACKTRACE__)
{:error, state}
after
Process.delete(:iex_history)
Process.delete(:iex_whereami)
end

defp eval_and_inspect_parsed({:incomplete, parser_state}, _counter, state) do
{:incomplete, parser_state, state}
end

defp put_history(%{history: history}) do
Process.put(:iex_history, history)
end
Expand Down Expand Up @@ -410,12 +391,7 @@ defmodule IEx.Evaluator do

_ ->
banner = Exception.format_banner(kind, blamed, stacktrace)

if String.contains?(banner, IO.ANSI.reset()) do
[banner]
else
[IEx.color(:eval_error, banner)]
end
[IEx.color(:eval_error, banner)]
end

stackdata = Exception.format_stacktrace(prune_stacktrace(stacktrace))
Expand Down
Loading