Skip to content

Add block_keyword_or_binary_operator to Code.Fragment #14414

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 2 commits into from
Apr 8, 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
59 changes: 49 additions & 10 deletions lib/elixir/lib/code/fragment.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ defmodule Code.Fragment do
or `{:local_or_var, charlist}` and `charlist` is a static part
Examples are `__MODULE__.Submodule` or `@hello.Submodule`

* `{:block_keyword_or_binary_operator, charlist}` - may be a block keyword (do, end, after,
catch, else, rescue) or a binary operator

* `{: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`
Expand Down Expand Up @@ -139,6 +142,7 @@ defmodule Code.Fragment do
@spec cursor_context(List.Chars.t(), keyword()) ::
{:alias, charlist}
| {:alias, inside_alias, charlist}
| {:block_keyword_or_binary_operator, charlist}
| {:dot, inside_dot, charlist}
| {:dot_arity, inside_dot, charlist}
| {:dot_call, inside_dot, charlist}
Expand Down Expand Up @@ -188,15 +192,15 @@ defmodule Code.Fragment do
cursor_context(to_charlist(other), opts)
end

@operators ~c"\\<>+-*/:=|&~^%!"
@starter_punctuation ~c",([{;"
@non_starter_punctuation ~c")]}\"'.$"
@operators ~c"\\<>+-*/:=|&~^%!$"
@starting_punctuation ~c",([{;"
@closing_punctuation ~c")]}\"'"
@space ~c"\t\s"
@trailing_identifier ~c"?!"
@tilde_op_prefix ~c"<=~"

@non_identifier @trailing_identifier ++
@operators ++ @starter_punctuation ++ @non_starter_punctuation ++ @space
@operators ++ @starting_punctuation ++ @closing_punctuation ++ @space ++ [?.]

@textual_operators ~w(when not and or in)c
@keywords ~w(do end after else catch rescue fn true false nil)c
Expand Down Expand Up @@ -226,11 +230,11 @@ defmodule Code.Fragment do
# 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})
[h | _] when h in @starting_punctuation -> {:expr, 0}
# It is keyword, binary operator, a local or remote call without parens
rest when spaces > 0 -> closing_or_call_to_cursor_context({rest, spaces})
# It is an identifier
_ -> identifier_to_cursor_context(reverse, 0, false)
_ -> identifier_to_cursor_context(reverse, spaces, false)
end
end

Expand Down Expand Up @@ -269,6 +273,14 @@ defmodule Code.Fragment do
end
end

defp closing_or_call_to_cursor_context({reverse, spaces}) do
if closing?(reverse) do
{{:block_keyword_or_binary_operator, ~c""}, 0}
else
call_to_cursor_context({reverse, spaces})
end
end

defp identifier_to_cursor_context([?., ?., ?: | _], n, _), do: {{:unquoted_atom, ~c".."}, n + 3}
defp identifier_to_cursor_context([?., ?., ?. | _], n, _), do: {{:local_or_var, ~c"..."}, n + 3}
defp identifier_to_cursor_context([?., ?: | _], n, _), do: {{:unquoted_atom, ~c"."}, n + 2}
Expand Down Expand Up @@ -320,15 +332,42 @@ defmodule Code.Fragment do
{~c"." ++ rest, count} when rest == [] or hd(rest) != ?. ->
dot(rest, count + 1, acc)

_ ->
{{:local_or_var, acc}, count}
{rest, rest_count} ->
response =
if rest_count > count and closing?(rest),
do: :block_keyword_or_binary_operator,
else: :local_or_var

{{response, acc}, count}
end

{:capture_arg, acc, count} ->
{{:capture_arg, acc}, count}
end
end

# If it is a closing punctuation
defp closing?([h | _]) when h in @closing_punctuation, do: true
# Closing bitstring (but deal with operators)
defp closing?([?>, ?> | rest]), do: rest == [] or hd(rest) not in [?>, ?~]
# Keywords
defp closing?(rest) do
case split_non_identifier(rest, []) do
{~c"nil", _} -> true
{~c"true", _} -> true
{~c"false", _} -> true
{[digit | _], _} when digit in ?0..?9 -> true
{[upper | _], _} when upper in ?A..?Z -> true
{[_ | _], [?: | rest]} -> rest == [] or hd(rest) != ?:
{_, _} -> false
end
end

defp split_non_identifier([h | t], acc) when h not in @non_identifier,
do: split_non_identifier(t, [h | acc])

defp split_non_identifier(rest, acc), do: {acc, rest}

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, [])
Expand Down
75 changes: 73 additions & 2 deletions lib/elixir/test/elixir/code_fragment_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ defmodule CodeFragmentTest do
assert CF.cursor_context("Hello::Wor") == {:alias, ~c"Wor"}
assert CF.cursor_context("Hello..Wor") == {:alias, ~c"Wor"}

assert CF.cursor_context("hello.World") ==
{:alias, {:local_or_var, ~c"hello"}, ~c"World"}

assert CF.cursor_context("__MODULE__.Wor") ==
{:alias, {:local_or_var, ~c"__MODULE__"}, ~c"Wor"}

Expand Down Expand Up @@ -368,6 +371,76 @@ defmodule CodeFragmentTest do
assert CF.cursor_context("@hello_wo") == {:module_attribute, ~c"hello_wo"}
end

test "keyword or binary operator" do
# Literals
assert CF.cursor_context("Foo.Bar ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("Foo ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context(":foo ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("123 ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("nil ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("true ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("false ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("\"foo\" ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("'foo' ") == {:block_keyword_or_binary_operator, ~c""}

# Containers
assert CF.cursor_context("(foo) ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("[foo] ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("{foo} ") == {:block_keyword_or_binary_operator, ~c""}
assert CF.cursor_context("<<foo>> ") == {:block_keyword_or_binary_operator, ~c""}

# False positives
assert CF.cursor_context("foo ~>> ") == {:operator_call, ~c"~>>"}
assert CF.cursor_context("foo >>> ") == {:operator_call, ~c">>>"}
end

test "keyword from keyword or binary operator" do
# Literals
assert CF.cursor_context("Foo.Bar do") == {:block_keyword_or_binary_operator, ~c"do"}
assert CF.cursor_context("Foo.Bar d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("Foo d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context(":foo d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("123 d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("nil d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("true d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("false d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("\"foo\" d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("'foo' d") == {:block_keyword_or_binary_operator, ~c"d"}

# Containers
assert CF.cursor_context("(foo) d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("[foo] d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("{foo} d") == {:block_keyword_or_binary_operator, ~c"d"}
assert CF.cursor_context("<<foo>> d") == {:block_keyword_or_binary_operator, ~c"d"}

# False positives
assert CF.cursor_context("foo ~>> d") == {:local_or_var, ~c"d"}
assert CF.cursor_context("foo >>> d") == {:local_or_var, ~c"d"}
end

test "operator from keyword or binary operator" do
# Literals
assert CF.cursor_context("Foo.Bar +") == {:operator, ~c"+"}
assert CF.cursor_context("Foo +") == {:operator, ~c"+"}
assert CF.cursor_context(":foo +") == {:operator, ~c"+"}
assert CF.cursor_context("123 +") == {:operator, ~c"+"}
assert CF.cursor_context("nil +") == {:operator, ~c"+"}
assert CF.cursor_context("true +") == {:operator, ~c"+"}
assert CF.cursor_context("false +") == {:operator, ~c"+"}
assert CF.cursor_context("\"foo\" +") == {:operator, ~c"+"}
assert CF.cursor_context("'foo' +") == {:operator, ~c"+"}

# Containers
assert CF.cursor_context("(foo) +") == {:operator, ~c"+"}
assert CF.cursor_context("[foo] +") == {:operator, ~c"+"}
assert CF.cursor_context("{foo} +") == {:operator, ~c"+"}
assert CF.cursor_context("<<foo>> +") == {:operator, ~c"+"}

# False positives
assert CF.cursor_context("foo ~>> +") == {:operator, ~c"+"}
assert CF.cursor_context("foo >>> +") == {:operator, ~c"+"}
end

test "none" do
# Punctuation
assert CF.cursor_context(")") == :none
Expand Down Expand Up @@ -399,8 +472,6 @@ defmodule CodeFragmentTest do
assert CF.cursor_context("HelloWór") == :none
assert CF.cursor_context("@Hello") == :none
assert CF.cursor_context("Hello(") == :none
assert CF.cursor_context("Hello ") == :none
assert CF.cursor_context("hello.World") == {:alias, {:local_or_var, ~c"hello"}, ~c"World"}

# Identifier
assert CF.cursor_context("foo@bar") == :none
Expand Down
26 changes: 26 additions & 0 deletions lib/iex/lib/iex/autocomplete.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ defmodule IEx.Autocomplete do
%{kind: :variable, name: "utf32"}
]

block_keywords =
for block_keyword <- ~w(do end after catch else rescue) do
%{kind: :block_keyword, name: block_keyword}
end

binary_operators =
for operator <-
["**", "*", "/", "+", "-", "++", "--", "+++", "---", "..", "<>"] ++
["in", "not in", "|>", "<<<", ">>>", "<<~", "~>>", "<~", "~>", "<~>"] ++
["<", ">", "<=", ">=", "==", "!=", "=~", "===", "!=="] ++
["&&", "&&&", "and", "||", "|||", "or"] ++
["=", "=>", "|", "::", "when", "<-", "\\\\"] do
%{kind: :export, name: operator, arity: 2}
end

@block_keyword_or_binary_operator block_keywords ++ Enum.sort(binary_operators)

@alias_only_atoms ~w(alias import require)a
@alias_only_charlists ~w(alias import require)c

Expand Down Expand Up @@ -63,6 +80,9 @@ defmodule IEx.Autocomplete do
{:unquoted_atom, unquoted_atom} ->
expand_erlang_modules(List.to_string(unquoted_atom))

{:block_keyword_or_binary_operator, hint} ->
filter_and_format_expansion(@block_keyword_or_binary_operator, List.to_string(hint))

expansion when helper == ?b ->
expand_typespecs(expansion, shell, &get_module_callbacks/1)

Expand Down Expand Up @@ -519,6 +539,12 @@ defmodule IEx.Autocomplete do

## Formatting

defp filter_and_format_expansion(results, hint) do
results
|> Enum.filter(&String.starts_with?(&1.name, hint))
|> format_expansion(hint)
end

defp format_expansion([], _) do
no()
end
Expand Down
15 changes: 15 additions & 0 deletions lib/iex/test/iex/autocomplete_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,21 @@ defmodule IEx.AutocompleteTest do
assert expand(~c"IEx.Xyz") == {:no, ~c"", []}
end

test "block keywords" do
assert expand(~c"if true do") == {:yes, ~c"", [~c"do"]}
assert expand(~c"if true a") == {:yes, ~c"", [~c"after", ~c"and/2"]}
assert expand(~c"if true d") == {:yes, ~c"o", []}
assert expand(~c"if true e") == {:yes, ~c"", [~c"end", ~c"else"]}
end

test "block keywords or operators" do
{:yes, ~c"", hints} = expand(~c"if true ")
assert ~c"do" in hints
assert ~c"else" in hints
assert ~c"+/2" in hints
assert ~c"and/2" in hints
end

test "function completion" do
assert expand(~c"System.ve") == {:yes, ~c"rsion", []}
assert expand(~c":ets.fun2") == {:yes, ~c"ms", []}
Expand Down
Loading