Skip to content

Commit c9ddb57

Browse files
authored
Multi-line prompts in IEx (#14522)
1 parent b4bcc3b commit c9ddb57

File tree

9 files changed

+143
-114
lines changed

9 files changed

+143
-114
lines changed

lib/iex/lib/iex.ex

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,8 @@ defmodule IEx do
396396
The supported options are:
397397
398398
* `:auto_reload`
399-
* `:alive_continuation_prompt`
400399
* `:alive_prompt`
401400
* `:colors`
402-
* `:continuation_prompt`
403401
* `:default_prompt`
404402
* `:dot_iex`
405403
* `:history_size`
@@ -485,14 +483,8 @@ defmodule IEx do
485483
486484
* `:default_prompt` - used when `Node.alive?/0` returns `false`
487485
488-
* `:continuation_prompt` - used when `Node.alive?/0` returns `false`
489-
and more input is expected
490-
491486
* `:alive_prompt` - used when `Node.alive?/0` returns `true`
492487
493-
* `:alive_continuation_prompt` - used when `Node.alive?/0` returns
494-
`true` and more input is expected
495-
496488
The following values in the prompt string will be replaced appropriately:
497489
498490
* `%counter` - the index of the history
@@ -506,11 +498,17 @@ defmodule IEx do
506498
The parser is a "mfargs", which is a tuple with three elements:
507499
the module name, the function name, and extra arguments to
508500
be appended. The parser receives at least three arguments, the
509-
current input as a string, the parsing options as a keyword list,
510-
and the buffer as a string. It must return `{:ok, expr, buffer}`
511-
or `{:incomplete, buffer}`.
501+
current input as a charlist, the parsing options as a keyword list,
502+
and the state. The initial state is an empty charlist. It must
503+
return `{:ok, expr, state}` or `{:incomplete, state}`.
504+
505+
If the parser raises, the state is reset to an empty charlist.
512506
513-
If the parser raises, the buffer is reset to an empty string.
507+
> In earlier Elixir versions, the parser would receive the input
508+
> and the initial buffer as strings. However, this behaviour
509+
> changed when Erlang/OTP introduced multiline editing. If you
510+
> support earlier Elixir versions, you can normalize the inputs
511+
> by calling `to_charlist/1`.
514512
515513
## `.iex`
516514

lib/iex/lib/iex/app.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ defmodule IEx.App do
88
use Application
99

1010
def start(_type, _args) do
11+
with :default <- Application.get_env(:stdlib, :shell_multiline_prompt, :default) do
12+
Application.put_env(:stdlib, :shell_multiline_prompt, {IEx.Config, :prompt})
13+
end
14+
1115
children = [IEx.Config, IEx.Broker, IEx.Pry]
1216
Supervisor.start_link(children, strategy: :one_for_one, name: IEx.Supervisor)
1317
end

lib/iex/lib/iex/config.ex

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,37 @@ defmodule IEx.Config do
1313
:inspect,
1414
:history_size,
1515
:default_prompt,
16-
:continuation_prompt,
1716
:alive_prompt,
18-
:alive_continuation_prompt,
1917
:width,
2018
:parser,
2119
:dot_iex,
2220
:auto_reload
2321
]
2422

23+
# Generate a continuation prompt based on IEx prompt.
24+
# This is set as global configuration on app start.
25+
def prompt(prompt) do
26+
case Enum.split_while(prompt, &(&1 != ?()) do
27+
# It is not the default Elixir shell, so we use the default prompt
28+
{_, []} ->
29+
List.duplicate(?\s, max(0, prompt_width(prompt) - 3)) ++ ~c".. "
30+
31+
{left, right} ->
32+
List.duplicate(?., prompt_width(left)) ++ right
33+
end
34+
end
35+
36+
# TODO: Remove this when we require Erlang/OTP 27+
37+
@compile {:no_warn_undefined, :prim_tty}
38+
@compile {:no_warn_undefined, :shell}
39+
defp prompt_width(prompt) do
40+
if function_exported?(:prim_tty, :npwcwidthstring, 1) do
41+
:prim_tty.npwcwidthstring(prompt)
42+
else
43+
:shell.prompt_width(prompt)
44+
end
45+
end
46+
2547
# Read API
2648

2749
def configuration() do
@@ -53,20 +75,12 @@ defmodule IEx.Config do
5375
Application.fetch_env!(:iex, :default_prompt)
5476
end
5577

56-
def continuation_prompt() do
57-
Application.get_env(:iex, :continuation_prompt, default_prompt())
58-
end
59-
6078
def alive_prompt() do
6179
Application.fetch_env!(:iex, :alive_prompt)
6280
end
6381

64-
def alive_continuation_prompt() do
65-
Application.get_env(:iex, :alive_continuation_prompt, alive_prompt())
66-
end
67-
6882
def parser() do
69-
Application.get_env(:iex, :parser, {IEx.Evaluator, :parse, []})
83+
Application.fetch_env!(:iex, :parser)
7084
end
7185

7286
def color(color) do
@@ -202,9 +216,7 @@ defmodule IEx.Config do
202216
defp validate_option({:inspect, new}) when is_list(new), do: :ok
203217
defp validate_option({:history_size, new}) when is_integer(new), do: :ok
204218
defp validate_option({:default_prompt, new}) when is_binary(new), do: :ok
205-
defp validate_option({:continuation_prompt, new}) when is_binary(new), do: :ok
206219
defp validate_option({:alive_prompt, new}) when is_binary(new), do: :ok
207-
defp validate_option({:alive_continuation_prompt, new}) when is_binary(new), do: :ok
208220
defp validate_option({:width, new}) when is_integer(new), do: :ok
209221
defp validate_option({:parser, tuple}) when tuple_size(tuple) == 3, do: :ok
210222
defp validate_option({:dot_iex, path}) when is_binary(path), do: :ok

lib/iex/lib/iex/evaluator.ex

Lines changed: 23 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,21 @@ defmodule IEx.Evaluator do
5555
end
5656
end
5757

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

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

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

75-
def parse(@break_trigger, _opts, {"", _} = parser_state) do
76-
{:incomplete, parser_state}
77-
end
72+
def parse(input, opts, []), do: parse(input, opts, {[], :other})
7873

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

8984
def parse(input, opts, {buffer, last_op}) do
90-
input = buffer <> input
85+
input = buffer ++ input
9186
file = Keyword.get(opts, :file, "nofile")
9287
line = Keyword.get(opts, :line, 1)
9388
column = Keyword.get(opts, :column, 1)
94-
charlist = String.to_charlist(input)
9589

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

109103
case result do
110104
{:ok, forms, last_op} ->
111-
{:ok, forms, {"", last_op}}
105+
{:ok, forms, {[], last_op}}
112106

113107
{:error, {_, _, ""}} ->
114108
{:incomplete, {input, last_op}}
@@ -119,7 +113,7 @@ defmodule IEx.Evaluator do
119113
file,
120114
error,
121115
token,
122-
{charlist, line, column, 0}
116+
{input, line, column, 0}
123117
)
124118
end
125119
end
@@ -189,9 +183,9 @@ defmodule IEx.Evaluator do
189183

190184
defp loop(%{server: server, ref: ref} = state) do
191185
receive do
192-
{:eval, ^server, code, counter, parser_state} ->
193-
{status, parser_state, state} = parse_eval_inspect(code, counter, parser_state, state)
194-
send(server, {:evaled, self(), status, parser_state})
186+
{:eval, ^server, code, counter} ->
187+
{status, state} = safe_eval_and_inspect(code, counter, state)
188+
send(server, {:evaled, self(), status})
195189
loop(state)
196190

197191
{:fields_from_env, ^server, ref, receiver, fields} ->
@@ -296,32 +290,19 @@ defmodule IEx.Evaluator do
296290
end
297291
end
298292

299-
defp parse_eval_inspect(code, counter, parser_state, state) do
300-
try do
301-
{parser_module, parser_fun, args} = IEx.Config.parser()
302-
args = [code, [line: counter, file: "iex"], parser_state | args]
303-
eval_and_inspect_parsed(apply(parser_module, parser_fun, args), counter, state)
304-
catch
305-
kind, error ->
306-
print_error(kind, error, __STACKTRACE__)
307-
{:error, "", state}
308-
end
309-
end
310-
311-
defp eval_and_inspect_parsed({:ok, forms, parser_state}, counter, state) do
293+
defp safe_eval_and_inspect(forms, counter, state) do
312294
put_history(state)
313295
put_whereami(state)
314-
state = eval_and_inspect(forms, counter, state)
315-
{:ok, parser_state, state}
296+
{:ok, eval_and_inspect(forms, counter, state)}
297+
catch
298+
kind, error ->
299+
print_error(kind, error, __STACKTRACE__)
300+
{:error, state}
316301
after
317302
Process.delete(:iex_history)
318303
Process.delete(:iex_whereami)
319304
end
320305

321-
defp eval_and_inspect_parsed({:incomplete, parser_state}, _counter, state) do
322-
{:incomplete, parser_state, state}
323-
end
324-
325306
defp put_history(%{history: history}) do
326307
Process.put(:iex_history, history)
327308
end
@@ -410,12 +391,7 @@ defmodule IEx.Evaluator do
410391

411392
_ ->
412393
banner = Exception.format_banner(kind, blamed, stacktrace)
413-
414-
if String.contains?(banner, IO.ANSI.reset()) do
415-
[banner]
416-
else
417-
[IEx.color(:eval_error, banner)]
418-
end
394+
[IEx.color(:eval_error, banner)]
419395
end
420396

421397
stackdata = Exception.format_stacktrace(prune_stacktrace(stacktrace))

0 commit comments

Comments
 (0)