Skip to content

Commit d85d13f

Browse files
committed
Code.cursor_context/2 (#10915)
1 parent 0b4eb72 commit d85d13f

File tree

4 files changed

+531
-227
lines changed

4 files changed

+531
-227
lines changed

lib/elixir/lib/code.ex

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,259 @@ defmodule Code do
206206
required_files()
207207
end
208208

209+
@doc """
210+
Receives a string and returns the cursor context.
211+
212+
This function receives a string with incomplete Elixir code,
213+
representing a cursor position, and based on the string, it
214+
provides contextual information about said position. The
215+
return of this function can then be used to provide tips,
216+
suggestions, and autocompletion functionality.
217+
218+
This function provides a best-effort detection and may not be
219+
accurate under certain circumstances. See the "Limitations"
220+
section below.
221+
222+
Consider adding a catch-all clause when handling the return
223+
type of this function as new cursor information may be added
224+
in future releases.
225+
226+
## Examples
227+
228+
iex> Code.cursor_context("")
229+
:expr
230+
231+
iex> Code.cursor_context("hello_wor")
232+
{:local_or_var, 'hello_wor'}
233+
234+
## Return values
235+
236+
* `{:alias, charlist}` - the context is an alias, potentially
237+
a nested one, such as `Hello.Wor` or `HelloWor`
238+
239+
* `{:dot, inside_dot, charlist}` - the context is a dot
240+
where `inside_dot` is either a `{:var, charlist}`, `{:alias, charlist}`,
241+
`{:module_attribute, charlist}`, `{:unquoted_atom, charlist}` or a `dot
242+
itself. If a var is given, this may either be a remote call or a map
243+
field access. Examples are `Hello.wor`, `:hello.wor`, `hello.wor`,
244+
`Hello.nested.wor`, `hello.nested.wor`, and `@hello.world`
245+
246+
* `{:dot_arity, inside_dot, charlist}` - the context is a dot arity
247+
where `inside_dot` is either a `{:var, charlist}`, `{:alias, charlist}`,
248+
`{:module_attribute, charlist}`, `{:unquoted_atom, charlist}` or a `dot`
249+
itself. If a var is given, it must be a remote arity. Examples are
250+
`Hello.world/`, `:hello.world/`, `hello.world/2`, and `@hello.world/2
251+
252+
* `{:dot_call, inside_dot, charlist}` - the context is a dot
253+
call. This means parentheses or space have been added after the expression.
254+
where `inside_dot` is either a `{:var, charlist}`, `{:alias, charlist}`,
255+
`{:module_attribute, charlist}`, `{:unquoted_atom, charlist}` or a `dot`
256+
itself. If a var is given, it must be a remote call. Examples are
257+
`Hello.world(`, `:hello.world(`, `Hello.world `, `hello.world(`, `hello.world `,
258+
and `@hello.world(`
259+
260+
* `:expr` - may be any expression. Autocompletion may suggest an alias,
261+
local or var
262+
263+
* `{:local_or_var, charlist}` - the context is a variable or a local
264+
(import or local) call, such as `hello_wor`
265+
266+
* `{:local_arity, charlist}` - the context is a local (import or local)
267+
call, such as `hello_world/`
268+
269+
* `{:local_call, charlist}` - the context is a local (import or local)
270+
call, such as `hello_world(` and `hello_world `
271+
272+
* `{:module_attribute, charlist}` - the context is a module attribute, such
273+
as `@hello_wor`
274+
275+
* `:none` - no context possible
276+
277+
* `:unquoted_atom` - the context is an unquoted atom. This can be either
278+
previous atoms or all available `:erlang` modules
279+
280+
## Limitations
281+
282+
* There is no context for operators
283+
* The current algorithm only considers the last line of the input
284+
* Context does not yet track strings, sigils, etc.
285+
* Arguments of functions calls are not currently recognized
286+
287+
"""
288+
@doc since: "1.12.0"
289+
@spec cursor_context(List.Chars.t(), keyword()) ::
290+
{:alias, charlist}
291+
| {:dot, inside_dot, charlist}
292+
| {:dot_arity, inside_dot, charlist}
293+
| {:dot_call, inside_dot, charlist}
294+
| :expr
295+
| {:local_or_var, charlist}
296+
| {:local_arity, charlist}
297+
| {:local_call, charlist}
298+
| {:module_attribute, charlist}
299+
| :none
300+
| {:unquoted_atom, charlist}
301+
when inside_dot:
302+
{:alias, charlist}
303+
| {:dot, inside_dot, charlist}
304+
| {:module_attribute, charlist}
305+
| {:unquoted_atom, charlist}
306+
| {:var, charlist}
307+
def cursor_context(string, opts \\ [])
308+
309+
def cursor_context(binary, opts) when is_binary(binary) and is_list(opts) do
310+
binary =
311+
case :binary.matches(binary, "\n") do
312+
[] ->
313+
binary
314+
315+
matches ->
316+
{position, _} = List.last(matches)
317+
binary_part(binary, position + 1, byte_size(binary) - position - 1)
318+
end
319+
320+
do_cursor_context(String.to_charlist(binary), opts)
321+
end
322+
323+
def cursor_context(charlist, opts) when is_list(charlist) and is_list(opts) do
324+
chunked = Enum.chunk_by(charlist, &(&1 == ?\n))
325+
326+
case List.last(chunked, []) do
327+
[?\n | _] -> do_cursor_context([], opts)
328+
rest -> do_cursor_context(rest, opts)
329+
end
330+
end
331+
332+
def cursor_context(other, opts) do
333+
cursor_context(to_charlist(other), opts)
334+
end
335+
336+
@operators '\\<>+-*/:=|&~^@%'
337+
@non_closing_punctuation '.,([{;'
338+
@closing_punctuation ')]}'
339+
@space '\t\s'
340+
@closing_identifier '?!'
341+
342+
@operators_and_non_closing_puctuation @operators ++ @non_closing_punctuation
343+
@non_identifier @closing_identifier ++
344+
@operators ++ @non_closing_punctuation ++ @closing_punctuation ++ @space
345+
346+
defp do_cursor_context(list, _opts) do
347+
reverse = Enum.reverse(list)
348+
349+
case strip_spaces(reverse, 0) do
350+
# It is empty
351+
{[], _} ->
352+
:expr
353+
354+
{[?: | _], 0} ->
355+
{:unquoted_atom, ''}
356+
357+
{[?@ | _], 0} ->
358+
{:module_attribute, ''}
359+
360+
{[?. | rest], _} ->
361+
dot(rest, '')
362+
363+
# It is a local or remote call with parens
364+
{[?( | rest], _} ->
365+
call_to_cursor_context(rest)
366+
367+
# A local arity definition
368+
{[?/ | rest], _} ->
369+
case identifier_to_cursor_context(rest) do
370+
{:local_or_var, acc} -> {:local_arity, acc}
371+
{:dot, base, acc} -> {:dot_arity, base, acc}
372+
_ -> :none
373+
end
374+
375+
# Starting a new expression
376+
{[h | _], _} when h in @operators_and_non_closing_puctuation ->
377+
:expr
378+
379+
# It is a local or remote call without parens
380+
{rest, spaces} when spaces > 0 ->
381+
call_to_cursor_context(rest)
382+
383+
# It is an identifier
384+
_ ->
385+
identifier_to_cursor_context(reverse)
386+
end
387+
end
388+
389+
defp strip_spaces([h | rest], count) when h in @space, do: strip_spaces(rest, count + 1)
390+
defp strip_spaces(rest, count), do: {rest, count}
391+
392+
defp call_to_cursor_context(reverse) do
393+
case identifier_to_cursor_context(reverse) do
394+
{:local_or_var, acc} -> {:local_call, acc}
395+
{:dot, base, acc} -> {:dot_call, base, acc}
396+
_ -> :none
397+
end
398+
end
399+
400+
defp identifier_to_cursor_context(reverse) do
401+
case identifier(reverse) do
402+
# Parse :: first to avoid ambiguity with atoms
403+
{:alias, false, '::' ++ _, _} -> :none
404+
{kind, _, '::' ++ _, acc} -> alias_or_local_or_var(kind, acc)
405+
# Now handle atoms, any other atom is unexpected
406+
{_kind, _, ':' ++ _, acc} -> {:unquoted_atom, acc}
407+
{:atom, _, _, _} -> :none
408+
# Parse .. first to avoid ambiguity with dots
409+
{:alias, false, _, _} -> :none
410+
{kind, _, '..' ++ _, acc} -> alias_or_local_or_var(kind, acc)
411+
# Module attributes
412+
{:alias, _, '@' ++ _, _} -> :none
413+
{:identifier, _, '@' ++ _, acc} -> {:module_attribute, acc}
414+
# Everything else
415+
{:alias, _, '.' ++ rest, acc} -> nested_alias(rest, acc)
416+
{:identifier, _, '.' ++ rest, acc} -> dot(rest, acc)
417+
{kind, _, _, acc} -> alias_or_local_or_var(kind, acc)
418+
:none -> :none
419+
end
420+
end
421+
422+
defp nested_alias(rest, acc) do
423+
case identifier_to_cursor_context(rest) do
424+
{:alias, prev} -> {:alias, prev ++ '.' ++ acc}
425+
_ -> :none
426+
end
427+
end
428+
429+
defp dot(rest, acc) do
430+
case identifier_to_cursor_context(rest) do
431+
{:local_or_var, prev} -> {:dot, {:var, prev}, acc}
432+
{:unquoted_atom, _} = prev -> {:dot, prev, acc}
433+
{:alias, _} = prev -> {:dot, prev, acc}
434+
{:dot, _, _} = prev -> {:dot, prev, acc}
435+
{:module_attribute, _} = prev -> {:dot, prev, acc}
436+
_ -> :none
437+
end
438+
end
439+
440+
defp alias_or_local_or_var(:alias, acc), do: {:alias, acc}
441+
defp alias_or_local_or_var(:identifier, acc), do: {:local_or_var, acc}
442+
defp alias_or_local_or_var(_, _), do: :none
443+
444+
defp identifier([?? | rest]), do: check_identifier(rest, [??])
445+
defp identifier([?! | rest]), do: check_identifier(rest, [?!])
446+
defp identifier(rest), do: check_identifier(rest, [])
447+
448+
defp check_identifier([h | _], _acc) when h in @non_identifier, do: :none
449+
defp check_identifier(rest, acc), do: rest_identifier(rest, acc)
450+
451+
defp rest_identifier([h | rest], acc) when h not in @non_identifier do
452+
rest_identifier(rest, [h | acc])
453+
end
454+
455+
defp rest_identifier(rest, acc) do
456+
case String.Tokenizer.tokenize(acc) do
457+
{kind, _, [], _, ascii_only?, _} -> {kind, ascii_only?, rest, acc}
458+
_ -> :none
459+
end
460+
end
461+
209462
@doc """
210463
Removes files from the required files list.
211464

0 commit comments

Comments
 (0)